Merge commit '3b735dd9330e0989f51a76771c3303031154154e' into 21158-wf-page-list
[arvados.git] / lib / boot / rails_db.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package boot
6
7 import (
8         "context"
9         "io/fs"
10         "os"
11         "path/filepath"
12         "strings"
13
14         "git.arvados.org/arvados.git/lib/controller/dblock"
15         "git.arvados.org/arvados.git/lib/ctrlctx"
16         "github.com/sirupsen/logrus"
17 )
18
19 type railsDatabase struct{}
20
21 func (runner railsDatabase) String() string {
22         return "railsDatabase"
23 }
24
25 // Run checks for and applies any pending Rails database migrations.
26 //
27 // If running a dev/test environment, and the database is empty, it
28 // initializes the database.
29 func (runner railsDatabase) Run(ctx context.Context, fail func(error), super *Supervisor) error {
30         err := super.wait(ctx, runPostgreSQL{}, installPassenger{src: "services/api"})
31         if err != nil {
32                 return err
33         }
34
35         // determine path to installed rails app or source tree
36         var appdir string
37         if super.ClusterType == "production" {
38                 appdir = "/var/lib/arvados/railsapi"
39         } else {
40                 appdir = filepath.Join(super.SourcePath, "services/api")
41         }
42
43         // Check for pending migrations before running rake.
44         //
45         // In principle, we could use "rake db:migrate:status" or skip
46         // this check entirely and let "rake db:migrate" be a no-op
47         // most of the time.  However, in the most common case when
48         // there are no new migrations, that would add ~2s to startup
49         // time / downtime during service restart.
50
51         todo, err := migrationList(appdir, super.logger)
52         if err != nil {
53                 return err
54         }
55
56         // read schema_migrations table (list of migrations already
57         // applied) and remove those entries from todo
58         dbconnector := ctrlctx.DBConnector{PostgreSQL: super.cluster.PostgreSQL}
59         defer dbconnector.Close()
60         db, err := dbconnector.GetDB(ctx)
61         if err != nil {
62                 return err
63         }
64         rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`)
65         if err != nil {
66                 if super.ClusterType == "production" {
67                         return err
68                 }
69                 super.logger.WithError(err).Info("schema_migrations query failed, trying db:setup")
70                 return super.RunProgram(ctx, "services/api", runOptions{env: railsEnv}, "bundle", "exec", "rake", "db:setup")
71         }
72         for rows.Next() {
73                 var v string
74                 err = rows.Scan(&v)
75                 if err != nil {
76                         return err
77                 }
78                 delete(todo, v)
79         }
80         err = rows.Close()
81         if err != nil {
82                 return err
83         }
84
85         // if nothing remains in todo, all available migrations are
86         // done, so return without running any [relatively slow]
87         // ruby/rake commands
88         if len(todo) == 0 {
89                 return nil
90         }
91
92         super.logger.Infof("%d migrations pending", len(todo))
93         if !dblock.RailsMigrations.Lock(ctx, dbconnector.GetDB) {
94                 return context.Canceled
95         }
96         defer dblock.RailsMigrations.Unlock()
97         return super.RunProgram(ctx, appdir, runOptions{env: railsEnv}, "bundle", "exec", "rake", "db:migrate")
98 }
99
100 func migrationList(dir string, log logrus.FieldLogger) (map[string]bool, error) {
101         todo := map[string]bool{}
102
103         // list versions in db/migrate/{version}_{name}.rb
104         err := fs.WalkDir(os.DirFS(dir), "db/migrate", func(path string, d fs.DirEntry, err error) error {
105                 if d.IsDir() {
106                         return nil
107                 }
108                 fnm := d.Name()
109                 if strings.HasSuffix(fnm, "~") {
110                         return nil
111                 }
112                 if !strings.HasSuffix(fnm, ".rb") {
113                         log.Warnf("unexpected file in db/migrate dir: %s", fnm)
114                         return nil
115                 }
116                 for i, c := range fnm {
117                         if i > 0 && c == '_' {
118                                 todo[fnm[:i]] = true
119                                 break
120                         }
121                         if c < '0' || c > '9' {
122                                 // non-numeric character before the
123                                 // first '_' means this is not a
124                                 // migration
125                                 log.Warnf("unexpected file in db/migrate dir: %s", fnm)
126                                 return nil
127                         }
128                 }
129                 return nil
130         })
131         if err != nil {
132                 return nil, err
133         }
134         return todo, nil
135 }