Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Add registry version artifact hash

Revision ID: b4f8c1d2e3a4
Revises: a3d7c9e8b4f2
Create Date: 2026-05-23 00:00:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "b4f8c1d2e3a4"
down_revision: str | None = "a3d7c9e8b4f2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column(
"registry_version",
sa.Column("artifact_hash", sa.String(), nullable=True),
)
op.add_column(
"platform_registry_version",
sa.Column("artifact_hash", sa.String(), nullable=True),
)


def downgrade() -> None:
op.drop_column("platform_registry_version", "artifact_hash")
op.drop_column("registry_version", "artifact_hash")
28 changes: 25 additions & 3 deletions frontend/src/client/schemas.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17961,9 +17961,9 @@ Attributes:
Example: {"tracecat_registry": "2024.12.10.123456"}
actions: Maps action name to its source origin.
Example: {"core.transform.reshape": "tracecat_registry"}
origin_fingerprints: Optional immutable manifest fingerprints for origins.
New executors use the builtin fingerprint to decide whether their
bundled tracecat_registry package is an exact match for the lock.`,
origin_fingerprints: Optional immutable origin fingerprints. New locks
prefer execution artifact SHA-256 hashes and fall back to manifest
fingerprints when older versions do not have artifact hashes.`,
} as const

export const $RegistryOAuthSecret = {
Expand Down Expand Up @@ -29968,6 +29968,17 @@ export const $tracecat__admin__registry__schemas__RegistryVersionRead = {
],
title: "Tarball Uri",
},
artifact_hash: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Artifact Hash",
},
created_at: {
type: "string",
format: "date-time",
Expand Down Expand Up @@ -30240,6 +30251,17 @@ export const $tracecat__registry__repositories__schemas__RegistryVersionRead = {
],
title: "Tarball Uri",
},
artifact_hash: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Artifact Hash",
},
created_at: {
type: "string",
format: "date-time",
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5526,9 +5526,9 @@ export type RegistryArtifactsBackfillStartResponse = {
* Example: {"tracecat_registry": "2024.12.10.123456"}
* actions: Maps action name to its source origin.
* Example: {"core.transform.reshape": "tracecat_registry"}
* origin_fingerprints: Optional immutable manifest fingerprints for origins.
* New executors use the builtin fingerprint to decide whether their
* bundled tracecat_registry package is an exact match for the lock.
* origin_fingerprints: Optional immutable origin fingerprints. New locks
* prefer execution artifact SHA-256 hashes and fall back to manifest
* fingerprints when older versions do not have artifact hashes.
*/
export type RegistryLock = {
origins: {
Expand Down Expand Up @@ -8905,6 +8905,7 @@ export type tracecat__admin__registry__schemas__RegistryVersionRead = {
version: string
commit_sha: string | null
tarball_uri: string | null
artifact_hash?: string | null
created_at: string
is_current?: boolean
artifacts_ready?: boolean
Expand Down Expand Up @@ -8964,6 +8965,7 @@ export type tracecat__registry__repositories__schemas__RegistryVersionRead = {
version: string
commit_sha: string | null
tarball_uri: string | null
artifact_hash?: string | null
created_at: string
}

Expand Down
219 changes: 219 additions & 0 deletions tests/unit/executor/test_registry_artifact_lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
from __future__ import annotations

import uuid
from collections.abc import Sequence
from types import TracebackType
from typing import Any

import pytest

from tracecat.exceptions import RegistryValidationError
from tracecat.executor.service import (
RegistryArtifactsContext,
get_registry_artifacts_for_lock,
)
from tracecat.registry.versions.schemas import (
RegistryVersionManifest,
registry_manifest_fingerprint,
)

type ArtifactRow = tuple[str, str, str | None, str | None, dict[str, Any]]


class _FakeResult:
def __init__(self, rows: Sequence[ArtifactRow]) -> None:
self._rows = rows

def all(self) -> Sequence[ArtifactRow]:
return self._rows


class _FakeSession:
def __init__(self, rows: Sequence[ArtifactRow]) -> None:
self._rows = rows

async def execute(self, _statement: object) -> _FakeResult:
return _FakeResult(self._rows)


class _FakeSessionManager:
def __init__(self, rows: Sequence[ArtifactRow]) -> None:
self._rows = rows

async def __aenter__(self) -> _FakeSession:
return _FakeSession(self._rows)

async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
pass


def _manifest_dict(manifest: RegistryVersionManifest) -> dict[str, Any]:
return manifest.model_dump(mode="json")


def _artifact_row(
*,
origin: str,
version: str,
artifact_hash: str | None,
manifest: RegistryVersionManifest,
) -> ArtifactRow:
return (
origin,
version,
f"s3://bucket/{uuid.uuid4().hex}/site-packages.squashfs",
artifact_hash,
_manifest_dict(manifest),
)


def _patch_artifact_lookup_session(
monkeypatch: pytest.MonkeyPatch,
rows: Sequence[ArtifactRow],
) -> None:
monkeypatch.setattr(
"tracecat.executor.service.get_async_session_bypass_rls_context_manager",
lambda: _FakeSessionManager(rows),
)


@pytest.mark.anyio
async def test_artifact_hash_lock_rejects_current_db_hash_rewrite(
monkeypatch: pytest.MonkeyPatch,
) -> None:
origin = "git+ssh://github.com/example/custom.git"
version = "v1"
locked_hash = "a" * 64
current_db_hash = "b" * 64
_patch_artifact_lookup_session(
monkeypatch,
[
_artifact_row(
origin=origin,
version=version,
artifact_hash=current_db_hash,
manifest=RegistryVersionManifest(),
)
],
)

with pytest.raises(
RegistryValidationError,
match="Locked registry artifact fingerprint mismatch",
):
await get_registry_artifacts_for_lock(
origins={origin: version},
organization_id=uuid.uuid4(),
origin_fingerprints={origin: locked_hash},
)


@pytest.mark.anyio
async def test_artifact_hash_lock_preserves_matching_locked_hash(
monkeypatch: pytest.MonkeyPatch,
) -> None:
origin = "git+ssh://github.com/example/custom.git"
version = "v1"
locked_hash = "a" * 64
_patch_artifact_lookup_session(
monkeypatch,
[
_artifact_row(
origin=origin,
version=version,
artifact_hash=locked_hash,
manifest=RegistryVersionManifest(),
)
],
)

artifacts = await get_registry_artifacts_for_lock(
origins={origin: version},
organization_id=uuid.uuid4(),
origin_fingerprints={origin: locked_hash},
)

assert artifacts == [
RegistryArtifactsContext(
origin=origin,
version=version,
artifact_uri=artifacts[0].artifact_uri,
artifact_hash=locked_hash,
)
]


@pytest.mark.anyio
async def test_manifest_fingerprint_lock_allows_current_artifact_hash(
monkeypatch: pytest.MonkeyPatch,
) -> None:
origin = "git+ssh://github.com/example/custom.git"
version = "v1"
artifact_hash = "b" * 64
manifest = RegistryVersionManifest()
manifest_fingerprint = registry_manifest_fingerprint(manifest)
_patch_artifact_lookup_session(
monkeypatch,
[
_artifact_row(
origin=origin,
version=version,
artifact_hash=artifact_hash,
manifest=manifest,
)
],
)

artifacts = await get_registry_artifacts_for_lock(
origins={origin: version},
organization_id=uuid.uuid4(),
origin_fingerprints={origin: manifest_fingerprint},
)

assert artifacts == [
RegistryArtifactsContext(
origin=origin,
version=version,
artifact_uri=artifacts[0].artifact_uri,
artifact_hash=artifact_hash,
)
]


@pytest.mark.anyio
async def test_lookup_without_lock_fingerprint_uses_current_artifact_hash(
monkeypatch: pytest.MonkeyPatch,
) -> None:
origin = "git+ssh://github.com/example/custom.git"
version = "v1"
artifact_hash = "c" * 64
_patch_artifact_lookup_session(
monkeypatch,
[
_artifact_row(
origin=origin,
version=version,
artifact_hash=artifact_hash,
manifest=RegistryVersionManifest(),
)
],
)

artifacts = await get_registry_artifacts_for_lock(
origins={origin: version},
organization_id=uuid.uuid4(),
)

assert artifacts == [
RegistryArtifactsContext(
origin=origin,
version=version,
artifact_uri=artifacts[0].artifact_uri,
artifact_hash=artifact_hash,
)
]
Loading
Loading