diff --git a/.github/workflows/run-migrations-e2e-tests.yml b/.github/workflows/run-migrations-e2e-tests.yml new file mode 100644 index 0000000000..59ab649f34 --- /dev/null +++ b/.github/workflows/run-migrations-e2e-tests.yml @@ -0,0 +1,499 @@ +on: + workflow_call: + inputs: + db-type: + required: true + type: string + redis_enabled: + required: true + type: boolean + python-version: + required: true + type: string + is-fork: + required: true + type: boolean + backend-image-name: + required: true + type: string + frontend-image-name: + required: true + type: string + +jobs: + run-migration-tests: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + REDIS: ${{ inputs.redis_enabled }} + REDIS_HOST: keep-redis + REDIS_PORT: 6379 + OLD_BACKEND_IMAGE: us-central1-docker.pkg.dev/keephq/keep/keep-api:0.42.0 + SOURCE_BACKEND_IMAGE: ${{ inputs.backend-image-name }} + OLD_FRONTEND_IMAGE: us-central1-docker.pkg.dev/keephq/keep/keep-ui:0.42.0 + SOURCE_FRONTEND_IMAGE: ${{ inputs.frontend-image-name }} + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Login to GitHub Container Registry + if: ${{ inputs.is-fork != true }} + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python ${{ inputs.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Restore dependencies cache + id: cache-deps + uses: actions/cache@v4.2.0 + with: + path: .venv + key: pydeps-${{ hashFiles('**/poetry.lock') }} + + # Only install dependencies if cache miss + - name: Install dependencies using poetry + if: steps.cache-deps.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root --with dev + + - name: Get Playwright version from poetry.lock + id: playwright-version + run: | + PLAYWRIGHT_VERSION=$(grep "playwright" poetry.lock -A 5 | grep "version" | head -n 1 | cut -d'"' -f2) + echo "version=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4.2.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright and dependencies + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: | + poetry run playwright install --with-deps + + # For forks: Build images locally again since they don't persist between jobs + - name: Set up Docker Buildx + if: ${{ inputs.is-fork == true }} + id: buildx + uses: docker/setup-buildx-action@v2 + + - name: Rebuild frontend image locally for fork PRs + if: ${{ inputs.is-fork == true }} + uses: docker/build-push-action@v4 + with: + context: keep-ui + file: ./docker/Dockerfile.ui + push: false + load: true + tags: | + keep-frontend:local + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILDKIT_INLINE_CACHE=1 + + - name: Rebuild backend image locally for fork PRs + if: ${{ inputs.is-fork == true }} + uses: docker/build-push-action@v4 + with: + context: . + file: ./docker/Dockerfile.api + push: false + load: true + tags: | + keep-backend:local + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILDKIT_INLINE_CACHE=1 + + # Phase 1: Run old version + - name: Create docker-compose file for old version + run: | + cp tests/e2e_tests/docker-compose-e2e-${{ inputs.db-type }}.yml tests/e2e_tests/docker-compose-modified.yml + sed -i "s|%KEEPFRONTEND_IMAGE%|${{ env.OLD_FRONTEND_IMAGE }}|g" tests/e2e_tests/docker-compose-modified.yml + sed -i "s|%KEEPBACKEND_IMAGE%|${{ env.OLD_BACKEND_IMAGE }}|g" tests/e2e_tests/docker-compose-modified.yml + cat tests/e2e_tests/docker-compose-modified.yml + + - name: Start old version services + run: | + if [[ "${{ inputs.is-fork }}" != "true" ]]; then + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml pull + fi + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml up --build -d + + - name: Wait for old version services + run: | + # Function for exponential backoff + function wait_for_service() { + local service_name=$1 + local check_command=$2 + local max_attempts=$3 + local compose_service=$4 # Docker Compose service name + local attempt=0 + local wait_time=1 + + echo "Waiting for $service_name to be ready..." + until eval "$check_command"; do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "Max attempts reached, exiting..." + # Show final logs before exiting + if [ ! -z "$compose_service" ]; then + echo "===== FINAL LOGS FOR ON ERROR EXIT $compose_service =====" + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs $compose_service + echo "==========================================" + fi + exit 1 + fi + + echo "Waiting for $service_name... (Attempt: $((attempt+1)), waiting ${wait_time}s)" + + # Print logs using docker compose + if [ ! -z "$compose_service" ]; then + echo "===== RECENT LOGS FOR $compose_service =====" + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs $compose_service --tail 100 + echo "==========================================" + fi + + attempt=$((attempt+1)) + sleep $wait_time + # Exponential backoff with max of 8 seconds + wait_time=$((wait_time * 2 > 8 ? 8 : wait_time * 2)) + done + echo "$service_name is ready!" + + # last time, print logs using docker compose + if [ ! -z "$compose_service" ]; then + echo "===== FINAL LOGS FOR $compose_service =====" + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs $compose_service --tail 100 + echo "==========================================" + fi + } + + # Database checks + if [ "${{ inputs.db-type }}" == "mysql" ]; then + wait_for_service "MySQL Database" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database mysqladmin ping -h \"localhost\" --silent" 10 "keep-database" + wait_for_service "MySQL Database (DB AUTH)" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database-db-auth mysqladmin ping -h \"localhost\" --silent" 10 "keep-database-db-auth" + elif [ "${{ inputs.db-type }}" == "postgres" ]; then + wait_for_service "Postgres Database" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database pg_isready -h localhost -U keepuser" 10 "keep-database" + wait_for_service "Postgres Database (DB AUTH)" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database-db-auth pg_isready -h localhost -U keepuser" 10 "keep-database-db-auth" + fi + + # Wait for services with health checks + wait_for_service "Keep backend" "curl --output /dev/null --silent --fail http://localhost:8080/healthcheck" 15 "keep-backend" + wait_for_service "Keep backend (DB AUTH)" "curl --output /dev/null --silent --fail http://localhost:8081/healthcheck" 15 "keep-backend-db-auth" + wait_for_service "Keep frontend" "curl --output /dev/null --silent --fail http://localhost:3000/" 15 "keep-frontend" + wait_for_service "Keep frontend (DB AUTH)" "curl --output /dev/null --silent --fail http://localhost:3001/" 15 "keep-frontend-db-auth" + + # Give everything a bit more time to stabilize + echo "Giving services additional time to stabilize..." + sleep 10 + + - name: Run tests against old version + run: | + # Prepare alembic config + docker exec keep-keep-backend-1 cp /venv/lib/python3.11/site-packages/keep/alembic.ini ./alembic_temp.ini + docker exec keep-keep-backend-1 sed -i 's|script_location.*|script_location = /venv/lib/python3.11/site-packages/keep/api/models/db/migrations|' ./alembic_temp.ini + + # Run alembic inside the container and extract revision containing '(head)' + REVISION=$(docker exec keep-keep-backend-1 alembic -c ./alembic_temp.ini current | grep '(head)' | awk '{print $1}') + + echo "Current head revision: $REVISION" + + # Save to output variable to reuse in later steps + echo "revision=$REVISION" >> $GITHUB_OUTPUT + + poetry run pytest -v tests/e2e_tests/test_end_to_end_db_auth.py -n 4 --dist=loadfile + + - name: Stop old version services + run: | + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml stop + + # Phase 2: Run source version + - name: Create docker-compose file for source version + run: | + cp tests/e2e_tests/docker-compose-e2e-${{ inputs.db-type }}.yml tests/e2e_tests/docker-compose-modified.yml + sed -i "s|%KEEPFRONTEND_IMAGE%|${{ env.SOURCE_FRONTEND_IMAGE }}|g" tests/e2e_tests/docker-compose-modified.yml + sed -i "s|%KEEPBACKEND_IMAGE%|${{ env.SOURCE_BACKEND_IMAGE }}|g" tests/e2e_tests/docker-compose-modified.yml + cat tests/e2e_tests/docker-compose-modified.yml + + - name: Start source version services + run: | + if [[ "${{ inputs.is-fork }}" != "true" ]]; then + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml pull + fi + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml up --build -d + + - name: Wait for source version services + run: | + # Function for exponential backoff + function wait_for_service() { + local service_name=$1 + local check_command=$2 + local max_attempts=$3 + local compose_service=$4 # Docker Compose service name + local attempt=0 + local wait_time=1 + + echo "Waiting for $service_name to be ready..." + until eval "$check_command"; do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "Max attempts reached, exiting..." + # Show final logs before exiting + if [ ! -z "$compose_service" ]; then + echo "===== FINAL LOGS FOR ON ERROR EXIT $compose_service =====" + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs $compose_service + echo "==========================================" + fi + exit 1 + fi + + echo "Waiting for $service_name... (Attempt: $((attempt+1)), waiting ${wait_time}s)" + + # Print logs using docker compose + if [ ! -z "$compose_service" ]; then + echo "===== RECENT LOGS FOR $compose_service =====" + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs $compose_service --tail 100 + echo "==========================================" + fi + + attempt=$((attempt+1)) + sleep $wait_time + # Exponential backoff with max of 8 seconds + wait_time=$((wait_time * 2 > 8 ? 8 : wait_time * 2)) + done + echo "$service_name is ready!" + + # last time, print logs using docker compose + if [ ! -z "$compose_service" ]; then + echo "===== FINAL LOGS FOR $compose_service =====" + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs $compose_service --tail 100 + echo "==========================================" + fi + } + + # Database checks + if [ "${{ inputs.db-type }}" == "mysql" ]; then + wait_for_service "MySQL Database" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database mysqladmin ping -h \"localhost\" --silent" 10 "keep-database" + wait_for_service "MySQL Database (DB AUTH)" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database-db-auth mysqladmin ping -h \"localhost\" --silent" 10 "keep-database-db-auth" + elif [ "${{ inputs.db-type }}" == "postgres" ]; then + wait_for_service "Postgres Database" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database pg_isready -h localhost -U keepuser" 10 "keep-database" + wait_for_service "Postgres Database (DB AUTH)" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database-db-auth pg_isready -h localhost -U keepuser" 10 "keep-database-db-auth" + fi + + # Wait for services with health checks + wait_for_service "Keep backend" "curl --output /dev/null --silent --fail http://localhost:8080/healthcheck" 15 "keep-backend" + wait_for_service "Keep backend (DB AUTH)" "curl --output /dev/null --silent --fail http://localhost:8081/healthcheck" 15 "keep-backend-db-auth" + wait_for_service "Keep frontend" "curl --output /dev/null --silent --fail http://localhost:3000/" 15 "keep-frontend" + wait_for_service "Keep frontend (DB AUTH)" "curl --output /dev/null --silent --fail http://localhost:3001/" 15 "keep-frontend-db-auth" + + # Give everything a bit more time to stabilize + echo "Giving services additional time to stabilize..." + sleep 10 + + - name: Run tests against source version + run: | + # Prepare alembic config + docker exec keep-keep-backend-1 cp /venv/lib/python3.11/site-packages/keep/alembic.ini ./alembic_temp.ini + docker exec keep-keep-backend-1 sed -i 's|script_location.*|script_location = /venv/lib/python3.11/site-packages/keep/api/models/db/migrations|' ./alembic_temp.ini + + # Run alembic inside the container and extract revision containing '(head)' + REVISION=$(docker exec keep-keep-backend-1 alembic -c ./alembic_temp.ini current | grep '(head)' | awk '{print $1}') + + echo "Current head revision: $REVISION" + + if [[ "$REVISION" != "$WORKFLOW_REVISION" ]]; then + echo "Revisions don't match, continue..." + else + echo "Revisions match, exiting..." + exit 1 + fi + + # Save to output variable to reuse in later steps + echo "revision=$REVISION" >> $GITHUB_OUTPUT + + # Running full test to test working after migrations + echo "Running tests..." + poetry run coverage run --branch -m pytest -v tests/e2e_tests/ -n 4 --dist=loadfile + echo "Tests completed!" + env: + WORKFLOW_REVISION: ${{ steps.get_revision.outputs.revision }} + + - name: Add dummy migration + run: | + docker exec keep-keep-backend-1 bash -c " + cp /venv/lib/python3.11/site-packages/keep/alembic.ini /app/alembic_temp.ini && + sed -i 's|script_location.*|script_location = /venv/lib/python3.11/site-packages/keep/api/models/db/migrations|' /app/alembic_temp.ini && + + LAST_MIGRATION=\$(ls -t /venv/lib/python3.11/site-packages/keep/api/models/db/migrations/versions/*.py | head -n1) && + echo \"Last migration: \$LAST_MIGRATION\" && + + alembic -c /app/alembic_temp.ini revision -m \"test_dummy_migration\" && + + NEW_MIGRATION=\$(ls -t /venv/lib/python3.11/site-packages/keep/api/models/db/migrations/versions/*.py | head -n1) && + + cat > \"\$NEW_MIGRATION\" << EOF + \"\"\"test_dummy_migration + + Revision ID: \$(basename \"\$NEW_MIGRATION\" .py) + Revises: \$(basename \"\$LAST_MIGRATION\" .py) + Create Date: \$(date +\"%Y-%m-%d %H:%M:%S\") + + \"\"\" + from alembic import op + import sqlalchemy as sa + + # revision identifiers, used by Alembic. + revision = '\$(basename \"\$NEW_MIGRATION\" .py)' + down_revision = '\$(basename \"\$LAST_MIGRATION\" .py)' + branch_labels = None + depends_on = None + + def upgrade() -> None: + # Empty upgrade operation + pass + + def downgrade() -> None: + # Empty downgrade operation + pass + EOF + + alembic -c /app/alembic_temp.ini upgrade head + " + echo "Dummy migration added" + + - name: Stop source version services + run: | + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml stop + + # Phase 3: Run source version without dummy migration again + - name: Start source version services again + run: | + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml up --build -d + + - name: Wait for source version services again + run: | + # Function for exponential backoff + function wait_for_service() { + local service_name=$1 + local check_command=$2 + local max_attempts=$3 + local compose_service=$4 # Docker Compose service name + local attempt=0 + local wait_time=1 + + echo "Waiting for $service_name to be ready..." + until eval "$check_command"; do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "Max attempts reached, exiting..." + # Show final logs before exiting + if [ ! -z "$compose_service" ]; then + echo "===== FINAL LOGS FOR ON ERROR EXIT $compose_service =====" + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs $compose_service + echo "==========================================" + fi + exit 1 + fi + + echo "Waiting for $service_name... (Attempt: $((attempt+1)), waiting ${wait_time}s)" + + # Print logs using docker compose + if [ ! -z "$compose_service" ]; then + echo "===== RECENT LOGS FOR $compose_service =====" + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs $compose_service --tail 100 + echo "==========================================" + fi + + attempt=$((attempt+1)) + sleep $wait_time + # Exponential backoff with max of 8 seconds + wait_time=$((wait_time * 2 > 8 ? 8 : wait_time * 2)) + done + echo "$service_name is ready!" + + # last time, print logs using docker compose + if [ ! -z "$compose_service" ]; then + echo "===== FINAL LOGS FOR $compose_service =====" + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs $compose_service --tail 100 + echo "==========================================" + fi + } + + # Database checks + if [ "${{ inputs.db-type }}" == "mysql" ]; then + wait_for_service "MySQL Database" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database mysqladmin ping -h \"localhost\" --silent" 10 "keep-database" + wait_for_service "MySQL Database (DB AUTH)" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database-db-auth mysqladmin ping -h \"localhost\" --silent" 10 "keep-database-db-auth" + elif [ "${{ inputs.db-type }}" == "postgres" ]; then + wait_for_service "Postgres Database" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database pg_isready -h localhost -U keepuser" 10 "keep-database" + wait_for_service "Postgres Database (DB AUTH)" "docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml exec -T keep-database-db-auth pg_isready -h localhost -U keepuser" 10 "keep-database-db-auth" + fi + + # Wait for services with health checks + wait_for_service "Keep backend" "curl --output /dev/null --silent --fail http://localhost:8080/healthcheck" 15 "keep-backend" + wait_for_service "Keep backend (DB AUTH)" "curl --output /dev/null --silent --fail http://localhost:8081/healthcheck" 15 "keep-backend-db-auth" + wait_for_service "Keep frontend" "curl --output /dev/null --silent --fail http://localhost:3000/" 15 "keep-frontend" + wait_for_service "Keep frontend (DB AUTH)" "curl --output /dev/null --silent --fail http://localhost:3001/" 15 "keep-frontend-db-auth" + + # Give everything a bit more time to stabilize + echo "Giving services additional time to stabilize..." + sleep 10 + + - name: Run tests against source version again + run: | + # Prepare alembic config + docker exec keep-keep-backend-1 cp /venv/lib/python3.11/site-packages/keep/alembic.ini ./alembic_temp.ini + docker exec keep-keep-backend-1 sed -i 's|script_location.*|script_location = /venv/lib/python3.11/site-packages/keep/api/models/db/migrations|' ./alembic_temp.ini + + # Run alembic inside the container and extract revision containing '(head)' + REVISION=$(docker exec keep-keep-backend-1 alembic -c ./alembic_temp.ini current | grep '(head)' | awk '{print $1}') + + echo "Current head revision: $REVISION" + + if [[ "$REVISION" != "$WORKFLOW_REVISION" ]]; then + echo "Revisions don't match, continue..." + else + echo "Revisions match, exiting..." + exit 1 + fi + + # Save to output variable to reuse in later steps + echo "revision=$REVISION" >> $GITHUB_OUTPUT + + poetry run pytest -v tests/e2e_tests/test_end_to_end_db_auth.py -n 4 --dist=loadfile + env: + WORKFLOW_REVISION: ${{ steps.get_revision.outputs.revision }} + + - name: Stop source version services again + run: | + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml down + + # Collect and upload logs + - name: Collect logs + if: always() + run: | + mkdir -p logs + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs > logs/old-version-logs.txt + docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs > logs/source-version-logs.txt + + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: migration-test-logs + path: logs/ diff --git a/.github/workflows/test-pr-e2e.yml b/.github/workflows/test-pr-e2e.yml index 564ca32277..d0583d0260 100644 --- a/.github/workflows/test-pr-e2e.yml +++ b/.github/workflows/test-pr-e2e.yml @@ -300,7 +300,7 @@ jobs: is-fork: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} backend-image-name: ${{ needs.build-backend.outputs.image_name }} frontend-image-name: ${{ needs.build-frontend.outputs.image_name }} - + run-postgresql-without-redis: needs: [build-frontend, build-backend, prepare-test-environment] uses: ./.github/workflows/run-e2e-tests.yml @@ -311,7 +311,7 @@ jobs: is-fork: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} backend-image-name: ${{ needs.build-backend.outputs.image_name }} frontend-image-name: ${{ needs.build-frontend.outputs.image_name }} - + run-sqlite-without-redis: needs: [build-frontend, build-backend, prepare-test-environment] uses: ./.github/workflows/run-e2e-tests.yml @@ -321,4 +321,15 @@ jobs: python-version: 3.11 is-fork: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} backend-image-name: ${{ needs.build-backend.outputs.image_name }} + frontend-image-name: ${{ needs.build-frontend.outputs.image_name }} + + run-migrations-tests: + needs: [build-frontend, build-backend, prepare-test-environment] + uses: ./.github/workflows/run-migrations-e2e-tests.yml + with: + db-type: postgres-migrations + redis_enabled: false + python-version: 3.11 + is-fork: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} + backend-image-name: ${{ needs.build-backend.outputs.image_name }} frontend-image-name: ${{ needs.build-frontend.outputs.image_name }} \ No newline at end of file diff --git a/docker-compose-with-arq.yml b/docker-compose-with-arq.yml index 9c2f0f300d..702ccd8a2a 100644 --- a/docker-compose-with-arq.yml +++ b/docker-compose-with-arq.yml @@ -24,6 +24,7 @@ services: - REDIS_PORT=6379 volumes: - ./state:/state + - ./tmp/keep/migrations:/tmp/keep/migrations depends_on: - keep-arq-redis diff --git a/docker-compose-with-auth.yml b/docker-compose-with-auth.yml index 6d9d8be528..277268b514 100644 --- a/docker-compose-with-auth.yml +++ b/docker-compose-with-auth.yml @@ -25,6 +25,7 @@ services: - KEEP_DEFAULT_PASSWORD=keep volumes: - ./state:/state + - ./tmp/keep/migrations:/tmp/keep/migrations keep-websocket-server: extends: diff --git a/docker-compose-with-otel.yaml b/docker-compose-with-otel.yaml index 84b668597b..4ef94ea6d3 100644 --- a/docker-compose-with-otel.yaml +++ b/docker-compose-with-otel.yaml @@ -111,6 +111,7 @@ services: volumes: - .:/app - ./state:/state + - ./tmp/keep/migrations:/tmp/keep/migrations keep-websocket-server: extends: diff --git a/docker-compose.common.yml b/docker-compose.common.yml index b2f8d44fcc..9456984b66 100644 --- a/docker-compose.common.yml +++ b/docker-compose.common.yml @@ -20,8 +20,10 @@ services: - PORT=8080 - SECRET_MANAGER_TYPE=FILE - SECRET_MANAGER_DIRECTORY=/state + - MIGRATIONS_PATH=/tmp/keep/migrations - DATABASE_CONNECTION_STRING=sqlite:////state/db.sqlite3?check_same_thread=False - OPENAI_API_KEY=$OPENAI_API_KEY + - ALLOW_DB_DOWNGRADE=false - PUSHER_APP_ID=1 - PUSHER_APP_KEY=keepappkey - PUSHER_APP_SECRET=keepappsecret diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 40e727b488..d9aec3e118 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -24,6 +24,7 @@ services: volumes: - .:/app - ./state:/state + - ./tmp/keep/migrations:/tmp/keep/migrations keep-websocket-server: extends: diff --git a/docker-compose.yml b/docker-compose.yml index 8358c4edc3..6efb646ed4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - KEEP_METRICS=true volumes: - ./state:/state + - ./tmp/keep/migrations:/tmp/keep/migrations keep-websocket-server: extends: diff --git a/docs/deployment/configuration.mdx b/docs/deployment/configuration.mdx index 3b02c4a76c..827711743b 100644 --- a/docs/deployment/configuration.mdx +++ b/docs/deployment/configuration.mdx @@ -28,6 +28,7 @@ Keep is highly configurable through environment variables. This allows you to cu | **KEEP_STORE_RAW_ALERTS** | Enables storing of raw alerts | No | "false" | "true" or "false" | | **TENANT_CONFIGURATION_RELOAD_TIME** | Time in minutes to reload tenant configurations | No | 5 | Positive integer | | **KEEP_LIVE_DEMO_MODE** | Keep will simulate incoming alerts and other activity | No | "false" | "true" or "false" | +| **MIGRATIONS_PATH** | Path to migrations directory | No | "/tmp/keep/migrations" | Valid os path | ### Logging and Environment @@ -63,6 +64,7 @@ Keep is highly configurable through environment variables. This allows you to cu | **DB_SERVICE_ACCOUNT** | Service account for database impersonation | No | None | Valid service account email | | **DB_IP_TYPE** | Specifies the Cloud SQL IP type | No | "public" | "public", "private" or "psc" | | **SKIP_DB_CREATION** | Skips database creation and migrations | No | "false" | "true" or "false" | +| **ALLOW_DB_DOWNGRADE** | Enables downgrading database schema | No | "false" | "true" or "false" | ### Resource Provisioning diff --git a/keep/api/core/db_on_start.py b/keep/api/core/db_on_start.py index eae6dc7850..555b7be6e3 100644 --- a/keep/api/core/db_on_start.py +++ b/keep/api/core/db_on_start.py @@ -16,9 +16,11 @@ import hashlib import logging import os +import shutil import alembic.command import alembic.config +from alembic.runtime.migration import MigrationContext from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select @@ -167,8 +169,79 @@ def try_create_single_tenant(tenant_id: str, create_default_user=True) -> None: logger.exception("Failed to create single tenant") pass +def get_current_revision(): + """Get current app revision""" + with engine.connect() as connection: + context = MigrationContext.configure(connection) + return context.get_current_revision() -def migrate_db(): +def copy_migrations(app_migrations_path, local_migrations_path): + """Copy migrations to a local backup folder for safe downgrade purposes.""" + + source_versions_path = os.path.join(app_migrations_path, "versions") + + # Ensure destination exists + try: + os.makedirs(local_migrations_path, exist_ok=True) + except Exception as e: + logger.error(f"Failed to create local migrations folder with error: {e}") + + + # Clear previous versioned migrations to ensure only migrations relevant to the current version are present + for filename in os.listdir(local_migrations_path): + file_path = os.path.join(local_migrations_path, filename) + if os.path.isfile(file_path) or os.path.islink(file_path): + os.remove(file_path) + + # Alembic needs the full migration history to safely perform a downgrade to earlier versions + # Copy new migrations + for item in os.listdir(source_versions_path): + src = os.path.join(source_versions_path, item) + dst = os.path.join(local_migrations_path, item) + if os.path.isdir(src): + shutil.copytree(src, dst, dirs_exist_ok=True) + else: + shutil.copy(src, dst) + +def downgrade_db(config, expected_revision, local_migrations_path, app_migrations_path): + """ + Downgrade the DB to the previous revision, using local backup migrations temporarily. + Restores original migrations after downgrade. + """ + source_versions_path = os.path.join(app_migrations_path, "versions") + source_versions_path_copy = os.path.join(app_migrations_path, "versions_copy") + + try: + logger.info("Backing up original migrations...") + if os.path.exists(source_versions_path_copy): + shutil.rmtree(source_versions_path_copy) + shutil.move(source_versions_path, source_versions_path_copy) + logger.info("Original migrations backed up.") + + logger.info("Restoring migrations from local backup...") + shutil.copytree(local_migrations_path, source_versions_path) + logger.info("Migrations restored from local.") + + logger.info("Downgrading the database...") + alembic.command.downgrade(config, expected_revision) + logger.info("Database successfully downgraded.") + + except Exception as e: + logger.error(f"Error occurred during downgrade process: {e}") + finally: + logger.info("Restoring original migrations...") + try: + if os.path.exists(source_versions_path): + shutil.rmtree(source_versions_path) + if os.path.exists(source_versions_path_copy): + shutil.move(source_versions_path_copy, source_versions_path) + logger.info("Original migrations restored!") + else: + logger.warning("Backup not found!!! Original migrations not restored!!!") + except Exception as restore_error: + logger.error(f"Failed to restore original migrations: {restore_error}") + +def migrate_db(config_path: str = None, app_migrations_path: str = None): """ Run migrations to make sure the DB is up-to-date. """ @@ -176,14 +249,43 @@ def migrate_db(): logger.info("Skipping running migrations...") return None - logger.info("Running migrations...") - config_path = os.path.dirname(os.path.abspath(__file__)) + "/../../" + "alembic.ini" + config_path = config_path or os.path.dirname(os.path.abspath(__file__)) + "/../../" + "alembic.ini" config = alembic.config.Config(file_=config_path) # Re-defined because alembic.ini uses relative paths which doesn't work # when running the app as a pyhton pakage (could happen form any path) + + # This path will be used to save migrations locally for safe downgrade purposes + local_migrations_path = os.environ.get("MIGRATIONS_PATH", "/tmp/keep/migrations") + app_migrations_path = app_migrations_path or os.path.dirname(os.path.abspath(__file__)) + "/../models/db/migrations" config.set_main_option( "script_location", - os.path.dirname(os.path.abspath(__file__)) + "/../models/db/migrations", + app_migrations_path, ) - alembic.command.upgrade(config, "head") + alembic_script = alembic.script.ScriptDirectory.from_config(config) + + current_revision = get_current_revision() + expected_revision = alembic_script.get_current_head() + + # If the current revision is the same as the expected revision, we don't need to run migrations + if current_revision and expected_revision and current_revision == expected_revision: + logger.info("Database schema is up-to-date!") + return None + + logger.warning(f"Database schema ({current_revision}) doesn't match application version ({expected_revision})") + logger.info("Running migrations...") + try: + alembic.command.upgrade(config, "head") + except Exception as e: + logger.error(f"{e} it's seems like Keep was rolled back to a previous version") + + if not os.getenv("ALLOW_DB_DOWNGRADE", "false") == "true": + logger.error(f"ALLOW_DB_DOWNGRADE is not set to true, but the database schema ({current_revision}) doesn't match application version ({expected_revision})") + raise RuntimeError("Database downgrade is not allowed") + + logger.info("Downgrading database schema...") + downgrade_db(config, expected_revision, local_migrations_path, app_migrations_path) + + # Copy migrations to local folder for safe downgrade purposes + copy_migrations(app_migrations_path, local_migrations_path) + logger.info("Finished migrations") diff --git a/poetry.lock b/poetry.lock index a232afe256..0b43cfd50f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "aiofiles" @@ -579,7 +579,7 @@ version = "24.10.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] files = [ {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, @@ -952,7 +952,7 @@ version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -1120,7 +1120,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {dev = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -3081,7 +3081,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" -groups = ["dev"] +groups = ["main"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -3585,7 +3585,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -6070,4 +6070,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.12" -content-hash = "a535acdb5b28f72fb5744a9398845184980f84f16fc5c046ea5ca2b0381ca280" +content-hash = "86501536596b4e1467dcca03f12c39b43a0d7c4571f0b23a84b6a6142e851c31" diff --git a/pyproject.toml b/pyproject.toml index 070afb5bfe..2624cae810 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ psycopg-binary = "^3.2.3" psycopg = "^3.2.3" prometheus-client = "^0.21.1" psycopg2-binary = "^2.9.10" +black = "^24.3.0" prometheus-fastapi-instrumentator = "^7.0.0" slowapi = "^0.1.9" @@ -100,7 +101,6 @@ awscli = "^1.40.8" pre-commit = "^3.0.4" pre-commit-hooks = "^4.4.0" yamllint = "^1.29.0" -black = "^24.3.0" isort = "^5.12.0" autopep8 = "^2.0.1" flake8 = "^6.0.0" diff --git a/tests/e2e_tests/docker-compose-e2e-mysql.yml b/tests/e2e_tests/docker-compose-e2e-mysql.yml index af081159c0..4bd0cc9a5f 100644 --- a/tests/e2e_tests/docker-compose-e2e-mysql.yml +++ b/tests/e2e_tests/docker-compose-e2e-mysql.yml @@ -29,21 +29,23 @@ services: - API_URL=http://keep-backend:8080 - POSTHOG_DISABLED=true - SENTRY_DISABLED=true + - ALLOW_DB_DOWNGRADE=true # Backend Services keep-backend: # to be replaced in github actions image: "%KEEPBACKEND_IMAGE%" + ports: + - "8080:8080" environment: - AUTH_TYPE=NO_AUTH - DATABASE_CONNECTION_STRING=mysql+pymysql://root:keep@keep-database:3306/keep - POSTHOG_DISABLED=true - SECRET_MANAGER_DIRECTORY=/app + - MIGRATIONS_PATH=/tmp/migrations - SQLALCHEMY_WARN_20=1 - REDIS=${REDIS:-false} - REDIS_HOST=${REDIS_HOST:-localhost} - ports: - - "8080:8080" depends_on: keep-database: condition: service_healthy diff --git a/tests/e2e_tests/docker-compose-e2e-postgres-migrations.yml b/tests/e2e_tests/docker-compose-e2e-postgres-migrations.yml new file mode 100644 index 0000000000..b111e231d6 --- /dev/null +++ b/tests/e2e_tests/docker-compose-e2e-postgres-migrations.yml @@ -0,0 +1,149 @@ +services: + ## Keep Services with NO_AUTH + # Database Service + keep-database: + image: postgres:13 + environment: + POSTGRES_USER: keepuser + POSTGRES_PASSWORD: keeppassword + POSTGRES_DB: keepdb + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + - ./postgres-custom.conf:/etc/postgresql/conf.d/custom.conf + - ./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d + + # Frontend Services + keep-frontend: + # to be replaced in github actions + image: "%KEEPFRONTEND_IMAGE%" + ports: + - "3000:3000" + environment: + - AUTH_TYPE=NO_AUTH + - NEXTAUTH_SECRET=secret + - API_URL=http://keep-backend:8080 + - POSTHOG_DISABLED=true + - SENTRY_DISABLED=true + + # Backend Services + keep-backend: + # to be replaced in github actions + image: "%KEEPBACKEND_IMAGE%" + ports: + - "8080:8080" + environment: + - AUTH_TYPE=NO_AUTH + - DATABASE_CONNECTION_STRING=postgresql+psycopg2://keepuser:keeppassword@keep-database:5432/keepdb + - POSTHOG_DISABLED=true + - SECRET_MANAGER_DIRECTORY=/app + - MIGRATION_PATH=/tmp/migrations + - SQLALCHEMY_WARN_20=1 + - REDIS=${REDIS:-false} + - REDIS_HOST=${REDIS_HOST:-localhost} + - ALLOW_DB_DOWNGRADE=true + volumes: + - backend-migrations:/tmp + depends_on: + - keep-database + + ## Keep Services with DB + # Database Service (5433) + keep-database-db-auth: + image: postgres:13 + environment: + POSTGRES_USER: keepuser + POSTGRES_PASSWORD: keeppassword + POSTGRES_DB: keepdb + ports: + - "5433:5432" + volumes: + - postgres-data:/var/lib/postgresql-auth-db/data + - ./postgres-custom.conf:/etc/postgresql-auth-db/conf.d/custom.conf + - ./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d + + # Frontend Services (3001) + keep-frontend-db-auth: + # to be replaced in github actions + image: "%KEEPFRONTEND_IMAGE%" + ports: + - "3001:3000" + environment: + - NEXTAUTH_SECRET=secret + - NEXTAUTH_URL=http://localhost:3001 + - POSTHOG_DISABLED=true + - AUTH_TYPE=DB + - API_URL=http://keep-backend-db-auth:8080 + - POSTHOG_DISABLED=true + - SENTRY_DISABLED=true + - AUTH_DEBUG=true + + # Backend Services (8081) + keep-backend-db-auth: + # to be replaced in github actions + image: "%KEEPBACKEND_IMAGE%" + ports: + - "8081:8080" + environment: + - PORT=8080 + - SECRET_MANAGER_TYPE=FILE + - SECRET_MANAGER_DIRECTORY=/state + - OPENAI_API_KEY=$OPENAI_API_KEY + - PUSHER_APP_ID=1 + - PUSHER_APP_KEY=keepappkey + - PUSHER_APP_SECRET=keepappsecret + - PUSHER_HOST=keep-websocket-server + - PUSHER_PORT=6001 + - USE_NGROK=false + - AUTH_TYPE=DB + - DATABASE_CONNECTION_STRING=postgresql+psycopg2://keepuser:keeppassword@keep-database-db-auth:5432/keepdb + - POSTHOG_DISABLED=true + - SECRET_MANAGER_DIRECTORY=/app + - SQLALCHEMY_WARN_20=1 + - KEEP_JWT_SECRET=verysecretkey + - KEEP_DEFAULT_USERNAME=keep + - KEEP_DEFAULT_PASSWORD=keep + # no need to set REDIS_HOST and REDIS_PORT for auth + # - REDIS=${REDIS:-false} + # - REDIS_HOST=${REDIS_HOST:-localhost} + depends_on: + - keep-database-db-auth + + # Other Services (Common) + keep-websocket-server: + extends: + file: docker-compose.common.yml + service: keep-websocket-server-common + + prometheus-server-for-test-target: + image: prom/prometheus + volumes: + - ./tests/e2e_tests/test_pushing_prometheus_config.yaml:/etc/prometheus/prometheus.yml + - ./tests/e2e_tests/test_pushing_prometheus_rules.yaml:/etc/prometheus/test_pushing_prometheus_rules.yaml + ports: + - "9090:9090" + + keep-redis: + image: redis/redis-stack + ports: + - "6379:6379" + - "8082:8001" + grafana: + image: grafana/grafana-enterprise:11.4.0 + user: "472" # Grafana's default user ID + ports: + - "3002:3000" + volumes: + - ./keep/providers/grafana_provider/grafana/provisioning:/etc/grafana/provisioning:ro + - ./tests/e2e_tests/grafana.ini:/etc/grafana/grafana.ini:ro + - grafana-storage:/var/lib/grafana + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + depends_on: + - prometheus-server-for-test-target + +volumes: + postgres-data: + backend-migrations: + grafana-storage: {} diff --git a/tests/e2e_tests/docker-compose-e2e-postgres.yml b/tests/e2e_tests/docker-compose-e2e-postgres.yml index 72c9e0bf56..cd81cbb6fb 100644 --- a/tests/e2e_tests/docker-compose-e2e-postgres.yml +++ b/tests/e2e_tests/docker-compose-e2e-postgres.yml @@ -39,9 +39,11 @@ services: - DATABASE_CONNECTION_STRING=postgresql+psycopg2://keepuser:keeppassword@keep-database:5432/keepdb - POSTHOG_DISABLED=true - SECRET_MANAGER_DIRECTORY=/app + - MIGRATION_PATH=/tmp/migrations - SQLALCHEMY_WARN_20=1 - REDIS=${REDIS:-false} - REDIS_HOST=${REDIS_HOST:-localhost} + - ALLOW_DB_DOWNGRADE=true depends_on: - keep-database diff --git a/tests/e2e_tests/docker-compose-e2e-redis.yml b/tests/e2e_tests/docker-compose-e2e-redis.yml index 20f1a3ea58..bdb165b8d9 100644 --- a/tests/e2e_tests/docker-compose-e2e-redis.yml +++ b/tests/e2e_tests/docker-compose-e2e-redis.yml @@ -11,6 +11,7 @@ services: - API_URL=http://keep-backend:8080 - POSTHOG_DISABLED=true - SENTRY_DISABLED=true + - ALLOW_DB_DOWNGRADE=true depends_on: - keep-backend @@ -23,7 +24,8 @@ services: - AUTH_TYPE=NO_AUTH - DATABASE_CONNECTION_STRING=sqlite:///./newdb.db?check_same_thread=False - POSTHOG_DISABLED=true - - SECRET_MANAGER_DIRECTORY=/appÖ¿ + - SECRET_MANAGER_DIRECTORY=/app + - MIGRATIONS_PATH=/tmp/migrations - REDIS=true - REDIS_HOST=keep-arq-redis - REDIS_PORT=6379 diff --git a/tests/e2e_tests/docker-compose-e2e-sqlite.yml b/tests/e2e_tests/docker-compose-e2e-sqlite.yml index 928b52e822..8c26cb5d0d 100644 --- a/tests/e2e_tests/docker-compose-e2e-sqlite.yml +++ b/tests/e2e_tests/docker-compose-e2e-sqlite.yml @@ -12,6 +12,7 @@ services: - API_URL=http://keep-backend:8080 - POSTHOG_DISABLED=true - SENTRY_DISABLED=true + - ALLOW_DB_DOWNGRADE=true # Backend Services keep-backend: @@ -21,6 +22,7 @@ services: - AUTH_TYPE=NO_AUTH - POSTHOG_DISABLED=true - SECRET_MANAGER_DIRECTORY=/app + - MIGRATIONS_PATH=/tmp/migrations - SQLALCHEMY_WARN_20=1 - REDIS=${REDIS:-false} - REDIS_HOST=${REDIS_HOST:-localhost} @@ -105,3 +107,4 @@ services: volumes: grafana-storage: {} + diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000000..7882e9d520 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,98 @@ +from pathlib import Path + +import pytest + +from keep.api.core.db_on_start import migrate_db, get_current_revision + +revision1 = """ +from alembic import op +import sqlalchemy as sa + +# Revision identifiers +revision = '202305010001' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table( + 'users', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('email', sa.String(length=255), nullable=False, unique=True), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False) + ) + +def downgrade(): + op.drop_table('users') +""" + +revision2 = """ +from alembic import op +import sqlalchemy as sa + +# Revision identifiers +revision = '202305010002' +down_revision = '202305010001' +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column('users', sa.Column('is_active', sa.Boolean, nullable=False, server_default='true')) + +def downgrade(): + op.drop_column('users', 'is_active') +""" + +import os +import shutil +import tempfile + +from alembic.config import Config + +def test_db_migrations(): + # Create a temporary directory to act as the Alembic environment + with tempfile.TemporaryDirectory() as temp_dir: + base_dir = Path(__file__).resolve().parent.parent + os.environ["SECRET_MANAGER_DIRECTORY"] = os.path.join(temp_dir, "state") + + shutil.copytree( + f"{base_dir}/keep/api/models/db/migrations", + os.path.join(temp_dir, "migrations"), + ignore=shutil.ignore_patterns("versions") + ) + + shutil.copy(f"{base_dir}/keep/alembic.ini", os.path.join(temp_dir, "migrations", "alembic.ini")) + alembic_ini_path = os.path.join(temp_dir, "migrations", "alembic.ini") + migrations_path = os.path.join(temp_dir, "migrations") + + alembic_cfg = Config(alembic_ini_path) + alembic_cfg.set_main_option("script_location", migrations_path) + + current_revision = get_current_revision() + + assert current_revision is None + os.makedirs(os.path.join(temp_dir, "migrations", "versions"), exist_ok=True) + # Test startup revision + with open(os.path.join(temp_dir, "migrations", "versions", "revision1.py"), "w") as f: + f.write(revision1) + + migrate_db(alembic_ini_path, migrations_path) + assert get_current_revision() == "202305010001" + + # Test upgrade revision + with open(os.path.join(temp_dir, "migrations", "versions", "revision2.py"), "w") as f: + f.write(revision2) + + migrate_db(alembic_ini_path, migrations_path) + assert get_current_revision() == "202305010002" + + # Test downgrade revision + os.remove(os.path.join(temp_dir, "migrations", "versions", "revision2.py")) + # Test downgrade not allowed when ALLOW_DB_DOWNGRADE is not set + with pytest.raises(RuntimeError): + migrate_db(alembic_ini_path, migrations_path) + + os.environ["ALLOW_DB_DOWNGRADE"] = "true" + migrate_db(alembic_ini_path, migrations_path) + + assert get_current_revision() == "202305010001" \ No newline at end of file