2992b6677e78c8f5154510787aeb7e6461205601
[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.Fprintln(stderr, "Setup complete. You should now be able to log in to workbench2 at", cluster.Services.Workbench2.ExternalURL.String())
396
397         return 0
398 }
399
400 func (initcmd *initCommand) GenerateSSHPrivateKey() (string, error) {
401         privkey, err := rsa.GenerateKey(rand.Reader, 4096)
402         if err != nil {
403                 return "", err
404         }
405         err = privkey.Validate()
406         if err != nil {
407                 return "", err
408         }
409         return string(pem.EncodeToMemory(&pem.Block{
410                 Type:  "RSA PRIVATE KEY",
411                 Bytes: x509.MarshalPKCS1PrivateKey(privkey),
412         })), nil
413 }
414
415 func (initcmd *initCommand) RandomHex(chars int) string {
416         b := make([]byte, chars/2)
417         _, err := rand.Read(b)
418         if err != nil {
419                 panic(err)
420         }
421         return fmt.Sprintf("%x", b)
422 }
423
424 func (initcmd *initCommand) createDB(ctx context.Context, dbconn arvados.PostgreSQLConnection, stderr io.Writer) error {
425         cmd := exec.CommandContext(ctx, "sudo", "-u", "postgres", "psql", "--quiet",
426                 "-c", `CREATE USER `+pq.QuoteIdentifier(dbconn["user"])+` WITH SUPERUSER ENCRYPTED PASSWORD `+pq.QuoteLiteral(dbconn["password"]),
427                 "-c", `CREATE DATABASE `+pq.QuoteIdentifier(dbconn["dbname"])+` WITH TEMPLATE template0 ENCODING 'utf8'`,
428         )
429         cmd.Dir = "/"
430         cmd.Stdout = stderr
431         cmd.Stderr = stderr
432         err := cmd.Run()
433         if err != nil {
434                 return fmt.Errorf("error setting up arvados user/database: %w", err)
435         }
436         return nil
437 }
438
439 // Confirm that http://{initcmd.Domain}:{port} reaches a server that
440 // we run on {port}.
441 //
442 // If port is "80", listening fails, and Nginx appears to be using the
443 // debian-packaged default configuration that listens on port 80,
444 // disable that Nginx config and try again.
445 //
446 // (Typically, the reason Nginx is installed is so that Arvados can
447 // run an Nginx child process; the default Nginx service using config
448 // from /etc/nginx is just an unfortunate side effect of installing
449 // Nginx by way of the Debian package.)
450 func (initcmd *initCommand) checkPort(ctx context.Context, port string) error {
451         err := initcmd.checkPortOnce(ctx, port)
452         if err == nil || port != "80" {
453                 // success, or poking Nginx in the eye won't help
454                 return err
455         }
456         d, err2 := os.Open("/etc/nginx/sites-enabled/.")
457         if err2 != nil {
458                 return err
459         }
460         fis, err2 := d.Readdir(-1)
461         if err2 != nil || len(fis) != 1 {
462                 return err
463         }
464         if target, err2 := os.Readlink("/etc/nginx/sites-enabled/default"); err2 != nil || target != "/etc/nginx/sites-available/default" {
465                 return err
466         }
467         err2 = os.Remove("/etc/nginx/sites-enabled/default")
468         if err2 != nil {
469                 return err
470         }
471         exec.CommandContext(ctx, "nginx", "-s", "reload").Run()
472         time.Sleep(time.Second)
473         return initcmd.checkPortOnce(ctx, port)
474 }
475
476 // Start an http server on 0.0.0.0:{port} and confirm that
477 // http://{initcmd.Domain}:{port} reaches that server.
478 func (initcmd *initCommand) checkPortOnce(ctx context.Context, port string) error {
479         b := make([]byte, 128)
480         _, err := rand.Read(b)
481         if err != nil {
482                 return err
483         }
484         token := fmt.Sprintf("%x", b)
485
486         srv := http.Server{
487                 Addr: net.JoinHostPort("", port),
488                 Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
489                         fmt.Fprint(w, token)
490                 })}
491         var errServe atomic.Value
492         go func() {
493                 errServe.Store(srv.ListenAndServe())
494         }()
495         defer srv.Close()
496         url := "http://" + net.JoinHostPort(initcmd.Domain, port) + "/probe"
497         req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
498         if err != nil {
499                 return err
500         }
501         resp, err := http.DefaultClient.Do(req)
502         if err == nil {
503                 defer resp.Body.Close()
504         }
505         if errServe, _ := errServe.Load().(error); errServe != nil {
506                 // If server already exited, return that error
507                 // (probably "can't listen"), not the request error.
508                 return errServe
509         }
510         if err != nil {
511                 return err
512         }
513         buf := make([]byte, len(token))
514         n, err := io.ReadFull(resp.Body, buf)
515         if string(buf[:n]) != token {
516                 return fmt.Errorf("listened on port %s but %s connected to something else, returned %q, err %v", port, url, buf[:n], err)
517         }
518         return nil
519 }