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
```





Changed files:
lib/rucio/tests/test_rule.py, lib/rucio/tests/test_bin_rucio.py, lib/rucio/core/rule.py

diff --git a/lib/rucio/core/rule.py b/lib/rucio/core/rule.py
index 68e90fa1d..27101c12f 100644
--- a/lib/rucio/core/rule.py
+++ b/lib/rucio/core/rule.py
@@ -74,7 +74,7 @@ from rucio.common.exception import (InvalidRSEExpression, InvalidReplicationRule
                                     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 = {}
diff --git a/lib/rucio/tests/test_bin_rucio.py b/lib/rucio/tests/test_bin_rucio.py
index 7812d5b38..38eb149bc 100755
--- a/lib/rucio/tests/test_bin_rucio.py
+++ b/lib/rucio/tests/test_bin_rucio.py
@@ -2074,3 +2074,21 @@ class TestBinRucio(unittest.TestCase):
         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
index 455685eda..ff812fc48 100644
--- a/lib/rucio/tests/test_rule.py
+++ b/lib/rucio/tests/test_rule.py
@@ -50,9 +50,8 @@ from rucio.client.ruleclient import RuleClient
 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 @@ class TestReplicationRuleClient(unittest.TestCase):
         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()