// Copyright (C) The Arvados Authors. All rights reserved. // // SPDX-License-Identifier: AGPL-3.0 package boot import ( "context" "io/fs" "os" "path/filepath" "strings" "git.arvados.org/arvados.git/lib/controller/dblock" "git.arvados.org/arvados.git/lib/ctrlctx" "github.com/sirupsen/logrus" ) type railsDatabase struct{} func (runner railsDatabase) String() string { return "railsDatabase" } // Run checks for and applies any pending Rails database migrations. // // If running a dev/test environment, and the database is empty, it // initializes the database. func (runner railsDatabase) Run(ctx context.Context, fail func(error), super *Supervisor) error { err := super.wait(ctx, runPostgreSQL{}, installPassenger{src: "services/api"}) if err != nil { return err } // determine path to installed rails app or source tree var appdir string if super.ClusterType == "production" { appdir = "/var/lib/arvados/railsapi" } else { appdir = filepath.Join(super.SourcePath, "services/api") } // Check for pending migrations before running rake. // // In principle, we could use "rake db:migrate:status" or skip // this check entirely and let "rake db:migrate" be a no-op // most of the time. However, in the most common case when // there are no new migrations, that would add ~2s to startup // time / downtime during service restart. todo, err := migrationList(appdir, super.logger) if err != nil { return err } // read schema_migrations table (list of migrations already // applied) and remove those entries from todo dbconnector := ctrlctx.DBConnector{PostgreSQL: super.cluster.PostgreSQL} defer dbconnector.Close() db, err := dbconnector.GetDB(ctx) if err != nil { return err } rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`) if err != nil { if super.ClusterType == "production" { return err } super.logger.WithError(err).Info("schema_migrations query failed, trying db:setup") return super.RunProgram(ctx, "services/api", runOptions{env: railsEnv}, "bundle", "exec", "rake", "db:setup") } for rows.Next() { var v string err = rows.Scan(&v) if err != nil { return err } delete(todo, v) } err = rows.Close() if err != nil { return err } // if nothing remains in todo, all available migrations are // done, so return without running any [relatively slow] // ruby/rake commands if len(todo) == 0 { return nil } super.logger.Infof("%d migrations pending", len(todo)) if !dblock.RailsMigrations.Lock(ctx, dbconnector.GetDB) { return context.Canceled } defer dblock.RailsMigrations.Unlock() return super.RunProgram(ctx, appdir, runOptions{env: railsEnv}, "bundle", "exec", "rake", "db:migrate") } func migrationList(dir string, log logrus.FieldLogger) (map[string]bool, error) { todo := map[string]bool{} // list versions in db/migrate/{version}_{name}.rb err := fs.WalkDir(os.DirFS(dir), "db/migrate", func(path string, d fs.DirEntry, err error) error { if d.IsDir() { return nil } fnm := d.Name() if strings.HasSuffix(fnm, "~") { return nil } if !strings.HasSuffix(fnm, ".rb") { log.Warnf("unexpected file in db/migrate dir: %s", fnm) return nil } for i, c := range fnm { if i > 0 && c == '_' { todo[fnm[:i]] = true break } if c < '0' || c > '9' { // non-numeric character before the // first '_' means this is not a // migration log.Warnf("unexpected file in db/migrate dir: %s", fnm) return nil } } return nil }) if err != nil { return nil, err } return todo, nil }