Merge branch '15209-python-arv-deps-pinned'
authorLucas Di Pentima <ldipentima@veritasgenetics.com>
Thu, 27 Jun 2019 18:59:53 +0000 (15:59 -0300)
committerLucas Di Pentima <ldipentima@veritasgenetics.com>
Thu, 27 Jun 2019 18:59:53 +0000 (15:59 -0300)
Closes #15209

Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <ldipentima@veritasgenetics.com>

71 files changed:
apps/workbench/app/helpers/application_helper.rb
apps/workbench/app/models/arvados_api_client.rb
apps/workbench/test/controllers/application_controller_test.rb
apps/workbench/test/controllers/container_requests_controller_test.rb
apps/workbench/test/integration/pipeline_instances_test.rb
build/run-tests.sh
lib/cmdtest/leakcheck.go
lib/config/config.default.yml
lib/config/export.go
lib/config/generated_config.go
lib/controller/federation/conn.go [new file with mode: 0644]
lib/controller/federation_test.go
lib/controller/handler.go
lib/controller/handler_test.go
lib/controller/proxy.go
lib/controller/railsproxy/railsproxy.go [new file with mode: 0644]
lib/controller/router/checker_test.go [new file with mode: 0644]
lib/controller/router/error.go [new file with mode: 0644]
lib/controller/router/request.go [new file with mode: 0644]
lib/controller/router/request_test.go [new file with mode: 0644]
lib/controller/router/response.go [new file with mode: 0644]
lib/controller/router/router.go [new file with mode: 0644]
lib/controller/router/router_test.go [new file with mode: 0644]
lib/controller/rpc/conn.go [new file with mode: 0644]
lib/controller/rpc/conn_test.go [new file with mode: 0644]
lib/controller/server_test.go
lib/service/cmd.go
sdk/go/arvados/api.go [new file with mode: 0644]
sdk/go/arvados/api_client_authorization.go
sdk/go/arvados/client.go
sdk/go/arvados/collection.go
sdk/go/arvados/config.go
sdk/go/arvados/context.go [new file with mode: 0644]
sdk/go/arvados/error.go
sdk/go/arvados/fs_backend.go
sdk/go/arvados/fs_collection.go
sdk/go/arvados/fs_project_test.go
sdk/go/arvados/group.go
sdk/go/arvados/link.go
sdk/go/arvados/log.go
sdk/go/arvados/node.go
sdk/go/arvados/resource_list.go
sdk/go/arvados/resource_list_test.go
sdk/go/arvados/specimen.go [new file with mode: 0644]
sdk/go/arvados/user.go
sdk/go/arvados/workflow.go
sdk/go/arvadostest/api.go [new file with mode: 0644]
sdk/go/arvadostest/fixtures.go
sdk/go/auth/auth.go
sdk/go/auth/handlers.go
sdk/go/httpserver/error.go
sdk/go/httpserver/logger.go
sdk/go/httpserver/logger_test.go
sdk/go/httpserver/metrics.go
sdk/go/keepclient/keepclient.go
sdk/python/tests/nginx.conf
sdk/python/tests/run_test_server.py
services/api/app/models/collection.rb
services/api/test/fixtures/collections.yml
services/api/test/fixtures/container_requests.yml
services/api/test/unit/collection_test.rb
services/crunch-run/crunchrun.go
services/keep-balance/collection_test.go
services/keep-balance/server.go
services/keep-web/cache.go
services/keep-web/cadaver_test.go
services/keep-web/handler_test.go
services/keep-web/server.go
services/keepproxy/keepproxy.go
services/keepstore/handlers.go
services/ws/session_v0_test.go

index 83123b26c35c469e3efad7ec2833679f55445a39..6352916b00b14560af0683aa9594b6daf7ce20ca 100644 (file)
@@ -359,8 +359,8 @@ module ApplicationHelper
           display_value = link.name
         elsif value_info[:link_name]
           display_value = value_info[:link_name]
-        elsif value_info[:selection_name]
-          display_value = value_info[:selection_name]
+        elsif (sn = value_info[:selection_name]) && sn != ""
+          display_value = sn
         end
       end
       if (attr == :components) and (subattr.size > 2)
index 5a8fd518d386ec89125552c9fe17730e0488d4c4..ce91cd3057bf4915d8e673af9c93e9ec6fbea75d 100644 (file)
@@ -113,11 +113,13 @@ class ArvadosApiClient
     # Clean up /arvados/v1/../../discovery/v1 to /discovery/v1
     url.sub! '/arvados/v1/../../', '/'
 
+    anon_tokens = [Rails.configuration.anonymous_user_token].select { |x| x && include_anon_token }
+
     query = {
       'reader_tokens' => ((tokens[:reader_tokens] ||
                            Thread.current[:reader_tokens] ||
                            []) +
-                          (include_anon_token ? [Rails.configuration.anonymous_user_token] : [])).to_json,
+                          anon_tokens).to_json,
     }
     if !data.nil?
       data.each do |k,v|
index 1b13d8f328def28af9064afff27191a6052195fe..b908c46def39664abb4b1715cba6b4f996a1caab 100644 (file)
@@ -325,9 +325,9 @@ class ApplicationControllerTest < ActionController::TestCase
     # Each pdh has more than one collection; however, we should get only one for each
     assert collections.size == 2, 'Expected two objects in the preloaded collection hash'
     assert collections[pdh1], 'Expected collections for the passed in pdh #{pdh1}'
-    assert_equal collections[pdh1].size, 1, 'Expected one collection for the passed in pdh #{pdh1}'
+    assert_equal collections[pdh1].size, 1, "Expected one collection for the passed in pdh #{pdh1}"
     assert collections[pdh2], 'Expected collections for the passed in pdh #{pdh2}'
-    assert_equal collections[pdh2].size, 1, 'Expected one collection for the passed in pdh #{pdh2}'
+    assert_equal collections[pdh2].size, 1, "Expected one collection for the passed in pdh #{pdh2}"
   end
 
   test "requesting a nonexistent object returns 404" do
index 93686aa6b14668d762185a5f19a5431d8398a60f..140b59fa5e7d0d2c923d974a3537ff501e0647af 100644 (file)
@@ -137,7 +137,7 @@ class ContainerRequestsControllerTest < ActionController::TestCase
     assert_includes @response.body, "href=\"\/collections/fa7aeb5140e2848d39b416daeef4ffc5+45/foobar\?" # locator on command
     assert_includes @response.body, "href=\"\/collections/fa7aeb5140e2848d39b416daeef4ffc5+45/foo" # mount input1
     assert_includes @response.body, "href=\"\/collections/fa7aeb5140e2848d39b416daeef4ffc5+45/bar" # mount input2
-    assert_includes @response.body, "href=\"\/collections/1fd08fc162a5c6413070a8bd0bffc818+150" # mount workflow
+    assert_includes @response.body, "href=\"\/collections/f9ddda46bb293b6847da984e3aa735db+290" # mount workflow
     assert_includes @response.body, "href=\"#Log\""
     assert_includes @response.body, "href=\"#Provenance\""
   end
index 801609fbb6747f74ee52d0e0bc38b9d7a57ae12b..adfd62bd8e04c73767ed2756a442ee15442691ec 100644 (file)
@@ -393,6 +393,7 @@ class PipelineInstancesTest < ActionDispatch::IntegrationTest
   def create_and_run_pipeline_in_aproject in_aproject, template_name, collection_fixture, choose_file=false
     # collection in aproject to be used as input
     collection = api_fixture('collections', collection_fixture)
+    collection['name'] ||= '' # API response is "" even if fixture attr is null
 
     # create a pipeline instance
     find('.btn', text: 'Run a process').click
@@ -421,7 +422,7 @@ class PipelineInstancesTest < ActionDispatch::IntegrationTest
 
       if collection_fixture == 'foo_collection_in_aproject'
         first('span', text: 'foo_tag').click
-      elsif collection['name']
+      elsif collection['name'] != ''
         first('span', text: "#{collection['name']}").click
       else
         collection_uuid = collection['uuid']
index 6f8f117744e431a4ce166f396de35725fc00da4d..b9dcd777fa55a0b37b9e03ad15c9f81bfd0cf642 100755 (executable)
@@ -77,6 +77,10 @@ doc
 lib/cli
 lib/cmd
 lib/controller
+lib/controller/federation
+lib/controller/railsproxy
+lib/controller/router
+lib/controller/rpc
 lib/crunchstat
 lib/cloud
 lib/cloud/azure
@@ -983,52 +987,7 @@ pythonstuff=(
 )
 
 declare -a gostuff
-gostuff=(
-    cmd/arvados-client
-    cmd/arvados-server
-    lib/cli
-    lib/cmd
-    lib/controller
-    lib/crunchstat
-    lib/cloud
-    lib/cloud/azure
-    lib/cloud/ec2
-    lib/cloud/cloudtest
-    lib/config
-    lib/dispatchcloud
-    lib/dispatchcloud/container
-    lib/dispatchcloud/scheduler
-    lib/dispatchcloud/ssh_executor
-    lib/dispatchcloud/worker
-    lib/service
-    sdk/go/arvados
-    sdk/go/arvadosclient
-    sdk/go/auth
-    sdk/go/blockdigest
-    sdk/go/dispatch
-    sdk/go/health
-    sdk/go/httpserver
-    sdk/go/manifest
-    sdk/go/asyncbuf
-    sdk/go/crunchrunner
-    sdk/go/stats
-    services/arv-git-httpd
-    services/crunchstat
-    services/health
-    services/keep-web
-    services/keepstore
-    sdk/go/keepclient
-    services/keep-balance
-    services/keepproxy
-    services/crunch-dispatch-local
-    services/crunch-dispatch-slurm
-    services/crunch-run
-    services/ws
-    tools/keep-block-check
-    tools/keep-exercise
-    tools/keep-rsync
-    tools/sync-groups
-)
+gostuff=($(git grep -lw func | grep \\.go | sed -e 's/\/[^\/]*$//' | sort -u))
 
 install_apps/workbench() {
     cd "$WORKSPACE/apps/workbench" \
@@ -1270,19 +1229,21 @@ else
             # assume emacs, or something, is offering a history buffer
             # and pre-populating the command will only cause trouble
             nextcmd=
-        elif [[ "$nextcmd" != "install deps" ]]; then
-            :
-        elif [[ -e "$VENVDIR/bin/activate" ]]; then
-            nextcmd="test lib/cmd"
-        else
+        elif [[ ! -e "$VENVDIR/bin/activate" ]]; then
             nextcmd="install deps"
+        else
+            nextcmd=""
         fi
     }
     echo
     help_interactive
     nextcmd="install deps"
     setnextcmd
-    while read -p 'What next? ' -e -i "${nextcmd}" nextcmd; do
+    HISTFILE="$WORKSPACE/tmp/.history"
+    history -r
+    while read -p 'What next? ' -e -i "$nextcmd" nextcmd; do
+        history -s "$nextcmd"
+        history -w
         read verb target opts <<<"${nextcmd}"
         target="${target%/}"
         target="${target/\/:/:}"
index c132f1b3600664f3c959d7c86463aebcb19fed3c..ba4c3c123cabb7a6ad018c836a51b36fac6fada7 100644 (file)
@@ -43,7 +43,7 @@ func LeakCheck(c *check.C) func() {
                os.Stdout, os.Stderr = stdout, stderr
 
                for i, tmpfile := range tmpfiles {
-                       c.Log("checking %s", i)
+                       c.Logf("checking %s", i)
                        _, err := tmpfile.Seek(0, io.SeekStart)
                        c.Assert(err, check.IsNil)
                        leaked, err := ioutil.ReadAll(tmpfile)
index 7a31d03504cbe5214476f23ba1dbfca10199b168..8e6ed7f2ca3127fc6539b2280914b4ad3b85927d 100644 (file)
@@ -693,3 +693,6 @@ Clusters:
       # Workbench2 configs
       VocabularyURL: ""
       FileViewersConfigURL: ""
+
+    # Use experimental controller code (see https://dev.arvados.org/issues/14287)
+    EnableBetaController14287: false
index 2f79c2b2969152469d61167306031ce496514537..faf542e6f773c6192779c7aa4dbb5ed0675fe0c5 100644 (file)
@@ -101,6 +101,7 @@ var whitelist = map[string]bool{
        "Containers.StaleLockTimeout":                false,
        "Containers.SupportedDockerImageFormats":     true,
        "Containers.UsePreemptibleInstances":         true,
+       "EnableBetaController14287":                  false,
        "Git":                                        false,
        "InstanceTypes":                              true,
        "InstanceTypes.*":                            true,
index 763e7b2bce70a9b0ee7337fc0ebac6b958389f20..5e5222d11e28b9265d1270befb30cf4d92c9feea 100644 (file)
@@ -699,4 +699,7 @@ Clusters:
       # Workbench2 configs
       VocabularyURL: ""
       FileViewersConfigURL: ""
+
+    # Use experimental controller code (see https://dev.arvados.org/issues/14287)
+    EnableBetaController14287: false
 `)
diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
new file mode 100644 (file)
index 0000000..e094953
--- /dev/null
@@ -0,0 +1,310 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package federation
+
+import (
+       "context"
+       "crypto/md5"
+       "errors"
+       "fmt"
+       "net/http"
+       "net/url"
+       "regexp"
+       "strings"
+
+       "git.curoverse.com/arvados.git/lib/controller/railsproxy"
+       "git.curoverse.com/arvados.git/lib/controller/rpc"
+       "git.curoverse.com/arvados.git/sdk/go/arvados"
+       "git.curoverse.com/arvados.git/sdk/go/auth"
+       "git.curoverse.com/arvados.git/sdk/go/ctxlog"
+)
+
+type Conn struct {
+       cluster *arvados.Cluster
+       local   backend
+       remotes map[string]backend
+}
+
+func New(cluster *arvados.Cluster) arvados.API {
+       local := railsproxy.NewConn(cluster)
+       remotes := map[string]backend{}
+       for id, remote := range cluster.RemoteClusters {
+               if !remote.Proxy {
+                       continue
+               }
+               remotes[id] = rpc.NewConn(id, &url.URL{Scheme: remote.Scheme, Host: remote.Host}, remote.Insecure, saltedTokenProvider(local, id))
+       }
+
+       return &Conn{
+               cluster: cluster,
+               local:   local,
+               remotes: remotes,
+       }
+}
+
+// Return a new rpc.TokenProvider that takes the client-provided
+// tokens from an incoming request context, determines whether they
+// should (and can) be salted for the given remoteID, and returns the
+// resulting tokens.
+func saltedTokenProvider(local backend, remoteID string) rpc.TokenProvider {
+       return func(ctx context.Context) ([]string, error) {
+               var tokens []string
+               incoming, ok := auth.FromContext(ctx)
+               if !ok {
+                       return nil, errors.New("no token provided")
+               }
+               for _, token := range incoming.Tokens {
+                       salted, err := auth.SaltToken(token, remoteID)
+                       switch err {
+                       case nil:
+                               tokens = append(tokens, salted)
+                       case auth.ErrSalted:
+                               tokens = append(tokens, token)
+                       case auth.ErrObsoleteToken:
+                               ctx := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{token}})
+                               aca, err := local.APIClientAuthorizationCurrent(ctx, arvados.GetOptions{})
+                               if errStatus(err) == http.StatusUnauthorized {
+                                       // pass through unmodified
+                                       tokens = append(tokens, token)
+                                       continue
+                               } else if err != nil {
+                                       return nil, err
+                               }
+                               salted, err := auth.SaltToken(aca.TokenV2(), remoteID)
+                               if err != nil {
+                                       return nil, err
+                               }
+                               tokens = append(tokens, salted)
+                       default:
+                               return nil, err
+                       }
+               }
+               return tokens, nil
+       }
+}
+
+// Return suitable backend for a query about the given cluster ID
+// ("aaaaa") or object UUID ("aaaaa-dz642-abcdefghijklmno").
+func (conn *Conn) chooseBackend(id string) backend {
+       if len(id) == 27 {
+               id = id[:5]
+       } else if len(id) != 5 {
+               // PDH or bogus ID
+               return conn.local
+       }
+       if id == conn.cluster.ClusterID {
+               return conn.local
+       } else if be, ok := conn.remotes[id]; ok {
+               return be
+       } else {
+               // TODO: return an "always error" backend?
+               return conn.local
+       }
+}
+
+// Call fn with the local backend; then, if fn returned 404, call fn
+// on the available remote backends (possibly concurrently) until one
+// succeeds.
+//
+// The second argument to fn is the cluster ID of the remote backend,
+// or "" for the local backend.
+//
+// A non-nil error means all backends failed.
+func (conn *Conn) tryLocalThenRemotes(ctx context.Context, fn func(context.Context, string, backend) error) error {
+       if err := fn(ctx, "", conn.local); err == nil || errStatus(err) != http.StatusNotFound {
+               return err
+       }
+
+       ctx, cancel := context.WithCancel(ctx)
+       defer cancel()
+       errchan := make(chan error, len(conn.remotes))
+       for remoteID, be := range conn.remotes {
+               remoteID, be := remoteID, be
+               go func() {
+                       errchan <- fn(ctx, remoteID, be)
+               }()
+       }
+       all404 := true
+       var errs []error
+       for i := 0; i < cap(errchan); i++ {
+               err := <-errchan
+               if err == nil {
+                       return nil
+               }
+               all404 = all404 && errStatus(err) == http.StatusNotFound
+               errs = append(errs, err)
+       }
+       if all404 {
+               return notFoundError{}
+       }
+       // FIXME: choose appropriate HTTP status
+       return fmt.Errorf("errors: %v", errs)
+}
+
+func (conn *Conn) CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error) {
+       return conn.chooseBackend(options.ClusterID).CollectionCreate(ctx, options)
+}
+
+func (conn *Conn) CollectionUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Collection, error) {
+       return conn.chooseBackend(options.UUID).CollectionUpdate(ctx, options)
+}
+
+func rewriteManifest(mt, remoteID string) string {
+       return regexp.MustCompile(` [0-9a-f]{32}\+[^ ]*`).ReplaceAllStringFunc(mt, func(tok string) string {
+               return strings.Replace(tok, "+A", "+R"+remoteID+"-", -1)
+       })
+}
+
+// this could be in sdk/go/arvados
+func portableDataHash(mt string) string {
+       h := md5.New()
+       blkRe := regexp.MustCompile(`^ [0-9a-f]{32}\+\d+`)
+       size := 0
+       _ = regexp.MustCompile(` ?[^ ]*`).ReplaceAllFunc([]byte(mt), func(tok []byte) []byte {
+               if m := blkRe.Find(tok); m != nil {
+                       // write hash+size, ignore remaining block hints
+                       tok = m
+               }
+               n, err := h.Write(tok)
+               if err != nil {
+                       panic(err)
+               }
+               size += n
+               return nil
+       })
+       return fmt.Sprintf("%x+%d", h.Sum(nil), size)
+}
+
+func (conn *Conn) CollectionGet(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
+       if len(options.UUID) == 27 {
+               // UUID is really a UUID
+               c, err := conn.chooseBackend(options.UUID).CollectionGet(ctx, options)
+               if err == nil && options.UUID[:5] != conn.cluster.ClusterID {
+                       c.ManifestText = rewriteManifest(c.ManifestText, options.UUID[:5])
+               }
+               return c, err
+       } else {
+               // UUID is a PDH
+               first := make(chan arvados.Collection, 1)
+               err := conn.tryLocalThenRemotes(ctx, func(ctx context.Context, remoteID string, be backend) error {
+                       c, err := be.CollectionGet(ctx, options)
+                       if err != nil {
+                               return err
+                       }
+                       // options.UUID is either hash+size or
+                       // hash+size+hints; only hash+size need to
+                       // match the computed PDH.
+                       if pdh := portableDataHash(c.ManifestText); pdh != options.UUID && !strings.HasPrefix(options.UUID, pdh+"+") {
+                               ctxlog.FromContext(ctx).Warnf("bad portable data hash %q received from remote %q (expected %q)", pdh, remoteID, options.UUID)
+                               return notFoundError{}
+                       }
+                       if remoteID != "" {
+                               c.ManifestText = rewriteManifest(c.ManifestText, remoteID)
+                       }
+                       select {
+                       case first <- c:
+                               return nil
+                       default:
+                               // lost race, return value doesn't matter
+                               return nil
+                       }
+               })
+               if err != nil {
+                       return arvados.Collection{}, err
+               }
+               return <-first, nil
+       }
+}
+
+func (conn *Conn) CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error) {
+       return conn.local.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)
+}
+
+func (conn *Conn) CollectionUsedBy(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error) {
+       return conn.chooseBackend(options.UUID).CollectionUsedBy(ctx, options)
+}
+
+func (conn *Conn) CollectionDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error) {
+       return conn.chooseBackend(options.UUID).CollectionDelete(ctx, options)
+}
+
+func (conn *Conn) CollectionTrash(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error) {
+       return conn.chooseBackend(options.UUID).CollectionTrash(ctx, options)
+}
+
+func (conn *Conn) CollectionUntrash(ctx context.Context, options arvados.UntrashOptions) (arvados.Collection, error) {
+       return conn.chooseBackend(options.UUID).CollectionUntrash(ctx, options)
+}
+
+func (conn *Conn) ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error) {
+       return conn.chooseBackend(options.ClusterID).ContainerCreate(ctx, options)
+}
+
+func (conn *Conn) ContainerUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Container, error) {
+       return conn.chooseBackend(options.UUID).ContainerUpdate(ctx, options)
+}
+
+func (conn *Conn) ContainerGet(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+       return conn.chooseBackend(options.UUID).ContainerGet(ctx, options)
+}
+
+func (conn *Conn) ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
+       return conn.local.ContainerList(ctx, options)
+}
+
+func (conn *Conn) ContainerDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Container, error) {
+       return conn.chooseBackend(options.UUID).ContainerDelete(ctx, options)
+}
+
+func (conn *Conn) ContainerLock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+       return conn.chooseBackend(options.UUID).ContainerLock(ctx, options)
+}
+
+func (conn *Conn) ContainerUnlock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+       return conn.chooseBackend(options.UUID).ContainerUnlock(ctx, options)
+}
+
+func (conn *Conn) SpecimenCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Specimen, error) {
+       return conn.chooseBackend(options.ClusterID).SpecimenCreate(ctx, options)
+}
+
+func (conn *Conn) SpecimenUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Specimen, error) {
+       return conn.chooseBackend(options.UUID).SpecimenUpdate(ctx, options)
+}
+
+func (conn *Conn) SpecimenGet(ctx context.Context, options arvados.GetOptions) (arvados.Specimen, error) {
+       return conn.chooseBackend(options.UUID).SpecimenGet(ctx, options)
+}
+
+func (conn *Conn) SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
+       return conn.local.SpecimenList(ctx, options)
+}
+
+func (conn *Conn) SpecimenDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Specimen, error) {
+       return conn.chooseBackend(options.UUID).SpecimenDelete(ctx, options)
+}
+
+func (conn *Conn) APIClientAuthorizationCurrent(ctx context.Context, options arvados.GetOptions) (arvados.APIClientAuthorization, error) {
+       return conn.chooseBackend(options.UUID).APIClientAuthorizationCurrent(ctx, options)
+}
+
+type backend interface{ arvados.API }
+
+type notFoundError struct{}
+
+func (notFoundError) HTTPStatus() int { return http.StatusNotFound }
+func (notFoundError) Error() string   { return "not found" }
+
+func errStatus(err error) int {
+       if httpErr, ok := err.(interface{ HTTPStatus() int }); ok {
+               return httpErr.HTTPStatus()
+       } else {
+               return http.StatusInternalServerError
+       }
+}
index 7d8e7a4334ae98bd727ed62725d1acea09746ae9..f7735a3053fdd377b00d5b2e8097375d49910237 100644 (file)
@@ -6,6 +6,7 @@ package controller
 
 import (
        "bytes"
+       "context"
        "encoding/json"
        "fmt"
        "io"
@@ -39,7 +40,8 @@ type FederationSuite struct {
        // provided by the integration test environment.
        remoteServer *httpserver.Server
        // remoteMock ("zmock") appends each incoming request to
-       // remoteMockRequests, and returns an empty 200 response.
+       // remoteMockRequests, and returns 200 with an empty JSON
+       // object.
        remoteMock         *httpserver.Server
        remoteMockRequests []http.Request
 }
@@ -55,8 +57,9 @@ func (s *FederationSuite) SetUpTest(c *check.C) {
        c.Assert(s.remoteMock.Start(), check.IsNil)
 
        cluster := &arvados.Cluster{
-               ClusterID:  "zhome",
-               PostgreSQL: integrationTestCluster().PostgreSQL,
+               ClusterID:                 "zhome",
+               PostgreSQL:                integrationTestCluster().PostgreSQL,
+               EnableBetaController14287: enableBetaController14287,
        }
        cluster.TLS.Insecure = true
        cluster.API.MaxItemsPerResponse = 1000
@@ -65,7 +68,9 @@ func (s *FederationSuite) SetUpTest(c *check.C) {
        arvadostest.SetServiceURL(&cluster.Services.Controller, "http://localhost:/")
        s.testHandler = &Handler{Cluster: cluster}
        s.testServer = newServerFromIntegrationTestEnv(c)
-       s.testServer.Server.Handler = httpserver.AddRequestIDs(httpserver.LogRequests(s.log, s.testHandler))
+       s.testServer.Server.Handler = httpserver.HandlerWithContext(
+               ctxlog.Context(context.Background(), s.log),
+               httpserver.AddRequestIDs(httpserver.LogRequests(s.testHandler)))
 
        cluster.RemoteClusters = map[string]arvados.RemoteCluster{
                "zzzzz": {
@@ -91,6 +96,8 @@ func (s *FederationSuite) remoteMockHandler(w http.ResponseWriter, req *http.Req
        req.Body.Close()
        req.Body = ioutil.NopCloser(b)
        s.remoteMockRequests = append(s.remoteMockRequests, *req)
+       // Repond 200 with a valid JSON object
+       fmt.Fprint(w, "{}")
 }
 
 func (s *FederationSuite) TearDownTest(c *check.C) {
@@ -102,15 +109,15 @@ func (s *FederationSuite) TearDownTest(c *check.C) {
        }
 }
 
-func (s *FederationSuite) testRequest(req *http.Request) *http.Response {
+func (s *FederationSuite) testRequest(req *http.Request) *httptest.ResponseRecorder {
        resp := httptest.NewRecorder()
        s.testServer.Server.Handler.ServeHTTP(resp, req)
-       return resp.Result()
+       return resp
 }
 
 func (s *FederationSuite) TestLocalRequest(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zhome-", 1), nil)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        s.checkHandledLocally(c, resp)
 }
 
@@ -125,7 +132,7 @@ func (s *FederationSuite) checkHandledLocally(c *check.C, resp *http.Response) {
 
 func (s *FederationSuite) TestNoAuth(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusUnauthorized)
        s.checkJSONErrorMatches(c, resp, `Not logged in`)
 }
@@ -133,7 +140,7 @@ func (s *FederationSuite) TestNoAuth(c *check.C) {
 func (s *FederationSuite) TestBadAuth(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
        req.Header.Set("Authorization", "Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusUnauthorized)
        s.checkJSONErrorMatches(c, resp, `Not logged in`)
 }
@@ -141,7 +148,7 @@ func (s *FederationSuite) TestBadAuth(c *check.C) {
 func (s *FederationSuite) TestNoAccess(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.SpectatorToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
        s.checkJSONErrorMatches(c, resp, `.*not found`)
 }
@@ -149,7 +156,7 @@ func (s *FederationSuite) TestNoAccess(c *check.C) {
 func (s *FederationSuite) TestGetUnknownRemote(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zz404-", 1), nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
        s.checkJSONErrorMatches(c, resp, `.*no proxy available for cluster zz404`)
 }
@@ -161,7 +168,7 @@ func (s *FederationSuite) TestRemoteError(c *check.C) {
 
        req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusBadGateway)
        s.checkJSONErrorMatches(c, resp, `.*HTTP response to HTTPS client`)
 }
@@ -169,7 +176,7 @@ func (s *FederationSuite) TestRemoteError(c *check.C) {
 func (s *FederationSuite) TestGetRemoteWorkflow(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var wf arvados.Workflow
        c.Check(json.NewDecoder(resp.Body).Decode(&wf), check.IsNil)
@@ -180,7 +187,7 @@ func (s *FederationSuite) TestGetRemoteWorkflow(c *check.C) {
 func (s *FederationSuite) TestOptionsMethod(c *check.C) {
        req := httptest.NewRequest("OPTIONS", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
        req.Header.Set("Origin", "https://example.com")
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        body, err := ioutil.ReadAll(resp.Body)
        c.Check(err, check.IsNil)
@@ -196,7 +203,7 @@ func (s *FederationSuite) TestOptionsMethod(c *check.C) {
 
 func (s *FederationSuite) TestRemoteWithTokenInQuery(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zmock-", 1)+"?api_token="+arvadostest.ActiveToken, nil)
-       s.testRequest(req)
+       s.testRequest(req).Result()
        c.Assert(s.remoteMockRequests, check.HasLen, 1)
        pr := s.remoteMockRequests[0]
        // Token is salted and moved from query to Authorization header.
@@ -205,39 +212,58 @@ func (s *FederationSuite) TestRemoteWithTokenInQuery(c *check.C) {
 }
 
 func (s *FederationSuite) TestLocalTokenSalted(c *check.C) {
-       req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zmock-", 1), nil)
-       req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       s.testRequest(req)
-       c.Assert(s.remoteMockRequests, check.HasLen, 1)
-       pr := s.remoteMockRequests[0]
-       // The salted token here has a "zzzzz-" UUID instead of a
-       // "ztest-" UUID because ztest's local database has the
-       // "zzzzz-" test fixtures. The "secret" part is HMAC(sha1,
-       // arvadostest.ActiveToken, "zmock") = "7fd3...".
-       c.Check(pr.Header.Get("Authorization"), check.Equals, "Bearer v2/zzzzz-gj3su-077z32aux8dg2s1/7fd31b61f39c0e82a4155592163218272cedacdc")
+       defer s.localServiceReturns404(c).Close()
+       for _, path := range []string{
+               // During the transition to the strongly typed
+               // controller implementation (#14287), workflows and
+               // collections test different code paths.
+               "/arvados/v1/workflows/" + strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zmock-", 1),
+               "/arvados/v1/collections/" + strings.Replace(arvadostest.UserAgreementCollection, "zzzzz-", "zmock-", 1),
+       } {
+               c.Log("testing path ", path)
+               s.remoteMockRequests = nil
+               req := httptest.NewRequest("GET", path, nil)
+               req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
+               s.testRequest(req).Result()
+               c.Assert(s.remoteMockRequests, check.HasLen, 1)
+               pr := s.remoteMockRequests[0]
+               // The salted token here has a "zzzzz-" UUID instead of a
+               // "ztest-" UUID because ztest's local database has the
+               // "zzzzz-" test fixtures. The "secret" part is HMAC(sha1,
+               // arvadostest.ActiveToken, "zmock") = "7fd3...".
+               c.Check(pr.Header.Get("Authorization"), check.Equals, "Bearer v2/zzzzz-gj3su-077z32aux8dg2s1/7fd31b61f39c0e82a4155592163218272cedacdc")
+       }
 }
 
 func (s *FederationSuite) TestRemoteTokenNotSalted(c *check.C) {
+       defer s.localServiceReturns404(c).Close()
        // remoteToken can be any v1 token that doesn't appear in
        // ztest's local db.
        remoteToken := "abcdef00000000000000000000000000000000000000000000"
-       req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zmock-", 1), nil)
-       req.Header.Set("Authorization", "Bearer "+remoteToken)
-       s.testRequest(req)
-       c.Assert(s.remoteMockRequests, check.HasLen, 1)
-       pr := s.remoteMockRequests[0]
-       c.Check(pr.Header.Get("Authorization"), check.Equals, "Bearer "+remoteToken)
+
+       for _, path := range []string{
+               // During the transition to the strongly typed
+               // controller implementation (#14287), workflows and
+               // collections test different code paths.
+               "/arvados/v1/workflows/" + strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zmock-", 1),
+               "/arvados/v1/collections/" + strings.Replace(arvadostest.UserAgreementCollection, "zzzzz-", "zmock-", 1),
+       } {
+               c.Log("testing path ", path)
+               s.remoteMockRequests = nil
+               req := httptest.NewRequest("GET", path, nil)
+               req.Header.Set("Authorization", "Bearer "+remoteToken)
+               s.testRequest(req).Result()
+               c.Assert(s.remoteMockRequests, check.HasLen, 1)
+               pr := s.remoteMockRequests[0]
+               c.Check(pr.Header.Get("Authorization"), check.Equals, "Bearer "+remoteToken)
+       }
 }
 
 func (s *FederationSuite) TestWorkflowCRUD(c *check.C) {
-       wf := arvados.Workflow{
-               Description: "TestCRUD",
-       }
+       var wf arvados.Workflow
        {
-               body := &strings.Builder{}
-               json.NewEncoder(body).Encode(&wf)
                req := httptest.NewRequest("POST", "/arvados/v1/workflows", strings.NewReader(url.Values{
-                       "workflow": {body.String()},
+                       "workflow": {`{"description": "TestCRUD"}`},
                }.Encode()))
                req.Header.Set("Content-type", "application/x-www-form-urlencoded")
                req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
@@ -268,7 +294,7 @@ func (s *FederationSuite) TestWorkflowCRUD(c *check.C) {
                req := httptest.NewRequest(method, "/arvados/v1/workflows/"+wf.UUID, strings.NewReader(form.Encode()))
                req.Header.Set("Content-type", "application/x-www-form-urlencoded")
                req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-               resp := s.testRequest(req)
+               resp := s.testRequest(req).Result()
                s.checkResponseOK(c, resp)
                err := json.NewDecoder(resp.Body).Decode(&wf)
                c.Check(err, check.IsNil)
@@ -278,7 +304,7 @@ func (s *FederationSuite) TestWorkflowCRUD(c *check.C) {
        {
                req := httptest.NewRequest("DELETE", "/arvados/v1/workflows/"+wf.UUID, nil)
                req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-               resp := s.testRequest(req)
+               resp := s.testRequest(req).Result()
                s.checkResponseOK(c, resp)
                err := json.NewDecoder(resp.Body).Decode(&wf)
                c.Check(err, check.IsNil)
@@ -286,7 +312,7 @@ func (s *FederationSuite) TestWorkflowCRUD(c *check.C) {
        {
                req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+wf.UUID, nil)
                req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-               resp := s.testRequest(req)
+               resp := s.testRequest(req).Result()
                c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
        }
 }
@@ -320,7 +346,15 @@ func (s *FederationSuite) localServiceHandler(c *check.C, h http.Handler) *https
 
 func (s *FederationSuite) localServiceReturns404(c *check.C) *httpserver.Server {
        return s.localServiceHandler(c, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-               w.WriteHeader(404)
+               if req.URL.Path == "/arvados/v1/api_client_authorizations/current" {
+                       if req.Header.Get("Authorization") == "Bearer "+arvadostest.ActiveToken {
+                               json.NewEncoder(w).Encode(arvados.APIClientAuthorization{UUID: arvadostest.ActiveTokenUUID, APIToken: arvadostest.ActiveToken})
+                       } else {
+                               w.WriteHeader(http.StatusUnauthorized)
+                       }
+               } else {
+                       w.WriteHeader(404)
+               }
        }))
 }
 
@@ -332,7 +366,7 @@ func (s *FederationSuite) TestGetLocalCollection(c *check.C) {
 
        req := httptest.NewRequest("GET", "/arvados/v1/collections/"+arvadostest.UserAgreementCollection, nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
 
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var col arvados.Collection
@@ -349,7 +383,7 @@ func (s *FederationSuite) TestGetLocalCollection(c *check.C) {
        }).Encode()))
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
        req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
-       resp = s.testRequest(req)
+       resp = s.testRequest(req).Result()
 
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        col = arvados.Collection{}
@@ -365,7 +399,7 @@ func (s *FederationSuite) TestGetRemoteCollection(c *check.C) {
 
        req := httptest.NewRequest("GET", "/arvados/v1/collections/"+arvadostest.UserAgreementCollection, nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var col arvados.Collection
        c.Check(json.NewDecoder(resp.Body).Decode(&col), check.IsNil)
@@ -380,7 +414,7 @@ func (s *FederationSuite) TestGetRemoteCollectionError(c *check.C) {
 
        req := httptest.NewRequest("GET", "/arvados/v1/collections/zzzzz-4zz18-fakefakefakefak", nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
 }
 
@@ -402,7 +436,7 @@ func (s *FederationSuite) TestGetLocalCollectionByPDH(c *check.C) {
 
        req := httptest.NewRequest("GET", "/arvados/v1/collections/"+arvadostest.UserAgreementPDH, nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
 
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var col arvados.Collection
@@ -418,7 +452,7 @@ func (s *FederationSuite) TestGetRemoteCollectionByPDH(c *check.C) {
 
        req := httptest.NewRequest("GET", "/arvados/v1/collections/"+arvadostest.UserAgreementPDH, nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
 
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 
@@ -436,7 +470,7 @@ func (s *FederationSuite) TestGetCollectionByPDHError(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/collections/99999999999999999999999999999999+99", nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
 
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        defer resp.Body.Close()
 
        c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
@@ -475,7 +509,7 @@ func (s *FederationSuite) TestGetCollectionByPDHErrorBadHash(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/collections/99999999999999999999999999999999+99", nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
 
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        defer resp.Body.Close()
 
        c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
@@ -486,7 +520,7 @@ func (s *FederationSuite) TestSaltedTokenGetCollectionByPDH(c *check.C) {
 
        req := httptest.NewRequest("GET", "/arvados/v1/collections/"+arvadostest.UserAgreementPDH, nil)
        req.Header.Set("Authorization", "Bearer v2/zzzzz-gj3su-077z32aux8dg2s1/282d7d172b6cfdce364c5ed12ddf7417b2d00065")
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
 
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var col arvados.Collection
@@ -502,7 +536,7 @@ func (s *FederationSuite) TestSaltedTokenGetCollectionByPDHError(c *check.C) {
 
        req := httptest.NewRequest("GET", "/arvados/v1/collections/99999999999999999999999999999999+99", nil)
        req.Header.Set("Authorization", "Bearer v2/zzzzz-gj3su-077z32aux8dg2s1/282d7d172b6cfdce364c5ed12ddf7417b2d00065")
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
 
        c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
 }
@@ -511,7 +545,7 @@ func (s *FederationSuite) TestGetRemoteContainerRequest(c *check.C) {
        defer s.localServiceReturns404(c).Close()
        req := httptest.NewRequest("GET", "/arvados/v1/container_requests/"+arvadostest.QueuedContainerRequestUUID, nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var cr arvados.ContainerRequest
        c.Check(json.NewDecoder(resp.Body).Decode(&cr), check.IsNil)
@@ -526,7 +560,7 @@ func (s *FederationSuite) TestUpdateRemoteContainerRequest(c *check.C) {
                        strings.NewReader(fmt.Sprintf(`{"container_request": {"priority": %d}}`, pri)))
                req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
                req.Header.Set("Content-type", "application/json")
-               resp := s.testRequest(req)
+               resp := s.testRequest(req).Result()
                c.Check(resp.StatusCode, check.Equals, http.StatusOK)
                var cr arvados.ContainerRequest
                c.Check(json.NewDecoder(resp.Body).Decode(&cr), check.IsNil)
@@ -554,7 +588,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequest(c *check.C) {
 `))
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
        req.Header.Set("Content-type", "application/json")
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var cr arvados.ContainerRequest
        c.Check(json.NewDecoder(resp.Body).Decode(&cr), check.IsNil)
@@ -586,7 +620,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequestCheckRuntimeToken(c *c
        arvadostest.SetServiceURL(&s.testHandler.Cluster.Services.RailsAPI, "https://"+os.Getenv("ARVADOS_TEST_API_HOST"))
        s.testHandler.Cluster.ClusterID = "zzzzz"
 
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var cr struct {
                arvados.ContainerRequest `json:"container_request"`
@@ -617,7 +651,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequestCheckSetRuntimeToken(c
 `))
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
        req.Header.Set("Content-type", "application/json")
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var cr struct {
                arvados.ContainerRequest `json:"container_request"`
@@ -646,7 +680,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequestRuntimeTokenFromAuth(c
 `))
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveTokenV2+"/zzzzz-dz642-parentcontainer")
        req.Header.Set("Content-type", "application/json")
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var cr struct {
                arvados.ContainerRequest `json:"container_request"`
@@ -672,7 +706,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequestError(c *check.C) {
 `))
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
        req.Header.Set("Content-type", "application/json")
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
 }
 
@@ -681,7 +715,7 @@ func (s *FederationSuite) TestGetRemoteContainer(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/containers/"+arvadostest.QueuedContainerUUID, nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
        resp := s.testRequest(req)
-       c.Check(resp.StatusCode, check.Equals, http.StatusOK)
+       c.Check(resp.Code, check.Equals, http.StatusOK)
        var cn arvados.Container
        c.Check(json.NewDecoder(resp.Body).Decode(&cn), check.IsNil)
        c.Check(cn.UUID, check.Equals, arvadostest.QueuedContainerUUID)
@@ -692,10 +726,11 @@ func (s *FederationSuite) TestListRemoteContainer(c *check.C) {
        req := httptest.NewRequest("GET", "/arvados/v1/containers?count=none&filters="+
                url.QueryEscape(fmt.Sprintf(`[["uuid", "in", ["%v"]]]`, arvadostest.QueuedContainerUUID)), nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var cn arvados.ContainerList
        c.Check(json.NewDecoder(resp.Body).Decode(&cn), check.IsNil)
+       c.Assert(cn.Items, check.HasLen, 1)
        c.Check(cn.Items[0].UUID, check.Equals, arvadostest.QueuedContainerUUID)
 }
 
@@ -712,7 +747,7 @@ func (s *FederationSuite) TestListMultiRemoteContainers(c *check.C) {
                url.QueryEscape(`["uuid", "command"]`)),
                nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        var cn arvados.ContainerList
        c.Check(json.NewDecoder(resp.Body).Decode(&cn), check.IsNil)
@@ -735,7 +770,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerError(c *check.C) {
                url.QueryEscape(`["uuid", "command"]`)),
                nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusBadGateway)
        s.checkJSONErrorMatches(c, resp, `error fetching from zhome \(404 Not Found\): EOF`)
 }
@@ -761,7 +796,7 @@ func (s *FederationSuite) TestListMultiRemoteContainersPaged(c *check.C) {
                        arvadostest.QueuedContainerUUID))),
                nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        c.Check(callCount, check.Equals, 2)
        var cn arvados.ContainerList
@@ -797,7 +832,7 @@ func (s *FederationSuite) TestListMultiRemoteContainersMissing(c *check.C) {
                        arvadostest.QueuedContainerUUID))),
                nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusOK)
        c.Check(callCount, check.Equals, 2)
        var cn arvados.ContainerList
@@ -818,7 +853,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerPageSizeError(c *check.C)
                        arvadostest.QueuedContainerUUID))),
                nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusBadRequest)
        s.checkJSONErrorMatches(c, resp, `Federated multi-object request for 2 objects which is more than max page size 1.`)
 }
@@ -829,7 +864,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerLimitError(c *check.C) {
                        arvadostest.QueuedContainerUUID))),
                nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusBadRequest)
        s.checkJSONErrorMatches(c, resp, `Federated multi-object may not provide 'limit', 'offset' or 'order'.`)
 }
@@ -840,7 +875,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerOffsetError(c *check.C) {
                        arvadostest.QueuedContainerUUID))),
                nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusBadRequest)
        s.checkJSONErrorMatches(c, resp, `Federated multi-object may not provide 'limit', 'offset' or 'order'.`)
 }
@@ -851,7 +886,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerOrderError(c *check.C) {
                        arvadostest.QueuedContainerUUID))),
                nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusBadRequest)
        s.checkJSONErrorMatches(c, resp, `Federated multi-object may not provide 'limit', 'offset' or 'order'.`)
 }
@@ -863,7 +898,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerSelectError(c *check.C) {
                url.QueryEscape(`["command"]`)),
                nil)
        req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-       resp := s.testRequest(req)
+       resp := s.testRequest(req).Result()
        c.Check(resp.StatusCode, check.Equals, http.StatusBadRequest)
        s.checkJSONErrorMatches(c, resp, `Federated multi-object request must include 'uuid' in 'select'`)
 }
index 12faacdd4398211f8466a4ed7e971283190b9871..d524195e4429a2358ea560a784acf469350f7751 100644 (file)
@@ -18,6 +18,8 @@ import (
        "time"
 
        "git.curoverse.com/arvados.git/lib/config"
+       "git.curoverse.com/arvados.git/lib/controller/railsproxy"
+       "git.curoverse.com/arvados.git/lib/controller/router"
        "git.curoverse.com/arvados.git/sdk/go/arvados"
        "git.curoverse.com/arvados.git/sdk/go/health"
        "git.curoverse.com/arvados.git/sdk/go/httpserver"
@@ -63,7 +65,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 
 func (h *Handler) CheckHealth() error {
        h.setupOnce.Do(h.setup)
-       _, _, err := findRailsAPI(h.Cluster)
+       _, _, err := railsproxy.FindRailsAPI(h.Cluster)
        return err
 }
 
@@ -88,6 +90,12 @@ func (h *Handler) setup() {
                io.Copy(w, &buf)
        }))
 
+       if h.Cluster.EnableBetaController14287 {
+               rtr := router.New(h.Cluster)
+               mux.Handle("/arvados/v1/collections", rtr)
+               mux.Handle("/arvados/v1/collections/", rtr)
+       }
+
        hs := http.NotFoundHandler()
        hs = prepend(hs, h.proxyRailsAPI)
        hs = h.setupProxyRemoteCluster(hs)
@@ -141,7 +149,7 @@ func prepend(next http.Handler, middleware middlewareFunc) http.Handler {
 }
 
 func (h *Handler) localClusterRequest(req *http.Request) (*http.Response, error) {
-       urlOut, insecure, err := findRailsAPI(h.Cluster)
+       urlOut, insecure, err := railsproxy.FindRailsAPI(h.Cluster)
        if err != nil {
                return nil, err
        }
index 9b0ff2764be620bd847dc03c2da2f0848b008f07..fbfb037d36dad0d4716c3422c452aec0a118049b 100644 (file)
@@ -22,9 +22,13 @@ import (
        check "gopkg.in/check.v1"
 )
 
+var enableBetaController14287 bool
+
 // Gocheck boilerplate
 func Test(t *testing.T) {
-       check.TestingT(t)
+       for _, enableBetaController14287 = range []bool{false, true} {
+               check.TestingT(t)
+       }
 }
 
 var _ = check.Suite(&HandlerSuite{})
@@ -42,6 +46,8 @@ func (s *HandlerSuite) SetUpTest(c *check.C) {
        s.cluster = &arvados.Cluster{
                ClusterID:  "zzzzz",
                PostgreSQL: integrationTestCluster().PostgreSQL,
+
+               EnableBetaController14287: enableBetaController14287,
        }
        s.cluster.TLS.Insecure = true
        arvadostest.SetServiceURL(&s.cluster.Services.RailsAPI, "https://"+os.Getenv("ARVADOS_TEST_API_HOST"))
index c0b94c2b5f76d604e738c2d9bc43d3a01f8bf5dc..9eac9362c94f9e387b657e0a8cf57754d33e2143 100644 (file)
@@ -25,20 +25,23 @@ func (h HTTPError) Error() string {
        return h.Message
 }
 
-// headers that shouldn't be forwarded when proxying. See
-// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers
 var dropHeaders = map[string]bool{
+       // Headers that shouldn't be forwarded when proxying. See
+       // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers
        "Connection":          true,
        "Keep-Alive":          true,
        "Proxy-Authenticate":  true,
        "Proxy-Authorization": true,
-       // this line makes gofmt 1.10 and 1.11 agree
-       "TE":                true,
-       "Trailer":           true,
-       "Transfer-Encoding": true, // *-Encoding headers interfer with Go's automatic compression/decompression
-       "Content-Encoding":  true,
+       // (comment/space here makes gofmt1.10 agree with gofmt1.11)
+       "TE":      true,
+       "Trailer": true,
+       "Upgrade": true,
+
+       // Headers that would interfere with Go's automatic
+       // compression/decompression if we forwarded them.
        "Accept-Encoding":   true,
-       "Upgrade":           true,
+       "Content-Encoding":  true,
+       "Transfer-Encoding": true,
 }
 
 type ResponseFilter func(*http.Response, error) (*http.Response, error)
diff --git a/lib/controller/railsproxy/railsproxy.go b/lib/controller/railsproxy/railsproxy.go
new file mode 100644 (file)
index 0000000..576e603
--- /dev/null
@@ -0,0 +1,52 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// Package railsproxy implements Arvados APIs by proxying to the
+// RailsAPI server on the local machine.
+package railsproxy
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "net/url"
+       "strings"
+
+       "git.curoverse.com/arvados.git/lib/controller/rpc"
+       "git.curoverse.com/arvados.git/sdk/go/arvados"
+       "git.curoverse.com/arvados.git/sdk/go/auth"
+)
+
+// For now, FindRailsAPI always uses the rails API running on this
+// node.
+func FindRailsAPI(cluster *arvados.Cluster) (*url.URL, bool, error) {
+       var best *url.URL
+       for target := range cluster.Services.RailsAPI.InternalURLs {
+               target := url.URL(target)
+               best = &target
+               if strings.HasPrefix(target.Host, "localhost:") || strings.HasPrefix(target.Host, "127.0.0.1:") || strings.HasPrefix(target.Host, "[::1]:") {
+                       break
+               }
+       }
+       if best == nil {
+               return nil, false, fmt.Errorf("Services.RailsAPI.InternalURLs is empty")
+       }
+       return best, cluster.TLS.Insecure, nil
+}
+
+func NewConn(cluster *arvados.Cluster) *rpc.Conn {
+       url, insecure, err := FindRailsAPI(cluster)
+       if err != nil {
+               panic(err)
+       }
+       return rpc.NewConn(cluster.ClusterID, url, insecure, provideIncomingToken)
+}
+
+func provideIncomingToken(ctx context.Context) ([]string, error) {
+       incoming, ok := auth.FromContext(ctx)
+       if !ok {
+               return nil, errors.New("no token provided")
+       }
+       return incoming.Tokens, nil
+}
diff --git a/lib/controller/router/checker_test.go b/lib/controller/router/checker_test.go
new file mode 100644 (file)
index 0000000..93d51fa
--- /dev/null
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+       "reflect"
+       "runtime"
+
+       check "gopkg.in/check.v1"
+)
+
+// a Gocheck checker for testing the name of a function. Used with
+// (*arvadostest.APIStub).Calls() to check that an HTTP request has
+// been routed to the correct arvados.API method.
+//
+//     c.Check(bytes.NewBuffer().Read, isMethodNamed, "Read")
+var isMethodNamed check.Checker = &chkIsMethodNamed{
+       CheckerInfo: &check.CheckerInfo{
+               Name:   "isMethodNamed",
+               Params: []string{"obtained", "expected"},
+       },
+}
+
+type chkIsMethodNamed struct{ *check.CheckerInfo }
+
+func (*chkIsMethodNamed) Check(params []interface{}, names []string) (bool, string) {
+       methodName := runtime.FuncForPC(reflect.ValueOf(params[0]).Pointer()).Name()
+       regex := `.*\)\.` + params[1].(string) + `(-.*)?`
+       return check.Matches.Check([]interface{}{methodName, regex}, names)
+}
diff --git a/lib/controller/router/error.go b/lib/controller/router/error.go
new file mode 100644 (file)
index 0000000..6db5f31
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+type errorWithStatus struct {
+       code int
+       error
+}
+
+func (err errorWithStatus) HTTPStatus() int {
+       return err.code
+}
+
+func httpError(code int, err error) error {
+       return errorWithStatus{code: code, error: err}
+}
diff --git a/lib/controller/router/request.go b/lib/controller/router/request.go
new file mode 100644 (file)
index 0000000..377f724
--- /dev/null
@@ -0,0 +1,173 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+       "encoding/json"
+       "fmt"
+       "io"
+       "mime"
+       "net/http"
+       "strconv"
+       "strings"
+
+       "github.com/julienschmidt/httprouter"
+)
+
+// Parse req as an Arvados V1 API request and return the request
+// parameters.
+//
+// If the request has a parameter whose name is attrsKey (e.g.,
+// "collection"), it is renamed to "attrs".
+func (rtr *router) loadRequestParams(req *http.Request, attrsKey string) (map[string]interface{}, error) {
+       err := req.ParseForm()
+       if err != nil {
+               return nil, httpError(http.StatusBadRequest, err)
+       }
+       params := map[string]interface{}{}
+
+       // Load parameters from req.Form, which (after
+       // req.ParseForm()) includes the query string and -- when
+       // Content-Type is application/x-www-form-urlencoded -- the
+       // request body.
+       for k, values := range req.Form {
+               // All of these form values arrive as strings, so we
+               // need some type-guessing to accept non-string
+               // inputs:
+               //
+               // Values for parameters that take ints (limit=1) or
+               // bools (include_trash=1) are parsed accordingly.
+               //
+               // "null" and "" are nil.
+               //
+               // Values that look like JSON objects, arrays, or
+               // strings are parsed as JSON.
+               //
+               // The rest are left as strings.
+               for _, v := range values {
+                       switch {
+                       case intParams[k]:
+                               params[k], err = strconv.ParseInt(v, 10, 64)
+                               if err != nil {
+                                       return nil, err
+                               }
+                       case boolParams[k]:
+                               params[k] = stringToBool(v)
+                       case v == "null" || v == "":
+                               params[k] = nil
+                       case strings.HasPrefix(v, "["):
+                               var j []interface{}
+                               err := json.Unmarshal([]byte(v), &j)
+                               if err != nil {
+                                       return nil, err
+                               }
+                               params[k] = j
+                       case strings.HasPrefix(v, "{"):
+                               var j map[string]interface{}
+                               err := json.Unmarshal([]byte(v), &j)
+                               if err != nil {
+                                       return nil, err
+                               }
+                               params[k] = j
+                       case strings.HasPrefix(v, "\""):
+                               var j string
+                               err := json.Unmarshal([]byte(v), &j)
+                               if err != nil {
+                                       return nil, err
+                               }
+                               params[k] = j
+                       default:
+                               params[k] = v
+                       }
+                       // TODO: Need to accept "?foo[]=bar&foo[]=baz"
+                       // as foo=["bar","baz"]?
+               }
+       }
+
+       // Decode body as JSON if Content-Type request header is
+       // missing or application/json.
+       mt := req.Header.Get("Content-Type")
+       if ct, _, err := mime.ParseMediaType(mt); err != nil && mt != "" {
+               return nil, fmt.Errorf("error parsing media type %q: %s", mt, err)
+       } else if (ct == "application/json" || mt == "") && req.ContentLength != 0 {
+               jsonParams := map[string]interface{}{}
+               err := json.NewDecoder(req.Body).Decode(&jsonParams)
+               if err != nil {
+                       return nil, httpError(http.StatusBadRequest, err)
+               }
+               for k, v := range jsonParams {
+                       params[k] = v
+               }
+               if attrsKey != "" && params[attrsKey] == nil {
+                       // Copy top-level parameters from JSON request
+                       // body into params[attrsKey]. Some SDKs rely
+                       // on this Rails API feature; see
+                       // https://api.rubyonrails.org/v5.2.1/classes/ActionController/ParamsWrapper.html
+                       params[attrsKey] = jsonParams
+               }
+       }
+
+       routeParams, _ := req.Context().Value(httprouter.ParamsKey).(httprouter.Params)
+       for _, p := range routeParams {
+               params[p.Key] = p.Value
+       }
+
+       if v, ok := params[attrsKey]; ok && attrsKey != "" {
+               params["attrs"] = v
+               delete(params, attrsKey)
+       }
+
+       if order, ok := params["order"].(string); ok {
+               // We must accept strings ("foo, bar desc") and arrays
+               // (["foo", "bar desc"]) because RailsAPI does.
+               // Convert to an array here before trying to unmarshal
+               // into options structs.
+               if order == "" {
+                       delete(params, "order")
+               } else {
+                       params["order"] = strings.Split(order, ",")
+               }
+       }
+
+       return params, nil
+}
+
+// Copy src to dst, using json as an intermediate format in order to
+// invoke src's json-marshaling and dst's json-unmarshaling behaviors.
+func (rtr *router) transcode(src interface{}, dst interface{}) error {
+       var errw error
+       pr, pw := io.Pipe()
+       go func() {
+               defer pw.Close()
+               errw = json.NewEncoder(pw).Encode(src)
+       }()
+       defer pr.Close()
+       err := json.NewDecoder(pr).Decode(dst)
+       if errw != nil {
+               return errw
+       }
+       return err
+}
+
+var intParams = map[string]bool{
+       "limit":  true,
+       "offset": true,
+}
+
+var boolParams = map[string]bool{
+       "distinct":             true,
+       "ensure_unique_name":   true,
+       "include_trash":        true,
+       "include_old_versions": true,
+}
+
+func stringToBool(s string) bool {
+       switch s {
+       case "", "false", "0":
+               return false
+       default:
+               return true
+       }
+}
diff --git a/lib/controller/router/request_test.go b/lib/controller/router/request_test.go
new file mode 100644 (file)
index 0000000..89238f6
--- /dev/null
@@ -0,0 +1,206 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+       "bytes"
+       "encoding/json"
+       "io"
+       "net/http"
+       "net/http/httptest"
+       "net/url"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvadostest"
+       check "gopkg.in/check.v1"
+)
+
+type testReq struct {
+       method   string
+       path     string
+       token    string // default is ActiveTokenV2; use noToken to omit
+       param    map[string]interface{}
+       attrs    map[string]interface{}
+       attrsKey string
+       header   http.Header
+
+       // variations on request formatting
+       json            bool
+       jsonAttrsTop    bool
+       jsonStringParam bool
+       tokenInBody     bool
+       tokenInQuery    bool
+       noContentType   bool
+
+       body *bytes.Buffer
+}
+
+const noToken = "(no token)"
+
+func (tr *testReq) Request() *http.Request {
+       param := map[string]interface{}{}
+       for k, v := range tr.param {
+               param[k] = v
+       }
+
+       if tr.body != nil {
+               // caller provided a buffer
+       } else if tr.json {
+               if tr.jsonAttrsTop {
+                       for k, v := range tr.attrs {
+                               param[k] = v
+                       }
+               } else if tr.attrs != nil {
+                       param[tr.attrsKey] = tr.attrs
+               }
+               tr.body = bytes.NewBuffer(nil)
+               err := json.NewEncoder(tr.body).Encode(param)
+               if err != nil {
+                       panic(err)
+               }
+       } else {
+               values := make(url.Values)
+               for k, v := range param {
+                       if vs, ok := v.(string); ok && !tr.jsonStringParam {
+                               values.Set(k, vs)
+                       } else {
+                               jv, err := json.Marshal(v)
+                               if err != nil {
+                                       panic(err)
+                               }
+                               values.Set(k, string(jv))
+                       }
+               }
+               if tr.attrs != nil {
+                       jattrs, err := json.Marshal(tr.attrs)
+                       if err != nil {
+                               panic(err)
+                       }
+                       values.Set(tr.attrsKey, string(jattrs))
+               }
+               tr.body = bytes.NewBuffer(nil)
+               io.WriteString(tr.body, values.Encode())
+       }
+       method := tr.method
+       if method == "" {
+               method = "GET"
+       }
+       path := tr.path
+       if path == "" {
+               path = "example/test/path"
+       }
+       req := httptest.NewRequest(method, "https://an.example/"+path, tr.body)
+       token := tr.token
+       if token == "" {
+               token = arvadostest.ActiveTokenV2
+       }
+       if token != noToken {
+               req.Header.Set("Authorization", "Bearer "+token)
+       }
+       if tr.json {
+               req.Header.Set("Content-Type", "application/json")
+       } else {
+               req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+       }
+       for k, v := range tr.header {
+               req.Header[k] = append([]string(nil), v...)
+       }
+       return req
+}
+
+func (tr *testReq) bodyContent() string {
+       return string(tr.body.Bytes())
+}
+
+func (s *RouterSuite) TestAttrsInBody(c *check.C) {
+       attrs := map[string]interface{}{"foo": "bar"}
+       for _, tr := range []testReq{
+               {attrsKey: "model_name", json: true, attrs: attrs},
+               {attrsKey: "model_name", json: true, attrs: attrs, jsonAttrsTop: true},
+       } {
+               c.Logf("tr: %#v", tr)
+               req := tr.Request()
+               params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+               c.Logf("params: %#v", params)
+               c.Assert(err, check.IsNil)
+               c.Check(params, check.NotNil)
+               c.Assert(params["attrs"], check.FitsTypeOf, map[string]interface{}{})
+               c.Check(params["attrs"].(map[string]interface{})["foo"], check.Equals, "bar")
+       }
+}
+
+func (s *RouterSuite) TestBoolParam(c *check.C) {
+       testKey := "ensure_unique_name"
+
+       for i, tr := range []testReq{
+               {method: "POST", param: map[string]interface{}{testKey: false}, json: true},
+               {method: "POST", param: map[string]interface{}{testKey: false}},
+               {method: "POST", param: map[string]interface{}{testKey: "false"}},
+               {method: "POST", param: map[string]interface{}{testKey: "0"}},
+               {method: "POST", param: map[string]interface{}{testKey: ""}},
+       } {
+               c.Logf("#%d, tr: %#v", i, tr)
+               req := tr.Request()
+               c.Logf("tr.body: %s", tr.bodyContent())
+               params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+               c.Logf("params: %#v", params)
+               c.Assert(err, check.IsNil)
+               c.Check(params, check.NotNil)
+               c.Check(params[testKey], check.Equals, false)
+       }
+
+       for i, tr := range []testReq{
+               {method: "POST", param: map[string]interface{}{testKey: true}, json: true},
+               {method: "POST", param: map[string]interface{}{testKey: true}},
+               {method: "POST", param: map[string]interface{}{testKey: "true"}},
+               {method: "POST", param: map[string]interface{}{testKey: "1"}},
+       } {
+               c.Logf("#%d, tr: %#v", i, tr)
+               req := tr.Request()
+               c.Logf("tr.body: %s", tr.bodyContent())
+               params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+               c.Logf("params: %#v", params)
+               c.Assert(err, check.IsNil)
+               c.Check(params, check.NotNil)
+               c.Check(params[testKey], check.Equals, true)
+       }
+}
+
+func (s *RouterSuite) TestOrderParam(c *check.C) {
+       for i, tr := range []testReq{
+               {method: "POST", param: map[string]interface{}{"order": ""}, json: true},
+               {method: "POST", param: map[string]interface{}{"order": ""}, json: false},
+               {method: "POST", param: map[string]interface{}{"order": []string{}}, json: true},
+               {method: "POST", param: map[string]interface{}{"order": []string{}}, json: false},
+               {method: "POST", param: map[string]interface{}{}, json: true},
+               {method: "POST", param: map[string]interface{}{}, json: false},
+       } {
+               c.Logf("#%d, tr: %#v", i, tr)
+               req := tr.Request()
+               params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+               c.Assert(err, check.IsNil)
+               c.Assert(params, check.NotNil)
+               if order, ok := params["order"]; ok && order != nil {
+                       c.Check(order, check.DeepEquals, []interface{}{})
+               }
+       }
+
+       for i, tr := range []testReq{
+               {method: "POST", param: map[string]interface{}{"order": "foo,bar desc"}, json: true},
+               {method: "POST", param: map[string]interface{}{"order": "foo,bar desc"}, json: false},
+               {method: "POST", param: map[string]interface{}{"order": "[\"foo\", \"bar desc\"]"}, json: false},
+               {method: "POST", param: map[string]interface{}{"order": []string{"foo", "bar desc"}}, json: true},
+               {method: "POST", param: map[string]interface{}{"order": []string{"foo", "bar desc"}}, json: false},
+       } {
+               c.Logf("#%d, tr: %#v", i, tr)
+               req := tr.Request()
+               params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+               c.Assert(err, check.IsNil)
+               if _, ok := params["order"].([]string); ok {
+                       c.Check(params["order"], check.DeepEquals, []string{"foo", "bar desc"})
+               } else {
+                       c.Check(params["order"], check.DeepEquals, []interface{}{"foo", "bar desc"})
+               }
+       }
+}
diff --git a/lib/controller/router/response.go b/lib/controller/router/response.go
new file mode 100644 (file)
index 0000000..aa3af1f
--- /dev/null
@@ -0,0 +1,151 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+       "encoding/json"
+       "fmt"
+       "net/http"
+       "regexp"
+       "strings"
+       "time"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvados"
+       "git.curoverse.com/arvados.git/sdk/go/httpserver"
+)
+
+const rfc3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
+
+type responseOptions struct {
+       Select []string
+       Count  string
+}
+
+func (rtr *router) responseOptions(opts interface{}) (responseOptions, error) {
+       var rOpts responseOptions
+       switch opts := opts.(type) {
+       case *arvados.GetOptions:
+               rOpts.Select = opts.Select
+       case *arvados.ListOptions:
+               rOpts.Select = opts.Select
+               rOpts.Count = opts.Count
+       }
+       return rOpts, nil
+}
+
+func applySelectParam(selectParam []string, orig map[string]interface{}) map[string]interface{} {
+       if len(selectParam) == 0 {
+               return orig
+       }
+       selected := map[string]interface{}{}
+       for _, attr := range selectParam {
+               if v, ok := orig[attr]; ok {
+                       selected[attr] = v
+               }
+       }
+       // Preserve "kind" even if not requested
+       if v, ok := orig["kind"]; ok {
+               selected["kind"] = v
+       }
+       return selected
+}
+
+func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts responseOptions) {
+       var tmp map[string]interface{}
+
+       err := rtr.transcode(resp, &tmp)
+       if err != nil {
+               rtr.sendError(w, err)
+               return
+       }
+
+       respKind := kind(resp)
+       if respKind != "" {
+               tmp["kind"] = respKind
+       }
+       defaultItemKind := ""
+       if strings.HasSuffix(respKind, "List") {
+               defaultItemKind = strings.TrimSuffix(respKind, "List")
+       }
+
+       if items, ok := tmp["items"].([]interface{}); ok {
+               for i, item := range items {
+                       // Fill in "kind" by inspecting UUID/PDH if
+                       // possible; fall back on assuming each
+                       // Items[] entry in an "arvados#fooList"
+                       // response should have kind="arvados#foo".
+                       item, _ := item.(map[string]interface{})
+                       infix := ""
+                       if uuid, _ := item["uuid"].(string); len(uuid) == 27 {
+                               infix = uuid[6:11]
+                       }
+                       if k := kind(infixMap[infix]); k != "" {
+                               item["kind"] = k
+                       } else if pdh, _ := item["portable_data_hash"].(string); pdh != "" {
+                               item["kind"] = "arvados#collection"
+                       } else if defaultItemKind != "" {
+                               item["kind"] = defaultItemKind
+                       }
+                       items[i] = applySelectParam(opts.Select, item)
+               }
+               if opts.Count == "none" {
+                       delete(tmp, "items_available")
+               }
+       } else {
+               tmp = applySelectParam(opts.Select, tmp)
+       }
+
+       // Format non-nil timestamps as rfc3339NanoFixed (by default
+       // they will have been encoded to time.RFC3339Nano, which
+       // omits trailing zeroes).
+       for k, v := range tmp {
+               if !strings.HasSuffix(k, "_at") {
+                       continue
+               }
+               switch tv := v.(type) {
+               case *time.Time:
+                       if tv == nil {
+                               break
+                       }
+                       tmp[k] = tv.Format(rfc3339NanoFixed)
+               case time.Time:
+                       tmp[k] = tv.Format(rfc3339NanoFixed)
+               case string:
+                       t, err := time.Parse(time.RFC3339Nano, tv)
+                       if err != nil {
+                               break
+                       }
+                       tmp[k] = t.Format(rfc3339NanoFixed)
+               }
+       }
+       w.Header().Set("Content-Type", "application/json")
+       json.NewEncoder(w).Encode(tmp)
+}
+
+func (rtr *router) sendError(w http.ResponseWriter, err error) {
+       code := http.StatusInternalServerError
+       if err, ok := err.(interface{ HTTPStatus() int }); ok {
+               code = err.HTTPStatus()
+       }
+       httpserver.Error(w, err.Error(), code)
+}
+
+var infixMap = map[string]interface{}{
+       "4zz18": arvados.Collection{},
+       "j7d0g": arvados.Group{},
+}
+
+var mungeKind = regexp.MustCompile(`\..`)
+
+func kind(resp interface{}) string {
+       t := fmt.Sprintf("%T", resp)
+       if !strings.HasPrefix(t, "arvados.") {
+               return ""
+       }
+       return mungeKind.ReplaceAllStringFunc(t, func(s string) string {
+               // "arvados.CollectionList" => "arvados#collectionList"
+               return "#" + strings.ToLower(s[1:])
+       })
+}
diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
new file mode 100644 (file)
index 0000000..f37c7ea
--- /dev/null
@@ -0,0 +1,283 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+       "context"
+       "fmt"
+       "net/http"
+       "strings"
+
+       "git.curoverse.com/arvados.git/lib/controller/federation"
+       "git.curoverse.com/arvados.git/sdk/go/arvados"
+       "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/sirupsen/logrus"
+)
+
+type router struct {
+       mux *httprouter.Router
+       fed arvados.API
+}
+
+func New(cluster *arvados.Cluster) *router {
+       rtr := &router{
+               mux: httprouter.New(),
+               fed: federation.New(cluster),
+       }
+       rtr.addRoutes()
+       return rtr
+}
+
+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
+       }{
+               {
+                       arvados.EndpointCollectionCreate,
+                       func() interface{} { return &arvados.CreateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.fed.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))
+                       },
+               },
+               {
+                       arvados.EndpointCollectionGet,
+                       func() interface{} { return &arvados.GetOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.fed.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))
+                       },
+               },
+               {
+                       arvados.EndpointCollectionProvenance,
+                       func() interface{} { return &arvados.GetOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.fed.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))
+                       },
+               },
+               {
+                       arvados.EndpointCollectionDelete,
+                       func() interface{} { return &arvados.DeleteOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.fed.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))
+                       },
+               },
+               {
+                       arvados.EndpointCollectionUntrash,
+                       func() interface{} { return &arvados.UntrashOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.fed.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))
+                       },
+               },
+               {
+                       arvados.EndpointContainerUpdate,
+                       func() interface{} { return &arvados.UpdateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.fed.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))
+                       },
+               },
+               {
+                       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))
+                       },
+               },
+               {
+                       arvados.EndpointContainerDelete,
+                       func() interface{} { return &arvados.DeleteOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.fed.ContainerDelete(ctx, *opts.(*arvados.DeleteOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerLock,
+                       func() interface{} {
+                               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))
+                       },
+               },
+               {
+                       arvados.EndpointContainerUnlock,
+                       func() interface{} {
+                               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))
+                       },
+               },
+               {
+                       arvados.EndpointSpecimenCreate,
+                       func() interface{} { return &arvados.CreateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.fed.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))
+                       },
+               },
+               {
+                       arvados.EndpointSpecimenGet,
+                       func() interface{} { return &arvados.GetOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.fed.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))
+                       },
+               },
+               {
+                       arvados.EndpointSpecimenDelete,
+                       func() interface{} { return &arvados.DeleteOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.fed.SpecimenDelete(ctx, *opts.(*arvados.DeleteOptions))
+                       },
+               },
+       } {
+               rtr.addRoute(route.endpoint, route.defaultOpts, route.exec)
+               if route.endpoint.Method == "PATCH" {
+                       // Accept PUT as a synonym for PATCH.
+                       endpointPUT := route.endpoint
+                       endpointPUT.Method = "PUT"
+                       rtr.addRoute(endpointPUT, route.defaultOpts, route.exec)
+               }
+       }
+       rtr.mux.NotFound = 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) {
+               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) {
+               logger := ctxlog.FromContext(req.Context())
+               params, err := rtr.loadRequestParams(req, endpoint.AttrsKey)
+               if err != nil {
+                       logger.WithFields(logrus.Fields{
+                               "req":      req,
+                               "method":   endpoint.Method,
+                               "endpoint": endpoint,
+                       }).WithError(err).Debug("error loading request params")
+                       rtr.sendError(w, err)
+                       return
+               }
+               opts := defaultOpts()
+               err = rtr.transcode(params, opts)
+               if err != nil {
+                       logger.WithField("params", params).WithError(err).Debugf("error transcoding params to %T", opts)
+                       rtr.sendError(w, err)
+                       return
+               }
+               respOpts, err := rtr.responseOptions(opts)
+               if err != nil {
+                       logger.WithField("opts", opts).WithError(err).Debugf("error getting response options from %T", opts)
+                       rtr.sendError(w, err)
+                       return
+               }
+
+               creds := auth.CredentialsFromRequest(req)
+               if rt, _ := params["reader_tokens"].([]interface{}); len(rt) > 0 {
+                       for _, t := range rt {
+                               if t, ok := t.(string); ok {
+                                       creds.Tokens = append(creds.Tokens, t)
+                               }
+                       }
+               }
+               ctx := auth.NewContext(req.Context(), creds)
+               ctx = arvados.ContextWithRequestID(ctx, req.Header.Get("X-Request-Id"))
+               logger.WithFields(logrus.Fields{
+                       "apiEndpoint": endpoint,
+                       "apiOptsType": fmt.Sprintf("%T", opts),
+                       "apiOpts":     opts,
+               }).Debug("exec")
+               resp, err := exec(ctx, opts)
+               if err != nil {
+                       logger.WithError(err).Debugf("returning error type %T", err)
+                       rtr.sendError(w, err)
+                       return
+               }
+               rtr.sendResponse(w, resp, respOpts)
+       })
+}
+
+func (rtr *router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+       switch strings.SplitN(strings.TrimLeft(r.URL.Path, "/"), "/", 2)[0] {
+       case "login", "logout", "auth":
+       default:
+               w.Header().Set("Access-Control-Allow-Origin", "*")
+               w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, POST, DELETE")
+               w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
+               w.Header().Set("Access-Control-Max-Age", "86486400")
+       }
+       if r.Method == "OPTIONS" {
+               return
+       }
+       r.ParseForm()
+       if m := r.FormValue("_method"); m != "" {
+               r2 := *r
+               r = &r2
+               r.Method = m
+       }
+       rtr.mux.ServeHTTP(w, r)
+}
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
new file mode 100644 (file)
index 0000000..4e6b161
--- /dev/null
@@ -0,0 +1,395 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+       "bytes"
+       "encoding/json"
+       "io"
+       "net/http"
+       "net/http/httptest"
+       "os"
+       "strings"
+       "testing"
+       "time"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvados"
+       "git.curoverse.com/arvados.git/sdk/go/arvadostest"
+       "github.com/julienschmidt/httprouter"
+       check "gopkg.in/check.v1"
+)
+
+// Gocheck boilerplate
+func Test(t *testing.T) {
+       check.TestingT(t)
+}
+
+var _ = check.Suite(&RouterSuite{})
+
+type RouterSuite struct {
+       rtr  *router
+       stub arvadostest.APIStub
+}
+
+func (s *RouterSuite) SetUpTest(c *check.C) {
+       s.stub = arvadostest.APIStub{}
+       s.rtr = &router{
+               mux: httprouter.New(),
+               fed: &s.stub,
+       }
+       s.rtr.addRoutes()
+}
+
+func (s *RouterSuite) TestOptions(c *check.C) {
+       token := arvadostest.ActiveToken
+       for _, trial := range []struct {
+               method       string
+               path         string
+               header       http.Header
+               body         string
+               shouldStatus int // zero value means 200
+               shouldCall   string
+               withOptions  interface{}
+       }{
+               {
+                       method:      "GET",
+                       path:        "/arvados/v1/collections/" + arvadostest.FooCollection,
+                       shouldCall:  "CollectionGet",
+                       withOptions: arvados.GetOptions{UUID: arvadostest.FooCollection},
+               },
+               {
+                       method:      "PUT",
+                       path:        "/arvados/v1/collections/" + arvadostest.FooCollection,
+                       shouldCall:  "CollectionUpdate",
+                       withOptions: arvados.UpdateOptions{UUID: arvadostest.FooCollection},
+               },
+               {
+                       method:      "PATCH",
+                       path:        "/arvados/v1/collections/" + arvadostest.FooCollection,
+                       shouldCall:  "CollectionUpdate",
+                       withOptions: arvados.UpdateOptions{UUID: arvadostest.FooCollection},
+               },
+               {
+                       method:      "DELETE",
+                       path:        "/arvados/v1/collections/" + arvadostest.FooCollection,
+                       shouldCall:  "CollectionDelete",
+                       withOptions: arvados.DeleteOptions{UUID: arvadostest.FooCollection},
+               },
+               {
+                       method:      "POST",
+                       path:        "/arvados/v1/collections",
+                       shouldCall:  "CollectionCreate",
+                       withOptions: arvados.CreateOptions{},
+               },
+               {
+                       method:      "GET",
+                       path:        "/arvados/v1/collections",
+                       shouldCall:  "CollectionList",
+                       withOptions: arvados.ListOptions{Limit: -1},
+               },
+               {
+                       method:      "GET",
+                       path:        "/arvados/v1/collections?limit=123&offset=456&include_trash=true&include_old_versions=1",
+                       shouldCall:  "CollectionList",
+                       withOptions: arvados.ListOptions{Limit: 123, Offset: 456, IncludeTrash: true, IncludeOldVersions: true},
+               },
+               {
+                       method:      "POST",
+                       path:        "/arvados/v1/collections?limit=123&_method=GET",
+                       body:        `{"offset":456,"include_trash":true,"include_old_versions":true}`,
+                       shouldCall:  "CollectionList",
+                       withOptions: arvados.ListOptions{Limit: 123, Offset: 456, IncludeTrash: true, IncludeOldVersions: true},
+               },
+               {
+                       method:      "POST",
+                       path:        "/arvados/v1/collections?limit=123",
+                       body:        "offset=456&include_trash=true&include_old_versions=1&_method=GET",
+                       header:      http.Header{"Content-Type": {"application/x-www-form-urlencoded"}},
+                       shouldCall:  "CollectionList",
+                       withOptions: arvados.ListOptions{Limit: 123, Offset: 456, IncludeTrash: true, IncludeOldVersions: true},
+               },
+               {
+                       method:       "PATCH",
+                       path:         "/arvados/v1/collections",
+                       shouldStatus: http.StatusMethodNotAllowed,
+               },
+               {
+                       method:       "PUT",
+                       path:         "/arvados/v1/collections",
+                       shouldStatus: http.StatusMethodNotAllowed,
+               },
+               {
+                       method:       "DELETE",
+                       path:         "/arvados/v1/collections",
+                       shouldStatus: http.StatusMethodNotAllowed,
+               },
+       } {
+               // Reset calls captured in previous trial
+               s.stub = arvadostest.APIStub{}
+
+               c.Logf("trial: %#v", trial)
+               _, rr, _ := doRequest(c, s.rtr, token, trial.method, trial.path, trial.header, bytes.NewBufferString(trial.body))
+               if trial.shouldStatus == 0 {
+                       c.Check(rr.Code, check.Equals, http.StatusOK)
+               } else {
+                       c.Check(rr.Code, check.Equals, trial.shouldStatus)
+               }
+               calls := s.stub.Calls(nil)
+               if trial.shouldCall == "" {
+                       c.Check(calls, check.HasLen, 0)
+               } else if len(calls) != 1 {
+                       c.Check(calls, check.HasLen, 1)
+               } else {
+                       c.Check(calls[0].Method, isMethodNamed, trial.shouldCall)
+                       c.Check(calls[0].Options, check.DeepEquals, trial.withOptions)
+               }
+       }
+}
+
+var _ = check.Suite(&RouterIntegrationSuite{})
+
+type RouterIntegrationSuite struct {
+       rtr *router
+}
+
+func (s *RouterIntegrationSuite) SetUpTest(c *check.C) {
+       cluster := &arvados.Cluster{}
+       cluster.TLS.Insecure = true
+       arvadostest.SetServiceURL(&cluster.Services.RailsAPI, "https://"+os.Getenv("ARVADOS_TEST_API_HOST"))
+       s.rtr = New(cluster)
+}
+
+func (s *RouterIntegrationSuite) TearDownSuite(c *check.C) {
+       err := arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil)
+       c.Check(err, check.IsNil)
+}
+
+func (s *RouterIntegrationSuite) TestCollectionResponses(c *check.C) {
+       token := arvadostest.ActiveTokenV2
+
+       // Check "get collection" response has "kind" key
+       _, rr, jresp := doRequest(c, s.rtr, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"include_trash":true}`))
+       c.Check(rr.Code, check.Equals, http.StatusOK)
+       c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
+       c.Check(jresp["kind"], check.Equals, "arvados#collectionList")
+       c.Check(jresp["items"].([]interface{})[0].(map[string]interface{})["kind"], check.Equals, "arvados#collection")
+
+       // Check items in list response have a "kind" key regardless
+       // of whether a uuid/pdh is selected.
+       for _, selectj := range []string{
+               ``,
+               `,"select":["portable_data_hash"]`,
+               `,"select":["name"]`,
+               `,"select":["uuid"]`,
+       } {
+               _, rr, jresp = doRequest(c, s.rtr, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"where":{"uuid":["`+arvadostest.FooCollection+`"]}`+selectj+`}`))
+               c.Check(rr.Code, check.Equals, http.StatusOK)
+               c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
+               c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
+               c.Check(jresp["kind"], check.Equals, "arvados#collectionList")
+               item0 := jresp["items"].([]interface{})[0].(map[string]interface{})
+               c.Check(item0["kind"], check.Equals, "arvados#collection")
+               if selectj == "" || strings.Contains(selectj, "portable_data_hash") {
+                       c.Check(item0["portable_data_hash"], check.Equals, arvadostest.FooCollectionPDH)
+               } else {
+                       c.Check(item0["portable_data_hash"], check.IsNil)
+               }
+               if selectj == "" || strings.Contains(selectj, "name") {
+                       c.Check(item0["name"], check.FitsTypeOf, "")
+               } else {
+                       c.Check(item0["name"], check.IsNil)
+               }
+               if selectj == "" || strings.Contains(selectj, "uuid") {
+                       c.Check(item0["uuid"], check.Equals, arvadostest.FooCollection)
+               } else {
+                       c.Check(item0["uuid"], check.IsNil)
+               }
+       }
+
+       // Check "create collection" response has "kind" key
+       _, rr, jresp = doRequest(c, s.rtr, token, "POST", `/arvados/v1/collections`, http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, bytes.NewBufferString(`ensure_unique_name=true`))
+       c.Check(rr.Code, check.Equals, http.StatusOK)
+       c.Check(jresp["uuid"], check.FitsTypeOf, "")
+       c.Check(jresp["kind"], check.Equals, "arvados#collection")
+}
+
+func (s *RouterIntegrationSuite) TestContainerList(c *check.C) {
+       token := arvadostest.ActiveTokenV2
+
+       _, rr, jresp := doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers?limit=0`, nil, nil)
+       c.Check(rr.Code, check.Equals, http.StatusOK)
+       c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
+       c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
+       c.Check(jresp["items"], check.HasLen, 0)
+
+       _, rr, jresp = doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers?limit=2&select=["uuid","command"]`, nil, nil)
+       c.Check(rr.Code, check.Equals, http.StatusOK)
+       c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
+       c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
+       c.Check(jresp["items"], check.HasLen, 2)
+       item0 := jresp["items"].([]interface{})[0].(map[string]interface{})
+       c.Check(item0["uuid"], check.HasLen, 27)
+       c.Check(item0["command"], check.FitsTypeOf, []interface{}{})
+       c.Check(item0["command"].([]interface{})[0], check.FitsTypeOf, "")
+       c.Check(item0["mounts"], check.IsNil)
+
+       _, rr, jresp = doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers`, nil, nil)
+       c.Check(rr.Code, check.Equals, http.StatusOK)
+       c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
+       c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
+       avail := int(jresp["items_available"].(float64))
+       c.Check(jresp["items"], check.HasLen, avail)
+       item0 = jresp["items"].([]interface{})[0].(map[string]interface{})
+       c.Check(item0["uuid"], check.HasLen, 27)
+       c.Check(item0["command"], check.FitsTypeOf, []interface{}{})
+       c.Check(item0["command"].([]interface{})[0], check.FitsTypeOf, "")
+       c.Check(item0["mounts"], check.NotNil)
+}
+
+func (s *RouterIntegrationSuite) TestContainerLock(c *check.C) {
+       uuid := arvadostest.QueuedContainerUUID
+       token := arvadostest.AdminToken
+       _, rr, jresp := doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
+       c.Check(rr.Code, check.Equals, http.StatusOK)
+       c.Check(jresp["uuid"], check.HasLen, 27)
+       c.Check(jresp["state"], check.Equals, "Locked")
+       _, rr, jresp = doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
+       c.Check(rr.Code, check.Equals, http.StatusUnprocessableEntity)
+       c.Check(rr.Body.String(), check.Not(check.Matches), `.*"uuid":.*`)
+       _, rr, jresp = doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", nil, nil)
+       c.Check(rr.Code, check.Equals, http.StatusOK)
+       c.Check(jresp["uuid"], check.HasLen, 27)
+       c.Check(jresp["state"], check.Equals, "Queued")
+       c.Check(jresp["environment"], check.IsNil)
+       _, rr, jresp = doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", nil, nil)
+       c.Check(rr.Code, check.Equals, http.StatusUnprocessableEntity)
+       c.Check(jresp["uuid"], check.IsNil)
+}
+
+func (s *RouterIntegrationSuite) TestFullTimestampsInResponse(c *check.C) {
+       uuid := arvadostest.CollectionReplicationDesired2Confirmed2UUID
+       token := arvadostest.ActiveTokenV2
+
+       _, rr, jresp := doRequest(c, s.rtr, token, "GET", `/arvados/v1/collections/`+uuid, nil, nil)
+       c.Check(rr.Code, check.Equals, http.StatusOK)
+       c.Check(jresp["uuid"], check.Equals, uuid)
+       expectNS := map[string]int{
+               "created_at":  596506000, // fixture says 596506247, but truncated by postgresql
+               "modified_at": 596338000, // fixture says 596338465, but truncated by postgresql
+       }
+       for key, ns := range expectNS {
+               mt, ok := jresp[key].(string)
+               c.Logf("jresp[%q] == %q", key, mt)
+               c.Assert(ok, check.Equals, true)
+               t, err := time.Parse(time.RFC3339Nano, mt)
+               c.Check(err, check.IsNil)
+               c.Check(t.Nanosecond(), check.Equals, ns)
+       }
+}
+
+func (s *RouterIntegrationSuite) TestSelectParam(c *check.C) {
+       uuid := arvadostest.QueuedContainerUUID
+       token := arvadostest.ActiveTokenV2
+       for _, sel := range [][]string{
+               {"uuid", "command"},
+               {"uuid", "command", "uuid"},
+               {"", "command", "uuid"},
+       } {
+               j, err := json.Marshal(sel)
+               c.Assert(err, check.IsNil)
+               _, rr, resp := doRequest(c, s.rtr, token, "GET", "/arvados/v1/containers/"+uuid+"?select="+string(j), nil, nil)
+               c.Check(rr.Code, check.Equals, http.StatusOK)
+
+               c.Check(resp["kind"], check.Equals, "arvados#container")
+               c.Check(resp["uuid"], check.HasLen, 27)
+               c.Check(resp["command"], check.HasLen, 2)
+               c.Check(resp["mounts"], check.IsNil)
+               _, hasMounts := resp["mounts"]
+               c.Check(hasMounts, check.Equals, false)
+       }
+}
+
+func (s *RouterIntegrationSuite) TestRouteNotFound(c *check.C) {
+       token := arvadostest.ActiveTokenV2
+       req := (&testReq{
+               method: "POST",
+               path:   "arvados/v1/collections/" + arvadostest.FooCollection + "/error404pls",
+               token:  token,
+       }).Request()
+       rr := httptest.NewRecorder()
+       s.rtr.ServeHTTP(rr, req)
+       c.Check(rr.Code, check.Equals, http.StatusNotFound)
+       c.Logf("body: %q", rr.Body.String())
+       var j map[string]interface{}
+       err := json.Unmarshal(rr.Body.Bytes(), &j)
+       c.Check(err, check.IsNil)
+       c.Logf("decoded: %v", j)
+       c.Assert(j["errors"], check.FitsTypeOf, []interface{}{})
+       c.Check(j["errors"].([]interface{})[0], check.Equals, "API endpoint not found")
+}
+
+func (s *RouterIntegrationSuite) TestCORS(c *check.C) {
+       token := arvadostest.ActiveTokenV2
+       req := (&testReq{
+               method: "OPTIONS",
+               path:   "arvados/v1/collections/" + arvadostest.FooCollection,
+               header: http.Header{"Origin": {"https://example.com"}},
+               token:  token,
+       }).Request()
+       rr := httptest.NewRecorder()
+       s.rtr.ServeHTTP(rr, req)
+       c.Check(rr.Code, check.Equals, http.StatusOK)
+       c.Check(rr.Body.String(), check.HasLen, 0)
+       c.Check(rr.Result().Header.Get("Access-Control-Allow-Origin"), check.Equals, "*")
+       for _, hdr := range []string{"Authorization", "Content-Type"} {
+               c.Check(rr.Result().Header.Get("Access-Control-Allow-Headers"), check.Matches, ".*"+hdr+".*")
+       }
+       for _, method := range []string{"GET", "HEAD", "PUT", "POST", "DELETE"} {
+               c.Check(rr.Result().Header.Get("Access-Control-Allow-Methods"), check.Matches, ".*"+method+".*")
+       }
+
+       for _, unsafe := range []string{"login", "logout", "auth", "auth/foo", "login/?blah"} {
+               req := (&testReq{
+                       method: "OPTIONS",
+                       path:   unsafe,
+                       header: http.Header{"Origin": {"https://example.com"}},
+                       token:  token,
+               }).Request()
+               rr := httptest.NewRecorder()
+               s.rtr.ServeHTTP(rr, req)
+               c.Check(rr.Code, check.Equals, http.StatusOK)
+               c.Check(rr.Body.String(), check.HasLen, 0)
+               c.Check(rr.Result().Header.Get("Access-Control-Allow-Origin"), check.Equals, "")
+               c.Check(rr.Result().Header.Get("Access-Control-Allow-Methods"), check.Equals, "")
+               c.Check(rr.Result().Header.Get("Access-Control-Allow-Headers"), check.Equals, "")
+
+               req = (&testReq{
+                       method: "POST",
+                       path:   unsafe,
+                       header: http.Header{"Origin": {"https://example.com"}},
+                       token:  token,
+               }).Request()
+               rr = httptest.NewRecorder()
+               s.rtr.ServeHTTP(rr, req)
+               c.Check(rr.Result().Header.Get("Access-Control-Allow-Origin"), check.Equals, "")
+               c.Check(rr.Result().Header.Get("Access-Control-Allow-Methods"), check.Equals, "")
+               c.Check(rr.Result().Header.Get("Access-Control-Allow-Headers"), check.Equals, "")
+       }
+}
+
+func doRequest(c *check.C, rtr http.Handler, token, method, path string, hdrs http.Header, body io.Reader) (*http.Request, *httptest.ResponseRecorder, map[string]interface{}) {
+       req := httptest.NewRequest(method, path, body)
+       for k, v := range hdrs {
+               req.Header[k] = v
+       }
+       req.Header.Set("Authorization", "Bearer "+token)
+       rr := httptest.NewRecorder()
+       rtr.ServeHTTP(rr, req)
+       c.Logf("response body: %s", rr.Body.String())
+       var jresp map[string]interface{}
+       err := json.Unmarshal(rr.Body.Bytes(), &jresp)
+       c.Check(err, check.IsNil)
+       return req, rr, jresp
+}
diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
new file mode 100644 (file)
index 0000000..e07eaf4
--- /dev/null
@@ -0,0 +1,266 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package rpc
+
+import (
+       "context"
+       "crypto/tls"
+       "encoding/json"
+       "fmt"
+       "io"
+       "net"
+       "net/http"
+       "net/url"
+       "strings"
+       "time"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvados"
+)
+
+type TokenProvider func(context.Context) ([]string, error)
+
+type Conn struct {
+       clusterID     string
+       httpClient    http.Client
+       baseURL       url.URL
+       tokenProvider TokenProvider
+}
+
+func NewConn(clusterID string, url *url.URL, insecure bool, tp TokenProvider) *Conn {
+       transport := http.DefaultTransport
+       if insecure {
+               // It's not safe to copy *http.DefaultTransport
+               // because it has a mutex (which might be locked)
+               // protecting a private map (which might not be nil).
+               // So we build our own, using the Go 1.12 default
+               // values, ignoring any changes the application has
+               // made to http.DefaultTransport.
+               transport = &http.Transport{
+                       DialContext: (&net.Dialer{
+                               Timeout:   30 * time.Second,
+                               KeepAlive: 30 * time.Second,
+                               DualStack: true,
+                       }).DialContext,
+                       MaxIdleConns:          100,
+                       IdleConnTimeout:       90 * time.Second,
+                       TLSHandshakeTimeout:   10 * time.Second,
+                       ExpectContinueTimeout: 1 * time.Second,
+                       TLSClientConfig:       &tls.Config{InsecureSkipVerify: true},
+               }
+       }
+       return &Conn{
+               clusterID:     clusterID,
+               httpClient:    http.Client{Transport: transport},
+               baseURL:       *url,
+               tokenProvider: tp,
+       }
+}
+
+func (conn *Conn) requestAndDecode(ctx context.Context, dst interface{}, ep arvados.APIEndpoint, body io.Reader, opts interface{}) error {
+       aClient := arvados.Client{
+               Client:  &conn.httpClient,
+               Scheme:  conn.baseURL.Scheme,
+               APIHost: conn.baseURL.Host,
+       }
+       tokens, err := conn.tokenProvider(ctx)
+       if err != nil {
+               return err
+       } else if len(tokens) > 0 {
+               ctx = arvados.ContextWithAuthorization(ctx, "Bearer "+tokens[0])
+       } else {
+               // Use a non-empty auth string to ensure we override
+               // any default token set on aClient -- and to avoid
+               // having the remote prompt us to send a token by
+               // responding 401.
+               ctx = arvados.ContextWithAuthorization(ctx, "Bearer -")
+       }
+
+       // Encode opts to JSON and decode from there to a
+       // map[string]interface{}, so we can munge the query params
+       // using the JSON key names specified by opts' struct tags.
+       j, err := json.Marshal(opts)
+       if err != nil {
+               return fmt.Errorf("%T: requestAndDecode: Marshal opts: %s", conn, err)
+       }
+       var params map[string]interface{}
+       err = json.Unmarshal(j, &params)
+       if err != nil {
+               return fmt.Errorf("%T: requestAndDecode: Unmarshal opts: %s", conn, err)
+       }
+       if attrs, ok := params["attrs"]; ok && ep.AttrsKey != "" {
+               params[ep.AttrsKey] = attrs
+               delete(params, "attrs")
+       }
+       if limit, ok := params["limit"].(float64); ok && limit < 0 {
+               // Negative limit means "not specified" here, but some
+               // servers/versions do not accept that, so we need to
+               // remove it entirely.
+               delete(params, "limit")
+       }
+       if len(tokens) > 1 {
+               params["reader_tokens"] = tokens[1:]
+       }
+       path := ep.Path
+       if strings.Contains(ep.Path, "/:uuid") {
+               uuid, _ := params["uuid"].(string)
+               path = strings.Replace(path, "/:uuid", "/"+uuid, 1)
+               delete(params, "uuid")
+       }
+       return aClient.RequestAndDecodeContext(ctx, dst, ep.Method, path, body, params)
+}
+
+func (conn *Conn) CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error) {
+       ep := arvados.EndpointCollectionCreate
+       var resp arvados.Collection
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) CollectionUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Collection, error) {
+       ep := arvados.EndpointCollectionUpdate
+       var resp arvados.Collection
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) CollectionGet(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
+       ep := arvados.EndpointCollectionGet
+       var resp arvados.Collection
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error) {
+       ep := arvados.EndpointCollectionList
+       var resp arvados.CollectionList
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) CollectionProvenance(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error) {
+       ep := arvados.EndpointCollectionProvenance
+       var resp map[string]interface{}
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) CollectionUsedBy(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error) {
+       ep := arvados.EndpointCollectionUsedBy
+       var resp map[string]interface{}
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) CollectionDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error) {
+       ep := arvados.EndpointCollectionDelete
+       var resp arvados.Collection
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) CollectionTrash(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error) {
+       ep := arvados.EndpointCollectionTrash
+       var resp arvados.Collection
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) CollectionUntrash(ctx context.Context, options arvados.UntrashOptions) (arvados.Collection, error) {
+       ep := arvados.EndpointCollectionUntrash
+       var resp arvados.Collection
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error) {
+       ep := arvados.EndpointContainerCreate
+       var resp arvados.Container
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) ContainerUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Container, error) {
+       ep := arvados.EndpointContainerUpdate
+       var resp arvados.Container
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) ContainerGet(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+       ep := arvados.EndpointContainerGet
+       var resp arvados.Container
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
+       ep := arvados.EndpointContainerList
+       var resp arvados.ContainerList
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) ContainerDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Container, error) {
+       ep := arvados.EndpointContainerDelete
+       var resp arvados.Container
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) ContainerLock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+       ep := arvados.EndpointContainerLock
+       var resp arvados.Container
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) ContainerUnlock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+       ep := arvados.EndpointContainerUnlock
+       var resp arvados.Container
+       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
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) SpecimenUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Specimen, error) {
+       ep := arvados.EndpointSpecimenUpdate
+       var resp arvados.Specimen
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) SpecimenGet(ctx context.Context, options arvados.GetOptions) (arvados.Specimen, error) {
+       ep := arvados.EndpointSpecimenGet
+       var resp arvados.Specimen
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
+       ep := arvados.EndpointSpecimenList
+       var resp arvados.SpecimenList
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) SpecimenDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Specimen, error) {
+       ep := arvados.EndpointSpecimenDelete
+       var resp arvados.Specimen
+       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
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
diff --git a/lib/controller/rpc/conn_test.go b/lib/controller/rpc/conn_test.go
new file mode 100644 (file)
index 0000000..80e90a0
--- /dev/null
@@ -0,0 +1,79 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package rpc
+
+import (
+       "context"
+       "net/url"
+       "os"
+       "testing"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvados"
+       "git.curoverse.com/arvados.git/sdk/go/arvadostest"
+       "git.curoverse.com/arvados.git/sdk/go/ctxlog"
+       "github.com/sirupsen/logrus"
+       check "gopkg.in/check.v1"
+)
+
+// Gocheck boilerplate
+func Test(t *testing.T) {
+       check.TestingT(t)
+}
+
+var _ = check.Suite(&RPCSuite{})
+
+const contextKeyTestTokens = "testTokens"
+
+type RPCSuite struct {
+       log  logrus.FieldLogger
+       ctx  context.Context
+       conn *Conn
+}
+
+func (s *RPCSuite) SetUpTest(c *check.C) {
+       ctx := ctxlog.Context(context.Background(), ctxlog.TestLogger(c))
+       s.ctx = context.WithValue(ctx, contextKeyTestTokens, []string{arvadostest.ActiveToken})
+       s.conn = NewConn("zzzzz", &url.URL{Scheme: "https", Host: os.Getenv("ARVADOS_TEST_API_HOST")}, true, func(ctx context.Context) ([]string, error) {
+               return ctx.Value(contextKeyTestTokens).([]string), nil
+       })
+}
+
+func (s *RPCSuite) TestCollectionCreate(c *check.C) {
+       coll, err := s.conn.CollectionCreate(s.ctx, arvados.CreateOptions{Attrs: map[string]interface{}{
+               "owner_uuid":         arvadostest.ActiveUserUUID,
+               "portable_data_hash": "d41d8cd98f00b204e9800998ecf8427e+0",
+       }})
+       c.Check(err, check.IsNil)
+       c.Check(coll.UUID, check.HasLen, 27)
+}
+
+func (s *RPCSuite) TestSpecimenCRUD(c *check.C) {
+       sp, err := s.conn.SpecimenCreate(s.ctx, arvados.CreateOptions{Attrs: map[string]interface{}{
+               "owner_uuid": arvadostest.ActiveUserUUID,
+               "properties": map[string]string{"foo": "bar"},
+       }})
+       c.Check(err, check.IsNil)
+       c.Check(sp.UUID, check.HasLen, 27)
+       c.Check(sp.Properties, check.HasLen, 1)
+       c.Check(sp.Properties["foo"], check.Equals, "bar")
+
+       spGet, err := s.conn.SpecimenGet(s.ctx, arvados.GetOptions{UUID: sp.UUID})
+       c.Check(spGet.UUID, check.Equals, sp.UUID)
+       c.Check(spGet.Properties["foo"], check.Equals, "bar")
+
+       spList, err := s.conn.SpecimenList(s.ctx, arvados.ListOptions{Limit: -1, Filters: []arvados.Filter{{"uuid", "=", sp.UUID}}})
+       c.Check(spList.ItemsAvailable, check.Equals, 1)
+       c.Assert(spList.Items, check.HasLen, 1)
+       c.Check(spList.Items[0].UUID, check.Equals, sp.UUID)
+       c.Check(spList.Items[0].Properties["foo"], check.Equals, "bar")
+
+       anonCtx := context.WithValue(context.Background(), contextKeyTestTokens, []string{arvadostest.AnonymousToken})
+       spList, err = s.conn.SpecimenList(anonCtx, arvados.ListOptions{Limit: -1, Filters: []arvados.Filter{{"uuid", "=", sp.UUID}}})
+       c.Check(spList.ItemsAvailable, check.Equals, 0)
+       c.Check(spList.Items, check.HasLen, 0)
+
+       spDel, err := s.conn.SpecimenDelete(s.ctx, arvados.DeleteOptions{UUID: sp.UUID})
+       c.Check(spDel.UUID, check.Equals, sp.UUID)
+}
index ae7f138b1b6862ab43022ed91b0fbdd360b3dc36..edc5fd117de33f4b96dd3fa53f815ab382b13bf1 100644 (file)
@@ -5,6 +5,7 @@
 package controller
 
 import (
+       "context"
        "net/http"
        "os"
        "path/filepath"
@@ -36,6 +37,8 @@ func newServerFromIntegrationTestEnv(c *check.C) *httpserver.Server {
        handler := &Handler{Cluster: &arvados.Cluster{
                ClusterID:  "zzzzz",
                PostgreSQL: integrationTestCluster().PostgreSQL,
+
+               EnableBetaController14287: enableBetaController14287,
        }}
        handler.Cluster.TLS.Insecure = true
        arvadostest.SetServiceURL(&handler.Cluster.Services.RailsAPI, "https://"+os.Getenv("ARVADOS_TEST_API_HOST"))
@@ -43,7 +46,9 @@ func newServerFromIntegrationTestEnv(c *check.C) *httpserver.Server {
 
        srv := &httpserver.Server{
                Server: http.Server{
-                       Handler: httpserver.AddRequestIDs(httpserver.LogRequests(log, handler)),
+                       Handler: httpserver.HandlerWithContext(
+                               ctxlog.Context(context.Background(), log),
+                               httpserver.AddRequestIDs(httpserver.LogRequests(handler))),
                },
                Addr: ":",
        }
index 94021163e469fd87c6eb58dc29041ba00b95b65a..603f48890eccb6686a77ff8f80e99a8d020bfd52 100644 (file)
@@ -116,7 +116,8 @@ func (c *command) RunCommand(prog string, args []string, stdin io.Reader, stdout
        }
        srv := &httpserver.Server{
                Server: http.Server{
-                       Handler: httpserver.AddRequestIDs(httpserver.LogRequests(log, handler)),
+                       Handler: httpserver.HandlerWithContext(ctx,
+                               httpserver.AddRequestIDs(httpserver.LogRequests(handler))),
                },
                Addr: listen,
        }
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
new file mode 100644 (file)
index 0000000..7126575
--- /dev/null
@@ -0,0 +1,105 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import "context"
+
+type APIEndpoint struct {
+       Method string
+       Path   string
+       // "new attributes" key for create/update requests
+       AttrsKey string
+}
+
+var (
+       EndpointCollectionCreate              = APIEndpoint{"POST", "arvados/v1/collections", "collection"}
+       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", ""}
+       EndpointSpecimenCreate                = APIEndpoint{"POST", "arvados/v1/specimens", "specimen"}
+       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", ""}
+       EndpointContainerCreate               = APIEndpoint{"POST", "arvados/v1/containers", "container"}
+       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", ""}
+       EndpointAPIClientAuthorizationCurrent = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/current", ""}
+)
+
+type GetOptions struct {
+       UUID         string   `json:"uuid"`
+       Select       []string `json:"select"`
+       IncludeTrash bool     `json:"include_trash"`
+}
+
+type UntrashOptions struct {
+       UUID             string `json:"uuid"`
+       EnsureUniqueName bool   `json:"ensure_unique_name"`
+}
+
+type ListOptions struct {
+       ClusterID          string                 `json:"cluster_id"`
+       Select             []string               `json:"select"`
+       Filters            []Filter               `json:"filters"`
+       Where              map[string]interface{} `json:"where"`
+       Limit              int                    `json:"limit"`
+       Offset             int                    `json:"offset"`
+       Order              []string               `json:"order"`
+       Distinct           bool                   `json:"distinct"`
+       Count              string                 `json:"count"`
+       IncludeTrash       bool                   `json:"include_trash"`
+       IncludeOldVersions bool                   `json:"include_old_versions"`
+}
+
+type CreateOptions struct {
+       ClusterID        string                 `json:"cluster_id"`
+       EnsureUniqueName bool                   `json:"ensure_unique_name"`
+       Select           []string               `json:"select"`
+       Attrs            map[string]interface{} `json:"attrs"`
+}
+
+type UpdateOptions struct {
+       UUID  string                 `json:"uuid"`
+       Attrs map[string]interface{} `json:"attrs"`
+}
+
+type DeleteOptions struct {
+       UUID string `json:"uuid"`
+}
+
+type API interface {
+       CollectionCreate(ctx context.Context, options CreateOptions) (Collection, error)
+       CollectionUpdate(ctx context.Context, options UpdateOptions) (Collection, error)
+       CollectionGet(ctx context.Context, options GetOptions) (Collection, error)
+       CollectionList(ctx context.Context, options ListOptions) (CollectionList, error)
+       CollectionProvenance(ctx context.Context, options GetOptions) (map[string]interface{}, error)
+       CollectionUsedBy(ctx context.Context, options GetOptions) (map[string]interface{}, error)
+       CollectionDelete(ctx context.Context, options DeleteOptions) (Collection, error)
+       CollectionTrash(ctx context.Context, options DeleteOptions) (Collection, error)
+       CollectionUntrash(ctx context.Context, options UntrashOptions) (Collection, error)
+       ContainerCreate(ctx context.Context, options CreateOptions) (Container, error)
+       ContainerUpdate(ctx context.Context, options UpdateOptions) (Container, error)
+       ContainerGet(ctx context.Context, options GetOptions) (Container, error)
+       ContainerList(ctx context.Context, options ListOptions) (ContainerList, error)
+       ContainerDelete(ctx context.Context, options DeleteOptions) (Container, error)
+       ContainerLock(ctx context.Context, options GetOptions) (Container, error)
+       ContainerUnlock(ctx context.Context, options GetOptions) (Container, 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)
+       SpecimenList(ctx context.Context, options ListOptions) (SpecimenList, error)
+       SpecimenDelete(ctx context.Context, options DeleteOptions) (Specimen, error)
+       APIClientAuthorizationCurrent(ctx context.Context, options GetOptions) (APIClientAuthorization, error)
+}
index 17cff235db82fba55fa12c6ff08fe0a114dff27b..7c17cdef04debdf4540b6c2de761673c34d01883 100644 (file)
@@ -6,10 +6,10 @@ package arvados
 
 // APIClientAuthorization is an arvados#apiClientAuthorization resource.
 type APIClientAuthorization struct {
-       UUID      string   `json:"uuid,omitempty"`
-       APIToken  string   `json:"api_token,omitempty"`
-       ExpiresAt string   `json:"expires_at,omitempty"`
-       Scopes    []string `json:"scopes,omitempty"`
+       UUID      string   `json:"uuid"`
+       APIToken  string   `json:"api_token"`
+       ExpiresAt string   `json:"expires_at"`
+       Scopes    []string `json:"scopes"`
 }
 
 // APIClientAuthorizationList is an arvados#apiClientAuthorizationList resource.
index cbc2ca72f035f150fce46613fa015d299a9bbd7b..a5815987b192a86c9ee646205bcc9ea0f7986dcc 100644 (file)
@@ -13,7 +13,6 @@ import (
        "io"
        "io/ioutil"
        "log"
-       "math"
        "net/http"
        "net/url"
        "os"
@@ -35,6 +34,9 @@ type Client struct {
        // DefaultSecureClient or InsecureHTTPClient will be used.
        Client *http.Client `json:"-"`
 
+       // Protocol scheme: "http", "https", or "" (https)
+       Scheme string
+
        // Hostname (or host:port) of Arvados API server.
        APIHost string
 
@@ -79,6 +81,7 @@ func NewClientFromConfig(cluster *Cluster) (*Client, error) {
                return nil, fmt.Errorf("no host in config Services.Controller.ExternalURL: %v", ctrlURL)
        }
        return &Client{
+               Scheme:   ctrlURL.Scheme,
                APIHost:  ctrlURL.Host,
                Insecure: cluster.TLS.Insecure,
        }, nil
@@ -105,6 +108,7 @@ func NewClientFromEnv() *Client {
                insecure = true
        }
        return &Client{
+               Scheme:          "https",
                APIHost:         os.Getenv("ARVADOS_API_HOST"),
                AuthToken:       os.Getenv("ARVADOS_API_TOKEN"),
                Insecure:        insecure,
@@ -117,12 +121,17 @@ var reqIDGen = httpserver.IDGenerator{Prefix: "req-"}
 // Do adds Authorization and X-Request-Id headers and then calls
 // (*http.Client)Do().
 func (c *Client) Do(req *http.Request) (*http.Response, error) {
-       if c.AuthToken != "" {
+       if auth, _ := req.Context().Value(contextKeyAuthorization{}).(string); auth != "" {
+               req.Header.Add("Authorization", auth)
+       } else if c.AuthToken != "" {
                req.Header.Add("Authorization", "OAuth2 "+c.AuthToken)
        }
 
        if req.Header.Get("X-Request-Id") == "" {
-               reqid, _ := c.context().Value(contextKeyRequestID).(string)
+               reqid, _ := req.Context().Value(contextKeyRequestID{}).(string)
+               if reqid == "" {
+                       reqid, _ = c.context().Value(contextKeyRequestID{}).(string)
+               }
                if reqid == "" {
                        reqid = reqIDGen.Next()
                }
@@ -178,7 +187,9 @@ func anythingToValues(params interface{}) (url.Values, error) {
                return nil, err
        }
        var generic map[string]interface{}
-       err = json.Unmarshal(j, &generic)
+       dec := json.NewDecoder(bytes.NewBuffer(j))
+       dec.UseNumber()
+       err = dec.Decode(&generic)
        if err != nil {
                return nil, err
        }
@@ -188,21 +199,29 @@ func anythingToValues(params interface{}) (url.Values, error) {
                        urlValues.Set(k, v)
                        continue
                }
-               if v, ok := v.(float64); ok {
-                       // Unmarshal decodes all numbers as float64,
-                       // which can be written as 1.2345e4 in JSON,
-                       // but this form is not accepted for ints in
-                       // url params. If a number fits in an int64,
-                       // encode it as int64 rather than float64.
-                       if v, frac := math.Modf(v); frac == 0 && v <= math.MaxInt64 && v >= math.MinInt64 {
-                               urlValues.Set(k, fmt.Sprintf("%d", int64(v)))
-                               continue
+               if v, ok := v.(json.Number); ok {
+                       urlValues.Set(k, v.String())
+                       continue
+               }
+               if v, ok := v.(bool); ok {
+                       if v {
+                               urlValues.Set(k, "true")
+                       } else {
+                               // "foo=false", "foo=0", and "foo="
+                               // are all taken as true strings, so
+                               // don't send false values at all --
+                               // rely on the default being false.
                        }
+                       continue
                }
                j, err := json.Marshal(v)
                if err != nil {
                        return nil, err
                }
+               if bytes.Equal(j, []byte("null")) {
+                       // don't add it to urlValues at all
+                       continue
+               }
                urlValues.Set(k, string(j))
        }
        return urlValues, nil
@@ -216,6 +235,10 @@ func anythingToValues(params interface{}) (url.Values, error) {
 //
 // path must not contain a query string.
 func (c *Client) RequestAndDecode(dst interface{}, method, path string, body io.Reader, params interface{}) error {
+       return c.RequestAndDecodeContext(c.context(), dst, method, path, body, params)
+}
+
+func (c *Client) RequestAndDecodeContext(ctx context.Context, dst interface{}, method, path string, body io.Reader, params interface{}) error {
        if body, ok := body.(io.Closer); ok {
                // Ensure body is closed even if we error out early
                defer body.Close()
@@ -243,6 +266,7 @@ func (c *Client) RequestAndDecode(dst interface{}, method, path string, body io.
        if err != nil {
                return err
        }
+       req = req.WithContext(ctx)
        req.Header.Set("Content-type", "application/x-www-form-urlencoded")
        return c.DoAndDecode(dst, req)
 }
@@ -265,13 +289,13 @@ func (c *Client) UpdateBody(rsc resource) io.Reader {
        return bytes.NewBufferString(v.Encode())
 }
 
-type contextKey string
-
-var contextKeyRequestID contextKey = "X-Request-Id"
-
+// WithRequestID returns a new shallow copy of c that sends the given
+// X-Request-Id value (instead of a new randomly generated one) with
+// each subsequent request that doesn't provide its own via context or
+// header.
 func (c *Client) WithRequestID(reqid string) *Client {
        cc := *c
-       cc.ctx = context.WithValue(cc.context(), contextKeyRequestID, reqid)
+       cc.ctx = ContextWithRequestID(cc.context(), reqid)
        return &cc
 }
 
@@ -294,7 +318,11 @@ func (c *Client) httpClient() *http.Client {
 }
 
 func (c *Client) apiURL(path string) string {
-       return "https://" + c.APIHost + "/" + path
+       scheme := c.Scheme
+       if scheme == "" {
+               scheme = "https"
+       }
+       return scheme + "://" + c.APIHost + "/" + path
 }
 
 // DiscoveryDocument is the Arvados server's description of itself.
index 5b613006077baf2657b87b60820c05e7f4537963..5b919bea74a325173109c5f2cc12a56053116f56 100644 (file)
@@ -15,23 +15,25 @@ import (
 
 // Collection is an arvados#collection resource.
 type Collection struct {
-       UUID                      string     `json:"uuid,omitempty"`
-       OwnerUUID                 string     `json:"owner_uuid,omitempty"`
-       TrashAt                   *time.Time `json:"trash_at,omitempty"`
-       ManifestText              string     `json:"manifest_text"`
-       UnsignedManifestText      string     `json:"unsigned_manifest_text,omitempty"`
-       Name                      string     `json:"name,omitempty"`
-       CreatedAt                 *time.Time `json:"created_at,omitempty"`
-       ModifiedAt                *time.Time `json:"modified_at,omitempty"`
-       PortableDataHash          string     `json:"portable_data_hash,omitempty"`
-       ReplicationConfirmed      *int       `json:"replication_confirmed,omitempty"`
-       ReplicationConfirmedAt    *time.Time `json:"replication_confirmed_at,omitempty"`
-       ReplicationDesired        *int       `json:"replication_desired,omitempty"`
-       StorageClassesDesired     []string   `json:"storage_classes_desired,omitempty"`
-       StorageClassesConfirmed   []string   `json:"storage_classes_confirmed,omitempty"`
-       StorageClassesConfirmedAt *time.Time `json:"storage_classes_confirmed_at,omitempty"`
-       DeleteAt                  *time.Time `json:"delete_at,omitempty"`
-       IsTrashed                 bool       `json:"is_trashed,omitempty"`
+       UUID                      string                 `json:"uuid"`
+       Etag                      string                 `json:"etag"`
+       OwnerUUID                 string                 `json:"owner_uuid"`
+       TrashAt                   *time.Time             `json:"trash_at"`
+       ManifestText              string                 `json:"manifest_text"`
+       UnsignedManifestText      string                 `json:"unsigned_manifest_text"`
+       Name                      string                 `json:"name"`
+       CreatedAt                 *time.Time             `json:"created_at"`
+       ModifiedAt                *time.Time             `json:"modified_at"`
+       PortableDataHash          string                 `json:"portable_data_hash"`
+       ReplicationConfirmed      *int                   `json:"replication_confirmed"`
+       ReplicationConfirmedAt    *time.Time             `json:"replication_confirmed_at"`
+       ReplicationDesired        *int                   `json:"replication_desired"`
+       StorageClassesDesired     []string               `json:"storage_classes_desired"`
+       StorageClassesConfirmed   []string               `json:"storage_classes_confirmed"`
+       StorageClassesConfirmedAt *time.Time             `json:"storage_classes_confirmed_at"`
+       DeleteAt                  *time.Time             `json:"delete_at"`
+       IsTrashed                 bool                   `json:"is_trashed"`
+       Properties                map[string]interface{} `json:"properties"`
 }
 
 func (c Collection) resourceName() string {
@@ -73,7 +75,6 @@ func (c *Collection) SizedDigests() ([]SizedDigest, error) {
        return sds, scanner.Err()
 }
 
-// CollectionList is an arvados#collectionList resource.
 type CollectionList struct {
        Items          []Collection `json:"items"`
        ItemsAvailable int          `json:"items_available"`
index d7e92e6ed98ca1edb0131391d2891f096aa1d9d8..799f2e996c1af741515f26d0543a25edf95f4a7a 100644 (file)
@@ -159,6 +159,8 @@ type Cluster struct {
                UserProfileFormMessage string
                VocabularyURL          string
        }
+
+       EnableBetaController14287 bool
 }
 
 type Services struct {
diff --git a/sdk/go/arvados/context.go b/sdk/go/arvados/context.go
new file mode 100644 (file)
index 0000000..6ecf85b
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "context"
+)
+
+type contextKeyRequestID struct{}
+type contextKeyAuthorization struct{}
+
+func ContextWithRequestID(ctx context.Context, reqid string) context.Context {
+       return context.WithValue(ctx, contextKeyRequestID{}, reqid)
+}
+
+// ContextWithAuthorization returns a child context that (when used
+// with (*Client)RequestAndDecodeContext) sends the given
+// Authorization header value instead of the Client's default
+// AuthToken.
+func ContextWithAuthorization(ctx context.Context, value string) context.Context {
+       return context.WithValue(ctx, contextKeyAuthorization{}, value)
+}
index 9a04855784a76eee88bb4119b1483fa3505b67f2..5329a5146ad75e616446be87c6755a61176d7b7f 100644 (file)
@@ -31,6 +31,10 @@ func (e TransactionError) Error() (s string) {
        return
 }
 
+func (e TransactionError) HTTPStatus() int {
+       return e.StatusCode
+}
+
 func newTransactionError(req *http.Request, resp *http.Response, buf []byte) *TransactionError {
        var e TransactionError
        if json.Unmarshal(buf, &e) != nil {
index 9ae0fc3a5f4dc2a1e674325b5c4d9f86f19e5afa..c8308aea59e94d06be2e94235b2f0bda4f16f8c0 100644 (file)
@@ -26,5 +26,4 @@ type keepClient interface {
 
 type apiClient interface {
        RequestAndDecode(dst interface{}, method, path string, body io.Reader, params interface{}) error
-       UpdateBody(rsc resource) io.Reader
 }
index 6644f4cfb8e93ef7d601e667cee21a9dbce5d39b..972b3979fcfa4dd7fdb3cde62a90eacd37b27c56 100644 (file)
@@ -131,7 +131,12 @@ func (fs *collectionFileSystem) Sync() error {
                UUID:         fs.uuid,
                ManifestText: txt,
        }
-       err = fs.RequestAndDecode(nil, "PUT", "arvados/v1/collections/"+fs.uuid, fs.UpdateBody(coll), map[string]interface{}{"select": []string{"uuid"}})
+       err = fs.RequestAndDecode(nil, "PUT", "arvados/v1/collections/"+fs.uuid, nil, map[string]interface{}{
+               "collection": map[string]string{
+                       "manifest_text": coll.ManifestText,
+               },
+               "select": []string{"uuid"},
+       })
        if err != nil {
                return fmt.Errorf("sync failed: update %s: %s", fs.uuid, err)
        }
index 49e7d675f8b5c6729b61d94d0f271f615c68e924..91b8222cdc631d6ac80bf005c3f07c84fe641b92 100644 (file)
@@ -118,20 +118,24 @@ func (s *SiteFSSuite) TestProjectReaddirAfterLoadOne(c *check.C) {
 }
 
 func (s *SiteFSSuite) TestSlashInName(c *check.C) {
-       badCollection := Collection{
-               Name:      "bad/collection",
-               OwnerUUID: fixtureAProjectUUID,
-       }
-       err := s.client.RequestAndDecode(&badCollection, "POST", "arvados/v1/collections", s.client.UpdateBody(&badCollection), nil)
+       var badCollection Collection
+       err := s.client.RequestAndDecode(&badCollection, "POST", "arvados/v1/collections", nil, map[string]interface{}{
+               "collection": map[string]string{
+                       "name":       "bad/collection",
+                       "owner_uuid": fixtureAProjectUUID,
+               },
+       })
        c.Assert(err, check.IsNil)
        defer s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/collections/"+badCollection.UUID, nil, nil)
 
-       badProject := Group{
-               Name:       "bad/project",
-               GroupClass: "project",
-               OwnerUUID:  fixtureAProjectUUID,
-       }
-       err = s.client.RequestAndDecode(&badProject, "POST", "arvados/v1/groups", s.client.UpdateBody(&badProject), nil)
+       var badProject Group
+       err = s.client.RequestAndDecode(&badProject, "POST", "arvados/v1/groups", nil, map[string]interface{}{
+               "group": map[string]string{
+                       "name":        "bad/project",
+                       "group_class": "project",
+                       "owner_uuid":  fixtureAProjectUUID,
+               },
+       })
        c.Assert(err, check.IsNil)
        defer s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/groups/"+badProject.UUID, nil, nil)
 
@@ -154,11 +158,13 @@ func (s *SiteFSSuite) TestProjectUpdatedByOther(c *check.C) {
        _, err = s.fs.Open("/home/A Project/oob")
        c.Check(err, check.NotNil)
 
-       oob := Collection{
-               Name:      "oob",
-               OwnerUUID: fixtureAProjectUUID,
-       }
-       err = s.client.RequestAndDecode(&oob, "POST", "arvados/v1/collections", s.client.UpdateBody(&oob), nil)
+       var oob Collection
+       err = s.client.RequestAndDecode(&oob, "POST", "arvados/v1/collections", nil, map[string]interface{}{
+               "collection": map[string]string{
+                       "name":       "oob",
+                       "owner_uuid": fixtureAProjectUUID,
+               },
+       })
        c.Assert(err, check.IsNil)
        defer s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/collections/"+oob.UUID, nil, nil)
 
@@ -179,8 +185,13 @@ func (s *SiteFSSuite) TestProjectUpdatedByOther(c *check.C) {
        c.Check(err, check.IsNil)
 
        // Delete test.txt behind s.fs's back by updating the
-       // collection record with the old (empty) ManifestText.
-       err = s.client.RequestAndDecode(nil, "PATCH", "arvados/v1/collections/"+oob.UUID, s.client.UpdateBody(&oob), nil)
+       // collection record with an empty ManifestText.
+       err = s.client.RequestAndDecode(nil, "PATCH", "arvados/v1/collections/"+oob.UUID, nil, map[string]interface{}{
+               "collection": map[string]string{
+                       "manifest_text":      "",
+                       "portable_data_hash": "d41d8cd98f00b204e9800998ecf8427e+0",
+               },
+       })
        c.Assert(err, check.IsNil)
 
        err = project.Sync()
index 6b5718a6c740e69b0fd5c4fc8f19106c7dddef11..bf2fe72ff880b9a3885482bbd326aab4255c0691 100644 (file)
@@ -6,9 +6,9 @@ package arvados
 
 // Group is an arvados#group record
 type Group struct {
-       UUID       string `json:"uuid,omitempty"`
-       Name       string `json:"name,omitempty"`
-       OwnerUUID  string `json:"owner_uuid,omitempty"`
+       UUID       string `json:"uuid"`
+       Name       string `json:"name"`
+       OwnerUUID  string `json:"owner_uuid"`
        GroupClass string `json:"group_class"`
 }
 
index dee13556e1098f1788a36a55c49eb39df44c78a1..fbd699f30653035ff8c23ad1f62223c5ca54adc9 100644 (file)
@@ -7,13 +7,13 @@ package arvados
 // Link is an arvados#link record
 type Link struct {
        UUID      string `json:"uuid,omiempty"`
-       OwnerUUID string `json:"owner_uuid,omitempty"`
-       Name      string `json:"name,omitempty"`
-       LinkClass string `json:"link_class,omitempty"`
-       HeadUUID  string `json:"head_uuid,omitempty"`
-       HeadKind  string `json:"head_kind,omitempty"`
-       TailUUID  string `json:"tail_uuid,omitempty"`
-       TailKind  string `json:"tail_kind,omitempty"`
+       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"`
 }
 
 // UserList is an arvados#userList resource.
index 6f72bf7c6c9f46d931aaafbe9c4f8594ac470984..6f72634e5457e7379ee6660297be9aced63b91a0 100644 (file)
@@ -10,14 +10,14 @@ import (
 
 // Log is an arvados#log record
 type Log struct {
-       ID              uint64                 `json:"id,omitempty"`
-       UUID            string                 `json:"uuid,omitempty"`
-       ObjectUUID      string                 `json:"object_uuid,omitempty"`
-       ObjectOwnerUUID string                 `json:"object_owner_uuid,omitempty"`
-       EventType       string                 `json:"event_type,omitempty"`
-       EventAt         *time.Time             `json:"event,omitempty"`
-       Properties      map[string]interface{} `json:"properties,omitempty"`
-       CreatedAt       *time.Time             `json:"created_at,omitempty"`
+       ID              uint64                 `json:"id"`
+       UUID            string                 `json:"uuid"`
+       ObjectUUID      string                 `json:"object_uuid"`
+       ObjectOwnerUUID string                 `json:"object_owner_uuid"`
+       EventType       string                 `json:"event_type"`
+       EventAt         *time.Time             `json:"event"`
+       Properties      map[string]interface{} `json:"properties"`
+       CreatedAt       *time.Time             `json:"created_at"`
 }
 
 // LogList is an arvados#logList resource.
index cc844fe8253c1684dcec9b006659b8a08e84e8cf..97466eb8ae8bc44863cf4711582268108772bbf6 100644 (file)
@@ -12,10 +12,10 @@ type Node struct {
        Domain     string         `json:"domain"`
        Hostname   string         `json:"hostname"`
        IPAddress  string         `json:"ip_address"`
-       LastPingAt *time.Time     `json:"last_ping_at,omitempty"`
+       LastPingAt *time.Time     `json:"last_ping_at"`
        SlotNumber int            `json:"slot_number"`
        Status     string         `json:"status"`
-       JobUUID    string         `json:"job_uuid,omitempty"`
+       JobUUID    string         `json:"job_uuid"`
        Properties NodeProperties `json:"properties"`
 }
 
index 14ce098cfc1a54f0f0de74aa9cf60ca8274a693e..d1a25c438a9eeb72e147cbd5658ee1bb340ee344 100644 (file)
@@ -4,7 +4,10 @@
 
 package arvados
 
-import "encoding/json"
+import (
+       "encoding/json"
+       "fmt"
+)
 
 // ResourceListParams expresses which results are requested in a
 // list/index API.
@@ -27,7 +30,35 @@ type Filter struct {
        Operand  interface{}
 }
 
-// MarshalJSON encodes a Filter in the form expected by the API.
+// MarshalJSON encodes a Filter to a JSON array.
 func (f *Filter) MarshalJSON() ([]byte, error) {
        return json.Marshal([]interface{}{f.Attr, f.Operator, f.Operand})
 }
+
+// UnmarshalJSON decodes a JSON array to a Filter.
+func (f *Filter) UnmarshalJSON(data []byte) error {
+       var elements []interface{}
+       err := json.Unmarshal(data, &elements)
+       if err != nil {
+               return err
+       }
+       if len(elements) != 3 {
+               return fmt.Errorf("invalid filter %q: must have 3 elements", data)
+       }
+       attr, ok := elements[0].(string)
+       if !ok {
+               return fmt.Errorf("invalid filter attr %q", elements[0])
+       }
+       op, ok := elements[1].(string)
+       if !ok {
+               return fmt.Errorf("invalid filter operator %q", elements[1])
+       }
+       operand := elements[2]
+       switch operand.(type) {
+       case string, float64, []interface{}, nil:
+       default:
+               return fmt.Errorf("invalid filter operand %q", elements[2])
+       }
+       *f = Filter{attr, op, operand}
+       return nil
+}
index 5642599b4c5664a16a7b1342887748863f9c2715..4e09c5375db980c60171f9209714c81f55aef152 100644 (file)
@@ -23,3 +23,14 @@ func TestMarshalFiltersWithNanoseconds(t *testing.T) {
                t.Errorf("Encoded as %q, expected %q", buf, expect)
        }
 }
+
+func TestMarshalFiltersWithNil(t *testing.T) {
+       buf, err := json.Marshal([]Filter{
+               {Attr: "modified_at", Operator: "=", Operand: nil}})
+       if err != nil {
+               t.Fatal(err)
+       }
+       if expect := []byte(`[["modified_at","=",null]]`); 0 != bytes.Compare(buf, expect) {
+               t.Errorf("Encoded as %q, expected %q", buf, expect)
+       }
+}
diff --git a/sdk/go/arvados/specimen.go b/sdk/go/arvados/specimen.go
new file mode 100644 (file)
index 0000000..e320ca2
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import "time"
+
+type Specimen struct {
+       UUID       string                 `json:"uuid"`
+       OwnerUUID  string                 `json:"owner_uuid"`
+       CreatedAt  time.Time              `json:"created_at"`
+       ModifiedAt time.Time              `json:"modified_at"`
+       UpdatedAt  time.Time              `json:"updated_at"`
+       Properties map[string]interface{} `json:"properties"`
+}
+
+type SpecimenList struct {
+       Items          []Specimen `json:"items"`
+       ItemsAvailable int        `json:"items_available"`
+       Offset         int        `json:"offset"`
+       Limit          int        `json:"limit"`
+}
index 3a36e5ebaee6614ba6ae64be3f353bd3edef6dba..27d2b28a42b6c5c4312d0aa16624e8061103ac5d 100644 (file)
@@ -6,11 +6,11 @@ package arvados
 
 // User is an arvados#user record
 type User struct {
-       UUID     string `json:"uuid,omitempty"`
+       UUID     string `json:"uuid"`
        IsActive bool   `json:"is_active"`
        IsAdmin  bool   `json:"is_admin"`
-       Username string `json:"username,omitempty"`
-       Email    string `json:"email,omitempty"`
+       Username string `json:"username"`
+       Email    string `json:"email"`
 }
 
 // UserList is an arvados#userList resource.
index 09c8c71e8ef5e7c1c03c5f33aa6f3a6c923cf22d..5ddc8732df16b09ad81f21686b25f05cca9eb873 100644 (file)
@@ -8,13 +8,13 @@ import "time"
 
 // Workflow is an arvados#workflow resource.
 type Workflow struct {
-       UUID        string     `json:"uuid,omitempty"`
-       OwnerUUID   string     `json:"owner_uuid,omitempty"`
-       Name        string     `json:"name,omitempty"`
-       Description string     `json:"description,omitempty"`
-       Definition  string     `json:"definition,omitempty"`
-       CreatedAt   *time.Time `json:"created_at,omitempty"`
-       ModifiedAt  *time.Time `json:"modified_at,omitempty"`
+       UUID        string     `json:"uuid"`
+       OwnerUUID   string     `json:"owner_uuid"`
+       Name        string     `json:"name"`
+       Description string     `json:"description"`
+       Definition  string     `json:"definition"`
+       CreatedAt   *time.Time `json:"created_at"`
+       ModifiedAt  *time.Time `json:"modified_at"`
 }
 
 // WorkflowList is an arvados#workflowList resource.
diff --git a/sdk/go/arvadostest/api.go b/sdk/go/arvadostest/api.go
new file mode 100644 (file)
index 0000000..a3cacf3
--- /dev/null
@@ -0,0 +1,135 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvadostest
+
+import (
+       "context"
+       "errors"
+       "sync"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvados"
+)
+
+var ErrStubUnimplemented = errors.New("stub unimplemented")
+
+type APIStub struct {
+       // The error to return from every stubbed API method.
+       Error error
+       calls []APIStubCall
+       mtx   sync.Mutex
+}
+
+func (as *APIStub) CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error) {
+       as.appendCall(as.CollectionCreate, ctx, options)
+       return arvados.Collection{}, as.Error
+}
+func (as *APIStub) CollectionUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Collection, error) {
+       as.appendCall(as.CollectionUpdate, ctx, options)
+       return arvados.Collection{}, as.Error
+}
+func (as *APIStub) CollectionGet(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
+       as.appendCall(as.CollectionGet, ctx, options)
+       return arvados.Collection{}, as.Error
+}
+func (as *APIStub) CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error) {
+       as.appendCall(as.CollectionList, ctx, options)
+       return arvados.CollectionList{}, as.Error
+}
+func (as *APIStub) CollectionProvenance(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error) {
+       as.appendCall(as.CollectionProvenance, ctx, options)
+       return nil, as.Error
+}
+func (as *APIStub) CollectionUsedBy(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error) {
+       as.appendCall(as.CollectionUsedBy, ctx, options)
+       return nil, as.Error
+}
+func (as *APIStub) CollectionDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error) {
+       as.appendCall(as.CollectionDelete, ctx, options)
+       return arvados.Collection{}, as.Error
+}
+func (as *APIStub) CollectionTrash(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error) {
+       as.appendCall(as.CollectionTrash, ctx, options)
+       return arvados.Collection{}, as.Error
+}
+func (as *APIStub) CollectionUntrash(ctx context.Context, options arvados.UntrashOptions) (arvados.Collection, error) {
+       as.appendCall(as.CollectionUntrash, ctx, options)
+       return arvados.Collection{}, as.Error
+}
+func (as *APIStub) ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error) {
+       as.appendCall(as.ContainerCreate, ctx, options)
+       return arvados.Container{}, as.Error
+}
+func (as *APIStub) ContainerUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Container, error) {
+       as.appendCall(as.ContainerUpdate, ctx, options)
+       return arvados.Container{}, as.Error
+}
+func (as *APIStub) ContainerGet(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+       as.appendCall(as.ContainerGet, ctx, options)
+       return arvados.Container{}, as.Error
+}
+func (as *APIStub) ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
+       as.appendCall(as.ContainerList, ctx, options)
+       return arvados.ContainerList{}, as.Error
+}
+func (as *APIStub) ContainerDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Container, error) {
+       as.appendCall(as.ContainerDelete, ctx, options)
+       return arvados.Container{}, as.Error
+}
+func (as *APIStub) ContainerLock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+       as.appendCall(as.ContainerLock, ctx, options)
+       return arvados.Container{}, as.Error
+}
+func (as *APIStub) ContainerUnlock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+       as.appendCall(as.ContainerUnlock, ctx, options)
+       return arvados.Container{}, as.Error
+}
+func (as *APIStub) SpecimenCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Specimen, error) {
+       as.appendCall(as.SpecimenCreate, ctx, options)
+       return arvados.Specimen{}, as.Error
+}
+func (as *APIStub) SpecimenUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Specimen, error) {
+       as.appendCall(as.SpecimenUpdate, ctx, options)
+       return arvados.Specimen{}, as.Error
+}
+func (as *APIStub) SpecimenGet(ctx context.Context, options arvados.GetOptions) (arvados.Specimen, error) {
+       as.appendCall(as.SpecimenGet, ctx, options)
+       return arvados.Specimen{}, as.Error
+}
+func (as *APIStub) SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
+       as.appendCall(as.SpecimenList, ctx, options)
+       return arvados.SpecimenList{}, as.Error
+}
+func (as *APIStub) SpecimenDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Specimen, error) {
+       as.appendCall(as.SpecimenDelete, ctx, options)
+       return arvados.Specimen{}, 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
+}
+
+func (as *APIStub) appendCall(method interface{}, ctx context.Context, options interface{}) {
+       as.mtx.Lock()
+       defer as.mtx.Unlock()
+       as.calls = append(as.calls, APIStubCall{method, ctx, options})
+}
+
+func (as *APIStub) Calls(method interface{}) []APIStubCall {
+       as.mtx.Lock()
+       defer as.mtx.Unlock()
+       var calls []APIStubCall
+       for _, call := range as.calls {
+               if method == nil || call.Method == method {
+                       calls = append(calls, call)
+               }
+       }
+       return calls
+}
+
+type APIStubCall struct {
+       Method  interface{}
+       Context context.Context
+       Options interface{}
+}
index 95b83265a05a4835363975cf8720157c0e9171da..be29bc23ef394df21ac95a19ea169873d83af4ca 100644 (file)
@@ -32,7 +32,7 @@ const (
        ASubprojectUUID = "zzzzz-j7d0g-axqo7eu9pwvna1x"
 
        FooAndBarFilesInDirUUID = "zzzzz-4zz18-foonbarfilesdir"
-       FooAndBarFilesInDirPDH  = "6bbac24198d09a93975f60098caf0bdf+62"
+       FooAndBarFilesInDirPDH  = "870369fc72738603c2fad16664e50e2d+58"
 
        Dispatch1Token    = "kwi8oowusvbutahacwk2geulqewy5oaqmpalczfna4b6bb0hfw"
        Dispatch1AuthUUID = "zzzzz-gj3su-k9dvestay1plssr"
@@ -55,6 +55,8 @@ const (
        FooCollectionSharingToken     = "iknqgmunrhgsyfok8uzjlwun9iscwm3xacmzmg65fa1j1lpdss"
 
        WorkflowWithDefinitionYAMLUUID = "zzzzz-7fd4e-validworkfloyml"
+
+       CollectionReplicationDesired2Confirmed2UUID = "zzzzz-4zz18-434zv1tnnf2rygp"
 )
 
 // PathologicalManifest : A valid manifest designed to test
index 3c266e0d3afda2254df6b3c7ccad7157a121bc6c..c2f6a0e8f0885e68a98f7e62a4ee4f17d0d930d2 100644 (file)
@@ -5,6 +5,7 @@
 package auth
 
 import (
+       "context"
        "encoding/base64"
        "net/http"
        "net/url"
@@ -19,8 +20,17 @@ func NewCredentials() *Credentials {
        return &Credentials{Tokens: []string{}}
 }
 
+func NewContext(ctx context.Context, c *Credentials) context.Context {
+       return context.WithValue(ctx, contextKeyCredentials{}, c)
+}
+
+func FromContext(ctx context.Context) (*Credentials, bool) {
+       c, ok := ctx.Value(contextKeyCredentials{}).(*Credentials)
+       return c, ok
+}
+
 func CredentialsFromRequest(r *http.Request) *Credentials {
-       if c, ok := r.Context().Value(contextKeyCredentials).(*Credentials); ok {
+       if c, ok := FromContext(r.Context()); ok {
                // preloaded by middleware
                return c
        }
index ad1fa5141a1cf268729e33bdef3cacc3fb14d76c..b638f7982516b431e15322adde0b55b0637c8a3f 100644 (file)
@@ -9,17 +9,15 @@ import (
        "net/http"
 )
 
-type contextKey string
-
-var contextKeyCredentials contextKey = "credentials"
+type contextKeyCredentials struct{}
 
 // LoadToken wraps the next handler, adding credentials to the request
 // context so subsequent handlers can access them efficiently via
 // CredentialsFromRequest.
 func LoadToken(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-               if _, ok := r.Context().Value(contextKeyCredentials).(*Credentials); !ok {
-                       r = r.WithContext(context.WithValue(r.Context(), contextKeyCredentials, CredentialsFromRequest(r)))
+               if _, ok := r.Context().Value(contextKeyCredentials{}).(*Credentials); !ok {
+                       r = r.WithContext(context.WithValue(r.Context(), contextKeyCredentials{}, CredentialsFromRequest(r)))
                }
                next.ServeHTTP(w, r)
        })
index 1ccf8c04782fbf57aedfe6cb20f75c50ef53cb9d..b222e18ea1159e67b9069c086207dbc3585c8e26 100644 (file)
@@ -14,10 +14,7 @@ type ErrorResponse struct {
 }
 
 func Error(w http.ResponseWriter, error string, code int) {
-       w.Header().Set("Content-Type", "application/json")
-       w.Header().Set("X-Content-Type-Options", "nosniff")
-       w.WriteHeader(code)
-       json.NewEncoder(w).Encode(ErrorResponse{Errors: []string{error}})
+       Errors(w, []string{error}, code)
 }
 
 func Errors(w http.ResponseWriter, errors []string, code int) {
index 357daee269f3784dd650107ef081d689cd3639de..f64708454c2b1e12cb5a75906d7d43676629cfb5 100644 (file)
@@ -9,6 +9,7 @@ import (
        "net/http"
        "time"
 
+       "git.curoverse.com/arvados.git/sdk/go/ctxlog"
        "git.curoverse.com/arvados.git/sdk/go/stats"
        "github.com/sirupsen/logrus"
 )
@@ -19,18 +20,23 @@ type contextKey struct {
 
 var (
        requestTimeContextKey = contextKey{"requestTime"}
-       loggerContextKey      = contextKey{"logger"}
 )
 
+// HandlerWithContext returns an http.Handler that changes the request
+// context to ctx (replacing http.Server's default
+// context.Background()), then calls next.
+func HandlerWithContext(ctx context.Context, next http.Handler) http.Handler {
+       return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               next.ServeHTTP(w, r.WithContext(ctx))
+       })
+}
+
 // LogRequests wraps an http.Handler, logging each request and
-// response via logger.
-func LogRequests(logger logrus.FieldLogger, h http.Handler) http.Handler {
-       if logger == nil {
-               logger = logrus.StandardLogger()
-       }
+// response.
+func LogRequests(h http.Handler) http.Handler {
        return http.HandlerFunc(func(wrapped http.ResponseWriter, req *http.Request) {
                w := &responseTimer{ResponseWriter: WrapResponseWriter(wrapped)}
-               lgr := logger.WithFields(logrus.Fields{
+               lgr := ctxlog.FromContext(req.Context()).WithFields(logrus.Fields{
                        "RequestID":       req.Header.Get("X-Request-Id"),
                        "remoteAddr":      req.RemoteAddr,
                        "reqForwardedFor": req.Header.Get("X-Forwarded-For"),
@@ -42,7 +48,7 @@ func LogRequests(logger logrus.FieldLogger, h http.Handler) http.Handler {
                })
                ctx := req.Context()
                ctx = context.WithValue(ctx, &requestTimeContextKey, time.Now())
-               ctx = context.WithValue(ctx, &loggerContextKey, lgr)
+               ctx = ctxlog.Context(ctx, lgr)
                req = req.WithContext(ctx)
 
                logRequest(w, req, lgr)
@@ -52,11 +58,7 @@ func LogRequests(logger logrus.FieldLogger, h http.Handler) http.Handler {
 }
 
 func Logger(req *http.Request) logrus.FieldLogger {
-       if lgr, ok := req.Context().Value(&loggerContextKey).(logrus.FieldLogger); ok {
-               return lgr
-       } else {
-               return logrus.StandardLogger()
-       }
+       return ctxlog.FromContext(req.Context())
 }
 
 func logRequest(w *responseTimer, req *http.Request, lgr *logrus.Entry) {
index 8386db9276935c9dbd565ea657357b15302111cb..3b2bc7758069b44345b3da522b8f80cc303c52fe 100644 (file)
@@ -6,12 +6,14 @@ package httpserver
 
 import (
        "bytes"
+       "context"
        "encoding/json"
        "net/http"
        "net/http/httptest"
        "testing"
        "time"
 
+       "git.curoverse.com/arvados.git/sdk/go/ctxlog"
        "github.com/sirupsen/logrus"
        check "gopkg.in/check.v1"
 )
@@ -31,15 +33,19 @@ func (s *Suite) TestLogRequests(c *check.C) {
        log.Formatter = &logrus.JSONFormatter{
                TimestampFormat: time.RFC3339Nano,
        }
+       ctx := ctxlog.Context(context.Background(), log)
+
+       h := AddRequestIDs(LogRequests(
+               http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+                       w.Write([]byte("hello world"))
+               })))
 
-       h := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-               w.Write([]byte("hello world"))
-       })
        req, err := http.NewRequest("GET", "https://foo.example/bar", nil)
        req.Header.Set("X-Forwarded-For", "1.2.3.4:12345")
        c.Assert(err, check.IsNil)
        resp := httptest.NewRecorder()
-       AddRequestIDs(LogRequests(log, h)).ServeHTTP(resp, req)
+
+       HandlerWithContext(ctx, h).ServeHTTP(resp, req)
 
        dec := json.NewDecoder(captured)
 
index 032093f8d8aab842a1a3af194089c1ee9b4980e4..fab6c3f11801a4a6ec52de6f1f86f17a438e246f 100644 (file)
@@ -104,7 +104,7 @@ func (m *metrics) ServeAPI(token string, next http.Handler) http.Handler {
 //
 // For the metrics to be accurate, the caller must ensure every
 // request passed to the Handler also passes through
-// LogRequests(logger, ...), and vice versa.
+// LogRequests(...), and vice versa.
 //
 // If registry is nil, a new registry is created.
 //
index ab610d65e71453ba8abc2287c5321a6a41dee217..c8dd09de86fbfeab54b32a71978ccbc7be026048 100644 (file)
@@ -551,7 +551,7 @@ func (kc *KeepClient) httpClient() HTTPClient {
                // It's not safe to copy *http.DefaultTransport
                // because it has a mutex (which might be locked)
                // protecting a private map (which might not be nil).
-               // So we build our own, using the Go 1.10 default
+               // So we build our own, using the Go 1.12 default
                // values, ignoring any changes the application has
                // made to http.DefaultTransport.
                Transport: &http.Transport{
@@ -563,7 +563,7 @@ func (kc *KeepClient) httpClient() HTTPClient {
                        MaxIdleConns:          100,
                        IdleConnTimeout:       90 * time.Second,
                        TLSHandshakeTimeout:   tlsTimeout,
-                       ExpectContinueTimeout: time.Second,
+                       ExpectContinueTimeout: 1 * time.Second,
                        TLSClientConfig:       arvadosclient.MakeTLSConfig(kc.Arvados.ApiInsecure),
                },
        }
index 1ef3b00c665e89c61aaa7853c7b0b455c944259a..a7b8bacdc340bcbd9c7286187fca1bfce3309d44 100644 (file)
@@ -8,7 +8,7 @@ events {
 }
 http {
   log_format customlog
-    '[$time_local] $server_name $status $body_bytes_sent $request_time $request_method "$scheme://$http_host$request_uri" $remote_addr:$remote_port '
+    '[$time_local] "$http_x_request_id" $server_name $status $body_bytes_sent $request_time $request_method "$scheme://$http_host$request_uri" $remote_addr:$remote_port '
     '"$http_referer" "$http_user_agent"';
   access_log "{{ACCESSLOG}}" customlog;
   client_body_temp_path "{{TMPDIR}}";
index fea0578abdeca4d70a87cfadf0b9fe95825eb866..9b0676b5953726d63ca9c8e284b53877355ed057 100644 (file)
@@ -413,9 +413,13 @@ def run_controller():
         f.write("""
 Clusters:
   zzzzz:
+    EnableBetaController14287: {beta14287}
     ManagementToken: e687950a23c3a9bceec28c6223a06c79
     API:
       RequestTimeout: 30s
+    Logging:
+        Level: "{loglevel}"
+    HTTPRequestTimeout: 30s
     PostgreSQL:
       ConnectionPool: 32
       Connection:
@@ -433,6 +437,8 @@ Clusters:
         InternalURLs:
           "https://localhost:{railsport}": {{}}
         """.format(
+            beta14287=('true' if '14287' in os.environ.get('ARVADOS_EXPERIMENTAL', '') else 'false'),
+            loglevel=('info' if os.environ.get('ARVADOS_DEBUG', '') in ['','0'] else 'debug'),
             dbhost=_dbconfig('host'),
             dbname=_dbconfig('database'),
             dbuser=_dbconfig('username'),
index 39d56997a09596f64a86a2842a96667b048a6f92..ac00e5d39c2cce78c1dd26cfca3fba285810b80e 100644 (file)
@@ -27,6 +27,7 @@ class Collection < ArvadosModel
   before_validation :check_manifest_validity
   before_validation :check_signatures
   before_validation :strip_signatures_and_update_replication_confirmed
+  before_validation :name_null_if_empty
   validate :ensure_pdh_matches_manifest_text
   validate :ensure_storage_classes_desired_is_not_empty
   validate :ensure_storage_classes_contain_non_empty_strings
@@ -38,7 +39,7 @@ class Collection < ArvadosModel
   around_update :manage_versioning, unless: :is_past_version?
 
   api_accessible :user, extend: :common do |t|
-    t.add :name
+    t.add lambda { |x| x.name || "" }, as: :name
     t.add :description
     t.add :properties
     t.add :portable_data_hash
@@ -77,6 +78,7 @@ class Collection < ArvadosModel
                 # correct timestamp in signed_manifest_text.
                 'manifest_text' => ['manifest_text', 'trash_at', 'is_trashed'],
                 'unsigned_manifest_text' => ['manifest_text'],
+                'name' => ['name'],
                 )
   end
 
@@ -195,6 +197,12 @@ class Collection < ArvadosModel
     end
   end
 
+  def name_null_if_empty
+    if name == ""
+      self.name = nil
+    end
+  end
+
   def set_file_names
     if self.manifest_text_changed?
       self.file_names = manifest_files
index c84e479e48fbe6118a1297a903013addf68e928e..1503f6bc0147ebde726fb449ee5af734637fdca1 100644 (file)
@@ -101,7 +101,7 @@ baz_file:
 w_a_z_file:
   uuid: zzzzz-4zz18-25k12570yk134b3
   current_version_uuid: zzzzz-4zz18-25k12570yk134b3
-  portable_data_hash: 8706aadd12a0ebc07d74cae88762ba9e+56
+  portable_data_hash: 44a8da9ec82098323895cd14e178386f+56
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2015-02-09T10:53:38Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -129,7 +129,7 @@ w_a_z_file_version_1:
 multilevel_collection_1:
   uuid: zzzzz-4zz18-pyw8yp9g3pr7irn
   current_version_uuid: zzzzz-4zz18-pyw8yp9g3pr7irn
-  portable_data_hash: 1fd08fc162a5c6413070a8bd0bffc818+150
+  portable_data_hash: f9ddda46bb293b6847da984e3aa735db+290
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -143,7 +143,7 @@ multilevel_collection_2:
   uuid: zzzzz-4zz18-45xf9hw1sxkhl6q
   current_version_uuid: zzzzz-4zz18-45xf9hw1sxkhl6q
   # All of this collection's files are deep in subdirectories.
-  portable_data_hash: 80cf6dd2cf079dd13f272ec4245cb4a8+48
+  portable_data_hash: 8591cc5caeca80fc62fd529ba1d63bf3+118
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -394,7 +394,7 @@ unique_expired_collection:
 unique_expired_collection2:
   uuid: zzzzz-4zz18-mto52zx1s7sn3jr
   current_version_uuid: zzzzz-4zz18-mto52zx1s7sn3jr
-  portable_data_hash: 4ad199f90029935844dc3f098f4fca2b+49
+  portable_data_hash: 64a2bed1ef0f40fe3a7d39bcf2584cb8+50
   owner_uuid: zzzzz-tpzed-000000000000000
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -404,7 +404,7 @@ unique_expired_collection2:
   is_trashed: true
   trash_at: 2001-01-01T00:00:00Z
   delete_at: 2038-01-01T00:00:00Z
-  manifest_text: ". 29d7797f1888013986899bc9083783fa+3 0:3:expired\n"
+  manifest_text: ". 29d7797f1888013986899bc9083783fa+3 0:3:expired2\n"
   name: unique_expired_collection2
 
 # a collection with a log file that can be parsed by the log viewer
@@ -474,14 +474,14 @@ collection_with_files_in_subdir:
   uuid: zzzzz-4zz18-filesinsubdir00
   current_version_uuid: zzzzz-4zz18-filesinsubdir00
   name: collection_files_in_subdir
-  portable_data_hash: 85877ca2d7e05498dd3d109baf2df106+95
+  portable_data_hash: 7eb64275355980ebc93411b44050c137+281
   owner_uuid: zzzzz-tpzed-user1withloadab
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
   modified_by_user_uuid: zzzzz-tpzed-user1withloadab
   modified_at: 2014-02-03T17:22:54Z
   updated_at: 2014-02-03T17:22:54Z
-  manifest_text: ". 85877ca2d7e05498dd3d109baf2df106+95 0:95:file_in_subdir1\n./subdir2/subdir3 2bbc341c702df4d8f42ec31f16c10120+64 0:32:file1_in_subdir3.txt 32:32:file2_in_subdir3.txt\n./subdir2/subdir3/subdir4 2bbc341c702df4d8f42ec31f16c10120+64 0:32:file1_in_subdir4.txt 32:32:file2_in_subdir4.txt"
+  manifest_text: ". 85877ca2d7e05498dd3d109baf2df106+95 0:95:file_in_subdir1\n./subdir2/subdir3 2bbc341c702df4d8f42ec31f16c10120+64 0:32:file1_in_subdir3.txt 32:32:file2_in_subdir3.txt\n./subdir2/subdir3/subdir4 2bbc341c702df4d8f42ec31f16c10120+64 0:32:file1_in_subdir4.txt 32:32:file2_in_subdir4.txt\n"
 
 graph_test_collection1:
   uuid: zzzzz-4zz18-bv31uwvy3neko22
@@ -722,7 +722,7 @@ collection_with_one_property:
 collection_with_repeated_filenames_and_contents_in_two_dirs_1:
   uuid: zzzzz-4zz18-duplicatenames1
   current_version_uuid: zzzzz-4zz18-duplicatenames1
-  portable_data_hash: f3a67fad3a19c31c658982fb8158fa58+144
+  portable_data_hash: ce437b12aa73ab34f7af5227f556c9e6+142
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -748,7 +748,7 @@ collection_with_repeated_filenames_and_contents_in_two_dirs_2:
 foo_and_bar_files_in_dir:
   uuid: zzzzz-4zz18-foonbarfilesdir
   current_version_uuid: zzzzz-4zz18-foonbarfilesdir
-  portable_data_hash: 6bbac24198d09a93975f60098caf0bdf+62
+  portable_data_hash: 870369fc72738603c2fad16664e50e2d+58
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -801,20 +801,20 @@ collection_with_several_unsupported_file_types:
 collection_not_readable_by_active:
   uuid: zzzzz-4zz18-cd42uwvy3neko21
   current_version_uuid: zzzzz-4zz18-cd42uwvy3neko21
-  portable_data_hash: bb89eb5140e2848d39b416daeef4ffc5+45
+  portable_data_hash: b9e51a238ce08a698e7d7f8f101aee18+55
   owner_uuid: zzzzz-tpzed-000000000000000
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
   modified_by_user_uuid: zzzzz-tpzed-d9tiejq69daie8f
   modified_at: 2014-02-03T17:22:54Z
   updated_at: 2014-02-03T17:22:54Z
-  manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+  manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar 0:0:empty\n"
   name: collection_not_readable_by_active
 
 collection_to_remove_and_rename_files:
   uuid: zzzzz-4zz18-a21ux3541sxa8sf
   current_version_uuid: zzzzz-4zz18-a21ux3541sxa8sf
-  portable_data_hash: 80cf6dd2cf079dd13f272ec4245cb4a8+48
+  portable_data_hash: 21aed8fd508bd6263704b673455949ba+57
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -843,7 +843,7 @@ collection_with_tags_owned_by_active:
 trashed_collection_to_test_name_conflict_on_untrash:
   uuid: zzzzz-4zz18-trashedcolnamec
   current_version_uuid: zzzzz-4zz18-trashedcolnamec
-  portable_data_hash: 80cf6dd2cf079dd13f272ec4245cb4a8+48
+  portable_data_hash: 21aed8fd508bd6263704b673455949ba+57
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -859,7 +859,7 @@ trashed_collection_to_test_name_conflict_on_untrash:
 same_name_as_trashed_coll_to_test_name_conflict_on_untrash:
   uuid: zzzzz-4zz18-namesameastrash
   current_version_uuid: zzzzz-4zz18-namesameastrash
-  portable_data_hash: 80cf6dd2cf079dd13f272ec4245cb4a8+48
+  portable_data_hash: 21aed8fd508bd6263704b673455949ba+57
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -872,7 +872,7 @@ same_name_as_trashed_coll_to_test_name_conflict_on_untrash:
 collection_in_trashed_subproject:
   uuid: zzzzz-4zz18-trashedproj2col
   current_version_uuid: zzzzz-4zz18-trashedproj2col
-  portable_data_hash: 80cf6dd2cf079dd13f272ec4245cb4a8+48
+  portable_data_hash: 21aed8fd508bd6263704b673455949ba+57
   owner_uuid: zzzzz-j7d0g-trashedproject2
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
index dea98887e9843866b182a7ca054aa60628223fd7..ea86dca1784834d7ca0c37838c743aa785812a7b 100644 (file)
@@ -322,7 +322,7 @@ completed_with_input_mounts:
           basename: bar
           class: File
           location: "keep:fa7aeb5140e2848d39b416daeef4ffc5+45/bar"
-    /var/lib/cwl/workflow.json: "keep:1fd08fc162a5c6413070a8bd0bffc818+150"
+    /var/lib/cwl/workflow.json: "keep:f9ddda46bb293b6847da984e3aa735db+290"
 
 uncommitted:
   uuid: zzzzz-xvhdp-cr4uncommittedc
index c2bf94fe73a2d85f45c23ca1dbc7a8dcda25a8c9..2dd6eedcfbc4b20c9a386d2473f6d90e0a415fbe 100644 (file)
@@ -1013,6 +1013,23 @@ class CollectionTest < ActiveSupport::TestCase
     assert_empty Collection.where(uuid: uuid)
   end
 
+  test "empty names are exempt from name uniqueness" do
+    act_as_user users(:active) do
+      c1 = Collection.new(name: nil, manifest_text: '', owner_uuid: groups(:aproject).uuid)
+      assert c1.save
+      c2 = Collection.new(name: '', manifest_text: '', owner_uuid: groups(:aproject).uuid)
+      assert c2.save
+      c3 = Collection.new(name: '', manifest_text: '', owner_uuid: groups(:aproject).uuid)
+      assert c3.save
+      c4 = Collection.new(name: 'c4', manifest_text: '', owner_uuid: groups(:aproject).uuid)
+      assert c4.save
+      c5 = Collection.new(name: 'c4', manifest_text: '', owner_uuid: groups(:aproject).uuid)
+      assert_raises(ActiveRecord::RecordNotUnique) do
+        c5.save
+      end
+    end
+  end
+
   test "create collections with managed properties" do
     Rails.configuration.Collections.ManagedProperties = {
       'default_prop1' => {'Value' => 'prop1_value'},
index 84b578a3e21ee6a1b9b70f1adf48709154452bb9..3261291b53650c516f7b58ca50668bfd27dca964 100644 (file)
@@ -987,7 +987,7 @@ func (runner *ContainerRunner) AttachStreams() (err error) {
                go func() {
                        _, err := io.Copy(response.Conn, stdinRdr)
                        if err != nil {
-                               runner.CrunchLog.Printf("While writing stdin collection to docker container %q", err)
+                               runner.CrunchLog.Printf("While writing stdin collection to docker container: %v", err)
                                runner.stop(nil)
                        }
                        stdinRdr.Close()
@@ -997,7 +997,7 @@ func (runner *ContainerRunner) AttachStreams() (err error) {
                go func() {
                        _, err := io.Copy(response.Conn, bytes.NewReader(stdinJson))
                        if err != nil {
-                               runner.CrunchLog.Printf("While writing stdin json to docker container %q", err)
+                               runner.CrunchLog.Printf("While writing stdin json to docker container: %v", err)
                                runner.stop(nil)
                        }
                        response.CloseWrite()
index a548b1ff9f338fb47f7998fe3423c06f5b2f2ad4..6aaf07abae395241fdbd5f26be8ae111f14aac1f 100644 (file)
@@ -30,7 +30,6 @@ func (s *integrationSuite) TestIdenticalTimestamps(c *check.C) {
                        var lastMod time.Time
                        sawUUID := make(map[string]bool)
                        err := EachCollection(&s.config.Client, pageSize, func(c arvados.Collection) error {
-                               got[trial] = append(got[trial], c.UUID)
                                if c.ModifiedAt == nil {
                                        return nil
                                }
@@ -38,6 +37,7 @@ func (s *integrationSuite) TestIdenticalTimestamps(c *check.C) {
                                        // dup
                                        return nil
                                }
+                               got[trial] = append(got[trial], c.UUID)
                                sawUUID[c.UUID] = true
                                if lastMod == *c.ModifiedAt {
                                        streak++
index 894056c9f27756c9f452f904568d53f88f433c74..e2f13a425ed8dfabc729649d98aa7e4ed977899a 100644 (file)
@@ -5,6 +5,7 @@
 package main
 
 import (
+       "context"
        "fmt"
        "net/http"
        "os"
@@ -14,6 +15,7 @@ import (
 
        "git.curoverse.com/arvados.git/sdk/go/arvados"
        "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/sirupsen/logrus"
 )
@@ -127,11 +129,13 @@ func (srv *Server) start() error {
        if srv.config.Listen == "" {
                return nil
        }
+       ctx := ctxlog.Context(context.Background(), srv.Logger)
        server := &httpserver.Server{
                Server: http.Server{
-                       Handler: httpserver.LogRequests(srv.Logger,
-                               auth.RequireLiteralToken(srv.config.ManagementToken,
-                                       srv.metrics.Handler(srv.Logger))),
+                       Handler: httpserver.HandlerWithContext(ctx,
+                               httpserver.LogRequests(
+                                       auth.RequireLiteralToken(srv.config.ManagementToken,
+                                               srv.metrics.Handler(srv.Logger)))),
                },
                Addr: srv.config.Listen,
        }
index 8336b78f9ea9614af2796211d9ed89d58da741e8..b9a1f3069f9d3e8bd03563c6785cb0b00d388582 100644 (file)
@@ -157,7 +157,11 @@ func (c *cache) Update(client *arvados.Client, coll arvados.Collection, fs arvad
        }
        var updated arvados.Collection
        defer c.pdhs.Remove(coll.UUID)
-       err := client.RequestAndDecode(&updated, "PATCH", "arvados/v1/collections/"+coll.UUID, client.UpdateBody(coll), nil)
+       err := client.RequestAndDecode(&updated, "PATCH", "arvados/v1/collections/"+coll.UUID, nil, map[string]interface{}{
+               "collection": map[string]string{
+                       "manifest_text": coll.ManifestText,
+               },
+       })
        if err == nil {
                c.collections.Add(client.AuthToken+"\000"+coll.PortableDataHash, &cachedCollection{
                        expire:     time.Now().Add(time.Duration(c.TTL)),
index 1c93a2b91c0981840c5ac2dde998a55adb1d9b51..9d9e314fcaf7e25710f1fdd341ca13c7491413f0 100644 (file)
@@ -9,7 +9,6 @@ import (
        "fmt"
        "io"
        "io/ioutil"
-       "net/url"
        "os"
        "os/exec"
        "path/filepath"
@@ -74,7 +73,7 @@ func (s *IntegrationSuite) testCadaver(c *check.C, password string, pathFunc fun
        var newCollection arvados.Collection
        arv := arvados.NewClientFromEnv()
        arv.AuthToken = arvadostest.ActiveToken
-       err = arv.RequestAndDecode(&newCollection, "POST", "arvados/v1/collections", bytes.NewBufferString(url.Values{"collection": {"{}"}}.Encode()), nil)
+       err = arv.RequestAndDecode(&newCollection, "POST", "arvados/v1/collections", nil, map[string]interface{}{"collection": map[string]interface{}{}})
        c.Assert(err, check.IsNil)
 
        readPath, writePath, pdhPath := pathFunc(newCollection)
index 040638623748f8aa57150b314886871703157287..93259f74cd9c2f2692555bc4a995cf3ce261241a 100644 (file)
@@ -465,8 +465,12 @@ func (s *IntegrationSuite) TestSpecialCharsInPath(c *check.C) {
        f.Close()
        mtxt, err := fs.MarshalManifest(".")
        c.Assert(err, check.IsNil)
-       coll := arvados.Collection{ManifestText: mtxt}
-       err = client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", client.UpdateBody(coll), nil)
+       var coll arvados.Collection
+       err = client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, map[string]interface{}{
+               "collection": map[string]string{
+                       "manifest_text": mtxt,
+               },
+       })
        c.Assert(err, check.IsNil)
 
        u, _ := url.Parse("http://download.example.com/c=" + coll.UUID + "/")
@@ -773,11 +777,14 @@ func (s *IntegrationSuite) TestDirectoryListing(c *check.C) {
 func (s *IntegrationSuite) TestDeleteLastFile(c *check.C) {
        arv := arvados.NewClientFromEnv()
        var newCollection arvados.Collection
-       err := arv.RequestAndDecode(&newCollection, "POST", "arvados/v1/collections", arv.UpdateBody(&arvados.Collection{
-               OwnerUUID:    arvadostest.ActiveUserUUID,
-               ManifestText: ". acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:foo.txt 0:3:bar.txt\n",
-               Name:         "keep-web test collection",
-       }), map[string]bool{"ensure_unique_name": true})
+       err := arv.RequestAndDecode(&newCollection, "POST", "arvados/v1/collections", nil, map[string]interface{}{
+               "collection": map[string]string{
+                       "owner_uuid":    arvadostest.ActiveUserUUID,
+                       "manifest_text": ". acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:foo.txt 0:3:bar.txt\n",
+                       "name":          "keep-web test collection",
+               },
+               "ensure_unique_name": true,
+       })
        c.Assert(err, check.IsNil)
        defer arv.RequestAndDecode(&newCollection, "DELETE", "arvados/v1/collections/"+newCollection.UUID, nil, nil)
 
index f70dd1a71f6ae92ecdc3f2979e2296f33238e28f..167fbbe5b85cf93f012d072e1fd97af3f5bd7106 100644 (file)
@@ -5,10 +5,13 @@
 package main
 
 import (
+       "context"
        "net/http"
 
+       "git.curoverse.com/arvados.git/sdk/go/ctxlog"
        "git.curoverse.com/arvados.git/sdk/go/httpserver"
        "github.com/prometheus/client_golang/prometheus"
+       "github.com/sirupsen/logrus"
 )
 
 type server struct {
@@ -20,7 +23,8 @@ func (srv *server) Start() error {
        h := &handler{Config: srv.Config}
        reg := prometheus.NewRegistry()
        h.Config.Cache.registry = reg
-       mh := httpserver.Instrument(reg, nil, httpserver.AddRequestIDs(httpserver.LogRequests(nil, h)))
+       ctx := ctxlog.Context(context.Background(), logrus.StandardLogger())
+       mh := httpserver.Instrument(reg, nil, httpserver.HandlerWithContext(ctx, httpserver.AddRequestIDs(httpserver.LogRequests(h))))
        h.MetricsAPI = mh.ServeAPI(h.Config.ManagementToken, http.NotFoundHandler())
        srv.Handler = mh
        srv.Addr = srv.Config.Listen
index c6fd99b9d8ed2f70b264b342ed041d5062eeb0a8..f8aa6c4aa7db3df87e7e598aaa901e8e3e91763c 100644 (file)
@@ -182,7 +182,7 @@ func main() {
 
        // Start serving requests.
        router = MakeRESTRouter(!cfg.DisableGet, !cfg.DisablePut, kc, time.Duration(cfg.Timeout), cfg.ManagementToken)
-       http.Serve(listener, httpserver.AddRequestIDs(httpserver.LogRequests(nil, router)))
+       http.Serve(listener, httpserver.AddRequestIDs(httpserver.LogRequests(router)))
 
        log.Println("shutting down")
 }
index 9a4d02df850fab836cdafaa4e21abb070b492782..72088e2b5ead5726e02bc06c6d8f84e6b5817fa5 100644 (file)
@@ -21,6 +21,7 @@ import (
        "time"
 
        "git.curoverse.com/arvados.git/sdk/go/arvados"
+       "git.curoverse.com/arvados.git/sdk/go/ctxlog"
        "git.curoverse.com/arvados.git/sdk/go/health"
        "git.curoverse.com/arvados.git/sdk/go/httpserver"
        "github.com/gorilla/mux"
@@ -93,8 +94,10 @@ func MakeRESTRouter(cluster *arvados.Cluster, reg *prometheus.Registry) http.Han
        rtr.metrics.setupWorkQueueMetrics(trashq, "trash")
        rtr.metrics.setupRequestMetrics(rtr.limiter)
 
-       instrumented := httpserver.Instrument(rtr.metrics.reg, nil,
-               httpserver.AddRequestIDs(httpserver.LogRequests(nil, rtr.limiter)))
+       instrumented := httpserver.Instrument(rtr.metrics.reg, log,
+               httpserver.HandlerWithContext(
+                       ctxlog.Context(context.Background(), log),
+                       httpserver.AddRequestIDs(httpserver.LogRequests(rtr.limiter))))
        return instrumented.ServeAPI(theConfig.ManagementToken, instrumented)
 }
 
index 7585bc5e17e017dc095a141d550e4e609c877c94..19e006744c9a7f1849595153529d55e60d819b26 100644 (file)
@@ -208,8 +208,8 @@ func (s *v0Suite) TestTrashedCollection(c *check.C) {
        ac := arvados.NewClientFromEnv()
        ac.AuthToken = s.token
 
-       coll := &arvados.Collection{ManifestText: ""}
-       err := ac.RequestAndDecode(coll, "POST", "arvados/v1/collections", s.jsonBody("collection", coll), map[string]interface{}{"ensure_unique_name": true})
+       var coll arvados.Collection
+       err := ac.RequestAndDecode(&coll, "POST", "arvados/v1/collections", s.jsonBody("collection", `{"manifest_text":""}`), map[string]interface{}{"ensure_unique_name": true})
        c.Assert(err, check.IsNil)
        s.ignoreLogID = s.lastLogID(c)
 
@@ -290,7 +290,7 @@ func (s *v0Suite) emitEvents(uuidChan chan<- string) {
        wf := &arvados.Workflow{
                Name: "ws_test",
        }
-       err := ac.RequestAndDecode(wf, "POST", "arvados/v1/workflows", s.jsonBody("workflow", wf), map[string]interface{}{"ensure_unique_name": true})
+       err := ac.RequestAndDecode(wf, "POST", "arvados/v1/workflows", s.jsonBody("workflow", `{"name":"ws_test"}`), map[string]interface{}{"ensure_unique_name": true})
        if err != nil {
                panic(err)
        }
@@ -298,17 +298,17 @@ func (s *v0Suite) emitEvents(uuidChan chan<- string) {
                uuidChan <- wf.UUID
        }
        lg := &arvados.Log{}
-       err = ac.RequestAndDecode(lg, "POST", "arvados/v1/logs", s.jsonBody("log", &arvados.Log{
-               ObjectUUID: wf.UUID,
-               EventType:  "blip",
-               Properties: map[string]interface{}{
+       err = ac.RequestAndDecode(lg, "POST", "arvados/v1/logs", s.jsonBody("log", map[string]interface{}{
+               "object_uuid": wf.UUID,
+               "event_type":  "blip",
+               "properties": map[string]interface{}{
                        "beep": "boop",
                },
        }), nil)
        if err != nil {
                panic(err)
        }
-       err = ac.RequestAndDecode(wf, "PUT", "arvados/v1/workflows/"+wf.UUID, s.jsonBody("workflow", wf), nil)
+       err = ac.RequestAndDecode(wf, "PUT", "arvados/v1/workflows/"+wf.UUID, s.jsonBody("workflow", `{"name":"ws_test"}`), nil)
        if err != nil {
                panic(err)
        }
@@ -316,12 +316,16 @@ func (s *v0Suite) emitEvents(uuidChan chan<- string) {
 }
 
 func (s *v0Suite) jsonBody(rscName string, ob interface{}) io.Reader {
-       j, err := json.Marshal(ob)
-       if err != nil {
-               panic(err)
+       val, ok := ob.(string)
+       if !ok {
+               j, err := json.Marshal(ob)
+               if err != nil {
+                       panic(err)
+               }
+               val = string(j)
        }
        v := url.Values{}
-       v[rscName] = []string{string(j)}
+       v[rscName] = []string{val}
        return bytes.NewBufferString(v.Encode())
 }