diff --git a/conda-store-server/conda_store_server/alembic/versions/64a749e87619_add_build_key_version.py b/conda-store-server/conda_store_server/alembic/versions/64a749e87619_add_build_key_version.py new file mode 100644 index 000000000..113f6cba2 --- /dev/null +++ b/conda-store-server/conda_store_server/alembic/versions/64a749e87619_add_build_key_version.py @@ -0,0 +1,28 @@ +"""add build_key_version + +Revision ID: 64a749e87619 +Revises: b387747ca9b7 +Create Date: 2023-11-05 00:59:57.132192 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "64a749e87619" +down_revision = "b387747ca9b7" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "build", + sa.Column( + "build_key_version", sa.Integer(), nullable=False, server_default="1" + ), + ) + + +def downgrade(): + op.drop_column("build", "build_key_version") diff --git a/conda-store-server/conda_store_server/orm.py b/conda-store-server/conda_store_server/orm.py index 5a9b11762..3e260df11 100644 --- a/conda-store-server/conda_store_server/orm.py +++ b/conda-store-server/conda_store_server/orm.py @@ -178,6 +178,14 @@ class Build(Base): started_on = Column(DateTime, default=None) ended_on = Column(DateTime, default=None) deleted_on = Column(DateTime, default=None) + build_key_version = Column(Integer, default=2, nullable=False) + + @validates("build_key_version") + def validate_build_key_version(self, key, build_key_version): + if build_key_version not in [1, 2]: + raise ValueError(f"invalid build_key_version={build_key_version}") + + return build_key_version build_artifacts = relationship( "BuildArtifact", back_populates="build", cascade="all, delete-orphan" @@ -225,15 +233,29 @@ def build_key(self): The build key should be a key that allows for the environment build to be easily identified and found in the database. """ - datetime_format = "%Y%m%d-%H%M%S-%f" - return f"{self.specification.sha256}-{self.scheduled_on.strftime(datetime_format)}-{self.id}-{self.specification.name}" + if self.build_key_version == 1: + datetime_format = "%Y%m%d-%H%M%S-%f" + return f"{self.specification.sha256}-{self.scheduled_on.strftime(datetime_format)}-{self.id}-{self.specification.name}" + elif self.build_key_version == 2: + hash = self.specification.sha256[:4] + timestamp = int(self.scheduled_on.timestamp()) + id = self.id + name = self.specification.name[:16] + return f"{hash}-{timestamp}-{id}-{name}" + else: + raise ValueError(f"invalid build key version: {self.build_key_version}") @staticmethod def parse_build_key(key): parts = key.split("-") - if len(parts) < 5: - return None - return int(parts[4]) # build_id + # Note: cannot rely on the number of dashes to differentiate between + # versions because name can contain dashes. Instead, this relies on the + # hash size to infer the format. The name is the last field, so indexing + # to find the id is okay. + if key[4] == "-": # v2 + return int(parts[2]) # build_id + else: # v1 + return int(parts[4]) # build_id @property def log_key(self): diff --git a/conda-store-server/tests/test_actions.py b/conda-store-server/tests/test_actions.py index 3ad926422..19e6ea30b 100644 --- a/conda-store-server/tests/test_actions.py +++ b/conda-store-server/tests/test_actions.py @@ -1,4 +1,5 @@ import asyncio +import datetime import pathlib import re import sys @@ -224,17 +225,20 @@ def test_add_lockfile_packages( @pytest.mark.parametrize( - "is_legacy_build", + "is_legacy_build, build_key_version", [ - False, - True, + (False, 0), # invalid + (False, 1), # long (legacy) + (False, 2), # short (default) + (True, 1), # build_key_version doesn't matter because there's no lockfile ], ) def test_api_get_build_lockfile( - request, conda_store, db, simple_specification_with_pip, conda_prefix, is_legacy_build + request, conda_store, db, simple_specification_with_pip, conda_prefix, is_legacy_build, build_key_version ): # initializes data needed to get the lockfile specification = simple_specification_with_pip + specification.name = "this-is-a-long-environment-name-to-test-truncation" namespace = "pytest" class MyAuthentication(DummyAuthentication): @@ -250,6 +254,16 @@ def authorize_request(self, *args, **kwargs): ) db.commit() build = api.get_build(db, build_id=build_id) + # makes this more visible in the lockfile + build_id = 12345678 + build.id = build_id + if build_key_version is 0: # invalid + with pytest.raises(ValueError, match=r"invalid build_key_version=0"): + build.build_key_version = build_key_version + return # invalid, nothing more to test + build.build_key_version = build_key_version + # makes sure the timestamp in build_key is always the same + build.scheduled_on = datetime.datetime(2023, 11, 5, 3, 54, 10, 510258) environment = api.get_environment(db, namespace=namespace) # adds packages (returned in the lockfile) @@ -293,12 +307,21 @@ def authorize_request(self, *args, **kwargs): assert re.match("http.*//.*tar.bz2#.*", lines[2]) is not None else: # new build: redirects to lockfile generated by conda-lock - lockfile_url_pattern = ( - "lockfile/" - "89e5a99aa094689b7aafc66c47987fa186e08f9d619a02ab1a469d0759da3b8b-" - ".*-test.yml" - ) + def lockfile_url(build_key): + return f"lockfile/{build_key}.yml" + if build_key_version is 1: + build_key = ( + "7a0c9317530e3732a25f22c2017a881dcd6f84ff85c96a609210168deea280ef-" + "20231105-035410-510258-12345678-this-is-a-long-environment-name-to-test-truncation" + ) + elif build_key_version is 2: + build_key = "7a0c-1699156450-12345678-this-is-a-long-e" + else: + raise ValueError(f"unexpected build_key_version: {build_key_version}") assert type(res) is RedirectResponse assert key == res.headers['location'] - assert re.match(lockfile_url_pattern, res.headers['location']) is not None + assert build.build_key == build_key + assert build.parse_build_key(build_key) == 12345678 + assert lockfile_url(build_key) == build.conda_lock_key + assert lockfile_url(build_key) == res.headers['location'] assert res.status_code == 307