18794: Merge branch 'main'
authorTom Clegg <tom@curii.com>
Thu, 5 May 2022 15:25:13 +0000 (11:25 -0400)
committerTom Clegg <tom@curii.com>
Thu, 5 May 2022 15:25:13 +0000 (11:25 -0400)
Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@curii.com>

1  2 
cmd/arvados-server/cmd.go
lib/config/load.go
lib/service/cmd.go
sdk/go/arvados/config.go
sdk/go/health/aggregator_test.go
sdk/python/tests/run_test_server.py

index 9e02d45b672cfa811aba39b872d7c3022b96fbc6,e4bd39002aafaab29a5ed690fc09a3174f30effc..342446a811f228a2fffeff990056a09fe08b7704
@@@ -21,7 -21,8 +21,9 @@@ import 
        "git.arvados.org/arvados.git/lib/install"
        "git.arvados.org/arvados.git/lib/lsf"
        "git.arvados.org/arvados.git/lib/recovercollection"
 +      "git.arvados.org/arvados.git/sdk/go/health"
+       "git.arvados.org/arvados.git/services/githttpd"
+       keepweb "git.arvados.org/arvados.git/services/keep-web"
        "git.arvados.org/arvados.git/services/keepproxy"
        "git.arvados.org/arvados.git/services/keepstore"
        "git.arvados.org/arvados.git/services/ws"
@@@ -34,7 -35,6 +36,7 @@@ var 
                "--version": cmd.Version,
  
                "boot":               boot.Command,
 +              "check":              health.CheckCommand,
                "cloudtest":          cloudtest.Command,
                "config-check":       config.CheckCommand,
                "config-defaults":    config.DumpDefaultsCommand,
                "crunch-run":         crunchrun.Command,
                "dispatch-cloud":     dispatchcloud.Command,
                "dispatch-lsf":       lsf.DispatchCommand,
+               "git-httpd":          githttpd.Command,
                "install":            install.Command,
                "init":               install.InitCommand,
+               "keep-web":           keepweb.Command,
                "keepproxy":          keepproxy.Command,
                "keepstore":          keepstore.Command,
                "recover-collection": recovercollection.Command,
diff --combined lib/config/load.go
index 1ad31402c37ad160aa8807e94ffe200e0b321177,6099215edc2f4d4fb5c014d2ee6b48aa327d730e..8f8ab2bf27312adb76b6597ef1f51f8e21ecb338
@@@ -6,7 -6,6 +6,7 @@@ package confi
  
  import (
        "bytes"
 +      "crypto/sha256"
        _ "embed"
        "encoding/json"
        "errors"
        "regexp"
        "strconv"
        "strings"
 +      "time"
  
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "github.com/ghodss/yaml"
        "github.com/imdario/mergo"
 +      "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
  )
  
@@@ -49,12 -46,6 +49,12 @@@ type Loader struct 
        KeepBalancePath         string
  
        configdata []byte
 +      // UTC time for configdata: either the modtime of the file we
 +      // read configdata from, or the time when we read configdata
 +      // from a pipe.
 +      sourceTimestamp time.Time
 +      // UTC time when configdata was read.
 +      loadTimestamp time.Time
  }
  
  // NewLoader returns a new Loader with Stdin and Logger set to the
@@@ -85,7 -76,7 +85,7 @@@ func (ldr *Loader) SetupFlags(flagset *
                flagset.StringVar(&ldr.CrunchDispatchSlurmPath, "legacy-crunch-dispatch-slurm-config", defaultCrunchDispatchSlurmConfigPath, "Legacy crunch-dispatch-slurm configuration `file`")
                flagset.StringVar(&ldr.WebsocketPath, "legacy-ws-config", defaultWebsocketConfigPath, "Legacy arvados-ws configuration `file`")
                flagset.StringVar(&ldr.KeepproxyPath, "legacy-keepproxy-config", defaultKeepproxyConfigPath, "Legacy keepproxy configuration `file`")
-               flagset.StringVar(&ldr.GitHttpdPath, "legacy-git-httpd-config", defaultGitHttpdConfigPath, "Legacy arv-git-httpd configuration `file`")
+               flagset.StringVar(&ldr.GitHttpdPath, "legacy-git-httpd-config", defaultGitHttpdConfigPath, "Legacy arvados-git-httpd configuration `file`")
                flagset.StringVar(&ldr.KeepBalancePath, "legacy-keepbalance-config", defaultKeepBalanceConfigPath, "Legacy keep-balance configuration `file`")
                flagset.BoolVar(&ldr.SkipLegacy, "skip-legacy", false, "Don't load legacy config files")
        }
@@@ -175,36 -166,25 +175,36 @@@ func (ldr *Loader) MungeLegacyConfigArg
        return munged
  }
  
 -func (ldr *Loader) loadBytes(path string) ([]byte, error) {
 +func (ldr *Loader) loadBytes(path string) (buf []byte, sourceTime, loadTime time.Time, err error) {
 +      loadTime = time.Now().UTC()
        if path == "-" {
 -              return ioutil.ReadAll(ldr.Stdin)
 +              buf, err = ioutil.ReadAll(ldr.Stdin)
 +              sourceTime = loadTime
 +              return
        }
        f, err := os.Open(path)
        if err != nil {
 -              return nil, err
 +              return
        }
        defer f.Close()
 -      return ioutil.ReadAll(f)
 +      fi, err := f.Stat()
 +      if err != nil {
 +              return
 +      }
 +      sourceTime = fi.ModTime().UTC()
 +      buf, err = ioutil.ReadAll(f)
 +      return
  }
  
  func (ldr *Loader) Load() (*arvados.Config, error) {
        if ldr.configdata == nil {
 -              buf, err := ldr.loadBytes(ldr.Path)
 +              buf, sourceTime, loadTime, err := ldr.loadBytes(ldr.Path)
                if err != nil {
                        return nil, err
                }
                ldr.configdata = buf
 +              ldr.sourceTimestamp = sourceTime
 +              ldr.loadTimestamp = loadTime
        }
  
        // FIXME: We should reject YAML if the same key is used twice
                        }
                }
        }
 +      cfg.SourceTimestamp = ldr.sourceTimestamp
 +      cfg.SourceSHA256 = fmt.Sprintf("%x", sha256.Sum256(ldr.configdata))
        return &cfg, nil
  }
  
@@@ -577,30 -555,3 +577,30 @@@ func (ldr *Loader) autofillPreemptible(
        }
  
  }
 +
 +// RegisterMetrics registers metrics showing the timestamp and content
 +// hash of the currently loaded config.
 +//
 +// Must not be called more than once for a given registry. Must not be
 +// called before Load(). Metrics are not updated by subsequent calls
 +// to Load().
 +func (ldr *Loader) RegisterMetrics(reg *prometheus.Registry) {
 +      hash := fmt.Sprintf("%x", sha256.Sum256(ldr.configdata))
 +      vec := prometheus.NewGaugeVec(prometheus.GaugeOpts{
 +              Namespace: "arvados",
 +              Subsystem: "config",
 +              Name:      "source_timestamp_seconds",
 +              Help:      "Timestamp of config file when it was loaded.",
 +      }, []string{"sha256"})
 +      vec.WithLabelValues(hash).Set(float64(ldr.sourceTimestamp.UnixNano()) / 1e9)
 +      reg.MustRegister(vec)
 +
 +      vec = prometheus.NewGaugeVec(prometheus.GaugeOpts{
 +              Namespace: "arvados",
 +              Subsystem: "config",
 +              Name:      "load_timestamp_seconds",
 +              Help:      "Time when config file was loaded.",
 +      }, []string{"sha256"})
 +      vec.WithLabelValues(hash).Set(float64(ldr.loadTimestamp.UnixNano()) / 1e9)
 +      reg.MustRegister(vec)
 +}
diff --combined lib/service/cmd.go
index 063ff9f6ee11e655eb6ae186b05e6d4740a107f6,43357998d8b6c79cbb065f82e4ce92f71c080d54..679cbede13bc8cf141a34ec40373a458c5195451
@@@ -21,8 -21,10 +21,10 @@@ import 
        "git.arvados.org/arvados.git/lib/config"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/sdk/go/health"
        "git.arvados.org/arvados.git/sdk/go/httpserver"
        "github.com/coreos/go-systemd/daemon"
+       "github.com/julienschmidt/httprouter"
        "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
  )
@@@ -30,6 -32,8 +32,8 @@@
  type Handler interface {
        http.Handler
        CheckHealth() error
+       // Done returns a channel that closes when the handler shuts
+       // itself down, or nil if this never happens.
        Done() <-chan struct{}
  }
  
@@@ -70,6 -74,13 +74,13 @@@ func (c *command) RunCommand(prog strin
  
        loader := config.NewLoader(stdin, log)
        loader.SetupFlags(flags)
+       // prog is [keepstore, keep-web, git-httpd, ...]  but the
+       // legacy config flags are [-legacy-keepstore-config,
+       // -legacy-keepweb-config, -legacy-git-httpd-config, ...]
+       legacyFlag := "-legacy-" + strings.Replace(prog, "keep-", "keep", 1) + "-config"
+       args = loader.MungeLegacyConfigArgs(log, args, legacyFlag)
        versionFlag := flags.Bool("version", false, "Write version information to stdout and exit 0")
        pprofAddr := flags.String("pprof", "", "Serve Go profile data at `[addr]:port`")
        if ok, code := cmd.ParseFlags(flags, prog, args, "", stderr); !ok {
        ctx = context.WithValue(ctx, contextKeyURL{}, listenURL)
  
        reg := prometheus.NewRegistry()
 +      loader.RegisterMetrics(reg)
 +
 +      // arvados_version_running{version="1.2.3~4"} 1.0
 +      mVersion := prometheus.NewGaugeVec(prometheus.GaugeOpts{
 +              Namespace: "arvados",
 +              Name:      "version_running",
 +              Help:      "Indicated version is running.",
 +      }, []string{"version"})
 +      mVersion.WithLabelValues(cmd.Version.String()).Set(1)
 +      reg.MustRegister(mVersion)
 +
        handler := c.newHandler(ctx, cluster, cluster.SystemRootToken, reg)
        if err = handler.CheckHealth(); err != nil {
                return 1
                httpserver.HandlerWithDeadline(cluster.API.RequestTimeout.Duration(),
                        httpserver.AddRequestIDs(
                                httpserver.LogRequests(
-                                       httpserver.NewRequestLimiter(cluster.API.MaxConcurrentRequests, handler, reg)))))
+                                       interceptHealthReqs(cluster.ManagementToken, handler.CheckHealth,
+                                               httpserver.NewRequestLimiter(cluster.API.MaxConcurrentRequests, handler, reg))))))
        srv := &httpserver.Server{
                Server: http.Server{
-                       Handler:     instrumented.ServeAPI(cluster.ManagementToken, instrumented),
+                       Handler:     ifCollectionInHost(instrumented, instrumented.ServeAPI(cluster.ManagementToken, instrumented)),
                        BaseContext: func(net.Listener) context.Context { return ctx },
                },
                Addr: listenURL.Host,
        return 0
  }
  
+ // If an incoming request's target vhost has an embedded collection
+ // UUID or PDH, handle it with hTrue, otherwise handle it with
+ // hFalse.
+ //
+ // Facilitates routing "http://collections.example/metrics" to metrics
+ // and "http://{uuid}.collections.example/metrics" to a file in a
+ // collection.
+ func ifCollectionInHost(hTrue, hFalse http.Handler) http.Handler {
+       return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               if arvados.CollectionIDFromDNSName(r.Host) != "" {
+                       hTrue.ServeHTTP(w, r)
+               } else {
+                       hFalse.ServeHTTP(w, r)
+               }
+       })
+ }
+ func interceptHealthReqs(mgtToken string, checkHealth func() error, next http.Handler) http.Handler {
+       mux := httprouter.New()
+       mux.Handler("GET", "/_health/ping", &health.Handler{
+               Token:  mgtToken,
+               Prefix: "/_health/",
+               Routes: health.Routes{"ping": checkHealth},
+       })
+       mux.NotFound = next
+       return ifCollectionInHost(next, mux)
+ }
  func getListenAddr(svcs arvados.Services, prog arvados.ServiceName, log logrus.FieldLogger) (arvados.URL, error) {
        svc, ok := svcs.Map()[prog]
        if !ok {
diff --combined sdk/go/arvados/config.go
index b508a3f05d2c40a1c25345c3bd2f8b79c907089d,1295350a4dd066d8960566cf854ebb69117d6839..f0adcda5f1c0ba2710ea0bd62b643453f46acfca
@@@ -10,7 -10,6 +10,7 @@@ import 
        "fmt"
        "net/url"
        "os"
 +      "time"
  
        "git.arvados.org/arvados.git/sdk/go/config"
  )
@@@ -25,8 -24,6 +25,8 @@@ var DefaultConfigFile = func() string 
  type Config struct {
        Clusters         map[string]Cluster
        AutoReloadConfig bool
 +      SourceTimestamp  time.Time
 +      SourceSHA256     string
  }
  
  // GetConfig returns the current system config, loading it from
@@@ -628,35 -625,37 +628,37 @@@ func (ss *StringSet) UnmarshalJSON(dat
  type ServiceName string
  
  const (
-       ServiceNameRailsAPI      ServiceName = "arvados-api-server"
        ServiceNameController    ServiceName = "arvados-controller"
        ServiceNameDispatchCloud ServiceName = "arvados-dispatch-cloud"
        ServiceNameDispatchLSF   ServiceName = "arvados-dispatch-lsf"
+       ServiceNameGitHTTP       ServiceName = "arvados-git-httpd"
        ServiceNameHealth        ServiceName = "arvados-health"
-       ServiceNameWorkbench1    ServiceName = "arvados-workbench1"
-       ServiceNameWorkbench2    ServiceName = "arvados-workbench2"
-       ServiceNameWebsocket     ServiceName = "arvados-ws"
        ServiceNameKeepbalance   ServiceName = "keep-balance"
-       ServiceNameKeepweb       ServiceName = "keep-web"
        ServiceNameKeepproxy     ServiceName = "keepproxy"
        ServiceNameKeepstore     ServiceName = "keepstore"
+       ServiceNameKeepweb       ServiceName = "keep-web"
+       ServiceNameRailsAPI      ServiceName = "arvados-api-server"
+       ServiceNameWebsocket     ServiceName = "arvados-ws"
+       ServiceNameWorkbench1    ServiceName = "arvados-workbench1"
+       ServiceNameWorkbench2    ServiceName = "arvados-workbench2"
  )
  
  // Map returns all services as a map, suitable for iterating over all
  // services or looking up a service by name.
  func (svcs Services) Map() map[ServiceName]Service {
        return map[ServiceName]Service{
-               ServiceNameRailsAPI:      svcs.RailsAPI,
                ServiceNameController:    svcs.Controller,
                ServiceNameDispatchCloud: svcs.DispatchCloud,
                ServiceNameDispatchLSF:   svcs.DispatchLSF,
+               ServiceNameGitHTTP:       svcs.GitHTTP,
                ServiceNameHealth:        svcs.Health,
-               ServiceNameWorkbench1:    svcs.Workbench1,
-               ServiceNameWorkbench2:    svcs.Workbench2,
-               ServiceNameWebsocket:     svcs.Websocket,
                ServiceNameKeepbalance:   svcs.Keepbalance,
-               ServiceNameKeepweb:       svcs.WebDAV,
                ServiceNameKeepproxy:     svcs.Keepproxy,
                ServiceNameKeepstore:     svcs.Keepstore,
+               ServiceNameKeepweb:       svcs.WebDAV,
+               ServiceNameRailsAPI:      svcs.RailsAPI,
+               ServiceNameWebsocket:     svcs.Websocket,
+               ServiceNameWorkbench1:    svcs.Workbench1,
+               ServiceNameWorkbench2:    svcs.Workbench2,
        }
  }
index f9b6dc6ae0e060f7d011b658881bc8f312486ab1,f8507ef4f5b53aa568652f3253d670548248b54d..f8f7ff9f1b7e189b83423d017be2a48fd763cf28
@@@ -5,22 -5,14 +5,22 @@@
  package health
  
  import (
 +      "bytes"
 +      "crypto/sha256"
        "encoding/json"
 +      "fmt"
 +      "io/ioutil"
        "net/http"
        "net/http/httptest"
 +      "regexp"
        "strings"
        "time"
  
 +      "git.arvados.org/arvados.git/lib/config"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
 +      "git.arvados.org/arvados.git/sdk/go/ctxlog"
 +      "github.com/ghodss/yaml"
        "gopkg.in/check.v1"
  )
  
@@@ -38,17 -30,9 +38,17 @@@ func (s *AggregatorSuite) TestInterface
  }
  
  func (s *AggregatorSuite) SetUpTest(c *check.C) {
 -      s.handler = &Aggregator{Cluster: &arvados.Cluster{
 -              ManagementToken: arvadostest.ManagementToken,
 -      }}
 +      ldr := config.NewLoader(bytes.NewBufferString(`Clusters: {zzzzz: {}}`), ctxlog.TestLogger(c))
 +      ldr.Path = "-"
 +      cfg, err := ldr.Load()
 +      c.Assert(err, check.IsNil)
 +      cluster, err := cfg.GetCluster("")
 +      c.Assert(err, check.IsNil)
 +      cluster.ManagementToken = arvadostest.ManagementToken
 +      cluster.SystemRootToken = arvadostest.SystemRootToken
 +      cluster.Collections.BlobSigningKey = arvadostest.BlobSigningKey
 +      cluster.Volumes["z"] = arvados.Volume{StorageClasses: map[string]bool{"default": true}}
 +      s.handler = &Aggregator{Cluster: cluster}
        s.req = httptest.NewRequest("GET", "/_health/all", nil)
        s.req.Header.Set("Authorization", "Bearer "+arvadostest.ManagementToken)
        s.resp = httptest.NewRecorder()
@@@ -123,103 -107,6 +123,103 @@@ func (s *AggregatorSuite) TestHealthyAn
        c.Logf("%#v", ep)
  }
  
 +// If an InternalURL host is 0.0.0.0, localhost, 127/8, or ::1 and
 +// nothing is listening there, don't fail the health check -- instead,
 +// assume the relevant component just isn't installed/enabled on this
 +// node, but does work when contacted through ExternalURL.
 +func (s *AggregatorSuite) TestUnreachableLoopbackPort(c *check.C) {
 +      srvH, listenH := s.stubServer(&healthyHandler{})
 +      defer srvH.Close()
 +      s.setAllServiceURLs(listenH)
 +      arvadostest.SetServiceURL(&s.handler.Cluster.Services.Keepproxy, "http://localhost:9/")
 +      arvadostest.SetServiceURL(&s.handler.Cluster.Services.Workbench1, "http://0.0.0.0:9/")
 +      arvadostest.SetServiceURL(&s.handler.Cluster.Services.Keepbalance, "http://127.0.0.127:9/")
 +      arvadostest.SetServiceURL(&s.handler.Cluster.Services.WebDAV, "http://[::1]:9/")
 +      s.handler.ServeHTTP(s.resp, s.req)
 +      s.checkOK(c)
 +
 +      // If a non-loopback address is unreachable, that's still a
 +      // fail.
 +      s.resp = httptest.NewRecorder()
 +      arvadostest.SetServiceURL(&s.handler.Cluster.Services.WebDAV, "http://172.31.255.254:9/")
 +      s.handler.ServeHTTP(s.resp, s.req)
 +      s.checkUnhealthy(c)
 +}
 +
 +func (s *AggregatorSuite) TestIsLocalHost(c *check.C) {
 +      c.Check(isLocalHost("Localhost"), check.Equals, true)
 +      c.Check(isLocalHost("localhost"), check.Equals, true)
 +      c.Check(isLocalHost("127.0.0.1"), check.Equals, true)
 +      c.Check(isLocalHost("127.0.0.127"), check.Equals, true)
 +      c.Check(isLocalHost("127.1.2.7"), check.Equals, true)
 +      c.Check(isLocalHost("0.0.0.0"), check.Equals, true)
 +      c.Check(isLocalHost("::1"), check.Equals, true)
 +      c.Check(isLocalHost("1.2.3.4"), check.Equals, false)
 +      c.Check(isLocalHost("1::1"), check.Equals, false)
 +      c.Check(isLocalHost("example.com"), check.Equals, false)
 +      c.Check(isLocalHost("127.0.0"), check.Equals, false)
 +      c.Check(isLocalHost(""), check.Equals, false)
 +}
 +
 +func (s *AggregatorSuite) TestConfigMismatch(c *check.C) {
 +      // time1/hash1: current config
 +      time1 := time.Now().Add(time.Second - time.Minute - time.Hour)
 +      hash1 := fmt.Sprintf("%x", sha256.Sum256([]byte(`Clusters: {zzzzz: {SystemRootToken: xyzzy}}`)))
 +      // time2/hash2: old config
 +      time2 := time1.Add(-time.Hour)
 +      hash2 := fmt.Sprintf("%x", sha256.Sum256([]byte(`Clusters: {zzzzz: {SystemRootToken: old-token}}`)))
 +
 +      // srv1: current file
 +      handler1 := healthyHandler{configHash: hash1, configTime: time1}
 +      srv1, listen1 := s.stubServer(&handler1)
 +      defer srv1.Close()
 +      // srv2: old file, current content
 +      handler2 := healthyHandler{configHash: hash1, configTime: time2}
 +      srv2, listen2 := s.stubServer(&handler2)
 +      defer srv2.Close()
 +      // srv3: old file, old content
 +      handler3 := healthyHandler{configHash: hash2, configTime: time2}
 +      srv3, listen3 := s.stubServer(&handler3)
 +      defer srv3.Close()
 +      // srv4: no metrics handler
 +      handler4 := healthyHandler{}
 +      srv4, listen4 := s.stubServer(&handler4)
 +      defer srv4.Close()
 +
 +      s.setAllServiceURLs(listen1)
 +
 +      // listen2 => old timestamp, same content => no problem
 +      s.resp = httptest.NewRecorder()
 +      arvadostest.SetServiceURL(&s.handler.Cluster.Services.DispatchCloud,
 +              "http://localhost"+listen2+"/")
 +      s.handler.ServeHTTP(s.resp, s.req)
 +      resp := s.checkOK(c)
 +
 +      // listen4 => no metrics on some services => no problem
 +      s.resp = httptest.NewRecorder()
 +      arvadostest.SetServiceURL(&s.handler.Cluster.Services.WebDAV,
 +              "http://localhost"+listen4+"/")
 +      s.handler.ServeHTTP(s.resp, s.req)
 +      resp = s.checkOK(c)
 +
 +      // listen3 => old timestamp, old content => report discrepancy
 +      s.resp = httptest.NewRecorder()
 +      arvadostest.SetServiceURL(&s.handler.Cluster.Services.Keepstore,
 +              "http://localhost"+listen1+"/",
 +              "http://localhost"+listen3+"/")
 +      s.handler.ServeHTTP(s.resp, s.req)
 +      resp = s.checkUnhealthy(c)
 +      if c.Check(len(resp.Errors) > 0, check.Equals, true) {
 +              c.Check(resp.Errors[0], check.Matches, `outdated config: \Qkeepstore+http://localhost`+listen3+`\E: config file \(sha256 .*\) does not match latest version with timestamp .*`)
 +      }
 +
 +      // no services report config time (migrating to current version) => no problem
 +      s.resp = httptest.NewRecorder()
 +      s.setAllServiceURLs(listen4)
 +      s.handler.ServeHTTP(s.resp, s.req)
 +      s.checkOK(c)
 +}
 +
  func (s *AggregatorSuite) TestPingTimeout(c *check.C) {
        s.handler.timeout = arvados.Duration(100 * time.Millisecond)
        srv, listen := s.stubServer(&slowHandler{})
        c.Check(rt > 0.005, check.Equals, true)
  }
  
 +func (s *AggregatorSuite) TestCheckCommand(c *check.C) {
 +      srv, listen := s.stubServer(&healthyHandler{})
 +      defer srv.Close()
 +      s.setAllServiceURLs(listen)
 +      tmpdir := c.MkDir()
 +      confdata, err := yaml.Marshal(arvados.Config{Clusters: map[string]arvados.Cluster{s.handler.Cluster.ClusterID: *s.handler.Cluster}})
 +      c.Assert(err, check.IsNil)
 +      confdata = regexp.MustCompile(`Source(Timestamp|SHA256): [^\n]+\n`).ReplaceAll(confdata, []byte{})
 +      err = ioutil.WriteFile(tmpdir+"/config.yml", confdata, 0777)
 +      c.Assert(err, check.IsNil)
 +
 +      var stdout, stderr bytes.Buffer
 +
 +      exitcode := CheckCommand.RunCommand("check", []string{"-config=" + tmpdir + "/config.yml"}, &bytes.Buffer{}, &stdout, &stderr)
 +      c.Check(exitcode, check.Equals, 0)
 +      c.Check(stderr.String(), check.Equals, "")
 +      c.Check(stdout.String(), check.Equals, "")
 +
 +      stdout.Reset()
 +      stderr.Reset()
 +      exitcode = CheckCommand.RunCommand("check", []string{"-config=" + tmpdir + "/config.yml", "-yaml"}, &bytes.Buffer{}, &stdout, &stderr)
 +      c.Check(exitcode, check.Equals, 0)
 +      c.Check(stderr.String(), check.Equals, "")
 +      c.Check(stdout.String(), check.Matches, `(?ms).*(\n|^)health: OK\n.*`)
 +}
 +
  func (s *AggregatorSuite) checkError(c *check.C) {
        c.Check(s.resp.Code, check.Not(check.Equals), http.StatusOK)
        var resp ClusterHealthResponse
@@@ -293,6 -154,7 +293,7 @@@ func (s *AggregatorSuite) setAllService
                &svcs.Controller,
                &svcs.DispatchCloud,
                &svcs.DispatchLSF,
+               &svcs.GitHTTP,
                &svcs.Keepbalance,
                &svcs.Keepproxy,
                &svcs.Keepstore,
@@@ -317,37 -179,11 +318,37 @@@ func (*unhealthyHandler) ServeHTTP(res
        }
  }
  
 -type healthyHandler struct{}
 +type healthyHandler struct {
 +      configHash string
 +      configTime time.Time
 +}
  
 -func (*healthyHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
 +func (h *healthyHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
 +      authOK := req.Header.Get("Authorization") == "Bearer "+arvadostest.ManagementToken
        if req.URL.Path == "/_health/ping" {
 +              if !authOK {
 +                      http.Error(resp, "unauthorized", http.StatusUnauthorized)
 +                      return
 +              }
                resp.Write([]byte(`{"health":"OK"}`))
 +      } else if req.URL.Path == "/metrics" {
 +              if !authOK {
 +                      http.Error(resp, "unauthorized", http.StatusUnauthorized)
 +                      return
 +              }
 +              t := h.configTime
 +              if t.IsZero() {
 +                      t = time.Now()
 +              }
 +              fmt.Fprintf(resp, `# HELP arvados_config_load_timestamp_seconds Time when config file was loaded.
 +# TYPE arvados_config_load_timestamp_seconds gauge
 +arvados_config_load_timestamp_seconds{sha256="%s"} %g
 +# HELP arvados_config_source_timestamp_seconds Timestamp of config file when it was loaded.
 +# TYPE arvados_config_source_timestamp_seconds gauge
 +arvados_config_source_timestamp_seconds{sha256="%s"} %g
 +`,
 +                      h.configHash, float64(time.Now().UnixNano())/1e9,
 +                      h.configHash, float64(t.UnixNano())/1e9)
        } else {
                http.Error(resp, "not found", http.StatusNotFound)
        }
index a8bd059581dc11270a539554ce2e1086e876e06c,74722b256e4e7c1dadb0148d3b507bcbc3b0d08f..76893ac84217d9fbd576723a19e9aaaa06be72db
@@@ -331,19 -331,6 +331,19 @@@ def run(leave_running_atexit=False)
          os.makedirs(gitdir)
      subprocess.check_output(['tar', '-xC', gitdir, '-f', gittarball])
  
 +    # Customizing the passenger config template is the only documented
 +    # way to override the default passenger_stat_throttle_rate (10 s).
 +    # In the testing environment, we want restart.txt to take effect
 +    # immediately.
 +    resdir = subprocess.check_output(['bundle', 'exec', 'passenger-config', 'about', 'resourcesdir']).decode().rstrip()
 +    with open(resdir + '/templates/standalone/config.erb') as f:
 +        template = f.read()
 +    newtemplate = re.sub('http {', 'http {\n        passenger_stat_throttle_rate 0;', template)
 +    if newtemplate == template:
 +        raise "template edit failed"
 +    with open('tmp/passenger-nginx.conf.erb', 'w') as f:
 +        f.write(newtemplate)
 +
      port = internal_port_from_config("RailsAPI")
      env = os.environ.copy()
      env['RAILS_ENV'] = 'test'
      railsapi = subprocess.Popen(
          ['bundle', 'exec',
           'passenger', 'start', '-p{}'.format(port),
 +         '--nginx-config-template', 'tmp/passenger-nginx.conf.erb',
 +       '--no-friendly-error-pages',
 +       '--disable-anonymous-telemetry',
 +       '--disable-security-update-check',
           '--pid-file', pid_file,
           '--log-file', '/dev/stdout',
           '--ssl',
@@@ -535,7 -518,7 +535,7 @@@ def run_keep(num_servers=2, **kwargs)
      # If keepproxy and/or keep-web is running, send SIGHUP to make
      # them discover the new keepstore services.
      for svc in ('keepproxy', 'keep-web'):
-         pidfile = _pidfile('keepproxy')
+         pidfile = _pidfile(svc)
          if os.path.exists(pidfile):
              try:
                  with open(pidfile) as pid:
@@@ -561,7 -544,7 +561,7 @@@ def run_keep_proxy()
      env['ARVADOS_API_TOKEN'] = auth_token('anonymous')
      logf = open(_logfilename('keepproxy'), WRITE_MODE)
      kp = subprocess.Popen(
-         ['keepproxy'], env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
+         ['arvados-server', 'keepproxy'], env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
  
      with open(_pidfile('keepproxy'), 'w') as f:
          f.write(str(kp.pid))
@@@ -598,17 -581,17 +598,17 @@@ def run_arv_git_httpd()
      gitport = internal_port_from_config("GitHTTP")
      env = os.environ.copy()
      env.pop('ARVADOS_API_TOKEN', None)
-     logf = open(_logfilename('arv-git-httpd'), WRITE_MODE)
-     agh = subprocess.Popen(['arv-git-httpd'],
+     logf = open(_logfilename('githttpd'), WRITE_MODE)
+     agh = subprocess.Popen(['arvados-server', 'git-httpd'],
          env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf)
-     with open(_pidfile('arv-git-httpd'), 'w') as f:
+     with open(_pidfile('githttpd'), 'w') as f:
          f.write(str(agh.pid))
      _wait_until_port_listens(gitport)
  
  def stop_arv_git_httpd():
      if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
          return
-     kill_server_pid(_pidfile('arv-git-httpd'))
+     kill_server_pid(_pidfile('githttpd'))
  
  def run_keep_web():
      if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
      env = os.environ.copy()
      logf = open(_logfilename('keep-web'), WRITE_MODE)
      keepweb = subprocess.Popen(
-         ['keep-web'],
+         ['arvados-server', 'keep-web'],
          env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf)
      with open(_pidfile('keep-web'), 'w') as f:
          f.write(str(keepweb.pid))
@@@ -697,7 -680,6 +697,6 @@@ def setup_config()
      keepstore_ports = sorted([str(find_available_port()) for _ in range(0,4)])
      keep_web_port = find_available_port()
      keep_web_external_port = find_available_port()
-     keep_web_dl_port = find_available_port()
      keep_web_dl_external_port = find_available_port()
  
      configsrc = os.environ.get("CONFIGSRC", None)
          "WebDAVDownload": {
              "ExternalURL": "https://%s:%s" % (localhost, keep_web_dl_external_port),
              "InternalURLs": {
-                 "http://%s:%s"%(localhost, keep_web_dl_port): {},
+                 "http://%s:%s"%(localhost, keep_web_port): {},
              },
          },
      }
@@@ -959,7 -941,7 +958,7 @@@ if __name__ == "__main__"
          'start_keep', 'stop_keep',
          'start_keep_proxy', 'stop_keep_proxy',
          'start_keep-web', 'stop_keep-web',
-         'start_arv-git-httpd', 'stop_arv-git-httpd',
+         'start_githttpd', 'stop_githttpd',
          'start_nginx', 'stop_nginx', 'setup_config',
      ]
      parser = argparse.ArgumentParser()
          run_keep_proxy()
      elif args.action == 'stop_keep_proxy':
          stop_keep_proxy()
-     elif args.action == 'start_arv-git-httpd':
+     elif args.action == 'start_githttpd':
          run_arv_git_httpd()
-     elif args.action == 'stop_arv-git-httpd':
+     elif args.action == 'stop_githttpd':
          stop_arv_git_httpd()
      elif args.action == 'start_keep-web':
          run_keep_web()