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