Skip to content

Commit 9feb053

Browse files
committed
fix: Migrate secret_value column to be TEXT
1 parent 68b140a commit 9feb053

4 files changed

Lines changed: 176 additions & 1 deletion

File tree

cloud_pipelines_backend/backend_types_sql.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ class Secret(_TableBase):
523523
__tablename__ = "secret"
524524
user_id: orm.Mapped[str] = orm.mapped_column(primary_key=True, index=True)
525525
secret_name: orm.Mapped[str] = orm.mapped_column(primary_key=True)
526-
secret_value: orm.Mapped[str]
526+
secret_value: orm.Mapped[str] = orm.mapped_column(sql.Text())
527527
created_at: orm.Mapped[datetime.datetime]
528528
updated_at: orm.Mapped[datetime.datetime]
529529
expires_at: orm.Mapped[datetime.datetime | None] = orm.mapped_column(default=None)

cloud_pipelines_backend/database_migrations.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,3 +722,69 @@ def run_all_annotation_backfills(
722722

723723
_logger.info("Exit backfill for annotations table")
724724
return result
725+
726+
727+
def migrate_secret_value_column(
728+
*,
729+
db_engine: sqlalchemy.Engine,
730+
) -> None:
731+
"""Widen secret.secret_value to TEXT.
732+
733+
Idempotent: inspects the actual DB column and skips if already TEXT.
734+
Dialect-aware: MySQL, PostgreSQL, SQLite.
735+
"""
736+
table = bts.Secret.__table__
737+
column = table.c.secret_value
738+
type_sql = column.type.compile(dialect=db_engine.dialect)
739+
740+
inspector = sqlalchemy.inspect(db_engine)
741+
db_columns = {c["name"]: c for c in inspector.get_columns(table.name)}
742+
secret_col_type = db_columns[column.name]["type"]
743+
dialect = db_engine.dialect.name
744+
745+
if isinstance(secret_col_type, sqlalchemy.types.Text):
746+
_logger.info(f"migrate column to TEXT: skipped (already TEXT)")
747+
return
748+
749+
current_length = (
750+
secret_col_type.length if isinstance(secret_col_type.length, int) else 0
751+
)
752+
753+
_logger.info(
754+
f"migrate column to TEXT: {table.name}.{column.name}"
755+
f" — current_type={secret_col_type}, current_length={current_length},"
756+
f" target_type={type_sql}, dialect={dialect}"
757+
)
758+
759+
try:
760+
if dialect == "mysql":
761+
alter_sql = (
762+
f"ALTER TABLE {table.name} MODIFY COLUMN {column.name} {type_sql}"
763+
)
764+
elif dialect == "postgresql":
765+
alter_sql = (
766+
f"ALTER TABLE {table.name} ALTER COLUMN {column.name} TYPE {type_sql}"
767+
)
768+
elif dialect == "sqlite":
769+
_logger.info(
770+
f"migrate column to TEXT: SQLite does not enforce VARCHAR length"
771+
f" and does not support ALTER COLUMN — no migration needed"
772+
)
773+
return
774+
else:
775+
_logger.warning(
776+
f"migrate column to TEXT: unsupported dialect {dialect} — skipping"
777+
)
778+
return
779+
780+
_logger.info(f"migrate column to TEXT: executing SQL: {alter_sql}")
781+
with db_engine.connect() as conn:
782+
conn.execute(sqlalchemy.text(alter_sql))
783+
conn.commit()
784+
_logger.info(f"migrate column to TEXT: complete")
785+
except Exception:
786+
_logger.exception(
787+
f"migrate column to TEXT failed: table={table.name},"
788+
f" column={column.name}, current_type={secret_col_type},"
789+
f" target_type={type_sql}, dialect={dialect}"
790+
)

cloud_pipelines_backend/database_ops.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ def migrate_db(
104104
index.create(db_engine, checkfirst=True)
105105
break
106106

107+
database_migrations.migrate_secret_value_column(db_engine=db_engine)
108+
107109
if do_skip_backfill:
108110
_logger.info("Skipping annotation backfills")
109111
else:

tests/test_database_migrations.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1929,3 +1929,110 @@ def test_migrate_db_skips_backfills_when_flag_set(self) -> None:
19291929
_count_annotations(session_factory=session_factory, key=pipeline_name_key)
19301930
== 0
19311931
)
1932+
1933+
1934+
# ---------------------------------------------------------------------------
1935+
# secret_value column migration
1936+
# ---------------------------------------------------------------------------
1937+
1938+
1939+
def test_migrate_secret_value_column_idempotent(
1940+
caplog: pytest.LogCaptureFixture,
1941+
) -> None:
1942+
"""First call ALTERs VARCHAR(255) to TEXT; second call skips (already TEXT)."""
1943+
db_engine = database_ops.create_db_engine(database_uri="sqlite://")
1944+
bts._TableBase.metadata.create_all(db_engine)
1945+
1946+
varchar_255 = sqlalchemy.types.String(length=255)
1947+
text_type = sqlalchemy.types.Text()
1948+
1949+
call_count = 0
1950+
1951+
def _fake_get_columns(
1952+
*,
1953+
table_name: str,
1954+
) -> list[dict[str, Any]]:
1955+
nonlocal call_count
1956+
call_count += 1
1957+
fake_type = varchar_255 if call_count == 1 else text_type
1958+
return [{"name": "secret_value", "type": fake_type}]
1959+
1960+
mock_conn = mock.MagicMock()
1961+
mock_connect = mock.MagicMock()
1962+
mock_connect.__enter__ = mock.MagicMock(return_value=mock_conn)
1963+
mock_connect.__exit__ = mock.MagicMock(return_value=False)
1964+
1965+
real_dialect = db_engine.dialect
1966+
fake_dialect = mock.MagicMock(wraps=real_dialect)
1967+
fake_dialect.name = "mysql"
1968+
fake_dialect.type_compiler_instance = real_dialect.type_compiler_instance
1969+
1970+
with (
1971+
mock.patch.object(db_engine, "dialect", fake_dialect),
1972+
mock.patch("sqlalchemy.inspect") as mock_inspect,
1973+
mock.patch.object(db_engine, "connect", return_value=mock_connect),
1974+
caplog.at_level(logging.INFO),
1975+
):
1976+
mock_inspect.return_value.get_columns.side_effect = (
1977+
lambda table_name: _fake_get_columns(table_name=table_name)
1978+
)
1979+
1980+
database_migrations.migrate_secret_value_column(db_engine=db_engine)
1981+
database_migrations.migrate_secret_value_column(db_engine=db_engine)
1982+
1983+
msgs = caplog.messages
1984+
assert len(msgs) == 4
1985+
assert (
1986+
"current_type=VARCHAR(255), current_length=255, target_type=TEXT, dialect=mysql"
1987+
in msgs[0]
1988+
)
1989+
assert (
1990+
"executing SQL: ALTER TABLE secret MODIFY COLUMN secret_value TEXT" in msgs[1]
1991+
)
1992+
assert "complete" in msgs[2]
1993+
assert "skipped (already TEXT)" in msgs[3]
1994+
mock_conn.execute.assert_called_once()
1995+
mock_conn.commit.assert_called_once()
1996+
1997+
1998+
def test_migrate_secret_value_column_mysql_alter_sql(
1999+
caplog: pytest.LogCaptureFixture,
2000+
) -> None:
2001+
"""Mock MySQL dialect and verify the exact ALTER SQL generated."""
2002+
db_engine = database_ops.create_db_engine(database_uri="sqlite://")
2003+
bts._TableBase.metadata.create_all(db_engine)
2004+
2005+
real_dialect = db_engine.dialect
2006+
2007+
def _fake_get_columns(
2008+
*,
2009+
table_name: str,
2010+
) -> list[dict[str, Any]]:
2011+
return [{"name": "secret_value", "type": sqlalchemy.types.String(length=255)}]
2012+
2013+
mock_conn = mock.MagicMock()
2014+
mock_connect = mock.MagicMock()
2015+
mock_connect.__enter__ = mock.MagicMock(return_value=mock_conn)
2016+
mock_connect.__exit__ = mock.MagicMock(return_value=False)
2017+
2018+
fake_dialect = mock.MagicMock(wraps=real_dialect)
2019+
fake_dialect.name = "mysql"
2020+
fake_dialect.type_compiler_instance = real_dialect.type_compiler_instance
2021+
2022+
with (
2023+
mock.patch.object(db_engine, "dialect", fake_dialect),
2024+
mock.patch("sqlalchemy.inspect") as mock_inspect,
2025+
mock.patch.object(db_engine, "connect", return_value=mock_connect),
2026+
caplog.at_level(logging.INFO),
2027+
):
2028+
mock_inspect.return_value.get_columns.side_effect = (
2029+
lambda table_name: _fake_get_columns(table_name=table_name)
2030+
)
2031+
2032+
database_migrations.migrate_secret_value_column(db_engine=db_engine)
2033+
2034+
executed_sql = mock_conn.execute.call_args[0][0].text
2035+
assert executed_sql == "ALTER TABLE secret MODIFY COLUMN secret_value TEXT"
2036+
assert any("current_length=255" in msg for msg in caplog.messages)
2037+
assert any("dialect=mysql" in msg for msg in caplog.messages)
2038+
mock_conn.commit.assert_called_once()

0 commit comments

Comments
 (0)