[ARVADOS] created: 1.1.4-369-gd636833ef

Git user git at public.curoverse.com
Tue Jun 12 16:48:10 EDT 2018


        at  d636833ef26fd6568acc49e32ee9d040bea67f92 (commit)


commit d636833ef26fd6568acc49e32ee9d040bea67f92
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue Jun 12 16:39:41 2018 -0400

    13497: Add timeout for proxy requests.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index a1b3848e5..6e4f0e3b4 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -5,12 +5,14 @@
 package controller
 
 import (
+	"context"
 	"io"
 	"net"
 	"net/http"
 	"net/url"
 	"strings"
 	"sync"
+	"time"
 
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/health"
@@ -44,7 +46,7 @@ func (h *Handler) setup() {
 func (h *Handler) proxyRailsAPI(w http.ResponseWriter, reqIn *http.Request) {
 	urlOut, err := findRailsAPI(h.Cluster, h.Node)
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		httpserver.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 	urlOut = &url.URL{
@@ -68,14 +70,21 @@ func (h *Handler) proxyRailsAPI(w http.ResponseWriter, reqIn *http.Request) {
 	hdrOut.Set("X-Forwarded-For", xff)
 	hdrOut.Add("Via", reqIn.Proto+" arvados-controller")
 
+	ctx := reqIn.Context()
+	if timeout := h.Cluster.HTTPRequestTimeout; timeout > 0 {
+		var cancel context.CancelFunc
+		ctx, cancel = context.WithDeadline(ctx, time.Now().Add(time.Duration(timeout)))
+		defer cancel()
+	}
+
 	reqOut := (&http.Request{
 		Method: reqIn.Method,
 		URL:    urlOut,
 		Header: hdrOut,
-	}).WithContext(reqIn.Context())
+	}).WithContext(ctx)
 	resp, err := arvados.InsecureHTTPClient.Do(reqOut)
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		httpserver.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 	for k, v := range resp.Header {
diff --git a/lib/controller/handler_test.go b/lib/controller/handler_test.go
index 57bb13d95..a187ba443 100644
--- a/lib/controller/handler_test.go
+++ b/lib/controller/handler_test.go
@@ -10,9 +10,11 @@ import (
 	"net/http/httptest"
 	"os"
 	"testing"
+	"time"
 
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
+	"git.curoverse.com/arvados.git/sdk/go/httpserver"
 	check "gopkg.in/check.v1"
 )
 
@@ -56,6 +58,19 @@ func (s *HandlerSuite) TestProxyDiscoveryDoc(c *check.C) {
 	c.Check(len(dd.Schemas), check.Not(check.Equals), 0)
 }
 
+func (s *HandlerSuite) TestRequestTimeout(c *check.C) {
+	s.cluster.HTTPRequestTimeout = arvados.Duration(time.Nanosecond)
+	req := httptest.NewRequest("GET", "/discovery/v1/apis/arvados/v1/rest", nil)
+	resp := httptest.NewRecorder()
+	s.handler.ServeHTTP(resp, req)
+	c.Check(resp.Code, check.Equals, http.StatusInternalServerError)
+	var jresp httpserver.ErrorResponse
+	err := json.Unmarshal(resp.Body.Bytes(), &jresp)
+	c.Check(err, check.IsNil)
+	c.Assert(len(jresp.Errors), check.Equals, 1)
+	c.Check(jresp.Errors[0], check.Matches, `.*context deadline exceeded`)
+}
+
 func (s *HandlerSuite) TestProxyWithoutToken(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/users/current", nil)
 	resp := httptest.NewRecorder()
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index e0a2b1d28..16d93362a 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -49,10 +49,11 @@ func (sc *Config) GetCluster(clusterID string) (*Cluster, error) {
 }
 
 type Cluster struct {
-	ClusterID       string `json:"-"`
-	ManagementToken string
-	SystemNodes     map[string]SystemNode
-	InstanceTypes   []InstanceType
+	ClusterID          string `json:"-"`
+	ManagementToken    string
+	SystemNodes        map[string]SystemNode
+	InstanceTypes      []InstanceType
+	HTTPRequestTimeout Duration
 }
 
 type InstanceType struct {
diff --git a/sdk/go/httpserver/error.go b/sdk/go/httpserver/error.go
new file mode 100644
index 000000000..398e61fcd
--- /dev/null
+++ b/sdk/go/httpserver/error.go
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package httpserver
+
+import (
+	"encoding/json"
+	"net/http"
+)
+
+type ErrorResponse struct {
+	Errors []string `json:"errors"`
+}
+
+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}})
+}

commit 00c7dd4b4677f734ea14c2a7855d5251f84da140
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue Jun 12 16:00:07 2018 -0400

    13497: Proxy requests to Rails API.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/cmd.go b/lib/controller/cmd.go
index 2bb68aed9..e006b6594 100644
--- a/lib/controller/cmd.go
+++ b/lib/controller/cmd.go
@@ -12,6 +12,8 @@ import (
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 )
 
-var Command cmd.Handler = service.Command(arvados.ServiceNameController, func(cluster *arvados.Cluster, _ *arvados.SystemNode) http.Handler {
-	return &Handler{Cluster: cluster}
-})
+var Command cmd.Handler = service.Command(arvados.ServiceNameController, newHandler)
+
+func newHandler(cluster *arvados.Cluster, node *arvados.SystemNode) http.Handler {
+	return &Handler{Cluster: cluster, Node: node}
+}
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index f0354d94d..a1b3848e5 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -6,16 +6,20 @@ package controller
 
 import (
 	"io"
+	"net"
 	"net/http"
 	"net/url"
+	"strings"
 	"sync"
 
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/health"
+	"git.curoverse.com/arvados.git/sdk/go/httpserver"
 )
 
 type Handler struct {
 	Cluster *arvados.Cluster
+	Node    *arvados.SystemNode
 
 	setupOnce    sync.Once
 	handlerStack http.Handler
@@ -37,15 +41,39 @@ func (h *Handler) setup() {
 	h.handlerStack = mux
 }
 
-func (h *Handler) proxyRailsAPI(w http.ResponseWriter, incomingReq *http.Request) {
-	url, err := findRailsAPI(h.Cluster)
+func (h *Handler) proxyRailsAPI(w http.ResponseWriter, reqIn *http.Request) {
+	urlOut, err := findRailsAPI(h.Cluster, h.Node)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	req := *incomingReq
-	req.URL.Host = url.Host
-	resp, err := arvados.InsecureHTTPClient.Do(&req)
+	urlOut = &url.URL{
+		Scheme:   urlOut.Scheme,
+		Host:     urlOut.Host,
+		Path:     reqIn.URL.Path,
+		RawPath:  reqIn.URL.RawPath,
+		RawQuery: reqIn.URL.RawQuery,
+	}
+
+	// Copy headers from incoming request, then add/replace proxy
+	// headers like Via and X-Forwarded-For.
+	hdrOut := http.Header{}
+	for k, v := range reqIn.Header {
+		hdrOut[k] = v
+	}
+	xff := reqIn.RemoteAddr
+	if xffIn := reqIn.Header.Get("X-Forwarded-For"); xffIn != "" {
+		xff = xffIn + "," + xff
+	}
+	hdrOut.Set("X-Forwarded-For", xff)
+	hdrOut.Add("Via", reqIn.Proto+" arvados-controller")
+
+	reqOut := (&http.Request{
+		Method: reqIn.Method,
+		URL:    urlOut,
+		Header: hdrOut,
+	}).WithContext(reqIn.Context())
+	resp, err := arvados.InsecureHTTPClient.Do(reqOut)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -56,15 +84,27 @@ func (h *Handler) proxyRailsAPI(w http.ResponseWriter, incomingReq *http.Request
 		}
 	}
 	w.WriteHeader(resp.StatusCode)
-	io.Copy(w, resp.Body)
+	n, err := io.Copy(w, resp.Body)
+	if err != nil {
+		httpserver.Logger(reqIn).WithError(err).WithField("bytesCopied", n).Error("error copying response body")
+	}
 }
 
 // For now, findRailsAPI always uses the rails API running on this
 // node.
-func findRailsAPI(cluster *arvados.Cluster) (*url.URL, error) {
-	node, err := cluster.GetThisSystemNode()
-	if err != nil {
+func findRailsAPI(cluster *arvados.Cluster, node *arvados.SystemNode) (*url.URL, error) {
+	hostport := node.RailsAPI.Listen
+	if len(hostport) > 1 && hostport[0] == ':' && strings.TrimRight(hostport[1:], "0123456789") == "" {
+		// ":12345" => connect to indicated port on localhost
+		hostport = "localhost" + hostport
+	} else if _, _, err := net.SplitHostPort(hostport); err == nil {
+		// "[::1]:12345" => connect to indicated address & port
+	} else {
 		return nil, err
 	}
-	return url.Parse("http://" + node.RailsAPI.Listen)
+	proto := "http"
+	if node.RailsAPI.TLS {
+		proto = "https"
+	}
+	return url.Parse(proto + "://" + hostport)
 }
diff --git a/lib/controller/handler_test.go b/lib/controller/handler_test.go
new file mode 100644
index 000000000..57bb13d95
--- /dev/null
+++ b/lib/controller/handler_test.go
@@ -0,0 +1,91 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package controller
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
+	check "gopkg.in/check.v1"
+)
+
+// Gocheck boilerplate
+func Test(t *testing.T) {
+	check.TestingT(t)
+}
+
+var _ = check.Suite(&HandlerSuite{})
+
+type HandlerSuite struct {
+	cluster *arvados.Cluster
+	handler http.Handler
+}
+
+func (s *HandlerSuite) SetUpTest(c *check.C) {
+	s.cluster = &arvados.Cluster{
+		ClusterID: "zzzzz",
+		SystemNodes: map[string]arvados.SystemNode{
+			"*": {
+				Controller: arvados.SystemServiceInstance{Listen: ":"},
+				RailsAPI:   arvados.SystemServiceInstance{Listen: os.Getenv("ARVADOS_API_HOST"), TLS: true},
+			},
+		},
+	}
+	node := s.cluster.SystemNodes["*"]
+	s.handler = newHandler(s.cluster, &node)
+}
+
+func (s *HandlerSuite) TestProxyDiscoveryDoc(c *check.C) {
+	req := httptest.NewRequest("GET", "/discovery/v1/apis/arvados/v1/rest", nil)
+	resp := httptest.NewRecorder()
+	s.handler.ServeHTTP(resp, req)
+	c.Check(resp.Code, check.Equals, http.StatusOK)
+	var dd arvados.DiscoveryDocument
+	err := json.Unmarshal(resp.Body.Bytes(), &dd)
+	c.Check(err, check.IsNil)
+	c.Check(dd.BlobSignatureTTL, check.Not(check.Equals), int64(0))
+	c.Check(dd.BlobSignatureTTL > 0, check.Equals, true)
+	c.Check(len(dd.Resources), check.Not(check.Equals), 0)
+	c.Check(len(dd.Schemas), check.Not(check.Equals), 0)
+}
+
+func (s *HandlerSuite) TestProxyWithoutToken(c *check.C) {
+	req := httptest.NewRequest("GET", "/arvados/v1/users/current", nil)
+	resp := httptest.NewRecorder()
+	s.handler.ServeHTTP(resp, req)
+	c.Check(resp.Code, check.Equals, http.StatusUnauthorized)
+	jresp := map[string]interface{}{}
+	err := json.Unmarshal(resp.Body.Bytes(), &jresp)
+	c.Check(err, check.IsNil)
+	c.Check(jresp["errors"], check.FitsTypeOf, []interface{}{})
+}
+
+func (s *HandlerSuite) TestProxyWithToken(c *check.C) {
+	req := httptest.NewRequest("GET", "/arvados/v1/users/current", nil)
+	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
+	resp := httptest.NewRecorder()
+	s.handler.ServeHTTP(resp, req)
+	c.Check(resp.Code, check.Equals, http.StatusOK)
+	var u arvados.User
+	err := json.Unmarshal(resp.Body.Bytes(), &u)
+	c.Check(err, check.IsNil)
+	c.Check(u.UUID, check.Equals, arvadostest.ActiveUserUUID)
+}
+
+func (s *HandlerSuite) TestProxyNotFound(c *check.C) {
+	req := httptest.NewRequest("GET", "/arvados/v1/xyzzy", nil)
+	resp := httptest.NewRecorder()
+	s.handler.ServeHTTP(resp, req)
+	c.Check(resp.Code, check.Equals, http.StatusNotFound)
+	jresp := map[string]interface{}{}
+	err := json.Unmarshal(resp.Body.Bytes(), &jresp)
+	c.Check(err, check.IsNil)
+	c.Check(jresp["errors"], check.FitsTypeOf, []interface{}{})
+}
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index 875a274dc..e0a2b1d28 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -131,4 +131,5 @@ func (sn *SystemNode) ServicePorts() map[ServiceName]string {
 
 type SystemServiceInstance struct {
 	Listen string
+	TLS    bool
 }
diff --git a/sdk/go/httpserver/logger.go b/sdk/go/httpserver/logger.go
index ec3fa7fae..9577718c7 100644
--- a/sdk/go/httpserver/logger.go
+++ b/sdk/go/httpserver/logger.go
@@ -17,7 +17,10 @@ type contextKey struct {
 	name string
 }
 
-var requestTimeContextKey = contextKey{"requestTime"}
+var (
+	requestTimeContextKey = contextKey{"requestTime"}
+	loggerContextKey      = contextKey{"logger"}
+)
 
 // LogRequests wraps an http.Handler, logging each request and
 // response via logger.
@@ -27,7 +30,6 @@ func LogRequests(logger logrus.FieldLogger, h http.Handler) http.Handler {
 	}
 	return http.HandlerFunc(func(wrapped http.ResponseWriter, req *http.Request) {
 		w := &responseTimer{ResponseWriter: WrapResponseWriter(wrapped)}
-		req = req.WithContext(context.WithValue(req.Context(), &requestTimeContextKey, time.Now()))
 		lgr := logger.WithFields(logrus.Fields{
 			"RequestID":       req.Header.Get("X-Request-Id"),
 			"remoteAddr":      req.RemoteAddr,
@@ -38,12 +40,25 @@ func LogRequests(logger logrus.FieldLogger, h http.Handler) http.Handler {
 			"reqQuery":        req.URL.RawQuery,
 			"reqBytes":        req.ContentLength,
 		})
+		ctx := req.Context()
+		ctx = context.WithValue(ctx, &requestTimeContextKey, time.Now())
+		ctx = context.WithValue(ctx, &loggerContextKey, lgr)
+		req = req.WithContext(ctx)
+
 		logRequest(w, req, lgr)
 		defer logResponse(w, req, lgr)
 		h.ServeHTTP(w, req)
 	})
 }
 
+func Logger(req *http.Request) logrus.FieldLogger {
+	if lgr, ok := req.Context().Value(&loggerContextKey).(logrus.FieldLogger); ok {
+		return lgr
+	} else {
+		return logrus.StandardLogger()
+	}
+}
+
 func logRequest(w *responseTimer, req *http.Request, lgr *logrus.Entry) {
 	lgr.Info("request")
 }

commit 6f8541529d68addd2915dfc849b988a6e41f66a3
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue Jun 12 11:10:12 2018 -0400

    13497: Move common system service code to lib/service.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/cmd/cmd.go b/lib/cmd/cmd.go
index 0d3a07a61..353167e80 100644
--- a/lib/cmd/cmd.go
+++ b/lib/cmd/cmd.go
@@ -58,6 +58,11 @@ func (m Multi) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
 		return 2
 	}
 	_, basename := filepath.Split(prog)
+	if strings.HasPrefix(basename, "arvados-") {
+		basename = basename[8:]
+	} else if strings.HasPrefix(basename, "crunch-") {
+		basename = basename[7:]
+	}
 	if cmd, ok := m[basename]; ok {
 		return cmd.RunCommand(prog, args, stdin, stdout, stderr)
 	} else if cmd, ok = m[args[0]]; ok {
diff --git a/lib/controller/cmd.go b/lib/controller/cmd.go
index c13d0fa07..2bb68aed9 100644
--- a/lib/controller/cmd.go
+++ b/lib/controller/cmd.go
@@ -5,75 +5,13 @@
 package controller
 
 import (
-	"flag"
-	"fmt"
-	"io"
 	"net/http"
 
 	"git.curoverse.com/arvados.git/lib/cmd"
+	"git.curoverse.com/arvados.git/lib/service"
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
-	"git.curoverse.com/arvados.git/sdk/go/httpserver"
-	"github.com/Sirupsen/logrus"
 )
 
-const rfc3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
-
-var Command cmd.Handler = &command{}
-
-type command struct{}
-
-func (*command) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
-	log := logrus.StandardLogger()
-	log.Formatter = &logrus.JSONFormatter{
-		TimestampFormat: rfc3339NanoFixed,
-	}
-	log.Out = stderr
-
-	var err error
-	defer func() {
-		if err != nil {
-			log.WithError(err).Info("exiting")
-		}
-	}()
-	flags := flag.NewFlagSet("", flag.ContinueOnError)
-	flags.SetOutput(stderr)
-	configFile := flags.String("config", arvados.DefaultConfigFile, "Site configuration `file`")
-	err = flags.Parse(args)
-	if err != nil {
-		return 2
-	}
-	cfg, err := arvados.GetConfig(*configFile)
-	if err != nil {
-		return 1
-	}
-	cluster, err := cfg.GetCluster("")
-	if err != nil {
-		return 1
-	}
-	node, err := cluster.GetThisSystemNode()
-	if err != nil {
-		return 1
-	}
-	if node.Controller.Listen == "" {
-		err = fmt.Errorf("configuration does not run a controller on this host: Clusters[%q].SystemNodes[`hostname` or *].Controller.Listen == \"\"", cluster.ClusterID)
-		return 1
-	}
-	srv := &httpserver.Server{
-		Server: http.Server{
-			Handler: httpserver.LogRequests(&Handler{
-				Cluster: cluster,
-			}),
-		},
-		Addr: node.Controller.Listen,
-	}
-	err = srv.Start()
-	if err != nil {
-		return 1
-	}
-	log.WithField("Listen", srv.Addr).Info("listening")
-	err = srv.Wait()
-	if err != nil {
-		return 1
-	}
-	return 0
-}
+var Command cmd.Handler = service.Command(arvados.ServiceNameController, func(cluster *arvados.Cluster, _ *arvados.SystemNode) http.Handler {
+	return &Handler{Cluster: cluster}
+})
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index c51f8668b..f0354d94d 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -12,14 +12,12 @@ import (
 
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/health"
-	"git.curoverse.com/arvados.git/sdk/go/httpserver"
 )
 
 type Handler struct {
 	Cluster *arvados.Cluster
 
 	setupOnce    sync.Once
-	mux          http.ServeMux
 	handlerStack http.Handler
 	proxyClient  *arvados.Client
 }
@@ -30,12 +28,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 }
 
 func (h *Handler) setup() {
-	h.mux.Handle("/_health/", &health.Handler{
+	mux := http.NewServeMux()
+	mux.Handle("/_health/", &health.Handler{
 		Token:  h.Cluster.ManagementToken,
 		Prefix: "/_health/",
 	})
-	h.mux.Handle("/", http.HandlerFunc(h.proxyRailsAPI))
-	h.handlerStack = httpserver.LogRequests(&h.mux)
+	mux.Handle("/", http.HandlerFunc(h.proxyRailsAPI))
+	h.handlerStack = mux
 }
 
 func (h *Handler) proxyRailsAPI(w http.ResponseWriter, incomingReq *http.Request) {
diff --git a/lib/service/cmd.go b/lib/service/cmd.go
new file mode 100644
index 000000000..e59ac486a
--- /dev/null
+++ b/lib/service/cmd.go
@@ -0,0 +1,101 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+// package service provides a cmd.Handler that brings up a system service.
+package service
+
+import (
+	"flag"
+	"fmt"
+	"io"
+	"net/http"
+
+	"git.curoverse.com/arvados.git/lib/cmd"
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/httpserver"
+	"github.com/Sirupsen/logrus"
+)
+
+type NewHandlerFunc func(*arvados.Cluster, *arvados.SystemNode) http.Handler
+
+type command struct {
+	newHandler NewHandlerFunc
+	svcName    arvados.ServiceName
+}
+
+// Command returns a cmd.Handler that loads site config, calls
+// newHandler with the current cluster and node configs, and brings up
+// an http server with the returned handler.
+//
+// The handler is wrapped with server middleware (adding X-Request-ID
+// headers, logging requests/responses, etc).
+func Command(svcName arvados.ServiceName, newHandler NewHandlerFunc) cmd.Handler {
+	return &command{
+		newHandler: newHandler,
+		svcName:    svcName,
+	}
+}
+
+func (c *command) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+	log := logrus.New()
+	log.Formatter = &logrus.JSONFormatter{
+		TimestampFormat: rfc3339NanoFixed,
+	}
+	log.Out = stderr
+
+	var err error
+	defer func() {
+		if err != nil {
+			log.WithError(err).Info("exiting")
+		}
+	}()
+	flags := flag.NewFlagSet("", flag.ContinueOnError)
+	flags.SetOutput(stderr)
+	configFile := flags.String("config", arvados.DefaultConfigFile, "Site configuration `file`")
+	err = flags.Parse(args)
+	if err == flag.ErrHelp {
+		err = nil
+		return 0
+	} else if err != nil {
+		return 2
+	}
+	cfg, err := arvados.GetConfig(*configFile)
+	if err != nil {
+		return 1
+	}
+	cluster, err := cfg.GetCluster("")
+	if err != nil {
+		return 1
+	}
+	node, err := cluster.GetThisSystemNode()
+	if err != nil {
+		return 1
+	}
+	listen := node.ServicePorts()[c.svcName]
+	if listen == "" {
+		err = fmt.Errorf("configuration does not enable the %s service on this host", c.svcName)
+		return 1
+	}
+	srv := &httpserver.Server{
+		Server: http.Server{
+			Handler: httpserver.AddRequestIDs(httpserver.LogRequests(log, c.newHandler(cluster, node))),
+		},
+		Addr: listen,
+	}
+	err = srv.Start()
+	if err != nil {
+		return 1
+	}
+	log.WithFields(logrus.Fields{
+		"Listen":  srv.Addr,
+		"Service": c.svcName,
+	}).Info("listening")
+	err = srv.Wait()
+	if err != nil {
+		return 1
+	}
+	return 0
+}
+
+const rfc3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index 2de13d784..875a274dc 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -101,18 +101,31 @@ type SystemNode struct {
 	Workbench   SystemServiceInstance `json:"arvados-workbench"`
 }
 
+type ServiceName string
+
+const (
+	ServiceNameRailsAPI    ServiceName = "arvados-api-server"
+	ServiceNameController  ServiceName = "arvados-controller"
+	ServiceNameNodemanager ServiceName = "arvados-node-manager"
+	ServiceNameWorkbench   ServiceName = "arvados-workbench"
+	ServiceNameWebsocket   ServiceName = "arvados-ws"
+	ServiceNameKeepweb     ServiceName = "keep-web"
+	ServiceNameKeepproxy   ServiceName = "keepproxy"
+	ServiceNameKeepstore   ServiceName = "keepstore"
+)
+
 // ServicePorts returns the configured listening address (or "" if
 // disabled) for each service on the node.
-func (sn *SystemNode) ServicePorts() map[string]string {
-	return map[string]string{
-		"arvados-api-server":   sn.RailsAPI.Listen,
-		"arvados-controller":   sn.Controller.Listen,
-		"arvados-node-manager": sn.Nodemanager.Listen,
-		"arvados-workbench":    sn.Workbench.Listen,
-		"arvados-ws":           sn.Websocket.Listen,
-		"keep-web":             sn.Keepweb.Listen,
-		"keepproxy":            sn.Keepproxy.Listen,
-		"keepstore":            sn.Keepstore.Listen,
+func (sn *SystemNode) ServicePorts() map[ServiceName]string {
+	return map[ServiceName]string{
+		ServiceNameRailsAPI:    sn.RailsAPI.Listen,
+		ServiceNameController:  sn.Controller.Listen,
+		ServiceNameNodemanager: sn.Nodemanager.Listen,
+		ServiceNameWorkbench:   sn.Workbench.Listen,
+		ServiceNameWebsocket:   sn.Websocket.Listen,
+		ServiceNameKeepweb:     sn.Keepweb.Listen,
+		ServiceNameKeepproxy:   sn.Keepproxy.Listen,
+		ServiceNameKeepstore:   sn.Keepstore.Listen,
 	}
 }
 
diff --git a/sdk/go/health/aggregator.go b/sdk/go/health/aggregator.go
index 5edb1f95c..68087139c 100644
--- a/sdk/go/health/aggregator.go
+++ b/sdk/go/health/aggregator.go
@@ -87,7 +87,7 @@ type ClusterHealthResponse struct {
 	// exposes problems that can't be expressed in Checks, like
 	// "service S is needed, but isn't configured to run
 	// anywhere."
-	Services map[string]ServiceHealth `json:"services"`
+	Services map[arvados.ServiceName]ServiceHealth `json:"services"`
 }
 
 type CheckResult struct {
@@ -108,7 +108,7 @@ func (agg *Aggregator) ClusterHealth(cluster *arvados.Cluster) ClusterHealthResp
 	resp := ClusterHealthResponse{
 		Health:   "OK",
 		Checks:   make(map[string]CheckResult),
-		Services: make(map[string]ServiceHealth),
+		Services: make(map[arvados.ServiceName]ServiceHealth),
 	}
 
 	mtx := sync.Mutex{}
@@ -128,7 +128,7 @@ func (agg *Aggregator) ClusterHealth(cluster *arvados.Cluster) ClusterHealthResp
 			}
 
 			wg.Add(1)
-			go func(node, svc, addr string) {
+			go func(node string, svc arvados.ServiceName, addr string) {
 				defer wg.Done()
 				var result CheckResult
 				url, err := agg.pingURL(node, addr)
@@ -143,7 +143,7 @@ func (agg *Aggregator) ClusterHealth(cluster *arvados.Cluster) ClusterHealthResp
 
 				mtx.Lock()
 				defer mtx.Unlock()
-				resp.Checks[svc+"+"+url] = result
+				resp.Checks[fmt.Sprintf("%s%s", svc, url)] = result
 				if result.Health == "OK" {
 					h := resp.Services[svc]
 					h.N++
diff --git a/sdk/go/httpserver/logger.go b/sdk/go/httpserver/logger.go
index 1a4b7c559..ec3fa7fae 100644
--- a/sdk/go/httpserver/logger.go
+++ b/sdk/go/httpserver/logger.go
@@ -19,15 +19,16 @@ type contextKey struct {
 
 var requestTimeContextKey = contextKey{"requestTime"}
 
-var Logger logrus.FieldLogger = logrus.StandardLogger()
-
 // LogRequests wraps an http.Handler, logging each request and
-// response via logrus.
-func LogRequests(h http.Handler) http.Handler {
+// response via logger.
+func LogRequests(logger logrus.FieldLogger, h http.Handler) http.Handler {
+	if logger == nil {
+		logger = logrus.StandardLogger()
+	}
 	return http.HandlerFunc(func(wrapped http.ResponseWriter, req *http.Request) {
 		w := &responseTimer{ResponseWriter: WrapResponseWriter(wrapped)}
 		req = req.WithContext(context.WithValue(req.Context(), &requestTimeContextKey, time.Now()))
-		lgr := Logger.WithFields(logrus.Fields{
+		lgr := logger.WithFields(logrus.Fields{
 			"RequestID":       req.Header.Get("X-Request-Id"),
 			"remoteAddr":      req.RemoteAddr,
 			"reqForwardedFor": req.Header.Get("X-Forwarded-For"),
diff --git a/sdk/go/httpserver/logger_test.go b/sdk/go/httpserver/logger_test.go
index bbcafa143..bdde3303e 100644
--- a/sdk/go/httpserver/logger_test.go
+++ b/sdk/go/httpserver/logger_test.go
@@ -9,11 +9,10 @@ import (
 	"encoding/json"
 	"net/http"
 	"net/http/httptest"
-	"os"
 	"testing"
 	"time"
 
-	log "github.com/Sirupsen/logrus"
+	"github.com/Sirupsen/logrus"
 	check "gopkg.in/check.v1"
 )
 
@@ -26,12 +25,13 @@ var _ = check.Suite(&Suite{})
 type Suite struct{}
 
 func (s *Suite) TestLogRequests(c *check.C) {
-	defer log.SetOutput(os.Stdout)
 	captured := &bytes.Buffer{}
-	log.SetOutput(captured)
-	log.SetFormatter(&log.JSONFormatter{
+	log := logrus.New()
+	log.Out = captured
+	log.Formatter = &logrus.JSONFormatter{
 		TimestampFormat: time.RFC3339Nano,
-	})
+	}
+
 	h := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 		w.Write([]byte("hello world"))
 	})
@@ -39,7 +39,7 @@ func (s *Suite) TestLogRequests(c *check.C) {
 	req.Header.Set("X-Forwarded-For", "1.2.3.4:12345")
 	c.Assert(err, check.IsNil)
 	resp := httptest.NewRecorder()
-	AddRequestIDs(LogRequests(h)).ServeHTTP(resp, req)
+	AddRequestIDs(LogRequests(log, h)).ServeHTTP(resp, req)
 
 	dec := json.NewDecoder(captured)
 
diff --git a/services/keep-web/server.go b/services/keep-web/server.go
index 2995bd30a..e51376c3b 100644
--- a/services/keep-web/server.go
+++ b/services/keep-web/server.go
@@ -14,7 +14,7 @@ type server struct {
 }
 
 func (srv *server) Start() error {
-	srv.Handler = httpserver.AddRequestIDs(httpserver.LogRequests(&handler{Config: srv.Config}))
+	srv.Handler = httpserver.AddRequestIDs(httpserver.LogRequests(nil, &handler{Config: srv.Config}))
 	srv.Addr = srv.Config.Listen
 	return srv.Server.Start()
 }
diff --git a/services/keepproxy/keepproxy.go b/services/keepproxy/keepproxy.go
index 07fc63b63..b6c8bd66a 100644
--- a/services/keepproxy/keepproxy.go
+++ b/services/keepproxy/keepproxy.go
@@ -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(router)))
+	http.Serve(listener, httpserver.AddRequestIDs(httpserver.LogRequests(nil, router)))
 
 	log.Println("shutting down")
 }
diff --git a/services/keepstore/handlers.go b/services/keepstore/handlers.go
index a84a84db3..fb327a386 100644
--- a/services/keepstore/handlers.go
+++ b/services/keepstore/handlers.go
@@ -92,7 +92,7 @@ func MakeRESTRouter() http.Handler {
 
 	mux := http.NewServeMux()
 	mux.Handle("/", theConfig.metrics.Instrument(
-		httpserver.AddRequestIDs(httpserver.LogRequests(rtr.limiter))))
+		httpserver.AddRequestIDs(httpserver.LogRequests(nil, rtr.limiter))))
 	mux.HandleFunc("/metrics.json", theConfig.metrics.exportJSON)
 	mux.Handle("/metrics", theConfig.metrics.exportProm)
 

commit 72bd39971753efa7e951b945d07e8d9704a07221
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu Jun 7 15:23:05 2018 -0400

    13497: Build arvados-server and arvados-controller packages.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/build/run-build-packages.sh b/build/run-build-packages.sh
index 63f81832f..61dc07e4a 100755
--- a/build/run-build-packages.sh
+++ b/build/run-build-packages.sh
@@ -291,6 +291,10 @@ export GOPATH=$(mktemp -d)
 go get github.com/kardianos/govendor
 package_go_binary cmd/arvados-client arvados-client \
     "Arvados command line tool (beta)"
+package_go_binary cmd/arvados-server arvados-server \
+    "Arvados server daemons"
+package_go_binary cmd/arvados-server arvados-controller \
+    "Arvados cluster controller daemon"
 package_go_binary sdk/go/crunchrunner crunchrunner \
     "Crunchrunner executes a command inside a container and uploads the output"
 package_go_binary services/arv-git-httpd arvados-git-httpd \

commit ceb5ad39a5b94ba26d4a4a059f7801b758ddcec8
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Wed Jun 6 15:25:45 2018 -0400

    13427: More symlink hack
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/build/run-tests.sh b/build/run-tests.sh
index 40d37621e..9bcc5ba05 100755
--- a/build/run-tests.sh
+++ b/build/run-tests.sh
@@ -516,12 +516,17 @@ export GOPATH
     set -e
     mkdir -p "$GOPATH/src/git.curoverse.com"
     rmdir -v --parents --ignore-fail-on-non-empty "${temp}/GOPATH"
+    if [[ ! -h "$GOPATH/src/git.curoverse.com/arvados.git" ]]; then
+        for d in \
+            "$GOPATH/src/git.curoverse.com/arvados.git/tmp/GOPATH" \
+                "$GOPATH/src/git.curoverse.com/arvados.git/tmp" \
+                "$GOPATH/src/git.curoverse.com/arvados.git"; do
+            [[ -d "$d" ]] && rmdir "$d"
+        done
+    fi
     for d in \
-        "$GOPATH/src/git.curoverse.com/arvados.git/tmp/GOPATH" \
-        "$GOPATH/src/git.curoverse.com/arvados.git/tmp" \
         "$GOPATH/src/git.curoverse.com/arvados.git/arvados" \
         "$GOPATH/src/git.curoverse.com/arvados.git"; do
-        [[ -d "$d" ]] && rmdir "$d"
         [[ -h "$d" ]] && rm "$d"
     done
     ln -vsfT "$WORKSPACE" "$GOPATH/src/git.curoverse.com/arvados.git"

commit 220778381f3a6aa6988c682f914fb9baeada85be
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu Jun 7 10:20:29 2018 -0400

    13497: Add arvados-server command.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/build/run-tests.sh b/build/run-tests.sh
index 17a4fbd35..40d37621e 100755
--- a/build/run-tests.sh
+++ b/build/run-tests.sh
@@ -70,6 +70,7 @@ apps/workbench_integration (*)
 apps/workbench_benchmark
 apps/workbench_profile
 cmd/arvados-client
+cmd/arvados-server
 doc
 lib/cli
 lib/cmd
@@ -891,6 +892,7 @@ do_install services/api apiserver
 declare -a gostuff
 gostuff=(
     cmd/arvados-client
+    cmd/arvados-server
     lib/cli
     lib/cmd
     lib/controller
diff --git a/cmd/arvados-client/cmd.go b/cmd/arvados-client/cmd.go
index b616b54bd..4550ae53a 100644
--- a/cmd/arvados-client/cmd.go
+++ b/cmd/arvados-client/cmd.go
@@ -5,24 +5,19 @@
 package main
 
 import (
-	"fmt"
-	"io"
 	"os"
-	"regexp"
-	"runtime"
 
 	"git.curoverse.com/arvados.git/lib/cli"
 	"git.curoverse.com/arvados.git/lib/cmd"
 )
 
 var (
-	version                = "dev"
-	cmdVersion cmd.Handler = versionCmd{}
-	handler                = cmd.Multi(map[string]cmd.Handler{
-		"-e":        cmdVersion,
-		"version":   cmdVersion,
-		"-version":  cmdVersion,
-		"--version": cmdVersion,
+	version = "dev"
+	handler = cmd.Multi(map[string]cmd.Handler{
+		"-e":        cmd.Version(version),
+		"version":   cmd.Version(version),
+		"-version":  cmd.Version(version),
+		"--version": cmd.Version(version),
 
 		"copy":     cli.Copy,
 		"create":   cli.Create,
@@ -61,14 +56,6 @@ var (
 	})
 )
 
-type versionCmd struct{}
-
-func (versionCmd) RunCommand(prog string, args []string, _ io.Reader, stdout, _ io.Writer) int {
-	prog = regexp.MustCompile(` -*version$`).ReplaceAllLiteralString(prog, "")
-	fmt.Fprintf(stdout, "%s %s (%s)\n", prog, version, runtime.Version())
-	return 0
-}
-
 func fixLegacyArgs(args []string) []string {
 	flags, _ := cli.LegacyFlagSet()
 	return cmd.SubcommandToFront(args, flags)
diff --git a/cmd/arvados-server/cmd.go b/cmd/arvados-server/cmd.go
new file mode 100644
index 000000000..1af3745df
--- /dev/null
+++ b/cmd/arvados-server/cmd.go
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package main
+
+import (
+	"os"
+
+	"git.curoverse.com/arvados.git/lib/cmd"
+	"git.curoverse.com/arvados.git/lib/controller"
+)
+
+var (
+	version = "dev"
+	handler = cmd.Multi(map[string]cmd.Handler{
+		"version":   cmd.Version(version),
+		"-version":  cmd.Version(version),
+		"--version": cmd.Version(version),
+
+		"controller": controller.Command,
+	})
+)
+
+func main() {
+	os.Exit(handler.RunCommand(os.Args[0], os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
+}
diff --git a/lib/cmd/cmd.go b/lib/cmd/cmd.go
index 8b8427a70..0d3a07a61 100644
--- a/lib/cmd/cmd.go
+++ b/lib/cmd/cmd.go
@@ -12,6 +12,8 @@ import (
 	"io"
 	"io/ioutil"
 	"path/filepath"
+	"regexp"
+	"runtime"
 	"sort"
 	"strings"
 )
@@ -26,6 +28,14 @@ func (f HandlerFunc) RunCommand(prog string, args []string, stdin io.Reader, std
 	return f(prog, args, stdin, stdout, stderr)
 }
 
+type Version string
+
+func (v Version) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+	prog = regexp.MustCompile(` -*version$`).ReplaceAllLiteralString(prog, "")
+	fmt.Fprintf(stdout, "%s %s (%s)\n", prog, v, runtime.Version())
+	return 0
+}
+
 // Multi is a Handler that looks up its first argument in a map, and
 // invokes the resulting Handler with the remaining args.
 //

commit 25a80e9318880fbff91289ac8f70e1cae4c132a2
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu Jun 7 09:33:20 2018 -0400

    13497: Use basename($0) as subcommand, if it is one.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/cmd/cmd.go b/lib/cmd/cmd.go
index 2cc71e68a..8b8427a70 100644
--- a/lib/cmd/cmd.go
+++ b/lib/cmd/cmd.go
@@ -11,6 +11,7 @@ import (
 	"fmt"
 	"io"
 	"io/ioutil"
+	"path/filepath"
 	"sort"
 	"strings"
 )
@@ -46,12 +47,15 @@ func (m Multi) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
 		m.Usage(stderr)
 		return 2
 	}
-	if cmd, ok := m[args[0]]; !ok {
-		fmt.Fprintf(stderr, "unrecognized command %q\n", args[0])
+	_, basename := filepath.Split(prog)
+	if cmd, ok := m[basename]; ok {
+		return cmd.RunCommand(prog, args, stdin, stdout, stderr)
+	} else if cmd, ok = m[args[0]]; ok {
+		return cmd.RunCommand(prog+" "+args[0], args[1:], stdin, stdout, stderr)
+	} else {
+		fmt.Fprintf(stderr, "%s: unrecognized command %q\n", prog, args[0])
 		m.Usage(stderr)
 		return 2
-	} else {
-		return cmd.RunCommand(prog+" "+args[0], args[1:], stdin, stdout, stderr)
 	}
 }
 
diff --git a/lib/cmd/cmd_test.go b/lib/cmd/cmd_test.go
index d8a486157..2fc50985f 100644
--- a/lib/cmd/cmd_test.go
+++ b/lib/cmd/cmd_test.go
@@ -42,6 +42,16 @@ func (s *CmdSuite) TestHello(c *check.C) {
 	c.Check(stderr.String(), check.Equals, "")
 }
 
+func (s *CmdSuite) TestHelloViaProg(c *check.C) {
+	defer cmdtest.LeakCheck(c)()
+	stdout := bytes.NewBuffer(nil)
+	stderr := bytes.NewBuffer(nil)
+	exited := testCmd.RunCommand("/usr/local/bin/echo", []string{"hello", "world"}, bytes.NewReader(nil), stdout, stderr)
+	c.Check(exited, check.Equals, 0)
+	c.Check(stdout.String(), check.Equals, "hello world\n")
+	c.Check(stderr.String(), check.Equals, "")
+}
+
 func (s *CmdSuite) TestUsage(c *check.C) {
 	defer cmdtest.LeakCheck(c)()
 	stdout := bytes.NewBuffer(nil)
@@ -49,7 +59,7 @@ func (s *CmdSuite) TestUsage(c *check.C) {
 	exited := testCmd.RunCommand("prog", []string{"nosuchcommand", "hi"}, bytes.NewReader(nil), stdout, stderr)
 	c.Check(exited, check.Equals, 2)
 	c.Check(stdout.String(), check.Equals, "")
-	c.Check(stderr.String(), check.Matches, `(?ms)^unrecognized command "nosuchcommand"\n.*echo.*\n`)
+	c.Check(stderr.String(), check.Matches, `(?ms)^prog: unrecognized command "nosuchcommand"\n.*echo.*\n`)
 }
 
 func (s *CmdSuite) TestSubcommandToFront(c *check.C) {

commit a788135c352c36d1a905c7630423ba57b2ae072a
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu Jun 7 00:46:47 2018 -0400

    13497: Add controller, proxy to Rails API.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/build/run-tests.sh b/build/run-tests.sh
index 1b6e4f142..17a4fbd35 100755
--- a/build/run-tests.sh
+++ b/build/run-tests.sh
@@ -73,6 +73,7 @@ cmd/arvados-client
 doc
 lib/cli
 lib/cmd
+lib/controller
 lib/crunchstat
 lib/dispatchcloud
 services/api
@@ -892,6 +893,7 @@ gostuff=(
     cmd/arvados-client
     lib/cli
     lib/cmd
+    lib/controller
     lib/crunchstat
     lib/dispatchcloud
     sdk/go/arvados
diff --git a/lib/controller/cmd.go b/lib/controller/cmd.go
new file mode 100644
index 000000000..c13d0fa07
--- /dev/null
+++ b/lib/controller/cmd.go
@@ -0,0 +1,79 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package controller
+
+import (
+	"flag"
+	"fmt"
+	"io"
+	"net/http"
+
+	"git.curoverse.com/arvados.git/lib/cmd"
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/httpserver"
+	"github.com/Sirupsen/logrus"
+)
+
+const rfc3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
+
+var Command cmd.Handler = &command{}
+
+type command struct{}
+
+func (*command) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+	log := logrus.StandardLogger()
+	log.Formatter = &logrus.JSONFormatter{
+		TimestampFormat: rfc3339NanoFixed,
+	}
+	log.Out = stderr
+
+	var err error
+	defer func() {
+		if err != nil {
+			log.WithError(err).Info("exiting")
+		}
+	}()
+	flags := flag.NewFlagSet("", flag.ContinueOnError)
+	flags.SetOutput(stderr)
+	configFile := flags.String("config", arvados.DefaultConfigFile, "Site configuration `file`")
+	err = flags.Parse(args)
+	if err != nil {
+		return 2
+	}
+	cfg, err := arvados.GetConfig(*configFile)
+	if err != nil {
+		return 1
+	}
+	cluster, err := cfg.GetCluster("")
+	if err != nil {
+		return 1
+	}
+	node, err := cluster.GetThisSystemNode()
+	if err != nil {
+		return 1
+	}
+	if node.Controller.Listen == "" {
+		err = fmt.Errorf("configuration does not run a controller on this host: Clusters[%q].SystemNodes[`hostname` or *].Controller.Listen == \"\"", cluster.ClusterID)
+		return 1
+	}
+	srv := &httpserver.Server{
+		Server: http.Server{
+			Handler: httpserver.LogRequests(&Handler{
+				Cluster: cluster,
+			}),
+		},
+		Addr: node.Controller.Listen,
+	}
+	err = srv.Start()
+	if err != nil {
+		return 1
+	}
+	log.WithField("Listen", srv.Addr).Info("listening")
+	err = srv.Wait()
+	if err != nil {
+		return 1
+	}
+	return 0
+}
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
new file mode 100644
index 000000000..c51f8668b
--- /dev/null
+++ b/lib/controller/handler.go
@@ -0,0 +1,71 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package controller
+
+import (
+	"io"
+	"net/http"
+	"net/url"
+	"sync"
+
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/health"
+	"git.curoverse.com/arvados.git/sdk/go/httpserver"
+)
+
+type Handler struct {
+	Cluster *arvados.Cluster
+
+	setupOnce    sync.Once
+	mux          http.ServeMux
+	handlerStack http.Handler
+	proxyClient  *arvados.Client
+}
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	h.setupOnce.Do(h.setup)
+	h.handlerStack.ServeHTTP(w, req)
+}
+
+func (h *Handler) setup() {
+	h.mux.Handle("/_health/", &health.Handler{
+		Token:  h.Cluster.ManagementToken,
+		Prefix: "/_health/",
+	})
+	h.mux.Handle("/", http.HandlerFunc(h.proxyRailsAPI))
+	h.handlerStack = httpserver.LogRequests(&h.mux)
+}
+
+func (h *Handler) proxyRailsAPI(w http.ResponseWriter, incomingReq *http.Request) {
+	url, err := findRailsAPI(h.Cluster)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	req := *incomingReq
+	req.URL.Host = url.Host
+	resp, err := arvados.InsecureHTTPClient.Do(&req)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	for k, v := range resp.Header {
+		for _, v := range v {
+			w.Header().Add(k, v)
+		}
+	}
+	w.WriteHeader(resp.StatusCode)
+	io.Copy(w, resp.Body)
+}
+
+// For now, findRailsAPI always uses the rails API running on this
+// node.
+func findRailsAPI(cluster *arvados.Cluster) (*url.URL, error) {
+	node, err := cluster.GetThisSystemNode()
+	if err != nil {
+		return nil, err
+	}
+	return url.Parse("http://" + node.RailsAPI.Listen)
+}
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index 9ed0eacf2..2de13d784 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -90,6 +90,7 @@ func (cc *Cluster) GetSystemNode(node string) (*SystemNode, error) {
 }
 
 type SystemNode struct {
+	Controller  SystemServiceInstance `json:"arvados-controller"`
 	Health      SystemServiceInstance `json:"arvados-health"`
 	Keepproxy   SystemServiceInstance `json:"keepproxy"`
 	Keepstore   SystemServiceInstance `json:"keepstore"`
@@ -105,6 +106,7 @@ type SystemNode struct {
 func (sn *SystemNode) ServicePorts() map[string]string {
 	return map[string]string{
 		"arvados-api-server":   sn.RailsAPI.Listen,
+		"arvados-controller":   sn.Controller.Listen,
 		"arvados-node-manager": sn.Nodemanager.Listen,
 		"arvados-workbench":    sn.Workbench.Listen,
 		"arvados-ws":           sn.Websocket.Listen,

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


hooks/post-receive
-- 




More information about the arvados-commits mailing list