1
0
mirror of https://github.com/quay/quay.git synced 2026-01-27 18:42:52 +03:00
Files
quay/test/fixtures.py
jbpratt 08153b6660 chore: CI runtime improvements (#4586)
* chore: update ci to use new large ubuntu 24.04 runner

Signed-off-by: Brady Pratt <bpratt@redhat.com>
Co-Authored-By: Dave O'Connor <doconnor@redhat.com>

* fix: add libfreetype6-dev for Ubuntu 24.04 compatibility

The reportlab package requires FreeType development headers to build.
On Ubuntu 24.04, this dependency is not pulled in transitively and
must be explicitly installed. This fixes the "cannot find ft2build.h"
build error.

Added libfreetype6-dev to all jobs that install system dependencies
in CI.yaml and CI-nightly.yaml workflows.

Signed-off-by: Brady Pratt <bpratt@redhat.com>
Co-Authored-By: Dave O'Connor <doconnor@redhat.com>

* chore: set the TEST_DATETIME to a static value

this caused an issue in xdist when generating test names

Signed-off-by: Brady Pratt <bpratt@redhat.com>

* chore: cache pip packages in CI

Signed-off-by: Brady Pratt <bpratt@redhat.com>

* chore: run registry tests with -n auto

Signed-off-by: Brady Pratt <bpratt@redhat.com>

* chore: run psql with -n auto

Signed-off-by: Brady Pratt <bpratt@redhat.com>

* chore: add file locking to prevent parallel test db init race condition

When running pytest -n auto with multiple workers, both workers would
simultaneously execute populate_database(), causing duplicate key
violations on shared tables like imagestoragelocation:

Worker 1: Check if User "devtable" exists → No → Start populating
Worker 2: Check if User "devtable" exists → No → Start populating
Both: INSERT INTO imagestoragelocation (name) VALUES ('local_eu')
Result: IntegrityError - duplicate key violation

Solution: Wrap init_db_path fixture with FileLock to ensure only one
worker initializes the database at a time. The lock file is created
in pytest's shared temp directory, coordinating across all workers.

- First worker acquires lock and populates database
- Subsequent workers wait at lock, then see database is already
  populated (via User.get() check in populate_database())
- Works for both PostgreSQL and MySQL
- 300-second timeout prevents deadlocks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: run mysql with -n auto

Signed-off-by: Brady Pratt <bpratt@redhat.com>

---------

Signed-off-by: Brady Pratt <bpratt@redhat.com>
Co-authored-by: Dave O'Connor <doconnor@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 09:01:18 -05:00

382 lines
13 KiB
Python

import datetime
import inspect
import os
import shutil
from collections import namedtuple
import pytest
from filelock import FileLock
from flask import Flask, jsonify
from flask.testing import FlaskClient
from flask_login import LoginManager
from flask_mail import Mail
from flask_principal import Principal, identity_loaded
from mock import patch
from peewee import InternalError, SqliteDatabase
import features
# Ensure application loads test configuration at import time
os.environ.setdefault("TEST", "1")
from app import app as application
from auth.permissions import on_identity_loaded
from data import model
from data.database import close_db_filter, configure, db
from data.model.user import LoginWrappedDBUser
from data.userfiles import Userfiles
from endpoints.api import api_bp
from endpoints.v1 import v1_bp
from endpoints.v2 import v2_bp
from endpoints.web import web
from endpoints.webhooks import webhooks
from initdb import initialize_database, populate_database
from path_converters import (
APIRepositoryPathConverter,
RegexConverter,
RepositoryPathConverter,
RepositoryPathRedirectConverter,
V1CreateRepositoryPathConverter,
)
from test.testconfig import FakeTransaction
INIT_DB_PATH = 0
__all__ = ["init_db_path", "database_uri", "sqlitedb_file", "appconfig", "initialized_db", "app"]
@pytest.fixture(scope="session")
def init_db_path(tmpdir_factory):
"""
Creates a new database and appropriate configuration.
Note that the initial database is created *once* per session. In the non-full-db-test case, the
database_uri fixture makes a copy of the SQLite database file on disk and passes a new copy to
each test.
"""
# NOTE: We use a global here because pytest runs this code multiple times, due to the fixture
# being imported instead of being in a conftest. Moving to conftest has its own issues, and this
# call is quite slow, so we simply cache it here.
# Use file lock to coordinate database initialization across pytest-xdist workers
# tmpdir_factory.getbasetemp() returns py.path.local.LocalPath, use dirpath() for parent
lock_file = str(tmpdir_factory.getbasetemp().dirpath("db_init.lock"))
with FileLock(lock_file, timeout=300):
global INIT_DB_PATH
INIT_DB_PATH = INIT_DB_PATH or _init_db_path(tmpdir_factory)
return INIT_DB_PATH
def _init_db_path(tmpdir_factory):
if os.environ.get("TEST_DATABASE_URI"):
return _init_db_path_real_db(os.environ.get("TEST_DATABASE_URI"))
return _init_db_path_sqlite(tmpdir_factory)
def _init_db_path_real_db(db_uri):
"""
Initializes a real database for testing by populating it from scratch. Note that this does.
*not* add the tables (merely data). Callers must have migrated the database before calling
the test suite.
"""
configure(
{
"DB_URI": db_uri,
"SECRET_KEY": "superdupersecret!!!1",
"DB_CONNECTION_ARGS": {
"threadlocals": True,
"autorollback": True,
},
"DB_TRANSACTION_FACTORY": _create_transaction,
"DATABASE_SECRET_KEY": "anothercrazykey!",
}
)
populate_database()
return db_uri
def _init_db_path_sqlite(tmpdir_factory):
"""
Initializes a SQLite database for testing by populating it from scratch and placing it into a
temp directory file.
"""
sqlitedbfile = str(tmpdir_factory.mktemp("data").join("test.db"))
sqlitedb = "sqlite:///{0}".format(sqlitedbfile)
conf = {
"TESTING": True,
"DEBUG": True,
"SECRET_KEY": "superdupersecret!!!1",
"DATABASE_SECRET_KEY": "anothercrazykey!",
"DB_URI": sqlitedb,
}
os.environ["DB_URI"] = str(sqlitedb)
db.initialize(SqliteDatabase(sqlitedbfile))
application.config.update(conf)
application.config.update({"DB_URI": sqlitedb})
initialize_database()
db.obj.execute_sql("PRAGMA foreign_keys = ON;")
db.obj.execute_sql('PRAGMA encoding="UTF-8";')
populate_database()
close_db_filter(None)
return str(sqlitedbfile)
@pytest.yield_fixture()
def database_uri(monkeypatch, init_db_path, sqlitedb_file):
"""
Returns the database URI to use for testing.
In the SQLite case, a new, distinct copy of the SQLite database is created by copying the
initialized database file (sqlitedb_file) on a per-test basis. In the non-SQLite case, a
reference to the existing database URI is returned.
"""
if os.environ.get("TEST_DATABASE_URI"):
db_uri = os.environ["TEST_DATABASE_URI"]
monkeypatch.setenv("DB_URI", db_uri)
yield db_uri
else:
# Copy the golden database file to a new path.
shutil.copy2(init_db_path, sqlitedb_file)
# Monkeypatch the DB_URI.
db_path = "sqlite:///{0}".format(sqlitedb_file)
monkeypatch.setenv("DB_URI", db_path)
yield db_path
# Delete the DB copy.
assert ".." not in sqlitedb_file
assert "test.db" in sqlitedb_file
os.remove(sqlitedb_file)
@pytest.fixture()
def sqlitedb_file(tmpdir):
"""
Returns the path at which the initialized, golden SQLite database file will be placed.
"""
test_db_file = tmpdir.mkdir("quaydb").join("test.db")
return str(test_db_file)
def _create_transaction(db):
return FakeTransaction()
@pytest.fixture()
def appconfig(database_uri):
"""
Returns application configuration for testing that references the proper database URI.
"""
conf = {
"TESTING": True,
"DEBUG": True,
"DB_URI": database_uri,
"SECRET_KEY": "superdupersecret!!!1",
"DATABASE_SECRET_KEY": "anothercrazykey!",
"DB_CONNECTION_ARGS": {
"threadlocals": True,
"autorollback": True,
},
"DB_TRANSACTION_FACTORY": _create_transaction,
"DATA_MODEL_CACHE_CONFIG": {
"engine": "inmemory",
},
"USERFILES_PATH": "userfiles/",
"MAIL_SERVER": "",
"MAIL_DEFAULT_SENDER": "admin@example.com",
"DATABASE_SECRET_KEY": "anothercrazykey!",
"FEATURE_PROXY_CACHE": True,
"ACTION_LOG_AUDIT_LOGINS": True,
"ACTION_LOG_AUDIT_LOGIN_FAILURES": True,
"ACTION_LOG_AUDIT_PULL_FAILURES": True,
"ACTION_LOG_AUDIT_PUSH_FAILURES": True,
"ACTION_LOG_AUDIT_DELETE_FAILURES": True,
}
return conf
AllowedAutoJoin = namedtuple("AllowedAutoJoin", ["frame_start_index", "pattern_prefixes"])
ALLOWED_AUTO_JOINS = [
AllowedAutoJoin(0, ["test_"]),
AllowedAutoJoin(0, ["<", "test_"]),
]
CALLER_FRAMES_OFFSET = 3
FRAME_NAME_INDEX = 3
@pytest.fixture()
def initialized_db(appconfig):
"""
Configures the database for the database found in the appconfig.
"""
under_test_real_database = bool(os.environ.get("TEST_DATABASE_URI"))
# Configure the database.
configure(appconfig)
# Initialize caches.
model._basequery._lookup_team_roles()
model._basequery.get_public_repo_visibility()
model.log.get_log_entry_kinds()
if not under_test_real_database:
# Make absolutely sure foreign key constraints are on.
db.obj.execute_sql("PRAGMA foreign_keys = ON;")
db.obj.execute_sql('PRAGMA encoding="UTF-8";')
assert db.obj.execute_sql("PRAGMA foreign_keys;").fetchone()[0] == 1
assert db.obj.execute_sql("PRAGMA encoding;").fetchone()[0] == "UTF-8"
# If under a test *real* database, setup a savepoint.
if under_test_real_database:
with db.transaction():
test_savepoint = db.savepoint()
test_savepoint.__enter__()
yield # Run the test.
try:
test_savepoint.rollback()
test_savepoint.__exit__(None, None, None)
except InternalError:
# If postgres fails with an exception (like IntegrityError) mid-transaction, it terminates
# it immediately, so when we go to remove the savepoint, it complains. We can safely ignore
# this case.
pass
else:
if os.environ.get("DISALLOW_AUTO_JOINS", "false").lower() == "true":
# Patch get_rel_instance to fail if we try to load any non-joined foreign key. This will allow
# us to catch missing joins when running tests.
def get_rel_instance(self, instance):
value = instance.__data__.get(self.name)
if value is not None or self.name in instance.__rel__:
if self.name not in instance.__rel__:
# NOTE: We only raise an exception if this auto-lookup occurs from non-testing code.
# Testing code can be a bit inefficient.
lookup_allowed = False
try:
outerframes = inspect.getouterframes(inspect.currentframe())
except IndexError:
# Happens due to a bug in Jinja.
outerframes = []
for allowed_auto_join in ALLOWED_AUTO_JOINS:
if lookup_allowed:
break
if (
len(outerframes)
>= allowed_auto_join.frame_start_index + CALLER_FRAMES_OFFSET
):
found_match = True
for index, pattern_prefix in enumerate(
allowed_auto_join.pattern_prefixes
):
frame_info = outerframes[index + CALLER_FRAMES_OFFSET]
if not frame_info[FRAME_NAME_INDEX].startswith(pattern_prefix):
found_match = False
break
if found_match:
lookup_allowed = True
break
if not lookup_allowed:
raise Exception(
"Missing join on instance `%s` for field `%s`", instance, self.name
)
obj = self.rel_model.get(self.field.rel_field == value)
instance.__rel__[self.name] = obj
return instance.__rel__[self.name]
elif not self.field.null:
raise self.rel_model.DoesNotExist
return value
with patch("peewee.ForeignKeyAccessor.get_rel_instance", get_rel_instance):
yield
else:
yield
class _FlaskLoginClient(FlaskClient):
"""
A Flask test client that knows how to log in users
using the Flask-Login extension.
https://github.com/maxcountryman/flask-login/pull/470
"""
def __init__(self, *args, **kwargs):
user = kwargs.pop("user", None)
fresh = kwargs.pop("fresh_login", True)
super(_FlaskLoginClient, self).__init__(*args, **kwargs)
with self.session_transaction() as sess:
if user:
sess["_user_id"] = user.uuid
sess["user_id"] = user.uuid
sess["_fresh"] = fresh
sess["login_time"] = datetime.datetime.now()
else:
sess["_user_id"] = "anonymous"
@pytest.fixture()
def app(appconfig, initialized_db):
"""
Used by pytest-flask plugin to inject a custom app instance for testing.
"""
app = Flask(__name__)
login_manager = LoginManager(app)
login_manager.init_app(app)
@app.errorhandler(model.DataModelException)
def handle_dme(ex):
response = jsonify({"message": str(ex)})
response.status_code = 400
return response
@login_manager.user_loader
def load_user(user_uuid):
return LoginWrappedDBUser(user_uuid)
@identity_loaded.connect_via(app)
def on_identity_loaded_for_test(sender, identity):
on_identity_loaded(sender, identity)
app.test_client_class = _FlaskLoginClient
Principal(app, use_sessions=False)
app.url_map.converters["regex"] = RegexConverter
app.url_map.converters["apirepopath"] = APIRepositoryPathConverter
app.url_map.converters["repopath"] = RepositoryPathConverter
app.url_map.converters["repopathredirect"] = RepositoryPathRedirectConverter
app.url_map.converters["v1createrepopath"] = V1CreateRepositoryPathConverter
app.register_blueprint(api_bp, url_prefix="/api")
app.register_blueprint(web, url_prefix="/")
app.register_blueprint(v1_bp, url_prefix="/v1")
app.register_blueprint(v2_bp, url_prefix="/v2")
app.register_blueprint(webhooks, url_prefix="/webhooks")
app.config.update(appconfig)
features.import_features(app.config)
Userfiles(app)
Mail(app)
with app.app_context():
yield app