17170: Merge branch 'master'
authorTom Clegg <tom@curii.com>
Thu, 28 Jan 2021 17:04:52 +0000 (12:04 -0500)
committerTom Clegg <tom@curii.com>
Thu, 28 Jan 2021 17:04:52 +0000 (12:04 -0500)
Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@curii.com>

12 files changed:
1  2 
lib/config/config.default.yml
lib/config/generated_config.go
lib/controller/federation/conn.go
lib/controller/handler.go
lib/controller/router/router.go
lib/controller/rpc/conn.go
lib/crunchrun/crunchrun.go
sdk/go/arvados/api.go
sdk/go/arvados/container.go
sdk/go/arvadostest/api.go
services/api/app/models/container.rb
services/api/test/unit/container_test.rb

index 7748ea4aabc2cb5038add656334c042ee559e161,771dc2ee799584e4853c5e59c1769bd46bf44509..f62c483f46748310ce4c97b3fc82b6399da3b465
@@@ -255,9 -255,6 +255,6 @@@ Clusters
  
        # The e-mail address of the user you would like to become marked as an admin
        # user on their first login.
-       # In the default configuration, authentication happens through the Arvados SSO
-       # server, which uses OAuth2 against Google's servers, so in that case this
-       # should be an address associated with a Google account.
        AutoAdminUserWithEmail: ""
  
        # If AutoAdminFirstUser is set to true, the first user to log in when no
        NewUserNotificationRecipients: {}
        NewInactiveUserNotificationRecipients: {}
  
-       # Set AnonymousUserToken to enable anonymous user access. You can get
-       # the token by running "bundle exec ./script/get_anonymous_user_token.rb"
-       # in the directory where your API server is running.
+       # Set AnonymousUserToken to enable anonymous user access. Populate this
+       # field with a long random string. Then run "bundle exec
+       # ./script/get_anonymous_user_token.rb" in the directory where your API
+       # server is running to record the token in the database.
        AnonymousUserToken: ""
  
        # If a new user has an alternate email address (local@domain)
          # period.
          LogUpdateSize: 32MiB
  
 +      ShellAccess:
 +        # An admin user can use "arvados-client shell" to start an
 +        # interactive shell (with any user ID) in any running
 +        # container.
 +        Admin: false
 +
 +        # Any user can use "arvados-client shell" to start an
 +        # interactive shell (with any user ID) in any running
 +        # container that they started, provided it isn't also
 +        # associated with a different user's container request.
 +        #
 +        # Interactive sessions make it easy to alter the container's
 +        # runtime environment in ways that aren't recorded or
 +        # reproducible. Consider the implications for automatic
 +        # container reuse before enabling and using this feature. In
 +        # particular, note that starting an interactive session does
 +        # not disqualify a container from being reused by a different
 +        # user/workflow in the future.
 +        User: false
 +
        SLURM:
          PrioritySpread: 0
          SbatchArgumentsList: []
          # Cloud-specific driver parameters.
          DriverParameters:
  
-           # (ec2) Credentials.
+           # (ec2) Credentials. Omit or leave blank if using IAM role.
            AccessKeyID: ""
            SecretAccessKey: ""
  
index fd5723b1396ebe4721988accaeb91a765109be00,a202a540476ed54a36097244005c24822503ccf2..df4b02862210ce8a574cb3e5bbb05314e5cce386
@@@ -261,9 -261,6 +261,6 @@@ Clusters
  
        # The e-mail address of the user you would like to become marked as an admin
        # user on their first login.
-       # In the default configuration, authentication happens through the Arvados SSO
-       # server, which uses OAuth2 against Google's servers, so in that case this
-       # should be an address associated with a Google account.
        AutoAdminUserWithEmail: ""
  
        # If AutoAdminFirstUser is set to true, the first user to log in when no
        NewUserNotificationRecipients: {}
        NewInactiveUserNotificationRecipients: {}
  
-       # Set AnonymousUserToken to enable anonymous user access. You can get
-       # the token by running "bundle exec ./script/get_anonymous_user_token.rb"
-       # in the directory where your API server is running.
+       # Set AnonymousUserToken to enable anonymous user access. Populate this
+       # field with a long random string. Then run "bundle exec
+       # ./script/get_anonymous_user_token.rb" in the directory where your API
+       # server is running to record the token in the database.
        AnonymousUserToken: ""
  
        # If a new user has an alternate email address (local@domain)
          # period.
          LogUpdateSize: 32MiB
  
 +      ShellAccess:
 +        # An admin user can use "arvados-client shell" to start an
 +        # interactive shell (with any user ID) in any running
 +        # container.
 +        Admin: false
 +
 +        # Any user can use "arvados-client shell" to start an
 +        # interactive shell (with any user ID) in any running
 +        # container that they started, provided it isn't also
 +        # associated with a different user's container request.
 +        #
 +        # Interactive sessions make it easy to alter the container's
 +        # runtime environment in ways that aren't recorded or
 +        # reproducible. Consider the implications for automatic
 +        # container reuse before enabling and using this feature. In
 +        # particular, note that starting an interactive session does
 +        # not disqualify a container from being reused by a different
 +        # user/workflow in the future.
 +        User: false
 +
        SLURM:
          PrioritySpread: 0
          SbatchArgumentsList: []
          # Cloud-specific driver parameters.
          DriverParameters:
  
-           # (ec2) Credentials.
+           # (ec2) Credentials. Omit or leave blank if using IAM role.
            AccessKeyID: ""
            SecretAccessKey: ""
  
index a32382ce254afa9a8018792fa9fbee932f73476b,00523c7826a74331ea8c1560013c40aebca86f3d..b86266d67e6f02c170deb631d32c777ecb072781
@@@ -336,10 -336,68 +336,72 @@@ func (conn *Conn) ContainerUnlock(ctx c
        return conn.chooseBackend(options.UUID).ContainerUnlock(ctx, options)
  }
  
 +func (conn *Conn) ContainerSSH(ctx context.Context, options arvados.ContainerSSHOptions) (arvados.ContainerSSHConnection, error) {
 +      return conn.chooseBackend(options.UUID).ContainerSSH(ctx, options)
 +}
 +
+ func (conn *Conn) ContainerRequestList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerRequestList, error) {
+       return conn.generated_ContainerRequestList(ctx, options)
+ }
+ func (conn *Conn) ContainerRequestCreate(ctx context.Context, options arvados.CreateOptions) (arvados.ContainerRequest, error) {
+       be := conn.chooseBackend(options.ClusterID)
+       if be == conn.local {
+               return be.ContainerRequestCreate(ctx, options)
+       }
+       if _, ok := options.Attrs["runtime_token"]; !ok {
+               // If runtime_token is not set, create a new token
+               aca, err := conn.local.APIClientAuthorizationCurrent(ctx, arvados.GetOptions{})
+               if err != nil {
+                       // This should probably be StatusUnauthorized
+                       // (need to update test in
+                       // lib/controller/federation_test.go):
+                       // When RoR is out of the picture this should be:
+                       // return arvados.ContainerRequest{}, httpErrorf(http.StatusUnauthorized, "%w", err)
+                       return arvados.ContainerRequest{}, httpErrorf(http.StatusForbidden, "%s", "invalid API token")
+               }
+               user, err := conn.local.UserGetCurrent(ctx, arvados.GetOptions{})
+               if err != nil {
+                       return arvados.ContainerRequest{}, err
+               }
+               if len(aca.Scopes) == 0 || aca.Scopes[0] != "all" {
+                       return arvados.ContainerRequest{}, httpErrorf(http.StatusForbidden, "token scope is not [all]")
+               }
+               if strings.HasPrefix(aca.UUID, conn.cluster.ClusterID) {
+                       // Local user, submitting to a remote cluster.
+                       // Create a new time-limited token.
+                       local, ok := conn.local.(*localdb.Conn)
+                       if !ok {
+                               return arvados.ContainerRequest{}, httpErrorf(http.StatusInternalServerError, "bug: local backend is a %T, not a *localdb.Conn", conn.local)
+                       }
+                       aca, err = local.CreateAPIClientAuthorization(ctx, conn.cluster.SystemRootToken, rpc.UserSessionAuthInfo{UserUUID: user.UUID,
+                               ExpiresAt: time.Now().UTC().Add(conn.cluster.Collections.BlobSigningTTL.Duration())})
+                       if err != nil {
+                               return arvados.ContainerRequest{}, err
+                       }
+                       options.Attrs["runtime_token"] = aca.TokenV2()
+               } else {
+                       // Remote user. Container request will use the
+                       // current token, minus the trailing portion
+                       // (optional container uuid).
+                       options.Attrs["runtime_token"] = aca.TokenV2()
+               }
+       }
+       return be.ContainerRequestCreate(ctx, options)
+ }
+ func (conn *Conn) ContainerRequestUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.ContainerRequest, error) {
+       return conn.chooseBackend(options.UUID).ContainerRequestUpdate(ctx, options)
+ }
+ func (conn *Conn) ContainerRequestGet(ctx context.Context, options arvados.GetOptions) (arvados.ContainerRequest, error) {
+       return conn.chooseBackend(options.UUID).ContainerRequestGet(ctx, options)
+ }
+ func (conn *Conn) ContainerRequestDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.ContainerRequest, error) {
+       return conn.chooseBackend(options.UUID).ContainerRequestDelete(ctx, options)
+ }
  func (conn *Conn) SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
        return conn.generated_SpecimenList(ctx, options)
  }
index 7847be0a4938cd03b316ba80a12f89ae80962347,b04757ac338fc1549f38daf960c07381803442dd..5f6fb192e1731a75b9052e9096c9a04dab6ddd99
@@@ -25,7 -25,6 +25,7 @@@ import 
        "git.arvados.org/arvados.git/sdk/go/health"
        "git.arvados.org/arvados.git/sdk/go/httpserver"
        "github.com/jmoiron/sqlx"
 +
        // sqlx needs lib/pq to talk to PostgreSQL
        _ "github.com/lib/pq"
  )
@@@ -101,7 -100,8 +101,9 @@@ func (h *Handler) setup() 
                mux.Handle("/arvados/v1/collections/", rtr)
                mux.Handle("/arvados/v1/users", rtr)
                mux.Handle("/arvados/v1/users/", rtr)
 +              mux.Handle("/arvados/v1/connect/", rtr)
+               mux.Handle("/arvados/v1/container_requests", rtr)
+               mux.Handle("/arvados/v1/container_requests/", rtr)
                mux.Handle("/login", rtr)
                mux.Handle("/logout", rtr)
        }
index a09b66cedf7213d901469d2512ebe57ae2c0d82c,9fb2a0d32b49b5af5c6337cc2187c94b7d86a991..83c89d322ab3d85aa31fff177b92115efa469ed8
@@@ -168,6 -168,41 +168,41 @@@ func (rtr *router) addRoutes() 
                                return rtr.backend.ContainerDelete(ctx, *opts.(*arvados.DeleteOptions))
                        },
                },
+               {
+                       arvados.EndpointContainerRequestCreate,
+                       func() interface{} { return &arvados.CreateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestCreate(ctx, *opts.(*arvados.CreateOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestUpdate,
+                       func() interface{} { return &arvados.UpdateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestUpdate(ctx, *opts.(*arvados.UpdateOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestGet,
+                       func() interface{} { return &arvados.GetOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestGet(ctx, *opts.(*arvados.GetOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestList,
+                       func() interface{} { return &arvados.ListOptions{Limit: -1} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestList(ctx, *opts.(*arvados.ListOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestDelete,
+                       func() interface{} { return &arvados.DeleteOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestDelete(ctx, *opts.(*arvados.DeleteOptions))
+                       },
+               },
                {
                        arvados.EndpointContainerLock,
                        func() interface{} {
                                return rtr.backend.ContainerUnlock(ctx, *opts.(*arvados.GetOptions))
                        },
                },
 +              {
 +                      arvados.EndpointContainerSSH,
 +                      func() interface{} { return &arvados.ContainerSSHOptions{} },
 +                      func(ctx context.Context, opts interface{}) (interface{}, error) {
 +                              return rtr.backend.ContainerSSH(ctx, *opts.(*arvados.ContainerSSHOptions))
 +                      },
 +              },
                {
                        arvados.EndpointSpecimenCreate,
                        func() interface{} { return &arvados.CreateOptions{} },
index c9c0ac308cded26082ad07cf782042ed85ed1c76,d9d24260bbc98e617b62083f2bb3dd899d59995a..3a19f4ab5ad50d2ad5ceb5fbdf9981108bf1213f
@@@ -5,7 -5,6 +5,7 @@@
  package rpc
  
  import (
 +      "bufio"
        "bytes"
        "context"
        "crypto/tls"
@@@ -13,7 -12,6 +13,7 @@@
        "errors"
        "fmt"
        "io"
 +      "io/ioutil"
        "net"
        "net/http"
        "net/url"
  
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/auth"
 +      "git.arvados.org/arvados.git/sdk/go/httpserver"
  )
  
+ const rfc3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
  type TokenProvider func(context.Context) ([]string, error)
  
  func PassthroughTokenProvider(ctx context.Context) ([]string, error) {
@@@ -123,6 -122,20 +125,20 @@@ func (conn *Conn) requestAndDecode(ctx 
                        delete(params, "limit")
                }
        }
+       if authinfo, ok := params["auth_info"]; ok {
+               if tmp, ok2 := authinfo.(map[string]interface{}); ok2 {
+                       for k, v := range tmp {
+                               if strings.HasSuffix(k, "_at") {
+                                       // Change zero times values to nil
+                                       if v, ok3 := v.(string); ok3 && (strings.HasPrefix(v, "0001-01-01T00:00:00") || v == "") {
+                                               tmp[k] = nil
+                                       }
+                               }
+                       }
+               }
+       }
        if len(tokens) > 1 {
                params["reader_tokens"] = tokens[1:]
        }
@@@ -289,82 -302,27 +305,103 @@@ func (conn *Conn) ContainerUnlock(ctx c
        return resp, err
  }
  
 +// ContainerSSH returns a connection to the out-of-band SSH server for
 +// a running container. If the returned error is nil, the caller is
 +// responsible for closing sshconn.Conn.
 +func (conn *Conn) ContainerSSH(ctx context.Context, options arvados.ContainerSSHOptions) (sshconn arvados.ContainerSSHConnection, err error) {
 +      addr := conn.baseURL.Host
 +      if strings.Index(addr, ":") < 1 || (strings.Contains(addr, "::") && addr[0] != '[') {
 +              // hostname or ::1 or 1::1
 +              addr = net.JoinHostPort(addr, "https")
 +      }
 +      insecure := false
 +      if tlsconf := conn.httpClient.Transport.(*http.Transport).TLSClientConfig; tlsconf != nil && tlsconf.InsecureSkipVerify {
 +              insecure = true
 +      }
 +      netconn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: insecure})
 +      if err != nil {
 +              err = fmt.Errorf("tls.Dial: %w", err)
 +              return
 +      }
 +      defer func() {
 +              if err != nil {
 +                      netconn.Close()
 +              }
 +      }()
 +      bufr := bufio.NewReader(netconn)
 +      bufw := bufio.NewWriter(netconn)
 +
 +      u, err := conn.baseURL.Parse("/" + strings.Replace(arvados.EndpointContainerSSH.Path, "{uuid}", options.UUID, -1))
 +      if err != nil {
 +              err = fmt.Errorf("tls.Dial: %w", err)
 +              return
 +      }
 +      u.RawQuery = url.Values{
 +              "detach_keys":    {options.DetachKeys},
 +              "login_username": {options.LoginUsername},
 +      }.Encode()
 +      tokens, err := conn.tokenProvider(ctx)
 +      if err != nil {
 +              return
 +      } else if len(tokens) < 1 {
 +              err = httpserver.ErrorWithStatus(errors.New("unauthorized"), http.StatusUnauthorized)
 +              return
 +      }
 +      bufw.WriteString("GET " + u.String() + " HTTP/1.1\r\n")
 +      bufw.WriteString("Authorization: Bearer " + tokens[0] + "\r\n")
 +      bufw.WriteString("Host: " + u.Host + "\r\n")
 +      bufw.WriteString("Upgrade: ssh\r\n")
 +      bufw.WriteString("\r\n")
 +      bufw.Flush()
 +      resp, err := http.ReadResponse(bufr, &http.Request{Method: "GET"})
 +      if err != nil {
 +              err = fmt.Errorf("http.ReadResponse: %w", err)
 +              return
 +      }
 +      if resp.StatusCode != http.StatusSwitchingProtocols {
 +              defer resp.Body.Close()
 +              body, _ := ioutil.ReadAll(resp.Body)
 +              var message string
 +              var errDoc httpserver.ErrorResponse
 +              if err := json.Unmarshal(body, &errDoc); err == nil {
 +                      message = strings.Join(errDoc.Errors, "; ")
 +              } else {
 +                      message = fmt.Sprintf("%q", body)
 +              }
 +              err = fmt.Errorf("server did not provide a tunnel: %s (HTTP %d)", message, resp.StatusCode)
 +              return
 +      }
 +      if strings.ToLower(resp.Header.Get("Upgrade")) != "ssh" ||
 +              strings.ToLower(resp.Header.Get("Connection")) != "upgrade" {
 +              err = fmt.Errorf("bad response from server: Upgrade %q Connection %q", resp.Header.Get("Upgrade"), resp.Header.Get("Connection"))
 +              return
 +      }
 +      sshconn.Conn = netconn
 +      sshconn.Bufrw = &bufio.ReadWriter{Reader: bufr, Writer: bufw}
 +      return
 +}
 +
+ func (conn *Conn) ContainerRequestCreate(ctx context.Context, options arvados.CreateOptions) (arvados.ContainerRequest, error) {
+       ep := arvados.EndpointContainerRequestCreate
+       var resp arvados.ContainerRequest
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+ }
+ func (conn *Conn) ContainerRequestUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.ContainerRequest, error) {
+       ep := arvados.EndpointContainerRequestUpdate
+       var resp arvados.ContainerRequest
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+ }
+ func (conn *Conn) ContainerRequestGet(ctx context.Context, options arvados.GetOptions) (arvados.ContainerRequest, error) {
+       ep := arvados.EndpointContainerRequestGet
+       var resp arvados.ContainerRequest
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+ }
  func (conn *Conn) ContainerRequestList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerRequestList, error) {
        ep := arvados.EndpointContainerRequestList
        var resp arvados.ContainerRequestList
        return resp, err
  }
  
+ func (conn *Conn) ContainerRequestDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.ContainerRequest, error) {
+       ep := arvados.EndpointContainerRequestDelete
+       var resp arvados.ContainerRequest
+       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
@@@ -488,11 -453,13 +532,13 @@@ func (conn *Conn) APIClientAuthorizatio
  }
  
  type UserSessionAuthInfo struct {
-       Email           string   `json:"email"`
-       AlternateEmails []string `json:"alternate_emails"`
-       FirstName       string   `json:"first_name"`
-       LastName        string   `json:"last_name"`
-       Username        string   `json:"username"`
+       UserUUID        string    `json:"user_uuid"`
+       Email           string    `json:"email"`
+       AlternateEmails []string  `json:"alternate_emails"`
+       FirstName       string    `json:"first_name"`
+       LastName        string    `json:"last_name"`
+       Username        string    `json:"username"`
+       ExpiresAt       time.Time `json:"expires_at"`
  }
  
  type UserSessionCreateOptions struct {
index f28593fe0cf3232c0da5ad4b45f6cfb682934e72,730185c1969f2af43b6cb76148f07541711ec451..f6094e0e9265fdb4e91e7709d331c0c0abe475d4
@@@ -178,8 -178,6 +178,8 @@@ type ContainerRunner struct 
        arvMountLog   *ThrottledLogger
  
        containerWatchdogInterval time.Duration
 +
 +      gateway Gateway
  }
  
  // setupSignals sets up signal handling to gracefully terminate the underlying
@@@ -623,7 -621,7 +623,7 @@@ func (runner *ContainerRunner) SetupMou
                return fmt.Errorf("output path does not correspond to a writable mount point")
        }
  
-       if wantAPI := runner.Container.RuntimeConstraints.API; needCertMount && wantAPI != nil && *wantAPI {
+       if needCertMount && runner.Container.RuntimeConstraints.API {
                for _, certfile := range arvadosclient.CertFiles {
                        _, err := os.Stat(certfile)
                        if err == nil {
@@@ -1094,7 -1092,7 +1094,7 @@@ func (runner *ContainerRunner) CreateCo
                },
        }
  
-       if wantAPI := runner.Container.RuntimeConstraints.API; wantAPI != nil && *wantAPI {
+       if runner.Container.RuntimeConstraints.API {
                tok, err := runner.ContainerToken()
                if err != nil {
                        return err
@@@ -1271,7 -1269,7 +1271,7 @@@ func (runner *ContainerRunner) updateLo
  // CaptureOutput saves data from the container's output directory if
  // needed, and updates the container output accordingly.
  func (runner *ContainerRunner) CaptureOutput() error {
-       if wantAPI := runner.Container.RuntimeConstraints.API; wantAPI != nil && *wantAPI {
+       if runner.Container.RuntimeConstraints.API {
                // Output may have been set directly by the container, so
                // refresh the container record to check.
                err := runner.DispatcherArvClient.Get("containers", runner.Container.UUID,
@@@ -1471,7 -1469,7 +1471,7 @@@ func (runner *ContainerRunner) UpdateCo
                return ErrCancelled
        }
        return runner.DispatcherArvClient.Update("containers", runner.Container.UUID,
 -              arvadosclient.Dict{"container": arvadosclient.Dict{"state": "Running"}}, nil)
 +              arvadosclient.Dict{"container": arvadosclient.Dict{"state": "Running", "gateway_address": runner.gateway.Address}}, nil)
  }
  
  // ContainerToken returns the api_token the container (and any
@@@ -1870,20 -1868,6 +1870,20 @@@ func (command) RunCommand(prog string, 
                return 1
        }
  
 +      cr.gateway = Gateway{
 +              Address:           os.Getenv("GatewayAddress"),
 +              AuthSecret:        os.Getenv("GatewayAuthSecret"),
 +              ContainerUUID:     containerID,
 +              DockerContainerID: &cr.ContainerID,
 +              Log:               cr.CrunchLog,
 +      }
 +      os.Unsetenv("GatewayAuthSecret")
 +      err = cr.gateway.Start()
 +      if err != nil {
 +              log.Printf("error starting gateway server: %s", err)
 +              return 1
 +      }
 +
        parentTemp, tmperr := cr.MkTempDir("", "crunch-run."+containerID+".")
        if tmperr != nil {
                log.Printf("%s: %v", containerID, tmperr)
diff --combined sdk/go/arvados/api.go
index 4675906e74c07f4b6ebad04f2ae01367a55fcd25,a11872971a9dffc9a2563dedb7338a1438ca40b3..37a3e007b16108cb6a2f9c6627c29106eee52bca
@@@ -5,12 -5,8 +5,12 @@@
  package arvados
  
  import (
 +      "bufio"
        "context"
        "encoding/json"
 +      "net"
 +
 +      "github.com/sirupsen/logrus"
  )
  
  type APIEndpoint struct {
@@@ -45,8 -41,11 +45,12 @@@ var 
        EndpointContainerDelete               = APIEndpoint{"DELETE", "arvados/v1/containers/{uuid}", ""}
        EndpointContainerLock                 = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/lock", ""}
        EndpointContainerUnlock               = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/unlock", ""}
 +      EndpointContainerSSH                  = APIEndpoint{"GET", "arvados/v1/connect/{uuid}/ssh", ""} // move to /containers after #17014 fixes routing
+       EndpointContainerRequestCreate        = APIEndpoint{"POST", "arvados/v1/container_requests", "container_request"}
+       EndpointContainerRequestUpdate        = APIEndpoint{"PATCH", "arvados/v1/container_requests/{uuid}", "container_request"}
+       EndpointContainerRequestGet           = APIEndpoint{"GET", "arvados/v1/container_requests/{uuid}", ""}
        EndpointContainerRequestList          = APIEndpoint{"GET", "arvados/v1/container_requests", ""}
+       EndpointContainerRequestDelete        = APIEndpoint{"DELETE", "arvados/v1/container_requests/{uuid}", ""}
        EndpointUserActivate                  = APIEndpoint{"POST", "arvados/v1/users/{uuid}/activate", ""}
        EndpointUserCreate                    = APIEndpoint{"POST", "arvados/v1/users", "user"}
        EndpointUserCurrent                   = APIEndpoint{"GET", "arvados/v1/users/current", ""}
        EndpointAPIClientAuthorizationCurrent = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/current", ""}
  )
  
 +type ContainerSSHOptions struct {
 +      UUID          string `json:"uuid"`
 +      DetachKeys    string `json:"detach_keys"`
 +      LoginUsername string `json:"login_username"`
 +}
 +
 +type ContainerSSHConnection struct {
 +      Conn   net.Conn           `json:"-"`
 +      Bufrw  *bufio.ReadWriter  `json:"-"`
 +      Logger logrus.FieldLogger `json:"-"`
 +}
 +
  type GetOptions struct {
        UUID         string   `json:"uuid,omitempty"`
        Select       []string `json:"select"`
@@@ -193,7 -180,11 +197,12 @@@ type API interface 
        ContainerDelete(ctx context.Context, options DeleteOptions) (Container, error)
        ContainerLock(ctx context.Context, options GetOptions) (Container, error)
        ContainerUnlock(ctx context.Context, options GetOptions) (Container, error)
 +      ContainerSSH(ctx context.Context, options ContainerSSHOptions) (ContainerSSHConnection, error)
+       ContainerRequestCreate(ctx context.Context, options CreateOptions) (ContainerRequest, error)
+       ContainerRequestUpdate(ctx context.Context, options UpdateOptions) (ContainerRequest, error)
+       ContainerRequestGet(ctx context.Context, options GetOptions) (ContainerRequest, error)
+       ContainerRequestList(ctx context.Context, options ListOptions) (ContainerRequestList, error)
+       ContainerRequestDelete(ctx context.Context, options DeleteOptions) (ContainerRequest, error)
        SpecimenCreate(ctx context.Context, options CreateOptions) (Specimen, error)
        SpecimenUpdate(ctx context.Context, options UpdateOptions) (Specimen, error)
        SpecimenGet(ctx context.Context, options GetOptions) (Specimen, error)
index 86b7c86846247c8b994ca9379800471b58e02580,3ff7c52055caf0e6ce72d0f32afc0fa1809007a3..1981e8ab95c2ee90c7df2977c8a394f217be44a8
@@@ -8,30 -8,28 +8,30 @@@ import "time
  
  // Container is an arvados#container resource.
  type Container struct {
 -      UUID                 string                 `json:"uuid"`
 -      Etag                 string                 `json:"etag"`
 -      CreatedAt            time.Time              `json:"created_at"`
 -      ModifiedByClientUUID string                 `json:"modified_by_client_uuid"`
 -      ModifiedByUserUUID   string                 `json:"modified_by_user_uuid"`
 -      ModifiedAt           time.Time              `json:"modified_at"`
 -      Command              []string               `json:"command"`
 -      ContainerImage       string                 `json:"container_image"`
 -      Cwd                  string                 `json:"cwd"`
 -      Environment          map[string]string      `json:"environment"`
 -      LockedByUUID         string                 `json:"locked_by_uuid"`
 -      Mounts               map[string]Mount       `json:"mounts"`
 -      Output               string                 `json:"output"`
 -      OutputPath           string                 `json:"output_path"`
 -      Priority             int64                  `json:"priority"`
 -      RuntimeConstraints   RuntimeConstraints     `json:"runtime_constraints"`
 -      State                ContainerState         `json:"state"`
 -      SchedulingParameters SchedulingParameters   `json:"scheduling_parameters"`
 -      ExitCode             int                    `json:"exit_code"`
 -      RuntimeStatus        map[string]interface{} `json:"runtime_status"`
 -      StartedAt            *time.Time             `json:"started_at"`  // nil if not yet started
 -      FinishedAt           *time.Time             `json:"finished_at"` // nil if not yet finished
 +      UUID                      string                 `json:"uuid"`
 +      Etag                      string                 `json:"etag"`
 +      CreatedAt                 time.Time              `json:"created_at"`
 +      ModifiedByClientUUID      string                 `json:"modified_by_client_uuid"`
 +      ModifiedByUserUUID        string                 `json:"modified_by_user_uuid"`
 +      ModifiedAt                time.Time              `json:"modified_at"`
 +      Command                   []string               `json:"command"`
 +      ContainerImage            string                 `json:"container_image"`
 +      Cwd                       string                 `json:"cwd"`
 +      Environment               map[string]string      `json:"environment"`
 +      LockedByUUID              string                 `json:"locked_by_uuid"`
 +      Mounts                    map[string]Mount       `json:"mounts"`
 +      Output                    string                 `json:"output"`
 +      OutputPath                string                 `json:"output_path"`
 +      Priority                  int64                  `json:"priority"`
 +      RuntimeConstraints        RuntimeConstraints     `json:"runtime_constraints"`
 +      State                     ContainerState         `json:"state"`
 +      SchedulingParameters      SchedulingParameters   `json:"scheduling_parameters"`
 +      ExitCode                  int                    `json:"exit_code"`
 +      RuntimeStatus             map[string]interface{} `json:"runtime_status"`
 +      StartedAt                 *time.Time             `json:"started_at"`  // nil if not yet started
 +      FinishedAt                *time.Time             `json:"finished_at"` // nil if not yet finished
 +      GatewayAddress            string                 `json:"gateway_address"`
 +      InteractiveSessionStarted bool                   `json:"interactive_session_started"`
  }
  
  // ContainerRequest is an arvados#container_request resource.
@@@ -67,6 -65,9 +67,9 @@@ type ContainerRequest struct 
        LogUUID                 string                 `json:"log_uuid"`
        OutputUUID              string                 `json:"output_uuid"`
        RuntimeToken            string                 `json:"runtime_token"`
+       ExpiresAt               time.Time              `json:"expires_at"`
+       Filters                 []Filter               `json:"filters"`
+       ContainerCount          int                    `json:"container_count"`
  }
  
  // Mount is special behavior to attach to a filesystem path or device.
@@@ -88,7 -89,7 +91,7 @@@ type Mount struct 
  // RuntimeConstraints specify a container's compute resources (RAM,
  // CPU) and network connectivity.
  type RuntimeConstraints struct {
-       API          *bool
+       API          bool  `json:"api"`
        RAM          int64 `json:"ram"`
        VCPUs        int   `json:"vcpus"`
        KeepCacheRAM int64 `json:"keep_cache_ram"`
index 2b78549476fa49b4725373db6f446deecf71d373,df3e46febd5598f9d065fba24c964701b480c1d1..930eabf27ef997a2662d0a56c8c5a1494e59a98a
@@@ -105,10 -105,26 +105,30 @@@ func (as *APIStub) ContainerUnlock(ctx 
        as.appendCall(ctx, as.ContainerUnlock, options)
        return arvados.Container{}, as.Error
  }
 +func (as *APIStub) ContainerSSH(ctx context.Context, options arvados.ContainerSSHOptions) (arvados.ContainerSSHConnection, error) {
 +      as.appendCall(ctx, as.ContainerSSH, options)
 +      return arvados.ContainerSSHConnection{}, as.Error
 +}
+ func (as *APIStub) ContainerRequestCreate(ctx context.Context, options arvados.CreateOptions) (arvados.ContainerRequest, error) {
+       as.appendCall(ctx, as.ContainerRequestCreate, options)
+       return arvados.ContainerRequest{}, as.Error
+ }
+ func (as *APIStub) ContainerRequestUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.ContainerRequest, error) {
+       as.appendCall(ctx, as.ContainerRequestUpdate, options)
+       return arvados.ContainerRequest{}, as.Error
+ }
+ func (as *APIStub) ContainerRequestGet(ctx context.Context, options arvados.GetOptions) (arvados.ContainerRequest, error) {
+       as.appendCall(ctx, as.ContainerRequestGet, options)
+       return arvados.ContainerRequest{}, as.Error
+ }
+ func (as *APIStub) ContainerRequestList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerRequestList, error) {
+       as.appendCall(ctx, as.ContainerRequestList, options)
+       return arvados.ContainerRequestList{}, as.Error
+ }
+ func (as *APIStub) ContainerRequestDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.ContainerRequest, error) {
+       as.appendCall(ctx, as.ContainerRequestDelete, options)
+       return arvados.ContainerRequest{}, as.Error
+ }
  func (as *APIStub) SpecimenCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Specimen, error) {
        as.appendCall(ctx, as.SpecimenCreate, options)
        return arvados.Specimen{}, as.Error
index 49be3df558536d86af80535a22cb13232683c2dc,d01787cbc7c0389e50fc0ebe421bc9bd0672f5ed..8feee77ff23553eaba0429125c1b06f3f5688d50
@@@ -29,6 -29,7 +29,7 @@@ class Container < ArvadosMode
    serialize :command, Array
    serialize :scheduling_parameters, Hash
  
+   after_find :fill_container_defaults_after_find
    before_validation :fill_field_defaults, :if => :new_record?
    before_validation :set_timestamps
    before_validation :check_lock
@@@ -76,8 -77,6 +77,8 @@@
      t.add :runtime_user_uuid
      t.add :runtime_auth_scopes
      t.add :lock_count
 +    t.add :gateway_address
 +    t.add :interactive_session_started
    end
  
    # Supported states for a container
    end
  
    def self.full_text_searchable_columns
 -    super - ["secret_mounts", "secret_mounts_md5", "runtime_token"]
 +    super - ["secret_mounts", "secret_mounts_md5", "runtime_token", "gateway_address"]
    end
  
    def self.searchable_columns *args
 -    super - ["secret_mounts_md5", "runtime_token"]
 +    super - ["secret_mounts_md5", "runtime_token", "gateway_address"]
    end
  
    def logged_attributes
    # containers are suitable).
    def self.resolve_runtime_constraints(runtime_constraints)
      rc = {}
-     defaults = {
-       'keep_cache_ram' =>
-       Rails.configuration.Containers.DefaultKeepCacheRAM,
-     }
-     defaults.merge(runtime_constraints).each do |k, v|
+     runtime_constraints.each do |k, v|
        if v.is_a? Array
          rc[k] = v[0]
        else
          rc[k] = v
        end
      end
+     if rc['keep_cache_ram'] == 0
+       rc['keep_cache_ram'] = Rails.configuration.Containers.DefaultKeepCacheRAM
+     end
      rc
    end
  
      when Running
        permitted.push :priority, *progress_attrs
        if self.state_changed?
 -        permitted.push :started_at
 +        permitted.push :started_at, :gateway_address
 +      end
 +      if !self.interactive_session_started_was
 +        permitted.push :interactive_session_started
        end
  
      when Complete
index 7853b6f6a9152c6a492e366cc9f13a1edce0add7,4d853852422bc5c7abeadd2b5e3d72601a2d7a37..efe8c81333abe7e3362db6222ff18cb3f89dc11e
@@@ -23,6 -23,8 +23,8 @@@ class ContainerTest < ActiveSupport::Te
      command: ["echo", "hello"],
      output_path: "test",
      runtime_constraints: {
+       "api" => false,
+       "keep_cache_ram" => 0,
        "ram" => 12000000000,
        "vcpus" => 4,
      },
      set_user_from_auth :active
      env = {"C" => "3", "B" => "2", "A" => "1"}
      m = {"F" => {"kind" => "3"}, "E" => {"kind" => "2"}, "D" => {"kind" => "1"}}
-     rc = {"vcpus" => 1, "ram" => 1, "keep_cache_ram" => 1}
+     rc = {"vcpus" => 1, "ram" => 1, "keep_cache_ram" => 1, "api" => true}
      c, _ = minimal_new(environment: env, mounts: m, runtime_constraints: rc)
-     assert_equal c.environment.to_json, Container.deep_sort_hash(env).to_json
-     assert_equal c.mounts.to_json, Container.deep_sort_hash(m).to_json
-     assert_equal c.runtime_constraints.to_json, Container.deep_sort_hash(rc).to_json
+     c.reload
+     assert_equal Container.deep_sort_hash(env).to_json, c.environment.to_json
+     assert_equal Container.deep_sort_hash(m).to_json, c.mounts.to_json
+     assert_equal Container.deep_sort_hash(rc).to_json, c.runtime_constraints.to_json
    end
  
    test 'deep_sort_hash on array of hashes' do
      assert_equal Container::Queued, c1.state
      reused = Container.find_reusable(common_attrs.merge(runtime_token_attr(:container_runtime_token)))
      # See #14584
+     assert_not_nil reused
      assert_equal c1.uuid, reused.uuid
    end
  
      assert_equal Container::Queued, c1.state
      reused = Container.find_reusable(common_attrs.merge(runtime_token_attr(:container_runtime_token)))
      # See #14584
+     assert_not_nil reused
      assert_equal c1.uuid, reused.uuid
    end
  
      assert_equal Container::Queued, c1.state
      reused = Container.find_reusable(common_attrs.merge(runtime_token_attr(:container_runtime_token)))
      # See #14584
+     assert_not_nil reused
      assert_equal c1.uuid, reused.uuid
    end
  
      [Container::Running, {priority: 123456789}],
      [Container::Running, {runtime_status: {'error' => 'oops'}}],
      [Container::Running, {cwd: '/'}],
 +    [Container::Running, {gateway_address: "172.16.0.1:12345"}],
 +    [Container::Running, {interactive_session_started: true}],
      [Container::Complete, {state: Container::Cancelled}],
      [Container::Complete, {priority: 123456789}],
      [Container::Complete, {runtime_status: {'error' => 'oops'}}],