Merge branch '17944-backend-vocabulary-validation-rebased' into main.
authorLucas Di Pentima <lucas.dipentima@curii.com>
Thu, 11 Nov 2021 17:51:31 +0000 (14:51 -0300)
committerLucas Di Pentima <lucas.dipentima@curii.com>
Thu, 11 Nov 2021 17:51:31 +0000 (14:51 -0300)
Refs #17944

Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima@curii.com>

35 files changed:
.gitignore
doc/_config.yml
doc/_includes/_metadata_vocabulary_example.liquid [moved from doc/_includes/_wb2_vocabulary_example.liquid with 90% similarity]
doc/admin/metadata-vocabulary.html.textile.liquid [moved from doc/admin/workbench2-vocabulary.html.textile.liquid with 75% similarity]
doc/admin/upgrading.html.textile.liquid
doc/install/install-workbench2-app.html.textile.liquid
lib/config/config.default.yml
lib/config/export.go
lib/config/generated_config.go
lib/controller/federation.go
lib/controller/federation/conn.go
lib/controller/federation/federation_test.go
lib/controller/federation/generate.go
lib/controller/federation/generated.go
lib/controller/federation/login_test.go
lib/controller/federation/user_test.go
lib/controller/handler.go
lib/controller/handler_test.go
lib/controller/localdb/collection.go
lib/controller/localdb/collection_test.go
lib/controller/localdb/conn.go
lib/controller/localdb/container_request.go [new file with mode: 0644]
lib/controller/localdb/container_request_test.go [new file with mode: 0644]
lib/controller/localdb/group.go [new file with mode: 0644]
lib/controller/localdb/group_test.go [new file with mode: 0644]
lib/controller/localdb/link.go [new file with mode: 0644]
lib/controller/localdb/link_test.go [new file with mode: 0644]
lib/controller/router/router.go
lib/controller/rpc/conn.go
sdk/go/arvados/api.go
sdk/go/arvados/config.go
sdk/go/arvados/link.go
sdk/go/arvados/vocabulary.go [new file with mode: 0644]
sdk/go/arvados/vocabulary_test.go [new file with mode: 0644]
sdk/go/arvadostest/api.go

index beb84b3c2034f23e7c3072ac510f4a43722a0c75..231424accd37d1549e1edf3d066aa93a135dfa31 100644 (file)
@@ -32,5 +32,6 @@ services/api/config/arvados-clients.yml
 .Rproj.user
 _version.py
 *.bak
+*.log
 arvados-snakeoil-ca.pem
 .vagrant
index 31db9c41d54eb82fbb57c3fbc798f4cffeaa5e69..dde87323d778e47f7e6297da784a1995d9f9726d 100644 (file)
@@ -194,7 +194,7 @@ navbar:
       - admin/keep-balance.html.textile.liquid
       - admin/controlling-container-reuse.html.textile.liquid
       - admin/logs-table-management.html.textile.liquid
-      - admin/workbench2-vocabulary.html.textile.liquid
+      - admin/metadata-vocabulary.html.textile.liquid
       - admin/storage-classes.html.textile.liquid
       - admin/keep-recovering-data.html.textile.liquid
       - admin/keep-measuring-deduplication.html.textile.liquid
similarity index 90%
rename from doc/_includes/_wb2_vocabulary_example.liquid
rename to doc/_includes/_metadata_vocabulary_example.liquid
index ee2ac97ef3cf5a7d4af57b39f53f0a88dfedcffe..fb8e57725bb17d6795d83941863333748098365d 100644 (file)
@@ -1,4 +1,8 @@
-{
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}{
     "strict_tags": false,
     "tags": {
         "IDTAGANIMALS": {
similarity index 75%
rename from doc/admin/workbench2-vocabulary.html.textile.liquid
rename to doc/admin/metadata-vocabulary.html.textile.liquid
index 9a8d7fcd015b795144ca21e277be6379fa34a84f..170699ab6c36d3207ad08fe36d6a6631dce9d1f4 100644 (file)
@@ -1,7 +1,7 @@
 ---
 layout: default
 navsection: admin
-title: User properties vocabulary
+title: Metadata vocabulary
 ...
 
 {% comment %}
@@ -12,17 +12,19 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 
 Many Arvados objects (like collections and projects) can store metadata as properties that in turn can be used in searches allowing a flexible way of organizing data inside the system.
 
-The Workbench2 user interface enables the site adminitrator to set up a properties vocabulary formal definition so that users can select from predefined key/value pairs of properties, offering the possibility to add different terms for the same concept.
+Arvados enables the site administrator to set up a formal metadata vocabulary definition so that users can select from predefined key/value pairs of properties, offering the possibility to add different terms for the same concept on clients' UI such as workbench2.
 
-h2. Workbench2 configuration
+The Controller service loads and caches the configured vocabulary file in memory at startup time, exporting it on a particular endpoint. From time to time, it'll check for updates in the local copy and refresh its cache if validation passes.
 
-Workbench2 retrieves the vocabulary file URL from the cluster config as shown:
+h2. Configuration
+
+The site administrator should place the JSON vocabulary file on the same host as the controller service and set up the config file as follows:
 
 <notextile>
 <pre><code>Cluster:
   zzzzz:
-    Workbench:
-      VocabularyURL: <span class="userinput">https://site.example.com/vocabulary.json</span>
+    API:
+      VocabularyPath: <span class="userinput">/etc/arvados/vocabulary.json</span>
 </code></pre>
 </notextile>
 
@@ -35,10 +37,12 @@ Keys and values are indexed by identifiers so that the concept of a term is pres
 The following is an example of a vocabulary definition:
 
 {% codeblock as json %}
-{% include 'wb2_vocabulary_example' %}
+{% include 'metadata_vocabulary_example' %}
 {% endcodeblock %}
 
-If the @strict_tags@ flag at the root level is @true@, it will restrict the users from saving property keys other than the ones defined in the vocabulary. Take notice that this restriction is at the client level on Workbench2, it doesn't limit the user's ability to set any arbitrary property via other means (e.g. Python SDK or CLI commands)
+For clients to be able to query the vocabulary definition, a special endpoint is exposed on the @controller@ service: @/arvados/v1/vocabulary@. This endpoint doesn't require authentication and returns the vocabulary definition in JSON format.
+
+If the @strict_tags@ flag at the root level is @true@, it will restrict the users from saving property keys other than the ones defined in the vocabulary. This restriction is enforced at the backend level to ensure consistency across different clients.
 
 Inside the @tags@ member, IDs are defined (@IDTAGANIMALS@, @IDTAGCOMMENT@, @IDTAGIMPORTANCES@) and can have any format that the current application requires. Every key will declare at least a @labels@ list with zero or more label objects.
 
index 16cbf2e60c0e24fba94e8556bb59175830003115..15b8d2c40d4afcabe1efcc30afa6a2c8d939fafe 100644 (file)
@@ -35,7 +35,7 @@ TODO: extract this information based on git commit messages and generate changel
 <div class="releasenotes">
 </notextile>
 
-h2(#main). development main (as of 2021-10-27)
+h2(#main). development main (as of 2021-11-10)
 
 "previous: Upgrading from 2.3.0":#v2_3_0
 
@@ -46,6 +46,12 @@ When Arvados runs a container via @arvados-dispatch-cloud@, the @crunch-run@ sup
 * If you already have a robust permanent keepstore infrastructure, you can set @Containers.LocalKeepBlobBuffersPerVCPU@ to 0 to disable this feature and preserve the previous behavior of sending container I/O traffic to your separately provisioned keepstore servers.
 * This feature is enabled only if no volumes use @AccessViaHosts@, and no volumes have underlying @Replication@ less than @Collections.DefaultReplication@. If the feature is configured but cannot be enabled due to an incompatible volume configuration, this will be noted in the @crunch-run.txt@ file in the container log.
 
+h3. Backend support for vocabulary checking
+
+If your installation uses the vocabulary feature on Workbench2, you will need to update the cluster configuration by moving the vocabulary definition file to the node where @controller@ runs, and set the @API.VocabularyPath@ configuration parameter to the local path where the file was placed.
+This will enable the vocabulary checking cluster-wide, including Workbench2. The @Workbench.VocabularyURL@ configuration parameter is deprecated and will be removed in a future release.
+You can read more about how this feature works on the "admin page":{{site.baseurl}}/admin/metadata-vocabulary.html.
+
 h2(#v2_3_0). v2.3.0 (2021-10-27)
 
 "previous: Upgrading to 2.2.0":#v2_2_0
@@ -292,7 +298,7 @@ Workbench 2 is now ready for regular use.  Follow the instructions to "install w
 
 h3. New property vocabulary format for Workbench2
 
-(feature "#14151":https://dev.arvados.org/issues/14151) Workbench2 supports a new vocabulary format and it isn't compatible with the previous one, please read the "workbench2 vocabulary format admin page":{{site.baseurl}}/admin/workbench2-vocabulary.html for more information.
+(feature "#14151":https://dev.arvados.org/issues/14151) Workbench2 supports a new vocabulary format and it isn't compatible with the previous one, please read the "metadata vocabulary format admin page":{{site.baseurl}}/admin/metadata-vocabulary.html for more information.
 
 h3. Cloud installations only: node manager replaced by arvados-dispatch-cloud
 
index f3a320b10251745f64a8d7eece1e36fb73628e6a..c9a1c7012659fd1a3b629431441dcda69f018a68 100644 (file)
@@ -75,7 +75,7 @@ server {
 
 h2. Vocabulary configuration (optional)
 
-Workbench2 can load a vocabulary file which lists available metadata properties for groups and collections.  To configure the property vocabulary definition, please visit the "Workbench2 Vocabulary Format":{{site.baseurl}}/admin/workbench2-vocabulary.html page in the Admin section.
+Workbench2 can load a vocabulary file which lists available metadata properties for groups and collections.  To configure the property vocabulary definition, please visit the "Metadata Vocabulary Format":{{site.baseurl}}/admin/metadata-vocabulary.html page in the Admin section.
 
 {% assign arvados_component = 'arvados-workbench2' %}
 
index 97ded6bf6863739e23ed53d365be8db1014a5b35..378690ad5aef257aa627c6a70020c415f8e2ba8e 100644 (file)
@@ -234,6 +234,12 @@ Clusters:
       # Timeout on requests to internal Keep services.
       KeepServiceRequestTimeout: 15s
 
+      # Vocabulary file path, local to the node running the controller.
+      # This JSON file should contain the description of what's allowed
+      # as object's metadata. Its format is described at:
+      # https://doc.arvados.org/admin/metadata-vocabulary.html
+      VocabularyPath: ""
+
     Users:
       # Config parameters to automatically setup new users.  If enabled,
       # this users will be able to self-activate.  Enable this if you want
@@ -1566,7 +1572,6 @@ Clusters:
       DefaultOpenIdPrefix: "https://www.google.com/accounts/o8/id"
 
       # Workbench2 configs
-      VocabularyURL: ""
       FileViewersConfigURL: ""
 
       # Idle time after which the user's session will be auto closed.
index e36d6e76cae40d17c54217e643bf82341e298b87..fe8d45509ec4c6ae0a049239c33e36de362f7f92 100644 (file)
@@ -72,6 +72,7 @@ var whitelist = map[string]bool{
        "API.MaxTokenLifetime":                                false,
        "API.RequestTimeout":                                  true,
        "API.SendTimeout":                                     true,
+       "API.VocabularyPath":                                  false,
        "API.WebsocketClientEventQueue":                       false,
        "API.WebsocketServerEventQueue":                       false,
        "AuditLogs":                                           false,
@@ -276,7 +277,6 @@ var whitelist = map[string]bool{
        "Workbench.UserProfileFormFields.*.*":                 true,
        "Workbench.UserProfileFormFields.*.*.*":               true,
        "Workbench.UserProfileFormMessage":                    true,
-       "Workbench.VocabularyURL":                             true,
        "Workbench.WelcomePageHTML":                           true,
 }
 
index f7849d6142cda6e4353a076a90a561295f7fa034..a1bb2330dfdd9b5b77dc60a63ea0793317652823 100644 (file)
@@ -240,6 +240,12 @@ Clusters:
       # Timeout on requests to internal Keep services.
       KeepServiceRequestTimeout: 15s
 
+      # Vocabulary file path, local to the node running the controller.
+      # This JSON file should contain the description of what's allowed
+      # as object's metadata. Its format is described at:
+      # https://doc.arvados.org/admin/metadata-vocabulary.html
+      VocabularyPath: ""
+
     Users:
       # Config parameters to automatically setup new users.  If enabled,
       # this users will be able to self-activate.  Enable this if you want
@@ -1572,7 +1578,6 @@ Clusters:
       DefaultOpenIdPrefix: "https://www.google.com/accounts/o8/id"
 
       # Workbench2 configs
-      VocabularyURL: ""
       FileViewersConfigURL: ""
 
       # Idle time after which the user's session will be auto closed.
index 144d41c21beb62213195d537d32bca8fa9650f99..cd69727ecb5d2fac27f2777905ad4ba0b5bd4ef7 100644 (file)
@@ -121,8 +121,6 @@ func (h *Handler) setupProxyRemoteCluster(next http.Handler) http.Handler {
 
                mux.ServeHTTP(w, req)
        })
-
-       return mux
 }
 
 type CurrentUser struct {
index 39e4f2676695bed5fd478a2b1470de1e1d63b6f8..d1bf473d76856abd59bfb35f069e4f47f498e680 100644 (file)
@@ -22,6 +22,7 @@ import (
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/auth"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/sdk/go/health"
 )
 
 type Conn struct {
@@ -30,7 +31,7 @@ type Conn struct {
        remotes map[string]backend
 }
 
-func New(cluster *arvados.Cluster) *Conn {
+func New(cluster *arvados.Cluster, healthFuncs *map[string]health.Func) *Conn {
        local := localdb.NewConn(cluster)
        remotes := map[string]backend{}
        for id, remote := range cluster.RemoteClusters {
@@ -44,6 +45,11 @@ func New(cluster *arvados.Cluster) *Conn {
                remotes[id] = conn
        }
 
+       if healthFuncs != nil {
+               hf := map[string]health.Func{"vocabulary": local.LastVocabularyError}
+               *healthFuncs = hf
+       }
+
        return &Conn{
                cluster: cluster,
                local:   local,
@@ -202,6 +208,10 @@ func (conn *Conn) ConfigGet(ctx context.Context) (json.RawMessage, error) {
        return json.RawMessage(buf.Bytes()), err
 }
 
+func (conn *Conn) VocabularyGet(ctx context.Context) (arvados.Vocabulary, error) {
+       return conn.chooseBackend(conn.cluster.ClusterID).VocabularyGet(ctx)
+}
+
 func (conn *Conn) Login(ctx context.Context, options arvados.LoginOptions) (arvados.LoginResponse, error) {
        if id := conn.cluster.Login.LoginCluster; id != "" && id != conn.cluster.ClusterID {
                // defer entire login procedure to designated cluster
@@ -475,6 +485,26 @@ func (conn *Conn) GroupUntrash(ctx context.Context, options arvados.UntrashOptio
        return conn.chooseBackend(options.UUID).GroupUntrash(ctx, options)
 }
 
+func (conn *Conn) LinkCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Link, error) {
+       return conn.chooseBackend(options.ClusterID).LinkCreate(ctx, options)
+}
+
+func (conn *Conn) LinkUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Link, error) {
+       return conn.chooseBackend(options.UUID).LinkUpdate(ctx, options)
+}
+
+func (conn *Conn) LinkGet(ctx context.Context, options arvados.GetOptions) (arvados.Link, error) {
+       return conn.chooseBackend(options.UUID).LinkGet(ctx, options)
+}
+
+func (conn *Conn) LinkList(ctx context.Context, options arvados.ListOptions) (arvados.LinkList, error) {
+       return conn.generated_LinkList(ctx, options)
+}
+
+func (conn *Conn) LinkDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Link, error) {
+       return conn.chooseBackend(options.UUID).LinkDelete(ctx, options)
+}
+
 func (conn *Conn) SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
        return conn.generated_SpecimenList(ctx, options)
 }
index 984d32dc3d9a294fc20bd60b38295aaf890abb37..5460e938a66348ec2a98f2a478372ea901c4c235 100644 (file)
@@ -70,7 +70,7 @@ func (s *FederationSuite) SetUpTest(c *check.C) {
        ctx = ctrlctx.NewWithTransaction(ctx, s.tx)
        s.ctx = ctx
 
-       s.fed = New(s.cluster)
+       s.fed = New(s.cluster, nil)
 }
 
 func (s *FederationSuite) TearDownTest(c *check.C) {
index 06a5ce12d792ce000d669f513f9b1908ae1011f8..b49e138ce1f635c3b692a873d646da20ca444173 100644 (file)
@@ -53,7 +53,7 @@ func main() {
                defer out.Close()
                out.Write(regexp.MustCompile(`(?ms)^.*package .*?import.*?\n\)\n`).Find(buf))
                io.WriteString(out, "//\n// -- this file is auto-generated -- do not edit -- edit list.go and run \"go generate\" instead --\n//\n\n")
-               for _, t := range []string{"Container", "ContainerRequest", "Group", "Specimen", "User"} {
+               for _, t := range []string{"Container", "ContainerRequest", "Group", "Specimen", "User", "Link"} {
                        _, err := out.Write(bytes.ReplaceAll(orig, []byte("Collection"), []byte(t)))
                        if err != nil {
                                panic(err)
index 49a2e5b7513f537f0f129fac6c8ba81103e72a29..e8a5a08ff00c10d491e5fb29032fe003fb9b137a 100755 (executable)
@@ -221,3 +221,44 @@ func (conn *Conn) generated_UserList(ctx context.Context, options arvados.ListOp
        }
        return merged, err
 }
+
+func (conn *Conn) generated_LinkList(ctx context.Context, options arvados.ListOptions) (arvados.LinkList, error) {
+       var mtx sync.Mutex
+       var merged arvados.LinkList
+       var needSort atomic.Value
+       needSort.Store(false)
+       err := conn.splitListRequest(ctx, options, func(ctx context.Context, _ string, backend arvados.API, options arvados.ListOptions) ([]string, error) {
+               options.ForwardedFor = conn.cluster.ClusterID + "-" + options.ForwardedFor
+               cl, err := backend.LinkList(ctx, options)
+               if err != nil {
+                       return nil, err
+               }
+               mtx.Lock()
+               defer mtx.Unlock()
+               if len(merged.Items) == 0 {
+                       merged = cl
+               } else if len(cl.Items) > 0 {
+                       merged.Items = append(merged.Items, cl.Items...)
+                       needSort.Store(true)
+               }
+               uuids := make([]string, 0, len(cl.Items))
+               for _, item := range cl.Items {
+                       uuids = append(uuids, item.UUID)
+               }
+               return uuids, nil
+       })
+       if needSort.Load().(bool) {
+               // Apply the default/implied order, "modified_at desc"
+               sort.Slice(merged.Items, func(i, j int) bool {
+                       mi, mj := merged.Items[i].ModifiedAt, merged.Items[j].ModifiedAt
+                       return mj.Before(mi)
+               })
+       }
+       if merged.Items == nil {
+               // Return empty results as [], not null
+               // (https://github.com/golang/go/issues/27589 might be
+               // a better solution in the future)
+               merged.Items = []arvados.Link{}
+       }
+       return merged, err
+}
index 5353ebf0f52ce7394b868838eb75e730945b4d8e..c05ebfce69820b3be781a3d18be8a591aaa94eb2 100644 (file)
@@ -47,7 +47,7 @@ func (s *LoginSuite) TestLogout(c *check.C) {
        s.cluster.Login.LoginCluster = "zhome"
        // s.fed is already set by SetUpTest, but we need to
        // reinitialize with the above config changes.
-       s.fed = New(s.cluster)
+       s.fed = New(s.cluster, nil)
 
        returnTo := "https://app.example.com/foo?bar"
        for _, trial := range []struct {
index 2812c1f41d5c6cd7aa3f02da5b6a06f051c6283a..064f8ce5d09e8e931f0769a23970ee676ba4e6ca 100644 (file)
@@ -30,7 +30,7 @@ type UserSuite struct {
 func (s *UserSuite) TestLoginClusterUserList(c *check.C) {
        s.cluster.ClusterID = "local"
        s.cluster.Login.LoginCluster = "zzzzz"
-       s.fed = New(s.cluster)
+       s.fed = New(s.cluster, nil)
        s.addDirectRemote(c, "zzzzz", rpc.NewConn("zzzzz", &url.URL{Scheme: "https", Host: os.Getenv("ARVADOS_API_HOST")}, true, rpc.PassthroughTokenProvider))
 
        for _, updateFail := range []bool{false, true} {
@@ -120,7 +120,7 @@ func (s *UserSuite) TestLoginClusterUserList(c *check.C) {
 func (s *UserSuite) TestLoginClusterUserGet(c *check.C) {
        s.cluster.ClusterID = "local"
        s.cluster.Login.LoginCluster = "zzzzz"
-       s.fed = New(s.cluster)
+       s.fed = New(s.cluster, nil)
        s.addDirectRemote(c, "zzzzz", rpc.NewConn("zzzzz", &url.URL{Scheme: "https", Host: os.Getenv("ARVADOS_API_HOST")}, true, rpc.PassthroughTokenProvider))
 
        opts := arvados.GetOptions{UUID: "zzzzz-tpzed-xurymjxw79nv3jz", Select: []string{"uuid", "email"}}
@@ -174,7 +174,7 @@ func (s *UserSuite) TestLoginClusterUserGet(c *check.C) {
 func (s *UserSuite) TestLoginClusterUserListBypassFederation(c *check.C) {
        s.cluster.ClusterID = "local"
        s.cluster.Login.LoginCluster = "zzzzz"
-       s.fed = New(s.cluster)
+       s.fed = New(s.cluster, nil)
        s.addDirectRemote(c, "zzzzz", rpc.NewConn("zzzzz", &url.URL{Scheme: "https", Host: os.Getenv("ARVADOS_API_HOST")},
                true, rpc.PassthroughTokenProvider))
 
index a35d0030194e8bf9e79d1f2f256ff9fab5621fe7..b51d909110827bf7d8470120a87f5e29db008a15 100644 (file)
@@ -9,6 +9,7 @@ import (
        "errors"
        "fmt"
        "net/http"
+       "net/http/httptest"
        "net/url"
        "strings"
        "sync"
@@ -74,7 +75,21 @@ func (h *Handler) CheckHealth() error {
                return err
        }
        _, _, err = railsproxy.FindRailsAPI(h.Cluster)
-       return err
+       if err != nil {
+               return err
+       }
+       if h.Cluster.API.VocabularyPath != "" {
+               req, err := http.NewRequest("GET", "/arvados/v1/vocabulary", nil)
+               if err != nil {
+                       return err
+               }
+               var resp httptest.ResponseRecorder
+               h.handlerStack.ServeHTTP(&resp, req)
+               if resp.Result().StatusCode != http.StatusOK {
+                       return fmt.Errorf("%d %s", resp.Result().StatusCode, resp.Result().Status)
+               }
+       }
+       return nil
 }
 
 func (h *Handler) Done() <-chan struct{} {
@@ -85,18 +100,25 @@ func neverRedirect(*http.Request, []*http.Request) error { return http.ErrUseLas
 
 func (h *Handler) setup() {
        mux := http.NewServeMux()
-       mux.Handle("/_health/", &health.Handler{
-               Token:  h.Cluster.ManagementToken,
-               Prefix: "/_health/",
-               Routes: health.Routes{"ping": func() error { _, err := h.db(context.TODO()); return err }},
-       })
+       healthFuncs := make(map[string]health.Func)
 
        oidcAuthorizer := localdb.OIDCAccessTokenAuthorizer(h.Cluster, h.db)
-       rtr := router.New(federation.New(h.Cluster), router.Config{
+       rtr := router.New(federation.New(h.Cluster, &healthFuncs), router.Config{
                MaxRequestSize: h.Cluster.API.MaxRequestSize,
                WrapCalls:      api.ComposeWrappers(ctrlctx.WrapCallsInTransactions(h.db), oidcAuthorizer.WrapCalls),
        })
+
+       healthRoutes := health.Routes{"ping": func() error { _, err := h.db(context.TODO()); return err }}
+       for name, f := range healthFuncs {
+               healthRoutes[name] = f
+       }
+       mux.Handle("/_health/", &health.Handler{
+               Token:  h.Cluster.ManagementToken,
+               Prefix: "/_health/",
+               Routes: healthRoutes,
+       })
        mux.Handle("/arvados/v1/config", rtr)
+       mux.Handle("/arvados/v1/vocabulary", rtr)
        mux.Handle("/"+arvados.EndpointUserAuthenticate.Path, rtr) // must come before .../users/
        mux.Handle("/arvados/v1/collections", rtr)
        mux.Handle("/arvados/v1/collections/", rtr)
@@ -107,6 +129,8 @@ func (h *Handler) setup() {
        mux.Handle("/arvados/v1/container_requests/", rtr)
        mux.Handle("/arvados/v1/groups", rtr)
        mux.Handle("/arvados/v1/groups/", rtr)
+       mux.Handle("/arvados/v1/links", rtr)
+       mux.Handle("/arvados/v1/links/", rtr)
        mux.Handle("/login", rtr)
        mux.Handle("/logout", rtr)
 
index 9b71c349a4b5624cf32cdf3eb6bba83d06d737bc..f854079f97d87376c9d6e3813b10b2872701d0f5 100644 (file)
@@ -88,6 +88,104 @@ func (s *HandlerSuite) TestConfigExport(c *check.C) {
        }
 }
 
+func (s *HandlerSuite) TestVocabularyExport(c *check.C) {
+       voc := `{
+               "strict_tags": false,
+               "tags": {
+                       "IDTAGIMPORTANCE": {
+                               "strict": false,
+                               "labels": [{"label": "Importance"}],
+                               "values": {
+                                       "HIGH": {
+                                               "labels": [{"label": "High"}]
+                                       },
+                                       "LOW": {
+                                               "labels": [{"label": "Low"}]
+                                       }
+                               }
+                       }
+               }
+       }`
+       f, err := os.CreateTemp("", "test-vocabulary-*.json")
+       c.Assert(err, check.IsNil)
+       defer os.Remove(f.Name())
+       _, err = f.WriteString(voc)
+       c.Assert(err, check.IsNil)
+       f.Close()
+       s.cluster.API.VocabularyPath = f.Name()
+       for _, method := range []string{"GET", "OPTIONS"} {
+               c.Log(c.TestName()+" ", method)
+               req := httptest.NewRequest(method, "/arvados/v1/vocabulary", nil)
+               resp := httptest.NewRecorder()
+               s.handler.ServeHTTP(resp, req)
+               c.Log(resp.Body.String())
+               if !c.Check(resp.Code, check.Equals, http.StatusOK) {
+                       continue
+               }
+               c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, `*`)
+               c.Check(resp.Header().Get("Access-Control-Allow-Methods"), check.Matches, `.*\bGET\b.*`)
+               c.Check(resp.Header().Get("Access-Control-Allow-Headers"), check.Matches, `.+`)
+               if method == "OPTIONS" {
+                       c.Check(resp.Body.String(), check.HasLen, 0)
+                       continue
+               }
+               var expectedVoc, receivedVoc *arvados.Vocabulary
+               err := json.Unmarshal([]byte(voc), &expectedVoc)
+               c.Check(err, check.IsNil)
+               err = json.Unmarshal(resp.Body.Bytes(), &receivedVoc)
+               c.Check(err, check.IsNil)
+               c.Check(receivedVoc, check.DeepEquals, expectedVoc)
+       }
+}
+
+func (s *HandlerSuite) TestVocabularyFailedCheckStatus(c *check.C) {
+       voc := `{
+               "strict_tags": false,
+               "tags": {
+                       "IDTAGIMPORTANCE": {
+                               "strict": true,
+                               "labels": [{"label": "Importance"}],
+                               "values": {
+                                       "HIGH": {
+                                               "labels": [{"label": "High"}]
+                                       },
+                                       "LOW": {
+                                               "labels": [{"label": "Low"}]
+                                       }
+                               }
+                       }
+               }
+       }`
+       f, err := os.CreateTemp("", "test-vocabulary-*.json")
+       c.Assert(err, check.IsNil)
+       defer os.Remove(f.Name())
+       _, err = f.WriteString(voc)
+       c.Assert(err, check.IsNil)
+       f.Close()
+       s.cluster.API.VocabularyPath = f.Name()
+
+       req := httptest.NewRequest("POST", "/arvados/v1/collections",
+               strings.NewReader(`{
+                       "collection": {
+                               "properties": {
+                                       "IDTAGIMPORTANCE": "Critical"
+                               }
+                       }
+               }`))
+       req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
+       req.Header.Set("Content-type", "application/json")
+
+       resp := httptest.NewRecorder()
+       s.handler.ServeHTTP(resp, req)
+       c.Log(resp.Body.String())
+       c.Assert(resp.Code, check.Equals, http.StatusBadRequest)
+       var jresp httpserver.ErrorResponse
+       err = json.Unmarshal(resp.Body.Bytes(), &jresp)
+       c.Check(err, check.IsNil)
+       c.Assert(len(jresp.Errors), check.Equals, 1)
+       c.Check(jresp.Errors[0], check.Matches, `.*tag value.*is not valid for key.*`)
+}
+
 func (s *HandlerSuite) TestProxyDiscoveryDoc(c *check.C) {
        req := httptest.NewRequest("GET", "/discovery/v1/apis/arvados/v1/rest", nil)
        resp := httptest.NewRecorder()
@@ -245,7 +343,7 @@ func (s *HandlerSuite) CheckObjectType(c *check.C, url string, token string, ski
        resp := httptest.NewRecorder()
        s.handler.ServeHTTP(resp, req)
        c.Assert(resp.Code, check.Equals, http.StatusOK,
-               check.Commentf("Wasn't able to get data from the controller at %q", url))
+               check.Commentf("Wasn't able to get data from the controller at %q: %q", url, resp.Body.String()))
        err = json.Unmarshal(resp.Body.Bytes(), &proxied)
        c.Check(err, check.Equals, nil)
 
index d81dd812bfe2ca575fa44895dac2fadd23fc6a72..96c89252ec0285e58dac4330333070c9898cce9e 100644 (file)
@@ -49,8 +49,12 @@ func (conn *Conn) CollectionList(ctx context.Context, opts arvados.ListOptions)
 }
 
 // CollectionCreate defers to railsProxy for everything except blob
-// signatures.
+// signatures and vocabulary checking.
 func (conn *Conn) CollectionCreate(ctx context.Context, opts arvados.CreateOptions) (arvados.Collection, error) {
+       err := conn.checkProperties(ctx, opts.Attrs["properties"])
+       if err != nil {
+               return arvados.Collection{}, err
+       }
        if len(opts.Select) > 0 {
                // We need to know IsTrashed and TrashAt to implement
                // signing properly, even if the caller doesn't want
@@ -66,8 +70,12 @@ func (conn *Conn) CollectionCreate(ctx context.Context, opts arvados.CreateOptio
 }
 
 // CollectionUpdate defers to railsProxy for everything except blob
-// signatures.
+// signatures and vocabulary checking.
 func (conn *Conn) CollectionUpdate(ctx context.Context, opts arvados.UpdateOptions) (arvados.Collection, error) {
+       err := conn.checkProperties(ctx, opts.Attrs["properties"])
+       if err != nil {
+               return arvados.Collection{}, err
+       }
        if len(opts.Select) > 0 {
                // We need to know IsTrashed and TrashAt to implement
                // signing properly, even if the caller doesn't want
index 4a44949641da71b70985f2a5ce472e94327b81ec..bbfb811165c7c869cfc62e6306611d9e60c6f457 100644 (file)
@@ -48,6 +48,93 @@ func (s *CollectionSuite) TearDownTest(c *check.C) {
        s.railsSpy.Close()
 }
 
+func (s *CollectionSuite) setUpVocabulary(c *check.C, testVocabulary string) {
+       if testVocabulary == "" {
+               testVocabulary = `{
+                       "strict_tags": false,
+                       "tags": {
+                               "IDTAGIMPORTANCES": {
+                                       "strict": true,
+                                       "labels": [{"label": "Importance"}, {"label": "Priority"}],
+                                       "values": {
+                                               "IDVALIMPORTANCES1": { "labels": [{"label": "Critical"}, {"label": "Urgent"}, {"label": "High"}] },
+                                               "IDVALIMPORTANCES2": { "labels": [{"label": "Normal"}, {"label": "Moderate"}] },
+                                               "IDVALIMPORTANCES3": { "labels": [{"label": "Low"}] }
+                                       }
+                               }
+                       }
+               }`
+       }
+       voc, err := arvados.NewVocabulary([]byte(testVocabulary), []string{})
+       c.Assert(err, check.IsNil)
+       s.cluster.API.VocabularyPath = "foo"
+       s.localdb.vocabularyCache = voc
+}
+
+func (s *CollectionSuite) TestCollectionCreateWithProperties(c *check.C) {
+       s.setUpVocabulary(c, "")
+       ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
+
+       tests := []struct {
+               name    string
+               props   map[string]interface{}
+               success bool
+       }{
+               {"Invalid prop key", map[string]interface{}{"Priority": "IDVALIMPORTANCES1"}, false},
+               {"Invalid prop value", map[string]interface{}{"IDTAGIMPORTANCES": "high"}, false},
+               {"Valid prop key & value", map[string]interface{}{"IDTAGIMPORTANCES": "IDVALIMPORTANCES1"}, true},
+               {"Empty properties", map[string]interface{}{}, true},
+       }
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+
+               coll, err := s.localdb.CollectionCreate(ctx, arvados.CreateOptions{
+                       Select: []string{"uuid", "properties"},
+                       Attrs: map[string]interface{}{
+                               "properties": tt.props,
+                       }})
+               if tt.success {
+                       c.Assert(err, check.IsNil)
+                       c.Assert(coll.Properties, check.DeepEquals, tt.props)
+               } else {
+                       c.Assert(err, check.NotNil)
+               }
+       }
+}
+
+func (s *CollectionSuite) TestCollectionUpdateWithProperties(c *check.C) {
+       s.setUpVocabulary(c, "")
+       ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
+
+       tests := []struct {
+               name    string
+               props   map[string]interface{}
+               success bool
+       }{
+               {"Invalid prop key", map[string]interface{}{"Priority": "IDVALIMPORTANCES1"}, false},
+               {"Invalid prop value", map[string]interface{}{"IDTAGIMPORTANCES": "high"}, false},
+               {"Valid prop key & value", map[string]interface{}{"IDTAGIMPORTANCES": "IDVALIMPORTANCES1"}, true},
+               {"Empty properties", map[string]interface{}{}, true},
+       }
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+               coll, err := s.localdb.CollectionCreate(ctx, arvados.CreateOptions{})
+               c.Assert(err, check.IsNil)
+               coll, err = s.localdb.CollectionUpdate(ctx, arvados.UpdateOptions{
+                       UUID:   coll.UUID,
+                       Select: []string{"uuid", "properties"},
+                       Attrs: map[string]interface{}{
+                               "properties": tt.props,
+                       }})
+               if tt.success {
+                       c.Assert(err, check.IsNil)
+                       c.Assert(coll.Properties, check.DeepEquals, tt.props)
+               } else {
+                       c.Assert(err, check.NotNil)
+               }
+       }
+}
+
 func (s *CollectionSuite) TestSignatures(c *check.C) {
        ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
 
index a90deded593ab59c31599e6bcde3ca833a961349..323e660c6f1e75d79721466e513dc8c611a52833 100644 (file)
@@ -6,27 +6,37 @@ package localdb
 
 import (
        "context"
+       "encoding/json"
        "fmt"
+       "net/http"
+       "os"
        "strings"
+       "time"
 
        "git.arvados.org/arvados.git/lib/controller/railsproxy"
        "git.arvados.org/arvados.git/lib/controller/rpc"
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "github.com/sirupsen/logrus"
 )
 
 type railsProxy = rpc.Conn
 
 type Conn struct {
-       cluster     *arvados.Cluster
-       *railsProxy // handles API methods that aren't defined on Conn itself
+       cluster                    *arvados.Cluster
+       *railsProxy                // handles API methods that aren't defined on Conn itself
+       vocabularyCache            *arvados.Vocabulary
+       vocabularyFileModTime      time.Time
+       lastVocabularyRefreshCheck time.Time
+       lastVocabularyError        error
        loginController
 }
 
 func NewConn(cluster *arvados.Cluster) *Conn {
        railsProxy := railsproxy.NewConn(cluster)
        railsProxy.RedactHostInErrors = true
-       var conn Conn
-       conn = Conn{
+       conn := Conn{
                cluster:    cluster,
                railsProxy: railsProxy,
        }
@@ -34,6 +44,106 @@ func NewConn(cluster *arvados.Cluster) *Conn {
        return &conn
 }
 
+func (conn *Conn) checkProperties(ctx context.Context, properties interface{}) error {
+       if properties == nil {
+               return nil
+       }
+       var props map[string]interface{}
+       switch properties := properties.(type) {
+       case string:
+               err := json.Unmarshal([]byte(properties), &props)
+               if err != nil {
+                       return err
+               }
+       case map[string]interface{}:
+               props = properties
+       default:
+               return fmt.Errorf("unexpected properties type %T", properties)
+       }
+       voc, err := conn.VocabularyGet(ctx)
+       if err != nil {
+               return err
+       }
+       err = voc.Check(props)
+       if err != nil {
+               return httpErrorf(http.StatusBadRequest, voc.Check(props).Error())
+       }
+       return nil
+}
+
+func (conn *Conn) maybeRefreshVocabularyCache(logger logrus.FieldLogger) error {
+       if conn.lastVocabularyRefreshCheck.Add(time.Second).After(time.Now()) {
+               // Throttle the access to disk to at most once per second.
+               return nil
+       }
+       conn.lastVocabularyRefreshCheck = time.Now()
+       fi, err := os.Stat(conn.cluster.API.VocabularyPath)
+       if err != nil {
+               err = fmt.Errorf("couldn't stat vocabulary file %q: %v", conn.cluster.API.VocabularyPath, err)
+               conn.lastVocabularyError = err
+               return err
+       }
+       if fi.ModTime().After(conn.vocabularyFileModTime) {
+               err = conn.loadVocabularyFile()
+               if err != nil {
+                       conn.lastVocabularyError = err
+                       return err
+               }
+               conn.vocabularyFileModTime = fi.ModTime()
+               conn.lastVocabularyError = nil
+               logger.Info("vocabulary file reloaded successfully")
+       }
+       return nil
+}
+
+func (conn *Conn) loadVocabularyFile() error {
+       vf, err := os.ReadFile(conn.cluster.API.VocabularyPath)
+       if err != nil {
+               return fmt.Errorf("couldn't reading the vocabulary file: %v", err)
+       }
+       mk := make([]string, 0, len(conn.cluster.Collections.ManagedProperties))
+       for k := range conn.cluster.Collections.ManagedProperties {
+               mk = append(mk, k)
+       }
+       voc, err := arvados.NewVocabulary(vf, mk)
+       if err != nil {
+               return fmt.Errorf("while loading vocabulary file %q: %s", conn.cluster.API.VocabularyPath, err)
+       }
+       conn.vocabularyCache = voc
+       return nil
+}
+
+// LastVocabularyError returns the last error encountered while loading the
+// vocabulary file.
+// Implements health.Func
+func (conn *Conn) LastVocabularyError() error {
+       conn.maybeRefreshVocabularyCache(ctxlog.FromContext(context.Background()))
+       return conn.lastVocabularyError
+}
+
+// VocabularyGet refreshes the vocabulary cache if necessary and returns it.
+func (conn *Conn) VocabularyGet(ctx context.Context) (arvados.Vocabulary, error) {
+       if conn.cluster.API.VocabularyPath == "" {
+               return arvados.Vocabulary{
+                       Tags: map[string]arvados.VocabularyTag{},
+               }, nil
+       }
+       logger := ctxlog.FromContext(ctx)
+       if conn.vocabularyCache == nil {
+               // Initial load of vocabulary file.
+               err := conn.loadVocabularyFile()
+               if err != nil {
+                       logger.WithError(err).Error("error loading vocabulary file")
+                       return arvados.Vocabulary{}, err
+               }
+       }
+       err := conn.maybeRefreshVocabularyCache(logger)
+       if err != nil {
+               logger.WithError(err).Error("error reloading vocabulary file - ignoring")
+       }
+       return *conn.vocabularyCache, nil
+}
+
 // Logout handles the logout of conn giving to the appropriate loginController
 func (conn *Conn) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
        return conn.loginController.Logout(ctx, opts)
@@ -96,3 +206,7 @@ func (conn *Conn) GroupContents(ctx context.Context, options arvados.GroupConten
 
        return conn.railsProxy.GroupContents(ctx, options)
 }
+
+func httpErrorf(code int, format string, args ...interface{}) error {
+       return httpserver.ErrorWithStatus(fmt.Errorf(format, args...), code)
+}
diff --git a/lib/controller/localdb/container_request.go b/lib/controller/localdb/container_request.go
new file mode 100644 (file)
index 0000000..5b2ce95
--- /dev/null
@@ -0,0 +1,39 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+)
+
+// ContainerRequestCreate defers to railsProxy for everything except
+// vocabulary checking.
+func (conn *Conn) ContainerRequestCreate(ctx context.Context, opts arvados.CreateOptions) (arvados.ContainerRequest, error) {
+       err := conn.checkProperties(ctx, opts.Attrs["properties"])
+       if err != nil {
+               return arvados.ContainerRequest{}, err
+       }
+       resp, err := conn.railsProxy.ContainerRequestCreate(ctx, opts)
+       if err != nil {
+               return resp, err
+       }
+       return resp, nil
+}
+
+// ContainerRequestUpdate defers to railsProxy for everything except
+// vocabulary checking.
+func (conn *Conn) ContainerRequestUpdate(ctx context.Context, opts arvados.UpdateOptions) (arvados.ContainerRequest, error) {
+       err := conn.checkProperties(ctx, opts.Attrs["properties"])
+       if err != nil {
+               return arvados.ContainerRequest{}, err
+       }
+       resp, err := conn.railsProxy.ContainerRequestUpdate(ctx, opts)
+       if err != nil {
+               return resp, err
+       }
+       return resp, nil
+}
diff --git a/lib/controller/localdb/container_request_test.go b/lib/controller/localdb/container_request_test.go
new file mode 100644 (file)
index 0000000..cca541a
--- /dev/null
@@ -0,0 +1,166 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+
+       "git.arvados.org/arvados.git/lib/config"
+       "git.arvados.org/arvados.git/lib/controller/rpc"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&ContainerRequestSuite{})
+
+type ContainerRequestSuite struct {
+       cluster  *arvados.Cluster
+       localdb  *Conn
+       railsSpy *arvadostest.Proxy
+}
+
+func (s *ContainerRequestSuite) TearDownSuite(c *check.C) {
+       // Undo any changes/additions to the user database so they
+       // don't affect subsequent tests.
+       arvadostest.ResetEnv()
+       c.Check(arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil), check.IsNil)
+}
+
+func (s *ContainerRequestSuite) SetUpTest(c *check.C) {
+       cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
+       c.Assert(err, check.IsNil)
+       s.cluster, err = cfg.GetCluster("")
+       c.Assert(err, check.IsNil)
+       s.localdb = NewConn(s.cluster)
+       s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
+       *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
+}
+
+func (s *ContainerRequestSuite) TearDownTest(c *check.C) {
+       s.railsSpy.Close()
+}
+
+func (s *ContainerRequestSuite) setUpVocabulary(c *check.C, testVocabulary string) {
+       if testVocabulary == "" {
+               testVocabulary = `{
+                       "strict_tags": false,
+                       "tags": {
+                               "IDTAGIMPORTANCES": {
+                                       "strict": true,
+                                       "labels": [{"label": "Importance"}, {"label": "Priority"}],
+                                       "values": {
+                                               "IDVALIMPORTANCES1": { "labels": [{"label": "Critical"}, {"label": "Urgent"}, {"label": "High"}] },
+                                               "IDVALIMPORTANCES2": { "labels": [{"label": "Normal"}, {"label": "Moderate"}] },
+                                               "IDVALIMPORTANCES3": { "labels": [{"label": "Low"}] }
+                                       }
+                               }
+                       }
+               }`
+       }
+       voc, err := arvados.NewVocabulary([]byte(testVocabulary), []string{})
+       c.Assert(err, check.IsNil)
+       s.localdb.vocabularyCache = voc
+       s.cluster.API.VocabularyPath = "foo"
+}
+
+func (s *ContainerRequestSuite) TestCRCreateWithProperties(c *check.C) {
+       s.setUpVocabulary(c, "")
+       ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
+
+       tests := []struct {
+               name    string
+               props   map[string]interface{}
+               success bool
+       }{
+               {"Invalid prop key", map[string]interface{}{"Priority": "IDVALIMPORTANCES1"}, false},
+               {"Invalid prop value", map[string]interface{}{"IDTAGIMPORTANCES": "high"}, false},
+               {"Valid prop key & value", map[string]interface{}{"IDTAGIMPORTANCES": "IDVALIMPORTANCES1"}, true},
+               {"Empty properties", map[string]interface{}{}, true},
+       }
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+
+               cnt, err := s.localdb.ContainerRequestCreate(ctx, arvados.CreateOptions{
+                       Select: []string{"uuid", "properties"},
+                       Attrs: map[string]interface{}{
+                               "command":         []string{"echo", "foo"},
+                               "container_image": "arvados/apitestfixture:latest",
+                               "cwd":             "/tmp",
+                               "environment":     map[string]string{},
+                               "mounts": map[string]interface{}{
+                                       "/out": map[string]interface{}{
+                                               "kind":     "tmp",
+                                               "capacity": 1000000,
+                                       },
+                               },
+                               "output_path": "/out",
+                               "runtime_constraints": map[string]interface{}{
+                                       "vcpus": 1,
+                                       "ram":   2,
+                               },
+                               "properties": tt.props,
+                       }})
+               if tt.success {
+                       c.Assert(err, check.IsNil)
+                       c.Assert(cnt.Properties, check.DeepEquals, tt.props)
+               } else {
+                       c.Assert(err, check.NotNil)
+               }
+       }
+}
+
+func (s *ContainerRequestSuite) TestCRUpdateWithProperties(c *check.C) {
+       s.setUpVocabulary(c, "")
+       ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
+
+       tests := []struct {
+               name    string
+               props   map[string]interface{}
+               success bool
+       }{
+               {"Invalid prop key", map[string]interface{}{"Priority": "IDVALIMPORTANCES1"}, false},
+               {"Invalid prop value", map[string]interface{}{"IDTAGIMPORTANCES": "high"}, false},
+               {"Valid prop key & value", map[string]interface{}{"IDTAGIMPORTANCES": "IDVALIMPORTANCES1"}, true},
+               {"Empty properties", map[string]interface{}{}, true},
+       }
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+               cnt, err := s.localdb.ContainerRequestCreate(ctx, arvados.CreateOptions{
+                       Attrs: map[string]interface{}{
+                               "command":         []string{"echo", "foo"},
+                               "container_image": "arvados/apitestfixture:latest",
+                               "cwd":             "/tmp",
+                               "environment":     map[string]string{},
+                               "mounts": map[string]interface{}{
+                                       "/out": map[string]interface{}{
+                                               "kind":     "tmp",
+                                               "capacity": 1000000,
+                                       },
+                               },
+                               "output_path": "/out",
+                               "runtime_constraints": map[string]interface{}{
+                                       "vcpus": 1,
+                                       "ram":   2,
+                               },
+                       },
+               })
+               c.Assert(err, check.IsNil)
+               cnt, err = s.localdb.ContainerRequestUpdate(ctx, arvados.UpdateOptions{
+                       UUID:   cnt.UUID,
+                       Select: []string{"uuid", "properties"},
+                       Attrs: map[string]interface{}{
+                               "properties": tt.props,
+                       }})
+               if tt.success {
+                       c.Assert(err, check.IsNil)
+                       c.Assert(cnt.Properties, check.DeepEquals, tt.props)
+               } else {
+                       c.Assert(err, check.NotNil)
+               }
+       }
+}
diff --git a/lib/controller/localdb/group.go b/lib/controller/localdb/group.go
new file mode 100644 (file)
index 0000000..0d77bdb
--- /dev/null
@@ -0,0 +1,39 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+)
+
+// GroupCreate defers to railsProxy for everything except vocabulary
+// checking.
+func (conn *Conn) GroupCreate(ctx context.Context, opts arvados.CreateOptions) (arvados.Group, error) {
+       err := conn.checkProperties(ctx, opts.Attrs["properties"])
+       if err != nil {
+               return arvados.Group{}, err
+       }
+       resp, err := conn.railsProxy.GroupCreate(ctx, opts)
+       if err != nil {
+               return resp, err
+       }
+       return resp, nil
+}
+
+// GroupUpdate defers to railsProxy for everything except vocabulary
+// checking.
+func (conn *Conn) GroupUpdate(ctx context.Context, opts arvados.UpdateOptions) (arvados.Group, error) {
+       err := conn.checkProperties(ctx, opts.Attrs["properties"])
+       if err != nil {
+               return arvados.Group{}, err
+       }
+       resp, err := conn.railsProxy.GroupUpdate(ctx, opts)
+       if err != nil {
+               return resp, err
+       }
+       return resp, nil
+}
diff --git a/lib/controller/localdb/group_test.go b/lib/controller/localdb/group_test.go
new file mode 100644 (file)
index 0000000..2d55def
--- /dev/null
@@ -0,0 +1,138 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+
+       "git.arvados.org/arvados.git/lib/config"
+       "git.arvados.org/arvados.git/lib/controller/rpc"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&GroupSuite{})
+
+type GroupSuite struct {
+       cluster  *arvados.Cluster
+       localdb  *Conn
+       railsSpy *arvadostest.Proxy
+}
+
+func (s *GroupSuite) TearDownSuite(c *check.C) {
+       // Undo any changes/additions to the user database so they
+       // don't affect subsequent tests.
+       arvadostest.ResetEnv()
+       c.Check(arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil), check.IsNil)
+}
+
+func (s *GroupSuite) SetUpTest(c *check.C) {
+       cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
+       c.Assert(err, check.IsNil)
+       s.cluster, err = cfg.GetCluster("")
+       c.Assert(err, check.IsNil)
+       s.localdb = NewConn(s.cluster)
+       s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
+       *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
+}
+
+func (s *GroupSuite) TearDownTest(c *check.C) {
+       s.railsSpy.Close()
+}
+
+func (s *GroupSuite) setUpVocabulary(c *check.C, testVocabulary string) {
+       if testVocabulary == "" {
+               testVocabulary = `{
+                       "strict_tags": false,
+                       "tags": {
+                               "IDTAGIMPORTANCES": {
+                                       "strict": true,
+                                       "labels": [{"label": "Importance"}, {"label": "Priority"}],
+                                       "values": {
+                                               "IDVALIMPORTANCES1": { "labels": [{"label": "Critical"}, {"label": "Urgent"}, {"label": "High"}] },
+                                               "IDVALIMPORTANCES2": { "labels": [{"label": "Normal"}, {"label": "Moderate"}] },
+                                               "IDVALIMPORTANCES3": { "labels": [{"label": "Low"}] }
+                                       }
+                               }
+                       }
+               }`
+       }
+       voc, err := arvados.NewVocabulary([]byte(testVocabulary), []string{})
+       c.Assert(err, check.IsNil)
+       s.localdb.vocabularyCache = voc
+       s.cluster.API.VocabularyPath = "foo"
+}
+
+func (s *GroupSuite) TestGroupCreateWithProperties(c *check.C) {
+       s.setUpVocabulary(c, "")
+       ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
+
+       tests := []struct {
+               name    string
+               props   map[string]interface{}
+               success bool
+       }{
+               {"Invalid prop key", map[string]interface{}{"Priority": "IDVALIMPORTANCES1"}, false},
+               {"Invalid prop value", map[string]interface{}{"IDTAGIMPORTANCES": "high"}, false},
+               {"Valid prop key & value", map[string]interface{}{"IDTAGIMPORTANCES": "IDVALIMPORTANCES1"}, true},
+               {"Empty properties", map[string]interface{}{}, true},
+       }
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+
+               grp, err := s.localdb.GroupCreate(ctx, arvados.CreateOptions{
+                       Select: []string{"uuid", "properties"},
+                       Attrs: map[string]interface{}{
+                               "group_class": "project",
+                               "properties":  tt.props,
+                       }})
+               if tt.success {
+                       c.Assert(err, check.IsNil)
+                       c.Assert(grp.Properties, check.DeepEquals, tt.props)
+               } else {
+                       c.Assert(err, check.NotNil)
+               }
+       }
+}
+
+func (s *GroupSuite) TestGroupUpdateWithProperties(c *check.C) {
+       s.setUpVocabulary(c, "")
+       ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
+
+       tests := []struct {
+               name    string
+               props   map[string]interface{}
+               success bool
+       }{
+               {"Invalid prop key", map[string]interface{}{"Priority": "IDVALIMPORTANCES1"}, false},
+               {"Invalid prop value", map[string]interface{}{"IDTAGIMPORTANCES": "high"}, false},
+               {"Valid prop key & value", map[string]interface{}{"IDTAGIMPORTANCES": "IDVALIMPORTANCES1"}, true},
+               {"Empty properties", map[string]interface{}{}, true},
+       }
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+               grp, err := s.localdb.GroupCreate(ctx, arvados.CreateOptions{
+                       Attrs: map[string]interface{}{
+                               "group_class": "project",
+                       },
+               })
+               c.Assert(err, check.IsNil)
+               grp, err = s.localdb.GroupUpdate(ctx, arvados.UpdateOptions{
+                       UUID:   grp.UUID,
+                       Select: []string{"uuid", "properties"},
+                       Attrs: map[string]interface{}{
+                               "properties": tt.props,
+                       }})
+               if tt.success {
+                       c.Assert(err, check.IsNil)
+                       c.Assert(grp.Properties, check.DeepEquals, tt.props)
+               } else {
+                       c.Assert(err, check.NotNil)
+               }
+       }
+}
diff --git a/lib/controller/localdb/link.go b/lib/controller/localdb/link.go
new file mode 100644 (file)
index 0000000..cfcae3d
--- /dev/null
@@ -0,0 +1,39 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+)
+
+// LinkCreate defers to railsProxy for everything except vocabulary
+// checking.
+func (conn *Conn) LinkCreate(ctx context.Context, opts arvados.CreateOptions) (arvados.Link, error) {
+       err := conn.checkProperties(ctx, opts.Attrs["properties"])
+       if err != nil {
+               return arvados.Link{}, err
+       }
+       resp, err := conn.railsProxy.LinkCreate(ctx, opts)
+       if err != nil {
+               return resp, err
+       }
+       return resp, nil
+}
+
+// LinkUpdate defers to railsProxy for everything except vocabulary
+// checking.
+func (conn *Conn) LinkUpdate(ctx context.Context, opts arvados.UpdateOptions) (arvados.Link, error) {
+       err := conn.checkProperties(ctx, opts.Attrs["properties"])
+       if err != nil {
+               return arvados.Link{}, err
+       }
+       resp, err := conn.railsProxy.LinkUpdate(ctx, opts)
+       if err != nil {
+               return resp, err
+       }
+       return resp, nil
+}
diff --git a/lib/controller/localdb/link_test.go b/lib/controller/localdb/link_test.go
new file mode 100644 (file)
index 0000000..2f07fb4
--- /dev/null
@@ -0,0 +1,142 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+
+       "git.arvados.org/arvados.git/lib/config"
+       "git.arvados.org/arvados.git/lib/controller/rpc"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&LinkSuite{})
+
+type LinkSuite struct {
+       cluster  *arvados.Cluster
+       localdb  *Conn
+       railsSpy *arvadostest.Proxy
+}
+
+func (s *LinkSuite) TearDownSuite(c *check.C) {
+       // Undo any changes/additions to the user database so they
+       // don't affect subsequent tests.
+       arvadostest.ResetEnv()
+       c.Check(arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil), check.IsNil)
+}
+
+func (s *LinkSuite) SetUpTest(c *check.C) {
+       cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
+       c.Assert(err, check.IsNil)
+       s.cluster, err = cfg.GetCluster("")
+       c.Assert(err, check.IsNil)
+       s.localdb = NewConn(s.cluster)
+       s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
+       *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
+}
+
+func (s *LinkSuite) TearDownTest(c *check.C) {
+       s.railsSpy.Close()
+}
+
+func (s *LinkSuite) setUpVocabulary(c *check.C, testVocabulary string) {
+       if testVocabulary == "" {
+               testVocabulary = `{
+                       "strict_tags": false,
+                       "tags": {
+                               "IDTAGIMPORTANCES": {
+                                       "strict": true,
+                                       "labels": [{"label": "Importance"}, {"label": "Priority"}],
+                                       "values": {
+                                               "IDVALIMPORTANCES1": { "labels": [{"label": "Critical"}, {"label": "Urgent"}, {"label": "High"}] },
+                                               "IDVALIMPORTANCES2": { "labels": [{"label": "Normal"}, {"label": "Moderate"}] },
+                                               "IDVALIMPORTANCES3": { "labels": [{"label": "Low"}] }
+                                       }
+                               }
+                       }
+               }`
+       }
+       voc, err := arvados.NewVocabulary([]byte(testVocabulary), []string{})
+       c.Assert(err, check.IsNil)
+       s.localdb.vocabularyCache = voc
+       s.cluster.API.VocabularyPath = "foo"
+}
+
+func (s *LinkSuite) TestLinkCreateWithProperties(c *check.C) {
+       s.setUpVocabulary(c, "")
+       ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
+
+       tests := []struct {
+               name    string
+               props   map[string]interface{}
+               success bool
+       }{
+               {"Invalid prop key", map[string]interface{}{"Priority": "IDVALIMPORTANCES1"}, false},
+               {"Invalid prop value", map[string]interface{}{"IDTAGIMPORTANCES": "high"}, false},
+               {"Valid prop key & value", map[string]interface{}{"IDTAGIMPORTANCES": "IDVALIMPORTANCES1"}, true},
+               {"Empty properties", map[string]interface{}{}, true},
+       }
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+
+               lnk, err := s.localdb.LinkCreate(ctx, arvados.CreateOptions{
+                       Select: []string{"uuid", "properties"},
+                       Attrs: map[string]interface{}{
+                               "link_class": "star",
+                               "tail_uuid":  "zzzzz-j7d0g-publicfavorites",
+                               "head_uuid":  arvadostest.FooCollection,
+                               "properties": tt.props,
+                       }})
+               if tt.success {
+                       c.Assert(err, check.IsNil)
+                       c.Assert(lnk.Properties, check.DeepEquals, tt.props)
+               } else {
+                       c.Assert(err, check.NotNil)
+               }
+       }
+}
+
+func (s *LinkSuite) TestLinkUpdateWithProperties(c *check.C) {
+       s.setUpVocabulary(c, "")
+       ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
+
+       tests := []struct {
+               name    string
+               props   map[string]interface{}
+               success bool
+       }{
+               {"Invalid prop key", map[string]interface{}{"Priority": "IDVALIMPORTANCES1"}, false},
+               {"Invalid prop value", map[string]interface{}{"IDTAGIMPORTANCES": "high"}, false},
+               {"Valid prop key & value", map[string]interface{}{"IDTAGIMPORTANCES": "IDVALIMPORTANCES1"}, true},
+               {"Empty properties", map[string]interface{}{}, true},
+       }
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+               lnk, err := s.localdb.LinkCreate(ctx, arvados.CreateOptions{
+                       Attrs: map[string]interface{}{
+                               "link_class": "star",
+                               "tail_uuid":  "zzzzz-j7d0g-publicfavorites",
+                               "head_uuid":  arvadostest.FooCollection,
+                       },
+               })
+               c.Assert(err, check.IsNil)
+               lnk, err = s.localdb.LinkUpdate(ctx, arvados.UpdateOptions{
+                       UUID:   lnk.UUID,
+                       Select: []string{"uuid", "properties"},
+                       Attrs: map[string]interface{}{
+                               "properties": tt.props,
+                       }})
+               if tt.success {
+                       c.Assert(err, check.IsNil)
+                       c.Assert(lnk.Properties, check.DeepEquals, tt.props)
+               } else {
+                       c.Assert(err, check.NotNil)
+               }
+       }
+}
index 9826c1e7448e548bc41c6f14dd092bacd2046742..02e06279f1168adca61999a543a9e82ad059e424 100644 (file)
@@ -65,6 +65,13 @@ func (rtr *router) addRoutes() {
                                return rtr.backend.ConfigGet(ctx)
                        },
                },
+               {
+                       arvados.EndpointVocabularyGet,
+                       func() interface{} { return &struct{}{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.VocabularyGet(ctx)
+                       },
+               },
                {
                        arvados.EndpointLogin,
                        func() interface{} { return &arvados.LoginOptions{} },
@@ -307,6 +314,41 @@ func (rtr *router) addRoutes() {
                                return rtr.backend.GroupUntrash(ctx, *opts.(*arvados.UntrashOptions))
                        },
                },
+               {
+                       arvados.EndpointLinkCreate,
+                       func() interface{} { return &arvados.CreateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.LinkCreate(ctx, *opts.(*arvados.CreateOptions))
+                       },
+               },
+               {
+                       arvados.EndpointLinkUpdate,
+                       func() interface{} { return &arvados.UpdateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.LinkUpdate(ctx, *opts.(*arvados.UpdateOptions))
+                       },
+               },
+               {
+                       arvados.EndpointLinkList,
+                       func() interface{} { return &arvados.ListOptions{Limit: -1} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.LinkList(ctx, *opts.(*arvados.ListOptions))
+                       },
+               },
+               {
+                       arvados.EndpointLinkGet,
+                       func() interface{} { return &arvados.GetOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.LinkGet(ctx, *opts.(*arvados.GetOptions))
+                       },
+               },
+               {
+                       arvados.EndpointLinkDelete,
+                       func() interface{} { return &arvados.DeleteOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.LinkDelete(ctx, *opts.(*arvados.DeleteOptions))
+                       },
+               },
                {
                        arvados.EndpointSpecimenCreate,
                        func() interface{} { return &arvados.CreateOptions{} },
index 640bbf1c23b837822485bc77b1326791f628c03d..25f47bc3bac4f801f2aa33b90e2ab935b0f651f9 100644 (file)
@@ -178,6 +178,13 @@ func (conn *Conn) ConfigGet(ctx context.Context) (json.RawMessage, error) {
        return resp, err
 }
 
+func (conn *Conn) VocabularyGet(ctx context.Context) (arvados.Vocabulary, error) {
+       ep := arvados.EndpointVocabularyGet
+       var resp arvados.Vocabulary
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, nil)
+       return resp, err
+}
+
 func (conn *Conn) Login(ctx context.Context, options arvados.LoginOptions) (arvados.LoginResponse, error) {
        ep := arvados.EndpointLogin
        var resp arvados.LoginResponse
@@ -495,6 +502,41 @@ func (conn *Conn) GroupUntrash(ctx context.Context, options arvados.UntrashOptio
        return resp, err
 }
 
+func (conn *Conn) LinkCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Link, error) {
+       ep := arvados.EndpointLinkCreate
+       var resp arvados.Link
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) LinkUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Link, error) {
+       ep := arvados.EndpointLinkUpdate
+       var resp arvados.Link
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) LinkGet(ctx context.Context, options arvados.GetOptions) (arvados.Link, error) {
+       ep := arvados.EndpointLinkGet
+       var resp arvados.Link
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) LinkList(ctx context.Context, options arvados.ListOptions) (arvados.LinkList, error) {
+       ep := arvados.EndpointLinkList
+       var resp arvados.LinkList
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) LinkDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Link, error) {
+       ep := arvados.EndpointLinkDelete
+       var resp arvados.Link
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
 func (conn *Conn) SpecimenCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Specimen, error) {
        ep := arvados.EndpointSpecimenCreate
        var resp arvados.Specimen
index b429e800841eb7e4935c63ebb56560ec93f556eb..0fdc13d1985d085c28db23615dd9ce1c673781cd 100644 (file)
@@ -23,6 +23,7 @@ type APIEndpoint struct {
 
 var (
        EndpointConfigGet                     = APIEndpoint{"GET", "arvados/v1/config", ""}
+       EndpointVocabularyGet                 = APIEndpoint{"GET", "arvados/v1/vocabulary", ""}
        EndpointLogin                         = APIEndpoint{"GET", "login", ""}
        EndpointLogout                        = APIEndpoint{"GET", "logout", ""}
        EndpointCollectionCreate              = APIEndpoint{"POST", "arvados/v1/collections", "collection"}
@@ -62,6 +63,11 @@ var (
        EndpointGroupDelete                   = APIEndpoint{"DELETE", "arvados/v1/groups/{uuid}", ""}
        EndpointGroupTrash                    = APIEndpoint{"POST", "arvados/v1/groups/{uuid}/trash", ""}
        EndpointGroupUntrash                  = APIEndpoint{"POST", "arvados/v1/groups/{uuid}/untrash", ""}
+       EndpointLinkCreate                    = APIEndpoint{"POST", "arvados/v1/links", "link"}
+       EndpointLinkUpdate                    = APIEndpoint{"PATCH", "arvados/v1/links/{uuid}", "link"}
+       EndpointLinkGet                       = APIEndpoint{"GET", "arvados/v1/links/{uuid}", ""}
+       EndpointLinkList                      = APIEndpoint{"GET", "arvados/v1/links", ""}
+       EndpointLinkDelete                    = APIEndpoint{"DELETE", "arvados/v1/links/{uuid}", ""}
        EndpointUserActivate                  = APIEndpoint{"POST", "arvados/v1/users/{uuid}/activate", ""}
        EndpointUserCreate                    = APIEndpoint{"POST", "arvados/v1/users", "user"}
        EndpointUserCurrent                   = APIEndpoint{"GET", "arvados/v1/users/current", ""}
@@ -219,6 +225,7 @@ type BlockWriteResponse struct {
 
 type API interface {
        ConfigGet(ctx context.Context) (json.RawMessage, error)
+       VocabularyGet(ctx context.Context) (Vocabulary, error)
        Login(ctx context.Context, options LoginOptions) (LoginResponse, error)
        Logout(ctx context.Context, options LogoutOptions) (LogoutResponse, error)
        CollectionCreate(ctx context.Context, options CreateOptions) (Collection, error)
@@ -252,6 +259,11 @@ type API interface {
        GroupDelete(ctx context.Context, options DeleteOptions) (Group, error)
        GroupTrash(ctx context.Context, options DeleteOptions) (Group, error)
        GroupUntrash(ctx context.Context, options UntrashOptions) (Group, error)
+       LinkCreate(ctx context.Context, options CreateOptions) (Link, error)
+       LinkUpdate(ctx context.Context, options UpdateOptions) (Link, error)
+       LinkGet(ctx context.Context, options GetOptions) (Link, error)
+       LinkList(ctx context.Context, options ListOptions) (LinkList, error)
+       LinkDelete(ctx context.Context, options DeleteOptions) (Link, error)
        SpecimenCreate(ctx context.Context, options CreateOptions) (Specimen, error)
        SpecimenUpdate(ctx context.Context, options UpdateOptions) (Specimen, error)
        SpecimenGet(ctx context.Context, options GetOptions) (Specimen, error)
index e736f79fd7f2bcee24e449596b4950bd279881bc..8755bbd3e2fc88eca31062a7eb1b6380fb331b14 100644 (file)
@@ -77,6 +77,12 @@ type UploadDownloadRolePermissions struct {
        Admin UploadDownloadPermission
 }
 
+type ManagedProperties map[string]struct {
+       Value     interface{}
+       Function  string
+       Protected bool
+}
+
 type Cluster struct {
        ClusterID       string `json:"-"`
        ManagementToken string
@@ -102,6 +108,7 @@ type Cluster struct {
                WebsocketClientEventQueue      int
                WebsocketServerEventQueue      int
                KeepServiceRequestTimeout      Duration
+               VocabularyPath                 string
        }
        AuditLogs struct {
                MaxAge             Duration
@@ -109,23 +116,19 @@ type Cluster struct {
                UnloggedAttributes StringSet
        }
        Collections struct {
-               BlobSigning              bool
-               BlobSigningKey           string
-               BlobSigningTTL           Duration
-               BlobTrash                bool
-               BlobTrashLifetime        Duration
-               BlobTrashCheckInterval   Duration
-               BlobTrashConcurrency     int
-               BlobDeleteConcurrency    int
-               BlobReplicateConcurrency int
-               CollectionVersioning     bool
-               DefaultTrashLifetime     Duration
-               DefaultReplication       int
-               ManagedProperties        map[string]struct {
-                       Value     interface{}
-                       Function  string
-                       Protected bool
-               }
+               BlobSigning                  bool
+               BlobSigningKey               string
+               BlobSigningTTL               Duration
+               BlobTrash                    bool
+               BlobTrashLifetime            Duration
+               BlobTrashCheckInterval       Duration
+               BlobTrashConcurrency         int
+               BlobDeleteConcurrency        int
+               BlobReplicateConcurrency     int
+               CollectionVersioning         bool
+               DefaultTrashLifetime         Duration
+               DefaultReplication           int
+               ManagedProperties            ManagedProperties
                PreserveVersionIfIdle        Duration
                TrashSweepInterval           Duration
                TrustAllContent              bool
@@ -273,7 +276,6 @@ type Cluster struct {
                        Options              map[string]struct{}
                }
                UserProfileFormMessage string
-               VocabularyURL          string
                WelcomePageHTML        string
                InactivePageHTML       string
                SSHHelpPageHTML        string
index f7d1f35a3c322953c702437ca5caecd40687bddd..7df6b84d60eb338fd833944940a2f966192960c2 100644 (file)
@@ -4,17 +4,25 @@
 
 package arvados
 
+import "time"
+
 // Link is an arvados#link record
 type Link struct {
-       UUID       string                 `json:"uuid,omiempty"`
-       OwnerUUID  string                 `json:"owner_uuid"`
-       Name       string                 `json:"name"`
-       LinkClass  string                 `json:"link_class"`
-       HeadUUID   string                 `json:"head_uuid"`
-       HeadKind   string                 `json:"head_kind"`
-       TailUUID   string                 `json:"tail_uuid"`
-       TailKind   string                 `json:"tail_kind"`
-       Properties map[string]interface{} `json:"properties"`
+       UUID                 string                 `json:"uuid,omitempty"`
+       Etag                 string                 `json:"etag"`
+       Href                 string                 `json:"href"`
+       OwnerUUID            string                 `json:"owner_uuid"`
+       Name                 string                 `json:"name"`
+       LinkClass            string                 `json:"link_class"`
+       CreatedAt            time.Time              `json:"created_at"`
+       ModifiedAt           time.Time              `json:"modified_at"`
+       ModifiedByClientUUID string                 `json:"modified_by_client_uuid"`
+       ModifiedByUserUUID   string                 `json:"modified_by_user_uuid"`
+       HeadUUID             string                 `json:"head_uuid"`
+       HeadKind             string                 `json:"head_kind"`
+       TailUUID             string                 `json:"tail_uuid"`
+       TailKind             string                 `json:"tail_kind"`
+       Properties           map[string]interface{} `json:"properties"`
 }
 
 // LinkList is an arvados#linkList resource.
diff --git a/sdk/go/arvados/vocabulary.go b/sdk/go/arvados/vocabulary.go
new file mode 100644 (file)
index 0000000..150091b
--- /dev/null
@@ -0,0 +1,220 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "bytes"
+       "encoding/json"
+       "fmt"
+       "reflect"
+       "strings"
+)
+
+type Vocabulary struct {
+       reservedTagKeys map[string]bool          `json:"-"`
+       StrictTags      bool                     `json:"strict_tags"`
+       Tags            map[string]VocabularyTag `json:"tags"`
+}
+
+type VocabularyTag struct {
+       Strict bool                          `json:"strict"`
+       Labels []VocabularyLabel             `json:"labels"`
+       Values map[string]VocabularyTagValue `json:"values"`
+}
+
+// Cannot have a constant map in Go, so we have to use a function
+func (v *Vocabulary) systemTagKeys() map[string]bool {
+       return map[string]bool{
+               "type":                  true,
+               "template_uuid":         true,
+               "groups":                true,
+               "username":              true,
+               "image_timestamp":       true,
+               "docker-image-repo-tag": true,
+               "filters":               true,
+               "container_request":     true,
+       }
+}
+
+type VocabularyLabel struct {
+       Label string `json:"label"`
+}
+
+type VocabularyTagValue struct {
+       Labels []VocabularyLabel `json:"labels"`
+}
+
+// NewVocabulary creates a new Vocabulary from a JSON definition and a list
+// of reserved tag keys that will get special treatment when strict mode is
+// enabled.
+func NewVocabulary(data []byte, managedTagKeys []string) (voc *Vocabulary, err error) {
+       if r := bytes.Compare(data, []byte("")); r == 0 {
+               return &Vocabulary{}, nil
+       }
+       err = json.Unmarshal(data, &voc)
+       if err != nil {
+               return nil, fmt.Errorf("invalid JSON format error: %q", err)
+       }
+       if reflect.DeepEqual(voc, &Vocabulary{}) {
+               return nil, fmt.Errorf("JSON data provided doesn't match Vocabulary format: %q", data)
+       }
+       voc.reservedTagKeys = make(map[string]bool)
+       for _, managedKey := range managedTagKeys {
+               voc.reservedTagKeys[managedKey] = true
+       }
+       for systemKey := range voc.systemTagKeys() {
+               voc.reservedTagKeys[systemKey] = true
+       }
+       err = voc.validate()
+       if err != nil {
+               return nil, err
+       }
+       return voc, nil
+}
+
+func (v *Vocabulary) validate() error {
+       if v == nil {
+               return nil
+       }
+       tagKeys := map[string]string{}
+       // Checks for Vocabulary strictness
+       if v.StrictTags && len(v.Tags) == 0 {
+               return fmt.Errorf("vocabulary is strict but no tags are defined")
+       }
+       // Checks for collisions between tag keys, reserved tag keys
+       // and tag key labels.
+       for key := range v.Tags {
+               if v.reservedTagKeys[key] {
+                       return fmt.Errorf("tag key %q is reserved", key)
+               }
+               lcKey := strings.ToLower(key)
+               if tagKeys[lcKey] != "" {
+                       return fmt.Errorf("duplicate tag key %q", key)
+               }
+               tagKeys[lcKey] = key
+               for _, lbl := range v.Tags[key].Labels {
+                       label := strings.ToLower(lbl.Label)
+                       if tagKeys[label] != "" {
+                               return fmt.Errorf("tag label %q for key %q already seen as a tag key or label", lbl.Label, key)
+                       }
+                       tagKeys[label] = lbl.Label
+               }
+               // Checks for value strictness
+               if v.Tags[key].Strict && len(v.Tags[key].Values) == 0 {
+                       return fmt.Errorf("tag key %q is configured as strict but doesn't provide values", key)
+               }
+               // Checks for collisions between tag values and tag value labels.
+               tagValues := map[string]string{}
+               for val := range v.Tags[key].Values {
+                       lcVal := strings.ToLower(val)
+                       if tagValues[lcVal] != "" {
+                               return fmt.Errorf("duplicate tag value %q for tag %q", val, key)
+                       }
+                       // Checks for collisions between labels from different values.
+                       tagValues[lcVal] = val
+                       for _, tagLbl := range v.Tags[key].Values[val].Labels {
+                               label := strings.ToLower(tagLbl.Label)
+                               if tagValues[label] != "" && tagValues[label] != val {
+                                       return fmt.Errorf("tag value label %q for pair (%q:%q) already seen on value %q", tagLbl.Label, key, val, tagValues[label])
+                               }
+                               tagValues[label] = val
+                       }
+               }
+       }
+       return nil
+}
+
+func (v *Vocabulary) getLabelsToKeys() (labels map[string]string) {
+       if v == nil {
+               return
+       }
+       labels = make(map[string]string)
+       for key, val := range v.Tags {
+               for _, lbl := range val.Labels {
+                       label := strings.ToLower(lbl.Label)
+                       labels[label] = key
+               }
+       }
+       return labels
+}
+
+func (v *Vocabulary) getLabelsToValues(key string) (labels map[string]string) {
+       if v == nil {
+               return
+       }
+       labels = make(map[string]string)
+       if _, ok := v.Tags[key]; ok {
+               for val := range v.Tags[key].Values {
+                       labels[strings.ToLower(val)] = val
+                       for _, tagLbl := range v.Tags[key].Values[val].Labels {
+                               label := strings.ToLower(tagLbl.Label)
+                               labels[label] = val
+                       }
+               }
+       }
+       return labels
+}
+
+func (v *Vocabulary) checkValue(key, val string) error {
+       if _, ok := v.Tags[key].Values[val]; !ok {
+               lcVal := strings.ToLower(val)
+               correctValue, ok := v.getLabelsToValues(key)[lcVal]
+               if ok {
+                       return fmt.Errorf("tag value %q for key %q is an alias, must be provided as %q", val, key, correctValue)
+               } else if v.Tags[key].Strict {
+                       return fmt.Errorf("tag value %q is not valid for key %q", val, key)
+               }
+       }
+       return nil
+}
+
+// Check validates the given data against the vocabulary.
+func (v *Vocabulary) Check(data map[string]interface{}) error {
+       if v == nil {
+               return nil
+       }
+       for key, val := range data {
+               // Checks for key validity
+               if v.reservedTagKeys[key] {
+                       // Allow reserved keys to be used even if they are not defined in
+                       // the vocabulary no matter its strictness.
+                       continue
+               }
+               if _, ok := v.Tags[key]; !ok {
+                       lcKey := strings.ToLower(key)
+                       correctKey, ok := v.getLabelsToKeys()[lcKey]
+                       if ok {
+                               return fmt.Errorf("tag key %q is an alias, must be provided as %q", key, correctKey)
+                       } else if v.StrictTags {
+                               return fmt.Errorf("tag key %q is not defined in the vocabulary", key)
+                       }
+                       // If the key is not defined, we don't need to check the value
+                       continue
+               }
+               // Checks for value validity -- key is defined
+               switch val := val.(type) {
+               case string:
+                       err := v.checkValue(key, val)
+                       if err != nil {
+                               return err
+                       }
+               case []interface{}:
+                       for _, singleVal := range val {
+                               switch singleVal := singleVal.(type) {
+                               case string:
+                                       err := v.checkValue(key, singleVal)
+                                       if err != nil {
+                                               return err
+                                       }
+                               default:
+                                       return fmt.Errorf("value list element type for tag key %q was %T, but expected a string", key, singleVal)
+                               }
+                       }
+               default:
+                       return fmt.Errorf("value type for tag key %q was %T, but expected a string or list of strings", key, val)
+               }
+       }
+       return nil
+}
diff --git a/sdk/go/arvados/vocabulary_test.go b/sdk/go/arvados/vocabulary_test.go
new file mode 100644 (file)
index 0000000..5a5189d
--- /dev/null
@@ -0,0 +1,457 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "encoding/json"
+
+       check "gopkg.in/check.v1"
+)
+
+type VocabularySuite struct {
+       testVoc *Vocabulary
+}
+
+var _ = check.Suite(&VocabularySuite{})
+
+func (s *VocabularySuite) SetUpTest(c *check.C) {
+       s.testVoc = &Vocabulary{
+               reservedTagKeys: map[string]bool{
+                       "reservedKey": true,
+               },
+               StrictTags: false,
+               Tags: map[string]VocabularyTag{
+                       "IDTAGANIMALS": {
+                               Strict: false,
+                               Labels: []VocabularyLabel{{Label: "Animal"}, {Label: "Creature"}},
+                               Values: map[string]VocabularyTagValue{
+                                       "IDVALANIMAL1": {
+                                               Labels: []VocabularyLabel{{Label: "Human"}, {Label: "Homo sapiens"}},
+                                       },
+                                       "IDVALANIMAL2": {
+                                               Labels: []VocabularyLabel{{Label: "Elephant"}, {Label: "Loxodonta"}},
+                                       },
+                               },
+                       },
+                       "IDTAGIMPORTANCE": {
+                               Strict: true,
+                               Labels: []VocabularyLabel{{Label: "Importance"}, {Label: "Priority"}},
+                               Values: map[string]VocabularyTagValue{
+                                       "IDVAL3": {
+                                               Labels: []VocabularyLabel{{Label: "Low"}, {Label: "Low priority"}},
+                                       },
+                                       "IDVAL2": {
+                                               Labels: []VocabularyLabel{{Label: "Medium"}, {Label: "Medium priority"}},
+                                       },
+                                       "IDVAL1": {
+                                               Labels: []VocabularyLabel{{Label: "High"}, {Label: "High priority"}},
+                                       },
+                               },
+                       },
+                       "IDTAGCOMMENT": {
+                               Strict: false,
+                               Labels: []VocabularyLabel{{Label: "Comment"}},
+                       },
+               },
+       }
+       err := s.testVoc.validate()
+       c.Assert(err, check.IsNil)
+}
+
+func (s *VocabularySuite) TestCheck(c *check.C) {
+       tests := []struct {
+               name          string
+               strictVoc     bool
+               props         string
+               expectSuccess bool
+               errMatches    string
+       }{
+               // Check succeeds
+               {
+                       "Known key, known value",
+                       false,
+                       `{"IDTAGANIMALS":"IDVALANIMAL1"}`,
+                       true,
+                       "",
+               },
+               {
+                       "Unknown non-alias key on non-strict vocabulary",
+                       false,
+                       `{"foo":"bar"}`,
+                       true,
+                       "",
+               },
+               {
+                       "Known non-strict key, unknown non-alias value",
+                       false,
+                       `{"IDTAGANIMALS":"IDVALANIMAL3"}`,
+                       true,
+                       "",
+               },
+               {
+                       "Undefined but reserved key on strict vocabulary",
+                       true,
+                       `{"reservedKey":"bar"}`,
+                       true,
+                       "",
+               },
+               {
+                       "Known key, list of known values",
+                       false,
+                       `{"IDTAGANIMALS":["IDVALANIMAL1","IDVALANIMAL2"]}`,
+                       true,
+                       "",
+               },
+               {
+                       "Known non-strict key, list of unknown non-alias values",
+                       false,
+                       `{"IDTAGCOMMENT":["hello world","lorem ipsum"]}`,
+                       true,
+                       "",
+               },
+               // Check fails
+               {
+                       "Known first key & value; known 2nd key, unknown 2nd value",
+                       false,
+                       `{"IDTAGANIMALS":"IDVALANIMAL1", "IDTAGIMPORTANCE": "blah blah"}`,
+                       false,
+                       "tag value.*is not valid for key.*",
+               },
+               {
+                       "Unknown non-alias key on strict vocabulary",
+                       true,
+                       `{"foo":"bar"}`,
+                       false,
+                       "tag key.*is not defined in the vocabulary",
+               },
+               {
+                       "Known non-strict key, known value alias",
+                       false,
+                       `{"IDTAGANIMALS":"Loxodonta"}`,
+                       false,
+                       "tag value.*for key.* is an alias, must be provided as.*",
+               },
+               {
+                       "Known strict key, unknown non-alias value",
+                       false,
+                       `{"IDTAGIMPORTANCE":"Unimportant"}`,
+                       false,
+                       "tag value.*is not valid for key.*",
+               },
+               {
+                       "Known strict key, lowercase value regarded as alias",
+                       false,
+                       `{"IDTAGIMPORTANCE":"idval1"}`,
+                       false,
+                       "tag value.*for key.* is an alias, must be provided as.*",
+               },
+               {
+                       "Known strict key, known value alias",
+                       false,
+                       `{"IDTAGIMPORTANCE":"High"}`,
+                       false,
+                       "tag value.* for key.*is an alias, must be provided as.*",
+               },
+               {
+                       "Known strict key, list of known alias values",
+                       false,
+                       `{"IDTAGIMPORTANCE":["High", "Low"]}`,
+                       false,
+                       "tag value.*for key.*is an alias, must be provided as.*",
+               },
+               {
+                       "Known strict key, list of unknown non-alias values",
+                       false,
+                       `{"IDTAGIMPORTANCE":["foo","bar"]}`,
+                       false,
+                       "tag value.*is not valid for key.*",
+               },
+               {
+                       "Invalid value type",
+                       false,
+                       `{"IDTAGANIMALS":1}`,
+                       false,
+                       "value type for tag key.* was.*, but expected a string or list of strings",
+               },
+               {
+                       "Value list of invalid type",
+                       false,
+                       `{"IDTAGANIMALS":[1]}`,
+                       false,
+                       "value list element type for tag key.* was.*, but expected a string",
+               },
+       }
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+               s.testVoc.StrictTags = tt.strictVoc
+
+               var data map[string]interface{}
+               err := json.Unmarshal([]byte(tt.props), &data)
+               c.Assert(err, check.IsNil)
+               err = s.testVoc.Check(data)
+               if tt.expectSuccess {
+                       c.Assert(err, check.IsNil)
+               } else {
+                       c.Assert(err, check.NotNil)
+                       c.Assert(err.Error(), check.Matches, tt.errMatches)
+               }
+       }
+}
+
+func (s *VocabularySuite) TestNewVocabulary(c *check.C) {
+       tests := []struct {
+               name       string
+               data       string
+               isValid    bool
+               errMatches string
+               expect     *Vocabulary
+       }{
+               {"Empty data", "", true, "", &Vocabulary{}},
+               {"Invalid JSON", "foo", false, "invalid JSON format.*", nil},
+               {"Valid, empty JSON", "{}", false, ".*doesn't match Vocabulary format.*", nil},
+               {"Valid JSON, wrong data", `{"foo":"bar"}`, false, ".*doesn't match Vocabulary format.*", nil},
+               {
+                       "Simple valid example",
+                       `{"tags":{
+                               "IDTAGANIMALS":{
+                                       "strict": false,
+                                       "labels": [{"label": "Animal"}, {"label": "Creature"}],
+                                       "values": {
+                                               "IDVALANIMAL1":{"labels":[{"label":"Human"}, {"label":"Homo sapiens"}]},
+                                               "IDVALANIMAL2":{"labels":[{"label":"Elephant"}, {"label":"Loxodonta"}]},
+                                               "DOG":{"labels":[{"label":"Dog"}, {"label":"Canis lupus familiaris"}, {"label":"dOg"}]}
+                                       }
+                               }
+                       }}`,
+                       true, "",
+                       &Vocabulary{
+                               reservedTagKeys: map[string]bool{
+                                       "type":                  true,
+                                       "template_uuid":         true,
+                                       "groups":                true,
+                                       "username":              true,
+                                       "image_timestamp":       true,
+                                       "docker-image-repo-tag": true,
+                                       "filters":               true,
+                                       "container_request":     true,
+                               },
+                               StrictTags: false,
+                               Tags: map[string]VocabularyTag{
+                                       "IDTAGANIMALS": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Animal"}, {Label: "Creature"}},
+                                               Values: map[string]VocabularyTagValue{
+                                                       "IDVALANIMAL1": {
+                                                               Labels: []VocabularyLabel{{Label: "Human"}, {Label: "Homo sapiens"}},
+                                                       },
+                                                       "IDVALANIMAL2": {
+                                                               Labels: []VocabularyLabel{{Label: "Elephant"}, {Label: "Loxodonta"}},
+                                                       },
+                                                       "DOG": {
+                                                               Labels: []VocabularyLabel{{Label: "Dog"}, {Label: "Canis lupus familiaris"}, {Label: "dOg"}},
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+               },
+               {
+                       "Valid data, but uses reserved key",
+                       `{"tags":{
+                               "type":{
+                                       "strict": false,
+                                       "labels": [{"label": "Type"}]
+                               }
+                       }}`,
+                       false, "tag key.*is reserved", nil,
+               },
+       }
+
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+               voc, err := NewVocabulary([]byte(tt.data), []string{})
+               if tt.isValid {
+                       c.Assert(err, check.IsNil)
+               } else {
+                       c.Assert(err, check.NotNil)
+                       if tt.errMatches != "" {
+                               c.Assert(err, check.ErrorMatches, tt.errMatches)
+                       }
+               }
+               c.Assert(voc, check.DeepEquals, tt.expect)
+       }
+}
+
+func (s *VocabularySuite) TestValidationErrors(c *check.C) {
+       tests := []struct {
+               name       string
+               voc        *Vocabulary
+               errMatches string
+       }{
+               {
+                       "Strict vocabulary, no keys",
+                       &Vocabulary{
+                               StrictTags: true,
+                       },
+                       "vocabulary is strict but no tags are defined",
+               },
+               {
+                       "Collision between tag key and tag key label",
+                       &Vocabulary{
+                               StrictTags: false,
+                               Tags: map[string]VocabularyTag{
+                                       "IDTAGANIMALS": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Animal"}, {Label: "Creature"}},
+                                       },
+                                       "IDTAGCOMMENT": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Comment"}, {Label: "IDTAGANIMALS"}},
+                                       },
+                               },
+                       },
+                       "", // Depending on how the map is sorted, this could be one of two errors
+               },
+               {
+                       "Collision between tag key and tag key label (case-insensitive)",
+                       &Vocabulary{
+                               StrictTags: false,
+                               Tags: map[string]VocabularyTag{
+                                       "IDTAGANIMALS": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Animal"}, {Label: "Creature"}},
+                                       },
+                                       "IDTAGCOMMENT": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Comment"}, {Label: "IdTagAnimals"}},
+                                       },
+                               },
+                       },
+                       "", // Depending on how the map is sorted, this could be one of two errors
+               },
+               {
+                       "Collision between tag key labels",
+                       &Vocabulary{
+                               StrictTags: false,
+                               Tags: map[string]VocabularyTag{
+                                       "IDTAGANIMALS": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Animal"}, {Label: "Creature"}},
+                                       },
+                                       "IDTAGCOMMENT": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Comment"}, {Label: "Animal"}},
+                                       },
+                               },
+                       },
+                       "tag label.*for key.*already seen.*",
+               },
+               {
+                       "Collision between tag value and tag value label",
+                       &Vocabulary{
+                               StrictTags: false,
+                               Tags: map[string]VocabularyTag{
+                                       "IDTAGANIMALS": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Animal"}, {Label: "Creature"}},
+                                               Values: map[string]VocabularyTagValue{
+                                                       "IDVALANIMAL1": {
+                                                               Labels: []VocabularyLabel{{Label: "Human"}, {Label: "Mammal"}},
+                                                       },
+                                                       "IDVALANIMAL2": {
+                                                               Labels: []VocabularyLabel{{Label: "Elephant"}, {Label: "IDVALANIMAL1"}},
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       "", // Depending on how the map is sorted, this could be one of two errors
+               },
+               {
+                       "Collision between tag value and tag value label (case-insensitive)",
+                       &Vocabulary{
+                               StrictTags: false,
+                               Tags: map[string]VocabularyTag{
+                                       "IDTAGANIMALS": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Animal"}, {Label: "Creature"}},
+                                               Values: map[string]VocabularyTagValue{
+                                                       "IDVALANIMAL1": {
+                                                               Labels: []VocabularyLabel{{Label: "Human"}, {Label: "Mammal"}},
+                                                       },
+                                                       "IDVALANIMAL2": {
+                                                               Labels: []VocabularyLabel{{Label: "Elephant"}, {Label: "IDValAnimal1"}},
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       "", // Depending on how the map is sorted, this could be one of two errors
+               },
+               {
+                       "Collision between tag value labels",
+                       &Vocabulary{
+                               StrictTags: false,
+                               Tags: map[string]VocabularyTag{
+                                       "IDTAGANIMALS": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Animal"}, {Label: "Creature"}},
+                                               Values: map[string]VocabularyTagValue{
+                                                       "IDVALANIMAL1": {
+                                                               Labels: []VocabularyLabel{{Label: "Human"}, {Label: "Mammal"}},
+                                                       },
+                                                       "IDVALANIMAL2": {
+                                                               Labels: []VocabularyLabel{{Label: "Elephant"}, {Label: "Mammal"}},
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       "tag value label.*for pair.*already seen.*on value.*",
+               },
+               {
+                       "Collision between tag value labels (case-insensitive)",
+                       &Vocabulary{
+                               StrictTags: false,
+                               Tags: map[string]VocabularyTag{
+                                       "IDTAGANIMALS": {
+                                               Strict: false,
+                                               Labels: []VocabularyLabel{{Label: "Animal"}, {Label: "Creature"}},
+                                               Values: map[string]VocabularyTagValue{
+                                                       "IDVALANIMAL1": {
+                                                               Labels: []VocabularyLabel{{Label: "Human"}, {Label: "Mammal"}},
+                                                       },
+                                                       "IDVALANIMAL2": {
+                                                               Labels: []VocabularyLabel{{Label: "Elephant"}, {Label: "mAMMAL"}},
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       "tag value label.*for pair.*already seen.*on value.*",
+               },
+               {
+                       "Strict tag key, with no values",
+                       &Vocabulary{
+                               StrictTags: false,
+                               Tags: map[string]VocabularyTag{
+                                       "IDTAGANIMALS": {
+                                               Strict: true,
+                                               Labels: []VocabularyLabel{{Label: "Animal"}, {Label: "Creature"}},
+                                       },
+                               },
+                       },
+                       "tag key.*is configured as strict but doesn't provide values",
+               },
+       }
+       for _, tt := range tests {
+               c.Log(c.TestName()+" ", tt.name)
+               err := tt.voc.validate()
+               c.Assert(err, check.NotNil)
+               if tt.errMatches != "" {
+                       c.Assert(err, check.ErrorMatches, tt.errMatches)
+               }
+       }
+}
index 8bf01693c444100cb1b866b796e03c5c7699f5ed..0af477125b737a65f1fad46fce3009f5e27d1bcd 100644 (file)
@@ -33,6 +33,10 @@ func (as *APIStub) ConfigGet(ctx context.Context) (json.RawMessage, error) {
        as.appendCall(ctx, as.ConfigGet, nil)
        return nil, as.Error
 }
+func (as *APIStub) VocabularyGet(ctx context.Context) (arvados.Vocabulary, error) {
+       as.appendCall(ctx, as.VocabularyGet, nil)
+       return arvados.Vocabulary{}, as.Error
+}
 func (as *APIStub) Login(ctx context.Context, options arvados.LoginOptions) (arvados.LoginResponse, error) {
        as.appendCall(ctx, as.Login, options)
        return arvados.LoginResponse{}, as.Error
@@ -165,6 +169,26 @@ func (as *APIStub) GroupUntrash(ctx context.Context, options arvados.UntrashOpti
        as.appendCall(ctx, as.GroupUntrash, options)
        return arvados.Group{}, as.Error
 }
+func (as *APIStub) LinkCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Link, error) {
+       as.appendCall(ctx, as.LinkCreate, options)
+       return arvados.Link{}, as.Error
+}
+func (as *APIStub) LinkUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Link, error) {
+       as.appendCall(ctx, as.LinkUpdate, options)
+       return arvados.Link{}, as.Error
+}
+func (as *APIStub) LinkGet(ctx context.Context, options arvados.GetOptions) (arvados.Link, error) {
+       as.appendCall(ctx, as.LinkGet, options)
+       return arvados.Link{}, as.Error
+}
+func (as *APIStub) LinkList(ctx context.Context, options arvados.ListOptions) (arvados.LinkList, error) {
+       as.appendCall(ctx, as.LinkList, options)
+       return arvados.LinkList{}, as.Error
+}
+func (as *APIStub) LinkDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Link, error) {
+       as.appendCall(ctx, as.LinkDelete, options)
+       return arvados.Link{}, as.Error
+}
 func (as *APIStub) SpecimenCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Specimen, error) {
        as.appendCall(ctx, as.SpecimenCreate, options)
        return arvados.Specimen{}, as.Error