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