[ARVADOS] created: 2.1.0-1581-ge17da836b

Git user git at public.arvados.org
Wed Nov 3 21:14:16 UTC 2021


        at  e17da836bf89535ed00f785bce789625a06e655c (commit)


commit e17da836bf89535ed00f785bce789625a06e655c
Author: Lucas Di Pentima <lucas.dipentima at curii.com>
Date:   Wed Nov 3 12:55:44 2021 -0300

    17944: Adds vocabulary checking support to links.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima at curii.com>

diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
index 972941622..d47730352 100644
--- a/lib/controller/federation/conn.go
+++ b/lib/controller/federation/conn.go
@@ -469,6 +469,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)
 }
diff --git a/lib/controller/federation/generate.go b/lib/controller/federation/generate.go
index 06a5ce12d..b49e138ce 100644
--- a/lib/controller/federation/generate.go
+++ b/lib/controller/federation/generate.go
@@ -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)
diff --git a/lib/controller/federation/generated.go b/lib/controller/federation/generated.go
index 49a2e5b75..e8a5a08ff 100755
--- a/lib/controller/federation/generated.go
+++ b/lib/controller/federation/generated.go
@@ -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
+}
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index 638591307..358b0ed0c 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -123,6 +123,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)
 
diff --git a/lib/controller/handler_test.go b/lib/controller/handler_test.go
index 728c760af..ac27b8ea5 100644
--- a/lib/controller/handler_test.go
+++ b/lib/controller/handler_test.go
@@ -343,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)
 
diff --git a/lib/controller/localdb/link.go b/lib/controller/localdb/link.go
new file mode 100644
index 000000000..cfcae3d38
--- /dev/null
+++ b/lib/controller/localdb/link.go
@@ -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
index 000000000..05bd47366
--- /dev/null
+++ b/lib/controller/localdb/link_test.go
@@ -0,0 +1,143 @@
+// 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)
+	c.Assert(voc.Validate(), 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)
+		}
+	}
+}
diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index d04eccf68..02e06279f 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -314,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{} },
diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
index 1acddfab7..25f47bc3b 100644
--- a/lib/controller/rpc/conn.go
+++ b/lib/controller/rpc/conn.go
@@ -502,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
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 41727beea..0fdc13d19 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -63,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", ""}
@@ -254,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)
diff --git a/sdk/go/arvados/link.go b/sdk/go/arvados/link.go
index f7d1f35a3..7df6b84d6 100644
--- a/sdk/go/arvados/link.go
+++ b/sdk/go/arvados/link.go
@@ -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.

commit 1cd689f0355b29be7d0e4b316369eed5c228f92a
Author: Lucas Di Pentima <lucas.dipentima at curii.com>
Date:   Tue Nov 2 17:03:49 2021 -0300

    17944: Makes returning an empty Vocabulary also include an empty "tags".
    
    ...instead of returning "tags":null which makes workbench2's code to fail.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima at curii.com>

diff --git a/lib/controller/localdb/conn.go b/lib/controller/localdb/conn.go
index edbbcb09c..9d1aa5362 100644
--- a/lib/controller/localdb/conn.go
+++ b/lib/controller/localdb/conn.go
@@ -126,7 +126,9 @@ func (conn *Conn) loadVocabularyFile() error {
 // 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{}, nil
+		return arvados.Vocabulary{
+			Tags: map[string]arvados.VocabularyTag{},
+		}, nil
 	}
 	logger := ctxlog.FromContext(ctx)
 	if conn.vocabularyCache == nil {
@@ -134,7 +136,9 @@ func (conn *Conn) VocabularyGet(ctx context.Context) (arvados.Vocabulary, error)
 		err := conn.loadVocabularyFile()
 		if err != nil {
 			logger.WithError(err).Error("error loading vocabulary file")
-			return arvados.Vocabulary{}, err
+			return arvados.Vocabulary{
+				Tags: map[string]arvados.VocabularyTag{},
+			}, err
 		}
 		go watchVocabulary(logger, conn.cluster.API.VocabularyPath, func() {
 			logger.Info("vocabulary file changed, it'll be reloaded next time it's needed")

commit 88a28473710c28f36cf8fab178697dd5e0d7a6ac
Author: Lucas Di Pentima <lucas.dipentima at curii.com>
Date:   Mon Nov 1 17:12:14 2021 -0300

    17944: Forces vocabulary checking at startup time.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima at curii.com>

diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index 51c72b282..638591307 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -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{} {

commit 7e85063ebffe3a6d990fb0b2eac62b1906660a21
Author: Lucas Di Pentima <lucas.dipentima at curii.com>
Date:   Mon Nov 1 16:02:20 2021 -0300

    17944: Vocabulary check errors return status 400 instead of 500.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima at curii.com>

diff --git a/lib/controller/handler_test.go b/lib/controller/handler_test.go
index c99faba73..728c760af 100644
--- a/lib/controller/handler_test.go
+++ b/lib/controller/handler_test.go
@@ -138,6 +138,54 @@ func (s *HandlerSuite) TestVocabularyExport(c *check.C) {
 	}
 }
 
+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.*for key.*is not listed as valid.*`)
+}
+
 func (s *HandlerSuite) TestProxyDiscoveryDoc(c *check.C) {
 	req := httptest.NewRequest("GET", "/discovery/v1/apis/arvados/v1/rest", nil)
 	resp := httptest.NewRecorder()
diff --git a/lib/controller/localdb/conn.go b/lib/controller/localdb/conn.go
index 0fae35e7d..edbbcb09c 100644
--- a/lib/controller/localdb/conn.go
+++ b/lib/controller/localdb/conn.go
@@ -8,6 +8,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"net/http"
 	"os"
 	"strings"
 
@@ -15,6 +16,7 @@ import (
 	"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/fsnotify/fsnotify"
 	"github.com/sirupsen/logrus"
 )
@@ -60,7 +62,11 @@ func (conn *Conn) checkProperties(ctx context.Context, properties interface{}) e
 	if err != nil {
 		return err
 	}
-	return voc.Check(props)
+	err = voc.Check(props)
+	if err != nil {
+		return httpErrorf(http.StatusBadRequest, voc.Check(props).Error())
+	}
+	return nil
 }
 
 func watchVocabulary(logger logrus.FieldLogger, vocPath string, fn func()) {
@@ -209,3 +215,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)
+}

commit d98a3ad49469f33e01e35776afff55e1452a2321
Author: Lucas Di Pentima <lucas.dipentima at curii.com>
Date:   Mon Oct 25 13:05:31 2021 -0300

    17944: Vocabulary loading, monitoring and checking on several object types.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima at curii.com>

diff --git a/doc/_includes/_metadata_vocabulary_example.liquid b/doc/_includes/_metadata_vocabulary_example.liquid
index 016b48c6a..fb8e57725 100644
--- a/doc/_includes/_metadata_vocabulary_example.liquid
+++ b/doc/_includes/_metadata_vocabulary_example.liquid
@@ -2,9 +2,7 @@
 Copyright (C) The Arvados Authors. All rights reserved.
 
 SPDX-License-Identifier: CC-BY-SA-3.0
-{% endcomment %}
-
-{
+{% endcomment %}{
     "strict_tags": false,
     "tags": {
         "IDTAGANIMALS": {
diff --git a/lib/controller/federation.go b/lib/controller/federation.go
index 144d41c21..cd69727ec 100644
--- a/lib/controller/federation.go
+++ b/lib/controller/federation.go
@@ -121,8 +121,6 @@ func (h *Handler) setupProxyRemoteCluster(next http.Handler) http.Handler {
 
 		mux.ServeHTTP(w, req)
 	})
-
-	return mux
 }
 
 type CurrentUser struct {
diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
index aa05cb1e6..972941622 100644
--- a/lib/controller/federation/conn.go
+++ b/lib/controller/federation/conn.go
@@ -192,6 +192,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
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index a35d00301..51c72b282 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -97,6 +97,7 @@ func (h *Handler) setup() {
 		WrapCalls:      api.ComposeWrappers(ctrlctx.WrapCallsInTransactions(h.db), oidcAuthorizer.WrapCalls),
 	})
 	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)
diff --git a/lib/controller/handler_test.go b/lib/controller/handler_test.go
index 9b71c349a..c99faba73 100644
--- a/lib/controller/handler_test.go
+++ b/lib/controller/handler_test.go
@@ -88,6 +88,56 @@ 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) TestProxyDiscoveryDoc(c *check.C) {
 	req := httptest.NewRequest("GET", "/discovery/v1/apis/arvados/v1/rest", nil)
 	resp := httptest.NewRecorder()
diff --git a/lib/controller/localdb/collection.go b/lib/controller/localdb/collection.go
index d81dd812b..96c89252e 100644
--- a/lib/controller/localdb/collection.go
+++ b/lib/controller/localdb/collection.go
@@ -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
diff --git a/lib/controller/localdb/collection_test.go b/lib/controller/localdb/collection_test.go
index 4a4494964..ae996d27b 100644
--- a/lib/controller/localdb/collection_test.go
+++ b/lib/controller/localdb/collection_test.go
@@ -48,6 +48,94 @@ 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)
+	c.Assert(voc.Validate(), 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}})
 
diff --git a/lib/controller/localdb/conn.go b/lib/controller/localdb/conn.go
index a90deded5..0fae35e7d 100644
--- a/lib/controller/localdb/conn.go
+++ b/lib/controller/localdb/conn.go
@@ -6,27 +6,33 @@ package localdb
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
+	"os"
 	"strings"
 
 	"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"
+	"github.com/fsnotify/fsnotify"
+	"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
+	reloadVocabulary bool
 	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 +40,113 @@ 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
+	}
+	return voc.Check(props)
+}
+
+func watchVocabulary(logger logrus.FieldLogger, vocPath string, fn func()) {
+	watcher, err := fsnotify.NewWatcher()
+	if err != nil {
+		logger.WithError(err).Error("vocabulary fsnotify setup failed")
+		return
+	}
+	defer watcher.Close()
+
+	err = watcher.Add(vocPath)
+	if err != nil {
+		logger.WithError(err).Error("vocabulary file watcher failed")
+		return
+	}
+
+	for {
+		select {
+		case err, ok := <-watcher.Errors:
+			if !ok {
+				return
+			}
+			logger.WithError(err).Warn("vocabulary file watcher error")
+		case _, ok := <-watcher.Events:
+			if !ok {
+				return
+			}
+			for len(watcher.Events) > 0 {
+				<-watcher.Events
+			}
+			fn()
+		}
+	}
+}
+
+func (conn *Conn) loadVocabularyFile() error {
+	vf, err := os.ReadFile(conn.cluster.API.VocabularyPath)
+	if err != nil {
+		return fmt.Errorf("couldn't read vocabulary file %q: %v", conn.cluster.API.VocabularyPath, 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)
+	}
+	err = voc.Validate()
+	if err != nil {
+		return fmt.Errorf("while validating vocabulary file %q: %s", conn.cluster.API.VocabularyPath, err)
+	}
+	conn.vocabularyCache = voc
+	return nil
+}
+
+// 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{}, 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
+		}
+		go watchVocabulary(logger, conn.cluster.API.VocabularyPath, func() {
+			logger.Info("vocabulary file changed, it'll be reloaded next time it's needed")
+			conn.reloadVocabulary = true
+		})
+	} else if conn.reloadVocabulary {
+		// Requested reload of vocabulary file.
+		conn.reloadVocabulary = false
+		err := conn.loadVocabularyFile()
+		if err != nil {
+			logger.WithError(err).Error("error reloading vocabulary file - ignoring")
+		} else {
+			logger.Info("vocabulary file reloaded successfully")
+		}
+	}
+	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)
diff --git a/lib/controller/localdb/container_request.go b/lib/controller/localdb/container_request.go
new file mode 100644
index 000000000..5b2ce95da
--- /dev/null
+++ b/lib/controller/localdb/container_request.go
@@ -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
index 000000000..c231e3ca7
--- /dev/null
+++ b/lib/controller/localdb/container_request_test.go
@@ -0,0 +1,167 @@
+// 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)
+	c.Assert(voc.Validate(), 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
index 000000000..0d77bdbd9
--- /dev/null
+++ b/lib/controller/localdb/group.go
@@ -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
index 000000000..0991f3b72
--- /dev/null
+++ b/lib/controller/localdb/group_test.go
@@ -0,0 +1,139 @@
+// 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)
+	c.Assert(voc.Validate(), 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/router/router.go b/lib/controller/router/router.go
index 9826c1e74..d04eccf68 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -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{} },
diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
index 640bbf1c2..1acddfab7 100644
--- a/lib/controller/rpc/conn.go
+++ b/lib/controller/rpc/conn.go
@@ -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
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index b429e8008..41727beea 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -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"}
@@ -219,6 +220,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)
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index 2df0b9057..8755bbd3e 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -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
@@ -110,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
diff --git a/sdk/go/arvados/vocabulary.go b/sdk/go/arvados/vocabulary.go
new file mode 100644
index 000000000..cb1106e9b
--- /dev/null
+++ b/sdk/go/arvados/vocabulary.go
@@ -0,0 +1,209 @@
+// 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"`
+}
+
+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]bool{}
+	// Checks for Vocabulary strictness
+	if v.StrictTags && len(v.Tags) == 0 {
+		return fmt.Errorf("vocabulary is strict but no tags are defined")
+	}
+	// Checks for duplicate tag keys
+	for key := range v.Tags {
+		if v.reservedTagKeys[key] {
+			return fmt.Errorf("tag key %q is reserved", key)
+		}
+		if tagKeys[key] {
+			return fmt.Errorf("duplicate tag key %q", key)
+		}
+		tagKeys[key] = true
+		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", label, key)
+			}
+			tagKeys[label] = true
+		}
+		// 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 value duplication within a key
+		tagValues := map[string]bool{}
+		for val := range v.Tags[key].Values {
+			if tagValues[val] {
+				return fmt.Errorf("duplicate tag value %q for tag %q", val, key)
+			}
+			tagValues[val] = true
+			for _, tagLbl := range v.Tags[key].Values[val].Labels {
+				label := strings.ToLower(tagLbl.Label)
+				if tagValues[label] {
+					return fmt.Errorf("tag value label %q for pair (%q:%q) already seen as a value key or label", label, key, val)
+				}
+				tagValues[label] = true
+			}
+		}
+	}
+	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 {
+			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)
+		alias, ok := v.getLabelsToValues(key)[lcVal]
+		if ok {
+			return fmt.Errorf("tag value %q for key %q is not defined but is an alias for %q", val, key, alias)
+		} else if v.Tags[key].Strict {
+			return fmt.Errorf("tag value %q for key %q is not listed as valid", 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)
+			alias, ok := v.getLabelsToKeys()[lcKey]
+			if ok {
+				return fmt.Errorf("tag key %q is not defined but is an alias for %q", key, alias)
+			} else if v.StrictTags {
+				return fmt.Errorf("tag key %q is not defined", 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:
+			return v.checkValue(key, val)
+		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("tag value %q for key %q is not a valid type (%T)", singleVal, key, singleVal)
+				}
+			}
+		default:
+			return fmt.Errorf("tag value %q for key %q is not a valid type (%T)", val, key, val)
+		}
+	}
+	return nil
+}
diff --git a/sdk/go/arvados/vocabulary_test.go b/sdk/go/arvados/vocabulary_test.go
new file mode 100644
index 000000000..b2748c7be
--- /dev/null
+++ b/sdk/go/arvados/vocabulary_test.go
@@ -0,0 +1,252 @@
+// 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
+	}{
+		// 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
+		{"Unknown non-alias key on strict vocabulary", true, `{"foo":"bar"}`, false},
+		{"Known non-strict key, known value alias", false, `{"IDTAGANIMALS":"Loxodonta"}`, false},
+		{"Known strict key, unknown non-alias value", false, `{"IDTAGIMPORTANCE":"Unimportant"}`, false},
+		{"Known strict key, known value alias", false, `{"IDTAGIMPORTANCE":"High"}`, false},
+		{"Known strict key, list of known alias values", false, `{"IDTAGIMPORTANCE":["Unimportant","High"]}`, false},
+		{"Known strict key, list of unknown non-alias values", false, `{"IDTAGIMPORTANCE":["foo","bar"]}`, false},
+	}
+	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)
+		}
+	}
+}
+
+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"}]}
+					}
+				}
+			}}`,
+			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"}},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			"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",
+		},
+		{
+			"Duplicated tag keys",
+			&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.*",
+		},
+		{
+			"Duplicated tag values",
+			&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.*",
+		},
+		{
+			"Strict key, 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)
+		c.Assert(err, check.ErrorMatches, tt.errMatches)
+	}
+}
diff --git a/sdk/go/arvadostest/api.go b/sdk/go/arvadostest/api.go
index 8bf01693c..2cb35366c 100644
--- a/sdk/go/arvadostest/api.go
+++ b/sdk/go/arvadostest/api.go
@@ -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

commit 7dcbe06182de70d158835e400d61fa026eac6384
Author: Lucas Di Pentima <lucas.dipentima at curii.com>
Date:   Thu Oct 21 15:16:14 2021 -0300

    17944: Updates config knobs and documentation.
    
    Workbench.VocabularyURL will be auto-exported providing the new vocabulary
    endpoint, for backwards compatibility.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima at curii.com>

diff --git a/doc/_config.yml b/doc/_config.yml
index 31db9c41d..dde87323d 100644
--- a/doc/_config.yml
+++ b/doc/_config.yml
@@ -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
diff --git a/doc/_includes/_wb2_vocabulary_example.liquid b/doc/_includes/_metadata_vocabulary_example.liquid
similarity index 90%
rename from doc/_includes/_wb2_vocabulary_example.liquid
rename to doc/_includes/_metadata_vocabulary_example.liquid
index ee2ac97ef..016b48c6a 100644
--- a/doc/_includes/_wb2_vocabulary_example.liquid
+++ b/doc/_includes/_metadata_vocabulary_example.liquid
@@ -1,3 +1,9 @@
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
 {
     "strict_tags": false,
     "tags": {
diff --git a/doc/admin/workbench2-vocabulary.html.textile.liquid b/doc/admin/metadata-vocabulary.html.textile.liquid
similarity index 79%
rename from doc/admin/workbench2-vocabulary.html.textile.liquid
rename to doc/admin/metadata-vocabulary.html.textile.liquid
index 9a8d7fcd0..e615fa3c8 100644
--- a/doc/admin/workbench2-vocabulary.html.textile.liquid
+++ b/doc/admin/metadata-vocabulary.html.textile.liquid
@@ -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,10 @@ 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)
+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.
 
diff --git a/doc/admin/upgrading.html.textile.liquid b/doc/admin/upgrading.html.textile.liquid
index 0aea90bd0..c4299746e 100644
--- a/doc/admin/upgrading.html.textile.liquid
+++ b/doc/admin/upgrading.html.textile.liquid
@@ -292,7 +292,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
 
diff --git a/doc/install/install-workbench2-app.html.textile.liquid b/doc/install/install-workbench2-app.html.textile.liquid
index f3a320b10..c9a1c7012 100644
--- a/doc/install/install-workbench2-app.html.textile.liquid
+++ b/doc/install/install-workbench2-app.html.textile.liquid
@@ -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' %}
 
diff --git a/lib/config/config.default.yml b/lib/config/config.default.yml
index 97ded6bf6..378690ad5 100644
--- a/lib/config/config.default.yml
+++ b/lib/config/config.default.yml
@@ -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.
diff --git a/lib/config/export.go b/lib/config/export.go
index e36d6e76c..fe8d45509 100644
--- a/lib/config/export.go
+++ b/lib/config/export.go
@@ -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,
 }
 
diff --git a/lib/config/generated_config.go b/lib/config/generated_config.go
index f7849d614..a1bb2330d 100644
--- a/lib/config/generated_config.go
+++ b/lib/config/generated_config.go
@@ -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.
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index e736f79fd..2df0b9057 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -102,6 +102,7 @@ type Cluster struct {
 		WebsocketClientEventQueue      int
 		WebsocketServerEventQueue      int
 		KeepServiceRequestTimeout      Duration
+		VocabularyPath                 string
 	}
 	AuditLogs struct {
 		MaxAge             Duration
@@ -273,7 +274,6 @@ type Cluster struct {
 			Options              map[string]struct{}
 		}
 		UserProfileFormMessage string
-		VocabularyURL          string
 		WelcomePageHTML        string
 		InactivePageHTML       string
 		SSHHelpPageHTML        string

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list