Skip to content

Commit 2c8f146

Browse files
authored
fix: remove legacy org-scoped builtin registry repo (#2346)
1 parent 00eb078 commit 2c8f146

File tree

4 files changed

+377
-5
lines changed

4 files changed

+377
-5
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""remove legacy org scoped builtin registry repository
2+
3+
Revision ID: 0a1e3100a432
4+
Revises: cd84c08340a5
5+
Create Date: 2026-03-11 18:26:36.376624
6+
7+
"""
8+
9+
from collections.abc import Sequence
10+
11+
import sqlalchemy as sa
12+
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "0a1e3100a432"
17+
down_revision: str | None = "cd84c08340a5"
18+
branch_labels: str | Sequence[str] | None = None
19+
depends_on: str | Sequence[str] | None = None
20+
21+
22+
def upgrade() -> None:
23+
"""Delete legacy org-scoped builtin registry repositories."""
24+
op.execute(
25+
sa.text(
26+
"UPDATE registry_repository "
27+
"SET current_version_id = NULL "
28+
"WHERE origin = 'tracecat_registry'"
29+
)
30+
)
31+
op.execute(
32+
sa.text("DELETE FROM registry_repository WHERE origin = 'tracecat_registry'")
33+
)
34+
35+
36+
def downgrade() -> None:
37+
"""Legacy data cleanup is not reversible."""
38+
pass
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"""Tests for removing legacy org-scoped builtin registry repositories."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import subprocess
7+
import uuid
8+
from collections.abc import Iterator
9+
10+
import pytest
11+
from sqlalchemy import create_engine, text
12+
from sqlalchemy.pool import NullPool
13+
14+
from tests.database import TEST_DB_CONFIG
15+
16+
MIGRATION_REVISION = "0a1e3100a432"
17+
PREVIOUS_REVISION = "6171727be56a"
18+
19+
20+
def _run_alembic(db_url: str, *args: str) -> None:
21+
env = os.environ.copy()
22+
env["TRACECAT__DB_URI"] = db_url
23+
result = subprocess.run(
24+
["uv", "run", "alembic", *args],
25+
env=env,
26+
capture_output=True,
27+
text=True,
28+
check=False,
29+
)
30+
if result.returncode != 0:
31+
raise RuntimeError(
32+
"Alembic command failed:\n"
33+
f"stdout:\n{result.stdout}\n"
34+
f"stderr:\n{result.stderr}"
35+
)
36+
37+
38+
@pytest.fixture(scope="function")
39+
def migration_db_url() -> Iterator[str]:
40+
default_engine = create_engine(
41+
TEST_DB_CONFIG.sys_url_sync,
42+
isolation_level="AUTOCOMMIT",
43+
poolclass=NullPool,
44+
)
45+
db_name = f"test_registry_cleanup_{uuid.uuid4().hex[:8]}"
46+
termination_query = text(
47+
f"""
48+
SELECT pg_terminate_backend(pg_stat_activity.pid)
49+
FROM pg_stat_activity
50+
WHERE pg_stat_activity.datname = '{db_name}'
51+
AND pid <> pg_backend_pid();
52+
"""
53+
)
54+
55+
try:
56+
with default_engine.connect() as conn:
57+
conn.execute(termination_query)
58+
conn.execute(text(f'CREATE DATABASE "{db_name}"'))
59+
60+
db_url = TEST_DB_CONFIG.test_url_sync.replace(
61+
TEST_DB_CONFIG.test_db_name, db_name
62+
)
63+
_run_alembic(db_url, "upgrade", PREVIOUS_REVISION)
64+
yield db_url
65+
finally:
66+
with default_engine.connect() as conn:
67+
conn.execute(termination_query)
68+
conn.execute(text(f'DROP DATABASE IF EXISTS "{db_name}"'))
69+
default_engine.dispose()
70+
71+
72+
def _seed_legacy_registry_rows(db_url: str) -> tuple[uuid.UUID, uuid.UUID]:
73+
engine = create_engine(db_url, poolclass=NullPool)
74+
organization_id = uuid.uuid4()
75+
legacy_repo_id = uuid.uuid4()
76+
legacy_version_id = uuid.uuid4()
77+
platform_repo_id = uuid.uuid4()
78+
platform_version_id = uuid.uuid4()
79+
80+
try:
81+
with engine.begin() as conn:
82+
conn.execute(
83+
text(
84+
"""
85+
INSERT INTO organization (id, name, slug, is_active)
86+
VALUES (:id, 'Test org', :slug, true)
87+
"""
88+
),
89+
{"id": organization_id, "slug": f"test-org-{organization_id.hex[:8]}"},
90+
)
91+
conn.execute(
92+
text(
93+
"""
94+
INSERT INTO registry_repository (id, organization_id, origin, current_version_id)
95+
VALUES (:id, :organization_id, 'tracecat_registry', NULL)
96+
"""
97+
),
98+
{
99+
"id": legacy_repo_id,
100+
"organization_id": organization_id,
101+
},
102+
)
103+
conn.execute(
104+
text(
105+
"""
106+
INSERT INTO registry_version (
107+
id,
108+
organization_id,
109+
repository_id,
110+
version,
111+
manifest,
112+
tarball_uri
113+
)
114+
VALUES (
115+
:id,
116+
:organization_id,
117+
:repository_id,
118+
'1.0.0',
119+
CAST('{}' AS jsonb),
120+
's3://test-bucket/legacy.tar.gz'
121+
)
122+
"""
123+
),
124+
{
125+
"id": legacy_version_id,
126+
"organization_id": organization_id,
127+
"repository_id": legacy_repo_id,
128+
},
129+
)
130+
conn.execute(
131+
text(
132+
"""
133+
UPDATE registry_repository
134+
SET current_version_id = :version_id
135+
WHERE id = :repository_id
136+
"""
137+
),
138+
{
139+
"version_id": legacy_version_id,
140+
"repository_id": legacy_repo_id,
141+
},
142+
)
143+
conn.execute(
144+
text(
145+
"""
146+
INSERT INTO platform_registry_repository (id, origin, current_version_id)
147+
VALUES (:id, 'tracecat_registry', NULL)
148+
"""
149+
),
150+
{"id": platform_repo_id},
151+
)
152+
conn.execute(
153+
text(
154+
"""
155+
INSERT INTO platform_registry_version (
156+
id,
157+
repository_id,
158+
version,
159+
manifest,
160+
tarball_uri
161+
)
162+
VALUES (
163+
:id,
164+
:repository_id,
165+
'1.0.0',
166+
CAST('{}' AS jsonb),
167+
's3://test-bucket/platform.tar.gz'
168+
)
169+
"""
170+
),
171+
{
172+
"id": platform_version_id,
173+
"repository_id": platform_repo_id,
174+
},
175+
)
176+
conn.execute(
177+
text(
178+
"""
179+
UPDATE platform_registry_repository
180+
SET current_version_id = :version_id
181+
WHERE id = :repository_id
182+
"""
183+
),
184+
{
185+
"version_id": platform_version_id,
186+
"repository_id": platform_repo_id,
187+
},
188+
)
189+
finally:
190+
engine.dispose()
191+
192+
return legacy_repo_id, platform_repo_id
193+
194+
195+
def test_upgrade_removes_legacy_org_scoped_builtin_registry_repository(
196+
migration_db_url: str,
197+
) -> None:
198+
db_url = migration_db_url
199+
legacy_repo_id, platform_repo_id = _seed_legacy_registry_rows(db_url)
200+
_run_alembic(db_url, "upgrade", MIGRATION_REVISION)
201+
202+
engine = create_engine(db_url, poolclass=NullPool)
203+
try:
204+
with engine.begin() as conn:
205+
legacy_repo_count = conn.execute(
206+
text(
207+
"""
208+
SELECT COUNT(*)
209+
FROM registry_repository
210+
WHERE id = :repository_id
211+
"""
212+
),
213+
{"repository_id": legacy_repo_id},
214+
).scalar_one()
215+
assert legacy_repo_count == 0
216+
217+
legacy_version_count = conn.execute(
218+
text(
219+
"""
220+
SELECT COUNT(*)
221+
FROM registry_version
222+
WHERE repository_id = :repository_id
223+
"""
224+
),
225+
{"repository_id": legacy_repo_id},
226+
).scalar_one()
227+
assert legacy_version_count == 0
228+
229+
platform_repo_count = conn.execute(
230+
text(
231+
"""
232+
SELECT COUNT(*)
233+
FROM platform_registry_repository
234+
WHERE id = :repository_id
235+
"""
236+
),
237+
{"repository_id": platform_repo_id},
238+
).scalar_one()
239+
assert platform_repo_count == 1
240+
finally:
241+
engine.dispose()
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
from inspect import unwrap
4+
5+
import pytest
6+
from fastapi import HTTPException
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from tracecat.auth.types import Role
10+
from tracecat.db.models import Organization, RegistryRepository
11+
from tracecat.registry.constants import (
12+
DEFAULT_LOCAL_REGISTRY_ORIGIN,
13+
DEFAULT_REGISTRY_ORIGIN,
14+
)
15+
from tracecat.registry.repositories import router as registry_repos_router
16+
from tracecat.registry.repositories.schemas import (
17+
RegistryRepositoryCreate,
18+
RegistryRepositoryUpdate,
19+
)
20+
21+
pytestmark = pytest.mark.usefixtures("db")
22+
23+
24+
@pytest.mark.anyio
25+
async def test_create_registry_repository_rejects_platform_builtin_origin(
26+
session: AsyncSession,
27+
test_role: Role,
28+
) -> None:
29+
"""Org-scoped registry API must not recreate the platform builtin origin."""
30+
create_repository = unwrap(registry_repos_router.create_registry_repository)
31+
32+
with pytest.raises(HTTPException) as exc_info:
33+
await create_repository(
34+
role=test_role,
35+
session=session,
36+
params=RegistryRepositoryCreate(origin=DEFAULT_REGISTRY_ORIGIN),
37+
)
38+
39+
assert exc_info.value.status_code == 400
40+
assert "platform-scoped" in str(exc_info.value.detail)
41+
42+
43+
@pytest.mark.anyio
44+
async def test_update_registry_repository_rejects_platform_builtin_origin(
45+
session: AsyncSession,
46+
test_role: Role,
47+
) -> None:
48+
"""Org-scoped registry API must not mutate a repo into the builtin origin."""
49+
update_repository = unwrap(registry_repos_router.update_registry_repository)
50+
51+
session.add(
52+
Organization(
53+
id=test_role.organization_id,
54+
name="Registry router test org",
55+
slug="registry-router-test-org",
56+
is_active=True,
57+
)
58+
)
59+
await session.flush()
60+
61+
repository = RegistryRepository(
62+
organization_id=test_role.organization_id,
63+
origin=DEFAULT_LOCAL_REGISTRY_ORIGIN,
64+
)
65+
session.add(repository)
66+
await session.commit()
67+
68+
with pytest.raises(HTTPException) as exc_info:
69+
await update_repository(
70+
role=test_role,
71+
session=session,
72+
repository_id=repository.id,
73+
params=RegistryRepositoryUpdate(origin=DEFAULT_REGISTRY_ORIGIN),
74+
)
75+
76+
assert exc_info.value.status_code == 400
77+
assert "platform-scoped" in str(exc_info.value.detail)

0 commit comments

Comments
 (0)