17344: Rename "root" subcommand to "sudo".
[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         err = initcmd.checkPort(ctx, "4440")
119         err = initcmd.checkPort(ctx, "443")
120         if initcmd.TLS == "auto" {
121                 err = initcmd.checkPort(ctx, "80")
122                 if err != nil {
123                         return 1
124                 }
125         }
126
127         // Do the "create extension" thing early. This way, if there's
128         // no local postgresql server (a likely failure mode), we can
129         // bail out without any side effects, and the user can start
130         // over easily.
131         fmt.Fprintln(stderr, "installing pg_trgm postgresql extension...")
132         cmd := exec.CommandContext(ctx, "sudo", "-u", "postgres", "psql", "--quiet",
133                 "-c", `CREATE EXTENSION IF NOT EXISTS pg_trgm`)
134         cmd.Dir = "/"
135         cmd.Stdout = stdout
136         cmd.Stderr = stderr
137         err = cmd.Run()
138         if err != nil {
139                 err = fmt.Errorf("error preparing postgresql server: %w", err)
140                 return 1
141         }
142         fmt.Fprintln(stderr, "...done")
143
144         wwwuser, err := user.Lookup("www-data")
145         if err != nil {
146                 err = fmt.Errorf("user.Lookup(%q): %w", "www-data", err)
147                 return 1
148         }
149         wwwgid, err := strconv.Atoi(wwwuser.Gid)
150         if err != nil {
151                 return 1
152         }
153         initcmd.PostgreSQLPassword = initcmd.RandomHex(32)
154
155         fmt.Fprintln(stderr, "creating data storage directory /var/lib/arvados/keep ...")
156         err = os.Mkdir("/var/lib/arvados/keep", 0600)
157         if err != nil && !os.IsExist(err) {
158                 err = fmt.Errorf("mkdir /var/lib/arvados/keep: %w", err)
159                 return 1
160         }
161         fmt.Fprintln(stderr, "...done")
162
163         fmt.Fprintln(stderr, "creating config file", conffile, "...")
164         err = os.Mkdir(confdir, 0750)
165         if err != nil && !os.IsExist(err) {
166                 err = fmt.Errorf("mkdir %s: %w", confdir, err)
167                 return 1
168         }
169         err = os.Chown(confdir, 0, wwwgid)
170         if err != nil {
171                 err = fmt.Errorf("chown 0:%d %s: %w", wwwgid, confdir, err)
172                 return 1
173         }
174         f, err := os.OpenFile(conffile+".tmp", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
175         if err != nil {
176                 err = fmt.Errorf("open %s: %w", conffile+".tmp", err)
177                 return 1
178         }
179         tmpl, err := template.New("config").Parse(`Clusters:
180   {{.ClusterID}}:
181     Services:
182       Controller:
183         InternalURLs:
184           "http://0.0.0.0:9000/": {}
185         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4440/" ) }}
186       RailsAPI:
187         InternalURLs:
188           "http://0.0.0.0:9001/": {}
189       Websocket:
190         InternalURLs:
191           "http://0.0.0.0:8005/": {}
192         ExternalURL: {{printf "%q" ( print "wss://" .Domain ":4446/" ) }}
193       Keepbalance:
194         InternalURLs:
195           "http://0.0.0.0:9019/": {}
196       GitHTTP:
197         InternalURLs:
198           "http://0.0.0.0:9005/": {}
199         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4445/" ) }}
200       DispatchCloud:
201         InternalURLs:
202           "http://0.0.0.0:9006/": {}
203       Keepproxy:
204         InternalURLs:
205           "http://0.0.0.0:9007/": {}
206         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4447/" ) }}
207       WebDAV:
208         InternalURLs:
209           "http://0.0.0.0:9008/": {}
210         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4448/" ) }}
211       WebDAVDownload:
212         InternalURLs:
213           "http://0.0.0.0:9009/": {}
214         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4449/" ) }}
215       Keepstore:
216         InternalURLs:
217           "http://0.0.0.0:9010/": {}
218       Composer:
219         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4459/composer" ) }}
220       Workbench1:
221         InternalURLs:
222           "http://0.0.0.0:9002/": {}
223         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4442/" ) }}
224       Workbench2:
225         InternalURLs:
226           "http://0.0.0.0:9003/": {}
227         ExternalURL: {{printf "%q" ( print "https://" .Domain "/" ) }}
228       Health:
229         InternalURLs:
230           "http://0.0.0.0:9011/": {}
231     Collections:
232       BlobSigningKey: {{printf "%q" ( .RandomHex 50 )}}
233       {{if eq .TLS "insecure"}}
234       TrustAllContent: true
235       {{end}}
236     Containers:
237       DispatchPrivateKey: {{printf "%q" .GenerateSSHPrivateKey}}
238       CloudVMs:
239         Enable: true
240         Driver: loopback
241     ManagementToken: {{printf "%q" ( .RandomHex 50 )}}
242     PostgreSQL:
243       Connection:
244         dbname: arvados
245         host: localhost
246         user: arvados
247         password: {{printf "%q" .PostgreSQLPassword}}
248     SystemRootToken: {{printf "%q" ( .RandomHex 50 )}}
249     TLS:
250       {{if eq .TLS "insecure"}}
251       Insecure: true
252       {{else if eq .TLS "auto"}}
253       Automatic: true
254       {{else if eq .TLS "acme"}}
255       Certificate: {{printf "%q" (print "/var/lib/acme/live/" .Domain "/cert")}}
256       Key: {{printf "%q" (print "/var/lib/acme/live/" .Domain "/privkey")}}
257       {{else}}
258       {}
259       {{end}}
260     Volumes:
261       {{.ClusterID}}-nyw5e-000000000000000:
262         Driver: Directory
263         DriverParameters:
264           Root: /var/lib/arvados/keep
265         Replication: 2
266     Workbench:
267       SecretKeyBase: {{printf "%q" ( .RandomHex 50 )}}
268     {{if .LoginPAM}}
269     Login:
270       PAM:
271         Enable: true
272     {{else if .LoginTest}}
273     Login:
274       Test:
275         Enable: true
276         Users:
277           admin:
278             Email: {{printf "%q" .AdminEmail}}
279             Password: admin
280     {{else if .LoginGoogle}}
281     Login:
282       Google:
283         Enable: true
284         ClientID: {{printf "%q" .LoginGoogleClientID}}
285         ClientSecret: {{printf "%q" .LoginGoogleClientSecret}}
286     {{end}}
287     Users:
288       AutoAdminUserWithEmail: {{printf "%q" .AdminEmail}}
289 `)
290         if err != nil {
291                 return 1
292         }
293         err = tmpl.Execute(f, initcmd)
294         if err != nil {
295                 err = fmt.Errorf("%s: tmpl.Execute: %w", conffile+".tmp", err)
296                 return 1
297         }
298         err = f.Close()
299         if err != nil {
300                 err = fmt.Errorf("%s: close: %w", conffile+".tmp", err)
301                 return 1
302         }
303         err = os.Rename(conffile+".tmp", conffile)
304         if err != nil {
305                 err = fmt.Errorf("rename %s -> %s: %w", conffile+".tmp", conffile, err)
306                 return 1
307         }
308         fmt.Fprintln(stderr, "...done")
309
310         ldr := config.NewLoader(nil, logger)
311         ldr.SkipLegacy = true
312         ldr.Path = conffile // load the file we just wrote, even if $ARVADOS_CONFIG is set
313         cfg, err := ldr.Load()
314         if err != nil {
315                 err = fmt.Errorf("%s: %w", conffile, err)
316                 return 1
317         }
318         cluster, err := cfg.GetCluster("")
319         if err != nil {
320                 return 1
321         }
322
323         fmt.Fprintln(stderr, "creating postresql user and database...")
324         err = initcmd.createDB(ctx, cluster.PostgreSQL.Connection, stderr)
325         if err != nil {
326                 return 1
327         }
328         fmt.Fprintln(stderr, "...done")
329
330         fmt.Fprintln(stderr, "initializing database...")
331         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")
332         cmd.Dir = "/var/lib/arvados/railsapi"
333         cmd.Stdout = stderr
334         cmd.Stderr = stderr
335         err = cmd.Run()
336         if err != nil {
337                 err = fmt.Errorf("rake db:setup failed: %w", err)
338                 return 1
339         }
340         fmt.Fprintln(stderr, "...done")
341
342         if initcmd.Start {
343                 fmt.Fprintln(stderr, "starting systemd service...")
344                 cmd := exec.CommandContext(ctx, "systemctl", "start", "arvados")
345                 cmd.Dir = "/"
346                 cmd.Stdout = stderr
347                 cmd.Stderr = stderr
348                 err = cmd.Run()
349                 if err != nil {
350                         err = fmt.Errorf("%v: %w", cmd.Args, err)
351                         return 1
352                 }
353                 fmt.Fprintln(stderr, "...done")
354
355                 fmt.Fprintln(stderr, "checking controller API endpoint...")
356                 u := url.URL(cluster.Services.Controller.ExternalURL)
357                 conn := rpc.NewConn(cluster.ClusterID, &u, cluster.TLS.Insecure, rpc.PassthroughTokenProvider)
358                 ctx := auth.NewContext(context.Background(), auth.NewCredentials(cluster.SystemRootToken))
359                 _, err = conn.UserGetCurrent(ctx, arvados.GetOptions{})
360                 if err != nil {
361                         err = fmt.Errorf("API request failed: %w", err)
362                         return 1
363                 }
364                 fmt.Fprintln(stderr, "...looks good")
365         }
366
367         if out, err := exec.CommandContext(ctx, "docker", "version").CombinedOutput(); err == nil && strings.Contains(string(out), "\nServer:\n") {
368                 fmt.Fprintln(stderr, "loading alpine docker image for diagnostics...")
369                 cmd := exec.CommandContext(ctx, "docker", "pull", "alpine")
370                 cmd.Stdout = stderr
371                 cmd.Stderr = stderr
372                 err = cmd.Run()
373                 if err != nil {
374                         err = fmt.Errorf("%v: %w", cmd.Args, err)
375                         return 1
376                 }
377                 cmd = exec.CommandContext(ctx, "arv", "sudo", "keep", "docker", "alpine")
378                 cmd.Stdout = stderr
379                 cmd.Stderr = stderr
380                 err = cmd.Run()
381                 if err != nil {
382                         err = fmt.Errorf("%v: %w", cmd.Args, err)
383                         return 1
384                 }
385                 fmt.Fprintln(stderr, "...done")
386         } else {
387                 fmt.Fprintln(stderr, "docker is not installed -- skipping step of downloading 'alpine' image")
388         }
389
390         fmt.Fprintln(stderr, "Setup complete. You should now be able to log in to workbench2 at", cluster.Services.Workbench2.ExternalURL.String())
391
392         return 0
393 }
394
395 func (initcmd *initCommand) GenerateSSHPrivateKey() (string, error) {
396         privkey, err := rsa.GenerateKey(rand.Reader, 4096)
397         if err != nil {
398                 return "", err
399         }
400         err = privkey.Validate()
401         if err != nil {
402                 return "", err
403         }
404         return string(pem.EncodeToMemory(&pem.Block{
405                 Type:  "RSA PRIVATE KEY",
406                 Bytes: x509.MarshalPKCS1PrivateKey(privkey),
407         })), nil
408 }
409
410 func (initcmd *initCommand) RandomHex(chars int) string {
411         b := make([]byte, chars/2)
412         _, err := rand.Read(b)
413         if err != nil {
414                 panic(err)
415         }
416         return fmt.Sprintf("%x", b)
417 }
418
419 func (initcmd *initCommand) createDB(ctx context.Context, dbconn arvados.PostgreSQLConnection, stderr io.Writer) error {
420         cmd := exec.CommandContext(ctx, "sudo", "-u", "postgres", "psql", "--quiet",
421                 "-c", `CREATE USER `+pq.QuoteIdentifier(dbconn["user"])+` WITH SUPERUSER ENCRYPTED PASSWORD `+pq.QuoteLiteral(dbconn["password"]),
422                 "-c", `CREATE DATABASE `+pq.QuoteIdentifier(dbconn["dbname"])+` WITH TEMPLATE template0 ENCODING 'utf8'`,
423         )
424         cmd.Dir = "/"
425         cmd.Stdout = stderr
426         cmd.Stderr = stderr
427         err := cmd.Run()
428         if err != nil {
429                 return fmt.Errorf("error setting up arvados user/database: %w", err)
430         }
431         return nil
432 }
433
434 // Confirm that http://{initcmd.Domain}:{port} reaches a server that
435 // we run on {port}.
436 //
437 // If port is "80", listening fails, and Nginx appears to be using the
438 // debian-packaged default configuration that listens on port 80,
439 // disable that Nginx config and try again.
440 //
441 // (Typically, the reason Nginx is installed is so that Arvados can
442 // run an Nginx child process; the default Nginx service using config
443 // from /etc/nginx is just an unfortunate side effect of installing
444 // Nginx by way of the Debian package.)
445 func (initcmd *initCommand) checkPort(ctx context.Context, port string) error {
446         err := initcmd.checkPortOnce(ctx, port)
447         if err == nil || port != "80" {
448                 // success, or poking Nginx in the eye won't help
449                 return err
450         }
451         d, err2 := os.Open("/etc/nginx/sites-enabled/.")
452         if err2 != nil {
453                 return err
454         }
455         fis, err2 := d.Readdir(-1)
456         if err2 != nil || len(fis) != 1 {
457                 return err
458         }
459         if target, err2 := os.Readlink("/etc/nginx/sites-enabled/default"); err2 != nil || target != "/etc/nginx/sites-available/default" {
460                 return err
461         }
462         err2 = os.Remove("/etc/nginx/sites-enabled/default")
463         if err2 != nil {
464                 return err
465         }
466         exec.CommandContext(ctx, "nginx", "-s", "reload").Run()
467         time.Sleep(time.Second)
468         return initcmd.checkPortOnce(ctx, port)
469 }
470
471 // Start an http server on 0.0.0.0:{port} and confirm that
472 // http://{initcmd.Domain}:{port} reaches that server.
473 func (initcmd *initCommand) checkPortOnce(ctx context.Context, port string) error {
474         b := make([]byte, 128)
475         _, err := rand.Read(b)
476         if err != nil {
477                 return err
478         }
479         token := fmt.Sprintf("%x", b)
480
481         srv := http.Server{
482                 Addr: net.JoinHostPort("", port),
483                 Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
484                         fmt.Fprint(w, token)
485                 })}
486         var errServe atomic.Value
487         go func() {
488                 errServe.Store(srv.ListenAndServe())
489         }()
490         defer srv.Close()
491         url := "http://" + net.JoinHostPort(initcmd.Domain, port) + "/probe"
492         req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
493         if err != nil {
494                 return err
495         }
496         resp, err := http.DefaultClient.Do(req)
497         if errServe, _ := errServe.Load().(error); errServe != nil {
498                 // If server already exited, return that error
499                 // (probably "can't listen"), not the request error.
500                 return errServe
501         }
502         if err != nil {
503                 return err
504         }
505         buf := make([]byte, len(token))
506         n, err := io.ReadFull(resp.Body, buf)
507         if string(buf[:n]) != token {
508                 return fmt.Errorf("listened on port %s but %s connected to something else, returned %q, err %v", port, url, buf[:n], err)
509         }
510         return nil
511 }