17344: Point to diagnostics and docs after successful init.
[arvados.git] / lib / install / init.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package install
6
7 import (
8         "context"
9         "crypto/rand"
10         "crypto/rsa"
11         "crypto/x509"
12         "encoding/pem"
13         "flag"
14         "fmt"
15         "io"
16         "net"
17         "net/http"
18         "net/url"
19         "os"
20         "os/exec"
21         "os/user"
22         "regexp"
23         "strconv"
24         "strings"
25         "sync/atomic"
26         "text/template"
27         "time"
28
29         "git.arvados.org/arvados.git/lib/cmd"
30         "git.arvados.org/arvados.git/lib/config"
31         "git.arvados.org/arvados.git/lib/controller/rpc"
32         "git.arvados.org/arvados.git/sdk/go/arvados"
33         "git.arvados.org/arvados.git/sdk/go/auth"
34         "git.arvados.org/arvados.git/sdk/go/ctxlog"
35         "github.com/lib/pq"
36 )
37
38 var InitCommand cmd.Handler = &initCommand{}
39
40 type initCommand struct {
41         ClusterID          string
42         Domain             string
43         PostgreSQLPassword string
44         Login              string
45         TLS                string
46         AdminEmail         string
47         Start              bool
48
49         LoginPAM                bool
50         LoginTest               bool
51         LoginGoogle             bool
52         LoginGoogleClientID     string
53         LoginGoogleClientSecret string
54 }
55
56 func (initcmd *initCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
57         logger := ctxlog.New(stderr, "text", "info")
58         ctx := ctxlog.Context(context.Background(), logger)
59         ctx, cancel := context.WithCancel(ctx)
60         defer cancel()
61
62         var err error
63         defer func() {
64                 if err != nil {
65                         logger.WithError(err).Info("exiting")
66                 }
67         }()
68
69         hostname, err := os.Hostname()
70         if err != nil {
71                 err = fmt.Errorf("Hostname(): %w", err)
72                 return 1
73         }
74
75         flags := flag.NewFlagSet(prog, flag.ContinueOnError)
76         flags.SetOutput(stderr)
77         versionFlag := flags.Bool("version", false, "Write version information to stdout and exit 0")
78         flags.StringVar(&initcmd.ClusterID, "cluster-id", "", "cluster `id`, like x1234 for a dev cluster")
79         flags.StringVar(&initcmd.Domain, "domain", hostname, "cluster public DNS `name`, like x1234.arvadosapi.com")
80         flags.StringVar(&initcmd.Login, "login", "", "login `backend`: test, pam, 'google {client-id} {client-secret}', or ''")
81         flags.StringVar(&initcmd.AdminEmail, "admin-email", "", "give admin privileges to user with given `email`")
82         flags.StringVar(&initcmd.TLS, "tls", "none", "tls certificate `source`: acme, auto, insecure, or none")
83         flags.BoolVar(&initcmd.Start, "start", true, "start systemd service after creating config")
84         if ok, code := cmd.ParseFlags(flags, prog, args, "", stderr); !ok {
85                 return code
86         } else if *versionFlag {
87                 return cmd.Version.RunCommand(prog, args, stdin, stdout, stderr)
88         } else if !regexp.MustCompile(`^[a-z][a-z0-9]{4}`).MatchString(initcmd.ClusterID) {
89                 err = fmt.Errorf("cluster ID %q is invalid; must be an ASCII letter followed by 4 alphanumerics (try -help)", initcmd.ClusterID)
90                 return 1
91         }
92
93         if fields := strings.Fields(initcmd.Login); len(fields) == 3 && fields[0] == "google" {
94                 initcmd.LoginGoogle = true
95                 initcmd.LoginGoogleClientID = fields[1]
96                 initcmd.LoginGoogleClientSecret = fields[2]
97         } else if initcmd.Login == "test" {
98                 initcmd.LoginTest = true
99                 if initcmd.AdminEmail == "" {
100                         initcmd.AdminEmail = "admin@example.com"
101                 }
102         } else if initcmd.Login == "pam" {
103                 initcmd.LoginPAM = true
104         } else if initcmd.Login == "" {
105                 // none; login will show an error page
106         } else {
107                 err = fmt.Errorf("invalid argument to -login: %q: should be 'test', 'pam', 'google {client-id} {client-secret}', or empty", initcmd.Login)
108                 return 1
109         }
110
111         confdir := "/etc/arvados"
112         conffile := confdir + "/config.yml"
113         if _, err = os.Stat(conffile); err == nil {
114                 err = fmt.Errorf("config file %s already exists; delete it first if you really want to start over", conffile)
115                 return 1
116         }
117
118         ports := []int{443}
119         for i := 4440; i < 4460; i++ {
120                 ports = append(ports, i)
121         }
122         if initcmd.TLS == "acme" {
123                 ports = append(ports, 80)
124         }
125         for _, port := range ports {
126                 err = initcmd.checkPort(ctx, fmt.Sprintf("%d", port))
127                 if err != nil {
128                         return 1
129                 }
130         }
131
132         // Do the "create extension" thing early. This way, if there's
133         // no local postgresql server (a likely failure mode), we can
134         // bail out without any side effects, and the user can start
135         // over easily.
136         fmt.Fprintln(stderr, "installing pg_trgm postgresql extension...")
137         cmd := exec.CommandContext(ctx, "sudo", "-u", "postgres", "psql", "--quiet",
138                 "-c", `CREATE EXTENSION IF NOT EXISTS pg_trgm`)
139         cmd.Dir = "/"
140         cmd.Stdout = stdout
141         cmd.Stderr = stderr
142         err = cmd.Run()
143         if err != nil {
144                 err = fmt.Errorf("error preparing postgresql server: %w", err)
145                 return 1
146         }
147         fmt.Fprintln(stderr, "...done")
148
149         wwwuser, err := user.Lookup("www-data")
150         if err != nil {
151                 err = fmt.Errorf("user.Lookup(%q): %w", "www-data", err)
152                 return 1
153         }
154         wwwgid, err := strconv.Atoi(wwwuser.Gid)
155         if err != nil {
156                 return 1
157         }
158         initcmd.PostgreSQLPassword = initcmd.RandomHex(32)
159
160         fmt.Fprintln(stderr, "creating data storage directory /var/lib/arvados/keep ...")
161         err = os.Mkdir("/var/lib/arvados/keep", 0600)
162         if err != nil && !os.IsExist(err) {
163                 err = fmt.Errorf("mkdir /var/lib/arvados/keep: %w", err)
164                 return 1
165         }
166         fmt.Fprintln(stderr, "...done")
167
168         fmt.Fprintln(stderr, "creating config file", conffile, "...")
169         err = os.Mkdir(confdir, 0750)
170         if err != nil && !os.IsExist(err) {
171                 err = fmt.Errorf("mkdir %s: %w", confdir, err)
172                 return 1
173         }
174         err = os.Chown(confdir, 0, wwwgid)
175         if err != nil {
176                 err = fmt.Errorf("chown 0:%d %s: %w", wwwgid, confdir, err)
177                 return 1
178         }
179         f, err := os.OpenFile(conffile+".tmp", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
180         if err != nil {
181                 err = fmt.Errorf("open %s: %w", conffile+".tmp", err)
182                 return 1
183         }
184         tmpl, err := template.New("config").Parse(`Clusters:
185   {{.ClusterID}}:
186     Services:
187       Controller:
188         InternalURLs:
189           "http://0.0.0.0:9000/": {}
190         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4440/" ) }}
191       RailsAPI:
192         InternalURLs:
193           "http://0.0.0.0:9001/": {}
194       Websocket:
195         InternalURLs:
196           "http://0.0.0.0:8005/": {}
197         ExternalURL: {{printf "%q" ( print "wss://" .Domain ":4446/" ) }}
198       Keepbalance:
199         InternalURLs:
200           "http://0.0.0.0:9019/": {}
201       GitHTTP:
202         InternalURLs:
203           "http://0.0.0.0:9005/": {}
204         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4445/" ) }}
205       DispatchCloud:
206         InternalURLs:
207           "http://0.0.0.0:9006/": {}
208       Keepproxy:
209         InternalURLs:
210           "http://0.0.0.0:9007/": {}
211         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4447/" ) }}
212       WebDAV:
213         InternalURLs:
214           "http://0.0.0.0:9008/": {}
215         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4448/" ) }}
216       WebDAVDownload:
217         InternalURLs:
218           "http://0.0.0.0:9009/": {}
219         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4449/" ) }}
220       Keepstore:
221         InternalURLs:
222           "http://0.0.0.0:9010/": {}
223       Composer:
224         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4459/composer" ) }}
225       Workbench1:
226         InternalURLs:
227           "http://0.0.0.0:9002/": {}
228         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4442/" ) }}
229       Workbench2:
230         InternalURLs:
231           "http://0.0.0.0:9003/": {}
232         ExternalURL: {{printf "%q" ( print "https://" .Domain "/" ) }}
233       Health:
234         InternalURLs:
235           "http://0.0.0.0:9011/": {}
236     Collections:
237       BlobSigningKey: {{printf "%q" ( .RandomHex 50 )}}
238       {{if eq .TLS "insecure"}}
239       TrustAllContent: true
240       {{end}}
241     Containers:
242       DispatchPrivateKey: {{printf "%q" .GenerateSSHPrivateKey}}
243       CloudVMs:
244         Enable: true
245         Driver: loopback
246     ManagementToken: {{printf "%q" ( .RandomHex 50 )}}
247     PostgreSQL:
248       Connection:
249         dbname: arvados
250         host: localhost
251         user: arvados
252         password: {{printf "%q" .PostgreSQLPassword}}
253     SystemRootToken: {{printf "%q" ( .RandomHex 50 )}}
254     TLS:
255       {{if eq .TLS "insecure"}}
256       Insecure: true
257       {{else if eq .TLS "auto"}}
258       Automatic: true
259       {{else if eq .TLS "acme"}}
260       Certificate: {{printf "%q" (print "/var/lib/acme/live/" .Domain "/cert")}}
261       Key: {{printf "%q" (print "/var/lib/acme/live/" .Domain "/privkey")}}
262       {{else}}
263       {}
264       {{end}}
265     Volumes:
266       {{.ClusterID}}-nyw5e-000000000000000:
267         Driver: Directory
268         DriverParameters:
269           Root: /var/lib/arvados/keep
270         Replication: 2
271     Workbench:
272       SecretKeyBase: {{printf "%q" ( .RandomHex 50 )}}
273     {{if .LoginPAM}}
274     Login:
275       PAM:
276         Enable: true
277     {{else if .LoginTest}}
278     Login:
279       Test:
280         Enable: true
281         Users:
282           admin:
283             Email: {{printf "%q" .AdminEmail}}
284             Password: admin
285     {{else if .LoginGoogle}}
286     Login:
287       Google:
288         Enable: true
289         ClientID: {{printf "%q" .LoginGoogleClientID}}
290         ClientSecret: {{printf "%q" .LoginGoogleClientSecret}}
291     {{end}}
292     Users:
293       AutoAdminUserWithEmail: {{printf "%q" .AdminEmail}}
294 `)
295         if err != nil {
296                 return 1
297         }
298         err = tmpl.Execute(f, initcmd)
299         if err != nil {
300                 err = fmt.Errorf("%s: tmpl.Execute: %w", conffile+".tmp", err)
301                 return 1
302         }
303         err = f.Close()
304         if err != nil {
305                 err = fmt.Errorf("%s: close: %w", conffile+".tmp", err)
306                 return 1
307         }
308         err = os.Rename(conffile+".tmp", conffile)
309         if err != nil {
310                 err = fmt.Errorf("rename %s -> %s: %w", conffile+".tmp", conffile, err)
311                 return 1
312         }
313         fmt.Fprintln(stderr, "...done")
314
315         ldr := config.NewLoader(nil, logger)
316         ldr.SkipLegacy = true
317         ldr.Path = conffile // load the file we just wrote, even if $ARVADOS_CONFIG is set
318         cfg, err := ldr.Load()
319         if err != nil {
320                 err = fmt.Errorf("%s: %w", conffile, err)
321                 return 1
322         }
323         cluster, err := cfg.GetCluster("")
324         if err != nil {
325                 return 1
326         }
327
328         fmt.Fprintln(stderr, "creating postresql user and database...")
329         err = initcmd.createDB(ctx, cluster.PostgreSQL.Connection, stderr)
330         if err != nil {
331                 return 1
332         }
333         fmt.Fprintln(stderr, "...done")
334
335         fmt.Fprintln(stderr, "initializing database...")
336         cmd = exec.CommandContext(ctx, "sudo", "-u", "www-data", "-E", "HOME=/var/www", "PATH=/var/lib/arvados/bin:"+os.Getenv("PATH"), "/var/lib/arvados/bin/bundle", "exec", "rake", "db:setup")
337         cmd.Dir = "/var/lib/arvados/railsapi"
338         cmd.Stdout = stderr
339         cmd.Stderr = stderr
340         err = cmd.Run()
341         if err != nil {
342                 err = fmt.Errorf("rake db:setup failed: %w", err)
343                 return 1
344         }
345         fmt.Fprintln(stderr, "...done")
346
347         if initcmd.Start {
348                 fmt.Fprintln(stderr, "starting systemd service...")
349                 cmd := exec.CommandContext(ctx, "systemctl", "start", "arvados")
350                 cmd.Dir = "/"
351                 cmd.Stdout = stderr
352                 cmd.Stderr = stderr
353                 err = cmd.Run()
354                 if err != nil {
355                         err = fmt.Errorf("%v: %w", cmd.Args, err)
356                         return 1
357                 }
358                 fmt.Fprintln(stderr, "...done")
359
360                 fmt.Fprintln(stderr, "checking controller API endpoint...")
361                 u := url.URL(cluster.Services.Controller.ExternalURL)
362                 conn := rpc.NewConn(cluster.ClusterID, &u, cluster.TLS.Insecure, rpc.PassthroughTokenProvider)
363                 ctx := auth.NewContext(context.Background(), auth.NewCredentials(cluster.SystemRootToken))
364                 _, err = conn.UserGetCurrent(ctx, arvados.GetOptions{})
365                 if err != nil {
366                         err = fmt.Errorf("API request failed: %w", err)
367                         return 1
368                 }
369                 fmt.Fprintln(stderr, "...looks good")
370         }
371
372         if out, err := exec.CommandContext(ctx, "docker", "version").CombinedOutput(); err == nil && strings.Contains(string(out), "\nServer:\n") {
373                 fmt.Fprintln(stderr, "loading alpine docker image for diagnostics...")
374                 cmd := exec.CommandContext(ctx, "docker", "pull", "alpine")
375                 cmd.Stdout = stderr
376                 cmd.Stderr = stderr
377                 err = cmd.Run()
378                 if err != nil {
379                         err = fmt.Errorf("%v: %w", cmd.Args, err)
380                         return 1
381                 }
382                 cmd = exec.CommandContext(ctx, "arv", "sudo", "keep", "docker", "alpine")
383                 cmd.Stdout = stderr
384                 cmd.Stderr = stderr
385                 err = cmd.Run()
386                 if err != nil {
387                         err = fmt.Errorf("%v: %w", cmd.Args, err)
388                         return 1
389                 }
390                 fmt.Fprintln(stderr, "...done")
391         } else {
392                 fmt.Fprintln(stderr, "docker is not installed -- skipping step of downloading 'alpine' image")
393         }
394
395         fmt.Fprintf(stderr, `
396 Setup complete. Next steps:
397 * run 'arv sudo diagnostics'
398 * log in to workbench2 at %s
399 * see documentation at https://doc.arvados.org/install/automatic.html
400 `, cluster.Services.Workbench2.ExternalURL.String())
401
402         return 0
403 }
404
405 func (initcmd *initCommand) GenerateSSHPrivateKey() (string, error) {
406         privkey, err := rsa.GenerateKey(rand.Reader, 4096)
407         if err != nil {
408                 return "", err
409         }
410         err = privkey.Validate()
411         if err != nil {
412                 return "", err
413         }
414         return string(pem.EncodeToMemory(&pem.Block{
415                 Type:  "RSA PRIVATE KEY",
416                 Bytes: x509.MarshalPKCS1PrivateKey(privkey),
417         })), nil
418 }
419
420 func (initcmd *initCommand) RandomHex(chars int) string {
421         b := make([]byte, chars/2)
422         _, err := rand.Read(b)
423         if err != nil {
424                 panic(err)
425         }
426         return fmt.Sprintf("%x", b)
427 }
428
429 func (initcmd *initCommand) createDB(ctx context.Context, dbconn arvados.PostgreSQLConnection, stderr io.Writer) error {
430         cmd := exec.CommandContext(ctx, "sudo", "-u", "postgres", "psql", "--quiet",
431                 "-c", `CREATE USER `+pq.QuoteIdentifier(dbconn["user"])+` WITH SUPERUSER ENCRYPTED PASSWORD `+pq.QuoteLiteral(dbconn["password"]),
432                 "-c", `CREATE DATABASE `+pq.QuoteIdentifier(dbconn["dbname"])+` WITH TEMPLATE template0 ENCODING 'utf8'`,
433         )
434         cmd.Dir = "/"
435         cmd.Stdout = stderr
436         cmd.Stderr = stderr
437         err := cmd.Run()
438         if err != nil {
439                 return fmt.Errorf("error setting up arvados user/database: %w", err)
440         }
441         return nil
442 }
443
444 // Confirm that http://{initcmd.Domain}:{port} reaches a server that
445 // we run on {port}.
446 //
447 // If port is "80", listening fails, and Nginx appears to be using the
448 // debian-packaged default configuration that listens on port 80,
449 // disable that Nginx config and try again.
450 //
451 // (Typically, the reason Nginx is installed is so that Arvados can
452 // run an Nginx child process; the default Nginx service using config
453 // from /etc/nginx is just an unfortunate side effect of installing
454 // Nginx by way of the Debian package.)
455 func (initcmd *initCommand) checkPort(ctx context.Context, port string) error {
456         err := initcmd.checkPortOnce(ctx, port)
457         if err == nil || port != "80" {
458                 // success, or poking Nginx in the eye won't help
459                 return err
460         }
461         d, err2 := os.Open("/etc/nginx/sites-enabled/.")
462         if err2 != nil {
463                 return err
464         }
465         fis, err2 := d.Readdir(-1)
466         if err2 != nil || len(fis) != 1 {
467                 return err
468         }
469         if target, err2 := os.Readlink("/etc/nginx/sites-enabled/default"); err2 != nil || target != "/etc/nginx/sites-available/default" {
470                 return err
471         }
472         err2 = os.Remove("/etc/nginx/sites-enabled/default")
473         if err2 != nil {
474                 return err
475         }
476         exec.CommandContext(ctx, "nginx", "-s", "reload").Run()
477         time.Sleep(time.Second)
478         return initcmd.checkPortOnce(ctx, port)
479 }
480
481 // Start an http server on 0.0.0.0:{port} and confirm that
482 // http://{initcmd.Domain}:{port} reaches that server.
483 func (initcmd *initCommand) checkPortOnce(ctx context.Context, port string) error {
484         b := make([]byte, 128)
485         _, err := rand.Read(b)
486         if err != nil {
487                 return err
488         }
489         token := fmt.Sprintf("%x", b)
490
491         srv := http.Server{
492                 Addr: net.JoinHostPort("", port),
493                 Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
494                         fmt.Fprint(w, token)
495                 })}
496         var errServe atomic.Value
497         go func() {
498                 errServe.Store(srv.ListenAndServe())
499         }()
500         defer srv.Close()
501         url := "http://" + net.JoinHostPort(initcmd.Domain, port) + "/probe"
502         req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
503         if err != nil {
504                 return err
505         }
506         resp, err := http.DefaultClient.Do(req)
507         if err == nil {
508                 defer resp.Body.Close()
509         }
510         if errServe, _ := errServe.Load().(error); errServe != nil {
511                 // If server already exited, return that error
512                 // (probably "can't listen"), not the request error.
513                 return errServe
514         }
515         if err != nil {
516                 return err
517         }
518         buf := make([]byte, len(token))
519         n, err := io.ReadFull(resp.Body, buf)
520         if string(buf[:n]) != token {
521                 return fmt.Errorf("listened on port %s but %s connected to something else, returned %q, err %v", port, url, buf[:n], err)
522         }
523         return nil
524 }