Auto Import Database Schema

To get rid of docker-icingadb and its additional entry point, the schema
import functionality has been implemented directly in Icinga DB. Using
the new --database-auto-import command line argument will result in an
automatic schema import if no schema is found.

The implementation is split between the already existing CheckSchema
function and the introduced ImportSchema function.

The CheckSchema function is now able to distinguish between the absence
of a schema and an incorrect schema version. Both situations return a
separate error type.

As before, CheckSchema is called in the main function. If the error type
now implies the absence of a schema (ErrSchemaNotExists) and the
--database-auto-import flag is set, the auto-import is started.

The schema import itself is performed in the new ImportSchema function,
which loads the schema from a given file and inserts it within a
transaction, allowing to rollback in case of an error.

Fixes #896.
This commit is contained in:
Alvar Penning 2025-03-28 10:39:16 +01:00
parent eb0b947e3f
commit 48d4305e92
No known key found for this signature in database
3 changed files with 91 additions and 11 deletions

View file

@ -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)
}

View file

@ -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.

View file

@ -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)
}