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