Repository: rucio/rucio

Issue:
.add_replication_rule allows for 0 copies as input
Motivation
----------
Today I was somewhat confused about replication rules because of something that happened in the backend for the ESCAPE rucio instance. Now this may have been due to something unrelated, but while discussing this with @bari12, he concluded that what I tried should probably not be possible. In My code I had lines like:
```
rul_cli = RuleClient()
rul_cli.add_replication_rule(dids=[{'scope':scope,'name':data_filename}], copies=0, rse_expression=rse, lifetime=lifetime)
```
Asking for there to be 0 copies of an existing data set seems not sensible. I think this should raise an exception somewhere. Also the command line client is totally ok with me asking it for 0 replicas of a file:

```
[grange@145 DAC21]$ rucio add-rule LOFAR_ASTRON_GRANGE:L31415_SB000.uv.tar 0 'CNAF-STORM'
c4114a72373f4e5cb7c550eb2a121044
```






Discussion:
@bari12 Just for my understanding, are there any places where 0 copies should be allowed (like daemons, etc...)? If no, I would include it in the `core`, if yes, I would include it in the `api`...
No, this should conceptually not be allowed anywhere :-) 

Resolving patch:
diff --git a/lib/rucio/core/rule.py b/lib/rucio/core/rule.py
--- a/lib/rucio/core/rule.py
+++ b/lib/rucio/core/rule.py
@@ -74,7 +74,7 @@
                                     ReplicationRuleCreationTemporaryFailed, InsufficientTargetRSEs, RucioException,
                                     InvalidRuleWeight, StagingAreaRuleRequiresLifetime, DuplicateRule,
                                     InvalidObject, RSEWriteBlocked, RuleReplaceFailed, RequestNotFound,
-                                    ManualRuleApprovalBlocked, UnsupportedOperation, UndefinedPolicy)
+                                    ManualRuleApprovalBlocked, UnsupportedOperation, UndefinedPolicy, InvalidValueForKey)
 from rucio.common.schema import validate_schema
 from rucio.common.types import InternalScope, InternalAccount
 from rucio.common.utils import str_to_date, sizefmt, chunks
@@ -139,6 +139,9 @@ def add_rule(dids, account, copies, rse_expression, grouping, weight, lifetime,
     :raises:                           InvalidReplicationRule, InsufficientAccountLimit, InvalidRSEExpression, DataIdentifierNotFound, ReplicationRuleCreationTemporaryFailed, InvalidRuleWeight,
                                        StagingAreaRuleRequiresLifetime, DuplicateRule, RSEWriteBlocked, ScratchDiskLifetimeConflict, ManualRuleApprovalBlocked, RSEOverQuota
     """
+    if copies <= 0:
+        raise InvalidValueForKey("The number of copies for a replication rule should be greater than 0.")
+
     rule_ids = []
 
     grouping = {'ALL': RuleGrouping.ALL, 'NONE': RuleGrouping.NONE}.get(grouping, RuleGrouping.DATASET)
@@ -364,6 +367,8 @@ def add_rules(dids, rules, session=None, logger=logging.log):
     :raises:         InvalidReplicationRule, InsufficientAccountLimit, InvalidRSEExpression, DataIdentifierNotFound, ReplicationRuleCreationTemporaryFailed, InvalidRuleWeight,
                      StagingAreaRuleRequiresLifetime, DuplicateRule, RSEWriteBlocked, ScratchDiskLifetimeConflict, ManualRuleApprovalBlocked
     """
+    if any(r.get("copies", 1) <= 0 for r in rules):
+        raise InvalidValueForKey("The number of copies for a replication rule should be greater than 0.")
 
     with record_timer_block('rule.add_rules'):
         rule_ids = {}


Test patch:
diff --git a/lib/rucio/tests/test_bin_rucio.py b/lib/rucio/tests/test_bin_rucio.py
--- a/lib/rucio/tests/test_bin_rucio.py
+++ b/lib/rucio/tests/test_bin_rucio.py
@@ -2074,3 +2074,21 @@ def test_rucio_list_file_replicas(self):
         print(out, err)
         assert exitcode == 0
         assert tmp_file1[5:] in out
+
+    def test_rucio_create_rule_with_0_copies(self):
+        """CLIENT(USER): The creation of a rule with 0 copies shouldn't be possible."""
+        tmp_file1 = file_generator()
+        # add files
+        cmd = 'rucio upload --rse {0} --scope {1} {2}'.format(self.def_rse, self.user, tmp_file1)
+        print(self.marker + cmd)
+        exitcode, out, err = execute(cmd)
+        print(out, err)
+
+        # Try to add a rules with 0 copies, this shouldn't be possible
+        cmd = "rucio add-rule {0}:{1} 0 MOCK".format(self.user, tmp_file1[5:])
+        print(self.marker + cmd)
+        exitcode, out, err = execute(cmd)
+        print(err)
+        print(out)
+        assert exitcode != 0
+        assert "The number of copies for a replication rule should be greater than 0." in err
diff --git a/lib/rucio/tests/test_rule.py b/lib/rucio/tests/test_rule.py
--- a/lib/rucio/tests/test_rule.py
+++ b/lib/rucio/tests/test_rule.py
@@ -50,9 +50,8 @@
 from rucio.client.subscriptionclient import SubscriptionClient
 from rucio.common.config import config_get_bool
 from rucio.common.exception import (RuleNotFound, AccessDenied, InsufficientAccountLimit, DuplicateRule, RSEWriteBlocked,
-                                    RSEOverQuota,
-                                    RuleReplaceFailed, ManualRuleApprovalBlocked, InputValidationError,
-                                    UnsupportedOperation)
+                                    RSEOverQuota, RuleReplaceFailed, ManualRuleApprovalBlocked, InputValidationError,
+                                    UnsupportedOperation, InvalidValueForKey)
 from rucio.common.policy import get_policy
 from rucio.common.types import InternalAccount, InternalScope
 from rucio.common.utils import generate_uuid as uuid
@@ -1427,6 +1426,25 @@ def test_approve_rule(self):
         assert rule['state'] == RuleState.INJECT.name
 
 
+def test_add_rule_with_0_copies(did_client, did_factory, root_account, rse_factory):
+    """ REPLICATION RULE (CLIENT): Add a replication rule and list full history """
+    rse, rse_id = rse_factory.make_posix_rse()
+    file = did_factory.upload_test_file(rse)
+    dataset_internal = did_factory.make_dataset()
+    container_internal = did_factory.make_container()
+
+    # make all scopes external
+    file, dataset, container = ({'scope': did['scope'].external, 'name': did['name']} for did in (file, dataset_internal, container_internal))
+
+    # Attach dataset to container
+    did_client.add_files_to_dataset(files=[file], **dataset)
+    did_client.add_datasets_to_container(dsns=[dataset], **container)
+
+    with pytest.raises(InvalidValueForKey) as e:
+        add_rule(dids=[container_internal], account=root_account, copies=0, rse_expression='MOCK', grouping='ALL', weight=None, lifetime=None, locked=False, subscription_id=None)
+        assert "The number of copies for a replication rule should be greater than 0" in str(e)
+
+
 @pytest.mark.noparallel(reason='Asynchronos behavior when loading locks')
 def test_detach_dataset_lock_removal(did_client, did_factory, root_account, rse_factory, vo):
     rse, rse_id = rse_factory.make_posix_rse()