diff --git a/dev.py b/dev.py index feaba2ad6..c9fbca41b 100755 --- a/dev.py +++ b/dev.py @@ -35,6 +35,7 @@ def cli(): # Add the commands: cli.add_command(dev.lint) cli.add_command(dev.setup) +cli.add_command(dev.update) if __name__ == '__main__': # Configure logging: diff --git a/dev/__init__.py b/dev/__init__.py index f14fa5095..7314bbe68 100644 --- a/dev/__init__.py +++ b/dev/__init__.py @@ -14,5 +14,6 @@ # from .formatter import * +from .update import * from .lint import * from .setup import * diff --git a/dev/update.py b/dev/update.py new file mode 100644 index 000000000..bbc6bc4a0 --- /dev/null +++ b/dev/update.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +# +# Copyright (c) 2026 Red Hat Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +import hashlib +import logging + +import click + +from . import dirs + + +@click.group() +def update() -> None: + """ + Updates generated project artifacts. + """ + + +@update.command(name="hashes") +def hashes() -> None: + """ + Updates the database migrations hash. + """ + # Compute the hash of the migration files: + migrations_dir = dirs.project() / "internal" / "database" / "migrations" + migration_files = migrations_dir.glob("*.up.sql") + migration_files_sorted = sorted(migration_files) + computed_hash_source = "".join(migration_file.name + "\n" for migration_file in migration_files_sorted) + computed_hash_source_bytes = computed_hash_source.encode() + computed_hash_bytes = hashlib.sha256(computed_hash_source_bytes).digest() + computed_hash_text = computed_hash_bytes.hex() + + # Read the current hash from the file: + hash_file = migrations_dir.parent / "migrations.sha256" + stored_hash_text = hash_file.read_text().strip() if hash_file.exists() else "" + + # Check if the hash is already up to date: + if stored_hash_text == computed_hash_text: + logging.info("Database migrations hash is already up to date") + return + + # Update the hash file: + hash_file.write_text(computed_hash_text + "\n") + logging.info("Database migrations hash updated to '%s'", computed_hash_text) diff --git a/internal/database/README.md b/internal/database/README.md new file mode 100644 index 000000000..355b81da2 --- /dev/null +++ b/internal/database/README.md @@ -0,0 +1,15 @@ +# Database + +This directory contains the database layer of the service. The `migrations` subdirectory contains +the numbered `.up.sql` files that are applied sequentially to evolve the database schema. + +## Migration file hash + +The `migrations.sha256` file contains a SHA-256 digest of the sorted list of `.up.sql` filenames. +Its purpose is to prevent two pull requests from accidentally introducing migrations with the same +number: since both PRs would write a different hash, git will flag a merge conflict forcing the +second author to renumber their migration before merging. + +If you add a new migration, run `uv run dev.py update hashes` and commit the updated +`migrations.sha256` file alongside your new migration file. If you forget, the unit test that +verifies the hash will fail and indicate the mismatch. diff --git a/internal/database/database_migrations_test.go b/internal/database/database_migrations_test.go index 26080ca3d..c31b448b9 100644 --- a/internal/database/database_migrations_test.go +++ b/internal/database/database_migrations_test.go @@ -14,7 +14,10 @@ language governing permissions and limitations under the License. package database import ( + "bytes" + "crypto/sha256" "fmt" + "os" "path/filepath" "regexp" "sort" @@ -92,6 +95,43 @@ var _ = Describe("Migrations", func() { Expect(violations).To(BeEmpty(), "migration filenames violate naming convention: %v", violations) }) + It("Has an up-to-date migrations hash", func() { + // Read the stored hash: + storedHashFile, err := filepath.Abs("migrations.sha256") + Expect(err).ToNot(HaveOccurred()) + storedHashBytes, err := os.ReadFile(storedHashFile) + Expect(err).ToNot(HaveOccurred()) + storedHashText := strings.TrimSpace(string(storedHashBytes)) + + // Get the names of the migration migrationFiles and sort them: + migrationFiles, err := filepath.Glob("migrations/*.up.sql") + Expect(err).ToNot(HaveOccurred()) + Expect(migrationFiles).ToNot(BeEmpty()) + migrationNames := make([]string, len(migrationFiles)) + for i, file := range migrationFiles { + migrationNames[i] = filepath.Base(file) + } + sort.Strings(migrationNames) + + // Compute the SHA-256 hash of the migration file list: + computedHashSource := &bytes.Buffer{} + for _, migrationName := range migrationNames { + _, err := fmt.Fprintf(computedHashSource, "%s\n", migrationName) + Expect(err).ToNot(HaveOccurred()) + } + computedHashBytes := sha256.Sum256(computedHashSource.Bytes()) + computedHashText := fmt.Sprintf("%x", computedHashBytes) + + // Compare the computed hash with the stored hash: + if computedHashText != storedHashText { + Fail(fmt.Sprintf( + "Database migrations hash in '%s' is outdated, should be '%s' but is '%s', update "+ + "it manually or run 'uv run dev.py update hashes' to update it automatically", + storedHashFile, computedHashText, storedHashText, + )) + } + }) + It("Has no unexpected gaps in migration numbering", func() { files, err := filepath.Glob("migrations/*.up.sql") Expect(err).ToNot(HaveOccurred()) diff --git a/internal/database/migrations.sha256 b/internal/database/migrations.sha256 new file mode 100644 index 000000000..a32cebe50 --- /dev/null +++ b/internal/database/migrations.sha256 @@ -0,0 +1 @@ +ab88e4e53f1ffd9b1694b90b285f4e791070cee7f086c4045eb002747b6d451e