11a62f18e98c8a37bf79e962b811c74d40c7033c
[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/url"
17         "os"
18         "os/exec"
19         "os/user"
20         "regexp"
21         "strconv"
22         "strings"
23         "text/template"
24
25         "git.arvados.org/arvados.git/lib/cmd"
26         "git.arvados.org/arvados.git/lib/config"
27         "git.arvados.org/arvados.git/lib/controller/rpc"
28         "git.arvados.org/arvados.git/sdk/go/arvados"
29         "git.arvados.org/arvados.git/sdk/go/auth"
30         "git.arvados.org/arvados.git/sdk/go/ctxlog"
31         "github.com/lib/pq"
32 )
33
34 var InitCommand cmd.Handler = &initCommand{}
35
36 type initCommand struct {
37         ClusterID          string
38         Domain             string
39         PostgreSQLPassword string
40         Login              string
41         TLS                string
42         AdminEmail         string
43         Start              bool
44
45         LoginPAM                bool
46         LoginTest               bool
47         LoginGoogle             bool
48         LoginGoogleClientID     string
49         LoginGoogleClientSecret string
50 }
51
52 func (initcmd *initCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
53         logger := ctxlog.New(stderr, "text", "info")
54         ctx := ctxlog.Context(context.Background(), logger)
55         ctx, cancel := context.WithCancel(ctx)
56         defer cancel()
57
58         var err error
59         defer func() {
60                 if err != nil {
61                         logger.WithError(err).Info("exiting")
62                 }
63         }()
64
65         hostname, err := os.Hostname()
66         if err != nil {
67                 err = fmt.Errorf("Hostname(): %w", err)
68                 return 1
69         }
70
71         flags := flag.NewFlagSet(prog, flag.ContinueOnError)
72         flags.SetOutput(stderr)
73         versionFlag := flags.Bool("version", false, "Write version information to stdout and exit 0")
74         flags.StringVar(&initcmd.ClusterID, "cluster-id", "", "cluster `id`, like x1234 for a dev cluster")
75         flags.StringVar(&initcmd.Domain, "domain", hostname, "cluster public DNS `name`, like x1234.arvadosapi.com")
76         flags.StringVar(&initcmd.Login, "login", "", "login `backend`: test, pam, 'google {client-id} {client-secret}', or ''")
77         flags.StringVar(&initcmd.AdminEmail, "admin-email", "", "give admin privileges to user with given `email`")
78         flags.StringVar(&initcmd.TLS, "tls", "none", "tls certificate `source`: acme, auto, insecure, or none")
79         flags.BoolVar(&initcmd.Start, "start", true, "start systemd service after creating config")
80         if ok, code := cmd.ParseFlags(flags, prog, args, "", stderr); !ok {
81                 return code
82         } else if *versionFlag {
83                 return cmd.Version.RunCommand(prog, args, stdin, stdout, stderr)
84         } else if !regexp.MustCompile(`^[a-z][a-z0-9]{4}`).MatchString(initcmd.ClusterID) {
85                 err = fmt.Errorf("cluster ID %q is invalid; must be an ASCII letter followed by 4 alphanumerics (try -help)", initcmd.ClusterID)
86                 return 1
87         }
88
89         if fields := strings.Fields(initcmd.Login); len(fields) == 3 && fields[0] == "google" {
90                 initcmd.LoginGoogle = true
91                 initcmd.LoginGoogleClientID = fields[1]
92                 initcmd.LoginGoogleClientSecret = fields[2]
93         } else if initcmd.Login == "test" {
94                 initcmd.LoginTest = true
95                 if initcmd.AdminEmail == "" {
96                         initcmd.AdminEmail = "admin@example.com"
97                 }
98         } else if initcmd.Login == "pam" {
99                 initcmd.LoginPAM = true
100         } else if initcmd.Login == "" {
101                 // none; login will show an error page
102         } else {
103                 err = fmt.Errorf("invalid argument to -login: %q: should be 'test', 'pam', 'google {client-id} {client-secret}', or empty", initcmd.Login)
104                 return 1
105         }
106
107         confdir := "/etc/arvados"
108         conffile := confdir + "/config.yml"
109         if _, err = os.Stat(conffile); err == nil {
110                 err = fmt.Errorf("config file %s already exists; delete it first if you really want to start over", conffile)
111                 return 1
112         }
113
114         // Do the "create extension" thing early. This way, if there's
115         // no local postgresql server (a likely failure mode), we can
116         // bail out without any side effects, and the user can start
117         // over easily.
118         fmt.Fprintln(stderr, "installing pg_trgm postgresql extension...")
119         cmd := exec.CommandContext(ctx, "sudo", "-u", "postgres", "psql", "--quiet",
120                 "-c", `CREATE EXTENSION IF NOT EXISTS pg_trgm`)
121         cmd.Dir = "/"
122         cmd.Stdout = stdout
123         cmd.Stderr = stderr
124         err = cmd.Run()
125         if err != nil {
126                 err = fmt.Errorf("error preparing postgresql server: %w", err)
127                 return 1
128         }
129         fmt.Fprintln(stderr, "...done")
130
131         wwwuser, err := user.Lookup("www-data")
132         if err != nil {
133                 err = fmt.Errorf("user.Lookup(%q): %w", "www-data", err)
134                 return 1
135         }
136         wwwgid, err := strconv.Atoi(wwwuser.Gid)
137         if err != nil {
138                 return 1
139         }
140         initcmd.PostgreSQLPassword = initcmd.RandomHex(32)
141
142         fmt.Fprintln(stderr, "creating data storage directory /var/lib/arvados/keep ...")
143         err = os.Mkdir("/var/lib/arvados/keep", 0600)
144         if err != nil && !os.IsExist(err) {
145                 err = fmt.Errorf("mkdir /var/lib/arvados/keep: %w", err)
146                 return 1
147         }
148         fmt.Fprintln(stderr, "...done")
149
150         fmt.Fprintln(stderr, "creating config file", conffile, "...")
151         err = os.Mkdir(confdir, 0750)
152         if err != nil && !os.IsExist(err) {
153                 err = fmt.Errorf("mkdir %s: %w", confdir, err)
154                 return 1
155         }
156         err = os.Chown(confdir, 0, wwwgid)
157         if err != nil {
158                 err = fmt.Errorf("chown 0:%d %s: %w", wwwgid, confdir, err)
159                 return 1
160         }
161         f, err := os.OpenFile(conffile+".tmp", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
162         if err != nil {
163                 err = fmt.Errorf("open %s: %w", conffile+".tmp", err)
164                 return 1
165         }
166         tmpl, err := template.New("config").Parse(`Clusters:
167   {{.ClusterID}}:
168     Services:
169       Controller:
170         InternalURLs:
171           "http://0.0.0.0:9000/": {}
172         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4440/" ) }}
173       RailsAPI:
174         InternalURLs:
175           "http://0.0.0.0:9001/": {}
176       Websocket:
177         InternalURLs:
178           "http://0.0.0.0:8005/": {}
179         ExternalURL: {{printf "%q" ( print "wss://" .Domain ":4446/" ) }}
180       Keepbalance:
181         InternalURLs:
182           "http://0.0.0.0:9019/": {}
183       GitHTTP:
184         InternalURLs:
185           "http://0.0.0.0:9005/": {}
186         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4445/" ) }}
187       DispatchCloud:
188         InternalURLs:
189           "http://0.0.0.0:9006/": {}
190       Keepproxy:
191         InternalURLs:
192           "http://0.0.0.0:9007/": {}
193         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4447/" ) }}
194       WebDAV:
195         InternalURLs:
196           "http://0.0.0.0:9008/": {}
197         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4448/" ) }}
198       WebDAVDownload:
199         InternalURLs:
200           "http://0.0.0.0:9009/": {}
201         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4449/" ) }}
202       Keepstore:
203         InternalURLs:
204           "http://0.0.0.0:9010/": {}
205       Composer:
206         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4459/composer" ) }}
207       Workbench1:
208         InternalURLs:
209           "http://0.0.0.0:9002/": {}
210         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4442/" ) }}
211       Workbench2:
212         InternalURLs:
213           "http://0.0.0.0:9003/": {}
214         ExternalURL: {{printf "%q" ( print "https://" .Domain "/" ) }}
215       Health:
216         InternalURLs:
217           "http://0.0.0.0:9011/": {}
218     Collections:
219       BlobSigningKey: {{printf "%q" ( .RandomHex 50 )}}
220       {{if eq .TLS "insecure"}}
221       TrustAllContent: true
222       {{end}}
223     Containers:
224       DispatchPrivateKey: {{printf "%q" .GenerateSSHPrivateKey}}
225       CloudVMs:
226         Enable: true
227         Driver: loopback
228     ManagementToken: {{printf "%q" ( .RandomHex 50 )}}
229     PostgreSQL:
230       Connection:
231         dbname: arvados
232         host: localhost
233         user: arvados
234         password: {{printf "%q" .PostgreSQLPassword}}
235     SystemRootToken: {{printf "%q" ( .RandomHex 50 )}}
236     TLS:
237       {{if eq .TLS "insecure"}}
238       Insecure: true
239       {{else if eq .TLS "auto"}}
240       Automatic: true
241       {{else if eq .TLS "acme"}}
242       Certificate: {{printf "%q" (print "/var/lib/acme/live/" .Domain "/cert")}}
243       Key: {{printf "%q" (print "/var/lib/acme/live/" .Domain "/privkey")}}
244       {{else}}
245       {}
246       {{end}}
247     Volumes:
248       {{.ClusterID}}-nyw5e-000000000000000:
249         Driver: Directory
250         DriverParameters:
251           Root: /var/lib/arvados/keep
252         Replication: 2
253     Workbench:
254       SecretKeyBase: {{printf "%q" ( .RandomHex 50 )}}
255     {{if .LoginPAM}}
256     Login:
257       PAM:
258         Enable: true
259     {{else if .LoginTest}}
260     Login:
261       Test:
262         Enable: true
263         Users:
264           admin:
265             Email: {{printf "%q" .AdminEmail}}
266             Password: admin
267     {{else if .LoginGoogle}}
268     Login:
269       Google:
270         Enable: true
271         ClientID: {{printf "%q" .LoginGoogleClientID}}
272         ClientSecret: {{printf "%q" .LoginGoogleClientSecret}}
273     {{end}}
274     Users:
275       AutoAdminUserWithEmail: {{printf "%q" .AdminEmail}}
276 `)
277         if err != nil {
278                 return 1
279         }
280         err = tmpl.Execute(f, initcmd)
281         if err != nil {
282                 err = fmt.Errorf("%s: tmpl.Execute: %w", conffile+".tmp", err)
283                 return 1
284         }
285         err = f.Close()
286         if err != nil {
287                 err = fmt.Errorf("%s: close: %w", conffile+".tmp", err)
288                 return 1
289         }
290         err = os.Rename(conffile+".tmp", conffile)
291         if err != nil {
292                 err = fmt.Errorf("rename %s -> %s: %w", conffile+".tmp", conffile, err)
293                 return 1
294         }
295         fmt.Fprintln(stderr, "...done")
296
297         ldr := config.NewLoader(nil, logger)
298         ldr.SkipLegacy = true
299         ldr.Path = conffile // load the file we just wrote, even if $ARVADOS_CONFIG is set
300         cfg, err := ldr.Load()
301         if err != nil {
302                 err = fmt.Errorf("%s: %w", conffile, err)
303                 return 1
304         }
305         cluster, err := cfg.GetCluster("")
306         if err != nil {
307                 return 1
308         }
309
310         fmt.Fprintln(stderr, "creating postresql user and database...")
311         err = initcmd.createDB(ctx, cluster.PostgreSQL.Connection, stderr)
312         if err != nil {
313                 return 1
314         }
315         fmt.Fprintln(stderr, "...done")
316
317         fmt.Fprintln(stderr, "initializing database...")
318         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")
319         cmd.Dir = "/var/lib/arvados/railsapi"
320         cmd.Stdout = stderr
321         cmd.Stderr = stderr
322         err = cmd.Run()
323         if err != nil {
324                 err = fmt.Errorf("rake db:setup failed: %w", err)
325                 return 1
326         }
327         fmt.Fprintln(stderr, "...done")
328
329         if initcmd.Start {
330                 fmt.Fprintln(stderr, "starting systemd service...")
331                 cmd := exec.CommandContext(ctx, "systemctl", "start", "arvados")
332                 cmd.Dir = "/"
333                 cmd.Stdout = stderr
334                 cmd.Stderr = stderr
335                 err = cmd.Run()
336                 if err != nil {
337                         err = fmt.Errorf("%v: %w", cmd.Args, err)
338                         return 1
339                 }
340                 fmt.Fprintln(stderr, "...done")
341
342                 fmt.Fprintln(stderr, "checking controller API endpoint...")
343                 u := url.URL(cluster.Services.Controller.ExternalURL)
344                 conn := rpc.NewConn(cluster.ClusterID, &u, cluster.TLS.Insecure, rpc.PassthroughTokenProvider)
345                 ctx := auth.NewContext(context.Background(), auth.NewCredentials(cluster.SystemRootToken))
346                 _, err = conn.UserGetCurrent(ctx, arvados.GetOptions{})
347                 if err != nil {
348                         err = fmt.Errorf("API request failed: %w", err)
349                         return 1
350                 }
351                 fmt.Fprintln(stderr, "...looks good")
352         }
353
354         fmt.Fprintln(stderr, "Setup complete. You should now be able to log in to workbench2 at", cluster.Services.Workbench2.ExternalURL.String())
355
356         return 0
357 }
358
359 func (initcmd *initCommand) GenerateSSHPrivateKey() (string, error) {
360         privkey, err := rsa.GenerateKey(rand.Reader, 4096)
361         if err != nil {
362                 return "", err
363         }
364         err = privkey.Validate()
365         if err != nil {
366                 return "", err
367         }
368         return string(pem.EncodeToMemory(&pem.Block{
369                 Type:  "RSA PRIVATE KEY",
370                 Bytes: x509.MarshalPKCS1PrivateKey(privkey),
371         })), nil
372 }
373
374 func (initcmd *initCommand) RandomHex(chars int) string {
375         b := make([]byte, chars/2)
376         _, err := rand.Read(b)
377         if err != nil {
378                 panic(err)
379         }
380         return fmt.Sprintf("%x", b)
381 }
382
383 func (initcmd *initCommand) createDB(ctx context.Context, dbconn arvados.PostgreSQLConnection, stderr io.Writer) error {
384         cmd := exec.CommandContext(ctx, "sudo", "-u", "postgres", "psql", "--quiet",
385                 "-c", `CREATE USER `+pq.QuoteIdentifier(dbconn["user"])+` WITH SUPERUSER ENCRYPTED PASSWORD `+pq.QuoteLiteral(dbconn["password"]),
386                 "-c", `CREATE DATABASE `+pq.QuoteIdentifier(dbconn["dbname"])+` WITH TEMPLATE template0 ENCODING 'utf8'`,
387         )
388         cmd.Dir = "/"
389         cmd.Stdout = stderr
390         cmd.Stderr = stderr
391         err := cmd.Run()
392         if err != nil {
393                 return fmt.Errorf("error setting up arvados user/database: %w", err)
394         }
395         return nil
396 }