package env import ( "fmt" "io/fs" "time" "github.com/FAU-CDI/wisski-distillery/internal/bookkeeping" "github.com/FAU-CDI/wisski-distillery/internal/logging" "github.com/FAU-CDI/wisski-distillery/internal/sqle" "github.com/FAU-CDI/wisski-distillery/internal/stack" "github.com/FAU-CDI/wisski-distillery/internal/wait" "github.com/pkg/errors" "github.com/tkw1536/goprogram/exit" "github.com/tkw1536/goprogram/stream" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) // SQLStack returns the docker stack that handles the sql database. func (dis *Distillery) SQLStack() stack.Installable { return dis.asCoreStack("sql", stack.Installable{ MakeDirsPerm: fs.ModeDir | fs.ModePerm, MakeDirs: []string{ "data", }, }) } // SQLStackPath returns the path the SQLStack() lives at. func (dis *Distillery) SQLStackPath() string { return dis.SQLStack().Dir } // sqlOpen opens a new sql connection to the provided database using the administrative credentials func (env Distillery) sqlOpen(database string, config *gorm.Config) (*gorm.DB, error) { sql := mysql.Config{ DSN: fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8&parseTime=True&loc=Local", env.Config.MysqlAdminUser, env.Config.MysqlAdminPassword, "127.0.0.1:3306", database), DefaultStringSize: 256, } db, err := gorm.Open(mysql.New(sql), config) if err != nil { return db, err } gdb, err := db.DB() if err != nil { return db, err } gdb.SetMaxIdleConns(0) return db, nil } var errSQL = exit.Error{ Message: "error querying sql database: %s", ExitCode: exit.ExitGeneric, } // sqlBkTable returns a gorm connection to the bookkeeping database. func (dis *Distillery) sqlBkTable(silent bool) (*gorm.DB, error) { config := &gorm.Config{} if silent { config.Logger = logger.Default.LogMode(logger.Silent) } // open the database db, err := dis.sqlOpen(dis.Config.DistilleryBookkeepingDatabase, config) if err != nil { return nil, errSQL.WithMessageF(err) } // load the table table := db.Table(dis.Config.DistilleryBookkeepingTable) if table.Error != nil { return nil, errSQL.WithMessageF(err) } return table, nil } // SQLShell executes a mysql shell inside the SQLStack. func (dis *Distillery) SQLShell(io stream.IOStream, argv ...string) (int, error) { return dis.SQLStack().Exec(io, "sql", "mysql", argv...) } var errSQLBootstrap = exit.Error{ Message: "Unable to boostrap SQL: %s", ExitCode: exit.ExitGeneric, } const waitSQLInterval = 1 * time.Second // SQLWaitForShell waits for the sql database to be reachable via a docker-compose shell func (dis *Distillery) SQLWaitForShell() error { n := stream.FromNil() return wait.Wait(func() bool { code, err := dis.SQLShell(n, "-e", "show databases;") return err == nil && code == 0 }, waitSQLInterval, dis.Context()) } // SQLWaitForConnection waits for the sql connection to be alive func (dis *Distillery) SQLWaitForConnection() error { return wait.Wait(func() bool { _, err := dis.sqlBkTable(true) return err == nil }, waitSQLInterval, dis.Context()) } var errInvalidDatabaseName = errors.New("SQLProvision: Invalid database name") func (dis *Distillery) sqlRaw(query string, args ...interface{}) bool { sql := sqle.Format(query, args...) code, err := dis.SQLShell(stream.FromNil(), "-e", sql) return err == nil && code == 0 } // SQLProvision provisions a new sql database and user func (dis *Distillery) SQLProvision(name, user, password string) error { // wait for the database if err := dis.SQLWaitForShell(); err != nil { return err } // it's not a safe database name! if !sqle.IsSafeDatabaseName(name) { return errInvalidDatabaseName } // create the database and user! if !dis.sqlRaw("CREATE DATABASE `"+name+"`; CREATE USER ?@`%` IDENTIFIED BY ?; GRANT ALL PRIVILEGES ON `"+name+"`.* TO ?@`%`; FLUSH PRIVILEGES;", user, password, user) { return errors.New("SQLProvision: Failed to create user") } // and done! return nil } var errSQLPurgeUser = exit.Error{ Message: "Unable to delete user", ExitCode: exit.ExitGeneric, } // SQLPurgeUser deletes the specified user from the database func (dis *Distillery) SQLPurgeUser(user string) error { if !dis.sqlRaw("DROP USER IF EXISTS ?@`%`; FLUSH PRIVILEGES; ", user) { return errSQLPurgeUser } return nil } var errSQLPurgeDB = exit.Error{ Message: "Unable to delete database", ExitCode: exit.ExitGeneric, } // SQLPurgeDatabase deletes the specified db from the database func (dis *Distillery) SQLPurgeDatabase(db string) error { if !sqle.IsSafeDatabaseName(db) { return errSQLPurgeDB } if !dis.sqlRaw("DROP DATABASE IF EXISTS `" + db + "`") { return errSQLPurgeDB } return nil } // SQLBootstrap bootstraps the SQL database, and makes sure that the bookkeeping table is up-to-date func (dis *Distillery) SQLBootstrap(io stream.IOStream) error { if err := dis.SQLWaitForShell(); err != nil { return errSQLBootstrap.WithMessageF(err) } // create the admin user logging.LogMessage(io, "Creating administrative user") { username := dis.Config.MysqlAdminUser password := dis.Config.MysqlAdminPassword if !dis.sqlRaw("CREATE USER IF NOT EXISTS ?@'%' IDENTIFIED BY ?; GRANT ALL PRIVILEGES ON *.* TO ?@`%` WITH GRANT OPTION; FLUSH PRIVILEGES;", username, password, username) { return errSQLBootstrap.WithMessageF("Unable to create administrative user") } } // create the admin user logging.LogMessage(io, "Creating sql database") { if !sqle.IsSafeDatabaseName(dis.Config.DistilleryBookkeepingDatabase) { return errSQLBootstrap.WithMessageF("Unsafe database name") } createDBSQL := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`;", dis.Config.DistilleryBookkeepingDatabase) if !dis.sqlRaw(createDBSQL) { return errSQLBootstrap.WithMessageF(createDBSQL) } } // wait for the database to come up logging.LogMessage(io, "Waiting for database update to be complete") dis.SQLWaitForConnection() // open the database logging.LogMessage(io, "Migrating bookkeeping table") { db, err := dis.sqlBkTable(false) if err != nil { return errSQLBootstrap.WithMessageF(err) } if err := db.AutoMigrate(&bookkeeping.Instance{}); err != nil { return errSQLBootstrap.WithMessageF(err) } } return nil }