From 1408de65d6f9893e74ab8fab6506904da3d1e5e0 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 ++ go.mod | 1 + go.sum | 2 + internal/schema.go | 71 ++++++++++++++++++++++++++++++++ schema/mysql/schema.sql | 38 +++++++++++++++++ schema/mysql/upgrades/1.0.sql | 34 +++++++++++++++ schema/pgsql/schema.sql | 32 ++++++++++++++ schema/pgsql/upgrades/1.0.sql | 31 ++++++++++++++ 8 files changed, 213 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/go.mod b/go.mod index 43fce9a5..d0d5e7b5 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/teambition/rrule-go v1.8.2 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.51.0 + golang.org/x/mod v0.35.0 golang.org/x/sync v0.20.0 ) diff --git a/go.sum b/go.sum index a2beb230..975ee73b 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= diff --git a/internal/schema.go b/internal/schema.go new file mode 100644 index 00000000..590962e0 --- /dev/null +++ b/internal/schema.go @@ -0,0 +1,71 @@ +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" + "golang.org/x/mod/semver" +) + +const ( + 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") + } + + 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") + } + + // Compare the actual schema version with the expected version using semantic versioning comparison. + // A simple string comparison could have been enough in most cases, but using semver allows for more + // flexible versioning, so that `v1`, `v1.0`, and `v1.0.0` are all considered equivalent. + if actualSchemaVersion := dbResult[0]; semver.Compare(expectedSchemaVersion, actualSchemaVersion) != 0 { + 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..e0740a42 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 idx_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..bc73440a --- /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 idx_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..ad95ab60 100644 --- a/schema/pgsql/schema.sql +++ b/schema/pgsql/schema.sql @@ -30,6 +30,27 @@ 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; +BEGIN + SELECT version INTO actual_version FROM notifications_schema ORDER BY timestamp DESC LIMIT 1; + 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 +520,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 idx_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..e6ef14b7 --- /dev/null +++ b/schema/pgsql/upgrades/1.0.sql @@ -0,0 +1,31 @@ +-- 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; +BEGIN + SELECT version INTO actual_version FROM notifications_schema ORDER BY timestamp DESC LIMIT 1; + 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 idx_notifications_schema_version UNIQUE (version) +); + +INSERT INTO notifications_schema(version, timestamp) VALUES('v1.0', EXTRACT(EPOCH from NOW()) * 1000);