[ARVADOS] created: 1.3.0-1870-gf809da6bf

Git user git at public.curoverse.com
Mon Nov 18 14:56:22 UTC 2019


        at  f809da6bf23e5bc0eb6d0b47e5d8efee13670e07 (commit)


commit f809da6bf23e5bc0eb6d0b47e5d8efee13670e07
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon Nov 18 09:56:06 2019 -0500

    15720: Defer user listing to login cluster, cache results locally.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
index 9d01f1f7c..47ae0ba9e 100644
--- a/lib/controller/federation/conn.go
+++ b/lib/controller/federation/conn.go
@@ -323,8 +323,82 @@ func (conn *Conn) SpecimenDelete(ctx context.Context, options arvados.DeleteOpti
 	return conn.chooseBackend(options.UUID).SpecimenDelete(ctx, options)
 }
 
+var userAttrsCachedFromLoginCluster = map[string]bool{
+	"created_at":              true,
+	"email":                   true,
+	"first_name":              true,
+	"is_active":               true,
+	"is_admin":                true,
+	"last_name":               true,
+	"modified_at":             true,
+	"modified_by_client_uuid": true,
+	"modified_by_user_uuid":   true,
+	"prefs":                   true,
+	"username":                true,
+
+	"full_name":    false,
+	"identity_url": false,
+	"is_invited":   false,
+	"owner_uuid":   false,
+	"uuid":         false,
+}
+
 func (conn *Conn) UserList(ctx context.Context, options arvados.ListOptions) (arvados.UserList, error) {
-	return conn.generated_UserList(ctx, options)
+	logger := ctxlog.FromContext(ctx)
+	if id := conn.cluster.Login.LoginCluster; id != "" && id != conn.cluster.ClusterID {
+		resp, err := conn.chooseBackend(id).UserList(ctx, options)
+		if err != nil {
+			return resp, err
+		}
+		ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{conn.cluster.SystemRootToken}})
+		for _, user := range resp.Items {
+			if !strings.HasPrefix(user.UUID, id) {
+				continue
+			}
+			logger.Debug("cache user info for uuid %q", user.UUID)
+			var allFields map[string]interface{}
+			buf, err := json.Marshal(user)
+			if err != nil {
+				return arvados.UserList{}, fmt.Errorf("error encoding user record from remote response: %s", err)
+			}
+			err = json.Unmarshal(buf, &allFields)
+			if err != nil {
+				return arvados.UserList{}, fmt.Errorf("error transcoding user record from remote response: %s", err)
+			}
+			updates := allFields
+			if len(options.Select) > 0 {
+				updates = map[string]interface{}{}
+				for _, k := range options.Select {
+					if v, ok := allFields[k]; ok && userAttrsCachedFromLoginCluster[k] {
+						updates[k] = v
+					}
+				}
+			} else {
+				for k := range updates {
+					if !userAttrsCachedFromLoginCluster[k] {
+						delete(updates, k)
+					}
+				}
+			}
+			_, err = conn.local.UserUpdate(ctxRoot, arvados.UpdateOptions{
+				UUID:  user.UUID,
+				Attrs: updates,
+			})
+			if errStatus(err) == http.StatusNotFound {
+				updates["uuid"] = user.UUID
+				_, err = conn.local.UserCreate(ctxRoot, arvados.CreateOptions{
+					Attrs: updates,
+				})
+			}
+			if err != nil {
+				logger.WithError(err).WithField("UUID", user.UUID).Error("error updating local user record")
+				return arvados.UserList{}, fmt.Errorf("error updating local user record: %s", err)
+			}
+		}
+		return resp, nil
+	} else {
+		return conn.generated_UserList(ctx, options)
+	}
 }
 
 func (conn *Conn) UserCreate(ctx context.Context, options arvados.CreateOptions) (arvados.User, error) {
diff --git a/lib/controller/federation/federation_test.go b/lib/controller/federation/federation_test.go
new file mode 100644
index 000000000..60164b462
--- /dev/null
+++ b/lib/controller/federation/federation_test.go
@@ -0,0 +1,75 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package federation
+
+import (
+	"context"
+	"net/url"
+	"os"
+	"testing"
+
+	"git.curoverse.com/arvados.git/lib/controller/router"
+	"git.curoverse.com/arvados.git/lib/controller/rpc"
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
+	"git.curoverse.com/arvados.git/sdk/go/auth"
+	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
+	"git.curoverse.com/arvados.git/sdk/go/httpserver"
+	check "gopkg.in/check.v1"
+)
+
+// Gocheck boilerplate
+func Test(t *testing.T) {
+	check.TestingT(t)
+}
+
+// FederationSuite does some generic setup/teardown. Don't add Test*
+// methods to FederationSuite itself.
+type FederationSuite struct {
+	cluster *arvados.Cluster
+	ctx     context.Context
+	fed     *Conn
+}
+
+func (s *FederationSuite) SetUpTest(c *check.C) {
+	s.cluster = &arvados.Cluster{
+		ClusterID:       "aaaaa",
+		SystemRootToken: arvadostest.SystemRootToken,
+		RemoteClusters: map[string]arvados.RemoteCluster{
+			"aaaaa": arvados.RemoteCluster{
+				Host: os.Getenv("ARVADOS_API_HOST"),
+			},
+		},
+	}
+	arvadostest.SetServiceURL(&s.cluster.Services.RailsAPI, "https://"+os.Getenv("ARVADOS_TEST_API_HOST"))
+	s.cluster.TLS.Insecure = true
+	s.cluster.API.MaxItemsPerResponse = 3
+
+	ctx := context.Background()
+	ctx = ctxlog.Context(ctx, ctxlog.TestLogger(c))
+	ctx = auth.NewContext(ctx, &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
+	s.ctx = ctx
+
+	s.fed = New(s.cluster)
+}
+
+func (s *FederationSuite) addDirectRemote(c *check.C, id string, backend backend) {
+	s.cluster.RemoteClusters[id] = arvados.RemoteCluster{
+		Host: "in-process.local",
+	}
+	s.fed.remotes[id] = backend
+}
+
+func (s *FederationSuite) addHTTPRemote(c *check.C, id string, backend backend) {
+	srv := httpserver.Server{Addr: ":"}
+	srv.Handler = router.New(backend)
+	c.Check(srv.Start(), check.IsNil)
+	s.cluster.RemoteClusters[id] = arvados.RemoteCluster{
+		Scheme: "http",
+		Host:   srv.Addr,
+		Proxy:  true,
+	}
+	s.fed.remotes[id] = rpc.NewConn(id, &url.URL{Scheme: "http", Host: srv.Addr}, true, saltedTokenProvider(s.fed.local, id))
+}
diff --git a/lib/controller/federation/list_test.go b/lib/controller/federation/list_test.go
index c9b981fc1..5a630a945 100644
--- a/lib/controller/federation/list_test.go
+++ b/lib/controller/federation/list_test.go
@@ -8,74 +8,13 @@ import (
 	"context"
 	"fmt"
 	"net/http"
-	"net/url"
-	"os"
-	"testing"
 
-	"git.curoverse.com/arvados.git/lib/controller/router"
-	"git.curoverse.com/arvados.git/lib/controller/rpc"
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
-	"git.curoverse.com/arvados.git/sdk/go/auth"
-	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
-	"git.curoverse.com/arvados.git/sdk/go/httpserver"
 	check "gopkg.in/check.v1"
 )
 
-// Gocheck boilerplate
-func Test(t *testing.T) {
-	check.TestingT(t)
-}
-
-var (
-	_ = check.Suite(&FederationSuite{})
-	_ = check.Suite(&CollectionListSuite{})
-)
-
-type FederationSuite struct {
-	cluster *arvados.Cluster
-	ctx     context.Context
-	fed     *Conn
-}
-
-func (s *FederationSuite) SetUpTest(c *check.C) {
-	s.cluster = &arvados.Cluster{
-		ClusterID: "aaaaa",
-		RemoteClusters: map[string]arvados.RemoteCluster{
-			"aaaaa": arvados.RemoteCluster{
-				Host: os.Getenv("ARVADOS_API_HOST"),
-			},
-		},
-	}
-	arvadostest.SetServiceURL(&s.cluster.Services.RailsAPI, "https://"+os.Getenv("ARVADOS_TEST_API_HOST"))
-	s.cluster.TLS.Insecure = true
-	s.cluster.API.MaxItemsPerResponse = 3
-
-	ctx := context.Background()
-	ctx = ctxlog.Context(ctx, ctxlog.TestLogger(c))
-	ctx = auth.NewContext(ctx, &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
-	s.ctx = ctx
-
-	s.fed = New(s.cluster)
-}
-
-func (s *FederationSuite) addDirectRemote(c *check.C, id string, backend backend) {
-	s.cluster.RemoteClusters[id] = arvados.RemoteCluster{
-		Host: "in-process.local",
-	}
-	s.fed.remotes[id] = backend
-}
-
-func (s *FederationSuite) addHTTPRemote(c *check.C, id string, backend backend) {
-	srv := httpserver.Server{Addr: ":"}
-	srv.Handler = router.New(backend)
-	c.Check(srv.Start(), check.IsNil)
-	s.cluster.RemoteClusters[id] = arvados.RemoteCluster{
-		Host:  srv.Addr,
-		Proxy: true,
-	}
-	s.fed.remotes[id] = rpc.NewConn(id, &url.URL{Scheme: "http", Host: srv.Addr}, true, saltedTokenProvider(s.fed.local, id))
-}
+var _ = check.Suite(&CollectionListSuite{})
 
 type collectionLister struct {
 	arvadostest.APIStub
diff --git a/lib/controller/federation/login_test.go b/lib/controller/federation/login_test.go
index e001014e2..f83f5fb93 100644
--- a/lib/controller/federation/login_test.go
+++ b/lib/controller/federation/login_test.go
@@ -13,7 +13,13 @@ import (
 	check "gopkg.in/check.v1"
 )
 
-func (s *FederationSuite) TestDeferToLoginCluster(c *check.C) {
+var _ = check.Suite(&LoginSuite{})
+
+type LoginSuite struct {
+	FederationSuite
+}
+
+func (s *LoginSuite) TestDeferToLoginCluster(c *check.C) {
 	s.addHTTPRemote(c, "zhome", &arvadostest.APIStub{})
 	s.cluster.Login.LoginCluster = "zhome"
 
diff --git a/lib/controller/federation/user_test.go b/lib/controller/federation/user_test.go
new file mode 100644
index 000000000..993be9b0b
--- /dev/null
+++ b/lib/controller/federation/user_test.go
@@ -0,0 +1,121 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package federation
+
+import (
+	"encoding/json"
+	"errors"
+	"net/url"
+	"os"
+	"strings"
+
+	"git.curoverse.com/arvados.git/lib/controller/rpc"
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
+	check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&UserSuite{})
+
+type UserSuite struct {
+	FederationSuite
+}
+
+func (s *UserSuite) TestLoginClusterUserList(c *check.C) {
+	s.cluster.ClusterID = "local"
+	s.cluster.Login.LoginCluster = "zzzzz"
+	s.fed = New(s.cluster)
+	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} {
+		for _, opts := range []arvados.ListOptions{
+			{Offset: 0, Limit: -1, Select: nil},
+			{Offset: 1, Limit: 1, Select: nil},
+			{Offset: 0, Limit: 2, Select: []string{"uuid"}},
+			{Offset: 0, Limit: 2, Select: []string{"uuid", "email"}},
+		} {
+			c.Logf("updateFail %v, opts %#v", updateFail, opts)
+			spy := arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
+			stub := &arvadostest.APIStub{Error: errors.New("local cluster failure")}
+			if updateFail {
+				s.fed.local = stub
+			} else {
+				s.fed.local = rpc.NewConn(s.cluster.ClusterID, spy.URL, true, rpc.PassthroughTokenProvider)
+			}
+			userlist, err := s.fed.UserList(s.ctx, opts)
+			if updateFail && err == nil {
+				// All local updates fail, so the only
+				// cases expected to succeed are the
+				// ones with 0 results.
+				c.Check(userlist.Items, check.HasLen, 0)
+				c.Check(stub.Calls(nil), check.HasLen, 0)
+			} else if updateFail {
+				c.Logf("... err %#v", err)
+				calls := stub.Calls(stub.UserUpdate)
+				if c.Check(calls, check.HasLen, 1) {
+					c.Logf("... stub.UserUpdate called with options: %#v", calls[0].Options)
+					shouldUpdate := map[string]bool{
+						"uuid":       false,
+						"email":      true,
+						"first_name": true,
+						"last_name":  true,
+						"is_admin":   true,
+						"is_active":  true,
+						"prefs":      true,
+						// can't safely update locally
+						"owner_uuid":   false,
+						"identity_url": false,
+						// virtual attrs
+						"full_name":  false,
+						"is_invited": false,
+					}
+					if opts.Select != nil {
+						// Only the selected
+						// fields (minus uuid)
+						// should be updated.
+						for k := range shouldUpdate {
+							shouldUpdate[k] = false
+						}
+						for _, k := range opts.Select {
+							if k != "uuid" {
+								shouldUpdate[k] = true
+							}
+						}
+					}
+					for k, shouldFind := range shouldUpdate {
+						_, found := calls[0].Options.(arvados.UpdateOptions).Attrs[k]
+						c.Check(found, check.Equals, shouldFind, check.Commentf("offending attr: %s", k))
+					}
+				}
+			} else {
+				updates := 0
+				for _, d := range spy.RequestDumps {
+					d := string(d)
+					if strings.Contains(d, "PATCH /arvados/v1/users/zzzzz-tpzed-") {
+						c.Check(d, check.Matches, `(?ms).*Authorization: Bearer `+arvadostest.SystemRootToken+`.*`)
+						updates++
+					}
+				}
+				c.Check(err, check.IsNil)
+				c.Check(updates, check.Equals, len(userlist.Items))
+				c.Logf("... response items %#v", userlist.Items)
+			}
+		}
+	}
+}
+
+// userAttrsCachedFromLoginCluster must have an entry for every field
+// in the User struct.
+func (s *UserSuite) TestUserAttrsUpdateWhitelist(c *check.C) {
+	buf, err := json.Marshal(&arvados.User{})
+	c.Assert(err, check.IsNil)
+	var allFields map[string]interface{}
+	err = json.Unmarshal(buf, &allFields)
+	c.Assert(err, check.IsNil)
+	for k := range allFields {
+		_, ok := userAttrsCachedFromLoginCluster[k]
+		c.Check(ok, check.Equals, true, check.Commentf("field name %q missing from userAttrsCachedFromLoginCluster", k))
+	}
+}
diff --git a/sdk/go/arvados/user.go b/sdk/go/arvados/user.go
index 27d2b28a4..30bc094d0 100644
--- a/sdk/go/arvados/user.go
+++ b/sdk/go/arvados/user.go
@@ -4,13 +4,26 @@
 
 package arvados
 
+import "time"
+
 // User is an arvados#user record
 type User struct {
-	UUID     string `json:"uuid"`
-	IsActive bool   `json:"is_active"`
-	IsAdmin  bool   `json:"is_admin"`
-	Username string `json:"username"`
-	Email    string `json:"email"`
+	UUID                 string                 `json:"uuid"`
+	IsActive             bool                   `json:"is_active"`
+	IsAdmin              bool                   `json:"is_admin"`
+	Username             string                 `json:"username"`
+	Email                string                 `json:"email"`
+	FullName             string                 `json:"full_name"`
+	FirstName            string                 `json:"first_name"`
+	LastName             string                 `json:"last_name"`
+	IdentityURL          string                 `json:"identity_url"`
+	IsInvited            bool                   `json:"is_invited"`
+	OwnerUUID            string                 `json:"owner_uuid"`
+	CreatedAt            time.Time              `json:"created_at"`
+	ModifiedAt           time.Time              `json:"modified_at"`
+	ModifiedByUserUUID   string                 `json:"modified_by_user_uuid"`
+	ModifiedByClientUUID string                 `json:"modified_by_client_uuid"`
+	Prefs                map[string]interface{} `json:"prefs"`
 }
 
 // UserList is an arvados#userList resource.
diff --git a/sdk/go/arvadostest/api.go b/sdk/go/arvadostest/api.go
index 96b7c75f1..f18af8caa 100644
--- a/sdk/go/arvadostest/api.go
+++ b/sdk/go/arvadostest/api.go
@@ -185,7 +185,6 @@ func (as *APIStub) Calls(method interface{}) []APIStubCall {
 	defer as.mtx.Unlock()
 	var calls []APIStubCall
 	for _, call := range as.calls {
-
 		if method == nil || (runtime.FuncForPC(reflect.ValueOf(call.Method).Pointer()).Name() ==
 			runtime.FuncForPC(reflect.ValueOf(method).Pointer()).Name()) {
 			calls = append(calls, call)
diff --git a/sdk/go/arvadostest/fixtures.go b/sdk/go/arvadostest/fixtures.go
index be29bc23e..10b95c037 100644
--- a/sdk/go/arvadostest/fixtures.go
+++ b/sdk/go/arvadostest/fixtures.go
@@ -13,6 +13,7 @@ const (
 	AdminToken              = "4axaw8zxe0qm22wa6urpp5nskcne8z88cvbupv653y1njyi05h"
 	AnonymousToken          = "4kg6k6lzmp9kj4cpkcoxie964cmvjahbt4fod9zru44k4jqdmi"
 	DataManagerToken        = "320mkve8qkswstz7ff61glpk3mhgghmg67wmic7elw4z41pke1"
+	SystemRootToken         = "systemusertesttoken1234567890aoeuidhtnsqjkxbmwvzpy"
 	ManagementToken         = "jg3ajndnq63sywcd50gbs5dskdc9ckkysb0nsqmfz08nwf17nl"
 	ActiveUserUUID          = "zzzzz-tpzed-xurymjxw79nv3jz"
 	FederatedActiveUserUUID = "zbbbb-tpzed-xurymjxw79nv3jz"
diff --git a/sdk/python/tests/run_test_server.py b/sdk/python/tests/run_test_server.py
index 48aabbbe4..dd74df236 100644
--- a/sdk/python/tests/run_test_server.py
+++ b/sdk/python/tests/run_test_server.py
@@ -719,7 +719,7 @@ def setup_config():
             "zzzzz": {
                 "EnableBetaController14287": ('14287' in os.environ.get('ARVADOS_EXPERIMENTAL', '')),
                 "ManagementToken": "e687950a23c3a9bceec28c6223a06c79",
-                "SystemRootToken": auth_token('data_manager'),
+                "SystemRootToken": auth_token('system_user'),
                 "API": {
                     "RequestTimeout": "30s",
                 },
diff --git a/services/keepstore/handler_test.go b/services/keepstore/handler_test.go
index 54b4871fa..8247ce480 100644
--- a/services/keepstore/handler_test.go
+++ b/services/keepstore/handler_test.go
@@ -46,7 +46,7 @@ func testCluster(t TB) *arvados.Cluster {
 	if err != nil {
 		t.Fatal(err)
 	}
-	cluster.SystemRootToken = arvadostest.DataManagerToken
+	cluster.SystemRootToken = arvadostest.SystemRootToken
 	cluster.ManagementToken = arvadostest.ManagementToken
 	cluster.Collections.BlobSigning = false
 	return cluster
diff --git a/services/keepstore/proxy_remote_test.go b/services/keepstore/proxy_remote_test.go
index 6483d6cf0..fd98aa9cb 100644
--- a/services/keepstore/proxy_remote_test.go
+++ b/services/keepstore/proxy_remote_test.go
@@ -89,7 +89,7 @@ func (s *ProxyRemoteSuite) SetUpTest(c *check.C) {
 	s.remoteAPI.StartTLS()
 	s.cluster = testCluster(c)
 	s.cluster.Collections.BlobSigningKey = knownKey
-	s.cluster.SystemRootToken = arvadostest.DataManagerToken
+	s.cluster.SystemRootToken = arvadostest.SystemRootToken
 	s.cluster.RemoteClusters = map[string]arvados.RemoteCluster{
 		s.remoteClusterID: arvados.RemoteCluster{
 			Host:     strings.Split(s.remoteAPI.URL, "//")[1],

commit 513f43563b5ac1fb6151bf1ba3b931c661b5ac6a
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Wed Nov 13 14:47:38 2019 -0500

    15720: Add /users/* endpoints to internal API.

diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
index 3829d0a40..9d01f1f7c 100644
--- a/lib/controller/federation/conn.go
+++ b/lib/controller/federation/conn.go
@@ -251,6 +251,10 @@ func (conn *Conn) CollectionGet(ctx context.Context, options arvados.GetOptions)
 	}
 }
 
+func (conn *Conn) CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error) {
+	return conn.generated_CollectionList(ctx, options)
+}
+
 func (conn *Conn) CollectionProvenance(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error) {
 	return conn.chooseBackend(options.UUID).CollectionProvenance(ctx, options)
 }
@@ -271,6 +275,10 @@ func (conn *Conn) CollectionUntrash(ctx context.Context, options arvados.Untrash
 	return conn.chooseBackend(options.UUID).CollectionUntrash(ctx, options)
 }
 
+func (conn *Conn) ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
+	return conn.generated_ContainerList(ctx, options)
+}
+
 func (conn *Conn) ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error) {
 	return conn.chooseBackend(options.ClusterID).ContainerCreate(ctx, options)
 }
@@ -295,6 +303,10 @@ func (conn *Conn) ContainerUnlock(ctx context.Context, options arvados.GetOption
 	return conn.chooseBackend(options.UUID).ContainerUnlock(ctx, options)
 }
 
+func (conn *Conn) SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
+	return conn.generated_SpecimenList(ctx, options)
+}
+
 func (conn *Conn) SpecimenCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Specimen, error) {
 	return conn.chooseBackend(options.ClusterID).SpecimenCreate(ctx, options)
 }
@@ -311,6 +323,54 @@ func (conn *Conn) SpecimenDelete(ctx context.Context, options arvados.DeleteOpti
 	return conn.chooseBackend(options.UUID).SpecimenDelete(ctx, options)
 }
 
+func (conn *Conn) UserList(ctx context.Context, options arvados.ListOptions) (arvados.UserList, error) {
+	return conn.generated_UserList(ctx, options)
+}
+
+func (conn *Conn) UserCreate(ctx context.Context, options arvados.CreateOptions) (arvados.User, error) {
+	return conn.chooseBackend(options.ClusterID).UserCreate(ctx, options)
+}
+
+func (conn *Conn) UserUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.User, error) {
+	return conn.chooseBackend(options.UUID).UserUpdate(ctx, options)
+}
+
+func (conn *Conn) UserUpdateUUID(ctx context.Context, options arvados.UpdateUUIDOptions) (arvados.User, error) {
+	return conn.chooseBackend(options.UUID).UserUpdateUUID(ctx, options)
+}
+
+func (conn *Conn) UserMerge(ctx context.Context, options arvados.UserMergeOptions) (arvados.User, error) {
+	return conn.chooseBackend(options.OldUserUUID).UserMerge(ctx, options)
+}
+
+func (conn *Conn) UserActivate(ctx context.Context, options arvados.UserActivateOptions) (arvados.User, error) {
+	return conn.chooseBackend(options.UUID).UserActivate(ctx, options)
+}
+
+func (conn *Conn) UserSetup(ctx context.Context, options arvados.UserSetupOptions) (map[string]interface{}, error) {
+	return conn.chooseBackend(options.UUID).UserSetup(ctx, options)
+}
+
+func (conn *Conn) UserUnsetup(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	return conn.chooseBackend(options.UUID).UserUnsetup(ctx, options)
+}
+
+func (conn *Conn) UserGet(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	return conn.chooseBackend(options.UUID).UserGet(ctx, options)
+}
+
+func (conn *Conn) UserGetCurrent(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	return conn.chooseBackend(options.UUID).UserGetCurrent(ctx, options)
+}
+
+func (conn *Conn) UserGetSystem(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	return conn.chooseBackend(options.UUID).UserGetSystem(ctx, options)
+}
+
+func (conn *Conn) UserDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.User, error) {
+	return conn.chooseBackend(options.UUID).UserDelete(ctx, options)
+}
+
 func (conn *Conn) APIClientAuthorizationCurrent(ctx context.Context, options arvados.GetOptions) (arvados.APIClientAuthorization, error) {
 	return conn.chooseBackend(options.UUID).APIClientAuthorizationCurrent(ctx, options)
 }
diff --git a/lib/controller/federation/generate.go b/lib/controller/federation/generate.go
index 11f021e51..ab5d9966a 100644
--- a/lib/controller/federation/generate.go
+++ b/lib/controller/federation/generate.go
@@ -31,7 +31,7 @@ func main() {
 	if err != nil {
 		panic(err)
 	}
-	orig := regexp.MustCompile(`(?ms)\nfunc [^\n]*CollectionList\(.*?\n}\n`).Find(buf)
+	orig := regexp.MustCompile(`(?ms)\nfunc [^\n]*generated_CollectionList\(.*?\n}\n`).Find(buf)
 	if len(orig) == 0 {
 		panic("can't find CollectionList func")
 	}
@@ -52,7 +52,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", "Specimen"} {
+		for _, t := range []string{"Container", "Specimen", "User"} {
 			_, 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 b34b9b165..961cd5a40 100755
--- a/lib/controller/federation/generated.go
+++ b/lib/controller/federation/generated.go
@@ -16,7 +16,7 @@ import (
 // -- this file is auto-generated -- do not edit -- edit list.go and run "go generate" instead --
 //
 
-func (conn *Conn) ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
+func (conn *Conn) generated_ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
 	var mtx sync.Mutex
 	var merged arvados.ContainerList
 	err := conn.splitListRequest(ctx, options, func(ctx context.Context, _ string, backend arvados.API, options arvados.ListOptions) ([]string, error) {
@@ -41,7 +41,7 @@ func (conn *Conn) ContainerList(ctx context.Context, options arvados.ListOptions
 	return merged, err
 }
 
-func (conn *Conn) SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
+func (conn *Conn) generated_SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
 	var mtx sync.Mutex
 	var merged arvados.SpecimenList
 	err := conn.splitListRequest(ctx, options, func(ctx context.Context, _ string, backend arvados.API, options arvados.ListOptions) ([]string, error) {
@@ -65,3 +65,28 @@ func (conn *Conn) SpecimenList(ctx context.Context, options arvados.ListOptions)
 	sort.Slice(merged.Items, func(i, j int) bool { return merged.Items[i].UUID < merged.Items[j].UUID })
 	return merged, err
 }
+
+func (conn *Conn) generated_UserList(ctx context.Context, options arvados.ListOptions) (arvados.UserList, error) {
+	var mtx sync.Mutex
+	var merged arvados.UserList
+	err := conn.splitListRequest(ctx, options, func(ctx context.Context, _ string, backend arvados.API, options arvados.ListOptions) ([]string, error) {
+		cl, err := backend.UserList(ctx, options)
+		if err != nil {
+			return nil, err
+		}
+		mtx.Lock()
+		defer mtx.Unlock()
+		if len(merged.Items) == 0 {
+			merged = cl
+		} else {
+			merged.Items = append(merged.Items, cl.Items...)
+		}
+		uuids := make([]string, 0, len(cl.Items))
+		for _, item := range cl.Items {
+			uuids = append(uuids, item.UUID)
+		}
+		return uuids, nil
+	})
+	sort.Slice(merged.Items, func(i, j int) bool { return merged.Items[i].UUID < merged.Items[j].UUID })
+	return merged, err
+}
diff --git a/lib/controller/federation/list.go b/lib/controller/federation/list.go
index 6ba184c47..7178d7b0a 100644
--- a/lib/controller/federation/list.go
+++ b/lib/controller/federation/list.go
@@ -20,7 +20,7 @@ import (
 // CollectionList is used as a template to auto-generate List()
 // methods for other types; see generate.go.
 
-func (conn *Conn) CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error) {
+func (conn *Conn) generated_CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error) {
 	var mtx sync.Mutex
 	var merged arvados.CollectionList
 	err := conn.splitListRequest(ctx, options, func(ctx context.Context, _ string, backend arvados.API, options arvados.ListOptions) ([]string, error) {
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index f925233ba..a0c245009 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -83,6 +83,8 @@ func (h *Handler) setup() {
 	if h.Cluster.EnableBetaController14287 {
 		mux.Handle("/arvados/v1/collections", rtr)
 		mux.Handle("/arvados/v1/collections/", rtr)
+		mux.Handle("/arvados/v1/users", rtr)
+		mux.Handle("/arvados/v1/users/", rtr)
 		mux.Handle("/login", rtr)
 	}
 
diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index 709ddfb82..250f3cb45 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -205,6 +205,90 @@ func (rtr *router) addRoutes() {
 				return rtr.fed.SpecimenDelete(ctx, *opts.(*arvados.DeleteOptions))
 			},
 		},
+		{
+			arvados.EndpointUserCreate,
+			func() interface{} { return &arvados.CreateOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserCreate(ctx, *opts.(*arvados.CreateOptions))
+			},
+		},
+		{
+			arvados.EndpointUserMerge,
+			func() interface{} { return &arvados.UserMergeOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserMerge(ctx, *opts.(*arvados.UserMergeOptions))
+			},
+		},
+		{
+			arvados.EndpointUserActivate,
+			func() interface{} { return &arvados.UserActivateOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserActivate(ctx, *opts.(*arvados.UserActivateOptions))
+			},
+		},
+		{
+			arvados.EndpointUserSetup,
+			func() interface{} { return &arvados.UserSetupOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserSetup(ctx, *opts.(*arvados.UserSetupOptions))
+			},
+		},
+		{
+			arvados.EndpointUserUnsetup,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserUnsetup(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointUserGetCurrent,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserGetCurrent(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointUserGetSystem,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserGetSystem(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointUserGet,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserGet(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointUserUpdateUUID,
+			func() interface{} { return &arvados.UpdateUUIDOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserUpdateUUID(ctx, *opts.(*arvados.UpdateUUIDOptions))
+			},
+		},
+		{
+			arvados.EndpointUserUpdate,
+			func() interface{} { return &arvados.UpdateOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserUpdate(ctx, *opts.(*arvados.UpdateOptions))
+			},
+		},
+		{
+			arvados.EndpointUserList,
+			func() interface{} { return &arvados.ListOptions{Limit: -1} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserList(ctx, *opts.(*arvados.ListOptions))
+			},
+		},
+		{
+			arvados.EndpointUserDelete,
+			func() interface{} { return &arvados.DeleteOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.UserDelete(ctx, *opts.(*arvados.DeleteOptions))
+			},
+		},
 	} {
 		rtr.addRoute(route.endpoint, route.defaultOpts, route.exec)
 		if route.endpoint.Method == "PATCH" {
@@ -250,6 +334,11 @@ func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() int
 		}
 
 		creds := auth.CredentialsFromRequest(req)
+		err = creds.LoadTokensFromHTTPRequestBody(req)
+		if err != nil {
+			rtr.sendError(w, fmt.Errorf("error loading tokens from request body: %s", err))
+			return
+		}
 		if rt, _ := params["reader_tokens"].([]interface{}); len(rt) > 0 {
 			for _, t := range rt {
 				if t, ok := t.(string); ok {
diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
index afe749fb0..25efcfd43 100644
--- a/lib/controller/rpc/conn.go
+++ b/lib/controller/rpc/conn.go
@@ -308,6 +308,79 @@ func (conn *Conn) SpecimenDelete(ctx context.Context, options arvados.DeleteOpti
 	return resp, err
 }
 
+func (conn *Conn) UserCreate(ctx context.Context, options arvados.CreateOptions) (arvados.User, error) {
+	ep := arvados.EndpointUserCreate
+	var resp arvados.User
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.User, error) {
+	ep := arvados.EndpointUserUpdate
+	var resp arvados.User
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserUpdateUUID(ctx context.Context, options arvados.UpdateUUIDOptions) (arvados.User, error) {
+	ep := arvados.EndpointUserUpdateUUID
+	var resp arvados.User
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserMerge(ctx context.Context, options arvados.UserMergeOptions) (arvados.User, error) {
+	ep := arvados.EndpointUserUpdateUUID
+	var resp arvados.User
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserActivate(ctx context.Context, options arvados.UserActivateOptions) (arvados.User, error) {
+	ep := arvados.EndpointUserUpdateUUID
+	var resp arvados.User
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserSetup(ctx context.Context, options arvados.UserSetupOptions) (map[string]interface{}, error) {
+	ep := arvados.EndpointUserUpdateUUID
+	var resp map[string]interface{}
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserUnsetup(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	ep := arvados.EndpointUserUpdateUUID
+	var resp arvados.User
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserGet(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	ep := arvados.EndpointUserGet
+	var resp arvados.User
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserGetCurrent(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	ep := arvados.EndpointUserGetCurrent
+	var resp arvados.User
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserGetSystem(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	ep := arvados.EndpointUserGetSystem
+	var resp arvados.User
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserList(ctx context.Context, options arvados.ListOptions) (arvados.UserList, error) {
+	ep := arvados.EndpointUserList
+	var resp arvados.UserList
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+func (conn *Conn) UserDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.User, error) {
+	ep := arvados.EndpointUserDelete
+	var resp arvados.User
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
 func (conn *Conn) APIClientAuthorizationCurrent(ctx context.Context, options arvados.GetOptions) (arvados.APIClientAuthorization, error) {
 	ep := arvados.EndpointAPIClientAuthorizationCurrent
 	var resp arvados.APIClientAuthorization
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 5de94d73e..d86df9ef3 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -40,6 +40,20 @@ var (
 	EndpointContainerDelete               = APIEndpoint{"DELETE", "arvados/v1/containers/{uuid}", ""}
 	EndpointContainerLock                 = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/lock", ""}
 	EndpointContainerUnlock               = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/unlock", ""}
+	EndpointUserActivate                  = APIEndpoint{"POST", "arvados/v1/users/{uuid}/activate", ""}
+	EndpointUserCreate                    = APIEndpoint{"POST", "arvados/v1/users", "user"}
+	EndpointUserCurrent                   = APIEndpoint{"GET", "arvados/v1/users/current", ""}
+	EndpointUserDelete                    = APIEndpoint{"DELETE", "arvados/v1/users/{uuid}", ""}
+	EndpointUserGet                       = APIEndpoint{"GET", "arvados/v1/users/{uuid}", ""}
+	EndpointUserGetCurrent                = APIEndpoint{"GET", "arvados/v1/users/current", ""}
+	EndpointUserGetSystem                 = APIEndpoint{"GET", "arvados/v1/users/system", ""}
+	EndpointUserList                      = APIEndpoint{"GET", "arvados/v1/users", ""}
+	EndpointUserMerge                     = APIEndpoint{"POST", "arvados/v1/users/merge", ""}
+	EndpointUserSetup                     = APIEndpoint{"POST", "arvados/v1/users/setup", ""}
+	EndpointUserSystem                    = APIEndpoint{"GET", "arvados/v1/users/system", ""}
+	EndpointUserUnsetup                   = APIEndpoint{"POST", "arvados/v1/users/{uuid}/unsetup", ""}
+	EndpointUserUpdate                    = APIEndpoint{"PATCH", "arvados/v1/users/{uuid}", "user"}
+	EndpointUserUpdateUUID                = APIEndpoint{"POST", "arvados/v1/users/{uuid}/update_uuid", ""}
 	EndpointAPIClientAuthorizationCurrent = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/current", ""}
 )
 
@@ -80,6 +94,31 @@ type UpdateOptions struct {
 	Attrs map[string]interface{} `json:"attrs"`
 }
 
+type UpdateUUIDOptions struct {
+	UUID    string `json:"uuid"`
+	NewUUID string `json:"new_uuid"`
+}
+
+type UserActivateOptions struct {
+	UUID string `json:"uuid"`
+}
+
+type UserSetupOptions struct {
+	UUID                  string                 `json:"uuid"`
+	Email                 string                 `json:"email"`
+	OpenIDPrefix          string                 `json:"openid_prefix"`
+	RepoName              string                 `json:"repo_name"`
+	VMUUID                string                 `json:"vm_uuid"`
+	SendNotificationEmail bool                   `json:"send_notification_email"`
+	Attrs                 map[string]interface{} `json:"attrs"`
+}
+
+type UserMergeOptions struct {
+	NewUserUUID  string `json:"new_user_uuid,omitempty"`
+	OldUserUUID  string `json:"old_user_uuid,omitempty"`
+	NewUserToken string `json:"new_user_token,omitempty"`
+}
+
 type DeleteOptions struct {
 	UUID string `json:"uuid"`
 }
@@ -115,5 +154,17 @@ type API interface {
 	SpecimenGet(ctx context.Context, options GetOptions) (Specimen, error)
 	SpecimenList(ctx context.Context, options ListOptions) (SpecimenList, error)
 	SpecimenDelete(ctx context.Context, options DeleteOptions) (Specimen, error)
+	UserCreate(ctx context.Context, options CreateOptions) (User, error)
+	UserUpdate(ctx context.Context, options UpdateOptions) (User, error)
+	UserUpdateUUID(ctx context.Context, options UpdateUUIDOptions) (User, error)
+	UserMerge(ctx context.Context, options UserMergeOptions) (User, error)
+	UserActivate(ctx context.Context, options UserActivateOptions) (User, error)
+	UserSetup(ctx context.Context, options UserSetupOptions) (map[string]interface{}, error)
+	UserUnsetup(ctx context.Context, options GetOptions) (User, error)
+	UserGet(ctx context.Context, options GetOptions) (User, error)
+	UserGetCurrent(ctx context.Context, options GetOptions) (User, error)
+	UserGetSystem(ctx context.Context, options GetOptions) (User, error)
+	UserList(ctx context.Context, options ListOptions) (UserList, error)
+	UserDelete(ctx context.Context, options DeleteOptions) (User, error)
 	APIClientAuthorizationCurrent(ctx context.Context, options GetOptions) (APIClientAuthorization, error)
 }
diff --git a/sdk/go/arvadostest/api.go b/sdk/go/arvadostest/api.go
index 24e9f1908..96b7c75f1 100644
--- a/sdk/go/arvadostest/api.go
+++ b/sdk/go/arvadostest/api.go
@@ -121,6 +121,54 @@ func (as *APIStub) SpecimenDelete(ctx context.Context, options arvados.DeleteOpt
 	as.appendCall(as.SpecimenDelete, ctx, options)
 	return arvados.Specimen{}, as.Error
 }
+func (as *APIStub) UserCreate(ctx context.Context, options arvados.CreateOptions) (arvados.User, error) {
+	as.appendCall(as.UserCreate, ctx, options)
+	return arvados.User{}, as.Error
+}
+func (as *APIStub) UserUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.User, error) {
+	as.appendCall(as.UserUpdate, ctx, options)
+	return arvados.User{}, as.Error
+}
+func (as *APIStub) UserUpdateUUID(ctx context.Context, options arvados.UpdateUUIDOptions) (arvados.User, error) {
+	as.appendCall(as.UserUpdateUUID, ctx, options)
+	return arvados.User{}, as.Error
+}
+func (as *APIStub) UserActivate(ctx context.Context, options arvados.UserActivateOptions) (arvados.User, error) {
+	as.appendCall(as.UserActivate, ctx, options)
+	return arvados.User{}, as.Error
+}
+func (as *APIStub) UserSetup(ctx context.Context, options arvados.UserSetupOptions) (map[string]interface{}, error) {
+	as.appendCall(as.UserSetup, ctx, options)
+	return nil, as.Error
+}
+func (as *APIStub) UserUnsetup(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	as.appendCall(as.UserUnsetup, ctx, options)
+	return arvados.User{}, as.Error
+}
+func (as *APIStub) UserGet(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	as.appendCall(as.UserGet, ctx, options)
+	return arvados.User{}, as.Error
+}
+func (as *APIStub) UserGetCurrent(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	as.appendCall(as.UserGetCurrent, ctx, options)
+	return arvados.User{}, as.Error
+}
+func (as *APIStub) UserGetSystem(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
+	as.appendCall(as.UserGetSystem, ctx, options)
+	return arvados.User{}, as.Error
+}
+func (as *APIStub) UserList(ctx context.Context, options arvados.ListOptions) (arvados.UserList, error) {
+	as.appendCall(as.UserList, ctx, options)
+	return arvados.UserList{}, as.Error
+}
+func (as *APIStub) UserDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.User, error) {
+	as.appendCall(as.UserDelete, ctx, options)
+	return arvados.User{}, as.Error
+}
+func (as *APIStub) UserMerge(ctx context.Context, options arvados.UserMergeOptions) (arvados.User, error) {
+	as.appendCall(as.UserMerge, ctx, options)
+	return arvados.User{}, as.Error
+}
 func (as *APIStub) APIClientAuthorizationCurrent(ctx context.Context, options arvados.GetOptions) (arvados.APIClientAuthorization, error) {
 	as.appendCall(as.APIClientAuthorizationCurrent, ctx, options)
 	return arvados.APIClientAuthorization{}, as.Error

commit 6b4f1bee5f338846b69b0c287051f23c1b905dab
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu Nov 14 14:29:14 2019 -0500

    15720: Switch httprouter to gorilla/mux.
    
    In httprouter, "/users/:uuid" and "/users/:uuid/activate" are
    conflicting routes.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/request.go b/lib/controller/router/request.go
index 377f7243c..4d18395b6 100644
--- a/lib/controller/router/request.go
+++ b/lib/controller/router/request.go
@@ -13,7 +13,7 @@ import (
 	"strconv"
 	"strings"
 
-	"github.com/julienschmidt/httprouter"
+	"github.com/gorilla/mux"
 )
 
 // Parse req as an Arvados V1 API request and return the request
@@ -109,9 +109,8 @@ func (rtr *router) loadRequestParams(req *http.Request, attrsKey string) (map[st
 		}
 	}
 
-	routeParams, _ := req.Context().Value(httprouter.ParamsKey).(httprouter.Params)
-	for _, p := range routeParams {
-		params[p.Key] = p.Value
+	for k, v := range mux.Vars(req) {
+		params[k] = v
 	}
 
 	if v, ok := params[attrsKey]; ok && attrsKey != "" {
diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index d3bdce527..709ddfb82 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -14,18 +14,18 @@ import (
 	"git.curoverse.com/arvados.git/sdk/go/auth"
 	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
 	"git.curoverse.com/arvados.git/sdk/go/httpserver"
-	"github.com/julienschmidt/httprouter"
+	"github.com/gorilla/mux"
 	"github.com/sirupsen/logrus"
 )
 
 type router struct {
-	mux *httprouter.Router
+	mux *mux.Router
 	fed arvados.API
 }
 
 func New(fed arvados.API) *router {
 	rtr := &router{
-		mux: httprouter.New(),
+		mux: mux.NewRouter(),
 		fed: fed,
 	}
 	rtr.addRoutes()
@@ -214,16 +214,16 @@ func (rtr *router) addRoutes() {
 			rtr.addRoute(endpointPUT, route.defaultOpts, route.exec)
 		}
 	}
-	rtr.mux.NotFound = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+	rtr.mux.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 		httpserver.Errors(w, []string{"API endpoint not found"}, http.StatusNotFound)
 	})
-	rtr.mux.MethodNotAllowed = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+	rtr.mux.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 		httpserver.Errors(w, []string{"API endpoint not found"}, http.StatusMethodNotAllowed)
 	})
 }
 
 func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() interface{}, exec routableFunc) {
-	rtr.mux.HandlerFunc(endpoint.Method, "/"+endpoint.Path, func(w http.ResponseWriter, req *http.Request) {
+	rtr.mux.Methods(endpoint.Method).Path("/" + endpoint.Path).HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 		logger := ctxlog.FromContext(req.Context())
 		params, err := rtr.loadRequestParams(req, endpoint.AttrsKey)
 		if err != nil {
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index 3a7045aa4..6a9fd311b 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -19,7 +19,7 @@ import (
 	"git.curoverse.com/arvados.git/lib/controller/rpc"
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
-	"github.com/julienschmidt/httprouter"
+	"github.com/gorilla/mux"
 	check "gopkg.in/check.v1"
 )
 
@@ -38,7 +38,7 @@ type RouterSuite struct {
 func (s *RouterSuite) SetUpTest(c *check.C) {
 	s.stub = arvadostest.APIStub{}
 	s.rtr = &router{
-		mux: httprouter.New(),
+		mux: mux.NewRouter(),
 		fed: &s.stub,
 	}
 	s.rtr.addRoutes()
diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
index 7d7cb486f..afe749fb0 100644
--- a/lib/controller/rpc/conn.go
+++ b/lib/controller/rpc/conn.go
@@ -118,9 +118,9 @@ func (conn *Conn) requestAndDecode(ctx context.Context, dst interface{}, ep arva
 		params["reader_tokens"] = tokens[1:]
 	}
 	path := ep.Path
-	if strings.Contains(ep.Path, "/:uuid") {
+	if strings.Contains(ep.Path, "/{uuid}") {
 		uuid, _ := params["uuid"].(string)
-		path = strings.Replace(path, "/:uuid", "/"+uuid, 1)
+		path = strings.Replace(path, "/{uuid}", "/"+uuid, 1)
 		delete(params, "uuid")
 	}
 	return aClient.RequestAndDecodeContext(ctx, dst, ep.Method, path, body, params)
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 5531cf71d..5de94d73e 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -20,26 +20,26 @@ var (
 	EndpointConfigGet                     = APIEndpoint{"GET", "arvados/v1/config", ""}
 	EndpointLogin                         = APIEndpoint{"GET", "login", ""}
 	EndpointCollectionCreate              = APIEndpoint{"POST", "arvados/v1/collections", "collection"}
-	EndpointCollectionUpdate              = APIEndpoint{"PATCH", "arvados/v1/collections/:uuid", "collection"}
-	EndpointCollectionGet                 = APIEndpoint{"GET", "arvados/v1/collections/:uuid", ""}
+	EndpointCollectionUpdate              = APIEndpoint{"PATCH", "arvados/v1/collections/{uuid}", "collection"}
+	EndpointCollectionGet                 = APIEndpoint{"GET", "arvados/v1/collections/{uuid}", ""}
 	EndpointCollectionList                = APIEndpoint{"GET", "arvados/v1/collections", ""}
-	EndpointCollectionProvenance          = APIEndpoint{"GET", "arvados/v1/collections/:uuid/provenance", ""}
-	EndpointCollectionUsedBy              = APIEndpoint{"GET", "arvados/v1/collections/:uuid/used_by", ""}
-	EndpointCollectionDelete              = APIEndpoint{"DELETE", "arvados/v1/collections/:uuid", ""}
-	EndpointCollectionTrash               = APIEndpoint{"POST", "arvados/v1/collections/:uuid/trash", ""}
-	EndpointCollectionUntrash             = APIEndpoint{"POST", "arvados/v1/collections/:uuid/untrash", ""}
+	EndpointCollectionProvenance          = APIEndpoint{"GET", "arvados/v1/collections/{uuid}/provenance", ""}
+	EndpointCollectionUsedBy              = APIEndpoint{"GET", "arvados/v1/collections/{uuid}/used_by", ""}
+	EndpointCollectionDelete              = APIEndpoint{"DELETE", "arvados/v1/collections/{uuid}", ""}
+	EndpointCollectionTrash               = APIEndpoint{"POST", "arvados/v1/collections/{uuid}/trash", ""}
+	EndpointCollectionUntrash             = APIEndpoint{"POST", "arvados/v1/collections/{uuid}/untrash", ""}
 	EndpointSpecimenCreate                = APIEndpoint{"POST", "arvados/v1/specimens", "specimen"}
-	EndpointSpecimenUpdate                = APIEndpoint{"PATCH", "arvados/v1/specimens/:uuid", "specimen"}
-	EndpointSpecimenGet                   = APIEndpoint{"GET", "arvados/v1/specimens/:uuid", ""}
+	EndpointSpecimenUpdate                = APIEndpoint{"PATCH", "arvados/v1/specimens/{uuid}", "specimen"}
+	EndpointSpecimenGet                   = APIEndpoint{"GET", "arvados/v1/specimens/{uuid}", ""}
 	EndpointSpecimenList                  = APIEndpoint{"GET", "arvados/v1/specimens", ""}
-	EndpointSpecimenDelete                = APIEndpoint{"DELETE", "arvados/v1/specimens/:uuid", ""}
+	EndpointSpecimenDelete                = APIEndpoint{"DELETE", "arvados/v1/specimens/{uuid}", ""}
 	EndpointContainerCreate               = APIEndpoint{"POST", "arvados/v1/containers", "container"}
-	EndpointContainerUpdate               = APIEndpoint{"PATCH", "arvados/v1/containers/:uuid", "container"}
-	EndpointContainerGet                  = APIEndpoint{"GET", "arvados/v1/containers/:uuid", ""}
+	EndpointContainerUpdate               = APIEndpoint{"PATCH", "arvados/v1/containers/{uuid}", "container"}
+	EndpointContainerGet                  = APIEndpoint{"GET", "arvados/v1/containers/{uuid}", ""}
 	EndpointContainerList                 = APIEndpoint{"GET", "arvados/v1/containers", ""}
-	EndpointContainerDelete               = APIEndpoint{"DELETE", "arvados/v1/containers/:uuid", ""}
-	EndpointContainerLock                 = APIEndpoint{"POST", "arvados/v1/containers/:uuid/lock", ""}
-	EndpointContainerUnlock               = APIEndpoint{"POST", "arvados/v1/containers/:uuid/unlock", ""}
+	EndpointContainerDelete               = APIEndpoint{"DELETE", "arvados/v1/containers/{uuid}", ""}
+	EndpointContainerLock                 = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/lock", ""}
+	EndpointContainerUnlock               = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/unlock", ""}
 	EndpointAPIClientAuthorizationCurrent = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/current", ""}
 )
 

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


hooks/post-receive
-- 




More information about the arvados-commits mailing list