Skip to content
Merged
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
40 changes: 33 additions & 7 deletions control_plane/release_tuples.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import re
from dataclasses import dataclass
from typing import Protocol
import click

from control_plane.contracts.artifact_identity import ArtifactIdentityManifest
Expand All @@ -14,6 +15,10 @@
LONG_LIVED_RELEASE_TUPLE_CHANNELS = {"testing", "prod"}


class ReleaseTupleRecordStore(Protocol):
def list_release_tuple_records(self) -> tuple[ReleaseTupleRecord, ...]: ...


@dataclass(frozen=True)
class ReleaseTupleDefinition:
tuple_id: str
Expand Down Expand Up @@ -53,7 +58,9 @@ def render_release_tuple_catalog_toml(records: tuple[ReleaseTupleRecord, ...]) -
record.channel,
scope=f"Release tuple record {record.tuple_id}",
)
lines.append(f"[contexts.{_toml_bare_key(record.context)}.channels.{_toml_bare_key(channel_name)}]")
lines.append(
f"[contexts.{_toml_bare_key(record.context)}.channels.{_toml_bare_key(channel_name)}]"
)
lines.append(f"tuple_id = {_toml_string(record.tuple_id)}")
lines.append("")
lines.append(
Expand Down Expand Up @@ -250,23 +257,38 @@ def resolve_release_tuple(
return release_tuple


def _load_optional_release_tuple_catalog_from_database(*, database_url: str) -> ReleaseTupleCatalog | None:
def _load_optional_release_tuple_catalog_from_database(
*, database_url: str
) -> ReleaseTupleCatalog | None:
record_store: PostgresRecordStore | None = None
try:
record_store = PostgresRecordStore(database_url=database_url)
record_store.ensure_schema()
records = record_store.list_release_tuple_records()
return load_optional_release_tuple_catalog_from_store(
record_store=record_store,
source_label="Launchplane Postgres storage",
)
except Exception as error:
raise click.ClickException(f"Could not load release tuples from Launchplane Postgres storage: {error}") from error
raise click.ClickException(
f"Could not load release tuples from Launchplane Postgres storage: {error}"
) from error
finally:
try:
if record_store is not None:
record_store.close()
except Exception:
pass


def load_optional_release_tuple_catalog_from_store(
*,
record_store: ReleaseTupleRecordStore,
source_label: str = "Launchplane release tuple records",
) -> ReleaseTupleCatalog | None:
records = record_store.list_release_tuple_records()
if not records:
return None
return build_release_tuple_catalog_from_records(records, source_label="Launchplane Postgres storage")
return build_release_tuple_catalog_from_records(records, source_label=source_label)


def build_release_tuple_catalog_from_records(
Expand All @@ -282,7 +304,9 @@ def build_release_tuple_catalog_from_records(
scope=f"{source_label} record {record.tuple_id}",
)
if record.tuple_id in seen_tuple_ids:
raise click.ClickException(f"Duplicate release tuple id {record.tuple_id!r} found in {source_label}.")
raise click.ClickException(
f"Duplicate release tuple id {record.tuple_id!r} found in {source_label}."
)
seen_tuple_ids.add(record.tuple_id)
context_channels = merged_contexts.setdefault(record.context, {})
context_channels[normalized_channel_name] = ReleaseTupleDefinition(
Expand All @@ -305,7 +329,9 @@ def _build_release_tuple_catalog_from_context_map(
scope=f"release tuple catalog merge for context {context_name}",
)
if release_tuple.tuple_id in seen_tuple_ids:
raise click.ClickException(f"Duplicate release tuple id {release_tuple.tuple_id!r} found while merging catalogs.")
raise click.ClickException(
f"Duplicate release tuple id {release_tuple.tuple_id!r} found while merging catalogs."
)
seen_tuple_ids.add(release_tuple.tuple_id)
channels[normalized_channel_name] = ReleaseTupleDefinition(
tuple_id=release_tuple.tuple_id,
Expand Down
161 changes: 107 additions & 54 deletions tests/test_release_tuples.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
from unittest.mock import patch

from control_plane import release_tuples as control_plane_release_tuples
from control_plane.contracts.artifact_identity import ArtifactAddonSelector
from control_plane.contracts.artifact_identity import ArtifactAddonSource
from control_plane.contracts.artifact_identity import ArtifactIdentityManifest
from control_plane.contracts.artifact_identity import ArtifactImageReference
from control_plane.contracts.release_tuple_record import ReleaseTupleRecord
from control_plane.storage.postgres import PostgresRecordStore

Expand All @@ -32,12 +35,31 @@ def _write_release_tuple_records(
return database_url


def _artifact_image_reference() -> ArtifactImageReference:
return ArtifactImageReference(
repository="ghcr.io/cbusillo/odoo-private",
digest="sha256:image456",
)


class _FakeReleaseTupleStore:
def __init__(self, records: tuple[ReleaseTupleRecord, ...]) -> None:
self.records = records

def list_release_tuple_records(self) -> tuple[ReleaseTupleRecord, ...]:
return self.records


class ReleaseTupleTests(unittest.TestCase):
def test_should_mint_release_tuple_only_for_stable_remote_channels(self) -> None:
self.assertTrue(control_plane_release_tuples.should_mint_release_tuple_for_channel("testing"))
self.assertTrue(
control_plane_release_tuples.should_mint_release_tuple_for_channel("testing")
)
self.assertTrue(control_plane_release_tuples.should_mint_release_tuple_for_channel("prod"))
self.assertFalse(control_plane_release_tuples.should_mint_release_tuple_for_channel("dev"))
self.assertFalse(control_plane_release_tuples.should_mint_release_tuple_for_channel("preview"))
self.assertFalse(
control_plane_release_tuples.should_mint_release_tuple_for_channel("preview")
)

def test_resolve_release_tuple_reads_context_channel_repo_refs_from_database(self) -> None:
with TemporaryDirectory() as temporary_directory_name:
Expand Down Expand Up @@ -91,8 +113,7 @@ def test_load_release_tuple_catalog_requires_stored_records(self) -> None:

with patch.dict(os.environ, {"LAUNCHPLANE_DATABASE_URL": database_url}, clear=True):
with self.assertRaisesRegex(Exception, "No Launchplane release tuple records"):
control_plane_release_tuples.load_release_tuple_catalog(
)
control_plane_release_tuples.load_release_tuple_catalog()

def test_load_release_tuple_catalog_reads_database_records_only(self) -> None:
with TemporaryDirectory() as temporary_directory_name:
Expand Down Expand Up @@ -122,12 +143,43 @@ def test_load_release_tuple_catalog_reads_database_records_only(self) -> None:
)

with patch.dict(os.environ, {"LAUNCHPLANE_DATABASE_URL": database_url}, clear=True):
catalog = control_plane_release_tuples.load_release_tuple_catalog(
)
catalog = control_plane_release_tuples.load_release_tuple_catalog()

self.assertEqual(catalog.contexts["opw"].channels["testing"].tuple_id, "opw-testing-db")
self.assertEqual(catalog.contexts["opw"].channels["prod"].tuple_id, "opw-prod-db")

def test_load_optional_catalog_uses_structural_store_boundary(self) -> None:
catalog = control_plane_release_tuples.load_optional_release_tuple_catalog_from_store(
record_store=_FakeReleaseTupleStore(
records=(
ReleaseTupleRecord(
tuple_id="verireel-testing-db",
context="verireel",
channel="testing",
artifact_id="artifact-testing",
repo_shas={"verireel": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
provenance="ship",
minted_at="2026-04-21T18:00:00Z",
),
)
),
source_label="fake release tuple store",
)

self.assertIsNotNone(catalog)
assert catalog is not None
self.assertEqual(
catalog.contexts["verireel"].channels["testing"].tuple_id,
"verireel-testing-db",
)

def test_load_optional_catalog_returns_none_for_empty_store(self) -> None:
catalog = control_plane_release_tuples.load_optional_release_tuple_catalog_from_store(
record_store=_FakeReleaseTupleStore(records=()),
)

self.assertIsNone(catalog)

def test_resolve_release_tuple_fails_closed_when_channel_missing(self) -> None:
with TemporaryDirectory() as temporary_directory_name:
control_plane_root = Path(temporary_directory_name)
Expand Down Expand Up @@ -181,7 +233,9 @@ def test_build_release_tuple_catalog_from_records_rejects_duplicate_tuple_ids(se
)
)

def test_build_release_tuple_catalog_from_records_rejects_non_stable_remote_channels(self) -> None:
def test_build_release_tuple_catalog_from_records_rejects_non_stable_remote_channels(
self,
) -> None:
with self.assertRaisesRegex(Exception, "stable remote channels"):
control_plane_release_tuples.build_release_tuple_catalog_from_records(
(
Expand All @@ -203,23 +257,22 @@ def test_build_release_tuple_record_from_artifact_manifest_uses_split_repo_shas(
source_commit="abc1234",
enterprise_base_digest="sha256:enterprise123",
addon_sources=(
{
"repository": "cbusillo/odoo-shared-addons",
"ref": "def5678",
},
ArtifactAddonSource(
repository="cbusillo/odoo-shared-addons",
ref="def5678",
),
),
image={
"repository": "ghcr.io/cbusillo/odoo-private",
"digest": "sha256:image456",
},
image=_artifact_image_reference(),
)

release_tuple = control_plane_release_tuples.build_release_tuple_record_from_artifact_manifest(
context_name="opw",
channel_name="testing",
artifact_manifest=manifest,
deployment_record_id="deployment-1",
minted_at="2026-04-10T18:24:00Z",
release_tuple = (
control_plane_release_tuples.build_release_tuple_record_from_artifact_manifest(
context_name="opw",
channel_name="testing",
artifact_manifest=manifest,
deployment_record_id="deployment-1",
minted_at="2026-04-10T18:24:00Z",
)
)

self.assertEqual(release_tuple.tuple_id, "opw-testing-artifact-sha256-image456")
Expand All @@ -233,44 +286,47 @@ def test_build_release_tuple_record_ignores_addon_selector_metadata(self) -> Non
source_commit="abc1234",
enterprise_base_digest="sha256:enterprise123",
addon_sources=(
{
"repository": "cbusillo/disable_odoo_online",
"ref": "def5678",
},
ArtifactAddonSource(
repository="cbusillo/disable_odoo_online",
ref="def5678",
),
),
addon_selectors=(
{
"repository": "cbusillo/disable_odoo_online",
"selector": "main",
"resolved_ref": "def5678",
},
ArtifactAddonSelector(
repository="cbusillo/disable_odoo_online",
selector="main",
resolved_ref="def5678",
),
),
image={
"repository": "ghcr.io/cbusillo/odoo-private",
"digest": "sha256:image456",
},
image=_artifact_image_reference(),
)

release_tuple = control_plane_release_tuples.build_release_tuple_record_from_artifact_manifest(
context_name="opw",
channel_name="testing",
artifact_manifest=manifest,
deployment_record_id="deployment-1",
minted_at="2026-04-10T18:24:00Z",
release_tuple = (
control_plane_release_tuples.build_release_tuple_record_from_artifact_manifest(
context_name="opw",
channel_name="testing",
artifact_manifest=manifest,
deployment_record_id="deployment-1",
minted_at="2026-04-10T18:24:00Z",
)
)

self.assertEqual(release_tuple.repo_shas, {"tenant-opw": "abc1234", "disable_odoo_online": "def5678"})
self.assertEqual(
release_tuple.repo_shas, {"tenant-opw": "abc1234", "disable_odoo_online": "def5678"}
)

def test_build_release_tuple_record_rejects_branch_refs(self) -> None:
manifest = ArtifactIdentityManifest(
artifact_id="artifact-sha256-image456",
source_commit="abc1234",
enterprise_base_digest="sha256:enterprise123",
addon_sources=({"repository": "cbusillo/odoo-shared-addons", "ref": "main"},),
image={
"repository": "ghcr.io/cbusillo/odoo-private",
"digest": "sha256:image456",
},
addon_sources=(
ArtifactAddonSource(
repository="cbusillo/odoo-shared-addons",
ref="main",
),
),
image=_artifact_image_reference(),
)

with self.assertRaisesRegex(Exception, "must be a 7-40 character hexadecimal git sha"):
Expand All @@ -285,15 +341,12 @@ def test_build_release_tuple_record_rejects_dev_channel(self) -> None:
source_commit="abc1234",
enterprise_base_digest="sha256:enterprise123",
addon_sources=(
{
"repository": "cbusillo/odoo-shared-addons",
"ref": "def5678",
},
ArtifactAddonSource(
repository="cbusillo/odoo-shared-addons",
ref="def5678",
),
),
image={
"repository": "ghcr.io/cbusillo/odoo-private",
"digest": "sha256:image456",
},
image=_artifact_image_reference(),
)

with self.assertRaisesRegex(Exception, "stable remote channels"):
Expand Down
Loading