f6691b9b1ab63db840a80764ef20a918181d1da9
[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         wwwuser, err := user.Lookup("www-data")
115         if err != nil {
116                 err = fmt.Errorf("user.Lookup(%q): %w", "www-data", err)
117                 return 1
118         }
119         wwwgid, err := strconv.Atoi(wwwuser.Gid)
120         if err != nil {
121                 return 1
122         }
123         initcmd.PostgreSQLPassword = initcmd.RandomHex(32)
124
125         fmt.Fprintln(stderr, "creating data storage directory /var/lib/arvados/keep ...")
126         err = os.Mkdir("/var/lib/arvados/keep", 0600)
127         if err != nil && !os.IsExist(err) {
128                 err = fmt.Errorf("mkdir /var/lib/arvados/keep: %w", err)
129                 return 1
130         }
131         fmt.Fprintln(stderr, "...done")
132
133         fmt.Fprintln(stderr, "creating config file", conffile, "...")
134         err = os.Mkdir(confdir, 0750)
135         if err != nil && !os.IsExist(err) {
136                 err = fmt.Errorf("mkdir %s: %w", confdir, err)
137                 return 1
138         }
139         err = os.Chown(confdir, 0, wwwgid)
140         if err != nil {
141                 err = fmt.Errorf("chown 0:%d %s: %w", wwwgid, confdir, err)
142                 return 1
143         }
144         f, err := os.OpenFile(conffile+".tmp", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
145         if err != nil {
146                 err = fmt.Errorf("open %s: %w", conffile+".tmp", err)
147                 return 1
148         }
149         tmpl, err := template.New("config").Parse(`Clusters:
150   {{.ClusterID}}:
151     Services:
152       Controller:
153         InternalURLs:
154           "http://0.0.0.0:9000/": {}
155         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4440/" ) }}
156       RailsAPI:
157         InternalURLs:
158           "http://0.0.0.0:9001/": {}
159       Websocket:
160         InternalURLs:
161           "http://0.0.0.0:8005/": {}
162         ExternalURL: {{printf "%q" ( print "wss://" .Domain ":4446/" ) }}
163       Keepbalance:
164         InternalURLs:
165           "http://0.0.0.0:9019/": {}
166       GitHTTP:
167         InternalURLs:
168           "http://0.0.0.0:9005/": {}
169         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4445/" ) }}
170       DispatchCloud:
171         InternalURLs:
172           "http://0.0.0.0:9006/": {}
173       Keepproxy:
174         InternalURLs:
175           "http://0.0.0.0:9007/": {}
176         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4447/" ) }}
177       WebDAV:
178         InternalURLs:
179           "http://0.0.0.0:9008/": {}
180         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4448/" ) }}
181       WebDAVDownload:
182         InternalURLs:
183           "http://0.0.0.0:9009/": {}
184         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4449/" ) }}
185       Keepstore:
186         InternalURLs:
187           "http://0.0.0.0:9010/": {}
188       Composer:
189         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4459/composer" ) }}
190       Workbench1:
191         InternalURLs:
192           "http://0.0.0.0:9002/": {}
193         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4442/" ) }}
194       Workbench2:
195         InternalURLs:
196           "http://0.0.0.0:9003/": {}
197         ExternalURL: {{printf "%q" ( print "https://" .Domain "/" ) }}
198       Health:
199         InternalURLs:
200           "http://0.0.0.0:9011/": {}
201     Collections:
202       BlobSigningKey: {{printf "%q" ( .RandomHex 50 )}}
203       {{if eq .TLS "insecure"}}
204       TrustAllContent: true
205       {{end}}
206     Containers:
207       DispatchPrivateKey: {{printf "%q" .GenerateSSHPrivateKey}}
208       CloudVMs:
209         Enable: true
210         Driver: loopback
211     ManagementToken: {{printf "%q" ( .RandomHex 50 )}}
212     PostgreSQL:
213       Connection:
214         dbname: arvados
215         host: localhost
216         user: arvados
217         password: {{printf "%q" .PostgreSQLPassword}}
218     SystemRootToken: {{printf "%q" ( .RandomHex 50 )}}
219     TLS:
220       {{if eq .TLS "insecure"}}
221       Insecure: true
222       {{else if eq .TLS "auto"}}
223       Automatic: true
224       {{else if eq .TLS "acme"}}
225       Certificate: {{printf "%q" (print "/var/lib/acme/live/" .Domain "/cert")}}
226       Key: {{printf "%q" (print "/var/lib/acme/live/" .Domain "/privkey")}}
227       {{else}}
228       {}
229       {{end}}
230     Volumes:
231       {{.ClusterID}}-nyw5e-000000000000000:
232         Driver: Directory
233         DriverParameters:
234           Root: /var/lib/arvados/keep
235         Replication: 2
236     Workbench:
237       SecretKeyBase: {{printf "%q" ( .RandomHex 50 )}}
238     {{if .LoginPAM}}
239     Login:
240       PAM:
241         Enable: true
242     {{else if .LoginTest}}
243     Login:
244       Test:
245         Enable: true
246         Users:
247           admin:
248             Email: {{printf "%q" .AdminEmail}}
249             Password: admin
250     {{else if .LoginGoogle}}
251     Login:
252       Google:
253         Enable: true
254         ClientID: {{printf "%q" .LoginGoogleClientID}}
255         ClientSecret: {{printf "%q" .LoginGoogleClientSecret}}
256     {{end}}
257     Users:
258       AutoAdminUserWithEmail: {{printf "%q" .AdminEmail}}
259 `)
260         if err != nil {
261                 return 1
262         }
263         err = tmpl.Execute(f, initcmd)
264         if err != nil {
265                 err = fmt.Errorf("%s: tmpl.Execute: %w", conffile+".tmp", err)
266                 return 1
267         }
268         err = f.Close()
269         if err != nil {
270                 err = fmt.Errorf("%s: close: %w", conffile+".tmp", err)
271                 return 1
272         }
273         err = os.Rename(conffile+".tmp", conffile)
274         if err != nil {
275                 err = fmt.Errorf("rename %s -> %s: %w", conffile+".tmp", conffile, err)
276                 return 1
277         }
278         fmt.Fprintln(stderr, "...done")
279
280         ldr := config.NewLoader(nil, logger)
281         ldr.SkipLegacy = true
282         cfg, err := ldr.Load()
283         if err != nil {
284                 err = fmt.Errorf("%s: %w", conffile, err)
285                 return 1
286         }
287         cluster, err := cfg.GetCluster("")
288         if err != nil {
289                 return 1
290         }
291
292         fmt.Fprintln(stderr, "creating postresql user and database...")
293         err = initcmd.createDB(ctx, cluster.PostgreSQL.Connection, stderr)
294         if err != nil {
295                 return 1
296         }
297         fmt.Fprintln(stderr, "...done")
298
299         fmt.Fprintln(stderr, "initializing database...")
300         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")
301         cmd.Dir = "/var/lib/arvados/railsapi"
302         cmd.Stdout = stderr
303         cmd.Stderr = stderr
304         err = cmd.Run()
305         if err != nil {
306                 err = fmt.Errorf("rake db:setup failed: %w", err)
307                 return 1
308         }
309         fmt.Fprintln(stderr, "...done")
310
311         if initcmd.Start {
312                 fmt.Fprintln(stderr, "starting systemd service...")
313                 cmd := exec.CommandContext(ctx, "systemctl", "start", "arvados")
314                 cmd.Dir = "/"
315                 cmd.Stdout = stderr
316                 cmd.Stderr = stderr
317                 err = cmd.Run()
318                 if err != nil {
319                         err = fmt.Errorf("%v: %w", cmd.Args, err)
320                         return 1
321                 }
322
323                 fmt.Fprintln(stderr, "checking controller API endpoint...")
324                 u := url.URL(cluster.Services.Controller.ExternalURL)
325                 conn := rpc.NewConn(cluster.ClusterID, &u, cluster.TLS.Insecure, rpc.PassthroughTokenProvider)
326                 ctx := auth.NewContext(context.Background(), auth.NewCredentials(cluster.SystemRootToken))
327                 _, err = conn.UserGetCurrent(ctx, arvados.GetOptions{})
328                 if err != nil {
329                         err = fmt.Errorf("API request failed: %w", err)
330                         return 1
331                 }
332                 fmt.Fprintln(stderr, "...looks good")
333         }
334
335         fmt.Fprintln(stderr, "Setup complete. You should now be able to log in to workbench2 at", cluster.Services.Workbench2.ExternalURL.String())
336
337         return 0
338 }
339
340 func (initcmd *initCommand) GenerateSSHPrivateKey() (string, error) {
341         privkey, err := rsa.GenerateKey(rand.Reader, 4096)
342         if err != nil {
343                 return "", err
344         }
345         err = privkey.Validate()
346         if err != nil {
347                 return "", err
348         }
349         return string(pem.EncodeToMemory(&pem.Block{
350                 Type:  "RSA PRIVATE KEY",
351                 Bytes: x509.MarshalPKCS1PrivateKey(privkey),
352         })), nil
353 }
354
355 func (initcmd *initCommand) RandomHex(chars int) string {
356         b := make([]byte, chars/2)
357         _, err := rand.Read(b)
358         if err != nil {
359                 panic(err)
360         }
361         return fmt.Sprintf("%x", b)
362 }
363
364 func (initcmd *initCommand) createDB(ctx context.Context, dbconn arvados.PostgreSQLConnection, stderr io.Writer) error {
365         cmd := exec.CommandContext(ctx, "sudo", "-u", "postgres", "psql", "--quiet",
366                 "-c", `CREATE USER `+pq.QuoteIdentifier(dbconn["user"])+` WITH SUPERUSER ENCRYPTED PASSWORD `+pq.QuoteLiteral(dbconn["password"]),
367                 "-c", `CREATE DATABASE `+pq.QuoteIdentifier(dbconn["dbname"])+` WITH TEMPLATE template0 ENCODING 'utf8'`,
368                 "-c", `CREATE EXTENSION IF NOT EXISTS pg_trgm`,
369         )
370         cmd.Dir = "/"
371         cmd.Stdout = stderr
372         cmd.Stderr = stderr
373         err := cmd.Run()
374         if err != nil {
375                 return fmt.Errorf("error setting up arvados user/database: %w", err)
376         }
377         return nil
378 }