[ARVADOS] updated: 1.3.0-2177-g4fa9d36bf

Git user git at public.arvados.org
Tue Feb 18 20:17:46 UTC 2020


Summary of changes:
 .../app/controllers/actions_controller.rb          |   2 +-
 .../app/controllers/application_controller.rb      |   2 +-
 apps/workbench/app/models/container_work_unit.rb   |   2 +-
 .../app/views/users/_virtual_machines.html.erb     |  12 +-
 .../app/views/virtual_machines/_show_help.html.erb |  27 +-
 apps/workbench/fpm-info.sh                         |   4 +-
 build/package-build-dockerfiles/centos7/Dockerfile |   2 +-
 .../package-build-dockerfiles/debian10/Dockerfile  |   2 +-
 build/package-build-dockerfiles/debian9/Dockerfile |   2 +-
 .../ubuntu1604/Dockerfile                          |   2 +-
 .../ubuntu1804/Dockerfile                          |   2 +-
 build/rails-package-scripts/postinst.sh            |   2 +-
 build/run-build-packages.sh                        |   7 +-
 build/run-build-test-packages-one-target.sh        |   8 +-
 build/run-library.sh                               |  12 +-
 build/run-tests.sh                                 |   2 +-
 build/version-at-commit.sh                         |  39 ++-
 cmd/arvados-server/arvados-controller.service      |   2 +
 cmd/arvados-server/arvados-dispatch-cloud.service  |   2 +
 doc/_config.yml                                    |  10 +-
 doc/_includes/_navbar_top.liquid                   |   4 +-
 doc/_layouts/default.html.liquid                   |   1 +
 .../collection-versioning.html.textile.liquid      |  29 +-
 doc/admin/config-migration.html.textile.liquid     |   6 +-
 ...controlling-container-reuse.html.textile.liquid |  10 +-
 .../logs-table-management.html.textile.liquid      |  69 +++--
 doc/admin/metrics.html.textile.liquid              | 184 ++----------
 doc/admin/storage-classes.html.textile.liquid      |  22 +-
 doc/admin/upgrade-crunch2.html.textile.liquid      |   2 +-
 doc/admin/upgrading.html.textile.liquid            | 205 ++++++++-----
 .../workbench2-vocabulary.html.textile.liquid      |   4 +-
 doc/api/methods.html.textile.liquid                |   1 -
 doc/api/methods/links.html.textile.liquid          |  43 ++-
 doc/css/code.css                                   |   2 +
 doc/examples/config/zzzzz.yml                      |   7 -
 doc/install/index.html.textile.liquid              |   4 +
 .../install-dispatch-cloud.html.textile.liquid     |  10 +-
 .../install-workbench2-app.html.textile.liquid     |   4 +-
 doc/sdk/index.html.textile.liquid                  |   2 +-
 docker/jobs/Dockerfile                             |   7 +-
 go.mod                                             |   2 +-
 go.sum                                             |   2 +
 lib/boot/cert.go                                   |  58 ++++
 lib/boot/cmd.go                                    | 323 +++++++++++----------
 lib/boot/log.go                                    |  32 ++
 lib/boot/nginx.go                                  |  31 +-
 lib/boot/passenger.go                              |  93 ++++++
 lib/boot/postgresql.go                             | 100 +++++++
 lib/boot/seed.go                                   |  27 ++
 lib/boot/service.go                                |  68 +++++
 lib/config/config.default.yml                      |   9 +
 lib/config/export.go                               |   1 +
 lib/config/generated_config.go                     |   9 +
 lib/controller/federation/conn.go                  |  21 +-
 lib/controller/federation/list.go                  |  51 ++--
 lib/controller/federation/list_test.go             |  43 +++
 sdk/cwl/arvados_cwl/arvcontainer.py                |   3 +-
 sdk/cwl/arvados_cwl/executor.py                    |  52 ++--
 sdk/cwl/arvados_cwl/fsaccess.py                    |   5 +-
 sdk/cwl/arvados_cwl/runner.py                      |   8 +-
 sdk/cwl/setup.py                                   |   2 +-
 sdk/cwl/tests/test_submit.py                       |  13 +
 sdk/cwl/tests/tool/blub.txt.cat                    |   1 +
 .../tool/{submit_tool.cwl => tool_with_sf.cwl}     |   6 +-
 sdk/cwl/tests/tool/tool_with_sf.yml                |   3 +
 sdk/go/arvados/api.go                              |   1 +
 sdk/go/arvados/config.go                           |   1 +
 sdk/python/setup.py                                |   2 +-
 sdk/python/tests/nginx.conf                        |  26 +-
 sdk/python/tests/run_test_server.py                |  29 +-
 .../app/controllers/arvados/v1/users_controller.rb |   6 +-
 services/api/app/mailers/user_notifier.rb          |   2 +-
 services/api/app/models/collection.rb              |   6 +-
 services/api/config/application.default.yml        |  12 -
 services/api/lib/config_loader.rb                  |  11 +-
 .../functional/arvados/v1/users_controller_test.rb |   2 +-
 services/api/test/unit/user_notifier_test.rb       |   2 +-
 .../crunch-dispatch-slurm.service                  |   2 +
 services/fuse/arvados_version.py                   |  17 +-
 services/health/arvados-health.service             |   2 +
 services/keep-balance/keep-balance.service         |   2 +
 services/keep-web/keep-web.service                 |   2 +
 services/keepproxy/keepproxy.service               |   2 +
 services/keepstore/keepstore.service               |   2 +
 services/ws/arvados-ws.service                     |   2 +
 tools/arvbox/bin/arvbox                            |   6 +-
 tools/arvbox/lib/arvbox/docker/common.sh           |   1 +
 tools/arvbox/lib/arvbox/docker/service/nginx/run   |  82 +++---
 88 files changed, 1259 insertions(+), 683 deletions(-)
 create mode 100644 lib/boot/cert.go
 create mode 100644 lib/boot/log.go
 create mode 100644 lib/boot/passenger.go
 create mode 100644 lib/boot/postgresql.go
 create mode 100644 lib/boot/seed.go
 create mode 100644 lib/boot/service.go
 create mode 100644 sdk/cwl/tests/tool/blub.txt.cat
 copy sdk/cwl/tests/tool/{submit_tool.cwl => tool_with_sf.cwl} (87%)
 create mode 100644 sdk/cwl/tests/tool/tool_with_sf.yml

       via  4fa9d36bff13040b86c60490614b5e124f5a5606 (commit)
       via  93a06abafd2d6aacbb5da7bc4f04de558f404177 (commit)
       via  6d1d402cfb1a1e53f16668ff76a6fb38c03df94e (commit)
       via  44c25614832bd22c931479c38b05c6f3913e3a6f (commit)
       via  2fb6eafd2fda2834b0181b908d44a93ff8b6da43 (commit)
       via  8a471f18f22f85e996d2ce7110e7848aadfab44b (commit)
       via  7a24a37aa9e5ed425550403b68c270316a24d772 (commit)
       via  7abc7ca38954acd4eaa53c9280504e06a76b8d71 (commit)
       via  b9fd7e3f374248a61159e4750a84e38d1c48d5dd (commit)
       via  b01c4372354d84bbc712d2793b07a8d5d5276b98 (commit)
       via  618c29735325bcce9233747f04df1e86a4a3ef69 (commit)
       via  b8eec9929ab5b7a6c3ab1d3129123a63ba9dd978 (commit)
       via  1963a6aaf39f99bfd56264aeded298501eb8c3d4 (commit)
       via  04332347f3cca9e3aad62c7e67fe981e818b918c (commit)
       via  f6abd82e35d6c8f09ce820f1c5774ba1ff613a71 (commit)
       via  02dbc92e0a0df291a97edcfcc419319fdbc5f750 (commit)
       via  6272cb13e54254ad5ab3da898716daaab3ddae6a (commit)
       via  af1a79779dad6f9b01fd9deb2197ece416c014ec (commit)
       via  8039b0a469df007a282f321bd8349d9e47461a79 (commit)
       via  a29c0ced4f679e1972488fd2d6074fa1de3bac8d (commit)
       via  118908c39c6ffa0ae8b62cddbdb610c51a461b6d (commit)
       via  d8861941ef704dd729252ee1592e48a082798b65 (commit)
       via  1a0dce6698ce30e942877c959138160407b6a1be (commit)
       via  b742465734fe980578e106e6b035ca9d0aebf02e (commit)
       via  14bb7aa24ed7a06ce94e59f64ab32bdb44641168 (commit)
       via  e7d4393c847d036c2cf4a4d3c865e2c84636b1f2 (commit)
       via  4a30eb19ec37e84947f49f2d39b1acae3ebf3847 (commit)
       via  36c3c62be3b319a76dae1281206779d9dbecc030 (commit)
       via  273e436dbf8736869dde715e9bc04a20c169d63f (commit)
       via  fa9a17bf1ea10d9130188730a1aa160e89daaa13 (commit)
       via  ee0c92074b196d9e48ffe4e22bd20fbd58b5b837 (commit)
       via  4d969d35d1e1f022bd5ddcebb2de915bedd01334 (commit)
       via  73018eed5cd93c9db7467760f5f1484fa2750554 (commit)
       via  42ef5a07ca5fd2675f619d48de00c6f10143060c (commit)
       via  ce4675e0b3447611a0154de25ad89595f488890b (commit)
       via  4e450b7c2ec9563dd6d670238d096d1bc9fd158f (commit)
       via  74c70bf8c41ac135976f30784a4b5a3ab85dbe47 (commit)
       via  63de9493f14d062af48673fa1c4775d9c4e890fb (commit)
       via  040db1debca6c5dccda5513fb6d42f14687ae910 (commit)
       via  68a488a5a18d214a18016b4ef62137aafab0db8b (commit)
       via  cba44f28b4e932079f6bad2a478aa58fbcf9c24e (commit)
       via  c35311c706ed9c8c73ad5aa60285556f4ee266d7 (commit)
       via  7c6defa86739291b44683395780559ce5ddc2b88 (commit)
       via  405017353d0a74ef28288744957fd362df98c0e2 (commit)
       via  9fc2ff5583fc31c9f75f80dcf7258bed63084568 (commit)
       via  c88bd2141126b4d912ddb7f97774570ca81e6688 (commit)
       via  805883d4570e13e2760fa371e1a7714db3d8a0c9 (commit)
       via  bc4f35573b38ccde0756209057c8bce6947a38bc (commit)
       via  6e89a1027ef15b39b5d8b694939f68ff22e10c86 (commit)
       via  3c8aecebabdfeea3fd607789b48e38fba9ef5ce7 (commit)
       via  6637c4e3656d3e2334a46a7c30eaed91905a0603 (commit)
       via  28e50cc9480fdad416404542511a172cdc7253c7 (commit)
       via  6275b9ec9584e82f33688674a19225361fefa36b (commit)
       via  8b7de058f2d71698513ddbedfd5870a196c579bf (commit)
       via  16c01c88acf2a11d2e764278dcb1fb9aaa582559 (commit)
       via  18522b9cfaa23f0003094cccebfe127bf82a35f4 (commit)
       via  1b275b7390ba414f82241b23098b99654a227bf4 (commit)
       via  b871183da9ae071b277bfbd362f1cd3b0bde7076 (commit)
       via  ee38970362aa72e8aeb875ce15028fffd7834a3b (commit)
       via  a1a1e1b9e28c470c8efb27ea290ba00d48c520f2 (commit)
       via  b9969745321abe73dd8d2a04dc60c55fe9434ae6 (commit)
      from  0446c0a3a433936985d6f46b0eab9b253ed98e80 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.


commit 4fa9d36bff13040b86c60490614b5e124f5a5606
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Feb 18 15:16:17 2020 -0500

    15954: Tweak bundler sanity check.
    
    "gem install bundler -v 2.0.2 && bundle version" might report a
    previously installed version newer than 2.0.2.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/build/run-tests.sh b/build/run-tests.sh
index f21861762..2bcfbb84d 100755
--- a/build/run-tests.sh
+++ b/build/run-tests.sh
@@ -554,7 +554,7 @@ setup_ruby_environment() {
             export HOME=$GEMHOME
             ("$bundle" version | grep -q 2.0.2) \
                 || gem install --user bundler -v 2.0.2
-            "$bundle" version | grep 2.0.2
+            "$bundle" version | tee /dev/stderr | grep -q 'version 2'
         ) || fatal 'install bundler'
     fi
 }

commit 93a06abafd2d6aacbb5da7bc4f04de558f404177
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Feb 18 15:14:42 2020 -0500

    15954: Migrate test server's legacy config.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/sdk/python/tests/run_test_server.py b/sdk/python/tests/run_test_server.py
index 639eb87fd..9519c2b72 100644
--- a/sdk/python/tests/run_test_server.py
+++ b/sdk/python/tests/run_test_server.py
@@ -723,6 +723,9 @@ def setup_config():
                 "http://%s:%s"%(localhost, keep_web_dl_port): {},
             },
         },
+        "SSO": {
+            "ExternalURL": "http://localhost:3002",
+        },
     }
 
     config = {
@@ -732,6 +735,11 @@ def setup_config():
                 "SystemRootToken": auth_token('system_user'),
                 "API": {
                     "RequestTimeout": "30s",
+                    "RailsSessionSecretToken": "e24205c490ac07e028fd5f8a692dcb398bcd654eff1aef5f9fe6891994b18483",
+                },
+                "Login": {
+                    "ProviderAppID": "arvados-server",
+                    "ProviderAppSecret": "608dbf356a327e2d0d4932b60161e212c2d8d8f5e25690d7b622f850a990cd33",
                 },
                 "SystemLogs": {
                     "LogLevel": ('info' if os.environ.get('ARVADOS_DEBUG', '') in ['','0'] else 'debug'),
@@ -745,14 +753,22 @@ def setup_config():
                 "Services": services,
                 "Users": {
                     "AnonymousUserToken": auth_token('anonymous'),
+                    "UserProfileNotificationAddress": "arvados at example.com",
                 },
                 "Collections": {
                     "BlobSigningKey": "zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc",
                     "TrustAllContent": True,
                     "ForwardSlashNameSubstitution": "/",
+                    "TrashSweepInterval": "-1s",
                 },
                 "Git": {
-                    "Repositories": "%s/test" % os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'git'),
+                    "Repositories": os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'git', 'test'),
+                },
+                "Containers": {
+                    "JobsAPI": {
+                        "GitInternalDir": os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'internal.git'),
+                    },
+                    "SupportedDockerImageFormats": {"v1": {}},
                 },
                 "Volumes": {
                     "zzzzz-nyw5e-%015d"%n: {
diff --git a/services/api/config/application.default.yml b/services/api/config/application.default.yml
index 4e1936b77..9fd5368c0 100644
--- a/services/api/config/application.default.yml
+++ b/services/api/config/application.default.yml
@@ -76,15 +76,3 @@ test:
   action_controller.allow_forgery_protection: false
   action_mailer.delivery_method: :test
   active_support.deprecation: :stderr
-  uuid_prefix: zzzzz
-  sso_app_id: arvados-server
-  sso_app_secret: <%= rand(2**512).to_s(36) %>
-  sso_provider_url: http://localhost:3002
-  secret_token: <%= rand(2**512).to_s(36) %>
-  blob_signing_key: zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc
-  user_profile_notification_address: arvados at example.com
-  workbench_address: https://localhost:3001/
-  git_repositories_dir: <%= Rails.root.join 'tmp', 'git', 'test' %>
-  git_internal_dir: <%= Rails.root.join 'tmp', 'internal.git' %>
-  trash_sweep_interval: -1
-  docker_image_formats: ["v1"]
diff --git a/services/api/lib/config_loader.rb b/services/api/lib/config_loader.rb
index 522aa73b0..cf16993ca 100644
--- a/services/api/lib/config_loader.rb
+++ b/services/api/lib/config_loader.rb
@@ -180,8 +180,13 @@ class ConfigLoader
     end
   end
 
-  def self.parse_duration durstr, cfgkey:
-    duration_re = /-?(\d+(\.\d+)?)(s|m|h)/
+  def self.parse_duration(durstr, cfgkey:)
+    sign = 1
+    if durstr[0] == '-'
+      durstr = durstr[1..-1]
+      sign = -1
+    end
+    duration_re = /(\d+(\.\d+)?)(s|m|h)/
     dursec = 0
     while durstr != ""
       mt = duration_re.match durstr
@@ -189,7 +194,7 @@ class ConfigLoader
         raise "#{cfgkey} not a valid duration: '#{durstr}', accepted suffixes are s, m, h"
       end
       multiplier = {s: 1, m: 60, h: 3600}
-      dursec += (Float(mt[1]) * multiplier[mt[3].to_sym])
+      dursec += (Float(mt[1]) * multiplier[mt[3].to_sym] * sign)
       durstr = durstr[mt[0].length..-1]
     end
     return dursec.seconds

commit 6d1d402cfb1a1e53f16668ff76a6fb38c03df94e
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Feb 18 11:28:47 2020 -0500

    15954: Move pid/log files out of source tree.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/boot/passenger.go b/lib/boot/passenger.go
index 4aada107b..61f97df52 100644
--- a/lib/boot/passenger.go
+++ b/lib/boot/passenger.go
@@ -82,7 +82,11 @@ func (runner runPassenger) Run(ctx context.Context, fail func(error), boot *Boot
 		return fmt.Errorf("bug: no InternalURLs for component %q: %v", runner, runner.svc.InternalURLs)
 	}
 	go func() {
-		err = boot.RunProgram(ctx, runner.src, nil, nil, "bundle", "exec", "passenger", "start", "-p", port)
+		err = boot.RunProgram(ctx, runner.src, nil, nil, "bundle", "exec",
+			"passenger", "start",
+			"-p", port,
+			"--log-file", "/dev/null",
+			"--pid-file", filepath.Join(boot.tempdir, "passenger."+strings.Replace(runner.src, "/", "_", -1)+".pid"))
 		fail(err)
 	}()
 	return nil

commit 44c25614832bd22c931479c38b05c6f3913e3a6f
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Feb 18 10:40:12 2020 -0500

    15954: Control listen addresses, improve logging.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/boot/cmd.go b/lib/boot/cmd.go
index c288bab75..6cf44837c 100644
--- a/lib/boot/cmd.go
+++ b/lib/boot/cmd.go
@@ -25,8 +25,6 @@ import (
 
 	"git.arvados.org/arvados.git/lib/cmd"
 	"git.arvados.org/arvados.git/lib/config"
-	"git.arvados.org/arvados.git/lib/controller"
-	"git.arvados.org/arvados.git/lib/dispatchcloud"
 	"git.arvados.org/arvados.git/sdk/go/arvados"
 	"git.arvados.org/arvados.git/sdk/go/ctxlog"
 	"git.arvados.org/arvados.git/sdk/go/health"
@@ -81,6 +79,7 @@ func (bootCommand) RunCommand(prog string, args []string, stdin io.Reader, stdou
 	flags.StringVar(&boot.SourcePath, "source", ".", "arvados source tree `directory`")
 	flags.StringVar(&boot.LibPath, "lib", "/var/lib/arvados", "`directory` to install dependencies and library files")
 	flags.StringVar(&boot.ClusterType, "type", "production", "cluster `type`: development, test, or production")
+	flags.StringVar(&boot.ListenHost, "listen-host", "localhost", "host name or interface address for service listeners")
 	flags.StringVar(&boot.ControllerAddr, "controller-address", ":0", "desired controller address, `host:port` or `:port`")
 	flags.BoolVar(&boot.OwnTemporaryDatabase, "own-temporary-database", false, "bring up a postgres server and create a temporary database")
 	err = flags.Parse(args)
@@ -111,6 +110,7 @@ type Booter struct {
 	SourcePath           string // e.g., /home/username/src/arvados
 	LibPath              string // e.g., /var/lib/arvados
 	ClusterType          string // e.g., production
+	ListenHost           string // e.g., localhost
 	ControllerAddr       string // e.g., 127.0.0.1:8000
 	OwnTemporaryDatabase bool
 	Stderr               io.Writer
@@ -202,7 +202,11 @@ func (boot *Booter) run(loader *config.Loader) error {
 	}
 	// Now that we have the config, replace the bootstrap logger
 	// with a new one according to the logging config.
-	boot.logger = ctxlog.New(boot.Stderr, boot.cluster.SystemLogs.Format, boot.cluster.SystemLogs.LogLevel).WithFields(logrus.Fields{
+	loglevel := boot.cluster.SystemLogs.LogLevel
+	if s := os.Getenv("ARVADOS_DEBUG"); s != "" && s != "0" {
+		loglevel = "debug"
+	}
+	boot.logger = ctxlog.New(boot.Stderr, boot.cluster.SystemLogs.Format, loglevel).WithFields(logrus.Fields{
 		"PID": os.Getpid(),
 	})
 	boot.healthChecker = &health.Aggregator{Cluster: boot.cluster}
@@ -230,22 +234,22 @@ func (boot *Booter) run(loader *config.Loader) error {
 		createCertificates{},
 		runPostgreSQL{},
 		runNginx{},
-		runServiceCommand{name: "controller", command: controller.Command, depends: []bootTask{runPostgreSQL{}}},
+		runServiceCommand{name: "controller", svc: boot.cluster.Services.Controller, depends: []bootTask{runPostgreSQL{}}},
 		runGoProgram{src: "services/arv-git-httpd"},
 		runGoProgram{src: "services/health"},
-		runGoProgram{src: "services/keepproxy"},
+		runGoProgram{src: "services/keepproxy", depends: []bootTask{runPassenger{src: "services/api"}}},
 		runGoProgram{src: "services/keepstore", svc: boot.cluster.Services.Keepstore},
 		runGoProgram{src: "services/keep-web"},
 		runGoProgram{src: "services/ws", depends: []bootTask{runPostgreSQL{}}},
 		installPassenger{src: "services/api"},
 		runPassenger{src: "services/api", svc: boot.cluster.Services.RailsAPI, depends: []bootTask{createCertificates{}, runPostgreSQL{}, installPassenger{src: "services/api"}}},
-		installPassenger{src: "apps/workbench"},
+		installPassenger{src: "apps/workbench", depends: []bootTask{installPassenger{src: "services/api"}}}, // dependency ensures workbench doesn't delay api startup
 		runPassenger{src: "apps/workbench", svc: boot.cluster.Services.Workbench1, depends: []bootTask{installPassenger{src: "apps/workbench"}}},
 		seedDatabase{},
 	}
 	if boot.ClusterType != "test" {
 		tasks = append(tasks,
-			runServiceCommand{name: "dispatchcloud", command: dispatchcloud.Command},
+			runServiceCommand{name: "dispatch-cloud", svc: boot.cluster.Services.Controller},
 			runGoProgram{src: "services/keep-balance"},
 		)
 	}
@@ -260,10 +264,10 @@ func (boot *Booter) run(loader *config.Loader) error {
 				return
 			}
 			boot.cancel()
-			boot.logger.WithField("task", task).WithError(err).Error("task failed")
+			boot.logger.WithField("task", task.String()).WithError(err).Error("task failed")
 		}
 		go func() {
-			boot.logger.WithField("task", task).Info("starting")
+			boot.logger.WithField("task", task.String()).Info("starting")
 			err := task.Run(boot.ctx, fail, boot)
 			if err != nil {
 				fail(err)
@@ -272,7 +276,12 @@ func (boot *Booter) run(loader *config.Loader) error {
 			close(boot.tasksReady[task.String()])
 		}()
 	}
-	return boot.wait(boot.ctx, tasks...)
+	err = boot.wait(boot.ctx, tasks...)
+	if err != nil {
+		return err
+	}
+	<-boot.ctx.Done()
+	return boot.ctx.Err()
 }
 
 func (boot *Booter) wait(ctx context.Context, tasks ...bootTask) error {
@@ -281,7 +290,7 @@ func (boot *Booter) wait(ctx context.Context, tasks ...bootTask) error {
 		if !ok {
 			return fmt.Errorf("no such task: %s", task)
 		}
-		boot.logger.WithField("task", task).Info("waiting")
+		boot.logger.WithField("task", task.String()).Info("waiting")
 		select {
 		case <-ch:
 		case <-ctx.Done():
@@ -313,9 +322,10 @@ func (boot *Booter) WaitReady() bool {
 		// instead we wait for all configured components to
 		// pass.
 		waiting = false
-		for _, check := range resp.Checks {
+		for target, check := range resp.Checks {
 			if check.Health != "OK" {
 				waiting = true
+				boot.logger.WithField("target", target).Debug("waiting")
 			}
 		}
 	}
@@ -385,13 +395,23 @@ func (boot *Booter) lookPath(prog string) string {
 func (boot *Booter) RunProgram(ctx context.Context, dir string, output io.Writer, env []string, prog string, args ...string) error {
 	cmdline := fmt.Sprintf("%s", append([]string{prog}, args...))
 	fmt.Fprintf(boot.Stderr, "%s executing in %s\n", cmdline, dir)
+
+	logprefix := prog
+	if prog == "bundle" && len(args) > 2 && args[0] == "exec" {
+		logprefix = args[1]
+	}
+	if !strings.HasPrefix(dir, "/") {
+		logprefix = dir + ": " + logprefix
+	}
+	stderr := &logPrefixer{Writer: boot.Stderr, Prefix: []byte("[" + logprefix + "] ")}
+
 	cmd := exec.Command(boot.lookPath(prog), args...)
 	if output == nil {
-		cmd.Stdout = boot.Stderr
+		cmd.Stdout = stderr
 	} else {
 		cmd.Stdout = output
 	}
-	cmd.Stderr = boot.Stderr
+	cmd.Stderr = stderr
 	if strings.HasPrefix(dir, "/") {
 		cmd.Dir = dir
 	} else {
@@ -452,7 +472,7 @@ func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger)
 			return err
 		}
 		if h == "" {
-			h = "localhost"
+			h = boot.ListenHost
 		}
 		if p == "0" {
 			p, err = availablePort(":0")
@@ -486,11 +506,11 @@ func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger)
 			svc == &cluster.Services.WebDAVDownload ||
 			svc == &cluster.Services.Websocket ||
 			svc == &cluster.Services.Workbench1) {
-			svc.ExternalURL = arvados.URL{Scheme: "https", Host: fmt.Sprintf("localhost:%s", nextPort())}
+			svc.ExternalURL = arvados.URL{Scheme: "https", Host: fmt.Sprintf("%s:%s", boot.ListenHost, nextPort())}
 		}
 		if len(svc.InternalURLs) == 0 {
 			svc.InternalURLs = map[arvados.URL]arvados.ServiceInstance{
-				arvados.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%s", nextPort())}: arvados.ServiceInstance{},
+				arvados.URL{Scheme: "http", Host: fmt.Sprintf("%s:%s", boot.ListenHost, nextPort())}: arvados.ServiceInstance{},
 			}
 		}
 	}
@@ -518,7 +538,7 @@ func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger)
 	}
 	if boot.ClusterType == "test" {
 		// Add a second keepstore process.
-		cluster.Services.Keepstore.InternalURLs[arvados.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%s", nextPort())}] = arvados.ServiceInstance{}
+		cluster.Services.Keepstore.InternalURLs[arvados.URL{Scheme: "http", Host: fmt.Sprintf("%s:%s", boot.ListenHost, nextPort())}] = arvados.ServiceInstance{}
 
 		// Create a directory-backed volume for each keepstore
 		// process.
@@ -532,7 +552,7 @@ func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger)
 			} else if err = os.Mkdir(datadir, 0777); err != nil {
 				return err
 			}
-			cluster.Volumes[fmt.Sprintf("zzzzz-nyw5e-%015d", volnum)] = arvados.Volume{
+			cluster.Volumes[fmt.Sprintf(cluster.ClusterID+"-nyw5e-%015d", volnum)] = arvados.Volume{
 				Driver:           "Directory",
 				DriverParameters: json.RawMessage(fmt.Sprintf(`{"Root":%q}`, datadir)),
 				AccessViaHosts: map[arvados.URL]arvados.VolumeAccess{
diff --git a/lib/boot/log.go b/lib/boot/log.go
new file mode 100644
index 000000000..062a854a0
--- /dev/null
+++ b/lib/boot/log.go
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package boot
+
+import (
+	"bytes"
+	"io"
+)
+
+type logPrefixer struct {
+	io.Writer
+	Prefix []byte
+	did    bool
+}
+
+func (lp *logPrefixer) Write(p []byte) (int, error) {
+	if len(p) == 0 {
+		return 0, nil
+	}
+	if !lp.did {
+		lp.Writer.Write(lp.Prefix)
+		lp.did = p[len(p)-1] != '\n'
+	}
+	out := append(bytes.Replace(p[:len(p)-1], []byte("\n"), append([]byte("\n"), lp.Prefix...), -1), p[len(p)-1])
+	_, err := lp.Writer.Write(out)
+	if err != nil {
+		return 0, err
+	}
+	return len(p), nil
+}
diff --git a/lib/boot/nginx.go b/lib/boot/nginx.go
index c5bfd605a..5c1954c83 100644
--- a/lib/boot/nginx.go
+++ b/lib/boot/nginx.go
@@ -24,11 +24,12 @@ func (runNginx) String() string {
 
 func (runNginx) Run(ctx context.Context, fail func(error), boot *Booter) error {
 	vars := map[string]string{
-		"SSLCERT":   filepath.Join(boot.SourcePath, "services", "api", "tmp", "self-signed.pem"), // TODO: root ca
-		"SSLKEY":    filepath.Join(boot.SourcePath, "services", "api", "tmp", "self-signed.key"), // TODO: root ca
-		"ACCESSLOG": filepath.Join(boot.tempdir, "nginx_access.log"),
-		"ERRORLOG":  filepath.Join(boot.tempdir, "nginx_error.log"),
-		"TMPDIR":    boot.tempdir,
+		"LISTENHOST": boot.ListenHost,
+		"SSLCERT":    filepath.Join(boot.SourcePath, "services", "api", "tmp", "self-signed.pem"), // TODO: root ca
+		"SSLKEY":     filepath.Join(boot.SourcePath, "services", "api", "tmp", "self-signed.key"), // TODO: root ca
+		"ACCESSLOG":  filepath.Join(boot.tempdir, "nginx_access.log"),
+		"ERRORLOG":   filepath.Join(boot.tempdir, "nginx_error.log"),
+		"TMPDIR":     boot.tempdir,
 	}
 	var err error
 	for _, cmpt := range []struct {
diff --git a/lib/boot/passenger.go b/lib/boot/passenger.go
index b67370e1e..4aada107b 100644
--- a/lib/boot/passenger.go
+++ b/lib/boot/passenger.go
@@ -21,7 +21,7 @@ type installPassenger struct {
 }
 
 func (runner installPassenger) String() string {
-	return "install " + runner.src
+	return "installPassenger:" + runner.src
 }
 
 func (runner installPassenger) Run(ctx context.Context, fail func(error), boot *Booter) error {
@@ -69,8 +69,7 @@ type runPassenger struct {
 }
 
 func (runner runPassenger) String() string {
-	_, basename := filepath.Split(runner.src)
-	return basename
+	return "runPassenger:" + runner.src
 }
 
 func (runner runPassenger) Run(ctx context.Context, fail func(error), boot *Booter) error {
@@ -82,9 +81,9 @@ func (runner runPassenger) Run(ctx context.Context, fail func(error), boot *Boot
 	if err != nil {
 		return fmt.Errorf("bug: no InternalURLs for component %q: %v", runner, runner.svc.InternalURLs)
 	}
-	err = boot.RunProgram(ctx, runner.src, nil, nil, "bundle", "exec", "passenger", "start", "-p", port)
-	if err != nil {
-		return err
-	}
+	go func() {
+		err = boot.RunProgram(ctx, runner.src, nil, nil, "bundle", "exec", "passenger", "start", "-p", port)
+		fail(err)
+	}()
 	return nil
 }
diff --git a/lib/boot/seed.go b/lib/boot/seed.go
index f915764c5..9f086d544 100644
--- a/lib/boot/seed.go
+++ b/lib/boot/seed.go
@@ -15,7 +15,7 @@ func (seedDatabase) String() string {
 }
 
 func (seedDatabase) Run(ctx context.Context, fail func(error), boot *Booter) error {
-	err := boot.wait(ctx, runPostgreSQL{})
+	err := boot.wait(ctx, runPostgreSQL{}, installPassenger{src: "services/api"})
 	if err != nil {
 		return err
 	}
diff --git a/lib/boot/service.go b/lib/boot/service.go
index 9672dccc4..6edf78b3c 100644
--- a/lib/boot/service.go
+++ b/lib/boot/service.go
@@ -5,18 +5,15 @@
 package boot
 
 import (
-	"bytes"
 	"context"
-	"fmt"
 	"path/filepath"
 
-	"git.arvados.org/arvados.git/lib/cmd"
 	"git.arvados.org/arvados.git/sdk/go/arvados"
 )
 
 type runServiceCommand struct {
 	name    string
-	command cmd.Handler
+	svc     arvados.Service
 	depends []bootTask
 }
 
@@ -27,11 +24,10 @@ func (runner runServiceCommand) String() string {
 func (runner runServiceCommand) Run(ctx context.Context, fail func(error), boot *Booter) error {
 	boot.wait(ctx, runner.depends...)
 	go func() {
-		// runner.command.RunCommand() doesn't have access to
-		// ctx, so it can't shut down by itself when the
-		// caller cancels. We just abandon it.
-		exitcode := runner.command.RunCommand(runner.name, []string{"-config", boot.configfile}, bytes.NewBuffer(nil), boot.Stderr, boot.Stderr)
-		fail(fmt.Errorf("exit code %d", exitcode))
+		var u arvados.URL
+		for u = range runner.svc.InternalURLs {
+		}
+		fail(boot.RunProgram(ctx, boot.tempdir, nil, []string{"ARVADOS_SERVICE_INTERNAL_URL=" + u.String()}, "arvados-server", runner.name, "-config", boot.configfile))
 	}()
 	return nil
 }
diff --git a/sdk/python/tests/nginx.conf b/sdk/python/tests/nginx.conf
index b42090fed..b10b3f008 100644
--- a/sdk/python/tests/nginx.conf
+++ b/sdk/python/tests/nginx.conf
@@ -17,7 +17,7 @@ http {
   uwsgi_temp_path "{{TMPDIR}}";
   scgi_temp_path "{{TMPDIR}}";
   upstream arv-git-http {
-    server localhost:{{GITPORT}};
+    server {{LISTENHOST}}:{{GITPORT}};
   }
   server {
     listen *:{{GITSSLPORT}} ssl default_server;
@@ -33,7 +33,7 @@ http {
     }
   }
   upstream keepproxy {
-    server localhost:{{KEEPPROXYPORT}};
+    server {{LISTENHOST}}:{{KEEPPROXYPORT}};
   }
   server {
     listen *:{{KEEPPROXYSSLPORT}} ssl default_server;
@@ -52,7 +52,7 @@ http {
     }
   }
   upstream keep-web {
-    server localhost:{{KEEPWEBPORT}};
+    server {{LISTENHOST}}:{{KEEPWEBPORT}};
   }
   server {
     listen *:{{KEEPWEBSSLPORT}} ssl default_server;
@@ -89,7 +89,7 @@ http {
     }
   }
   upstream ws {
-    server localhost:{{WSPORT}};
+    server {{LISTENHOST}}:{{WSPORT}};
   }
   server {
     listen *:{{WSSSLPORT}} ssl default_server;
@@ -107,7 +107,7 @@ http {
     }
   }
   upstream workbench1 {
-    server localhost:{{WORKBENCH1PORT}};
+    server {{LISTENHOST}}:{{WORKBENCH1PORT}};
   }
   server {
     listen *:{{WORKBENCH1SSLPORT}} ssl default_server;
@@ -123,7 +123,7 @@ http {
     }
   }
   upstream controller {
-    server localhost:{{CONTROLLERPORT}};
+    server {{LISTENHOST}}:{{CONTROLLERPORT}};
   }
   server {
     listen *:{{CONTROLLERSSLPORT}} ssl default_server;
diff --git a/sdk/python/tests/run_test_server.py b/sdk/python/tests/run_test_server.py
index 005c75edf..639eb87fd 100644
--- a/sdk/python/tests/run_test_server.py
+++ b/sdk/python/tests/run_test_server.py
@@ -606,6 +606,7 @@ def run_nginx():
         return
     stop_nginx()
     nginxconf = {}
+    nginxconf['LISTENHOST'] = 'localhost'
     nginxconf['CONTROLLERPORT'] = internal_port_from_config("Controller")
     nginxconf['CONTROLLERSSLPORT'] = external_port_from_config("Controller")
     nginxconf['KEEPWEBPORT'] = internal_port_from_config("WebDAV")

commit 2fb6eafd2fda2834b0181b908d44a93ff8b6da43
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Fri Feb 14 13:07:03 2020 -0500

    15954: Add workbench1.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/boot/nginx.go b/lib/boot/nginx.go
index b5b712af6..c5bfd605a 100644
--- a/lib/boot/nginx.go
+++ b/lib/boot/nginx.go
@@ -40,6 +40,7 @@ func (runNginx) Run(ctx context.Context, fail func(error), boot *Booter) error {
 		{"KEEPWEBDL", boot.cluster.Services.WebDAVDownload},
 		{"KEEPPROXY", boot.cluster.Services.Keepproxy},
 		{"GIT", boot.cluster.Services.GitHTTP},
+		{"WORKBENCH1", boot.cluster.Services.Workbench1},
 		{"WS", boot.cluster.Services.Websocket},
 	} {
 		vars[cmpt.varname+"PORT"], err = internalPort(cmpt.svc)
diff --git a/sdk/python/tests/nginx.conf b/sdk/python/tests/nginx.conf
index e9be12235..b42090fed 100644
--- a/sdk/python/tests/nginx.conf
+++ b/sdk/python/tests/nginx.conf
@@ -106,6 +106,22 @@ http {
       proxy_redirect off;
     }
   }
+  upstream workbench1 {
+    server localhost:{{WORKBENCH1PORT}};
+  }
+  server {
+    listen *:{{WORKBENCH1SSLPORT}} ssl default_server;
+    server_name workbench1;
+    ssl_certificate "{{SSLCERT}}";
+    ssl_certificate_key "{{SSLKEY}}";
+    location  / {
+      proxy_pass http://workbench1;
+      proxy_set_header Host $http_host;
+      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+      proxy_set_header X-Forwarded-Proto https;
+      proxy_redirect off;
+    }
+  }
   upstream controller {
     server localhost:{{CONTROLLERPORT}};
   }
diff --git a/sdk/python/tests/run_test_server.py b/sdk/python/tests/run_test_server.py
index 5c05c124c..005c75edf 100644
--- a/sdk/python/tests/run_test_server.py
+++ b/sdk/python/tests/run_test_server.py
@@ -617,6 +617,8 @@ def run_nginx():
     nginxconf['GITSSLPORT'] = external_port_from_config("GitHTTP")
     nginxconf['WSPORT'] = internal_port_from_config("Websocket")
     nginxconf['WSSSLPORT'] = external_port_from_config("Websocket")
+    nginxconf['WORKBENCH1PORT'] = internal_port_from_config("Workbench1")
+    nginxconf['WORKBENCH1SSLPORT'] = external_port_from_config("Workbench1")
     nginxconf['SSLCERT'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem')
     nginxconf['SSLKEY'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.key')
     nginxconf['ACCESSLOG'] = _logfilename('nginx_access')
@@ -648,6 +650,8 @@ def setup_config():
     controller_external_port = find_available_port()
     websocket_port = find_available_port()
     websocket_external_port = find_available_port()
+    workbench1_port = find_available_port()
+    workbench1_external_port = find_available_port()
     git_httpd_port = find_available_port()
     git_httpd_external_port = find_available_port()
     keepproxy_port = find_available_port()
@@ -683,6 +687,12 @@ def setup_config():
                 "http://%s:%s"%(localhost, websocket_port): {},
             },
         },
+        "Workbench1": {
+            "ExternalURL": "https://%s:%s/" % (localhost, workbench1_external_port),
+            "InternalURLs": {
+                "http://%s:%s"%(localhost, workbench1_port): {},
+            },
+        },
         "GitHTTP": {
             "ExternalURL": "https://%s:%s" % (localhost, git_httpd_external_port),
             "InternalURLs": {

commit 8a471f18f22f85e996d2ce7110e7848aadfab44b
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Thu Feb 13 19:56:59 2020 -0500

    15954: Add -controller-address flag.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/boot/cmd.go b/lib/boot/cmd.go
index 7ce876805..c288bab75 100644
--- a/lib/boot/cmd.go
+++ b/lib/boot/cmd.go
@@ -18,7 +18,6 @@ import (
 	"os/exec"
 	"os/signal"
 	"path/filepath"
-	"strconv"
 	"strings"
 	"sync"
 	"syscall"
@@ -82,6 +81,7 @@ func (bootCommand) RunCommand(prog string, args []string, stdin io.Reader, stdou
 	flags.StringVar(&boot.SourcePath, "source", ".", "arvados source tree `directory`")
 	flags.StringVar(&boot.LibPath, "lib", "/var/lib/arvados", "`directory` to install dependencies and library files")
 	flags.StringVar(&boot.ClusterType, "type", "production", "cluster `type`: development, test, or production")
+	flags.StringVar(&boot.ControllerAddr, "controller-address", ":0", "desired controller address, `host:port` or `:port`")
 	flags.BoolVar(&boot.OwnTemporaryDatabase, "own-temporary-database", false, "bring up a postgres server and create a temporary database")
 	err = flags.Parse(args)
 	if err == flag.ErrHelp {
@@ -111,6 +111,7 @@ type Booter struct {
 	SourcePath           string // e.g., /home/username/src/arvados
 	LibPath              string // e.g., /var/lib/arvados
 	ClusterType          string // e.g., production
+	ControllerAddr       string // e.g., 127.0.0.1:8000
 	OwnTemporaryDatabase bool
 	Stderr               io.Writer
 
@@ -431,7 +432,37 @@ func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger)
 	if err != nil {
 		return err
 	}
-	port := 9000
+	usedPort := map[string]bool{}
+	nextPort := func() string {
+		for {
+			port, err := availablePort(":0")
+			if err != nil {
+				panic(err)
+			}
+			if usedPort[port] {
+				continue
+			}
+			usedPort[port] = true
+			return port
+		}
+	}
+	if cluster.Services.Controller.ExternalURL.Host == "" {
+		h, p, err := net.SplitHostPort(boot.ControllerAddr)
+		if err != nil {
+			return err
+		}
+		if h == "" {
+			h = "localhost"
+		}
+		if p == "0" {
+			p, err = availablePort(":0")
+			if err != nil {
+				return err
+			}
+			usedPort[p] = true
+		}
+		cluster.Services.Controller.ExternalURL = arvados.URL{Scheme: "https", Host: net.JoinHostPort(h, p)}
+	}
 	for _, svc := range []*arvados.Service{
 		&cluster.Services.Controller,
 		&cluster.Services.DispatchCloud,
@@ -448,12 +479,6 @@ func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger)
 		if svc == &cluster.Services.DispatchCloud && boot.ClusterType == "test" {
 			continue
 		}
-		if len(svc.InternalURLs) == 0 {
-			port++
-			svc.InternalURLs = map[arvados.URL]arvados.ServiceInstance{
-				arvados.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", port)}: arvados.ServiceInstance{},
-			}
-		}
 		if svc.ExternalURL.Host == "" && (svc == &cluster.Services.Controller ||
 			svc == &cluster.Services.GitHTTP ||
 			svc == &cluster.Services.Keepproxy ||
@@ -461,8 +486,12 @@ func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger)
 			svc == &cluster.Services.WebDAVDownload ||
 			svc == &cluster.Services.Websocket ||
 			svc == &cluster.Services.Workbench1) {
-			port++
-			svc.ExternalURL = arvados.URL{Scheme: "https", Host: fmt.Sprintf("localhost:%d", port)}
+			svc.ExternalURL = arvados.URL{Scheme: "https", Host: fmt.Sprintf("localhost:%s", nextPort())}
+		}
+		if len(svc.InternalURLs) == 0 {
+			svc.InternalURLs = map[arvados.URL]arvados.ServiceInstance{
+				arvados.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%s", nextPort())}: arvados.ServiceInstance{},
+			}
 		}
 	}
 	if cluster.SystemRootToken == "" {
@@ -489,8 +518,7 @@ func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger)
 	}
 	if boot.ClusterType == "test" {
 		// Add a second keepstore process.
-		port++
-		cluster.Services.Keepstore.InternalURLs[arvados.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", port)}] = arvados.ServiceInstance{}
+		cluster.Services.Keepstore.InternalURLs[arvados.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%s", nextPort())}] = arvados.ServiceInstance{}
 
 		// Create a directory-backed volume for each keepstore
 		// process.
@@ -514,14 +542,10 @@ func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger)
 		}
 	}
 	if boot.OwnTemporaryDatabase {
-		p, err := availablePort()
-		if err != nil {
-			return err
-		}
 		cluster.PostgreSQL.Connection = arvados.PostgreSQLConnection{
 			"client_encoding": "utf8",
 			"host":            "localhost",
-			"port":            strconv.Itoa(p),
+			"port":            nextPort(),
 			"dbname":          "arvados_test",
 			"user":            "arvados",
 			"password":        "insecure_arvados_test",
@@ -568,17 +592,17 @@ func externalPort(svc arvados.Service) (string, error) {
 	}
 }
 
-func availablePort() (int, error) {
-	ln, err := net.Listen("tcp", ":0")
+func availablePort(addr string) (string, error) {
+	ln, err := net.Listen("tcp", addr)
 	if err != nil {
-		return 0, err
+		return "", err
 	}
 	defer ln.Close()
-	_, p, err := net.SplitHostPort(ln.Addr().String())
+	_, port, err := net.SplitHostPort(ln.Addr().String())
 	if err != nil {
-		return 0, err
+		return "", err
 	}
-	return strconv.Atoi(p)
+	return port, nil
 }
 
 // Try to connect to addr until it works, then close ch. Give up if

commit 7a24a37aa9e5ed425550403b68c270316a24d772
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Thu Feb 13 19:56:48 2020 -0500

    15954: Change components to tasks. Add rake db:setup.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/boot/cert.go b/lib/boot/cert.go
index 011f418e9..560579b77 100644
--- a/lib/boot/cert.go
+++ b/lib/boot/cert.go
@@ -10,7 +10,13 @@ import (
 	"path/filepath"
 )
 
-func createCertificates(ctx context.Context, boot *Booter, ready chan<- bool) error {
+type createCertificates struct{}
+
+func (createCertificates) String() string {
+	return "certificates"
+}
+
+func (createCertificates) Run(ctx context.Context, fail func(error), boot *Booter) error {
 	// Generate root key
 	err := boot.RunProgram(ctx, boot.tempdir, nil, nil, "openssl", "genrsa", "-out", "rootCA.key", "4096")
 	if err != nil {
@@ -48,8 +54,5 @@ subjectAltName=DNS:localhost,DNS:localhost.localdomain
 	if err != nil {
 		return err
 	}
-
-	close(ready)
-	<-ctx.Done()
 	return nil
 }
diff --git a/lib/boot/cmd.go b/lib/boot/cmd.go
index 93f6ee0a8..7ce876805 100644
--- a/lib/boot/cmd.go
+++ b/lib/boot/cmd.go
@@ -36,6 +36,16 @@ import (
 
 var Command cmd.Handler = bootCommand{}
 
+type bootTask interface {
+	// Execute the task. Run should return nil when the task is
+	// done enough to satisfy a dependency relationship (e.g., the
+	// service is running and ready). If the task starts a
+	// goroutine that fails after Run returns (e.g., the service
+	// shuts down), it should call cancel.
+	Run(ctx context.Context, fail func(error), boot *Booter) error
+	String() string
+}
+
 type bootCommand struct{}
 
 func (bootCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
@@ -111,6 +121,7 @@ type Booter struct {
 	cancel        context.CancelFunc
 	done          chan struct{}
 	healthChecker *health.Aggregator
+	tasksReady    map[string]chan bool
 
 	tempdir    string
 	configfile string
@@ -214,48 +225,68 @@ func (boot *Booter) run(loader *config.Loader) error {
 		return err
 	}
 
-	var wg sync.WaitGroup
-	components := map[string]*component{
-		"certificates":  &component{runFunc: createCertificates},
-		"database":      &component{runFunc: runPostgres, depends: []string{"certificates"}},
-		"nginx":         &component{runFunc: runNginx},
-		"controller":    &component{cmdHandler: controller.Command, depends: []string{"database"}},
-		"dispatchcloud": &component{cmdHandler: dispatchcloud.Command, notIfTest: true},
-		"git-httpd":     &component{goProg: "services/arv-git-httpd"},
-		"health":        &component{goProg: "services/health"},
-		"keep-balance":  &component{goProg: "services/keep-balance", notIfTest: true},
-		"keepproxy":     &component{goProg: "services/keepproxy"},
-		"keepstore":     &component{goProg: "services/keepstore", svc: boot.cluster.Services.Keepstore},
-		"keep-web":      &component{goProg: "services/keep-web"},
-		"railsAPI":      &component{svc: boot.cluster.Services.RailsAPI, railsApp: "services/api", depends: []string{"database"}},
-		"workbench1":    &component{svc: boot.cluster.Services.Workbench1, railsApp: "apps/workbench"},
-		"ws":            &component{goProg: "services/ws", depends: []string{"database"}},
-	}
-	for _, cmpt := range components {
-		cmpt.ready = make(chan bool)
-	}
-	for name, cmpt := range components {
-		name, cmpt := name, cmpt
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			defer boot.cancel()
-			for _, dep := range cmpt.depends {
-				boot.logger.WithField("component", name).WithField("dependency", dep).Info("waiting")
-				select {
-				case <-components[dep].ready:
-				case <-boot.ctx.Done():
-					return
-				}
+	tasks := []bootTask{
+		createCertificates{},
+		runPostgreSQL{},
+		runNginx{},
+		runServiceCommand{name: "controller", command: controller.Command, depends: []bootTask{runPostgreSQL{}}},
+		runGoProgram{src: "services/arv-git-httpd"},
+		runGoProgram{src: "services/health"},
+		runGoProgram{src: "services/keepproxy"},
+		runGoProgram{src: "services/keepstore", svc: boot.cluster.Services.Keepstore},
+		runGoProgram{src: "services/keep-web"},
+		runGoProgram{src: "services/ws", depends: []bootTask{runPostgreSQL{}}},
+		installPassenger{src: "services/api"},
+		runPassenger{src: "services/api", svc: boot.cluster.Services.RailsAPI, depends: []bootTask{createCertificates{}, runPostgreSQL{}, installPassenger{src: "services/api"}}},
+		installPassenger{src: "apps/workbench"},
+		runPassenger{src: "apps/workbench", svc: boot.cluster.Services.Workbench1, depends: []bootTask{installPassenger{src: "apps/workbench"}}},
+		seedDatabase{},
+	}
+	if boot.ClusterType != "test" {
+		tasks = append(tasks,
+			runServiceCommand{name: "dispatchcloud", command: dispatchcloud.Command},
+			runGoProgram{src: "services/keep-balance"},
+		)
+	}
+	boot.tasksReady = map[string]chan bool{}
+	for _, task := range tasks {
+		boot.tasksReady[task.String()] = make(chan bool)
+	}
+	for _, task := range tasks {
+		task := task
+		fail := func(err error) {
+			if boot.ctx.Err() != nil {
+				return
 			}
-			boot.logger.WithField("component", name).Info("starting")
-			err := cmpt.Run(boot.ctx, name, boot)
-			if err != nil && err != context.Canceled {
-				boot.logger.WithError(err).WithField("component", name).Error("exited")
+			boot.cancel()
+			boot.logger.WithField("task", task).WithError(err).Error("task failed")
+		}
+		go func() {
+			boot.logger.WithField("task", task).Info("starting")
+			err := task.Run(boot.ctx, fail, boot)
+			if err != nil {
+				fail(err)
+				return
 			}
+			close(boot.tasksReady[task.String()])
 		}()
 	}
-	wg.Wait()
+	return boot.wait(boot.ctx, tasks...)
+}
+
+func (boot *Booter) wait(ctx context.Context, tasks ...bootTask) error {
+	for _, task := range tasks {
+		ch, ok := boot.tasksReady[task.String()]
+		if !ok {
+			return fmt.Errorf("no such task: %s", task)
+		}
+		boot.logger.WithField("task", task).Info("waiting")
+		select {
+		case <-ch:
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
 	return nil
 }
 
@@ -395,116 +426,6 @@ func (boot *Booter) RunProgram(ctx context.Context, dir string, output io.Writer
 	return nil
 }
 
-type component struct {
-	name       string
-	svc        arvados.Service
-	cmdHandler cmd.Handler
-	runFunc    func(ctx context.Context, boot *Booter, ready chan<- bool) error
-	railsApp   string   // source dir in arvados tree, e.g., "services/api"
-	goProg     string   // source dir in arvados tree, e.g., "services/keepstore"
-	notIfTest  bool     // don't run this component on a test cluster
-	depends    []string // don't start until all of these components are ready
-
-	ready chan bool
-}
-
-func (cmpt *component) Run(ctx context.Context, name string, boot *Booter) error {
-	if cmpt.notIfTest && boot.ClusterType == "test" {
-		fmt.Fprintf(boot.Stderr, "skipping component %q in %s mode\n", name, boot.ClusterType)
-		<-ctx.Done()
-		return nil
-	}
-	fmt.Fprintf(boot.Stderr, "starting component %q\n", name)
-	if cmpt.cmdHandler != nil {
-		errs := make(chan error, 1)
-		go func() {
-			defer close(errs)
-			exitcode := cmpt.cmdHandler.RunCommand(name, []string{"-config", boot.configfile}, bytes.NewBuffer(nil), boot.Stderr, boot.Stderr)
-			if exitcode != 0 {
-				errs <- fmt.Errorf("exit code %d", exitcode)
-			}
-		}()
-		select {
-		case err := <-errs:
-			return err
-		case <-ctx.Done():
-			// cmpt.cmdHandler.RunCommand() doesn't have
-			// access to our context, so it won't shut
-			// down by itself. We just abandon it.
-			return nil
-		}
-	}
-	if cmpt.goProg != "" {
-		boot.RunProgram(ctx, cmpt.goProg, nil, nil, "go", "install")
-		if ctx.Err() != nil {
-			return nil
-		}
-		_, basename := filepath.Split(cmpt.goProg)
-		if len(cmpt.svc.InternalURLs) > 0 {
-			// Run one for each URL
-			var wg sync.WaitGroup
-			for u := range cmpt.svc.InternalURLs {
-				u := u
-				wg.Add(1)
-				go func() {
-					defer wg.Done()
-					boot.RunProgram(ctx, boot.tempdir, nil, []string{"ARVADOS_SERVICE_INTERNAL_URL=" + u.String()}, basename)
-				}()
-			}
-			wg.Wait()
-		} else {
-			// Just run one
-			boot.RunProgram(ctx, boot.tempdir, nil, nil, basename)
-		}
-		return nil
-	}
-	if cmpt.runFunc != nil {
-		return cmpt.runFunc(ctx, boot, cmpt.ready)
-	}
-	if cmpt.railsApp != "" {
-		port, err := internalPort(cmpt.svc)
-		if err != nil {
-			return fmt.Errorf("bug: no InternalURLs for component %q: %v", name, cmpt.svc.InternalURLs)
-		}
-		var buf bytes.Buffer
-		err = boot.RunProgram(ctx, cmpt.railsApp, &buf, nil, "gem", "list", "--details", "bundler")
-		if err != nil {
-			return err
-		}
-		for _, version := range []string{"1.11.0", "1.17.3", "2.0.2"} {
-			if !strings.Contains(buf.String(), "("+version+")") {
-				err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "gem", "install", "--user", "bundler:1.11", "bundler:1.17.3", "bundler:2.0.2")
-				if err != nil {
-					return err
-				}
-				break
-			}
-		}
-		err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "bundle", "install", "--jobs", "4", "--path", filepath.Join(os.Getenv("HOME"), ".gem"))
-		if err != nil {
-			return err
-		}
-		err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "bundle", "exec", "passenger-config", "build-native-support")
-		if err != nil {
-			return err
-		}
-		err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "bundle", "exec", "passenger-config", "install-standalone-runtime")
-		if err != nil {
-			return err
-		}
-		err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "bundle", "exec", "passenger-config", "validate-install")
-		if err != nil {
-			return err
-		}
-		err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "bundle", "exec", "passenger", "start", "-p", port)
-		if err != nil {
-			return err
-		}
-		return nil
-	}
-	return fmt.Errorf("bug: component %q has nothing to run", name)
-}
-
 func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger) error {
 	cluster, err := cfg.GetCluster("")
 	if err != nil {
@@ -662,7 +583,7 @@ func availablePort() (int, error) {
 
 // Try to connect to addr until it works, then close ch. Give up if
 // ctx cancels.
-func connectAndClose(ctx context.Context, addr string, ch chan<- bool) {
+func waitForConnect(ctx context.Context, addr string) error {
 	dialer := net.Dialer{Timeout: time.Second}
 	for ctx.Err() == nil {
 		conn, err := dialer.DialContext(ctx, "tcp", addr)
@@ -671,7 +592,7 @@ func connectAndClose(ctx context.Context, addr string, ch chan<- bool) {
 			continue
 		}
 		conn.Close()
-		close(ch)
-		return
+		return nil
 	}
+	return ctx.Err()
 }
diff --git a/lib/boot/nginx.go b/lib/boot/nginx.go
index 2df5e90b3..b5b712af6 100644
--- a/lib/boot/nginx.go
+++ b/lib/boot/nginx.go
@@ -16,7 +16,13 @@ import (
 	"git.arvados.org/arvados.git/sdk/go/arvados"
 )
 
-func runNginx(ctx context.Context, boot *Booter, ready chan<- bool) error {
+type runNginx struct{}
+
+func (runNginx) String() string {
+	return "nginx"
+}
+
+func (runNginx) Run(ctx context.Context, fail func(error), boot *Booter) error {
 	vars := map[string]string{
 		"SSLCERT":   filepath.Join(boot.SourcePath, "services", "api", "tmp", "self-signed.pem"), // TODO: root ca
 		"SSLKEY":    filepath.Join(boot.SourcePath, "services", "api", "tmp", "self-signed.key"), // TODO: root ca
@@ -69,9 +75,11 @@ func runNginx(ctx context.Context, boot *Booter, ready chan<- bool) error {
 			}
 		}
 	}
-	go connectAndClose(ctx, boot.cluster.Services.Controller.ExternalURL.Host, ready)
-	return boot.RunProgram(ctx, ".", nil, nil, nginx,
-		"-g", "error_log stderr info;",
-		"-g", "pid "+filepath.Join(boot.tempdir, "nginx.pid")+";",
-		"-c", conffile)
+	go func() {
+		fail(boot.RunProgram(ctx, ".", nil, nil, nginx,
+			"-g", "error_log stderr info;",
+			"-g", "pid "+filepath.Join(boot.tempdir, "nginx.pid")+";",
+			"-c", conffile))
+	}()
+	return waitForConnect(ctx, boot.cluster.Services.Controller.ExternalURL.Host)
 }
diff --git a/lib/boot/passenger.go b/lib/boot/passenger.go
new file mode 100644
index 000000000..b67370e1e
--- /dev/null
+++ b/lib/boot/passenger.go
@@ -0,0 +1,90 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package boot
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"git.arvados.org/arvados.git/sdk/go/arvados"
+)
+
+type installPassenger struct {
+	src     string
+	depends []bootTask
+}
+
+func (runner installPassenger) String() string {
+	return "install " + runner.src
+}
+
+func (runner installPassenger) Run(ctx context.Context, fail func(error), boot *Booter) error {
+	err := boot.wait(ctx, runner.depends...)
+	if err != nil {
+		return err
+	}
+	var buf bytes.Buffer
+	err = boot.RunProgram(ctx, runner.src, &buf, nil, "gem", "list", "--details", "bundler")
+	if err != nil {
+		return err
+	}
+	for _, version := range []string{"1.11.0", "1.17.3", "2.0.2"} {
+		if !strings.Contains(buf.String(), "("+version+")") {
+			err = boot.RunProgram(ctx, runner.src, nil, nil, "gem", "install", "--user", "bundler:1.11", "bundler:1.17.3", "bundler:2.0.2")
+			if err != nil {
+				return err
+			}
+			break
+		}
+	}
+	err = boot.RunProgram(ctx, runner.src, nil, nil, "bundle", "install", "--jobs", "4", "--path", filepath.Join(os.Getenv("HOME"), ".gem"))
+	if err != nil {
+		return err
+	}
+	err = boot.RunProgram(ctx, runner.src, nil, nil, "bundle", "exec", "passenger-config", "build-native-support")
+	if err != nil {
+		return err
+	}
+	err = boot.RunProgram(ctx, runner.src, nil, nil, "bundle", "exec", "passenger-config", "install-standalone-runtime")
+	if err != nil {
+		return err
+	}
+	err = boot.RunProgram(ctx, runner.src, nil, nil, "bundle", "exec", "passenger-config", "validate-install")
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+type runPassenger struct {
+	src     string
+	svc     arvados.Service
+	depends []bootTask
+}
+
+func (runner runPassenger) String() string {
+	_, basename := filepath.Split(runner.src)
+	return basename
+}
+
+func (runner runPassenger) Run(ctx context.Context, fail func(error), boot *Booter) error {
+	err := boot.wait(ctx, runner.depends...)
+	if err != nil {
+		return err
+	}
+	port, err := internalPort(runner.svc)
+	if err != nil {
+		return fmt.Errorf("bug: no InternalURLs for component %q: %v", runner, runner.svc.InternalURLs)
+	}
+	err = boot.RunProgram(ctx, runner.src, nil, nil, "bundle", "exec", "passenger", "start", "-p", port)
+	if err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/lib/boot/postgresql.go b/lib/boot/postgresql.go
index 86328e110..48e24ffae 100644
--- a/lib/boot/postgresql.go
+++ b/lib/boot/postgresql.go
@@ -8,6 +8,7 @@ import (
 	"bytes"
 	"context"
 	"database/sql"
+	"fmt"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -18,9 +19,20 @@ import (
 	"github.com/lib/pq"
 )
 
-func runPostgres(ctx context.Context, boot *Booter, ready chan<- bool) error {
+type runPostgreSQL struct{}
+
+func (runPostgreSQL) String() string {
+	return "postgresql"
+}
+
+func (runPostgreSQL) Run(ctx context.Context, fail func(error), boot *Booter) error {
+	err := boot.wait(ctx, createCertificates{})
+	if err != nil {
+		return err
+	}
+
 	buf := bytes.NewBuffer(nil)
-	err := boot.RunProgram(ctx, boot.tempdir, buf, nil, "pg_config", "--bindir")
+	err = boot.RunProgram(ctx, boot.tempdir, buf, nil, "pg_config", "--bindir")
 	if err != nil {
 		return err
 	}
@@ -44,57 +56,45 @@ func runPostgres(ctx context.Context, boot *Booter, ready chan<- bool) error {
 
 	port := boot.cluster.PostgreSQL.Connection["port"]
 
-	ctx, cancel := context.WithCancel(ctx)
-	defer cancel()
-
 	go func() {
-		for {
-			if ctx.Err() != nil {
-				return
-			}
-			if exec.CommandContext(ctx, "pg_isready", "--timeout=10", "--host="+boot.cluster.PostgreSQL.Connection["host"], "--port="+port).Run() == nil {
-				break
-			}
-			time.Sleep(time.Second / 2)
-		}
-		db, err := sql.Open("postgres", arvados.PostgreSQLConnection{
-			"host":   datadir,
-			"port":   port,
-			"dbname": "postgres",
-		}.String())
-		if err != nil {
-			boot.logger.WithError(err).Error("db open failed")
-			cancel()
-			return
-		}
-		defer db.Close()
-		conn, err := db.Conn(ctx)
-		if err != nil {
-			boot.logger.WithError(err).Error("db conn failed")
-			cancel()
-			return
-		}
-		defer conn.Close()
-		_, err = conn.ExecContext(ctx, `CREATE USER `+pq.QuoteIdentifier(boot.cluster.PostgreSQL.Connection["user"])+` WITH SUPERUSER ENCRYPTED PASSWORD `+pq.QuoteLiteral(boot.cluster.PostgreSQL.Connection["password"]))
-		if err != nil {
-			boot.logger.WithError(err).Error("createuser failed")
-			cancel()
-			return
-		}
-		_, err = conn.ExecContext(ctx, `CREATE DATABASE `+pq.QuoteIdentifier(boot.cluster.PostgreSQL.Connection["dbname"]))
-		if err != nil {
-			boot.logger.WithError(err).Error("createdb failed")
-			cancel()
-			return
-		}
-		close(ready)
-		return
+		fail(boot.RunProgram(ctx, boot.tempdir, nil, nil, filepath.Join(bindir, "postgres"),
+			"-l",          // enable ssl
+			"-D", datadir, // data dir
+			"-k", datadir, // socket dir
+			"-p", boot.cluster.PostgreSQL.Connection["port"],
+		))
 	}()
 
-	return boot.RunProgram(ctx, boot.tempdir, nil, nil, filepath.Join(bindir, "postgres"),
-		"-l",          // enable ssl
-		"-D", datadir, // data dir
-		"-k", datadir, // socket dir
-		"-p", boot.cluster.PostgreSQL.Connection["port"],
-	)
+	for {
+		if ctx.Err() != nil {
+			return ctx.Err()
+		}
+		if exec.CommandContext(ctx, "pg_isready", "--timeout=10", "--host="+boot.cluster.PostgreSQL.Connection["host"], "--port="+port).Run() == nil {
+			break
+		}
+		time.Sleep(time.Second / 2)
+	}
+	db, err := sql.Open("postgres", arvados.PostgreSQLConnection{
+		"host":   datadir,
+		"port":   port,
+		"dbname": "postgres",
+	}.String())
+	if err != nil {
+		return fmt.Errorf("db open failed: %s", err)
+	}
+	defer db.Close()
+	conn, err := db.Conn(ctx)
+	if err != nil {
+		return fmt.Errorf("db conn failed: %s", err)
+	}
+	defer conn.Close()
+	_, err = conn.ExecContext(ctx, `CREATE USER `+pq.QuoteIdentifier(boot.cluster.PostgreSQL.Connection["user"])+` WITH SUPERUSER ENCRYPTED PASSWORD `+pq.QuoteLiteral(boot.cluster.PostgreSQL.Connection["password"]))
+	if err != nil {
+		return fmt.Errorf("createuser failed: %s", err)
+	}
+	_, err = conn.ExecContext(ctx, `CREATE DATABASE `+pq.QuoteIdentifier(boot.cluster.PostgreSQL.Connection["dbname"]))
+	if err != nil {
+		return fmt.Errorf("createdb failed: %s", err)
+	}
+	return nil
 }
diff --git a/lib/boot/seed.go b/lib/boot/seed.go
new file mode 100644
index 000000000..f915764c5
--- /dev/null
+++ b/lib/boot/seed.go
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package boot
+
+import (
+	"context"
+)
+
+type seedDatabase struct{}
+
+func (seedDatabase) String() string {
+	return "seedDatabase"
+}
+
+func (seedDatabase) Run(ctx context.Context, fail func(error), boot *Booter) error {
+	err := boot.wait(ctx, runPostgreSQL{})
+	if err != nil {
+		return err
+	}
+	err = boot.RunProgram(ctx, "services/api", nil, nil, "bundle", "exec", "rake", "db:setup")
+	if err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/lib/boot/service.go b/lib/boot/service.go
new file mode 100644
index 000000000..9672dccc4
--- /dev/null
+++ b/lib/boot/service.go
@@ -0,0 +1,72 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package boot
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"path/filepath"
+
+	"git.arvados.org/arvados.git/lib/cmd"
+	"git.arvados.org/arvados.git/sdk/go/arvados"
+)
+
+type runServiceCommand struct {
+	name    string
+	command cmd.Handler
+	depends []bootTask
+}
+
+func (runner runServiceCommand) String() string {
+	return runner.name
+}
+
+func (runner runServiceCommand) Run(ctx context.Context, fail func(error), boot *Booter) error {
+	boot.wait(ctx, runner.depends...)
+	go func() {
+		// runner.command.RunCommand() doesn't have access to
+		// ctx, so it can't shut down by itself when the
+		// caller cancels. We just abandon it.
+		exitcode := runner.command.RunCommand(runner.name, []string{"-config", boot.configfile}, bytes.NewBuffer(nil), boot.Stderr, boot.Stderr)
+		fail(fmt.Errorf("exit code %d", exitcode))
+	}()
+	return nil
+}
+
+type runGoProgram struct {
+	src     string
+	svc     arvados.Service
+	depends []bootTask
+}
+
+func (runner runGoProgram) String() string {
+	_, basename := filepath.Split(runner.src)
+	return basename
+}
+
+func (runner runGoProgram) Run(ctx context.Context, fail func(error), boot *Booter) error {
+	boot.wait(ctx, runner.depends...)
+	boot.RunProgram(ctx, runner.src, nil, nil, "go", "install")
+	if ctx.Err() != nil {
+		return ctx.Err()
+	}
+	_, basename := filepath.Split(runner.src)
+	if len(runner.svc.InternalURLs) > 0 {
+		// Run one for each URL
+		for u := range runner.svc.InternalURLs {
+			u := u
+			go func() {
+				fail(boot.RunProgram(ctx, boot.tempdir, nil, []string{"ARVADOS_SERVICE_INTERNAL_URL=" + u.String()}, basename))
+			}()
+		}
+	} else {
+		// Just run one
+		go func() {
+			fail(boot.RunProgram(ctx, boot.tempdir, nil, nil, basename))
+		}()
+	}
+	return nil
+}

commit 7abc7ca38954acd4eaa53c9280504e06a76b8d71
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Wed Feb 12 09:12:02 2020 -0500

    15954: Start own postgresql server.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/doc/examples/config/zzzzz.yml b/doc/examples/config/zzzzz.yml
index 9e3d718ed..c63550edf 100644
--- a/doc/examples/config/zzzzz.yml
+++ b/doc/examples/config/zzzzz.yml
@@ -1,12 +1,5 @@
 Clusters:
   zzzzz:
-    PostgreSQL:
-      Connection:
-        client_encoding: utf8
-        host: localhost
-        dbname: arvados_test
-        user: arvados
-        password: insecure_arvados_test
     ManagementToken: e687950a23c3a9bceec28c6223a06c79
     SystemRootToken: systemusertesttoken1234567890aoeuidhtnsqjkxbmwvzpy
     API:
diff --git a/go.mod b/go.mod
index 2e16e5a0f..85e5552f6 100644
--- a/go.mod
+++ b/go.mod
@@ -31,7 +31,7 @@ require (
 	github.com/jmcvetta/randutil v0.0.0-20150817122601-2bb1b664bcff
 	github.com/julienschmidt/httprouter v1.2.0
 	github.com/kevinburke/ssh_config v0.0.0-20171013211458-802051befeb5 // indirect
-	github.com/lib/pq v0.0.0-20171126050459-83612a56d3dd
+	github.com/lib/pq v1.3.0
 	github.com/marstr/guid v1.1.1-0.20170427235115-8bdf7d1a087c // indirect
 	github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747 // indirect
 	github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
diff --git a/go.sum b/go.sum
index d7a022dda..6c2323a31 100644
--- a/go.sum
+++ b/go.sum
@@ -111,6 +111,8 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/lib/pq v0.0.0-20171126050459-83612a56d3dd h1:2RDaVc4/izhWyAvYxNm8c9saSyCDIxefNwOcqaH7pcU=
 github.com/lib/pq v0.0.0-20171126050459-83612a56d3dd/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
+github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/marstr/guid v1.1.1-0.20170427235115-8bdf7d1a087c h1:ouxemItv3B/Zh008HJkEXDYCN3BIRyNHxtUN7ThJ5Js=
 github.com/marstr/guid v1.1.1-0.20170427235115-8bdf7d1a087c/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
diff --git a/lib/boot/cert.go b/lib/boot/cert.go
new file mode 100644
index 000000000..011f418e9
--- /dev/null
+++ b/lib/boot/cert.go
@@ -0,0 +1,55 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package boot
+
+import (
+	"context"
+	"io/ioutil"
+	"path/filepath"
+)
+
+func createCertificates(ctx context.Context, boot *Booter, ready chan<- bool) error {
+	// Generate root key
+	err := boot.RunProgram(ctx, boot.tempdir, nil, nil, "openssl", "genrsa", "-out", "rootCA.key", "4096")
+	if err != nil {
+		return err
+	}
+	// Generate a self-signed root certificate
+	err = boot.RunProgram(ctx, boot.tempdir, nil, nil, "openssl", "req", "-x509", "-new", "-nodes", "-key", "rootCA.key", "-sha256", "-days", "3650", "-out", "rootCA.crt", "-subj", "/C=US/ST=MA/O=Example Org/CN=localhost")
+	if err != nil {
+		return err
+	}
+	// Generate server key
+	err = boot.RunProgram(ctx, boot.tempdir, nil, nil, "openssl", "genrsa", "-out", "server.key", "2048")
+	if err != nil {
+		return err
+	}
+	// Build config file for signing request
+	defaultconf, err := ioutil.ReadFile("/etc/ssl/openssl.cnf")
+	if err != nil {
+		return err
+	}
+	err = ioutil.WriteFile(filepath.Join(boot.tempdir, "server.cfg"), append(defaultconf, []byte(`
+[SAN]
+subjectAltName=DNS:localhost,DNS:localhost.localdomain
+`)...), 0777)
+	if err != nil {
+		return err
+	}
+	// Generate signing request
+	err = boot.RunProgram(ctx, boot.tempdir, nil, nil, "openssl", "req", "-new", "-sha256", "-key", "server.key", "-subj", "/C=US/ST=MA/O=Example Org/CN=localhost", "-reqexts", "SAN", "-config", "server.cfg", "-out", "server.csr")
+	if err != nil {
+		return err
+	}
+	// Sign certificate
+	err = boot.RunProgram(ctx, boot.tempdir, nil, nil, "openssl", "x509", "-req", "-in", "server.csr", "-CA", "rootCA.crt", "-CAkey", "rootCA.key", "-CAcreateserial", "-out", "server.crt", "-days", "3650", "-sha256")
+	if err != nil {
+		return err
+	}
+
+	close(ready)
+	<-ctx.Done()
+	return nil
+}
diff --git a/lib/boot/cmd.go b/lib/boot/cmd.go
index 4d2c01f2c..93f6ee0a8 100644
--- a/lib/boot/cmd.go
+++ b/lib/boot/cmd.go
@@ -18,6 +18,7 @@ import (
 	"os/exec"
 	"os/signal"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"sync"
 	"syscall"
@@ -71,6 +72,7 @@ func (bootCommand) RunCommand(prog string, args []string, stdin io.Reader, stdou
 	flags.StringVar(&boot.SourcePath, "source", ".", "arvados source tree `directory`")
 	flags.StringVar(&boot.LibPath, "lib", "/var/lib/arvados", "`directory` to install dependencies and library files")
 	flags.StringVar(&boot.ClusterType, "type", "production", "cluster `type`: development, test, or production")
+	flags.BoolVar(&boot.OwnTemporaryDatabase, "own-temporary-database", false, "bring up a postgres server and create a temporary database")
 	err = flags.Parse(args)
 	if err == flag.ErrHelp {
 		err = nil
@@ -96,10 +98,11 @@ func (bootCommand) RunCommand(prog string, args []string, stdin io.Reader, stdou
 }
 
 type Booter struct {
-	SourcePath  string // e.g., /home/username/src/arvados
-	LibPath     string // e.g., /var/lib/arvados
-	ClusterType string // e.g., production
-	Stderr      io.Writer
+	SourcePath           string // e.g., /home/username/src/arvados
+	LibPath              string // e.g., /var/lib/arvados
+	ClusterType          string // e.g., production
+	OwnTemporaryDatabase bool
+	Stderr               io.Writer
 
 	logger  logrus.FieldLogger
 	cluster *arvados.Cluster
@@ -212,29 +215,43 @@ func (boot *Booter) run(loader *config.Loader) error {
 	}
 
 	var wg sync.WaitGroup
-	for _, cmpt := range []component{
-		{name: "nginx", runFunc: runNginx},
-		{name: "controller", cmdHandler: controller.Command},
-		{name: "dispatchcloud", cmdHandler: dispatchcloud.Command, notIfTest: true},
-		{name: "git-httpd", goProg: "services/arv-git-httpd"},
-		{name: "health", goProg: "services/health"},
-		{name: "keep-balance", goProg: "services/keep-balance", notIfTest: true},
-		{name: "keepproxy", goProg: "services/keepproxy"},
-		{name: "keepstore", goProg: "services/keepstore", svc: boot.cluster.Services.Keepstore},
-		{name: "keep-web", goProg: "services/keep-web"},
-		{name: "railsAPI", svc: boot.cluster.Services.RailsAPI, railsApp: "services/api"},
-		{name: "workbench1", svc: boot.cluster.Services.Workbench1, railsApp: "apps/workbench"},
-		{name: "ws", goProg: "services/ws"},
-	} {
-		cmpt := cmpt
+	components := map[string]*component{
+		"certificates":  &component{runFunc: createCertificates},
+		"database":      &component{runFunc: runPostgres, depends: []string{"certificates"}},
+		"nginx":         &component{runFunc: runNginx},
+		"controller":    &component{cmdHandler: controller.Command, depends: []string{"database"}},
+		"dispatchcloud": &component{cmdHandler: dispatchcloud.Command, notIfTest: true},
+		"git-httpd":     &component{goProg: "services/arv-git-httpd"},
+		"health":        &component{goProg: "services/health"},
+		"keep-balance":  &component{goProg: "services/keep-balance", notIfTest: true},
+		"keepproxy":     &component{goProg: "services/keepproxy"},
+		"keepstore":     &component{goProg: "services/keepstore", svc: boot.cluster.Services.Keepstore},
+		"keep-web":      &component{goProg: "services/keep-web"},
+		"railsAPI":      &component{svc: boot.cluster.Services.RailsAPI, railsApp: "services/api", depends: []string{"database"}},
+		"workbench1":    &component{svc: boot.cluster.Services.Workbench1, railsApp: "apps/workbench"},
+		"ws":            &component{goProg: "services/ws", depends: []string{"database"}},
+	}
+	for _, cmpt := range components {
+		cmpt.ready = make(chan bool)
+	}
+	for name, cmpt := range components {
+		name, cmpt := name, cmpt
 		wg.Add(1)
 		go func() {
 			defer wg.Done()
 			defer boot.cancel()
-			boot.logger.WithField("component", cmpt.name).Info("starting")
-			err := cmpt.Run(boot.ctx, boot)
+			for _, dep := range cmpt.depends {
+				boot.logger.WithField("component", name).WithField("dependency", dep).Info("waiting")
+				select {
+				case <-components[dep].ready:
+				case <-boot.ctx.Done():
+					return
+				}
+			}
+			boot.logger.WithField("component", name).Info("starting")
+			err := cmpt.Run(boot.ctx, name, boot)
 			if err != nil && err != context.Canceled {
-				boot.logger.WithError(err).WithField("component", cmpt.name).Error("exited")
+				boot.logger.WithError(err).WithField("component", name).Error("exited")
 			}
 		}()
 	}
@@ -382,24 +399,27 @@ type component struct {
 	name       string
 	svc        arvados.Service
 	cmdHandler cmd.Handler
-	runFunc    func(ctx context.Context, boot *Booter) error
-	railsApp   string // source dir in arvados tree, e.g., "services/api"
-	goProg     string // source dir in arvados tree, e.g., "services/keepstore"
-	notIfTest  bool   // don't run this component on a test cluster
+	runFunc    func(ctx context.Context, boot *Booter, ready chan<- bool) error
+	railsApp   string   // source dir in arvados tree, e.g., "services/api"
+	goProg     string   // source dir in arvados tree, e.g., "services/keepstore"
+	notIfTest  bool     // don't run this component on a test cluster
+	depends    []string // don't start until all of these components are ready
+
+	ready chan bool
 }
 
-func (cmpt *component) Run(ctx context.Context, boot *Booter) error {
+func (cmpt *component) Run(ctx context.Context, name string, boot *Booter) error {
 	if cmpt.notIfTest && boot.ClusterType == "test" {
-		fmt.Fprintf(boot.Stderr, "skipping component %q in %s mode\n", cmpt.name, boot.ClusterType)
+		fmt.Fprintf(boot.Stderr, "skipping component %q in %s mode\n", name, boot.ClusterType)
 		<-ctx.Done()
 		return nil
 	}
-	fmt.Fprintf(boot.Stderr, "starting component %q\n", cmpt.name)
+	fmt.Fprintf(boot.Stderr, "starting component %q\n", name)
 	if cmpt.cmdHandler != nil {
 		errs := make(chan error, 1)
 		go func() {
 			defer close(errs)
-			exitcode := cmpt.cmdHandler.RunCommand(cmpt.name, []string{"-config", boot.configfile}, bytes.NewBuffer(nil), boot.Stderr, boot.Stderr)
+			exitcode := cmpt.cmdHandler.RunCommand(name, []string{"-config", boot.configfile}, bytes.NewBuffer(nil), boot.Stderr, boot.Stderr)
 			if exitcode != 0 {
 				errs <- fmt.Errorf("exit code %d", exitcode)
 			}
@@ -439,12 +459,12 @@ func (cmpt *component) Run(ctx context.Context, boot *Booter) error {
 		return nil
 	}
 	if cmpt.runFunc != nil {
-		return cmpt.runFunc(ctx, boot)
+		return cmpt.runFunc(ctx, boot, cmpt.ready)
 	}
 	if cmpt.railsApp != "" {
 		port, err := internalPort(cmpt.svc)
 		if err != nil {
-			return fmt.Errorf("bug: no InternalURLs for component %q: %v", cmpt.name, cmpt.svc.InternalURLs)
+			return fmt.Errorf("bug: no InternalURLs for component %q: %v", name, cmpt.svc.InternalURLs)
 		}
 		var buf bytes.Buffer
 		err = boot.RunProgram(ctx, cmpt.railsApp, &buf, nil, "gem", "list", "--details", "bundler")
@@ -482,7 +502,7 @@ func (cmpt *component) Run(ctx context.Context, boot *Booter) error {
 		}
 		return nil
 	}
-	return fmt.Errorf("bug: component %q has nothing to run", cmpt.name)
+	return fmt.Errorf("bug: component %q has nothing to run", name)
 }
 
 func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger) error {
@@ -572,6 +592,21 @@ func (boot *Booter) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger)
 			}
 		}
 	}
+	if boot.OwnTemporaryDatabase {
+		p, err := availablePort()
+		if err != nil {
+			return err
+		}
+		cluster.PostgreSQL.Connection = arvados.PostgreSQLConnection{
+			"client_encoding": "utf8",
+			"host":            "localhost",
+			"port":            strconv.Itoa(p),
+			"dbname":          "arvados_test",
+			"user":            "arvados",
+			"password":        "insecure_arvados_test",
+		}
+	}
+
 	cfg.Clusters[cluster.ClusterID] = *cluster
 	return nil
 }
@@ -611,3 +646,32 @@ func externalPort(svc arvados.Service) (string, error) {
 		return "80", nil
 	}
 }
+
+func availablePort() (int, error) {
+	ln, err := net.Listen("tcp", ":0")
+	if err != nil {
+		return 0, err
+	}
+	defer ln.Close()
+	_, p, err := net.SplitHostPort(ln.Addr().String())
+	if err != nil {
+		return 0, err
+	}
+	return strconv.Atoi(p)
+}
+
+// Try to connect to addr until it works, then close ch. Give up if
+// ctx cancels.
+func connectAndClose(ctx context.Context, addr string, ch chan<- bool) {
+	dialer := net.Dialer{Timeout: time.Second}
+	for ctx.Err() == nil {
+		conn, err := dialer.DialContext(ctx, "tcp", addr)
+		if err != nil {
+			time.Sleep(time.Second / 10)
+			continue
+		}
+		conn.Close()
+		close(ch)
+		return
+	}
+}
diff --git a/lib/boot/nginx.go b/lib/boot/nginx.go
index 1b361dd9c..2df5e90b3 100644
--- a/lib/boot/nginx.go
+++ b/lib/boot/nginx.go
@@ -16,7 +16,7 @@ import (
 	"git.arvados.org/arvados.git/sdk/go/arvados"
 )
 
-func runNginx(ctx context.Context, boot *Booter) error {
+func runNginx(ctx context.Context, boot *Booter, ready chan<- bool) error {
 	vars := map[string]string{
 		"SSLCERT":   filepath.Join(boot.SourcePath, "services", "api", "tmp", "self-signed.pem"), // TODO: root ca
 		"SSLKEY":    filepath.Join(boot.SourcePath, "services", "api", "tmp", "self-signed.key"), // TODO: root ca
@@ -69,6 +69,7 @@ func runNginx(ctx context.Context, boot *Booter) error {
 			}
 		}
 	}
+	go connectAndClose(ctx, boot.cluster.Services.Controller.ExternalURL.Host, ready)
 	return boot.RunProgram(ctx, ".", nil, nil, nginx,
 		"-g", "error_log stderr info;",
 		"-g", "pid "+filepath.Join(boot.tempdir, "nginx.pid")+";",
diff --git a/lib/boot/postgresql.go b/lib/boot/postgresql.go
new file mode 100644
index 000000000..86328e110
--- /dev/null
+++ b/lib/boot/postgresql.go
@@ -0,0 +1,100 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package boot
+
+import (
+	"bytes"
+	"context"
+	"database/sql"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"git.arvados.org/arvados.git/sdk/go/arvados"
+	"github.com/lib/pq"
+)
+
+func runPostgres(ctx context.Context, boot *Booter, ready chan<- bool) error {
+	buf := bytes.NewBuffer(nil)
+	err := boot.RunProgram(ctx, boot.tempdir, buf, nil, "pg_config", "--bindir")
+	if err != nil {
+		return err
+	}
+	datadir := filepath.Join(boot.tempdir, "pgdata")
+
+	err = os.Mkdir(datadir, 0755)
+	if err != nil {
+		return err
+	}
+	bindir := strings.TrimSpace(buf.String())
+
+	err = boot.RunProgram(ctx, boot.tempdir, nil, nil, filepath.Join(bindir, "initdb"), "-D", datadir)
+	if err != nil {
+		return err
+	}
+
+	err = boot.RunProgram(ctx, boot.tempdir, nil, nil, "cp", "server.crt", "server.key", datadir)
+	if err != nil {
+		return err
+	}
+
+	port := boot.cluster.PostgreSQL.Connection["port"]
+
+	ctx, cancel := context.WithCancel(ctx)
+	defer cancel()
+
+	go func() {
+		for {
+			if ctx.Err() != nil {
+				return
+			}
+			if exec.CommandContext(ctx, "pg_isready", "--timeout=10", "--host="+boot.cluster.PostgreSQL.Connection["host"], "--port="+port).Run() == nil {
+				break
+			}
+			time.Sleep(time.Second / 2)
+		}
+		db, err := sql.Open("postgres", arvados.PostgreSQLConnection{
+			"host":   datadir,
+			"port":   port,
+			"dbname": "postgres",
+		}.String())
+		if err != nil {
+			boot.logger.WithError(err).Error("db open failed")
+			cancel()
+			return
+		}
+		defer db.Close()
+		conn, err := db.Conn(ctx)
+		if err != nil {
+			boot.logger.WithError(err).Error("db conn failed")
+			cancel()
+			return
+		}
+		defer conn.Close()
+		_, err = conn.ExecContext(ctx, `CREATE USER `+pq.QuoteIdentifier(boot.cluster.PostgreSQL.Connection["user"])+` WITH SUPERUSER ENCRYPTED PASSWORD `+pq.QuoteLiteral(boot.cluster.PostgreSQL.Connection["password"]))
+		if err != nil {
+			boot.logger.WithError(err).Error("createuser failed")
+			cancel()
+			return
+		}
+		_, err = conn.ExecContext(ctx, `CREATE DATABASE `+pq.QuoteIdentifier(boot.cluster.PostgreSQL.Connection["dbname"]))
+		if err != nil {
+			boot.logger.WithError(err).Error("createdb failed")
+			cancel()
+			return
+		}
+		close(ready)
+		return
+	}()
+
+	return boot.RunProgram(ctx, boot.tempdir, nil, nil, filepath.Join(bindir, "postgres"),
+		"-l",          // enable ssl
+		"-D", datadir, // data dir
+		"-k", datadir, // socket dir
+		"-p", boot.cluster.PostgreSQL.Connection["port"],
+	)
+}

commit b9fd7e3f374248a61159e4750a84e38d1c48d5dd
Merge: 0446c0a3a b01c43723
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Feb 11 11:39:44 2020 -0500

    15954: Merge branch 'master'
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>


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


hooks/post-receive
-- 




More information about the arvados-commits mailing list