99doc/contributing/10_release_process.md) or by a CD pipeline — never by normal
1010application startup.
1111
12+ It constructs an AzureSQLMemory with skip_schema_migration=True (bypassing the
13+ runtime guard), then explicitly calls _run_schema_migration to upgrade to head.
14+ The environment checks ensure this only runs from a release branch.
15+
1216Safety rails:
13- - Requires an explicit --target-revision argument (no blind "upgrade head").
1417- Validates the environment (release branch, clean working tree, no .dev version).
15- - Validates the target revision exists in the local migration graph.
16- - Reports current DB revision before and after migration.
17- - Runs schema check after upgrade to confirm models match.
1818- Interactive confirmation when running in a terminal.
1919- Exits non-zero on any failure.
2020
2121Usage:
22- python build_scripts/migrate_prod_memory_schema.py \
23- --target-revision c3d5e7f9a1b2
22+ python build_scripts/migrate_prod_memory_schema.py
2423
25- The script reads the production connection string from the
26- AZURE_SQL_DB_CONNECTION_STRING environment variable (same as AzureSQLMemory ).
24+ The script reads the production connection string from
25+ AZURE_SQL_DB_CONNECTION_STRING_PROD (loaded from ~/.pyrit/.env ).
2726"""
2827
29- import argparse
30- import os
3128import subprocess
3229import sys
3330
3431import dotenv
35- from alembic import command
36- from alembic .script import ScriptDirectory
37- from alembic .util .exc import AutogenerateDiffsDetected
38- from sqlalchemy import create_engine , text
3932
4033from pyrit .common .path import CONFIGURATION_DIRECTORY_PATH
4134
5144_YELLOW = "\033 [93m"
5245_RESET = "\033 [0m"
5346
54- _CONNECTION_STRING_ENV = "AZURE_SQL_DB_CONNECTION_STRING_PROD"
55-
5647
5748def _print_error (message : str ) -> None :
5849 """Print an error message in red to stderr."""
5950 print (f"{ _RED } ERROR: { message } { _RESET } " , file = sys .stderr )
6051
6152
62- def _print_warning (message : str ) -> None :
63- """Print a warning message in yellow."""
64- print (f"{ _YELLOW } WARNING: { message } { _RESET } " )
65-
66-
6753def _print_success (message : str ) -> None :
6854 """Print a success message in green."""
6955 print (f"{ _GREEN } { message } { _RESET } " )
7056
7157
72- def _get_current_revision (* , engine ) -> str | None :
73- """
74- Read the current Alembic revision from the database.
75-
76- Returns None if no version table exists (fresh database).
77- """
78- from sqlalchemy import inspect as sa_inspect
79-
80- from pyrit .memory .migration import PYRIT_MEMORY_ALEMBIC_VERSION_TABLE
81-
82- inspector = sa_inspect (engine )
83- if PYRIT_MEMORY_ALEMBIC_VERSION_TABLE not in inspector .get_table_names ():
84- return None
85-
86- with engine .connect () as conn :
87- result = conn .execute (text (f"SELECT version_num FROM { PYRIT_MEMORY_ALEMBIC_VERSION_TABLE } " ))
88- row = result .fetchone ()
89- return row [0 ] if row else None
90-
91-
92- def _validate_revision_exists (* , target_revision : str ) -> bool :
93- """Check that the target revision exists in the local migration script directory."""
94-
95- from pathlib import Path
96-
97- script_location = Path (__file__ ).parent .parent / "pyrit" / "memory" / "alembic"
98- # Build a minimal config to get the ScriptDirectory
99- from alembic .config import Config
100-
101- config = Config ()
102- config .set_main_option ("script_location" , str (script_location ))
103- script_dir = ScriptDirectory .from_config (config )
104-
105- try :
106- script_dir .get_revision (target_revision )
107- return True
108- except Exception :
109- return False
110-
111-
112- def _get_all_revision_ids () -> list [str ]:
113- """Get all revision IDs from the local migration graph."""
114- from pathlib import Path
115-
116- from alembic .config import Config
117-
118- script_location = Path (__file__ ).parent .parent / "pyrit" / "memory" / "alembic"
119- config = Config ()
120- config .set_main_option ("script_location" , str (script_location ))
121- script_dir = ScriptDirectory .from_config (config )
122- return [rev .revision for rev in script_dir .walk_revisions ()]
123-
124-
12558def _check_release_environment () -> list [str ]:
12659 """
12760 Validate that the script is running in a proper release environment.
@@ -130,7 +63,6 @@ def _check_release_environment() -> list[str]:
13063 """
13164 issues : list [str ] = []
13265
133- # Check 1: Running from a release branch (releases/vX.Y.Z)
13466 try :
13567 branch = subprocess .check_output (
13668 ["git" , "rev-parse" , "--abbrev-ref" , "HEAD" ],
@@ -145,7 +77,6 @@ def _check_release_environment() -> list[str]:
14577 except (subprocess .CalledProcessError , FileNotFoundError ):
14678 issues .append ("Could not determine current Git branch." )
14779
148- # Check 2: Clean working tree (no uncommitted changes to memory files)
14980 try :
15081 dirty_files = subprocess .check_output (
15182 ["git" , "status" , "--porcelain" , "--" , "pyrit/memory/" ],
@@ -161,7 +92,6 @@ def _check_release_environment() -> list[str]:
16192 except (subprocess .CalledProcessError , FileNotFoundError ):
16293 issues .append ("Could not check Git working tree status." )
16394
164- # Check 3: Not a .dev version (should be a release version)
16595 try :
16696 from pyrit import __version__
16797
@@ -176,120 +106,21 @@ def _check_release_environment() -> list[str]:
176106 return issues
177107
178108
179- def _run_migration (* , connection_string : str , target_revision : str ) -> int :
180- """
181- Execute the migration against the target database.
182-
183- Returns 0 on success, 1 on failure.
184- """
185- from pathlib import Path
186-
187- from alembic .config import Config
188-
189- from pyrit .memory .migration import check_schema_migrations
190-
191- engine = create_engine (connection_string )
192-
193- # Step 1: Report current state
194- current_rev = _get_current_revision (engine = engine )
195- print (f"Current database revision: { current_rev or '(none — fresh database)' } " )
196- print (f"Target revision: { target_revision } " )
197- print ()
198-
199- if current_rev == target_revision :
200- _print_success ("Database is already at the target revision. Nothing to do." )
201- engine .dispose ()
202- return 0
203-
204- # Step 2: Run upgrade to specific revision (not head)
205- print ("Applying migrations..." )
206- try :
207- script_location = Path (__file__ ).parent .parent / "pyrit" / "memory" / "alembic"
208- with engine .begin () as connection :
209- config = Config ()
210- config .set_main_option ("script_location" , str (script_location ))
211- config .attributes ["connection" ] = connection
212-
213- # Stamp unversioned schemas if needed
214- from pyrit .memory .migration import _validate_and_stamp_unversioned_memory_schema
215-
216- _validate_and_stamp_unversioned_memory_schema (config = config , connection = connection )
217- command .upgrade (config , target_revision )
218- except Exception as e :
219- _print_error (f"Migration failed: { e } " )
220- engine .dispose ()
221- return 1
222-
223- # Step 3: Verify new revision
224- new_rev = _get_current_revision (engine = engine )
225- print (f"Database revision after migration: { new_rev } " )
226-
227- if new_rev != target_revision :
228- _print_error (f"Expected revision { target_revision } , but database is at { new_rev } ." )
229- engine .dispose ()
230- return 1
231-
232- # Step 4: Schema check — verify models match
233- print ("Verifying schema matches models..." )
234- try :
235- check_schema_migrations (engine = engine )
236- except AutogenerateDiffsDetected as e :
237- _print_error (f"Schema check failed after migration: { e } " )
238- _print_error ("The models in this codebase do not match the database schema." )
239- engine .dispose ()
240- return 1
241-
242- _print_success ("Migration completed and verified successfully." )
243- engine .dispose ()
244- return 0
245-
109+ def main () -> int :
110+ """Entry point for production schema migration."""
111+ import argparse
246112
247- def _build_parser () -> argparse .ArgumentParser :
248- """Build the CLI argument parser."""
249113 parser = argparse .ArgumentParser (
250- description = (
251- "Apply Alembic schema migrations to a production database. "
252- "Validates release environment and requires an explicit target revision."
253- ),
254- )
255- parser .add_argument (
256- "--target-revision" ,
257- required = True ,
258- help = "The exact Alembic revision ID to upgrade to (e.g., 'c3d5e7f9a1b2')." ,
259- )
260- parser .add_argument (
261- "--connection-string-env" ,
262- default = _CONNECTION_STRING_ENV ,
263- help = f"Environment variable containing the connection string. Default: { _CONNECTION_STRING_ENV } " ,
114+ description = "Apply Alembic schema migrations to the production database." ,
264115 )
265116 parser .add_argument (
266117 "--skip-environment-check" ,
267118 action = "store_true" ,
268119 help = "Skip release environment checks (branch, clean tree, version). Use only in CI with caution." ,
269120 )
270- return parser
271-
121+ args = parser .parse_args ()
272122
273- def main () -> int :
274- """Entry point for production schema migration."""
275- args = _build_parser ().parse_args ()
276-
277- # Safety rail 1: Require connection string
278- connection_string = os .environ .get (args .connection_string_env )
279- if not connection_string :
280- _print_error (f"Environment variable '{ args .connection_string_env } ' is not set." )
281- return 1
282-
283- # Safety rail 2: Validate target revision exists in local migration graph
284- if not _validate_revision_exists (target_revision = args .target_revision ):
285- available = _get_all_revision_ids ()
286- _print_error (
287- f"Target revision '{ args .target_revision } ' not found in the local migration graph.\n "
288- f"Available revisions: { ', ' .join (available )} "
289- )
290- return 1
291-
292- # Safety rail 3: Verify release environment (branch, clean tree, version)
123+ # Safety rail: Verify release environment
293124 if not args .skip_environment_check :
294125 issues = _check_release_environment ()
295126 if issues :
@@ -299,18 +130,39 @@ def main() -> int:
299130 _print_error ("Fix the above issues or pass --skip-environment-check (CI only)." )
300131 return 1
301132 else :
302- _print_warning ( " Skipping release environment checks (--skip-environment-check). " )
133+ print ( f" { _YELLOW } WARNING: Skipping release environment checks. { _RESET } " )
303134
304- # Confirmation prompt (unless running in CI where stdin is not interactive)
135+ # Interactive confirmation
305136 if sys .stdin .isatty ():
306- print (f"About to migrate production database to revision: { args .target_revision } " )
307- print (f"Connection string source: ${ args .connection_string_env } " )
137+ print ("About to migrate production database schema to head." )
308138 response = input ("Type 'yes' to proceed: " )
309139 if response .strip ().lower () != "yes" :
310140 print ("Aborted." )
311141 return 1
312142
313- return _run_migration (connection_string = connection_string , target_revision = args .target_revision )
143+ # Construct AzureSQLMemory with skip_schema_migration=True to bypass the runtime guard,
144+ # then explicitly run migration.
145+ import os
146+
147+ from pyrit .memory import AzureSQLMemory
148+
149+ prod_conn = os .environ .get (AzureSQLMemory .AZURE_SQL_DB_CONNECTION_STRING_PROD )
150+ if not prod_conn :
151+ _print_error (f"Environment variable '{ AzureSQLMemory .AZURE_SQL_DB_CONNECTION_STRING_PROD } ' is not set." )
152+ return 1
153+
154+ try :
155+ memory = AzureSQLMemory (
156+ connection_string = prod_conn ,
157+ skip_schema_migration = True ,
158+ )
159+ print ("Running schema migration..." )
160+ memory ._run_schema_migration ()
161+ _print_success ("Production schema migration completed and verified successfully." )
162+ return 0
163+ except Exception as e :
164+ _print_error (f"Migration failed: { e } " )
165+ return 1
314166
315167
316168if __name__ == "__main__" :
0 commit comments