diff --git a/cmd/icingadb/main.go b/cmd/icingadb/main.go index 3b17873d..d5ebf679 100644 --- a/cmd/icingadb/main.go +++ b/cmd/icingadb/main.go @@ -67,7 +67,18 @@ func run() int { } } - if err := icingadb.CheckSchema(context.Background(), db); err != nil { + switch err := icingadb.CheckSchema(context.Background(), db); { + case errors.Is(err, icingadb.ErrSchemaNotExists): + if !cmd.Flags.DatabaseAutoImport { + logger.Fatal("The database schema is missing") + } + + logger.Info("Starting database schema auto import") + if err := icingadb.ImportSchema(context.Background(), db, cmd.Flags.DatabaseSchemaDir); err != nil { + logger.Fatalf("%+v", errors.Wrap(err, "can't import database schema")) + } + logger.Info("The database schema was successfully imported") + case err != nil: logger.Fatalf("%+v", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 5fb91d6d..503e6dbf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,6 +58,14 @@ type Flags struct { // Config is the path to the config file. If not provided, it defaults to DefaultConfigPath. Config string `short:"c" long:"config" description:"path to config file (default: /etc/icingadb/config.yml)"` // default must be kept in sync with DefaultConfigPath. + + // DatabaseAutoImport results in an initial schema check and update; mostly for containerized setups. + DatabaseAutoImport bool `long:"database-auto-import" description:"import database schema on startup if database is empty"` + + // DatabaseSchemaDir is the root directory for schema files to be used when DatabaseAutoImport is requested. + // + // The directory structure must mimic the git repo's schema dir, containing ./mysql/schema.sql and ./pgsql/schema.sql. + DatabaseSchemaDir string `long:"database-schema-dir" description:"directory for --database-auto-import, expects ./{my,pg}sql/schema.sql files" default:"./schema/"` } // GetConfigPath retrieves the path to the configuration file. diff --git a/pkg/icingadb/schema.go b/pkg/icingadb/schema.go index 9aa7374e..94ac8c3c 100644 --- a/pkg/icingadb/schema.go +++ b/pkg/icingadb/schema.go @@ -2,11 +2,15 @@ package icingadb import ( "context" + stderrors "errors" "fmt" "github.com/icinga/icinga-go-library/backoff" "github.com/icinga/icinga-go-library/database" "github.com/icinga/icinga-go-library/retry" + "github.com/jmoiron/sqlx" "github.com/pkg/errors" + "os" + "path" "time" ) @@ -15,7 +19,19 @@ const ( expectedPostgresSchemaVersion = 4 ) -// CheckSchema asserts the database schema of the expected version being present. +// ErrSchemaNotExists implies that no Icinga DB schema has been imported. +var ErrSchemaNotExists = stderrors.New("no database schema exists") + +// ErrSchemaMismatch implies an unexpected schema version, most likely after Icinga DB was updated but the database +// missed the schema upgrade. +var ErrSchemaMismatch = stderrors.New("unexpected database schema version") + +// CheckSchema verifies the correct database schema is present. +// +// This function returns the following error types, possibly wrapped: +// - If no schema exists, the error returned is ErrSchemaNotExists. +// - If the schema version does not match the expected version, the error returned is ErrSchemaMismatch. +// - Otherwise, the original error is returned, for example in case of general database problems. func CheckSchema(ctx context.Context, db *database.DB) error { var expectedDbSchemaVersion uint16 switch db.DriverName() { @@ -23,19 +39,25 @@ func CheckSchema(ctx context.Context, db *database.DB) error { expectedDbSchemaVersion = expectedMysqlSchemaVersion case database.PostgreSQL: expectedDbSchemaVersion = expectedPostgresSchemaVersion + default: + return errors.Errorf("unsupported database driver %q", db.DriverName()) + } + + if hasSchemaTable, err := db.HasTable(ctx, "icingadb_schema"); err != nil { + return errors.Wrap(err, "can't verify existence of database schema table") + } else if !hasSchemaTable { + return ErrSchemaNotExists } var version uint16 - err := retry.WithBackoff( ctx, - func(ctx context.Context) (err error) { + func(ctx context.Context) error { query := "SELECT version FROM icingadb_schema ORDER BY id DESC LIMIT 1" - err = db.QueryRowxContext(ctx, query).Scan(&version) - if err != nil { - err = database.CantPerformQuery(err, query) + if err := db.QueryRowxContext(ctx, query).Scan(&version); err != nil { + return database.CantPerformQuery(err, query) } - return + return nil }, retry.Retryable, backoff.NewExponentialWithJitter(128*time.Millisecond, 1*time.Minute), @@ -48,11 +70,50 @@ func CheckSchema(ctx context.Context, db *database.DB) error { // Since these error messages are trivial and mostly caused by users, we don't need // to print a stack trace here. However, since errors.Errorf() does this automatically, // we need to use fmt instead. - return fmt.Errorf( - "unexpected database schema version: v%d (expected v%d), please make sure you have applied all database"+ - " migrations after upgrading Icinga DB", version, expectedDbSchemaVersion, + return fmt.Errorf("%w: v%d (expected v%d), please make sure you have applied all database"+ + " migrations after upgrading Icinga DB", ErrSchemaMismatch, version, expectedDbSchemaVersion, ) } return nil } + +// ImportSchema performs an initial schema import in the db. +// +// This function assumes that no schema exists. So it should only be called after a prior CheckSchema call. +func ImportSchema( + ctx context.Context, + db *database.DB, + databaseSchemaDir string, +) error { + var schemaFileDirPart string + switch db.DriverName() { + case database.MySQL: + schemaFileDirPart = "mysql" + case database.PostgreSQL: + schemaFileDirPart = "pgsql" + default: + return errors.Errorf("unsupported database driver %q", db.DriverName()) + } + + schemaFile := path.Join(databaseSchemaDir, schemaFileDirPart, "schema.sql") + schema, err := os.ReadFile(schemaFile) // #nosec G304 -- path is constructed from "trusted" command line user input + if err != nil { + return errors.Wrapf(err, "can't open schema file %q", schemaFile) + } + + queries := []string{string(schema)} + if db.DriverName() == database.MySQL { + // MySQL/MariaDB requires the schema to be imported on a statement by statement basis. + queries = database.MysqlSplitStatements(string(schema)) + } + + return errors.Wrapf(db.ExecTx(ctx, func(ctx context.Context, tx *sqlx.Tx) error { + for _, query := range queries { + if _, err := tx.ExecContext(ctx, query); err != nil { + return errors.Wrap(database.CantPerformQuery(err, query), "can't perform schema import") + } + } + return nil + }), "can't import database schema from %q", schemaFile) +}