[ARVADOS] created: 1.3.0-2720-g2d23e6152

Git user git at public.arvados.org
Thu Jun 25 21:23:15 UTC 2020


        at  2d23e6152b9aedeb6d66fd2850cd3470e0f23906 (commit)


commit 2d23e6152b9aedeb6d66fd2850cd3470e0f23906
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Thu Jun 25 17:23:13 2020 -0400

    16534: Add database access in lib/controller/localdb.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/federation.go b/lib/controller/federation.go
index ac239fb9b..665e7652d 100644
--- a/lib/controller/federation.go
+++ b/lib/controller/federation.go
@@ -152,7 +152,7 @@ type CurrentUser struct {
 // non-nil, true, nil -- if the token is valid
 func (h *Handler) validateAPItoken(req *http.Request, token string) (*CurrentUser, bool, error) {
 	user := CurrentUser{Authorization: arvados.APIClientAuthorization{APIToken: token}}
-	db, err := h.db(req)
+	db, err := h.db()
 	if err != nil {
 		ctxlog.FromContext(req.Context()).WithError(err).Debugf("validateAPItoken(%s): database error", token)
 		return nil, false, err
@@ -189,7 +189,7 @@ func (h *Handler) validateAPItoken(req *http.Request, token string) (*CurrentUse
 }
 
 func (h *Handler) createAPItoken(req *http.Request, userUUID string, scopes []string) (*arvados.APIClientAuthorization, error) {
-	db, err := h.db(req)
+	db, err := h.db()
 	if err != nil {
 		return nil, err
 	}
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index 01f216163..bfe915d25 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -7,7 +7,6 @@ package controller
 import (
 	"context"
 	"database/sql"
-	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
@@ -16,6 +15,7 @@ import (
 	"time"
 
 	"git.arvados.org/arvados.git/lib/controller/federation"
+	"git.arvados.org/arvados.git/lib/controller/localdb"
 	"git.arvados.org/arvados.git/lib/controller/railsproxy"
 	"git.arvados.org/arvados.git/lib/controller/router"
 	"git.arvados.org/arvados.git/sdk/go/arvados"
@@ -63,7 +63,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 
 func (h *Handler) CheckHealth() error {
 	h.setupOnce.Do(h.setup)
-	_, _, err := railsproxy.FindRailsAPI(h.Cluster)
+	_, err := h.db()
+	if err != nil {
+		return err
+	}
+	_, _, err = railsproxy.FindRailsAPI(h.Cluster)
 	return err
 }
 
@@ -78,10 +82,10 @@ func (h *Handler) setup() {
 	mux.Handle("/_health/", &health.Handler{
 		Token:  h.Cluster.ManagementToken,
 		Prefix: "/_health/",
-		Routes: health.Routes{"ping": func() error { _, err := h.db(&http.Request{}); return err }},
+		Routes: health.Routes{"ping": func() error { _, err := h.db(); return err }},
 	})
 
-	rtr := router.New(federation.New(h.Cluster))
+	rtr := router.New(federation.New(h.Cluster), router.WrapCalls(localdb.TransactionWrapper(h.db)))
 	mux.Handle("/arvados/v1/config", rtr)
 	mux.Handle("/"+arvados.EndpointUserAuthenticate.Path, rtr)
 
@@ -113,9 +117,7 @@ func (h *Handler) setup() {
 	}
 }
 
-var errDBConnection = errors.New("database connection error")
-
-func (h *Handler) db(req *http.Request) (*sql.DB, error) {
+func (h *Handler) db() (*sql.DB, error) {
 	h.pgdbMtx.Lock()
 	defer h.pgdbMtx.Unlock()
 	if h.pgdb != nil {
@@ -124,15 +126,13 @@ func (h *Handler) db(req *http.Request) (*sql.DB, error) {
 
 	db, err := sql.Open("postgres", h.Cluster.PostgreSQL.Connection.String())
 	if err != nil {
-		httpserver.Logger(req).WithError(err).Error("postgresql connect failed")
-		return nil, errDBConnection
+		return nil, fmt.Errorf("postgresql connect failed: %s", err)
 	}
 	if p := h.Cluster.PostgreSQL.ConnectionPool; p > 0 {
 		db.SetMaxOpenConns(p)
 	}
 	if err := db.Ping(); err != nil {
-		httpserver.Logger(req).WithError(err).Error("postgresql connect succeeded but ping failed")
-		return nil, errDBConnection
+		return nil, fmt.Errorf("postgresql connect succeeded but ping failed: %s", err)
 	}
 	h.pgdb = db
 	return db, nil
diff --git a/lib/controller/localdb/db.go b/lib/controller/localdb/db.go
new file mode 100644
index 000000000..5f2272d33
--- /dev/null
+++ b/lib/controller/localdb/db.go
@@ -0,0 +1,98 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"sync"
+
+	"git.arvados.org/arvados.git/lib/controller/router"
+	"git.arvados.org/arvados.git/sdk/go/ctxlog"
+)
+
+// TransactionWrapper returns a wrapper (suitable for passing to
+// router.WrapCalls()) that starts a new transaction for each API
+// request, and commits only if the request succeeds.
+func TransactionWrapper(db func() (*sql.DB, error)) func(router.RoutableFunc) router.RoutableFunc {
+	return func(orig router.RoutableFunc) router.RoutableFunc {
+		return func(ctx context.Context, opts interface{}) (_ interface{}, err error) {
+			ctx, finishtx := starttx(ctx, db)
+			defer finishtx(&err)
+			return orig(ctx, opts)
+		}
+	}
+}
+
+type contextKeyT string
+
+var contextKeyTransaction = contextKeyT("transaction")
+
+type transaction struct {
+	tx    *sql.Tx
+	err   error
+	db    func() (*sql.DB, error)
+	setup sync.Once
+}
+
+type transactionFinishFunc func(*error)
+
+// starttx returns a new child context that can be used with
+// currenttx(). It does not open a database transaction until the
+// first call to currenttx().
+//
+// The caller must eventually call the returned finishtx() func to
+// commit or rollback the transaction, if any.
+//
+//	func example(ctx context.Context) (err error) {
+//		ctx, finishtx := starttx(ctx, dber)
+//		defer finishtx(&err)
+//		// ...
+//		tx, err := currenttx(ctx)
+//		if err != nil {
+//			return fmt.Errorf("example: %s", err)
+//		}
+//		return tx.ExecContext(...)
+//	}
+//
+// If *err is nil, finishtx() commits the transaction and assigns any
+// resulting error to *err.
+//
+// If *err is non-nil, finishtx() rolls back the transaction, and
+// does not modify *err.
+func starttx(ctx context.Context, db func() (*sql.DB, error)) (context.Context, transactionFinishFunc) {
+	txn := &transaction{db: db}
+	return context.WithValue(ctx, contextKeyTransaction, txn), func(err *error) {
+		// Ensure another goroutine can't open a transaction
+		// during/after finishtx().
+		txn.setup.Do(func() {})
+		if txn.tx == nil {
+			// we never [successfully] started a transaction
+			return
+		}
+		if *err != nil {
+			ctxlog.FromContext(ctx).Debug("rollback")
+			txn.tx.Rollback()
+			return
+		}
+		*err = txn.tx.Commit()
+	}
+}
+
+func currenttx(ctx context.Context) (*sql.Tx, error) {
+	txn, ok := ctx.Value(contextKeyTransaction).(*transaction)
+	if !ok {
+		return nil, errors.New("BUG: currenttx() was called on a context that wasn't returned by startttx()")
+	}
+	txn.setup.Do(func() {
+		if db, err := txn.db(); err != nil {
+			txn.err = err
+		} else {
+			txn.tx, txn.err = db.Begin()
+		}
+	})
+	return txn.tx, txn.err
+}
diff --git a/lib/controller/localdb/db_test.go b/lib/controller/localdb/db_test.go
new file mode 100644
index 000000000..32cfa19a5
--- /dev/null
+++ b/lib/controller/localdb/db_test.go
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"database/sql"
+
+	"git.arvados.org/arvados.git/sdk/go/arvados"
+	_ "github.com/lib/pq"
+)
+
+func clusterdb(cluster *arvados.Cluster) func() (*sql.DB, error) {
+	return func() (*sql.DB, error) {
+		return sql.Open("postgres", cluster.PostgreSQL.Connection.String())
+	}
+}
diff --git a/lib/controller/localdb/docker_test.go b/lib/controller/localdb/docker_test.go
new file mode 100644
index 000000000..90c98b7d5
--- /dev/null
+++ b/lib/controller/localdb/docker_test.go
@@ -0,0 +1,68 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"io"
+	"net"
+	"strings"
+
+	"git.arvados.org/arvados.git/sdk/go/arvados"
+	check "gopkg.in/check.v1"
+)
+
+type pgproxy struct {
+	net.Listener
+}
+
+// newPgProxy sets up a TCP proxy, listening on all interfaces, that
+// forwards all connections to the cluster's PostgreSQL server. This
+// allows the caller to run a docker container that can connect to a
+// postgresql instance that listens on the test host's loopback
+// interface.
+//
+// Caller is responsible for calling Close() on the returned pgproxy.
+func newPgProxy(c *check.C, cluster *arvados.Cluster) *pgproxy {
+	host := cluster.PostgreSQL.Connection["host"]
+	if host == "" {
+		host = "localhost"
+	}
+	port := cluster.PostgreSQL.Connection["port"]
+	if port == "" {
+		port = "5432"
+	}
+	target := net.JoinHostPort(host, port)
+
+	ln, err := net.Listen("tcp", ":")
+	c.Assert(err, check.IsNil)
+	go func() {
+		for {
+			downstream, err := ln.Accept()
+			if err != nil && strings.Contains(err.Error(), "use of closed network connection") {
+				return
+			}
+			c.Assert(err, check.IsNil)
+			go func() {
+				c.Logf("pgproxy accepted connection from %s", downstream.RemoteAddr().String())
+				defer downstream.Close()
+				upstream, err := net.Dial("tcp", target)
+				if err != nil {
+					c.Logf("net.Dial(%q): %s", target, err)
+					return
+				}
+				defer upstream.Close()
+				go io.Copy(downstream, upstream)
+				io.Copy(upstream, downstream)
+			}()
+		}
+	}()
+	c.Logf("pgproxy listening at %s", ln.Addr().String())
+	return &pgproxy{Listener: ln}
+}
+
+func (proxy *pgproxy) Port() string {
+	_, port, _ := net.SplitHostPort(proxy.Addr().String())
+	return port
+}
diff --git a/lib/controller/localdb/login.go b/lib/controller/localdb/login.go
index 905cfed15..1cd349a10 100644
--- a/lib/controller/localdb/login.go
+++ b/lib/controller/localdb/login.go
@@ -6,9 +6,13 @@ package localdb
 
 import (
 	"context"
+	"database/sql"
+	"encoding/json"
 	"errors"
+	"fmt"
 	"net/http"
 	"net/url"
+	"strings"
 
 	"git.arvados.org/arvados.git/lib/controller/rpc"
 	"git.arvados.org/arvados.git/sdk/go/arvados"
@@ -96,9 +100,9 @@ func noopLogout(cluster *arvados.Cluster, opts arvados.LogoutOptions) (arvados.L
 	return arvados.LogoutResponse{RedirectLocation: target}, nil
 }
 
-func createAPIClientAuthorization(ctx context.Context, conn *rpc.Conn, rootToken string, authinfo rpc.UserSessionAuthInfo) (arvados.APIClientAuthorization, error) {
+func createAPIClientAuthorization(ctx context.Context, conn *rpc.Conn, rootToken string, authinfo rpc.UserSessionAuthInfo) (resp arvados.APIClientAuthorization, err error) {
 	ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{rootToken}})
-	resp, err := conn.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+	newsession, err := conn.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
 		// Send a fake ReturnTo value instead of the caller's
 		// opts.ReturnTo. We won't follow the resulting
 		// redirect target anyway.
@@ -106,12 +110,36 @@ func createAPIClientAuthorization(ctx context.Context, conn *rpc.Conn, rootToken
 		AuthInfo: authinfo,
 	})
 	if err != nil {
-		return arvados.APIClientAuthorization{}, err
+		return
 	}
-	target, err := url.Parse(resp.RedirectLocation)
+	target, err := url.Parse(newsession.RedirectLocation)
 	if err != nil {
-		return arvados.APIClientAuthorization{}, err
+		return
 	}
 	token := target.Query().Get("api_token")
-	return conn.APIClientAuthorizationCurrent(auth.NewContext(ctx, auth.NewCredentials(token)), arvados.GetOptions{})
+	tx, err := currenttx(ctx)
+	if err != nil {
+		return
+	}
+	tokensecret := token
+	if strings.Contains(token, "/") {
+		tokenparts := strings.Split(token, "/")
+		if len(tokenparts) >= 3 {
+			tokensecret = tokenparts[2]
+		}
+	}
+	var exp sql.NullString
+	var scopes []byte
+	err = tx.QueryRowContext(ctx, "select uuid, api_token, expires_at, scopes from api_client_authorizations where api_token=$1", tokensecret).Scan(&resp.UUID, &resp.APIToken, &exp, &scopes)
+	if err != nil {
+		return
+	}
+	resp.ExpiresAt = exp.String
+	if len(scopes) > 0 {
+		err = json.Unmarshal(scopes, &resp.Scopes)
+		if err != nil {
+			return resp, fmt.Errorf("unmarshal scopes: %s", err)
+		}
+	}
+	return
 }
diff --git a/lib/controller/localdb/login_ldap_docker_test.go b/lib/controller/localdb/login_ldap_docker_test.go
index 79b5f1615..3cbf14fe0 100644
--- a/lib/controller/localdb/login_ldap_docker_test.go
+++ b/lib/controller/localdb/login_ldap_docker_test.go
@@ -24,10 +24,13 @@ func (s *LDAPSuite) TestLoginLDAPViaPAM(c *check.C) {
 	if !haveDocker() {
 		c.Skip("skipping docker test because docker is not available")
 	}
+	pgproxy := newPgProxy(c, s.cluster)
+	defer pgproxy.Close()
+
 	cmd := exec.Command("bash", "login_ldap_docker_test.sh")
 	cmd.Stdout = os.Stderr
 	cmd.Stderr = os.Stderr
-	cmd.Env = append(os.Environ(), "config_method=pam")
+	cmd.Env = append(os.Environ(), "config_method=pam", "pgport="+pgproxy.Port())
 	err := cmd.Run()
 	c.Check(err, check.IsNil)
 }
@@ -39,10 +42,13 @@ func (s *LDAPSuite) TestLoginLDAPBuiltin(c *check.C) {
 	if !haveDocker() {
 		c.Skip("skipping docker test because docker is not available")
 	}
+	pgproxy := newPgProxy(c, s.cluster)
+	defer pgproxy.Close()
+
 	cmd := exec.Command("bash", "login_ldap_docker_test.sh")
 	cmd.Stdout = os.Stderr
 	cmd.Stderr = os.Stderr
-	cmd.Env = append(os.Environ(), "config_method=ldap")
+	cmd.Env = append(os.Environ(), "config_method=ldap", "pgport="+pgproxy.Port())
 	err := cmd.Run()
 	c.Check(err, check.IsNil)
 }
diff --git a/lib/controller/localdb/login_ldap_docker_test.sh b/lib/controller/localdb/login_ldap_docker_test.sh
index 4e0679f62..0225f2046 100755
--- a/lib/controller/localdb/login_ldap_docker_test.sh
+++ b/lib/controller/localdb/login_ldap_docker_test.sh
@@ -1,5 +1,9 @@
 #!/bin/bash
 
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
 # This script demonstrates using LDAP for Arvados user authentication.
 #
 # It configures arvados controller in a docker container, optionally
@@ -74,6 +78,7 @@ Clusters:
       Connection:
         client_encoding: utf8
         host: ${hostname}
+        port: "${pgport}"
         dbname: arvados_test
         user: arvados
         password: insecure_arvados_test
diff --git a/lib/controller/localdb/login_ldap_test.go b/lib/controller/localdb/login_ldap_test.go
index 9a8f83f85..34727d21b 100644
--- a/lib/controller/localdb/login_ldap_test.go
+++ b/lib/controller/localdb/login_ldap_test.go
@@ -7,6 +7,7 @@ package localdb
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"net"
 	"net/http"
 
@@ -26,6 +27,10 @@ type LDAPSuite struct {
 	cluster *arvados.Cluster
 	ctrl    *ldapLoginController
 	ldap    *godap.LDAPServer // fake ldap server that accepts auth goodusername/goodpassword
+
+	// transaction context
+	ctx      context.Context
+	finishtx func(*error)
 }
 
 func (s *LDAPSuite) TearDownSuite(c *check.C) {
@@ -87,8 +92,19 @@ func (s *LDAPSuite) SetUpSuite(c *check.C) {
 	}
 }
 
+func (s *LDAPSuite) SetUpTest(c *check.C) {
+	s.ctx, s.finishtx = starttx(context.Background(), clusterdb(s.cluster))
+}
+
+func (s *LDAPSuite) TearDownTest(c *check.C) {
+	err := errors.New("rollback")
+	s.finishtx(&err)
+}
+
 func (s *LDAPSuite) TestLoginSuccess(c *check.C) {
-	resp, err := s.ctrl.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+	conn := NewConn(s.cluster)
+	conn.loginController = s.ctrl
+	resp, err := conn.UserAuthenticate(s.ctx, arvados.UserAuthenticateOptions{
 		Username: "goodusername",
 		Password: "goodpassword",
 	})
@@ -97,7 +113,7 @@ func (s *LDAPSuite) TestLoginSuccess(c *check.C) {
 	c.Check(resp.UUID, check.Matches, `zzzzz-gj3su-.*`)
 	c.Check(resp.Scopes, check.DeepEquals, []string{"all"})
 
-	ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{"v2/" + resp.UUID + "/" + resp.APIToken}})
+	ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: []string{"v2/" + resp.UUID + "/" + resp.APIToken}})
 	user, err := railsproxy.NewConn(s.cluster).UserGetCurrent(ctx, arvados.GetOptions{})
 	c.Check(err, check.IsNil)
 	c.Check(user.Email, check.Equals, "goodusername at example.com")
@@ -107,7 +123,7 @@ func (s *LDAPSuite) TestLoginSuccess(c *check.C) {
 func (s *LDAPSuite) TestLoginFailure(c *check.C) {
 	// search returns no results
 	s.cluster.Login.LDAP.SearchBase = "dc=example,dc=invalid"
-	resp, err := s.ctrl.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+	resp, err := s.ctrl.UserAuthenticate(s.ctx, arvados.UserAuthenticateOptions{
 		Username: "goodusername",
 		Password: "goodpassword",
 	})
@@ -120,7 +136,7 @@ func (s *LDAPSuite) TestLoginFailure(c *check.C) {
 
 	// search returns result, but auth fails
 	s.cluster.Login.LDAP.SearchBase = "dc=example,dc=com"
-	resp, err = s.ctrl.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+	resp, err = s.ctrl.UserAuthenticate(s.ctx, arvados.UserAuthenticateOptions{
 		Username: "badusername",
 		Password: "badpassword",
 	})
diff --git a/lib/controller/localdb/login_oidc_test.go b/lib/controller/localdb/login_oidc_test.go
index 1345e8690..349dfd01a 100644
--- a/lib/controller/localdb/login_oidc_test.go
+++ b/lib/controller/localdb/login_oidc_test.go
@@ -11,6 +11,7 @@ import (
 	"crypto/rsa"
 	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
@@ -39,7 +40,6 @@ var _ = check.Suite(&OIDCLoginSuite{})
 
 type OIDCLoginSuite struct {
 	cluster               *arvados.Cluster
-	ctx                   context.Context
 	localdb               *Conn
 	railsSpy              *arvadostest.Proxy
 	fakeIssuer            *httptest.Server
@@ -55,6 +55,10 @@ type OIDCLoginSuite struct {
 	authEmail         string
 	authEmailVerified bool
 	authName          string
+
+	// transaction context
+	ctx      context.Context
+	finishtx func(*error)
 }
 
 func (s *OIDCLoginSuite) TearDownSuite(c *check.C) {
@@ -182,20 +186,24 @@ func (s *OIDCLoginSuite) SetUpTest(c *check.C) {
 
 	s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
 	*s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
+
+	s.ctx, s.finishtx = starttx(context.Background(), clusterdb(s.cluster))
 }
 
 func (s *OIDCLoginSuite) TearDownTest(c *check.C) {
+	err := errors.New("rollback")
+	s.finishtx(&err)
 	s.railsSpy.Close()
 }
 
 func (s *OIDCLoginSuite) TestGoogleLogout(c *check.C) {
-	resp, err := s.localdb.Logout(context.Background(), arvados.LogoutOptions{ReturnTo: "https://foo.example.com/bar"})
+	resp, err := s.localdb.Logout(s.ctx, arvados.LogoutOptions{ReturnTo: "https://foo.example.com/bar"})
 	c.Check(err, check.IsNil)
 	c.Check(resp.RedirectLocation, check.Equals, "https://foo.example.com/bar")
 }
 
 func (s *OIDCLoginSuite) TestGoogleLogin_Start_Bogus(c *check.C) {
-	resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{})
+	resp, err := s.localdb.Login(s.ctx, arvados.LoginOptions{})
 	c.Check(err, check.IsNil)
 	c.Check(resp.RedirectLocation, check.Equals, "")
 	c.Check(resp.HTML.String(), check.Matches, `.*missing return_to parameter.*`)
@@ -203,7 +211,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_Start_Bogus(c *check.C) {
 
 func (s *OIDCLoginSuite) TestGoogleLogin_Start(c *check.C) {
 	for _, remote := range []string{"", "zzzzz"} {
-		resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{Remote: remote, ReturnTo: "https://app.example.com/foo?bar"})
+		resp, err := s.localdb.Login(s.ctx, arvados.LoginOptions{Remote: remote, ReturnTo: "https://app.example.com/foo?bar"})
 		c.Check(err, check.IsNil)
 		target, err := url.Parse(resp.RedirectLocation)
 		c.Check(err, check.IsNil)
@@ -221,7 +229,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_Start(c *check.C) {
 
 func (s *OIDCLoginSuite) TestGoogleLogin_InvalidCode(c *check.C) {
 	state := s.startLogin(c)
-	resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
+	resp, err := s.localdb.Login(s.ctx, arvados.LoginOptions{
 		Code:  "first-try-a-bogus-code",
 		State: state,
 	})
@@ -232,7 +240,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_InvalidCode(c *check.C) {
 
 func (s *OIDCLoginSuite) TestGoogleLogin_InvalidState(c *check.C) {
 	s.startLogin(c)
-	resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
+	resp, err := s.localdb.Login(s.ctx, arvados.LoginOptions{
 		Code:  s.validCode,
 		State: "bogus-state",
 	})
@@ -254,7 +262,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) {
 	s.authEmail = "joe.smith at primary.example.com"
 	s.setupPeopleAPIError(c)
 	state := s.startLogin(c)
-	_, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
+	_, err := s.localdb.Login(s.ctx, arvados.LoginOptions{
 		Code:  s.validCode,
 		State: state,
 	})
@@ -294,7 +302,7 @@ func (s *OIDCLoginSuite) TestConfig(c *check.C) {
 func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIError(c *check.C) {
 	s.setupPeopleAPIError(c)
 	state := s.startLogin(c)
-	resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
+	resp, err := s.localdb.Login(s.ctx, arvados.LoginOptions{
 		Code:  s.validCode,
 		State: state,
 	})
@@ -368,7 +376,7 @@ func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
 		*s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
 
 		state := s.startLogin(c)
-		resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
+		resp, err := s.localdb.Login(s.ctx, arvados.LoginOptions{
 			Code:  s.validCode,
 			State: state,
 		})
@@ -399,7 +407,7 @@ func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
 
 func (s *OIDCLoginSuite) TestGoogleLogin_Success(c *check.C) {
 	state := s.startLogin(c)
-	resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
+	resp, err := s.localdb.Login(s.ctx, arvados.LoginOptions{
 		Code:  s.validCode,
 		State: state,
 	})
@@ -420,7 +428,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_Success(c *check.C) {
 
 	// Try using the returned Arvados token.
 	c.Logf("trying an API call with new token %q", token)
-	ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{token}})
+	ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: []string{token}})
 	cl, err := s.localdb.CollectionList(ctx, arvados.ListOptions{Limit: -1})
 	c.Check(cl.ItemsAvailable, check.Not(check.Equals), 0)
 	c.Check(cl.Items, check.Not(check.HasLen), 0)
@@ -429,7 +437,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_Success(c *check.C) {
 	// Might as well check that bogus tokens aren't accepted.
 	badtoken := token + "plussomeboguschars"
 	c.Logf("trying an API call with mangled token %q", badtoken)
-	ctx = auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{badtoken}})
+	ctx = auth.NewContext(s.ctx, &auth.Credentials{Tokens: []string{badtoken}})
 	cl, err = s.localdb.CollectionList(ctx, arvados.ListOptions{Limit: -1})
 	c.Check(cl.Items, check.HasLen, 0)
 	c.Check(err, check.NotNil)
@@ -453,7 +461,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_RealName(c *check.C) {
 		},
 	}
 	state := s.startLogin(c)
-	s.localdb.Login(context.Background(), arvados.LoginOptions{
+	s.localdb.Login(s.ctx, arvados.LoginOptions{
 		Code:  s.validCode,
 		State: state,
 	})
@@ -467,7 +475,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) {
 	s.authName = "Joe P. Smith"
 	s.authEmail = "joe.smith at primary.example.com"
 	state := s.startLogin(c)
-	s.localdb.Login(context.Background(), arvados.LoginOptions{
+	s.localdb.Login(s.ctx, arvados.LoginOptions{
 		Code:  s.validCode,
 		State: state,
 	})
@@ -496,7 +504,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) {
 		},
 	}
 	state := s.startLogin(c)
-	s.localdb.Login(context.Background(), arvados.LoginOptions{
+	s.localdb.Login(s.ctx, arvados.LoginOptions{
 		Code:  s.validCode,
 		State: state,
 	})
@@ -526,7 +534,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *chec
 		},
 	}
 	state := s.startLogin(c)
-	s.localdb.Login(context.Background(), arvados.LoginOptions{
+	s.localdb.Login(s.ctx, arvados.LoginOptions{
 		Code:  s.validCode,
 		State: state,
 	})
@@ -552,7 +560,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
 		},
 	}
 	state := s.startLogin(c)
-	s.localdb.Login(context.Background(), arvados.LoginOptions{
+	s.localdb.Login(s.ctx, arvados.LoginOptions{
 		Code:  s.validCode,
 		State: state,
 	})
@@ -566,7 +574,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
 func (s *OIDCLoginSuite) startLogin(c *check.C) (state string) {
 	// Initiate login, but instead of following the redirect to
 	// the provider, just grab state from the redirect URL.
-	resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ReturnTo: "https://app.example.com/foo?bar"})
+	resp, err := s.localdb.Login(s.ctx, arvados.LoginOptions{ReturnTo: "https://app.example.com/foo?bar"})
 	c.Check(err, check.IsNil)
 	target, err := url.Parse(resp.RedirectLocation)
 	c.Check(err, check.IsNil)
diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index c347e2f79..bfb06b305 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -19,144 +19,159 @@ import (
 )
 
 type router struct {
-	mux *mux.Router
-	fed arvados.API
+	mux       *mux.Router
+	backend   arvados.API
+	wrapcalls []func(RoutableFunc) RoutableFunc
 }
 
-func New(fed arvados.API) *router {
+// New returns a new router (which implements the http.Handler
+// interface) that serves requests by calling Arvados API methods on
+// the given backend.
+func New(backend arvados.API, modifiers ...modifier) *router {
 	rtr := &router{
-		mux: mux.NewRouter(),
-		fed: fed,
+		mux:     mux.NewRouter(),
+		backend: backend,
+	}
+	for _, m := range modifiers {
+		m(rtr)
 	}
 	rtr.addRoutes()
 	return rtr
 }
 
-type routableFunc func(ctx context.Context, opts interface{}) (interface{}, error)
+type modifier func(*router)
+
+func WrapCalls(wrappers ...func(wrapped RoutableFunc) RoutableFunc) modifier {
+	return func(rtr *router) {
+		rtr.wrapcalls = append(rtr.wrapcalls, wrappers...)
+	}
+}
+
+type RoutableFunc func(ctx context.Context, opts interface{}) (interface{}, error)
 
 func (rtr *router) addRoutes() {
 	for _, route := range []struct {
 		endpoint    arvados.APIEndpoint
 		defaultOpts func() interface{}
-		exec        routableFunc
+		exec        RoutableFunc
 	}{
 		{
 			arvados.EndpointConfigGet,
 			func() interface{} { return &struct{}{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ConfigGet(ctx)
+				return rtr.backend.ConfigGet(ctx)
 			},
 		},
 		{
 			arvados.EndpointLogin,
 			func() interface{} { return &arvados.LoginOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.Login(ctx, *opts.(*arvados.LoginOptions))
+				return rtr.backend.Login(ctx, *opts.(*arvados.LoginOptions))
 			},
 		},
 		{
 			arvados.EndpointLogout,
 			func() interface{} { return &arvados.LogoutOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.Logout(ctx, *opts.(*arvados.LogoutOptions))
+				return rtr.backend.Logout(ctx, *opts.(*arvados.LogoutOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionCreate,
 			func() interface{} { return &arvados.CreateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionCreate(ctx, *opts.(*arvados.CreateOptions))
+				return rtr.backend.CollectionCreate(ctx, *opts.(*arvados.CreateOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionUpdate,
 			func() interface{} { return &arvados.UpdateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionUpdate(ctx, *opts.(*arvados.UpdateOptions))
+				return rtr.backend.CollectionUpdate(ctx, *opts.(*arvados.UpdateOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionGet,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionGet(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.CollectionGet(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionList,
 			func() interface{} { return &arvados.ListOptions{Limit: -1} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionList(ctx, *opts.(*arvados.ListOptions))
+				return rtr.backend.CollectionList(ctx, *opts.(*arvados.ListOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionProvenance,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionProvenance(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.CollectionProvenance(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionUsedBy,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionUsedBy(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.CollectionUsedBy(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionDelete,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionDelete(ctx, *opts.(*arvados.DeleteOptions))
+				return rtr.backend.CollectionDelete(ctx, *opts.(*arvados.DeleteOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionTrash,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionTrash(ctx, *opts.(*arvados.DeleteOptions))
+				return rtr.backend.CollectionTrash(ctx, *opts.(*arvados.DeleteOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionUntrash,
 			func() interface{} { return &arvados.UntrashOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionUntrash(ctx, *opts.(*arvados.UntrashOptions))
+				return rtr.backend.CollectionUntrash(ctx, *opts.(*arvados.UntrashOptions))
 			},
 		},
 		{
 			arvados.EndpointContainerCreate,
 			func() interface{} { return &arvados.CreateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerCreate(ctx, *opts.(*arvados.CreateOptions))
+				return rtr.backend.ContainerCreate(ctx, *opts.(*arvados.CreateOptions))
 			},
 		},
 		{
 			arvados.EndpointContainerUpdate,
 			func() interface{} { return &arvados.UpdateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerUpdate(ctx, *opts.(*arvados.UpdateOptions))
+				return rtr.backend.ContainerUpdate(ctx, *opts.(*arvados.UpdateOptions))
 			},
 		},
 		{
 			arvados.EndpointContainerGet,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerGet(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.ContainerGet(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointContainerList,
 			func() interface{} { return &arvados.ListOptions{Limit: -1} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerList(ctx, *opts.(*arvados.ListOptions))
+				return rtr.backend.ContainerList(ctx, *opts.(*arvados.ListOptions))
 			},
 		},
 		{
 			arvados.EndpointContainerDelete,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerDelete(ctx, *opts.(*arvados.DeleteOptions))
+				return rtr.backend.ContainerDelete(ctx, *opts.(*arvados.DeleteOptions))
 			},
 		},
 		{
@@ -165,7 +180,7 @@ func (rtr *router) addRoutes() {
 				return &arvados.GetOptions{Select: []string{"uuid", "state", "priority", "auth_uuid", "locked_by_uuid"}}
 			},
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerLock(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.ContainerLock(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
@@ -174,144 +189,148 @@ func (rtr *router) addRoutes() {
 				return &arvados.GetOptions{Select: []string{"uuid", "state", "priority", "auth_uuid", "locked_by_uuid"}}
 			},
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerUnlock(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.ContainerUnlock(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointSpecimenCreate,
 			func() interface{} { return &arvados.CreateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.SpecimenCreate(ctx, *opts.(*arvados.CreateOptions))
+				return rtr.backend.SpecimenCreate(ctx, *opts.(*arvados.CreateOptions))
 			},
 		},
 		{
 			arvados.EndpointSpecimenUpdate,
 			func() interface{} { return &arvados.UpdateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.SpecimenUpdate(ctx, *opts.(*arvados.UpdateOptions))
+				return rtr.backend.SpecimenUpdate(ctx, *opts.(*arvados.UpdateOptions))
 			},
 		},
 		{
 			arvados.EndpointSpecimenGet,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.SpecimenGet(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.SpecimenGet(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointSpecimenList,
 			func() interface{} { return &arvados.ListOptions{Limit: -1} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.SpecimenList(ctx, *opts.(*arvados.ListOptions))
+				return rtr.backend.SpecimenList(ctx, *opts.(*arvados.ListOptions))
 			},
 		},
 		{
 			arvados.EndpointSpecimenDelete,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.SpecimenDelete(ctx, *opts.(*arvados.DeleteOptions))
+				return rtr.backend.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))
+				return rtr.backend.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))
+				return rtr.backend.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))
+				return rtr.backend.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))
+				return rtr.backend.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))
+				return rtr.backend.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))
+				return rtr.backend.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))
+				return rtr.backend.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))
+				return rtr.backend.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))
+				return rtr.backend.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))
+				return rtr.backend.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))
+				return rtr.backend.UserList(ctx, *opts.(*arvados.ListOptions))
 			},
 		},
 		{
 			arvados.EndpointUserBatchUpdate,
 			func() interface{} { return &arvados.UserBatchUpdateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserBatchUpdate(ctx, *opts.(*arvados.UserBatchUpdateOptions))
+				return rtr.backend.UserBatchUpdate(ctx, *opts.(*arvados.UserBatchUpdateOptions))
 			},
 		},
 		{
 			arvados.EndpointUserDelete,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserDelete(ctx, *opts.(*arvados.DeleteOptions))
+				return rtr.backend.UserDelete(ctx, *opts.(*arvados.DeleteOptions))
 			},
 		},
 		{
 			arvados.EndpointUserAuthenticate,
 			func() interface{} { return &arvados.UserAuthenticateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserAuthenticate(ctx, *opts.(*arvados.UserAuthenticateOptions))
+				return rtr.backend.UserAuthenticate(ctx, *opts.(*arvados.UserAuthenticateOptions))
 			},
 		},
 	} {
-		rtr.addRoute(route.endpoint, route.defaultOpts, route.exec)
+		exec := route.exec
+		for _, w := range rtr.wrapcalls {
+			exec = w(exec)
+		}
+		rtr.addRoute(route.endpoint, route.defaultOpts, exec)
 	}
 	rtr.mux.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 		httpserver.Errors(w, []string{"API endpoint not found"}, http.StatusNotFound)
@@ -326,7 +345,7 @@ var altMethod = map[string]string{
 	"GET":   "HEAD", // Accept HEAD at any GET route
 }
 
-func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() interface{}, exec routableFunc) {
+func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() interface{}, exec RoutableFunc) {
 	methods := []string{endpoint.Method}
 	if alt, ok := altMethod[endpoint.Method]; ok {
 		methods = append(methods, alt)
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index 4cabe70f1..16f414bea 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -38,8 +38,8 @@ type RouterSuite struct {
 func (s *RouterSuite) SetUpTest(c *check.C) {
 	s.stub = arvadostest.APIStub{}
 	s.rtr = &router{
-		mux: mux.NewRouter(),
-		fed: &s.stub,
+		mux:     mux.NewRouter(),
+		backend: &s.stub,
 	}
 	s.rtr.addRoutes()
 }

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


hooks/post-receive
-- 




More information about the arvados-commits mailing list