From 91d64afcaffcb25de84a93e6e5be3b4431892998 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Wed, 13 May 2026 12:21:58 +0200 Subject: [PATCH] Add `notifications_schema` table & verify on daemon startup --- cmd/icinga-notifications/main.go | 4 ++ internal/schema.go | 72 ++++++++++++++++++++++++++++++++ schema/mysql/schema.sql | 38 +++++++++++++++++ schema/mysql/upgrades/1.0.sql | 34 +++++++++++++++ schema/pgsql/schema.sql | 31 ++++++++++++++ schema/pgsql/upgrades/1.0.sql | 30 +++++++++++++ 6 files changed, 209 insertions(+) create mode 100644 internal/schema.go create mode 100644 schema/mysql/upgrades/1.0.sql create mode 100644 schema/pgsql/upgrades/1.0.sql diff --git a/cmd/icinga-notifications/main.go b/cmd/icinga-notifications/main.go index 6ac3f434..9fb8b4ca 100644 --- a/cmd/icinga-notifications/main.go +++ b/cmd/icinga-notifications/main.go @@ -45,6 +45,10 @@ func main() { logger.Fatalf("Cannot connect to the database: %+v", err) } + if err := internal.CheckSchema(ctx, db); err != nil { + logger.Fatalf("%+v", err) + } + channel.UpsertPlugins(ctx, conf.ChannelsDir, logs.GetChildLogger("channel"), db) runtimeConfig := config.NewRuntimeConfig(logs, db) diff --git a/internal/schema.go b/internal/schema.go new file mode 100644 index 00000000..e8052737 --- /dev/null +++ b/internal/schema.go @@ -0,0 +1,72 @@ +package internal + +import ( + "context" + "errors" + "fmt" + + "github.com/icinga/icinga-go-library/backoff" + "github.com/icinga/icinga-go-library/database" + "github.com/icinga/icinga-go-library/retry" +) + +const ( + // Both MySQL and PostgreSQL schema versions are currently the same, but they can + // evolve independently in the future, so can't be merged into a single constant. + expectedMysqlSchemaVersion = "v1.0" + expectedPostgresSchemaVersion = "v1.0" +) + +// CheckSchema verifies that the database schema version matches the expected version for the database driver. +func CheckSchema(ctx context.Context, db *database.DB) error { + var expectedSchemaVersion string + switch db.DriverName() { + case database.MySQL: + expectedSchemaVersion = expectedMysqlSchemaVersion + case database.PostgreSQL: + expectedSchemaVersion = expectedPostgresSchemaVersion + default: + return fmt.Errorf("unsupported database driver %q", db.DriverName()) + } + + if hasSchemaTable, err := db.HasTable(ctx, "notifications_schema"); err != nil { + return fmt.Errorf("cannot verify existence of database schema table: %w", err) + } else if !hasSchemaTable { + return errors.New( + "notifications_schema table does not exist, please make sure you have applied all" + + " database migrations after upgrading Icinga Notifications", + ) + } + + var dbResult []string + err := retry.WithBackoff( + ctx, + func(ctx context.Context) error { + qs := `SELECT version FROM notifications_schema ORDER BY timestamp DESC LIMIT 1` + if err := db.SelectContext(ctx, &dbResult, qs); err != nil { + return database.CantPerformQuery(err, qs) + } + return nil + }, + retry.Retryable, + backoff.DefaultBackoff, + db.GetDefaultRetrySettings(), + ) + if err != nil { + return err + } + + if len(dbResult) == 0 { + return errors.New("no database schema version found") + } + + if actualSchemaVersion := dbResult[0]; actualSchemaVersion != expectedSchemaVersion { + return fmt.Errorf( + "unexpected database schema version: %s (expected %s), please make sure you have applied all"+ + " database migrations after upgrading Icinga Notifications", + actualSchemaVersion, expectedSchemaVersion, + ) + } + + return nil +} diff --git a/schema/mysql/schema.sql b/schema/mysql/schema.sql index 8960e81e..9b555faf 100644 --- a/schema/mysql/schema.sql +++ b/schema/mysql/schema.sql @@ -1,3 +1,30 @@ +DROP PROCEDURE IF EXISTS assert_correct_schema_version; +DELIMITER // +-- This procedure can be used in upgrade scripts to assert that the schema version in the database matches the +-- expected version before applying the upgrade. This is important to prevent users from accidentally skipping +-- intermediate upgrade scripts, which could lead to an inconsistent database state. For instance, since every +-- upgrade script knows its predecessor's version, we can just do "CALL assert_correct_schema_version('v1.0')" +-- at the beginning of the 1.x upgrade scripts to ensure that the 1.0 script has been applied before. +CREATE PROCEDURE assert_correct_schema_version(expected_version text) + READS SQL DATA + COMMENT 'Asserts that the schema version in the database matches the expected version and raises an error if not.' +BEGIN + DECLARE actual_version text; + DECLARE error_message text; + SELECT version INTO actual_version FROM notifications_schema ORDER BY timestamp DESC LIMIT 1; + IF actual_version IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Schema version not found in notifications_schema table.'; + ELSEIF actual_version != expected_version THEN + -- MySQL/MariaDB doesn't seem to allow to directly use CONCAT in the SIGNAL statement[^1], + -- so we need to set it to a variable first. + -- [^1]: https://bugs.mysql.com/bug.php?id=114001 + SET error_message = CONCAT('Schema version mismatch: expected ', expected_version, ', got ', actual_version, '. Please apply all previous upgrade scripts in order before applying this one.'); + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = error_message; + END IF; +END; +// +DELIMITER ; + CREATE TABLE available_channel_type ( type varchar(255) NOT NULL, name text NOT NULL, @@ -453,3 +480,14 @@ CREATE TABLE browser_session ( CREATE INDEX idx_browser_session_authenticated_at ON browser_session (authenticated_at DESC); CREATE INDEX idx_browser_session_username_agent ON browser_session (username, user_agent(512)); + +CREATE TABLE notifications_schema ( + id int NOT NULL AUTO_INCREMENT, + version varchar(64) NOT NULL, + timestamp bigint NOT NULL, + + CONSTRAINT pk_notifications_schema PRIMARY KEY (id), + CONSTRAINT uk_notifications_schema_version UNIQUE (version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +INSERT INTO notifications_schema(version, timestamp) VALUES('v1.0', UNIX_TIMESTAMP() * 1000); diff --git a/schema/mysql/upgrades/1.0.sql b/schema/mysql/upgrades/1.0.sql new file mode 100644 index 00000000..91dd0d26 --- /dev/null +++ b/schema/mysql/upgrades/1.0.sql @@ -0,0 +1,34 @@ +DROP PROCEDURE IF EXISTS assert_correct_schema_version; +DELIMITER // +-- This procedure can be used in upgrade scripts to assert that the schema version in the database matches the +-- expected version before applying the upgrade. This is important to prevent users from accidentally skipping +-- intermediate upgrade scripts, which could lead to an inconsistent database state. For instance, since every +-- upgrade script knows its predecessor's version, we can just do "CALL assert_correct_schema_version('v1.0')" +-- at the beginning of the 1.x upgrade scripts to ensure that the 1.0 script has been applied before. +CREATE PROCEDURE assert_correct_schema_version(expected_version text) + READS SQL DATA + COMMENT 'Asserts that the schema version in the database matches the expected version and raises an error if not.' +BEGIN + DECLARE actual_version text; + DECLARE error_message text; + SELECT version INTO actual_version FROM notifications_schema ORDER BY timestamp DESC LIMIT 1; + IF actual_version IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Schema version not found in notifications_schema table.'; + ELSEIF actual_version != expected_version THEN + SET error_message = CONCAT('Schema version mismatch: expected ', expected_version, ', got ', actual_version, '. Please apply all previous upgrade scripts in order before applying this one.'); + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = error_message; + END IF; +END; +// +DELIMITER ; + +CREATE TABLE notifications_schema ( + id int NOT NULL AUTO_INCREMENT, + version varchar(64) NOT NULL, + timestamp bigint NOT NULL, + + CONSTRAINT pk_notifications_schema PRIMARY KEY (id), + CONSTRAINT uk_notifications_schema_version UNIQUE (version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +INSERT INTO notifications_schema(version, timestamp) VALUES('v1.0', UNIX_TIMESTAMP() * 1000); diff --git a/schema/pgsql/schema.sql b/schema/pgsql/schema.sql index 791b5702..b8ae26d0 100644 --- a/schema/pgsql/schema.sql +++ b/schema/pgsql/schema.sql @@ -30,6 +30,26 @@ CREATE OR REPLACE FUNCTION anynonarrayliketext(anynonarray, text) $$; CREATE OPERATOR ~~ (LEFTARG=anynonarray, RIGHTARG=text, PROCEDURE=anynonarrayliketext); +-- This procedure can be used in upgrade scripts to assert that the schema version in the database matches the +-- expected version before applying the upgrade. This is important to prevent users from accidentally skipping +-- intermediate upgrade scripts, which could lead to an inconsistent database state. For instance, since every +-- upgrade script knows its predecessor's version, we can just do "CALL assert_correct_schema_version('v1.0')" +-- at the beginning of the 1.x upgrade scripts to ensure that the 1.0 script has been applied before. +CREATE OR REPLACE PROCEDURE assert_correct_schema_version(expected_version text) + LANGUAGE plpgsql +AS $$ +DECLARE + actual_version text := (SELECT version FROM notifications_schema ORDER BY timestamp DESC LIMIT 1); +BEGIN + IF actual_version IS NULL THEN + RAISE 'Schema version not found in notifications_schema table.'; + ELSIF actual_version != expected_version THEN + RAISE 'Schema version mismatch: expected %, got %. Please apply all previous upgrade scripts in order before applying this one.', expected_version, actual_version; + END IF; +END; +$$; +COMMENT ON PROCEDURE assert_correct_schema_version IS 'Asserts that the schema version in the database matches the expected version and raises an error if not.'; + CREATE TABLE available_channel_type ( type varchar(255) NOT NULL, name text NOT NULL, @@ -499,3 +519,14 @@ CREATE TABLE browser_session ( CREATE INDEX idx_browser_session_authenticated_at ON browser_session (authenticated_at DESC); CREATE INDEX idx_browser_session_username_agent ON browser_session (username, user_agent); + +CREATE TABLE notifications_schema ( + id serial, + version varchar(64) NOT NULL, + timestamp bigint NOT NULL, + + CONSTRAINT pk_notifications_schema PRIMARY KEY (id), + CONSTRAINT uk_notifications_schema_version UNIQUE (version) +); + +INSERT INTO notifications_schema(version, timestamp) VALUES('v1.0', EXTRACT(EPOCH from NOW()) * 1000); diff --git a/schema/pgsql/upgrades/1.0.sql b/schema/pgsql/upgrades/1.0.sql new file mode 100644 index 00000000..d10e568f --- /dev/null +++ b/schema/pgsql/upgrades/1.0.sql @@ -0,0 +1,30 @@ +-- This procedure can be used in upgrade scripts to assert that the schema version in the database matches the +-- expected version before applying the upgrade. This is important to prevent users from accidentally skipping +-- intermediate upgrade scripts, which could lead to an inconsistent database state. For instance, since every +-- upgrade script knows its predecessor's version, we can just do "CALL assert_correct_schema_version('v1.0')" +-- at the beginning of the 1.x upgrade scripts to ensure that the 1.0 script has been applied before. +CREATE OR REPLACE PROCEDURE assert_correct_schema_version(expected_version text) + LANGUAGE plpgsql +AS $$ +DECLARE + actual_version text := (SELECT version FROM notifications_schema ORDER BY timestamp DESC LIMIT 1); +BEGIN + IF actual_version IS NULL THEN + RAISE 'Schema version not found in notifications_schema table.'; + ELSIF actual_version != expected_version THEN + RAISE 'Schema version mismatch: expected %, got %. Please apply all previous upgrade scripts in order before applying this one.', expected_version, actual_version; + END IF; +END; +$$; +COMMENT ON PROCEDURE assert_correct_schema_version IS 'Asserts that the schema version in the database matches the expected version and raises an error if not.'; + +CREATE TABLE notifications_schema ( + id serial, + version varchar(64) NOT NULL, + timestamp bigint NOT NULL, + + CONSTRAINT pk_notifications_schema PRIMARY KEY (id), + CONSTRAINT uk_notifications_schema_version UNIQUE (version) +); + +INSERT INTO notifications_schema(version, timestamp) VALUES('v1.0', EXTRACT(EPOCH from NOW()) * 1000);