From 41108a0856ae57b3b7677322e7f0c7738bcd0364 Mon Sep 17 00:00:00 2001 From: Jake Moshenko Date: Wed, 18 Feb 2015 16:37:38 -0500 Subject: [PATCH] Allow tags to be marked as hidden. Create a hidden tag on every image during a push to prevent them from getting GCed. --- config.py | 5 ++- data/database.py | 1 + ...1fcf9_allow_tags_to_be_marked_as_hidden.py | 26 +++++++++++ data/model/legacy.py | 41 ++++++++++++------ endpoints/registry.py | 4 ++ test/data/test.db | Bin 729088 -> 729088 bytes 6 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 data/migrations/versions/4ef04c61fcf9_allow_tags_to_be_marked_as_hidden.py diff --git a/config.py b/config.py index 6a6c9c2e6..bedddcda7 100644 --- a/config.py +++ b/config.py @@ -194,4 +194,7 @@ class DefaultConfig(object): SYSTEM_SERVICES_PATH = "conf/init/" # Services that should not be shown in the logs view. - SYSTEM_SERVICE_BLACKLIST = [] \ No newline at end of file + SYSTEM_SERVICE_BLACKLIST = [] + + # Temporary tag expiration in seconds, this may actually be longer based on GC policy + PUSH_TEMP_TAG_EXPIRATION_S = 60 * 60 diff --git a/data/database.py b/data/database.py index d23157c0c..162057530 100644 --- a/data/database.py +++ b/data/database.py @@ -469,6 +469,7 @@ class RepositoryTag(BaseModel): repository = ForeignKeyField(Repository) lifetime_start_ts = IntegerField(default=_get_epoch_timestamp) lifetime_end_ts = IntegerField(null=True, index=True) + hidden = BooleanField(default=False) class Meta: database = db diff --git a/data/migrations/versions/4ef04c61fcf9_allow_tags_to_be_marked_as_hidden.py b/data/migrations/versions/4ef04c61fcf9_allow_tags_to_be_marked_as_hidden.py new file mode 100644 index 000000000..e4fc1ea5e --- /dev/null +++ b/data/migrations/versions/4ef04c61fcf9_allow_tags_to_be_marked_as_hidden.py @@ -0,0 +1,26 @@ +"""Allow tags to be marked as hidden. + +Revision ID: 4ef04c61fcf9 +Revises: 509d2857566f +Create Date: 2015-02-18 16:34:16.586129 + +""" + +# revision identifiers, used by Alembic. +revision = '4ef04c61fcf9' +down_revision = '509d2857566f' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('repositorytag', sa.Column('hidden', sa.Boolean(), nullable=False, server_default=sa.sql.expression.false())) + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('repositorytag', 'hidden') + ### end Alembic commands ### diff --git a/data/model/legacy.py b/data/model/legacy.py index f04283c5a..331bf2720 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -5,6 +5,7 @@ import json import time from datetime import datetime, timedelta, date +from uuid import uuid4 from data.database import (User, Repository, Image, AccessToken, Role, RepositoryPermission, Visibility, RepositoryTag, EmailConfirmation, FederatedLogin, @@ -1561,15 +1562,20 @@ def _tag_alive(query): (RepositoryTag.lifetime_end_ts > int(time.time()))) -def list_repository_tags(namespace_name, repository_name): - return _tag_alive(RepositoryTag - .select(RepositoryTag, Image) - .join(Repository) - .join(Namespace, on=(Repository.namespace_user == Namespace.id)) - .switch(RepositoryTag) - .join(Image) - .where(Repository.name == repository_name, - Namespace.username == namespace_name)) +def list_repository_tags(namespace_name, repository_name, include_hidden=False): + query = _tag_alive(RepositoryTag + .select(RepositoryTag, Image) + .join(Repository) + .join(Namespace, on=(Repository.namespace_user == Namespace.id)) + .switch(RepositoryTag) + .join(Image) + .where(Repository.name == repository_name, + Namespace.username == namespace_name)) + + if not include_hidden: + query = query.where(RepositoryTag.hidden == False) + + return query def _garbage_collect_tags(namespace_name, repository_name): @@ -1786,10 +1792,8 @@ def create_or_update_tag(namespace_name, repository_name, tag_name, # No tag that needs to be ended pass - tag = RepositoryTag.create(repository=repo, image=image, name=tag_name, - lifetime_start_ts=now_ts) - - return tag + return RepositoryTag.create(repository=repo, image=image, name=tag_name, + lifetime_start_ts=now_ts) def delete_tag(namespace_name, repository_name, tag_name): @@ -1812,6 +1816,17 @@ def delete_tag(namespace_name, repository_name, tag_name): found.save() +def create_temporary_hidden_tag(repo, image, expiration_s): + """ Create a tag with a defined timeline, that will not appear in the UI or CLI. Returns the name + of the temporary tag. """ + now_ts = int(time.time()) + expire_ts = now_ts + expiration_s + tag_name = str(uuid4()) + RepositoryTag.create(repository=repo, image=image, name=tag_name, lifetime_start_ts=now_ts, + lifetime_end_ts=expire_ts, hidden=True) + return tag_name + + def purge_all_repository_tags(namespace_name, repository_name): """ Immediately purge all repository tags without respecting the lifeline procedure """ try: diff --git a/endpoints/registry.py b/endpoints/registry.py index dc5069e22..8222212a7 100644 --- a/endpoints/registry.py +++ b/endpoints/registry.py @@ -467,6 +467,10 @@ def put_image_json(namespace, repository, image_id): repo_image = model.find_create_or_link_image(image_id, repo, username, {}, store.preferred_locations[0]) + # Create a temporary tag to prevent this image from getting garbage collected while the push + # is in progress. + model.create_temporary_hidden_tag(repo, repo_image, app.config['PUSH_TEMP_TAG_EXPIRATION_S']) + uuid = repo_image.storage.uuid if image_id != data['id']: diff --git a/test/data/test.db b/test/data/test.db index 0856c2f6acf315003dfdb7e0db326e7a5db6c37a..4da80c978b4df80b01a42e5de32d324dd0ae295b 100644 GIT binary patch delta 8193 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 I!I#(l4^|11ng9R*