15954: Fix missing return.
[arvados.git] / lib / boot / cmd.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package boot
6
7 import (
8         "bytes"
9         "context"
10         "crypto/rand"
11         "encoding/json"
12         "flag"
13         "fmt"
14         "io"
15         "io/ioutil"
16         "net"
17         "os"
18         "os/exec"
19         "os/signal"
20         "path/filepath"
21         "strings"
22         "sync"
23         "syscall"
24         "time"
25
26         "git.arvados.org/arvados.git/lib/cmd"
27         "git.arvados.org/arvados.git/lib/config"
28         "git.arvados.org/arvados.git/lib/controller"
29         "git.arvados.org/arvados.git/lib/dispatchcloud"
30         "git.arvados.org/arvados.git/sdk/go/arvados"
31         "git.arvados.org/arvados.git/sdk/go/ctxlog"
32         "git.arvados.org/arvados.git/sdk/go/health"
33         "github.com/sirupsen/logrus"
34 )
35
36 var Command cmd.Handler = &bootCommand{}
37
38 type bootCommand struct {
39         sourcePath  string // e.g., /home/username/src/arvados
40         libPath     string // e.g., /var/lib/arvados
41         clusterType string // e.g., production
42
43         cluster *arvados.Cluster
44         stdout  io.Writer
45         stderr  io.Writer
46
47         tempdir    string
48         configfile string
49         environ    []string // for child processes
50
51         setupRubyOnce sync.Once
52         setupRubyErr  error
53         goMutex       sync.Mutex
54 }
55
56 func (boot *bootCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
57         boot.stdout = stdout
58         boot.stderr = stderr
59         log := ctxlog.New(stderr, "json", "info")
60
61         var err error
62         defer func() {
63                 if err != nil {
64                         log.WithError(err).Info("exiting")
65                 }
66         }()
67
68         flags := flag.NewFlagSet(prog, flag.ContinueOnError)
69         flags.SetOutput(stderr)
70         loader := config.NewLoader(stdin, log)
71         loader.SetupFlags(flags)
72         versionFlag := flags.Bool("version", false, "Write version information to stdout and exit 0")
73         flags.StringVar(&boot.sourcePath, "source", ".", "arvados source tree `directory`")
74         flags.StringVar(&boot.libPath, "lib", "/var/lib/arvados", "`directory` to install dependencies and library files")
75         flags.StringVar(&boot.clusterType, "type", "production", "cluster `type`: development, test, or production")
76         err = flags.Parse(args)
77         if err == flag.ErrHelp {
78                 err = nil
79                 return 0
80         } else if err != nil {
81                 return 2
82         } else if *versionFlag {
83                 return cmd.Version.RunCommand(prog, args, stdin, stdout, stderr)
84         } else if boot.clusterType != "development" && boot.clusterType != "test" && boot.clusterType != "production" {
85                 err = fmt.Errorf("cluster type must be 'development', 'test', or 'production'")
86                 return 2
87         }
88
89         cwd, err := os.Getwd()
90         if err != nil {
91                 return 1
92         }
93         if !strings.HasPrefix(boot.sourcePath, "/") {
94                 boot.sourcePath = filepath.Join(cwd, boot.sourcePath)
95         }
96         boot.sourcePath, err = filepath.EvalSymlinks(boot.sourcePath)
97         if err != nil {
98                 return 1
99         }
100
101         loader.SkipAPICalls = true
102         cfg, err := loader.Load()
103         if err != nil {
104                 return 1
105         }
106
107         boot.tempdir, err = ioutil.TempDir("", "arvados-server-boot-")
108         if err != nil {
109                 return 1
110         }
111         defer os.RemoveAll(boot.tempdir)
112
113         // Fill in any missing config keys, and write the resulting
114         // config in the temp dir for child services to use.
115         err = boot.autofillConfig(cfg, log)
116         if err != nil {
117                 return 1
118         }
119         conffile, err := os.OpenFile(filepath.Join(boot.tempdir, "config.yml"), os.O_CREATE|os.O_WRONLY, 0777)
120         if err != nil {
121                 return 1
122         }
123         defer conffile.Close()
124         err = json.NewEncoder(conffile).Encode(cfg)
125         if err != nil {
126                 return 1
127         }
128         err = conffile.Close()
129         if err != nil {
130                 return 1
131         }
132         boot.configfile = conffile.Name()
133
134         boot.environ = os.Environ()
135         boot.setEnv("ARVADOS_CONFIG", boot.configfile)
136         boot.setEnv("RAILS_ENV", boot.clusterType)
137         boot.prependEnv("PATH", filepath.Join(boot.libPath, "bin")+":")
138
139         // Now that we have the config, replace the bootstrap logger
140         // with a new one according to the logging config.
141         boot.cluster, err = cfg.GetCluster("")
142         if err != nil {
143                 return 1
144         }
145         log = ctxlog.New(stderr, boot.cluster.SystemLogs.Format, boot.cluster.SystemLogs.LogLevel)
146         logger := log.WithFields(logrus.Fields{
147                 "PID": os.Getpid(),
148         })
149         ctx := ctxlog.Context(context.Background(), logger)
150         ctx, cancel := context.WithCancel(ctx)
151         defer cancel()
152
153         ch := make(chan os.Signal)
154         signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
155         go func() {
156                 for sig := range ch {
157                         logger.WithField("signal", sig).Info("caught signal")
158                         cancel()
159                 }
160         }()
161
162         for _, dir := range []string{boot.libPath, filepath.Join(boot.libPath, "bin")} {
163                 if _, err = os.Stat(filepath.Join(dir, ".")); os.IsNotExist(err) {
164                         err = os.Mkdir(dir, 0755)
165                         if err != nil {
166                                 return 1
167                         }
168                 } else if err != nil {
169                         return 1
170                 }
171         }
172         err = boot.installGoProgram(ctx, "cmd/arvados-server")
173         if err != nil {
174                 return 1
175         }
176         err = boot.setupRubyEnv()
177         if err != nil {
178                 return 1
179         }
180
181         var wg sync.WaitGroup
182         for _, cmpt := range []component{
183                 {name: "nginx", runFunc: runNginx},
184                 {name: "controller", cmdHandler: controller.Command},
185                 {name: "dispatchcloud", cmdHandler: dispatchcloud.Command, notIfTest: true},
186                 {name: "git-httpd", goProg: "services/arv-git-httpd"},
187                 {name: "health", goProg: "services/health"},
188                 {name: "keep-balance", goProg: "services/keep-balance", notIfTest: true},
189                 {name: "keepproxy", goProg: "services/keepproxy"},
190                 {name: "keepstore", goProg: "services/keepstore", svc: boot.cluster.Services.Keepstore},
191                 {name: "keep-web", goProg: "services/keep-web"},
192                 {name: "railsAPI", svc: boot.cluster.Services.RailsAPI, railsApp: "services/api"},
193                 {name: "ws", goProg: "services/ws"},
194         } {
195                 cmpt := cmpt
196                 wg.Add(1)
197                 go func() {
198                         defer wg.Done()
199                         defer cancel()
200                         logger.WithField("component", cmpt.name).Info("starting")
201                         err := cmpt.Run(ctx, boot, stdout, stderr)
202                         if err != nil {
203                                 logger.WithError(err).WithField("component", cmpt.name).Info("exited")
204                         }
205                 }()
206         }
207         if boot.waitUntilReady(ctx) {
208                 fmt.Fprintln(stdout, boot.cluster.Services.Controller.ExternalURL)
209         }
210         <-ctx.Done()
211         wg.Wait()
212         return 0
213 }
214
215 func (boot *bootCommand) waitUntilReady(ctx context.Context) bool {
216         agg := health.Aggregator{Cluster: boot.cluster}
217         for waiting := true; waiting; {
218                 time.Sleep(time.Second)
219                 if ctx.Err() != nil {
220                         return false
221                 }
222                 resp := agg.ClusterHealth()
223                 // The overall health check (resp.Health=="OK") might
224                 // never pass due to missing components (like
225                 // arvados-dispatch-cloud in a test cluster), so
226                 // instead we wait for all configured components to
227                 // pass.
228                 waiting = false
229                 for _, check := range resp.Checks {
230                         if check.Health != "OK" {
231                                 waiting = true
232                         }
233                 }
234         }
235         return true
236 }
237
238 func (boot *bootCommand) prependEnv(key, prepend string) {
239         for i, s := range boot.environ {
240                 if strings.HasPrefix(s, key+"=") {
241                         boot.environ[i] = key + "=" + prepend + s[len(key)+1:]
242                         return
243                 }
244         }
245         boot.environ = append(boot.environ, key+"="+prepend)
246 }
247
248 func (boot *bootCommand) setEnv(key, val string) {
249         for i, s := range boot.environ {
250                 if strings.HasPrefix(s, key+"=") {
251                         boot.environ[i] = key + "=" + val
252                         return
253                 }
254         }
255         boot.environ = append(boot.environ, key+"="+val)
256 }
257
258 func (boot *bootCommand) installGoProgram(ctx context.Context, srcpath string) error {
259         boot.goMutex.Lock()
260         defer boot.goMutex.Unlock()
261         return boot.RunProgram(ctx, filepath.Join(boot.sourcePath, srcpath), nil, []string{"GOPATH=" + boot.libPath}, "go", "install")
262 }
263
264 func (boot *bootCommand) setupRubyEnv() error {
265         buf, err := exec.Command("gem", "env", "gempath").Output() // /var/lib/arvados/.gem/ruby/2.5.0/bin:...
266         if err != nil || len(buf) == 0 {
267                 return fmt.Errorf("gem env gempath: %v", err)
268         }
269         gempath := string(bytes.Split(buf, []byte{':'})[0])
270         boot.prependEnv("PATH", gempath+"/bin:")
271         boot.setEnv("GEM_HOME", gempath)
272         boot.setEnv("GEM_PATH", gempath)
273         return nil
274 }
275
276 func (boot *bootCommand) lookPath(prog string) string {
277         for _, val := range boot.environ {
278                 if strings.HasPrefix(val, "PATH=") {
279                         for _, dir := range filepath.SplitList(val[5:]) {
280                                 path := filepath.Join(dir, prog)
281                                 if fi, err := os.Stat(path); err == nil && fi.Mode()&0111 != 0 {
282                                         return path
283                                 }
284                         }
285                 }
286         }
287         return prog
288 }
289
290 // Run prog with args, using dir as working directory. If ctx is
291 // cancelled while the child is running, RunProgram terminates the
292 // child, waits for it to exit, then returns.
293 //
294 // Child's environment will have our env vars, plus any given in env.
295 //
296 // Child's stdout will be written to output if non-nil, otherwise the
297 // boot command's stderr.
298 func (boot *bootCommand) RunProgram(ctx context.Context, dir string, output io.Writer, env []string, prog string, args ...string) error {
299         cmdline := fmt.Sprintf("%s", append([]string{prog}, args...))
300         fmt.Fprintf(boot.stderr, "%s executing in %s\n", cmdline, dir)
301         cmd := exec.Command(boot.lookPath(prog), args...)
302         if output == nil {
303                 cmd.Stdout = boot.stderr
304         } else {
305                 cmd.Stdout = output
306         }
307         cmd.Stderr = boot.stderr
308         if strings.HasPrefix(dir, "/") {
309                 cmd.Dir = dir
310         } else {
311                 cmd.Dir = filepath.Join(boot.sourcePath, dir)
312         }
313         cmd.Env = append(env, boot.environ...)
314         go func() {
315                 <-ctx.Done()
316                 log := ctxlog.FromContext(ctx).WithFields(logrus.Fields{"dir": dir, "cmdline": cmdline})
317                 for cmd.ProcessState == nil {
318                         // Child hasn't exited yet
319                         if cmd.Process == nil {
320                                 log.Infof("waiting for child process to start")
321                                 time.Sleep(time.Second)
322                         } else {
323                                 log.WithField("PID", cmd.Process.Pid).Info("sending SIGTERM")
324                                 cmd.Process.Signal(syscall.SIGTERM)
325                                 log.WithField("PID", cmd.Process.Pid).Info("waiting for child process to exit after SIGTERM")
326                                 time.Sleep(5 * time.Second)
327                         }
328                 }
329         }()
330         err := cmd.Run()
331         if err != nil {
332                 return fmt.Errorf("%s: error: %v", cmdline, err)
333         }
334         return nil
335 }
336
337 type component struct {
338         name       string
339         svc        arvados.Service
340         cmdHandler cmd.Handler
341         runFunc    func(ctx context.Context, boot *bootCommand, stdout, stderr io.Writer) error
342         railsApp   string // source dir in arvados tree, e.g., "services/api"
343         goProg     string // source dir in arvados tree, e.g., "services/keepstore"
344         notIfTest  bool   // don't run this component on a test cluster
345 }
346
347 func (cmpt *component) Run(ctx context.Context, boot *bootCommand, stdout, stderr io.Writer) error {
348         if cmpt.notIfTest && boot.clusterType == "test" {
349                 fmt.Fprintf(stderr, "skipping component %q in %s mode\n", cmpt.name, boot.clusterType)
350                 <-ctx.Done()
351                 return nil
352         }
353         fmt.Fprintf(stderr, "starting component %q\n", cmpt.name)
354         if cmpt.cmdHandler != nil {
355                 errs := make(chan error, 1)
356                 go func() {
357                         defer close(errs)
358                         exitcode := cmpt.cmdHandler.RunCommand(cmpt.name, []string{"-config", boot.configfile}, bytes.NewBuffer(nil), stdout, stderr)
359                         if exitcode != 0 {
360                                 errs <- fmt.Errorf("exit code %d", exitcode)
361                         }
362                 }()
363                 select {
364                 case err := <-errs:
365                         return err
366                 case <-ctx.Done():
367                         // cmpt.cmdHandler.RunCommand() doesn't have
368                         // access to our context, so it won't shut
369                         // down by itself. We just abandon it.
370                         return nil
371                 }
372         }
373         if cmpt.goProg != "" {
374                 boot.RunProgram(ctx, cmpt.goProg, nil, nil, "go", "install")
375                 if ctx.Err() != nil {
376                         return nil
377                 }
378                 _, basename := filepath.Split(cmpt.goProg)
379                 if len(cmpt.svc.InternalURLs) > 0 {
380                         // Run one for each URL
381                         var wg sync.WaitGroup
382                         for u := range cmpt.svc.InternalURLs {
383                                 u := u
384                                 wg.Add(1)
385                                 go func() {
386                                         defer wg.Done()
387                                         boot.RunProgram(ctx, boot.tempdir, nil, []string{"ARVADOS_SERVICE_INTERNAL_URL=" + u.String()}, basename)
388                                 }()
389                         }
390                         wg.Wait()
391                 } else {
392                         // Just run one
393                         boot.RunProgram(ctx, boot.tempdir, nil, nil, basename)
394                 }
395                 return nil
396         }
397         if cmpt.runFunc != nil {
398                 return cmpt.runFunc(ctx, boot, stdout, stderr)
399         }
400         if cmpt.railsApp != "" {
401                 port, err := internalPort(cmpt.svc)
402                 if err != nil {
403                         return fmt.Errorf("bug: no InternalURLs for component %q: %v", cmpt.name, cmpt.svc.InternalURLs)
404                 }
405                 var buf bytes.Buffer
406                 err = boot.RunProgram(ctx, cmpt.railsApp, &buf, nil, "gem", "list", "--details", "bundler")
407                 if err != nil {
408                         return err
409                 }
410                 for _, version := range []string{"1.11.0", "1.17.3", "2.0.2"} {
411                         if !strings.Contains(buf.String(), "("+version+")") {
412                                 err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "gem", "install", "--user", "bundler:1.11", "bundler:1.17.3", "bundler:2.0.2")
413                                 if err != nil {
414                                         return err
415                                 }
416                                 break
417                         }
418                 }
419                 err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "bundle", "install", "--jobs", "4", "--path", filepath.Join(os.Getenv("HOME"), ".gem"))
420                 if err != nil {
421                         return err
422                 }
423                 err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "bundle", "exec", "passenger-config", "build-native-support")
424                 if err != nil {
425                         return err
426                 }
427                 err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "bundle", "exec", "passenger-config", "install-standalone-runtime")
428                 if err != nil {
429                         return err
430                 }
431                 err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "bundle", "exec", "passenger-config", "validate-install")
432                 if err != nil {
433                         return err
434                 }
435                 err = boot.RunProgram(ctx, cmpt.railsApp, nil, nil, "bundle", "exec", "passenger", "start", "-p", port)
436                 if err != nil {
437                         return err
438                 }
439         }
440         return fmt.Errorf("bug: component %q has nothing to run", cmpt.name)
441 }
442
443 func (boot *bootCommand) autofillConfig(cfg *arvados.Config, log logrus.FieldLogger) error {
444         cluster, err := cfg.GetCluster("")
445         if err != nil {
446                 return err
447         }
448         port := 9000
449         for _, svc := range []*arvados.Service{
450                 &cluster.Services.Controller,
451                 &cluster.Services.DispatchCloud,
452                 &cluster.Services.GitHTTP,
453                 &cluster.Services.Health,
454                 &cluster.Services.Keepproxy,
455                 &cluster.Services.Keepstore,
456                 &cluster.Services.RailsAPI,
457                 &cluster.Services.WebDAV,
458                 &cluster.Services.WebDAVDownload,
459                 &cluster.Services.Websocket,
460         } {
461                 if svc == &cluster.Services.DispatchCloud && boot.clusterType == "test" {
462                         continue
463                 }
464                 if len(svc.InternalURLs) == 0 {
465                         port++
466                         svc.InternalURLs = map[arvados.URL]arvados.ServiceInstance{
467                                 arvados.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", port)}: arvados.ServiceInstance{},
468                         }
469                 }
470                 if svc.ExternalURL.Host == "" && (svc == &cluster.Services.Controller ||
471                         svc == &cluster.Services.GitHTTP ||
472                         svc == &cluster.Services.Keepproxy ||
473                         svc == &cluster.Services.WebDAV ||
474                         svc == &cluster.Services.WebDAVDownload ||
475                         svc == &cluster.Services.Websocket) {
476                         port++
477                         svc.ExternalURL = arvados.URL{Scheme: "https", Host: fmt.Sprintf("localhost:%d", port)}
478                 }
479         }
480         if cluster.SystemRootToken == "" {
481                 cluster.SystemRootToken = randomHexString(64)
482         }
483         if cluster.ManagementToken == "" {
484                 cluster.ManagementToken = randomHexString(64)
485         }
486         if cluster.API.RailsSessionSecretToken == "" {
487                 cluster.API.RailsSessionSecretToken = randomHexString(64)
488         }
489         if cluster.Collections.BlobSigningKey == "" {
490                 cluster.Collections.BlobSigningKey = randomHexString(64)
491         }
492         if boot.clusterType != "production" && cluster.Containers.DispatchPrivateKey == "" {
493                 buf, err := ioutil.ReadFile(filepath.Join(boot.sourcePath, "lib", "dispatchcloud", "test", "sshkey_dispatch"))
494                 if err != nil {
495                         return err
496                 }
497                 cluster.Containers.DispatchPrivateKey = string(buf)
498         }
499         if boot.clusterType != "production" {
500                 cluster.TLS.Insecure = true
501         }
502         if boot.clusterType == "test" {
503                 // Add a second keepstore process.
504                 port++
505                 cluster.Services.Keepstore.InternalURLs[arvados.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", port)}] = arvados.ServiceInstance{}
506
507                 // Create a directory-backed volume for each keepstore
508                 // process.
509                 cluster.Volumes = map[string]arvados.Volume{}
510                 for url := range cluster.Services.Keepstore.InternalURLs {
511                         volnum := len(cluster.Volumes)
512                         datadir := fmt.Sprintf("%s/keep%d.data", boot.tempdir, volnum)
513                         if _, err = os.Stat(datadir + "/."); err == nil {
514                         } else if !os.IsNotExist(err) {
515                                 return err
516                         } else if err = os.Mkdir(datadir, 0777); err != nil {
517                                 return err
518                         }
519                         cluster.Volumes[fmt.Sprintf("zzzzz-nyw5e-%015d", volnum)] = arvados.Volume{
520                                 Driver:           "Directory",
521                                 DriverParameters: json.RawMessage(fmt.Sprintf(`{"Root":%q}`, datadir)),
522                                 AccessViaHosts: map[arvados.URL]arvados.VolumeAccess{
523                                         url: {},
524                                 },
525                         }
526                 }
527         }
528         cfg.Clusters[cluster.ClusterID] = *cluster
529         return nil
530 }
531
532 func randomHexString(chars int) string {
533         b := make([]byte, chars/2)
534         _, err := rand.Read(b)
535         if err != nil {
536                 panic(err)
537         }
538         return fmt.Sprintf("%x", b)
539 }
540
541 func internalPort(svc arvados.Service) (string, error) {
542         for u := range svc.InternalURLs {
543                 if _, p, err := net.SplitHostPort(u.Host); err != nil {
544                         return "", err
545                 } else if p != "" {
546                         return p, nil
547                 } else if u.Scheme == "https" {
548                         return "443", nil
549                 } else {
550                         return "80", nil
551                 }
552         }
553         return "", fmt.Errorf("service has no InternalURLs")
554 }
555
556 func externalPort(svc arvados.Service) (string, error) {
557         if _, p, err := net.SplitHostPort(svc.ExternalURL.Host); err != nil {
558                 return "", err
559         } else if p != "" {
560                 return p, nil
561         } else if svc.ExternalURL.Scheme == "https" {
562                 return "443", nil
563         } else {
564                 return "80", nil
565         }
566 }