diff --git a/cmd/icingadb/main.go b/cmd/icingadb/main.go index 1cfc64a67..ef63cf28a 100644 --- a/cmd/icingadb/main.go +++ b/cmd/icingadb/main.go @@ -29,9 +29,8 @@ import ( ) const ( - ExitSuccess = 0 - ExitFailure = 1 - expectedRedisSchemaVersion = "6" + ExitSuccess = 0 + ExitFailure = 1 ) func main() { @@ -89,6 +88,10 @@ func run() int { _ = db.Close() logger.Info("The database schema was successfully imported") + + case errors.Is(err, icingadb.ErrSchemaImperfect): + logger.Warnw("Database schema should be checked", zap.Error(err)) + case err != nil: logger.Fatalf("%+v", err) } @@ -421,13 +424,13 @@ func checkRedisSchema(logger *logging.Logger, rc *redis.Client, pos string) (new } message := streams[0].Messages[0] - if version := message.Values["version"]; version != expectedRedisSchemaVersion { + if version := message.Values["version"]; version != internal.RedisSchemaVersion { // 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 Redis schema version: %q (expected %q), please make sure you are running compatible"+ - " versions of Icinga 2 and Icinga DB", version, expectedRedisSchemaVersion, + " versions of Icinga 2 and Icinga DB", version, internal.RedisSchemaVersion, ) } diff --git a/internal/version.go b/internal/version.go index 54719dbdc..2bdda2f6e 100644 --- a/internal/version.go +++ b/internal/version.go @@ -8,3 +8,33 @@ import ( // // The placeholders are replaced on `git archive` using the `export-subst` attribute. var Version = version.Version("1.5.1", "$Format:%(describe)$", "$Format:%H$") + +// MySqlSchemaVersions maps MySQL/MariaDB schema versions to Icinga DB release version. +// +// Each schema version implies an available schema upgrade, named after the Icinga DB +// version and stored under ./schema/mysql/upgrades. +// +// The largest key implies the latest and expected schema version. +var MySqlSchemaVersions = map[uint16]string{ + 2: "1.0.0-rc2", + 3: "1.0.0", + 4: "1.1.1", + 5: "1.2.0", + 6: "1.2.1", + 7: "1.4.0", +} + +// PgSqlSchemaVersions maps PostgreSQL schema versions to Icinga DB release version. +// +// Same as MySqlSchemaVersions, but for PostgreSQL instead. +var PgSqlSchemaVersions = map[uint16]string{ + 2: "1.1.1", + 3: "1.2.0", + 4: "1.2.1", + 5: "1.4.0", +} + +// RedisSchemaVersion is the expected Redis schema version. +// +// This version must match between Icinga 2 and Icinga DB. +var RedisSchemaVersion = "6" diff --git a/pkg/icingadb/schema.go b/pkg/icingadb/schema.go index d78a8957b..fcdcdacac 100644 --- a/pkg/icingadb/schema.go +++ b/pkg/icingadb/schema.go @@ -4,18 +4,18 @@ import ( "context" stderrors "errors" "fmt" + "maps" + "os" + "path" + "slices" + "strings" + "github.com/icinga/icinga-go-library/backoff" "github.com/icinga/icinga-go-library/database" "github.com/icinga/icinga-go-library/retry" + "github.com/icinga/icingadb/internal" "github.com/jmoiron/sqlx" "github.com/pkg/errors" - "os" - "path" -) - -const ( - expectedMysqlSchemaVersion = 7 - expectedPostgresSchemaVersion = 5 ) // ErrSchemaNotExists implies that no Icinga DB schema has been imported. @@ -25,23 +25,30 @@ var ErrSchemaNotExists = stderrors.New("no database schema exists") // missed the schema upgrade. var ErrSchemaMismatch = stderrors.New("unexpected database schema version") +// ErrSchemaImperfect implies some non critical failure condition of the database schema. +var ErrSchemaImperfect = stderrors.New("imperfect database schema") + // 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. +// - If there are non fatal database schema conditions, ErrSchemaImperfect is returned. This error must +// be reported back to the user, but should not lead in a program termination. // - 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 + var schemaVersions map[uint16]string switch db.DriverName() { case database.MySQL: - expectedDbSchemaVersion = expectedMysqlSchemaVersion + schemaVersions = internal.MySqlSchemaVersions case database.PostgreSQL: - expectedDbSchemaVersion = expectedPostgresSchemaVersion + schemaVersions = internal.PgSqlSchemaVersions default: return errors.Errorf("unsupported database driver %q", db.DriverName()) } + expectedDbSchemaVersion := slices.Max(slices.Sorted(maps.Keys(schemaVersions))) + 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 { @@ -53,7 +60,7 @@ func CheckSchema(ctx context.Context, db *database.DB) error { err := retry.WithBackoff( ctx, func(ctx context.Context) error { - query := "SELECT version FROM icingadb_schema ORDER BY version ASC" + query := "SELECT version FROM icingadb_schema ORDER BY id ASC" if err := db.SelectContext(ctx, &versions, query); err != nil { return database.CantPerformQuery(err, query) } @@ -66,29 +73,60 @@ func CheckSchema(ctx context.Context, db *database.DB) error { return errors.Wrap(err, "can't check database schema version") } + // In the following, multiple error conditions are checked. + // + // Since their 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.Errorf() instead. + + // Check if any schema was imported. if len(versions) == 0 { return fmt.Errorf("%w: no database schema version is stored in the database", ErrSchemaMismatch) } - // Check if each schema update between the initial import and the latest version was applied or, in other words, - // that no schema update was left out. The loop goes over the ascending sorted array of schema versions, verifying - // that each element's successor is the increment of this version, ensuring no gaps in between. - for i := 0; i < len(versions)-1; i++ { - if versions[i] != versions[i+1]-1 { + // Check if the latest schema version was imported. + if latestVersion := slices.Max(versions); latestVersion != expectedDbSchemaVersion { + var missingUpgrades []string + for version := latestVersion + 1; version <= expectedDbSchemaVersion; version++ { + if release, ok := schemaVersions[version]; ok { + missingUpgrades = append(missingUpgrades, release+".sql") + } else { + missingUpgrades = append(missingUpgrades, fmt.Sprintf("UNKNOWN (v%d)", version)) + } + } + + return fmt.Errorf("%w: v%d (expected v%d), "+ + "please apply the following schema upgrade(s) to your database in order: %s "+ + "(https://icinga.com/docs/icinga-db/latest/doc/04-Upgrading/)", + ErrSchemaMismatch, latestVersion, expectedDbSchemaVersion, strings.Join(missingUpgrades, ", ")) + } + + // Check if all schema updates between the oldest schema version and the expected version were applied. + for version := slices.Min(versions); version < expectedDbSchemaVersion; version++ { + if !slices.Contains(versions, version) { + release := "UNKNOWN" + if releaseVersion, ok := schemaVersions[version]; ok { + release = releaseVersion + } + return fmt.Errorf( - "%w: incomplete database schema upgrade: intermediate version v%d is missing,"+ - " please make sure you have applied all database migrations after upgrading Icinga DB", - ErrSchemaMismatch, versions[i]+1) + "%w: incomplete database schema upgrade: intermediate version v%d (%s) is missing, "+ + "please inspect the icingadb_schema database table and ensure that all database "+ + "migrations were applied in order after upgrading Icinga DB", + ErrSchemaMismatch, version, release) } } - if latestVersion := versions[len(versions)-1]; latestVersion != expectedDbSchemaVersion { - // 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("%w: v%d (expected v%d), please make sure you have applied all database"+ - " migrations after upgrading Icinga DB", ErrSchemaMismatch, latestVersion, expectedDbSchemaVersion, - ) + // Extend the prior check by checking if the schema updates were applied in a monotonic increasing order. + // However, this returns an ErrSchemaImperfect error instead of an ErrSchemaMismatch. + for i := 0; i < len(versions)-1; i++ { + if versions[i] != versions[i+1]-1 { + return fmt.Errorf( + "%w: unexpected schema upgrade order after schema version %d, "+ + "please inspect the icingadb_schema database table and ensure that all database "+ + "migrations were applied in order after upgrading Icinga DB", + ErrSchemaImperfect, versions[i]) + } } return nil