1
0
mirror of https://github.com/quay/quay.git synced 2025-07-30 07:43:13 +03:00

feat: Add auto-prune policy at repository level (PROJQUAY-6354) (#2691)

* feat: Add support for auto pruning at repository level (PROJQUAY-6354)

* Add repositoryautoprunepolicy table to alembic migration script

* Add repository auto-prune policy endpoints

* Add UI for repository auto-pruning policies

* case: apply repo auto-prune policy when no namespace policy given

* case: both namespace and repo pruning policy are given

* Add tests for repository autoprune endpoint

* Add cypress test for repository auto-prune

* Add repo auto-prune policy clean-up for repository deletion

* Add repository auto pruning tables to quay db snapshot for cypress tests

* Address review comments

* Add more tests + fix CI + reformat files

* Address review comments #2

---------

Signed-off-by: harishsurf <hgovinda@redhat.com>
This commit is contained in:
Harish Govindarajulu
2024-02-27 15:02:57 +05:30
committed by GitHub
parent 29258ae0c7
commit 98811f5397
23 changed files with 2098 additions and 51 deletions

View File

@ -748,6 +748,7 @@ class User(BaseModel):
OrganizationRhSkus,
NamespaceAutoPrunePolicy,
AutoPruneTaskStatus,
RepositoryAutoPrunePolicy,
}
| appr_classes
| v22_classes
@ -968,6 +969,7 @@ class Repository(BaseModel):
UploadedBlob,
QuotaNamespaceSize,
QuotaRepositorySize,
RepositoryAutoPrunePolicy,
}
| appr_classes
| v22_classes
@ -2021,6 +2023,13 @@ class AutoPruneTaskStatus(BaseModel):
status = TextField(null=True)
class RepositoryAutoPrunePolicy(BaseModel):
uuid = CharField(default=uuid_generator, max_length=36, index=True, null=False)
repository = ForeignKeyField(Repository, index=True, null=False)
namespace = QuayUserField(index=True, null=False)
policy = JSONField(null=False, default={})
# Defines a map from full-length index names to the legacy names used in our code
# to meet length restrictions.
LEGACY_INDEX_MAP = {

View File

@ -0,0 +1,78 @@
"""repository autoprune policy
Revision ID: b4da5b09c8df
Revises: 41d15c93c299
Create Date: 2024-02-05 10:47:32.172623
"""
# revision identifiers, used by Alembic.
revision = "b4da5b09c8df"
down_revision = "41d15c93c299"
import sqlalchemy as sa
def upgrade(op, tables, tester):
op.create_table(
"repositoryautoprunepolicy",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uuid", sa.String(length=36), nullable=False),
sa.Column("repository_id", sa.Integer(), nullable=False),
sa.Column("namespace_id", sa.Integer(), nullable=False),
sa.Column("policy", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(
["repository_id"],
["repository.id"],
name=op.f("fk_repositoryautoprunepolicy_repository_id_repository"),
),
sa.ForeignKeyConstraint(
["namespace_id"],
["user.id"],
name=op.f("fk_repositoryautoprunepolicy_namespace_id_user"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_repositoryautoprunepolicyid")),
)
op.create_index(
"repositoryautoprunepolicy_repository_id",
"repositoryautoprunepolicy",
["repository_id"],
unique=True,
)
op.create_index(
"repositoryautoprunepolicy_namespace_id",
"repositoryautoprunepolicy",
["namespace_id"],
unique=True,
)
op.create_index(
"repositoryautoprunepolicy_uuid",
"repositoryautoprunepolicy",
["uuid"],
unique=True,
)
op.bulk_insert(
tables.logentrykind,
[
{"name": "create_repository_autoprune_policy"},
{"name": "update_repository_autoprune_policy"},
{"name": "delete_repository_autoprune_policy"},
],
)
def downgrade(op, tables, tester):
op.drop_table("repositoryautoprunepolicy")
op.execute(
tables.logentrykind.delete().where(
tables.logentrykind.c.name
== op.inline_literal("create_repository_autoprune_policy") | tables.logentrykind.c.name
== op.inline_literal("update_repository_autoprune_policy") | tables.logentrykind.c.name
== op.inline_literal("delete_repository_autoprune_policy")
)
)

View File

@ -173,6 +173,26 @@ class InvalidNamespaceException(DataModelException):
pass
class RepositoryAutoPrunePolicyAlreadyExists(DataModelException):
pass
class RepositoryAutoPrunePolicyDoesNotExist(DataModelException):
pass
class InvalidRepositoryAutoPrunePolicy(DataModelException):
pass
class InvalidRepositoryAutoPruneMethod(DataModelException):
pass
class InvalidRepositoryException(DataModelException):
pass
class TooManyLoginAttemptsException(Exception):
def __init__(self, message, retry_after):
super(TooManyLoginAttemptsException, self).__init__(message)

View File

@ -2,21 +2,21 @@ import json
import logging.config
from enum import Enum
from data.database import AutoPruneTaskStatus, DeletedNamespace
from data.database import AutoPruneTaskStatus
from data.database import NamespaceAutoPrunePolicy as NamespaceAutoPrunePolicyTable
from data.database import (
Repository,
RepositoryState,
User,
db_for_update,
get_epoch_timestamp_ms,
)
from data.database import Repository
from data.database import RepositoryAutoPrunePolicy as RepositoryAutoPrunePolicyTable
from data.database import RepositoryState, User, db_for_update, get_epoch_timestamp_ms
from data.model import (
InvalidNamespaceAutoPruneMethod,
InvalidNamespaceAutoPrunePolicy,
InvalidNamespaceException,
InvalidRepositoryAutoPrunePolicy,
InvalidRepositoryException,
NamespaceAutoPrunePolicyAlreadyExists,
NamespaceAutoPrunePolicyDoesNotExist,
RepositoryAutoPrunePolicyAlreadyExists,
RepositoryAutoPrunePolicyDoesNotExist,
db_transaction,
log,
modelutil,
@ -53,6 +53,22 @@ class NamespaceAutoPrunePolicy:
return {"uuid": self.uuid, "method": self.method, "value": self.config.get("value")}
class RepositoryAutoPrunePolicy:
def __init__(self, db_row):
config = json.loads(db_row.policy)
self._db_row = db_row
self.uuid = db_row.uuid
self.method = config.get("method")
self.config = config
self.repository_id = db_row.repository_id
def get_row(self):
return self._db_row
def get_view(self):
return {"uuid": self.uuid, "method": self.method, "value": self.config.get("value")}
def valid_value(method, value):
"""
Method for validating the value provided for the policy method.
@ -88,6 +104,19 @@ def assert_valid_namespace_autoprune_policy(policy_config):
raise InvalidNamespaceAutoPrunePolicy("Invalid value given for method type")
def assert_valid_repository_autoprune_policy(policy_config):
"""
Asserts that the policy config is valid.
"""
try:
method = AutoPruneMethod(policy_config.get("method"))
except ValueError:
raise InvalidRepositoryAutoPrunePolicy("Invalid method provided")
if not valid_value(method, policy_config.get("value")):
raise InvalidRepositoryAutoPrunePolicy("Invalid value given for method type")
def get_namespace_autoprune_policies_by_orgname(orgname):
"""
Get the autopruning policies for the specified namespace.
@ -102,6 +131,34 @@ def get_namespace_autoprune_policies_by_orgname(orgname):
return [NamespaceAutoPrunePolicy(row) for row in query]
def get_repository_autoprune_policies_by_repo_name(orgname, repo_name):
"""
Get the autopruning policies for the specified repository.
"""
query = (
RepositoryAutoPrunePolicyTable.select(RepositoryAutoPrunePolicyTable)
.join(Repository)
.join(User)
.where(
User.username == orgname,
RepositoryAutoPrunePolicyTable.repository == Repository.id,
Repository.name == repo_name,
)
)
return [RepositoryAutoPrunePolicy(row) for row in query]
def get_repository_autoprune_policies_by_repo_id(repo_id):
"""
Get the autopruning policies for the specified repository.
"""
query = RepositoryAutoPrunePolicyTable.select().where(
RepositoryAutoPrunePolicyTable.repository == repo_id,
)
return [RepositoryAutoPrunePolicy(row) for row in query]
def get_namespace_autoprune_policies_by_id(namespace_id):
"""
Get the autopruning policies for the namespace by id.
@ -112,6 +169,16 @@ def get_namespace_autoprune_policies_by_id(namespace_id):
return [NamespaceAutoPrunePolicy(row) for row in query]
def get_repository_autoprune_policies_by_namespace_id(namespace_id):
"""
Get all repository autopruning policies for a namespace by id.
"""
query = RepositoryAutoPrunePolicyTable.select().where(
RepositoryAutoPrunePolicyTable.namespace == namespace_id,
)
return [RepositoryAutoPrunePolicy(row) for row in query]
def get_namespace_autoprune_policy(orgname, uuid):
"""
Get the specific autopruning policy for the specified namespace by uuid.
@ -128,6 +195,22 @@ def get_namespace_autoprune_policy(orgname, uuid):
return None
def get_repository_autoprune_policy_by_uuid(repo_name, uuid):
"""
Get the specific autopruning policy for the specified repository by uuid.
"""
try:
row = (
RepositoryAutoPrunePolicyTable.select(RepositoryAutoPrunePolicyTable)
.join(Repository)
.where(Repository.name == repo_name, RepositoryAutoPrunePolicyTable.uuid == uuid)
.get()
)
return RepositoryAutoPrunePolicy(row)
except RepositoryAutoPrunePolicyTable.DoesNotExist:
return None
def create_namespace_autoprune_policy(orgname, policy_config, create_task=False):
"""
Creates the namespace auto-prune policy. If create_task is True, then it will also create
@ -156,6 +239,37 @@ def create_namespace_autoprune_policy(orgname, policy_config, create_task=False)
return new_policy
def create_repository_autoprune_policy(orgname, repo_name, policy_config, create_task=False):
"""
Creates the repository auto-prune policy. If create_task is True, it will check if auto-prune task is not already present,
and only then it will create the auto-prune task. Deletion of the task will be handled by the autoprune worker.
"""
with db_transaction():
namespace = get_active_namespace_user_by_username(orgname)
namespace_id = namespace.id
repo = repository.get_repository(orgname, repo_name)
if repo is None:
raise InvalidRepositoryException("Repository does not exist: %s" % repo_name)
if repository_has_autoprune_policy(repo.id):
raise RepositoryAutoPrunePolicyAlreadyExists(
"Policy for this repository already exists, delete existing to create new policy"
)
assert_valid_repository_autoprune_policy(policy_config)
new_policy = RepositoryAutoPrunePolicyTable.create(
namespace=namespace_id, repository=repo.id, policy=json.dumps(policy_config)
)
if create_task and not namespace_has_autoprune_task(namespace_id):
create_autoprune_task(namespace_id)
return new_policy
def update_namespace_autoprune_policy(orgname, uuid, policy_config):
"""
Updates the namespace auto-prune policy with the provided policy config
@ -184,9 +298,41 @@ def update_namespace_autoprune_policy(orgname, uuid, policy_config):
return True
def update_repository_autoprune_policy(orgname, repo_name, uuid, policy_config):
"""
Updates the repository auto-prune policy with the provided policy config
for the specified uuid.
"""
namespace = get_active_namespace_user_by_username(orgname)
namespace_id = namespace.id
repo = repository.get_repository(orgname, repo_name)
if repo is None:
raise InvalidRepositoryException("Repository does not exist: %s" % repo_name)
policy = get_repository_autoprune_policy_by_uuid(repo_name, uuid)
if policy is None:
raise RepositoryAutoPrunePolicyDoesNotExist(
f"Policy not found for repository: {repo_name} with uuid: {uuid}"
)
assert_valid_repository_autoprune_policy(policy_config)
(
RepositoryAutoPrunePolicyTable.update(policy=json.dumps(policy_config))
.where(
RepositoryAutoPrunePolicyTable.uuid == uuid,
RepositoryAutoPrunePolicyTable.namespace == namespace_id,
)
.execute()
)
return True
def delete_namespace_autoprune_policy(orgname, uuid):
"""
Deletes the policy specified by the uuid
Deletes the namespace policy specified by the uuid
"""
with db_transaction():
@ -217,6 +363,43 @@ def delete_namespace_autoprune_policy(orgname, uuid):
return True
def delete_repository_autoprune_policy(orgname, repo_name, uuid):
"""
Deletes the repository policy specified by the uuid
"""
with db_transaction():
try:
namespace_id = User.select().where(User.username == orgname).get().id
except User.DoesNotExist:
raise InvalidNamespaceException("Invalid namespace provided: %s" % (orgname))
repo = repository.get_repository(orgname, repo_name)
if repo is None:
raise InvalidRepositoryException("Repository does not exist: %s" % repo_name)
policy = get_repository_autoprune_policy_by_uuid(repo_name, uuid)
if policy is None:
raise RepositoryAutoPrunePolicyDoesNotExist(
f"Policy not found for repository: {repo_name} with uuid: {uuid}"
)
response = (
RepositoryAutoPrunePolicyTable.delete()
.where(
RepositoryAutoPrunePolicyTable.uuid == uuid,
RepositoryAutoPrunePolicyTable.namespace == namespace_id,
)
.execute()
)
if not response:
raise RepositoryAutoPrunePolicyTable.DoesNotExist(
f"Policy not found for repository: {repo_name} with uuid: {uuid}"
)
return True
def namespace_has_autoprune_policy(namespace_id):
return (
NamespaceAutoPrunePolicyTable.select(1)
@ -225,6 +408,14 @@ def namespace_has_autoprune_policy(namespace_id):
)
def repository_has_autoprune_policy(repository_id):
return (
RepositoryAutoPrunePolicyTable.select(1)
.where(RepositoryAutoPrunePolicyTable.repository == repository_id)
.exists()
)
def namespace_has_autoprune_task(namespace_id):
return (
AutoPruneTaskStatus.select(1).where(AutoPruneTaskStatus.namespace == namespace_id).exists()
@ -260,6 +451,7 @@ def fetch_autoprune_task(task_run_interval_ms=60 * 60 * 1000):
AutoPruneTaskStatus.select(AutoPruneTaskStatus)
.where(
AutoPruneTaskStatus.namespace.not_in(
# this basically skips ns if user is not enabled
User.select(User.id).where(
User.enabled == False, User.id == AutoPruneTaskStatus.namespace
)
@ -411,12 +603,18 @@ def execute_policy_on_repo(policy, repo_id, namespace_id, tag_page_limit=100):
policy_to_func_map[policy.method](repo, policy.config, namespace, tag_page_limit)
def execute_policies_for_repo(policies, repo, namespace_id, tag_page_limit=100):
def execute_policies_for_repo(ns_policies, repo, namespace_id, tag_page_limit=100):
"""
Executes the policies for the given repository.
Executes both repository and namespace level policies for the given repository. The policies
are applied in a serial fashion and are run asynchronosly in the background.
"""
for policy in policies:
execute_policy_on_repo(policy, repo, namespace_id, tag_page_limit)
for ns_policy in ns_policies:
repo_policies = get_repository_autoprune_policies_by_repo_id(repo.id)
# note: currently only one policy is configured per repo
for repo_policy in repo_policies:
execute_policy_on_repo(repo_policy, repo.id, namespace_id, tag_page_limit)
# execute associated namespace policy
execute_policy_on_repo(ns_policy, repo.id, namespace_id, tag_page_limit)
def get_paginated_repositories_for_namespace(namespace_id, page_token=None, page_size=50):
@ -438,12 +636,28 @@ def get_paginated_repositories_for_namespace(namespace_id, page_token=None, page
)
def execute_namespace_polices(policies, namespace_id, repository_page_limit=50, tag_page_limit=100):
def get_repository_by_policy_repo_id(policy_repo_id):
try:
return (
Repository.select(Repository.name)
.where(
Repository.id == policy_repo_id,
Repository.state == RepositoryState.NORMAL,
)
.get()
)
except Repository.DoesNotExist:
return None
def execute_namespace_polices(
ns_policies, namespace_id, repository_page_limit=50, tag_page_limit=100
):
"""
Executes the given policies for the repositories in the provided namespace.
"""
if not policies:
if not ns_policies:
return
page_token = None
@ -453,7 +667,7 @@ def execute_namespace_polices(policies, namespace_id, repository_page_limit=50,
)
for repo in repos:
execute_policies_for_repo(policies, repo, namespace_id, tag_page_limit)
execute_policies_for_repo(ns_policies, repo, namespace_id, tag_page_limit)
if page_token is None:
break

View File

@ -21,6 +21,7 @@ from data.database import (
Repository,
RepositoryActionCount,
RepositoryAuthorizedEmail,
RepositoryAutoPrunePolicy,
RepositoryBuild,
RepositoryBuildTrigger,
RepositoryNotification,
@ -105,6 +106,8 @@ def purge_repository(repo, force=False):
ManifestSecurityStatus.select().where(ManifestSecurityStatus.repository == repo).count()
== 0
)
# Delete auto-prune policy associated with the repository
RepositoryAutoPrunePolicy.delete().where(RepositoryAutoPrunePolicy.repository == repo).execute()
# Delete any repository build triggers, builds, and any other large-ish reference tables for
# the repository.

View File

@ -52,9 +52,9 @@ def test_load_security_information(indexed_v4, expected_status, initialized_db):
@pytest.mark.parametrize(
"next_token, expected_next_token, expected_error",
[
(None, V4ScanToken(56), None),
(V4ScanToken(None), V4ScanToken(56), AssertionError),
(V4ScanToken(1), V4ScanToken(56), None),
(None, V4ScanToken(58), None),
(V4ScanToken(None), V4ScanToken(58), AssertionError),
(V4ScanToken(1), V4ScanToken(58), None),
],
)
def test_perform_indexing(next_token, expected_next_token, expected_error, initialized_db):

View File

@ -0,0 +1,169 @@
import json
import pytest
from data.model.autoprune import *
from data.model.organization import create_organization
from data.model.repository import create_repository, set_repository_state
from data.model.user import get_user
from test.fixtures import *
ORG1_NAME = "org1"
ORG2_NAME = "org2"
ORG3_NAME = "org3"
REPO1_NAME = "repo1"
REPO2_NAME = "repo2"
REPO3_NAME = "repo3"
class TestRepositoryAutoprune:
@pytest.fixture(autouse=True)
def setup(self, initialized_db):
user = get_user("devtable")
self.org1 = create_organization(ORG1_NAME, f"{ORG1_NAME}@devtable.com", user)
self.org2 = create_organization(ORG2_NAME, f"{ORG2_NAME}@devtable.com", user)
self.org3 = create_organization(ORG3_NAME, f"{ORG3_NAME}@devtable.com", user)
self.number_of_tags_policy = {"method": "number_of_tags", "value": 10}
self.repo1 = create_repository(ORG1_NAME, REPO1_NAME, None)
set_repository_state(self.repo1, RepositoryState.NORMAL)
self.repo2 = create_repository(ORG2_NAME, REPO2_NAME, None)
set_repository_state(self.repo2, RepositoryState.NORMAL)
self.repo3 = create_repository(ORG3_NAME, REPO3_NAME, None)
set_repository_state(self.repo3, RepositoryState.NORMAL)
self.repository_policy3 = create_repository_autoprune_policy(
ORG3_NAME, REPO3_NAME, self.number_of_tags_policy, create_task=False
)
def test_repo_policy_creation_without_task(self):
# policy based on tag count
new_repo_policy1 = create_repository_autoprune_policy(
ORG1_NAME, REPO1_NAME, self.number_of_tags_policy, create_task=False
)
assert new_repo_policy1.namespace.id == self.org1.id
assert new_repo_policy1.repository.id == self.repo1.id
assert json.loads(new_repo_policy1.policy) == self.number_of_tags_policy
# policy based on tag creation date
create_date_policy = {"method": "creation_date", "value": "7d"}
new_repo_policy2 = create_repository_autoprune_policy(
ORG2_NAME, REPO2_NAME, create_date_policy, create_task=False
)
assert new_repo_policy2.namespace.id == self.org2.id
assert new_repo_policy2.repository.id == self.repo2.id
assert json.loads(new_repo_policy2.policy) == create_date_policy
def test_repo_policy_creation_with_task(self):
new_repo_policy = create_repository_autoprune_policy(
ORG1_NAME, REPO1_NAME, self.number_of_tags_policy, create_task=True
)
assert new_repo_policy.namespace.id == self.org1.id
assert new_repo_policy.repository.id == self.repo1.id
assert namespace_has_autoprune_task(self.org1.id) is True
def test_repo_policy_creation_with_incorrect_repo_name(self):
with pytest.raises(InvalidRepositoryException) as excerror:
create_repository_autoprune_policy(
ORG1_NAME, "nonexistentrepo", self.number_of_tags_policy, create_task=True
)
assert str(excerror.value) == "Repository does not exist: nonexistentrepo"
def test_repo_policy_creation_for_repo_with_policy(self):
with pytest.raises(RepositoryAutoPrunePolicyAlreadyExists) as excerror:
create_repository_autoprune_policy(
ORG3_NAME, REPO3_NAME, self.number_of_tags_policy, create_task=True
)
assert (
str(excerror.value)
== "Policy for this repository already exists, delete existing to create new policy"
)
def test_get_repo_policies_by_reponame(self):
repo_policies = get_repository_autoprune_policies_by_repo_name(ORG3_NAME, REPO3_NAME)
assert len(repo_policies) == 1
assert repo_policies[0]._db_row.namespace_id == self.org3.id
assert repo_policies[0].repository_id == self.repo3.id
repo2_policies = get_repository_autoprune_policies_by_repo_name(ORG2_NAME, REPO2_NAME)
assert len(repo2_policies) == 0
def test_get_repo_policies_by_namespace_id(self):
repo_policies = get_repository_autoprune_policies_by_namespace_id(self.org3.id)
assert len(repo_policies) == 1
assert repo_policies[0]._db_row.namespace_id == self.org3.id
assert repo_policies[0].repository_id == self.repo3.id
repo2_policies = get_repository_autoprune_policies_by_namespace_id(self.org2.id)
assert len(repo2_policies) == 0
def test_get_repo_policies_by_repo_id(self):
repo_policies = get_repository_autoprune_policies_by_repo_id(self.repo3.id)
assert len(repo_policies) == 1
assert repo_policies[0]._db_row.namespace_id == self.org3.id
assert repo_policies[0].repository_id == self.repo3.id
repo2_policies = get_repository_autoprune_policies_by_repo_id(self.repo2.id)
assert len(repo2_policies) == 0
def test_update_repo_policy(self):
new_policy_config = {"method": "number_of_tags", "value": 100}
updated = update_repository_autoprune_policy(
ORG3_NAME, REPO3_NAME, self.repository_policy3.uuid, new_policy_config
)
assert updated is True
repo_policies = get_repository_autoprune_policies_by_repo_name(ORG3_NAME, REPO3_NAME)
assert repo_policies[0].config == new_policy_config
def test_incorrect_update_repo_policy(self):
# incorrect uuid
with pytest.raises(RepositoryAutoPrunePolicyDoesNotExist) as excerror:
update_repository_autoprune_policy(ORG3_NAME, REPO3_NAME, "random-uuid", {})
assert (
str(excerror.value)
== f"Policy not found for repository: {REPO3_NAME} with uuid: random-uuid"
)
# incorrect reponame
with pytest.raises(InvalidRepositoryException) as excerror:
update_repository_autoprune_policy(
ORG3_NAME, "nonexistentrepo", self.repository_policy3.uuid, {}
)
assert str(excerror.value) == "Repository does not exist: nonexistentrepo"
def test_delete_repo_policy(self):
deleted = delete_repository_autoprune_policy(
ORG3_NAME, REPO3_NAME, self.repository_policy3.uuid
)
assert deleted is True
def test_incorrect_delete_repo_policy(self):
# incorrect uuid
with pytest.raises(RepositoryAutoPrunePolicyDoesNotExist) as excerror:
delete_repository_autoprune_policy(ORG3_NAME, REPO3_NAME, "random-uuid")
assert (
str(excerror.value)
== f"Policy not found for repository: {REPO3_NAME} with uuid: random-uuid"
)
# incorrect reponame
with pytest.raises(InvalidRepositoryException) as excerror:
delete_repository_autoprune_policy(
ORG3_NAME, "nonexistentrepo", self.repository_policy3.uuid
)
assert str(excerror.value) == "Repository does not exist: nonexistentrepo"
def test_repository_policy(self):
policy_exists = repository_has_autoprune_policy(self.repo3.id)
assert policy_exists is True
repository_policy = get_repository_autoprune_policy_by_uuid(
self.repo3.name, self.repository_policy3.uuid
)
assert repository_policy.uuid == self.repository_policy3.uuid
assert repository_policy.repository_id == self.repo3.id
resp = get_repository_autoprune_policy_by_uuid("nonexistentrepo", "randome-uuid")
assert resp is None

View File

@ -5,15 +5,21 @@ from flask import request
import features
from auth import scopes
from auth.auth_context import get_authenticated_user
from auth.permissions import AdministerOrganizationPermission
from auth.permissions import (
AdministerOrganizationPermission,
AdministerRepositoryPermission,
)
from data import model
from data.registry_model import registry_model
from endpoints.api import (
ApiResource,
RepositoryParamResource,
allow_if_superuser,
log_action,
nickname,
path_param,
request_error,
require_repo_admin,
require_scope,
require_user_admin,
resource,
@ -231,6 +237,233 @@ class OrgAutoPrunePolicy(ApiResource):
return {"uuid": policy_uuid}, 200
@resource("/v1/repository/<apirepopath:repository>/autoprunepolicy/")
@path_param("repository", "The full path of the repository. e.g. namespace/name")
@show_if(features.AUTO_PRUNE)
class RepositoryAutoPrunePolicies(RepositoryParamResource):
"""
Resource for listing and creating repository auto-prune policies
"""
schemas = {
"AutoPrunePolicyConfig": {
"type": "object",
"description": "The policy configuration that is to be applied to the repository",
"required": ["method", "value"],
"properties": {
"method": {
"type": "string",
"description": "The method to use for pruning tags (number_of_tags, creation_date)",
},
"value": {
"type": ["integer", "string"],
"description": "The value to use for the pruning method (number of tags e.g. 10, time delta e.g. 7d (7 days))",
},
},
},
}
@require_repo_admin(allow_for_superuser=True)
@nickname("listRepositoryAutoPrunePolicies")
def get(self, namespace, repository):
"""
Lists the auto-prune policies for the repository
"""
permission = AdministerRepositoryPermission(namespace, repository)
if not permission.can():
raise Unauthorized()
if registry_model.lookup_repository(namespace, repository) is None:
raise NotFound()
policies = model.autoprune.get_repository_autoprune_policies_by_repo_name(
namespace, repository
)
return {"policies": [policy.get_view() for policy in policies]}
@require_repo_admin(allow_for_superuser=True)
@validate_json_request("AutoPrunePolicyConfig")
@nickname("createRepositoryAutoPrunePolicy")
def post(self, namespace, repository):
"""
Creates an auto-prune policy for the repository
"""
permission = AdministerRepositoryPermission(namespace, repository)
if not permission.can():
raise Unauthorized()
if registry_model.lookup_repository(namespace, repository) is None:
raise NotFound()
app_data = request.get_json()
method = app_data.get("method", None)
value = app_data.get("value", None)
if method is None or value is None:
request_error(message="Missing the following parameters: method, value")
policy_config = {
"method": method,
"value": value,
}
try:
policy = model.autoprune.create_repository_autoprune_policy(
namespace, repository, policy_config, create_task=True
)
except model.InvalidNamespaceException:
raise NotFound()
except model.InvalidRepositoryException:
raise NotFound()
except model.InvalidRepositoryAutoPrunePolicy as ex:
request_error(ex)
except model.RepositoryAutoPrunePolicyAlreadyExists as ex:
request_error(ex)
log_action(
"create_repository_autoprune_policy",
namespace,
{
"method": policy_config["method"],
"value": policy_config["value"],
"namespace": namespace,
"repo": repository,
},
repo_name=repository,
)
return {"uuid": policy.uuid}, 201
@resource("/v1/repository/<apirepopath:repository>/autoprunepolicy/<policy_uuid>")
@path_param("repository", "The full path of the repository. e.g. namespace/name")
@path_param("policy_uuid", "The unique ID of the policy")
@show_if(features.AUTO_PRUNE)
class RepositoryAutoPrunePolicy(RepositoryParamResource):
"""
Resource for fetching, updating, and deleting repository specific auto-prune policies
"""
schemas = {
"AutoPrunePolicyConfig": {
"type": "object",
"description": "The policy configuration that is to be applied to the repository",
"required": ["method", "value"],
"properties": {
"method": {
"type": "string",
"description": "The method to use for pruning tags (number_of_tags, creation_date)",
},
"value": {
"type": ["integer", "string"],
"description": "The value to use for the pruning method (number of tags e.g. 10, time delta e.g. 7d (7 days))",
},
},
},
}
@require_repo_admin(allow_for_superuser=True)
@nickname("getRepositoryAutoPrunePolicy")
def get(self, namespace, repository, policy_uuid):
"""
Fetches the auto-prune policy for the repository
"""
permission = AdministerRepositoryPermission(namespace, repository)
if not permission.can():
raise Unauthorized()
policy = model.autoprune.get_repository_autoprune_policy_by_uuid(repository, policy_uuid)
if policy is None:
raise NotFound()
return policy.get_view()
@require_repo_admin(allow_for_superuser=True)
@validate_json_request("AutoPrunePolicyConfig")
@nickname("updateRepositoryAutoPrunePolicy")
def put(self, namespace, repository, policy_uuid):
"""
Updates the auto-prune policy for the repository
"""
permission = AdministerRepositoryPermission(namespace, repository)
if not permission.can():
raise Unauthorized()
app_data = request.get_json()
method = app_data.get("method", None)
value = app_data.get("value", None)
if method is None or value is None:
request_error(message="Missing the following parameters: method, value")
policy_config = {
"method": method,
"value": value,
}
try:
updated = model.autoprune.update_repository_autoprune_policy(
namespace, repository, policy_uuid, policy_config
)
if not updated:
request_error(message="could not update policy")
except model.InvalidNamespaceException:
raise NotFound()
except model.InvalidRepositoryException:
raise NotFound()
except model.InvalidRepositoryAutoPrunePolicy as ex:
request_error(ex)
except model.RepositoryAutoPrunePolicyDoesNotExist as ex:
raise NotFound()
log_action(
"update_repository_autoprune_policy",
namespace,
{
"method": policy_config["method"],
"value": policy_config["value"],
"namespace": namespace,
"repo": repository,
},
repo_name=repository,
)
return {"uuid": policy_uuid}, 204
@require_repo_admin(allow_for_superuser=True)
@nickname("deleteRepositoryAutoPrunePolicy")
def delete(self, namespace, repository, policy_uuid):
"""
Deletes the auto-prune policy for the repository
"""
permission = AdministerRepositoryPermission(namespace, repository)
if not permission.can():
raise Unauthorized()
try:
updated = model.autoprune.delete_repository_autoprune_policy(
namespace, repository, policy_uuid
)
if not updated:
raise InvalidRequest("could not delete policy")
except model.InvalidNamespaceException:
raise NotFound()
except model.InvalidRepositoryException as ex:
raise NotFound()
except model.RepositoryAutoPrunePolicyDoesNotExist as ex:
raise NotFound()
log_action(
"delete_repository_autoprune_policy",
namespace,
{"policy_uuid": policy_uuid, "namespace": namespace, "repo": repository},
repo_name=repository,
)
return {"uuid": policy_uuid}, 200
@resource("/v1/user/autoprunepolicy/")
@show_if(features.AUTO_PRUNE)
class UserAutoPrunePolicies(ApiResource):

View File

@ -7,6 +7,8 @@ from data.model.log import get_latest_logs_query, get_log_entry_kinds
from endpoints.api.policy import (
OrgAutoPrunePolicies,
OrgAutoPrunePolicy,
RepositoryAutoPrunePolicies,
RepositoryAutoPrunePolicy,
UserAutoPrunePolicies,
UserAutoPrunePolicy,
)
@ -345,3 +347,187 @@ def test_delete_user_policy_nonexistent_policy(initialized_db, app):
{"orgname": "devtable", "policy_uuid": "doesnotexist"},
expected_code=404,
)
def test_get_repo_policies(initialized_db, app):
with client_with_identity("devtable", app) as cl:
params = {"repository": "devtable/simple"}
response = conduct_api_call(cl, RepositoryAutoPrunePolicies, "GET", params).json
assert len(response["policies"]) == 1
assert response["policies"][0]["method"] == "number_of_tags"
assert response["policies"][0]["value"] == 10
def test_create_repo_policy(initialized_db, app):
with client_with_identity("devtable", app) as cl:
params = {"repository": "testorgforautoprune/autoprunerepo"}
response = conduct_api_call(
cl,
RepositoryAutoPrunePolicies,
"POST",
params,
{"method": "creation_date", "value": "2w"},
201,
).json
assert response["uuid"] is not None
assert (
model.autoprune.get_repository_autoprune_policy_by_uuid(
"autoprunerepo", response["uuid"]
)
is not None
)
org = model.organization.get_organization("testorgforautoprune")
assert model.autoprune.namespace_has_autoprune_task(org.id)
# Check audit log was created
logs = list(get_latest_logs_query(namespace="testorgforautoprune"))
log_kinds = get_log_entry_kinds()
log = None
for l in logs:
if l.kind == log_kinds["create_repository_autoprune_policy"]:
log = l
break
assert log is not None
assert json.loads(log.metadata_json)["method"] == "creation_date"
assert json.loads(log.metadata_json)["value"] == "2w"
assert json.loads(log.metadata_json)["namespace"] == "testorgforautoprune"
def test_create_repo_policy_already_existing(initialized_db, app):
with client_with_identity("devtable", app) as cl:
params = {"repository": "devtable/simple"}
response = conduct_api_call(
cl,
RepositoryAutoPrunePolicies,
"POST",
params,
{"method": "creation_date", "value": "2w"},
expected_code=400,
).json
assert (
response["error_message"]
== "Policy for this repository already exists, delete existing to create new policy"
)
def test_create_repo_policy_nonexistent_method(initialized_db, app):
with client_with_identity("devtable", app) as cl:
params = {"repository": "testorgforautoprune/autoprunerepo"}
response = conduct_api_call(
cl,
RepositoryAutoPrunePolicies,
"POST",
params,
{"method": "doesnotexist", "value": "2w"},
expected_code=400,
).json
assert response["error_message"] == "Invalid method provided"
def test_get_repo_policy(initialized_db, app):
policies = model.autoprune.get_repository_autoprune_policies_by_repo_name("devtable", "simple")
assert len(policies) == 1
policy_uuid = policies[0].uuid
with client_with_identity("devtable", app) as cl:
params = {"repository": "devtable/simple", "policy_uuid": policy_uuid}
response = conduct_api_call(cl, RepositoryAutoPrunePolicy, "GET", params).json
assert response["method"] == "number_of_tags"
assert response["value"] == 10
def test_update_repo_policy(initialized_db, app):
policies = model.autoprune.get_repository_autoprune_policies_by_repo_name("devtable", "simple")
assert len(policies) == 1
policy_uuid = policies[0].uuid
with client_with_identity("devtable", app) as cl:
params_for_update = {"repository": "devtable/simple", "policy_uuid": policy_uuid}
conduct_api_call(
cl,
RepositoryAutoPrunePolicy,
"PUT",
params_for_update,
{"method": "creation_date", "value": "2w"},
expected_code=204,
)
# Make another request asserting it was updated
params = {"repository": "devtable/simple", "policy_uuid": policy_uuid}
get_response = conduct_api_call(cl, RepositoryAutoPrunePolicy, "GET", params).json
assert get_response["method"] == "creation_date"
assert get_response["value"] == "2w"
# Check audit log was created
logs = list(get_latest_logs_query(namespace="devtable"))
log_kinds = get_log_entry_kinds()
log = None
for l in logs:
if l.kind == log_kinds["update_repository_autoprune_policy"]:
log = l
break
assert log is not None
assert json.loads(log.metadata_json)["method"] == "creation_date"
assert json.loads(log.metadata_json)["value"] == "2w"
assert json.loads(log.metadata_json)["namespace"] == "devtable"
def test_update_repo_policy_nonexistent_policy(initialized_db, app):
with client_with_identity("devtable", app) as cl:
params_for_update = {"repository": "devtable/simple", "policy_uuid": "doesnotexist"}
conduct_api_call(
cl,
RepositoryAutoPrunePolicy,
"PUT",
params_for_update,
{"method": "creation_date", "value": "2w"},
expected_code=404,
)
def test_delete_repo_policy(initialized_db, app):
policies = model.autoprune.get_repository_autoprune_policies_by_repo_name("devtable", "simple")
assert len(policies) == 1
policy_uuid = policies[0].uuid
with client_with_identity("devtable", app) as cl:
params_for_delete = {"repository": "devtable/simple", "policy_uuid": policy_uuid}
conduct_api_call(
cl,
RepositoryAutoPrunePolicy,
"DELETE",
params_for_delete,
expected_code=200,
)
params = {"repository": "devtable/simple", "policy_uuid": policy_uuid}
conduct_api_call(
cl,
RepositoryAutoPrunePolicy,
"GET",
params,
expected_code=404,
)
# Check audit log was created
logs = list(get_latest_logs_query(namespace="devtable"))
log_kinds = get_log_entry_kinds()
log = None
for l in logs:
if l.kind == log_kinds["delete_repository_autoprune_policy"]:
log = l
break
assert log is not None
assert json.loads(log.metadata_json)["policy_uuid"] == policy_uuid
assert json.loads(log.metadata_json)["namespace"] == "devtable"
def test_delete_repo_policy_nonexistent_policy(initialized_db, app):
with client_with_identity("devtable", app) as cl:
params_for_delete = {
"repository": "testorgforautoprune/autoprunerepo",
"policy_uuid": "doesnotexist",
}
conduct_api_call(
cl,
RepositoryAutoPrunePolicy,
"DELETE",
params_for_delete,
expected_code=404,
)

View File

@ -6270,6 +6270,182 @@ SECURITY_TESTS: List[
(UserAutoPrunePolicy, "DELETE", {"policy_uuid": "some_uuid"}, None, "devtable", 404),
(UserAutoPrunePolicy, "DELETE", {"policy_uuid": "some_uuid"}, None, "freshuser", 404),
(UserAutoPrunePolicy, "DELETE", {"policy_uuid": "some_uuid"}, None, "reader", 404),
(
RepositoryAutoPrunePolicies,
"GET",
{"repository": "testorgforautoprune/autoprunerepo"},
None,
None,
401,
),
(
RepositoryAutoPrunePolicies,
"GET",
{"repository": "testorgforautoprune/autoprunerepo"},
None,
"devtable",
200,
),
(
RepositoryAutoPrunePolicies,
"GET",
{"repository": "testorgforautoprune/unknown"},
None,
"devtable",
404,
),
(
RepositoryAutoPrunePolicies,
"GET",
{"repository": "testorgforautoprune/autoprunerepo"},
None,
"freshuser",
403,
),
(
RepositoryAutoPrunePolicies,
"GET",
{"repository": "testorgforautoprune/autoprunerepo"},
None,
"reader",
403,
),
(
RepositoryAutoPrunePolicies,
"POST",
{"repository": "testorgforautoprune/autoprunerepo"},
{"method": "number_of_tags", "value": 10},
None,
401,
),
(
RepositoryAutoPrunePolicies,
"POST",
{"repository": "testorgforautoprune/autoprunerepo"},
{"method": "creation_date", "value": "2w"},
"devtable",
201,
),
(
RepositoryAutoPrunePolicies,
"POST",
{"repository": "testorgforautoprune/unknown"},
{"method": "number_of_tags", "value": 10},
"devtable",
404,
),
(
RepositoryAutoPrunePolicies,
"POST",
{"repository": "testorgforautoprune/autoprunerepo"},
{"method": "number_of_tags", "value": 10},
"freshuser",
403,
),
(
RepositoryAutoPrunePolicies,
"POST",
{"repository": "testorgforautoprune/autoprunerepo"},
{"method": "number_of_tags", "value": 10},
"reader",
403,
),
(
RepositoryAutoPrunePolicy,
"GET",
{"repository": "testorgforautoprune/autoprunerepo", "policy_uuid": "some_uuid"},
None,
None,
401,
),
(
RepositoryAutoPrunePolicy,
"GET",
{"repository": "testorgforautoprune/unknown", "policy_uuid": "some_uuid"},
None,
"devtable",
404,
),
(
RepositoryAutoPrunePolicy,
"GET",
{"repository": "testorgforautoprune/autoprunerepo", "policy_uuid": "some_uuid"},
None,
"freshuser",
403,
),
(
RepositoryAutoPrunePolicy,
"GET",
{"repository": "testorgforautoprune/autoprunerepo", "policy_uuid": "some_uuid"},
None,
"reader",
403,
),
(
RepositoryAutoPrunePolicy,
"PUT",
{"repository": "testorgforautoprune/autoprunerepo", "policy_uuid": "some_uuid"},
{"method": "number_of_tags", "value": 10},
None,
401,
),
(
RepositoryAutoPrunePolicy,
"PUT",
{"repository": "testorgforautoprune/unknown", "policy_uuid": "some_uuid"},
{"method": "number_of_tags", "value": 10},
"devtable",
404,
),
(
RepositoryAutoPrunePolicy,
"PUT",
{"repository": "testorgforautoprune/autoprunerepo", "policy_uuid": "some_uuid"},
{"method": "number_of_tags", "value": 10},
"freshuser",
403,
),
(
RepositoryAutoPrunePolicy,
"PUT",
{"repository": "testorgforautoprune/autoprunerepo", "policy_uuid": "some_uuid"},
{"method": "number_of_tags", "value": 10},
"reader",
403,
),
(
RepositoryAutoPrunePolicy,
"DELETE",
{"repository": "testorgforautoprune/autoprunerepo", "policy_uuid": "some_uuid"},
None,
None,
401,
),
(
RepositoryAutoPrunePolicy,
"DELETE",
{"repository": "testorgforautoprune/unknown", "policy_uuid": "some_uuid"},
None,
"devtable",
404,
),
(
RepositoryAutoPrunePolicy,
"DELETE",
{"repository": "testorgforautoprune/autoprunerepo", "policy_uuid": "some_uuid"},
None,
"freshuser",
403,
),
(
RepositoryAutoPrunePolicy,
"DELETE",
{"repository": "testorgforautoprune/autoprunerepo", "policy_uuid": "some_uuid"},
None,
"reader",
403,
),
]

View File

@ -31,14 +31,14 @@ def test_list_all_users(disabled, app):
def test_list_all_orgs(app):
with client_with_identity("devtable", app) as cl:
result = conduct_api_call(cl, SuperUserOrganizationList, "GET", None, None, 200).json
assert len(result["organizations"]) == 6
assert len(result["organizations"]) == 7
def test_paginate_orgs(app):
with client_with_identity("devtable", app) as cl:
params = {"limit": 3}
params = {"limit": 4}
firstResult = conduct_api_call(cl, SuperUserOrganizationList, "GET", params, None, 200).json
assert len(firstResult["organizations"]) == 3
assert len(firstResult["organizations"]) == 4
assert firstResult["next_page"] is not None
params["next_page"] = firstResult["next_page"]
secondResult = conduct_api_call(
@ -50,13 +50,13 @@ def test_paginate_orgs(app):
def test_paginate_test_list_all_users(app):
with client_with_identity("devtable", app) as cl:
params = {"limit": 6}
params = {"limit": 7}
firstResult = conduct_api_call(cl, SuperUserList, "GET", params, None, 200).json
assert len(firstResult["users"]) == 6
assert len(firstResult["users"]) == 7
assert firstResult["next_page"] is not None
params["next_page"] = firstResult["next_page"]
secondResult = conduct_api_call(cl, SuperUserList, "GET", params, None, 200).json
assert len(secondResult["users"]) == 5
assert len(secondResult["users"]) == 4
assert secondResult.get("next_page", None) is None

View File

@ -70,7 +70,10 @@ from data.decorators import is_deprecated_model
from data.encryption import FieldEncrypter
from data.fields import Credential
from data.logs_model import logs_model
from data.model.autoprune import create_namespace_autoprune_policy
from data.model.autoprune import (
create_namespace_autoprune_policy,
create_repository_autoprune_policy,
)
from data.queue import WorkQueue
from data.registry_model import registry_model
from data.registry_model.datatypes import RepositoryReference
@ -464,6 +467,10 @@ def initialize_database():
LogEntryKind.create(name="update_namespace_autoprune_policy")
LogEntryKind.create(name="delete_namespace_autoprune_policy")
LogEntryKind.create(name="create_repository_autoprune_policy")
LogEntryKind.create(name="update_repository_autoprune_policy")
LogEntryKind.create(name="delete_repository_autoprune_policy")
ImageStorageLocation.create(name="local_eu")
ImageStorageLocation.create(name="local_us")
@ -922,6 +929,25 @@ def populate_database(minimal=False):
"buynlarge", {"method": "creation_date", "value": "5d"}, create_task=True
)
org_for_autoprune = model.organization.create_organization(
"testorgforautoprune", "autoprune@devtable.com", new_user_1
)
org_repo = __generate_repository(
org_for_autoprune,
"autoprunerepo",
"Repository owned by an org.",
False,
[],
(4, [], ["latest", "prod"]),
)
create_repository_autoprune_policy(
"devtable", simple_repo.name, {"method": "number_of_tags", "value": 10}, create_task=True
)
create_repository_autoprune_policy(
"public", publicrepo.name, {"method": "creation_date", "value": "5d"}, create_task=True
)
liborg = model.organization.create_organization(
"library", "quay+library@devtable.com", new_user_1
)

View File

@ -970,11 +970,11 @@ class TestDeleteNamespace(ApiTestCase):
def test_deletenamespaces(self):
self.login(ADMIN_ACCESS_USER)
# Try to first delete the user. Since they are the sole admin of three orgs, it should fail.
# Try to first delete the user. Since they are the sole admin of five orgs, it should fail.
with check_transitive_modifications():
self.deleteResponse(User, expected_code=400)
# Delete the three orgs, checking in between.
# Delete the five orgs, checking in between.
with check_transitive_modifications():
self.deleteEmptyResponse(
Organization, params=dict(orgname=ORGANIZATION), expected_code=204
@ -985,9 +985,14 @@ class TestDeleteNamespace(ApiTestCase):
)
self.deleteResponse(User, expected_code=400) # Should still fail.
self.deleteEmptyResponse(Organization, params=dict(orgname="titi"), expected_code=204)
self.deleteResponse(User, expected_code=400) # Should still fail.
self.deleteEmptyResponse(
Organization, params=dict(orgname="proxyorg"), expected_code=204
)
self.deleteResponse(User, expected_code=400) # Should still fail.
self.deleteEmptyResponse(
Organization, params=dict(orgname="testorgforautoprune"), expected_code=204
)
# Add some queue items for the user.
notification_queue.put([ADMIN_ACCESS_USER, "somerepo", "somename"], "{}")

View File

@ -0,0 +1,157 @@
/// <reference types="cypress" />
describe('Repository settings - Repository autoprune policies', () => {
beforeEach(() => {
cy.exec('npm run quay:seed');
cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`)
.then((response) => response.body.csrf_token)
.then((token) => {
cy.loginByCSRF(token);
});
cy.intercept('GET', '/config', {fixture: 'config.json'}).as('getConfig');
});
const attemptCreateTagNumberRepoPolicy = (cy) => {
cy.get('[data-testid="repository-auto-prune-method"]').select(
'By number of tags',
);
cy.get('input[aria-label="number of tags"]').should('have.value', '20');
cy.get('input[aria-label="number of tags"]').type('{end}{backspace}5');
cy.contains('Save').click();
};
const attemptCreateCreationDateRepoPolicy = (cy) => {
cy.get('[data-testid="repository-auto-prune-method"]').select(
'By age of tags',
);
cy.get('input[aria-label="tag creation date value"]').should(
'have.value',
'7',
);
cy.get('select[aria-label="tag creation date unit"]').contains('days');
cy.get('input[aria-label="tag creation date value"]').type(
'2{leftArrow}{backspace}',
);
cy.get('select[aria-label="tag creation date unit"]').select('weeks');
cy.contains('Save').click();
};
it('creates repo policy based on number of tags', () => {
cy.visit('/repository/projectquay/repo1?tab=settings');
cy.contains('Repository Auto-Prune Policies').click();
cy.get('[data-testid="repository-auto-prune-method"]').contains('None');
// Create policy
attemptCreateTagNumberRepoPolicy(cy);
cy.contains('Successfully created repository auto-prune policy');
cy.get('input[aria-label="number of tags"]').should('have.value', '25');
});
it('creates repo policy based on creation date', () => {
cy.visit('/repository/projectquay/repo1?tab=settings');
cy.contains('Repository Auto-Prune Policies').click();
cy.get('[data-testid="repository-auto-prune-method"]').contains('None');
// Create policy
attemptCreateCreationDateRepoPolicy(cy);
cy.contains('Successfully created repository auto-prune policy');
cy.get('input[aria-label="tag creation date value"]').should(
'have.value',
'2',
);
cy.get('select[aria-label="tag creation date unit"]').contains('weeks');
});
it('updates repo policy', () => {
cy.visit('/repository/projectquay/repo1?tab=settings');
cy.contains('Repository Auto-Prune Policies').click();
cy.get('[data-testid="repository-auto-prune-method"]').contains('None');
// Create initial policy
attemptCreateTagNumberRepoPolicy(cy);
cy.contains('Successfully created repository auto-prune policy');
cy.get('input[aria-label="number of tags"]').should('have.value', '25');
// Update policy
attemptCreateCreationDateRepoPolicy(cy);
cy.contains('Successfully updated repository auto-prune policy');
cy.get('input[aria-label="tag creation date value"]').should(
'have.value',
'2',
);
cy.get('select[aria-label="tag creation date unit"]').contains('weeks');
});
it('deletes repo policy', () => {
cy.visit('/repository/projectquay/repo1?tab=settings');
cy.contains('Repository Auto-Prune Policies').click();
cy.get('[data-testid="repository-auto-prune-method"]').contains('None');
// Create initial policy
attemptCreateTagNumberRepoPolicy(cy);
cy.contains('Successfully created repository auto-prune policy');
cy.get('input[aria-label="number of tags"]').should('have.value', '25');
// Delete policy
cy.get('[data-testid="repository-auto-prune-method"]').select('None');
cy.contains('Save').click();
cy.contains('Successfully deleted repository auto-prune policy');
});
it('displays error when failing to load repo policy', () => {
cy.intercept('GET', '**/autoprunepolicy/**', {statusCode: 500}).as(
'getServerFailure',
);
cy.visit('/repository/projectquay/repo1?tab=settings');
cy.contains('Repository Auto-Prune Policies').click();
cy.contains('Unable to complete request');
cy.contains('AxiosError: Request failed with status code 500');
});
it('displays error when failing to create repo policy', () => {
cy.intercept('POST', '**/autoprunepolicy/**', {statusCode: 500}).as(
'getServerFailure',
);
cy.visit('/repository/projectquay/repo1?tab=settings');
cy.contains('Repository Auto-Prune Policies').click();
attemptCreateTagNumberRepoPolicy(cy);
cy.contains('Could not create repository auto-prune policy');
cy.get('button[aria-label="Danger alert details"]').click();
cy.contains('AxiosError: Request failed with status code 500');
});
it('displays error when failing to update repo policy', () => {
cy.intercept('PUT', '**/autoprunepolicy/**', {statusCode: 500}).as(
'getServerFailure',
);
cy.visit('/repository/projectquay/repo1?tab=settings');
cy.contains('Repository Auto-Prune Policies').click();
cy.get('[data-testid="repository-auto-prune-method"]').contains('None');
attemptCreateTagNumberRepoPolicy(cy);
attemptCreateCreationDateRepoPolicy(cy);
cy.contains('Could not update repository auto-prune policy');
cy.get('button[aria-label="Danger alert details"]').click();
cy.contains('AxiosError: Request failed with status code 500');
});
it('displays error when failing to delete repo policy', () => {
cy.intercept('DELETE', '**/autoprunepolicy/**', {statusCode: 500}).as(
'getServerFailure',
);
cy.visit('/repository/projectquay/repo1?tab=settings');
cy.contains('Repository Auto-Prune Policies').click();
cy.get('[data-testid="repository-auto-prune-method"]').contains('None');
attemptCreateTagNumberRepoPolicy(cy);
cy.contains('Successfully created repository auto-prune policy');
cy.get('input[aria-label="number of tags"]').should('have.value', '25');
cy.get('[data-testid="repository-auto-prune-method"]').select('None');
cy.contains('Save').click();
cy.contains('Could not delete repository auto-prune policy');
cy.get('button[aria-label="Danger alert details"]').click();
cy.contains('AxiosError: Request failed with status code 500');
});
});

View File

@ -60,6 +60,8 @@ ALTER TABLE IF EXISTS ONLY public.repositorybuild DROP CONSTRAINT IF EXISTS fk_r
ALTER TABLE IF EXISTS ONLY public.repositorybuild DROP CONSTRAINT IF EXISTS fk_repositorybuild_repository_id_repository;
ALTER TABLE IF EXISTS ONLY public.repositorybuild DROP CONSTRAINT IF EXISTS fk_repositorybuild_pull_robot_id_user;
ALTER TABLE IF EXISTS ONLY public.repositorybuild DROP CONSTRAINT IF EXISTS fk_repositorybuild_access_token_id_accesstoken;
ALTER TABLE IF EXISTS ONLY public.repositoryautoprunepolicy DROP CONSTRAINT IF EXISTS fk_repositoryautoprunepolicy_repository_id_repository;
ALTER TABLE IF EXISTS ONLY public.repositoryautoprunepolicy DROP CONSTRAINT IF EXISTS fk_repositoryautoprunepolicy_namespace_id_user;
ALTER TABLE IF EXISTS ONLY public.repositoryauthorizedemail DROP CONSTRAINT IF EXISTS fk_repositoryauthorizedemail_repository_id_repository;
ALTER TABLE IF EXISTS ONLY public.repositoryactioncount DROP CONSTRAINT IF EXISTS fk_repositoryactioncount_repository_id_repository;
ALTER TABLE IF EXISTS ONLY public.repository DROP CONSTRAINT IF EXISTS fk_repository_visibility_id_visibility;
@ -259,6 +261,9 @@ DROP INDEX IF EXISTS public.repositorybuild_queue_id;
DROP INDEX IF EXISTS public.repositorybuild_pull_robot_id;
DROP INDEX IF EXISTS public.repositorybuild_logs_archived;
DROP INDEX IF EXISTS public.repositorybuild_access_token_id;
DROP INDEX IF EXISTS public.repositoryautoprunepolicy_uuid;
DROP INDEX IF EXISTS public.repositoryautoprunepolicy_repository_id;
DROP INDEX IF EXISTS public.repositoryautoprunepolicy_namespace_id;
DROP INDEX IF EXISTS public.repositoryauthorizedemail_repository_id;
DROP INDEX IF EXISTS public.repositoryauthorizedemail_email_repository_id;
DROP INDEX IF EXISTS public.repositoryauthorizedemail_code;
@ -512,6 +517,7 @@ ALTER TABLE IF EXISTS ONLY public.repositorynotification DROP CONSTRAINT IF EXIS
ALTER TABLE IF EXISTS ONLY public.repositorykind DROP CONSTRAINT IF EXISTS pk_repositorykind;
ALTER TABLE IF EXISTS ONLY public.repositorybuildtrigger DROP CONSTRAINT IF EXISTS pk_repositorybuildtrigger;
ALTER TABLE IF EXISTS ONLY public.repositorybuild DROP CONSTRAINT IF EXISTS pk_repositorybuild;
ALTER TABLE IF EXISTS ONLY public.repositoryautoprunepolicy DROP CONSTRAINT IF EXISTS pk_repositoryautoprunepolicyid;
ALTER TABLE IF EXISTS ONLY public.repositoryauthorizedemail DROP CONSTRAINT IF EXISTS pk_repositoryauthorizedemail;
ALTER TABLE IF EXISTS ONLY public.repositoryactioncount DROP CONSTRAINT IF EXISTS pk_repositoryactioncount;
ALTER TABLE IF EXISTS ONLY public.repository DROP CONSTRAINT IF EXISTS pk_repository;
@ -617,6 +623,7 @@ ALTER TABLE IF EXISTS public.repositorynotification ALTER COLUMN id DROP DEFAULT
ALTER TABLE IF EXISTS public.repositorykind ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.repositorybuildtrigger ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.repositorybuild ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.repositoryautoprunepolicy ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.repositoryauthorizedemail ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.repositoryactioncount ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.repository ALTER COLUMN id DROP DEFAULT;
@ -755,6 +762,8 @@ DROP SEQUENCE IF EXISTS public.repositorybuildtrigger_id_seq;
DROP TABLE IF EXISTS public.repositorybuildtrigger;
DROP SEQUENCE IF EXISTS public.repositorybuild_id_seq;
DROP TABLE IF EXISTS public.repositorybuild;
DROP SEQUENCE IF EXISTS public.repositoryautoprunepolicy_id_seq;
DROP TABLE IF EXISTS public.repositoryautoprunepolicy;
DROP SEQUENCE IF EXISTS public.repositoryauthorizedemail_id_seq;
DROP TABLE IF EXISTS public.repositoryauthorizedemail;
DROP SEQUENCE IF EXISTS public.repositoryactioncount_id_seq;
@ -3542,6 +3551,43 @@ ALTER TABLE public.repositoryauthorizedemail_id_seq OWNER TO quay;
ALTER SEQUENCE public.repositoryauthorizedemail_id_seq OWNED BY public.repositoryauthorizedemail.id;
--
-- Name: repositoryautoprunepolicy; Type: TABLE; Schema: public; Owner: quay
--
CREATE TABLE public.repositoryautoprunepolicy (
id integer NOT NULL,
uuid character varying(36) NOT NULL,
repository_id integer NOT NULL,
namespace_id integer NOT NULL,
policy text NOT NULL
);
ALTER TABLE public.repositoryautoprunepolicy OWNER TO quay;
--
-- Name: repositoryautoprunepolicy_id_seq; Type: SEQUENCE; Schema: public; Owner: quay
--
CREATE SEQUENCE public.repositoryautoprunepolicy_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.repositoryautoprunepolicy_id_seq OWNER TO quay;
--
-- Name: repositoryautoprunepolicy_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quay
--
ALTER SEQUENCE public.repositoryautoprunepolicy_id_seq OWNED BY public.repositoryautoprunepolicy.id;
--
-- Name: repositorybuild; Type: TABLE; Schema: public; Owner: quay
--
@ -5309,6 +5355,13 @@ ALTER TABLE ONLY public.repositoryactioncount ALTER COLUMN id SET DEFAULT nextva
ALTER TABLE ONLY public.repositoryauthorizedemail ALTER COLUMN id SET DEFAULT nextval('public.repositoryauthorizedemail_id_seq'::regclass);
--
-- Name: repositoryautoprunepolicy id; Type: DEFAULT; Schema: public; Owner: quay
--
ALTER TABLE ONLY public.repositoryautoprunepolicy ALTER COLUMN id SET DEFAULT nextval('public.repositoryautoprunepolicy_id_seq'::regclass);
--
-- Name: repositorybuild id; Type: DEFAULT; Schema: public; Owner: quay
--
@ -5570,7 +5623,7 @@ COPY public.accesstokenkind (id, name) FROM stdin;
--
COPY public.alembic_version (version_num) FROM stdin;
41d15c93c299
b4da5b09c8df
\.
@ -6242,6 +6295,9 @@ COPY public.logentrykind (id, name) FROM stdin;
105 push_repo_failed
106 pull_repo_failed
107 delete_tag_failed
108 create_repository_autoprune_policy
109 update_repository_autoprune_policy
110 delete_repository_autoprune_policy
\.
@ -6518,8 +6574,8 @@ COPY public.quayservice (id, name) FROM stdin;
--
COPY public.queueitem (id, queue_name, body, available_after, available, processing_expires, retries_remaining, state_id) FROM stdin;
1 namespacegc/2/ {"marker_id": 1, "original_username": "quay"} 2023-11-07 19:57:36.102126 t 2023-11-07 22:52:36.037751 5 e90498e4-d92b-487a-90a3-1ab34388af7c
2 namespacegc/3/ {"marker_id": 2, "original_username": "clair"} 2023-11-07 19:57:37.766105 t 2023-11-07 22:52:37.740851 5 9ccfcc76-8277-4dc0-8a14-b37440753124
2 namespacegc/3/ {"marker_id": 2, "original_username": "clair"} 2024-02-20 14:01:29.891875 t 2024-02-20 16:56:29.836048 5 ff6a0b72-d8cc-4efe-ad83-70225ac325e1
1 namespacegc/2/ {"marker_id": 1, "original_username": "quay"} 2024-02-20 14:01:34.91615 t 2024-02-20 16:56:34.901017 5 6e996e00-e92b-45a9-962f-7e7f3cdb9e63
\.
@ -6928,6 +6984,14 @@ COPY public.repositoryauthorizedemail (id, repository_id, email, code, confirmed
\.
--
-- Data for Name: repositoryautoprunepolicy; Type: TABLE DATA; Schema: public; Owner: quay
--
COPY public.repositoryautoprunepolicy (id, uuid, repository_id, namespace_id, policy) FROM stdin;
\.
--
-- Data for Name: repositorybuild; Type: TABLE DATA; Schema: public; Owner: quay
--
@ -8084,7 +8148,7 @@ SELECT pg_catalog.setval('public.logentry_id_seq', 1, false);
-- Name: logentrykind_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.logentrykind_id_seq', 107, true);
SELECT pg_catalog.setval('public.logentrykind_id_seq', 110, true);
--
@ -8168,7 +8232,7 @@ SELECT pg_catalog.setval('public.namespacegeorestriction_id_seq', 1, false);
-- Name: notification_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.notification_id_seq', 1, false);
SELECT pg_catalog.setval('public.notification_id_seq', 1, true);
--
@ -8325,6 +8389,13 @@ SELECT pg_catalog.setval('public.repositoryactioncount_id_seq', 157, true);
SELECT pg_catalog.setval('public.repositoryauthorizedemail_id_seq', 1, true);
--
-- Name: repositoryautoprunepolicy_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.repositoryautoprunepolicy_id_seq', 1, false);
--
-- Name: repositorybuild_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
@ -8406,14 +8477,14 @@ SELECT pg_catalog.setval('public.role_id_seq', 3, true);
-- Name: servicekey_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.servicekey_id_seq', 1, false);
SELECT pg_catalog.setval('public.servicekey_id_seq', 1, true);
--
-- Name: servicekeyapproval_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.servicekeyapproval_id_seq', 1, false);
SELECT pg_catalog.setval('public.servicekeyapproval_id_seq', 1, true);
--
@ -9131,6 +9202,14 @@ ALTER TABLE ONLY public.repositoryauthorizedemail
ADD CONSTRAINT pk_repositoryauthorizedemail PRIMARY KEY (id);
--
-- Name: repositoryautoprunepolicy pk_repositoryautoprunepolicyid; Type: CONSTRAINT; Schema: public; Owner: quay
--
ALTER TABLE ONLY public.repositoryautoprunepolicy
ADD CONSTRAINT pk_repositoryautoprunepolicyid PRIMARY KEY (id);
--
-- Name: repositorybuild pk_repositorybuild; Type: CONSTRAINT; Schema: public; Owner: quay
--
@ -10936,6 +11015,27 @@ CREATE UNIQUE INDEX repositoryauthorizedemail_email_repository_id ON public.repo
CREATE INDEX repositoryauthorizedemail_repository_id ON public.repositoryauthorizedemail USING btree (repository_id);
--
-- Name: repositoryautoprunepolicy_namespace_id; Type: INDEX; Schema: public; Owner: quay
--
CREATE UNIQUE INDEX repositoryautoprunepolicy_namespace_id ON public.repositoryautoprunepolicy USING btree (namespace_id);
--
-- Name: repositoryautoprunepolicy_repository_id; Type: INDEX; Schema: public; Owner: quay
--
CREATE UNIQUE INDEX repositoryautoprunepolicy_repository_id ON public.repositoryautoprunepolicy USING btree (repository_id);
--
-- Name: repositoryautoprunepolicy_uuid; Type: INDEX; Schema: public; Owner: quay
--
CREATE UNIQUE INDEX repositoryautoprunepolicy_uuid ON public.repositoryautoprunepolicy USING btree (uuid);
--
-- Name: repositorybuild_access_token_id; Type: INDEX; Schema: public; Owner: quay
--
@ -12411,6 +12511,22 @@ ALTER TABLE ONLY public.repositoryauthorizedemail
ADD CONSTRAINT fk_repositoryauthorizedemail_repository_id_repository FOREIGN KEY (repository_id) REFERENCES public.repository(id);
--
-- Name: repositoryautoprunepolicy fk_repositoryautoprunepolicy_namespace_id_user; Type: FK CONSTRAINT; Schema: public; Owner: quay
--
ALTER TABLE ONLY public.repositoryautoprunepolicy
ADD CONSTRAINT fk_repositoryautoprunepolicy_namespace_id_user FOREIGN KEY (namespace_id) REFERENCES public."user"(id);
--
-- Name: repositoryautoprunepolicy fk_repositoryautoprunepolicy_repository_id_repository; Type: FK CONSTRAINT; Schema: public; Owner: quay
--
ALTER TABLE ONLY public.repositoryautoprunepolicy
ADD CONSTRAINT fk_repositoryautoprunepolicy_repository_id_repository FOREIGN KEY (repository_id) REFERENCES public.repository(id);
--
-- Name: repositorybuild fk_repositorybuild_access_token_id_accesstoken; Type: FK CONSTRAINT; Schema: public; Owner: quay
--

View File

@ -10,15 +10,20 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
export function useNamespaceAutoPrunePolicies(
namespace: string,
isUser: boolean,
isEnabled: boolean = true,
) {
const {
data: policies,
data: nsPolicies,
isLoading,
error,
isSuccess,
dataUpdatedAt,
} = useQuery(['namespace', 'autoprunepolicies', namespace], ({signal}) =>
fetchNamespaceAutoPrunePolicies(namespace, isUser, signal),
} = useQuery(
['namespace', 'autoprunepolicies', namespace],
({signal}) => fetchNamespaceAutoPrunePolicies(namespace, isUser, signal),
{
enabled: isEnabled,
},
);
return {
@ -26,7 +31,7 @@ export function useNamespaceAutoPrunePolicies(
isSuccess,
isLoading,
dataUpdatedAt,
policies,
nsPolicies,
};
}

View File

@ -0,0 +1,129 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {
RepositoryAutoPrunePolicy,
createRepositoryAutoPrunePolicy,
deleteRepositoryAutoPrunePolicy,
fetchRepositoryAutoPrunePolicies,
updateRepositoryAutoPrunePolicy,
} from 'src/resources/RepositoryAutoPruneResource';
export function useFetchRepositoryAutoPrunePolicies(
organizationName: string,
repoName: string,
) {
const {
data: repoPolicies,
isLoading: isLoadingRepoPolicies,
error: errorFetchingRepoPolicies,
isSuccess: successFetchingRepoPolicies,
dataUpdatedAt: repoPoliciesDataUpdatedAt,
} = useQuery(
['repositoryautoprunepolicies', organizationName, repoName],
({signal}) =>
fetchRepositoryAutoPrunePolicies(organizationName, repoName, signal),
);
return {
errorFetchingRepoPolicies,
successFetchingRepoPolicies,
isLoadingRepoPolicies,
repoPoliciesDataUpdatedAt,
repoPolicies,
};
}
export function useCreateRepositoryAutoPrunePolicy(
organizationName: string,
repoName: string,
) {
const queryClient = useQueryClient();
const {
mutate: createRepoPolicy,
isSuccess: successRepoPolicyCreation,
isError: errorRepoPolicyCreation,
error: errorDetailsRepoPolicyCreation,
} = useMutation(
async (policy: RepositoryAutoPrunePolicy) =>
createRepositoryAutoPrunePolicy(organizationName, repoName, policy),
{
onSuccess: () => {
queryClient.invalidateQueries([
'repositoryautoprunepolicies',
organizationName,
repoName,
]);
},
},
);
return {
createRepoPolicy,
successRepoPolicyCreation,
errorRepoPolicyCreation,
errorDetailsRepoPolicyCreation,
};
}
export function useUpdateRepositoryAutoPrunePolicy(
organizationName: string,
repoName: string,
) {
const queryClient = useQueryClient();
const {
mutate: updateRepoPolicy,
isSuccess: successRepoPolicyUpdation,
isError: errorRepoPolicyUpdation,
error: errorDetailsRepoPolicyUpdation,
} = useMutation(
async (policy: RepositoryAutoPrunePolicy) =>
updateRepositoryAutoPrunePolicy(organizationName, repoName, policy),
{
onSuccess: () => {
queryClient.invalidateQueries([
'repositoryautoprunepolicies',
organizationName,
repoName,
]);
},
},
);
return {
updateRepoPolicy,
successRepoPolicyUpdation,
errorRepoPolicyUpdation,
errorDetailsRepoPolicyUpdation,
};
}
export function useDeleteRepositoryAutoPrunePolicy(
organizationName: string,
repoName: string,
) {
const queryClient = useQueryClient();
const {
mutate: deleteRepoPolicy,
isSuccess: successRepoPolicyDeletion,
isError: errorRepoPolicyDeletion,
error: errorDetailsRepoPolicyDeletion,
} = useMutation(
async (uuid: string) =>
deleteRepositoryAutoPrunePolicy(organizationName, repoName, uuid),
{
onSuccess: () => {
queryClient.invalidateQueries([
'repositoryautoprunepolicies',
organizationName,
repoName,
]);
},
},
);
return {
deleteRepoPolicy,
successRepoPolicyDeletion,
errorRepoPolicyDeletion,
errorDetailsRepoPolicyDeletion,
};
}

View File

@ -0,0 +1,54 @@
import {AxiosResponse} from 'axios';
import axios from 'src/libs/axios';
import {assertHttpCode} from './ErrorHandling';
import {AutoPruneMethod} from './NamespaceAutoPruneResource';
export interface RepositoryAutoPrunePolicy {
method: AutoPruneMethod;
uuid?: string;
value?: string | number;
}
export async function fetchRepositoryAutoPrunePolicies(
organizationName: string,
repoName: string,
signal: AbortSignal,
) {
const repositoryAutoPruneUrl = `/api/v1/repository/${organizationName}/${repoName}/autoprunepolicy/`;
const response: AxiosResponse = await axios.get(repositoryAutoPruneUrl, {
signal,
});
assertHttpCode(response.status, 200);
const res = response.data.policies as RepositoryAutoPrunePolicy[];
return res;
}
export async function createRepositoryAutoPrunePolicy(
organizationName: string,
repoName: string,
policy: RepositoryAutoPrunePolicy,
) {
const repositoryAutoPruneUrl = `/api/v1/repository/${organizationName}/${repoName}/autoprunepolicy/`;
const response = await axios.post(repositoryAutoPruneUrl, policy);
assertHttpCode(response.status, 201);
}
export async function updateRepositoryAutoPrunePolicy(
organizationName: string,
repoName: string,
policy: RepositoryAutoPrunePolicy,
) {
const repositoryAutoPruneUrl = `/api/v1/repository/${organizationName}/${repoName}/autoprunepolicy/${policy.uuid}`;
const response = await axios.put(repositoryAutoPruneUrl, policy);
assertHttpCode(response.status, 204);
}
export async function deleteRepositoryAutoPrunePolicy(
organizationName: string,
repoName: string,
uuid: string,
) {
const repositoryAutoPruneUrl = `/api/v1/repository/${organizationName}/${repoName}/autoprunepolicy/${uuid}`;
const response = await axios.delete(repositoryAutoPruneUrl);
assertHttpCode(response.status, 200);
}

View File

@ -23,7 +23,6 @@ import Settings from './Tabs/Settings/Settings';
import TeamsAndMembershipList from './Tabs/TeamsAndMembership/TeamsAndMembershipList';
import AddNewTeamMemberDrawer from './Tabs/TeamsAndMembership/TeamsView/ManageMembers/AddNewTeamMemberDrawer';
import ManageMembersList from './Tabs/TeamsAndMembership/TeamsView/ManageMembers/ManageMembersList';
import UsageLogs from 'src/routes/UsageLogs/UsageLogs';
export enum OrganizationDrawerContentType {
None,

View File

@ -50,7 +50,7 @@ export default function AutoPruning(props: AutoPruning) {
error,
isSuccess: successFetchingPolicies,
isLoading,
policies,
nsPolicies,
dataUpdatedAt,
} = useNamespaceAutoPrunePolicies(props.org, props.isUser);
const {
@ -76,8 +76,8 @@ export default function AutoPruning(props: AutoPruning) {
if (successFetchingPolicies) {
// Currently we only support one policy per namespace but
// this will change in the future.
if (policies.length > 0) {
const policy: NamespaceAutoPrunePolicy = policies[0];
if (nsPolicies.length > 0) {
const policy: NamespaceAutoPrunePolicy = nsPolicies[0];
setMethod(policy.method);
setUuid(policy.uuid);
switch (policy.method) {

View File

@ -0,0 +1,426 @@
import {
ActionGroup,
Button,
Flex,
Form,
FormGroup,
FormSelect,
FormSelectOption,
NumberInput,
Spinner,
Title,
FormHelperText,
HelperText,
HelperTextItem,
DataList,
DataListItem,
DataListItemRow,
DataListItemCells,
DataListCell,
Gallery,
} from '@patternfly/react-core';
import {useEffect, useState} from 'react';
import {AlertVariant} from 'src/atoms/AlertState';
import Conditional from 'src/components/empty/Conditional';
import RequestError from 'src/components/errors/RequestError';
import {useAlerts} from 'src/hooks/UseAlerts';
import {useNamespaceAutoPrunePolicies} from 'src/hooks/UseNamespaceAutoPrunePolicies';
import {useOrganization} from 'src/hooks/UseOrganization';
import {
useCreateRepositoryAutoPrunePolicy,
useDeleteRepositoryAutoPrunePolicy,
useFetchRepositoryAutoPrunePolicies,
useUpdateRepositoryAutoPrunePolicy,
} from 'src/hooks/UseRepositoryAutoPrunePolicies';
import {isNullOrUndefined} from 'src/libs/utils';
import {AutoPruneMethod} from 'src/resources/NamespaceAutoPruneResource';
import {RepositoryAutoPrunePolicy} from 'src/resources/RepositoryAutoPruneResource';
import {shorthandTimeUnits} from 'src/routes/OrganizationsList/Organization/Tabs/Settings/AutoPruning';
export default function RepositoryAutoPruning(props: RepositoryAutoPruning) {
const [uuid, setUuid] = useState<string>(null);
const [method, setMethod] = useState<AutoPruneMethod>(AutoPruneMethod.NONE);
const [tagCount, setTagCount] = useState<number>(20);
const [tagCreationDateUnit, setTagCreationDateUnit] = useState<string>('d');
const [tagCreationDateValue, setTagCreationDateValue] = useState<number>(7);
const {addAlert} = useAlerts();
const {organization} = useOrganization(props.organizationName);
const {
error,
isSuccess: successFetchingPolicies,
isLoading,
nsPolicies,
dataUpdatedAt,
} = useNamespaceAutoPrunePolicies(
props.organizationName,
props.isUser,
organization?.is_org_admin || false,
);
const {
errorFetchingRepoPolicies,
successFetchingRepoPolicies,
isLoadingRepoPolicies,
repoPolicies,
repoPoliciesDataUpdatedAt,
} = useFetchRepositoryAutoPrunePolicies(
props.organizationName,
props.repoName,
);
const {
createRepoPolicy,
successRepoPolicyCreation,
errorRepoPolicyCreation,
errorDetailsRepoPolicyCreation,
} = useCreateRepositoryAutoPrunePolicy(
props.organizationName,
props.repoName,
);
const {
updateRepoPolicy,
successRepoPolicyUpdation,
errorRepoPolicyUpdation,
errorDetailsRepoPolicyUpdation,
} = useUpdateRepositoryAutoPrunePolicy(
props.organizationName,
props.repoName,
);
const {
deleteRepoPolicy,
successRepoPolicyDeletion,
errorRepoPolicyDeletion,
errorDetailsRepoPolicyDeletion,
} = useDeleteRepositoryAutoPrunePolicy(
props.organizationName,
props.repoName,
);
useEffect(() => {
if (successFetchingRepoPolicies) {
// Currently we only support one policy per repository but
// this will change in the future.
if (repoPolicies.length > 0) {
const policy: RepositoryAutoPrunePolicy = repoPolicies[0];
setMethod(policy.method);
setUuid(policy.uuid);
switch (policy.method) {
case AutoPruneMethod.TAG_NUMBER: {
setTagCount(policy.value as number);
break;
}
case AutoPruneMethod.TAG_CREATION_DATE: {
const tagAgeValue = (policy.value as string).match(/\d+/g);
const tagAgeUnit = (policy.value as string).match(/[a-zA-Z]+/g);
if (tagAgeValue.length > 0 && tagAgeUnit.length > 0) {
setTagCreationDateValue(Number(tagAgeValue[0]));
setTagCreationDateUnit(tagAgeUnit[0]);
} else {
console.error('Invalid tag age value');
}
break;
}
}
} else {
// If no policy was returned it's possible this was
// after the deletion of the policy, in which all the state
// has to be reset
setUuid(null);
setMethod(AutoPruneMethod.NONE);
setTagCount(20);
setTagCreationDateUnit('d');
setTagCreationDateValue(7);
}
}
}, [
successFetchingRepoPolicies,
successFetchingPolicies,
repoPoliciesDataUpdatedAt,
]);
useEffect(() => {
if (successRepoPolicyCreation) {
addAlert({
title: 'Successfully created repository auto-prune policy',
variant: AlertVariant.Success,
});
}
}, [successRepoPolicyCreation]);
useEffect(() => {
if (successRepoPolicyUpdation) {
addAlert({
title: 'Successfully updated repository auto-prune policy',
variant: AlertVariant.Success,
});
}
}, [successRepoPolicyUpdation]);
useEffect(() => {
if (successRepoPolicyDeletion) {
addAlert({
title: 'Successfully deleted repository auto-prune policy',
variant: AlertVariant.Success,
});
}
}, [successRepoPolicyDeletion]);
useEffect(() => {
if (errorRepoPolicyCreation) {
addAlert({
title: 'Could not create repository auto-prune policy',
variant: AlertVariant.Failure,
message: errorDetailsRepoPolicyCreation.toString(),
});
}
}, [errorRepoPolicyCreation]);
useEffect(() => {
if (errorRepoPolicyUpdation) {
addAlert({
title: 'Could not update repository auto-prune policy',
variant: AlertVariant.Failure,
message: errorDetailsRepoPolicyUpdation.toString(),
});
}
}, [errorRepoPolicyUpdation]);
useEffect(() => {
if (errorRepoPolicyDeletion) {
addAlert({
title: 'Could not delete repository auto-prune policy',
variant: AlertVariant.Failure,
message: errorDetailsRepoPolicyDeletion.toString(),
});
}
}, [errorRepoPolicyDeletion]);
const onSave = (e) => {
e.preventDefault();
let value = null;
switch (method) {
case AutoPruneMethod.TAG_NUMBER:
value = tagCount;
break;
case AutoPruneMethod.TAG_CREATION_DATE:
value = `${String(tagCreationDateValue)}${tagCreationDateUnit}`;
break;
case AutoPruneMethod.NONE:
// Delete the policy is done by setting the method to none
if (!isNullOrUndefined(uuid)) {
deleteRepoPolicy(uuid);
}
return;
default:
// Reaching here indicates programming error, component should always be aware of valid methods
return;
}
if (isNullOrUndefined(uuid)) {
createRepoPolicy({method: method, value: value});
} else {
updateRepoPolicy({uuid: uuid, method: method, value: value});
}
};
if (isLoadingRepoPolicies) {
return <Spinner />;
}
if (!isNullOrUndefined(errorFetchingRepoPolicies)) {
return <RequestError message={errorFetchingRepoPolicies.toString()} />;
}
return (
<>
<Conditional if={nsPolicies !== null && nsPolicies !== undefined}>
<Title headingLevel="h2" style={{paddingBottom: '.5em'}}>
Namespace Auto-Pruning Policies
</Title>
<Gallery>
<DataList
className="pf-v5-u-mb-lg"
aria-label="Simple data list example"
isCompact
>
<DataListItem aria-labelledby="simple-item1">
<DataListItemRow>
<DataListItemCells
dataListCells={
nsPolicies
? [
<DataListCell key="policy-method">
<span id="simple-item1">
{nsPolicies[0]?.method}:
</span>
</DataListCell>,
<DataListCell key="policy-value">
<span id="simple-item1">
{nsPolicies[0]?.value}
</span>
</DataListCell>,
]
: []
}
/>
</DataListItemRow>
</DataListItem>
</DataList>
</Gallery>
</Conditional>
<Title headingLevel="h2" style={{paddingBottom: '.5em'}}>
Repository Auto-Pruning Policies
</Title>
<p style={{paddingBottom: '1em'}}>
Auto-pruning policies automatically delete tags under this repository by
a given method.
</p>
<Form id="autopruning-form" maxWidth="40%">
<FormGroup
isInline
label="Prune Policy - select a method to prune tags"
fieldId="method"
isRequired
>
<FormSelect
placeholder=""
aria-label="repository-auto-prune-method"
data-testid="repository-auto-prune-method"
value={method}
onChange={(_, val) => setMethod(val as AutoPruneMethod)}
>
<FormSelectOption
key={1}
value={AutoPruneMethod.NONE}
label="None"
/>
<FormSelectOption
key={2}
value={AutoPruneMethod.TAG_NUMBER}
label="By number of tags"
/>
<FormSelectOption
key={3}
value={AutoPruneMethod.TAG_CREATION_DATE}
label="By age of tags"
/>
</FormSelect>
<FormHelperText>
<HelperText>
<HelperTextItem>The method used to prune tags.</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
<Conditional if={method === AutoPruneMethod.TAG_NUMBER}>
<FormGroup label="The number of tags to keep." fieldId="" isRequired>
<NumberInput
value={tagCount}
onMinus={() => {
tagCount > 1 ? setTagCount(tagCount - 1) : setTagCount(1);
}}
onChange={(e) => {
const input = (e.target as HTMLInputElement).value;
const value = Number(input);
if (value > 0 && /^\d+$/.test(input)) {
setTagCount(value);
}
}}
onPlus={() => {
setTagCount(tagCount + 1);
}}
inputAriaLabel="number of tags"
minusBtnAriaLabel="minus"
plusBtnAriaLabel="plus"
data-testid="repository-auto-prune-tag-count"
/>
<FormHelperText>
<HelperText>
<HelperTextItem>
All tags sorted by earliest creation date will be deleted
until the repository total falls below the threshold
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
</Conditional>
<Conditional if={method === AutoPruneMethod.TAG_CREATION_DATE}>
<FormGroup
label="Delete tags older than given timespan."
fieldId=""
isRequired
isInline
>
<div style={{display: 'flex'}}>
<NumberInput
value={tagCreationDateValue}
onMinus={() => {
tagCreationDateValue > 1
? setTagCreationDateValue(tagCreationDateValue - 1)
: setTagCreationDateValue(1);
}}
onChange={(e) => {
const input = (e.target as HTMLInputElement).value;
const value = Number(input);
if (value > 0 && /^\d+$/.test(input)) {
setTagCreationDateValue(value);
}
}}
onPlus={() => {
setTagCreationDateValue(tagCreationDateValue + 1);
}}
inputAriaLabel="tag creation date value"
minusBtnAriaLabel="minus"
plusBtnAriaLabel="plus"
data-testid="repository-auto-prune-tag-creation-date-value"
style={{paddingRight: '1em'}}
/>
<FormSelect
placeholder=""
aria-label="tag creation date unit"
data-testid="tag-auto-prune-creation-date-timeunit"
value={tagCreationDateUnit}
onChange={(_, val) => setTagCreationDateUnit(val)}
style={{width: '10em'}}
>
{Object.keys(shorthandTimeUnits).map((key) => (
<FormSelectOption
key={key}
value={key}
label={shorthandTimeUnits[key]}
/>
))}
</FormSelect>
</div>
<FormHelperText>
<HelperText>
<HelperTextItem>
All tags with a creation date earlier than the selected time
period will be deleted
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
</Conditional>
<ActionGroup>
<Flex
justifyContent={{default: 'justifyContentFlexEnd'}}
width="100%"
>
<Button variant="primary" type="submit" onClick={onSave}>
Save
</Button>
</Flex>
</ActionGroup>
</Form>
</>
);
}
interface RepositoryAutoPruning {
organizationName: string;
repoName: string;
isUser: boolean;
}

View File

@ -8,10 +8,14 @@ import Visibility from './Visibility';
import {RepositoryStateForm} from './RepositoryState';
import {RepositoryDetails} from 'src/resources/RepositoryResource';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import RepositoryAutoPruning from 'src/routes/RepositoryDetails/Settings/RepositoryAutoPruning';
import {useOrganization} from 'src/hooks/UseOrganization';
export default function Settings(props: SettingsProps) {
const [activeTabIndex, setActiveTabIndex] = useState(0);
const config = useQuayConfig();
const {isUserOrganization} = useOrganization(props.org);
const tabs = [
{
name: 'User and robot permissions',
@ -24,6 +28,21 @@ export default function Settings(props: SettingsProps) {
/>
),
},
...(config?.features?.AUTO_PRUNE
? [
{
name: 'Repository Auto-Prune Policies',
id: 'repositoryautoprunepolicies',
content: (
<RepositoryAutoPruning
organizationName={props.org}
repoName={props.repo}
isUser={isUserOrganization}
/>
),
},
]
: []),
{
name: 'Events and notifications',
id: 'eventsandnotifications',

View File

@ -1,4 +1,6 @@
import logging.config
import os
import sys
import time
import features
@ -35,21 +37,42 @@ class AutoPruneWorker(Worker):
autoprune_task.id,
autoprune_task.namespace,
)
repo_policies = []
try:
policies = get_namespace_autoprune_policies_by_id(autoprune_task.namespace)
if not policies:
# When implementing repo policies, fetch repo policies before deleting the task
delete_autoprune_task(autoprune_task)
continue
ns_policies = get_namespace_autoprune_policies_by_id(autoprune_task.namespace)
if not ns_policies:
repo_policies = get_repository_autoprune_policies_by_namespace_id(
autoprune_task.namespace
)
if not repo_policies:
logger.info(
"deleting autoprune task %s for namespace %s",
autoprune_task.id,
autoprune_task.namespace,
)
delete_autoprune_task(autoprune_task)
continue
execute_namespace_polices(
policies,
ns_policies,
autoprune_task.namespace,
FETCH_REPOSITORIES_PAGE_LIMIT,
FETCH_TAGS_PAGE_LIMIT,
)
# case: only repo policies exists & no namespace policy
for policy in repo_policies:
repo_id = policy.repository_id
repo = get_repository_by_policy_repo_id(repo_id)
logger.info(
"processing autoprune task %s for repository %s",
autoprune_task.id,
repo.name,
)
execute_policy_on_repo(
policy, repo_id, autoprune_task.namespace, tag_page_limit=100
)
update_autoprune_task(autoprune_task, task_status="success")
except Exception as err:
update_autoprune_task(autoprune_task, task_status=f"failure: {str(err)}")