rucio 38.2.0__py3-none-any.whl → 38.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of rucio might be problematic. Click here for more details.

Files changed (142) hide show
  1. rucio/cli/bin_legacy/rucio.py +26 -23
  2. rucio/cli/command.py +36 -26
  3. rucio/cli/config.py +22 -7
  4. rucio/cli/did.py +2 -2
  5. rucio/cli/download.py +1 -1
  6. rucio/cli/opendata.py +78 -10
  7. rucio/cli/utils.py +13 -1
  8. rucio/client/accountclient.py +20 -19
  9. rucio/client/accountlimitclient.py +5 -4
  10. rucio/client/baseclient.py +25 -25
  11. rucio/client/configclient.py +29 -5
  12. rucio/client/credentialclient.py +2 -1
  13. rucio/client/didclient.py +33 -32
  14. rucio/client/diracclient.py +2 -1
  15. rucio/client/exportclient.py +2 -1
  16. rucio/client/importclient.py +2 -1
  17. rucio/client/lifetimeclient.py +3 -2
  18. rucio/client/lockclient.py +4 -3
  19. rucio/client/metaconventionsclient.py +5 -4
  20. rucio/client/opendataclient.py +8 -7
  21. rucio/client/pingclient.py +2 -1
  22. rucio/client/replicaclient.py +27 -26
  23. rucio/client/requestclient.py +8 -8
  24. rucio/client/richclient.py +6 -0
  25. rucio/client/rseclient.py +31 -28
  26. rucio/client/ruleclient.py +13 -12
  27. rucio/client/scopeclient.py +4 -3
  28. rucio/client/subscriptionclient.py +6 -5
  29. rucio/common/constants.py +23 -0
  30. rucio/common/exception.py +30 -0
  31. rucio/common/plugins.py +33 -15
  32. rucio/common/utils.py +3 -3
  33. rucio/core/config.py +8 -6
  34. rucio/core/credential.py +19 -26
  35. rucio/core/did.py +1 -1
  36. rucio/core/did_meta_plugins/did_column_meta.py +226 -69
  37. rucio/core/opendata.py +150 -8
  38. rucio/core/replica.py +3 -4
  39. rucio/core/request.py +1 -1
  40. rucio/core/rule.py +6 -3
  41. rucio/core/rule_grouping.py +5 -5
  42. rucio/gateway/account.py +8 -7
  43. rucio/gateway/config.py +2 -37
  44. rucio/gateway/opendata.py +2 -2
  45. rucio/gateway/request.py +2 -117
  46. rucio/gateway/rule.py +2 -2
  47. rucio/rse/protocols/webdav.py +5 -2
  48. rucio/rse/translation.py +3 -3
  49. rucio/transfertool/fts3.py +0 -19
  50. rucio/transfertool/fts3_plugins.py +3 -3
  51. rucio/vcsversion.py +3 -3
  52. rucio/web/rest/flaskapi/v1/accountlimits.py +4 -3
  53. rucio/web/rest/flaskapi/v1/accounts.py +26 -25
  54. rucio/web/rest/flaskapi/v1/archives.py +2 -2
  55. rucio/web/rest/flaskapi/v1/auth.py +15 -14
  56. rucio/web/rest/flaskapi/v1/common.py +4 -4
  57. rucio/web/rest/flaskapi/v1/config.py +57 -17
  58. rucio/web/rest/flaskapi/v1/credentials.py +3 -3
  59. rucio/web/rest/flaskapi/v1/dids.py +25 -24
  60. rucio/web/rest/flaskapi/v1/dirac.py +3 -2
  61. rucio/web/rest/flaskapi/v1/export.py +4 -2
  62. rucio/web/rest/flaskapi/v1/heartbeats.py +2 -1
  63. rucio/web/rest/flaskapi/v1/identities.py +5 -4
  64. rucio/web/rest/flaskapi/v1/import.py +3 -2
  65. rucio/web/rest/flaskapi/v1/lifetime_exceptions.py +3 -2
  66. rucio/web/rest/flaskapi/v1/locks.py +4 -3
  67. rucio/web/rest/flaskapi/v1/meta_conventions.py +4 -3
  68. rucio/web/rest/flaskapi/v1/metrics.py +2 -1
  69. rucio/web/rest/flaskapi/v1/nongrid_traces.py +2 -1
  70. rucio/web/rest/flaskapi/v1/opendata.py +7 -6
  71. rucio/web/rest/flaskapi/v1/opendata_public.py +6 -5
  72. rucio/web/rest/flaskapi/v1/ping.py +3 -2
  73. rucio/web/rest/flaskapi/v1/redirect.py +4 -3
  74. rucio/web/rest/flaskapi/v1/replicas.py +31 -31
  75. rucio/web/rest/flaskapi/v1/requests.py +7 -7
  76. rucio/web/rest/flaskapi/v1/rses.py +23 -16
  77. rucio/web/rest/flaskapi/v1/rules.py +9 -8
  78. rucio/web/rest/flaskapi/v1/scopes.py +4 -3
  79. rucio/web/rest/flaskapi/v1/subscriptions.py +9 -8
  80. rucio/web/rest/flaskapi/v1/traces.py +2 -1
  81. rucio/web/rest/flaskapi/v1/vos.py +4 -3
  82. {rucio-38.2.0.dist-info → rucio-38.4.0.dist-info}/METADATA +1 -1
  83. {rucio-38.2.0.dist-info → rucio-38.4.0.dist-info}/RECORD +142 -142
  84. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/alembic.ini.template +0 -0
  85. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/alembic_offline.ini.template +0 -0
  86. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/globus-config.yml.template +0 -0
  87. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/ldap.cfg.template +0 -0
  88. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/mail_templates/rule_approval_request.tmpl +0 -0
  89. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/mail_templates/rule_approved_admin.tmpl +0 -0
  90. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/mail_templates/rule_approved_user.tmpl +0 -0
  91. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/mail_templates/rule_denied_admin.tmpl +0 -0
  92. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/mail_templates/rule_denied_user.tmpl +0 -0
  93. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/mail_templates/rule_ok_notification.tmpl +0 -0
  94. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/rse-accounts.cfg.template +0 -0
  95. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/rucio.cfg.atlas.client.template +0 -0
  96. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/rucio.cfg.template +0 -0
  97. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/etc/rucio_multi_vo.cfg.template +0 -0
  98. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/requirements.server.txt +0 -0
  99. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/tools/bootstrap.py +0 -0
  100. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/tools/merge_rucio_configs.py +0 -0
  101. {rucio-38.2.0.data → rucio-38.4.0.data}/data/rucio/tools/reset_database.py +0 -0
  102. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio +0 -0
  103. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-abacus-account +0 -0
  104. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-abacus-collection-replica +0 -0
  105. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-abacus-rse +0 -0
  106. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-admin +0 -0
  107. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-atropos +0 -0
  108. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-auditor +0 -0
  109. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-automatix +0 -0
  110. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-bb8 +0 -0
  111. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-cache-client +0 -0
  112. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-cache-consumer +0 -0
  113. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-conveyor-finisher +0 -0
  114. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-conveyor-poller +0 -0
  115. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-conveyor-preparer +0 -0
  116. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-conveyor-receiver +0 -0
  117. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-conveyor-stager +0 -0
  118. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-conveyor-submitter +0 -0
  119. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-conveyor-throttler +0 -0
  120. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-dark-reaper +0 -0
  121. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-dumper +0 -0
  122. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-follower +0 -0
  123. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-hermes +0 -0
  124. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-judge-cleaner +0 -0
  125. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-judge-evaluator +0 -0
  126. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-judge-injector +0 -0
  127. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-judge-repairer +0 -0
  128. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-kronos +0 -0
  129. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-minos +0 -0
  130. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-minos-temporary-expiration +0 -0
  131. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-necromancer +0 -0
  132. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-oauth-manager +0 -0
  133. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-reaper +0 -0
  134. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-replica-recoverer +0 -0
  135. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-rse-decommissioner +0 -0
  136. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-storage-consistency-actions +0 -0
  137. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-transmogrifier +0 -0
  138. {rucio-38.2.0.data → rucio-38.4.0.data}/scripts/rucio-undertaker +0 -0
  139. {rucio-38.2.0.dist-info → rucio-38.4.0.dist-info}/WHEEL +0 -0
  140. {rucio-38.2.0.dist-info → rucio-38.4.0.dist-info}/licenses/AUTHORS.rst +0 -0
  141. {rucio-38.2.0.dist-info → rucio-38.4.0.dist-info}/licenses/LICENSE +0 -0
  142. {rucio-38.2.0.dist-info → rucio-38.4.0.dist-info}/top_level.txt +0 -0
rucio/core/opendata.py CHANGED
@@ -21,10 +21,14 @@ from sqlalchemy.exc import DataError, IntegrityError
21
21
  from sqlalchemy.sql.expression import bindparam, select
22
22
 
23
23
  from rucio.common import exception
24
+ from rucio.common.config import config_get, config_get_bool, config_get_int
25
+ from rucio.common.constants import DEFAULT_VO
24
26
  from rucio.common.exception import OpenDataError, OpenDataInvalidStateUpdate
27
+ from rucio.common.types import InternalAccount
25
28
  from rucio.core.did import list_files
26
29
  from rucio.core.monitor import MetricManager
27
30
  from rucio.core.replica import list_replicas
31
+ from rucio.core.rule import add_rule
28
32
  from rucio.db.sqla import models
29
33
  from rucio.db.sqla.constants import DIDType, OpenDataDIDState
30
34
 
@@ -300,6 +304,7 @@ def get_opendata_did(
300
304
  include_files: bool = True,
301
305
  include_metadata: bool = False,
302
306
  include_doi: bool = True,
307
+ include_rule: bool = True,
303
308
  session: "Session",
304
309
  ) -> dict[str, Any]:
305
310
  """
@@ -312,10 +317,11 @@ def get_opendata_did(
312
317
  include_files: If True, include a list of associated files. Defaults to True.
313
318
  include_metadata: If True, include extended metadata. Defaults to False.
314
319
  include_doi: If True, include DOI (Digital Object Identifier) information. Defaults to True.
320
+ include_rule: If True, include the Opendata replication rule. Defaults to True.
315
321
  session: SQLAlchemy session to use for the query.
316
322
 
317
323
  Returns:
318
- A dictionary containing metadata about the specified DID.
324
+ A dictionary containing info about the specified DID which include "scope", "name", "state", "meta" (if requested), etc.
319
325
  """
320
326
 
321
327
  query = select(
@@ -345,6 +351,8 @@ def get_opendata_did(
345
351
  result["doi"] = get_opendata_doi(scope=scope, name=name, session=session)
346
352
  if include_metadata:
347
353
  result["meta"] = get_opendata_meta(scope=scope, name=name, session=session)
354
+ if include_rule:
355
+ result["rule"] = _fetch_opendata_rule(scope=scope, name=name, session=session)
348
356
  if include_files:
349
357
  opendata_files = get_opendata_did_files(scope=scope, name=name, session=session)
350
358
  result["files"] = opendata_files
@@ -508,7 +516,7 @@ def update_opendata_did(
508
516
  meta: Optional[Union[dict, str]] = None,
509
517
  doi: Optional[str] = None,
510
518
  session: "Session",
511
- ) -> None:
519
+ ) -> dict[str, Any]:
512
520
  """
513
521
  Update an existing Opendata DID in the catalog.
514
522
 
@@ -520,6 +528,9 @@ def update_opendata_did(
520
528
  doi: DOI to associate with the DID. Must be a valid DOI string (e.g., "10.1234/foo.bar").
521
529
  session: SQLAlchemy session to use for the operation.
522
530
 
531
+ Returns:
532
+ A dictionary containing the scope and name of the DID and details of the updates performed. (e.g., new/old state, new/old DOI, etc.)
533
+
523
534
  Raises:
524
535
  InputValidationError: If none of 'state', 'meta', or 'doi' are provided, or if the provided data is invalid.
525
536
  OpenDataDataIdentifierNotFound: If the Opendata DID does not exist.
@@ -533,14 +544,18 @@ def update_opendata_did(
533
544
  if not _check_opendata_did_exists(scope=scope, name=name, session=session):
534
545
  raise exception.OpenDataDataIdentifierNotFound(f"OpenData DID '{scope}:{name}' not found.")
535
546
 
547
+ result = {}
548
+
536
549
  if state is not None:
537
- update_opendata_state(scope=scope, name=name, state=state, session=session)
550
+ result |= update_opendata_state(scope=scope, name=name, state=state, session=session)
538
551
 
539
552
  if meta is not None:
540
- update_opendata_meta(scope=scope, name=name, meta=meta, session=session)
553
+ result |= update_opendata_meta(scope=scope, name=name, meta=meta, session=session)
541
554
 
542
555
  if doi is not None:
543
- update_opendata_doi(scope=scope, name=name, doi=doi, session=session)
556
+ result |= update_opendata_doi(scope=scope, name=name, doi=doi, session=session)
557
+
558
+ return result
544
559
 
545
560
 
546
561
  def update_opendata_meta(
@@ -549,7 +564,7 @@ def update_opendata_meta(
549
564
  name: str,
550
565
  meta: Union[dict, str],
551
566
  session: "Session",
552
- ) -> None:
567
+ ) -> dict[str, Any]:
553
568
  """
554
569
  Update the metadata associated with an Opendata DID.
555
570
 
@@ -559,6 +574,9 @@ def update_opendata_meta(
559
574
  meta: Metadata to update for the DID. Must be a valid JSON object or string.
560
575
  session: SQLAlchemy session to use for the operation.
561
576
 
577
+ Returns:
578
+ A dictionary containing the scope, name, and updated metadata of the Opendata DID.
579
+
562
580
  Raises:
563
581
  InputValidationError: If 'meta' is not a dictionary or a valid JSON string.
564
582
  OpenDataDataIdentifierNotFound: If the Opendata DID does not exist.
@@ -598,6 +616,93 @@ def update_opendata_meta(
598
616
  except DataError as error:
599
617
  raise exception.InputValidationError(f"Invalid data: {error}")
600
618
 
619
+ return {"scope": scope, "name": name, "meta_new": meta}
620
+
621
+
622
+ def _fetch_opendata_rule(scope: "InternalScope",
623
+ name: str,
624
+ session: "Session"
625
+ ) -> Optional[str]:
626
+ """
627
+ Retrieves the replication rule ID associated with an Opendata DID, if it exists.
628
+ The rule is searched for in the rules table by matching the scope, name, account (root), rse_expression,
629
+ and copies (1) based on the configuration used for creating the rule.
630
+
631
+ Parameters:
632
+ scope: The scope under which the Opendata DID is registered.
633
+ name: The name of the Opendata DID.
634
+ session: SQLAlchemy session to use for the query.
635
+ Returns:
636
+ The replication rule ID if it exists, otherwise None.
637
+ """
638
+
639
+ rule_rse_expression = config_get("opendata", "rule_rse_expression", raise_exception=False, default=None)
640
+ if not rule_rse_expression:
641
+ return None
642
+
643
+ rule_account = config_get("opendata", "rule_account", raise_exception=False, default="root")
644
+ rule_vo = config_get("opendata", "rule_vo", raise_exception=False, default=DEFAULT_VO)
645
+ rule_copies = config_get_int("opendata", "rule_copies", raise_exception=False, default=1)
646
+
647
+ return session.execute(
648
+ select(models.ReplicationRule.id).where(
649
+ and_(
650
+ models.ReplicationRule.scope == scope,
651
+ models.ReplicationRule.name == name,
652
+ models.ReplicationRule.account == InternalAccount(account=rule_account, vo=rule_vo),
653
+ models.ReplicationRule.rse_expression == rule_rse_expression,
654
+ models.ReplicationRule.copies == rule_copies,
655
+ )
656
+ )
657
+ ).scalar()
658
+
659
+
660
+ def _add_opendata_rule(
661
+ scope: "InternalScope",
662
+ name: str,
663
+ session: "Session"
664
+ ) -> str:
665
+ """
666
+ Create a replication rule for an Opendata DID.
667
+ The rule is created with parameters defined in the configuration file under the [opendata] section.
668
+
669
+ Parameters:
670
+ scope: The scope under which the Opendata DID is registered.
671
+ name: The name of the Opendata DID.
672
+ session: SQLAlchemy session to use for the operation.
673
+ Returns:
674
+ The ID of the created replication rule.
675
+ Raises:
676
+ ValueError: If there is an error during the rule creation process.
677
+ """
678
+
679
+ rule_asynchronous = config_get_bool("opendata", "rule_asynchronous", raise_exception=False, default=False)
680
+ rule_activity = config_get("opendata", "rule_activity", raise_exception=False, default=None)
681
+ rule_rse_expression = config_get("opendata", "rule_rse_expression", raise_exception=True)
682
+ rule_account = config_get("opendata", "rule_account", raise_exception=False, default="root")
683
+ rule_vo = config_get("opendata", "rule_vo", raise_exception=False, default=DEFAULT_VO)
684
+ rule_copies = config_get_int("opendata", "rule_copies", raise_exception=False, default=1)
685
+
686
+ add_rule_result = add_rule(
687
+ dids=[{"scope": scope, "name": name}],
688
+ # We need an account, perhaps we should pass the issuer argument around like in other methods with account
689
+ account=InternalAccount(account=rule_account, vo=rule_vo),
690
+ copies=rule_copies,
691
+ rse_expression=rule_rse_expression,
692
+ grouping="DATASET",
693
+ weight=None,
694
+ lifetime=None,
695
+ locked=False,
696
+ subscription_id=None,
697
+ activity=rule_activity,
698
+ asynchronous=rule_asynchronous,
699
+ session=session,
700
+ )
701
+ if len(add_rule_result) != 1:
702
+ raise ValueError(f"Error adding Open Data rule: {add_rule_result}")
703
+
704
+ return add_rule_result[0]
705
+
601
706
 
602
707
  def update_opendata_state(
603
708
  *,
@@ -605,9 +710,10 @@ def update_opendata_state(
605
710
  name: str,
606
711
  state: OpenDataDIDState,
607
712
  session: "Session",
608
- ) -> None:
713
+ ) -> dict[str, Any]:
609
714
  """
610
715
  Update the state of an Opendata DID.
716
+ If the new state is PUBLIC, a replication rule may be created based on configuration.
611
717
 
612
718
  Parameters:
613
719
  scope: The scope under which the Opendata DID is registered.
@@ -615,6 +721,9 @@ def update_opendata_state(
615
721
  state: The new state to set for the Opendata DID.
616
722
  session: SQLAlchemy session to use for the operation.
617
723
 
724
+ Returns:
725
+ A dictionary with the scope and name of the DID and the rule id if a rule was created and the old and new state.
726
+
618
727
  Raises:
619
728
  InputValidationError: If the provided state is not a valid OpenDataDIDState.
620
729
  OpenDataDataIdentifierNotFound: If the Opendata DID does not exist.
@@ -676,15 +785,30 @@ def update_opendata_state(
676
785
  if state_before == OpenDataDIDState.DRAFT:
677
786
  raise OpenDataInvalidStateUpdate("Cannot set state to SUSPENDED from DRAFT. First set it to PUBLIC.")
678
787
 
788
+ output = {"scope": scope, "name": name, "state_old": state_before, "state_new": state}
789
+
679
790
  try:
680
791
  result = session.execute(update_query)
681
792
 
682
793
  if result.rowcount == 0:
683
794
  raise ValueError(f"Error updating Opendata state for DID '{scope}:{name}'.")
684
795
 
796
+ if state == OpenDataDIDState.PUBLIC:
797
+ rule_enable = config_get_bool("opendata", "rule_enable", raise_exception=False, default=False)
798
+ if rule_enable:
799
+ rule_id = _fetch_opendata_rule(scope=scope, name=name, session=session)
800
+ if rule_id:
801
+ output["rule"] = rule_id
802
+ output["comments"] = "Replication rule already exists"
803
+ else:
804
+ output["rule"] = _add_opendata_rule(scope=scope, name=name, session=session)
805
+ output["comments"] = "Replication rule created"
806
+
685
807
  except DataError as error:
686
808
  raise exception.InputValidationError(f"Invalid data: {error}")
687
809
 
810
+ return output
811
+
688
812
 
689
813
  def update_opendata_doi(
690
814
  *,
@@ -692,7 +816,7 @@ def update_opendata_doi(
692
816
  name: str,
693
817
  doi: str,
694
818
  session: "Session",
695
- ) -> None:
819
+ ) -> dict[str, Any]:
696
820
  """
697
821
  Update the DOI (Digital Object Identifier) associated with an Opendata DID.
698
822
 
@@ -702,6 +826,9 @@ def update_opendata_doi(
702
826
  doi: The new DOI to associate with the Opendata DID. Must be a valid DOI string.
703
827
  session: SQLAlchemy session to use for the operation.
704
828
 
829
+ Returns:
830
+ A dictionary containing the scope, name, new DOI, and previous DOI of the Opendata DID.
831
+
705
832
  Raises:
706
833
  InputValidationError: If the provided DOI is not a valid string or does not match the expected format.
707
834
  OpenDataDataIdentifierNotFound: If the Opendata DID does not exist.
@@ -740,5 +867,20 @@ def update_opendata_doi(
740
867
  if result.rowcount == 0:
741
868
  raise ValueError(f"Error updating Opendata DOI for DID '{scope}:{name}'.")
742
869
 
870
+ except IntegrityError as error:
871
+ msg = str(error)
872
+
873
+ if (
874
+ search(r'ORA-00001: unique constraint \([^)]+\) violated', msg)
875
+ or search(r'UNIQUE constraint failed: dids_opendata_doi\.doi', msg)
876
+ or search(r'1062.*Duplicate entry.*for key', msg)
877
+ or search(r'duplicate key value violates unique constraint', msg)
878
+ or search(r'columns?.*not unique', msg)
879
+ ):
880
+ raise exception.OpenDataDuplicateDOI(doi=doi)
881
+
882
+ raise exception.OpenDataError()
743
883
  except DataError as error:
744
884
  raise exception.InputValidationError(f"Invalid data: {error}")
885
+
886
+ return {"scope": scope, "name": name, "doi_new": doi, "doi_old": doi_before}
rucio/core/replica.py CHANGED
@@ -22,7 +22,6 @@ from curses.ascii import isprint
22
22
  from datetime import datetime, timedelta
23
23
  from hashlib import sha256
24
24
  from itertools import groupby
25
- from json import dumps
26
25
  from re import match
27
26
  from struct import unpack
28
27
  from traceback import format_exc
@@ -2304,9 +2303,9 @@ def __cleanup_after_replica_deletion(
2304
2303
  for scope, name, did_type in session.execute(stmt):
2305
2304
  if did_type == DIDType.DATASET:
2306
2305
  messages.append({'event_type': 'ERASE',
2307
- 'payload': dumps({'scope': scope.external,
2308
- 'name': name,
2309
- 'account': 'root'})})
2306
+ 'payload': {'scope': scope.external,
2307
+ 'name': name,
2308
+ 'account': 'root'}})
2310
2309
  dids_to_delete.add(ScopeName(scope=scope, name=name))
2311
2310
 
2312
2311
  # Remove Archive Constituents
rucio/core/request.py CHANGED
@@ -1513,7 +1513,7 @@ class TransferStatsManager:
1513
1513
  def __enter__(self) -> "TransferStatsManager":
1514
1514
  self.record_stats = config_get_bool('transfers', 'stats_enabled', default=self.record_stats)
1515
1515
  downsample_period = config_get_int('transfers', 'stats_downsample_period', default=self.downsample_period)
1516
- # Introduce some voluntary jitter to reduce the likely-hood of performing this database
1516
+ # Introduce some voluntary jitter to reduce the likelihood of performing this database
1517
1517
  # operation multiple times in parallel.
1518
1518
  self.downsample_period = random.randint(downsample_period * 3 // 4, math.ceil(downsample_period * 5 / 4)) # noqa: S311
1519
1519
  if self.record_stats:
rucio/core/rule.py CHANGED
@@ -37,7 +37,7 @@ import rucio.core.lock # import get_replica_locks, get_files_and_replica_locks_
37
37
  import rucio.core.replica # import get_and_lock_file_replicas, get_and_lock_file_replicas_for_dataset
38
38
  from rucio.common.cache import MemcacheRegion
39
39
  from rucio.common.config import config_get
40
- from rucio.common.constants import DEFAULT_VO, RseAttr
40
+ from rucio.common.constants import DEFAULT_ACTIVITY, DEFAULT_VO, POLICY_ALGORITHM_TYPES_LITERAL, RseAttr
41
41
  from rucio.common.exception import (
42
42
  DataIdentifierNotFound,
43
43
  DuplicateRule,
@@ -98,7 +98,7 @@ class AutoApprove(PolicyPackageAlgorithms):
98
98
  Handle automatic approval algorithms for replication rules
99
99
  """
100
100
 
101
- _algorithm_type = 'auto_approve'
101
+ _algorithm_type: POLICY_ALGORITHM_TYPES_LITERAL = 'auto_approve'
102
102
 
103
103
  def __init__(self, rule: models.ReplicationRule, did: models.DataIdentifier, session: 'Session', vo: str = DEFAULT_VO) -> None:
104
104
  super().__init__()
@@ -180,7 +180,7 @@ def add_rule(
180
180
  locked: bool,
181
181
  subscription_id: Optional[str],
182
182
  source_replica_expression: Optional[str] = None,
183
- activity: str = 'User Subscriptions',
183
+ activity: Optional[str] = None,
184
184
  notify: Optional[Literal['Y', 'N', 'C', 'P']] = None,
185
185
  purge_replicas: bool = False,
186
186
  ignore_availability: bool = False,
@@ -232,6 +232,9 @@ def add_rule(
232
232
  if copies <= 0:
233
233
  raise InvalidValueForKey("The number of copies for a replication rule should be greater than 0.")
234
234
 
235
+ if not activity:
236
+ activity = DEFAULT_ACTIVITY
237
+
235
238
  rule_ids = []
236
239
 
237
240
  grouping_value = {'ALL': RuleGrouping.ALL, 'NONE': RuleGrouping.NONE}.get(grouping, RuleGrouping.DATASET)
@@ -690,7 +690,7 @@ def __repair_stuck_locks_with_none_grouping(datasetfiles, locks, replicas, sourc
690
690
  associated_replica.lock_cnt = session.execute(stmt).scalar_one()
691
691
  continue
692
692
  # Check if this is a STUCK lock due to source_replica filtering
693
- if source_rses:
693
+ if source_rses and not lock.repair_cnt:
694
694
  associated_replica = [replica for replica in replicas[(file['scope'], file['name'])] if replica.rse_id == lock.rse_id][0]
695
695
  # Check if there is an eligible source replica for this lock
696
696
  if set(source_replicas.get((file['scope'], file['name']), [])).intersection(source_rses) and (selector_rse_dict.get(lock.rse_id, {}).get('availability_write', True) or rule.ignore_availability):
@@ -806,7 +806,7 @@ def __repair_stuck_locks_with_all_grouping(datasetfiles, locks, replicas, source
806
806
  associated_replica.lock_cnt = session.execute(stmt).scalar_one()
807
807
  continue
808
808
  # Check if this is a STUCK lock due to source_replica filtering
809
- if source_rses:
809
+ if source_rses and not lock.repair_cnt:
810
810
  associated_replica = [replica for replica in replicas[(file['scope'], file['name'])] if replica.rse_id == lock.rse_id][0]
811
811
  # Check if there is an eligible source replica for this lock
812
812
  if set(source_replicas.get((file['scope'], file['name']), [])).intersection(source_rses) and (selector_rse_dict.get(lock.rse_id, {}).get('availability_write', True) or rule.ignore_availability):
@@ -891,7 +891,7 @@ def __repair_stuck_locks_with_dataset_grouping(datasetfiles, locks, replicas, so
891
891
  associated_replica.lock_cnt = session.execute(stmt).scalar_one()
892
892
  continue
893
893
  # Check if this is a STUCK lock due to source_replica filtering
894
- if source_rses:
894
+ if source_rses and not lock.repair_cnt:
895
895
  associated_replica = [replica for replica in replicas[(file['scope'], file['name'])] if replica.rse_id == lock.rse_id][0]
896
896
  # Check if there is an eligible source replica for this lock
897
897
  if set(source_replicas.get((file['scope'], file['name']), [])).intersection(source_rses) and (selector_rse_dict.get(lock.rse_id, {}).get('availability_write', True) or rule.ignore_availability):
@@ -921,8 +921,8 @@ def __is_retry_required(lock, activity):
921
921
  :param activity: The activity of the rule.
922
922
  """
923
923
 
924
- created_at_diff = (datetime.utcnow() - lock.created_at).days * 24 * 3600 + (datetime.utcnow() - lock.created_at).seconds
925
- updated_at_diff = (datetime.utcnow() - lock.updated_at).days * 24 * 3600 + (datetime.utcnow() - lock.updated_at).seconds
924
+ created_at_diff = (datetime.utcnow() - lock.created_at).total_seconds()
925
+ updated_at_diff = (datetime.utcnow() - lock.updated_at).total_seconds()
926
926
 
927
927
  if activity == 'Express':
928
928
  if updated_at_diff > 3600 * 2:
rucio/gateway/account.py CHANGED
@@ -14,10 +14,9 @@
14
14
 
15
15
  from typing import TYPE_CHECKING, Any, Optional
16
16
 
17
- import rucio.common.exception
18
- import rucio.core.identity
19
17
  import rucio.gateway.permission
20
18
  from rucio.common.constants import DEFAULT_VO
19
+ from rucio.common.exception import AccessDenied, InvalidAccountType
21
20
  from rucio.common.schema import validate_schema
22
21
  from rucio.common.types import InternalAccount
23
22
  from rucio.common.utils import gateway_update_return_dict
@@ -55,11 +54,13 @@ def add_account(
55
54
  validate_schema(name='account', obj=account, vo=vo)
56
55
 
57
56
  kwargs = {'account': account, 'type': type_}
57
+ if type_.upper() not in AccountType._member_names_:
58
+ raise InvalidAccountType(f"{type_} is an invalid account type. Choose from {AccountType._member_names_}")
58
59
 
59
60
  with db_session(DatabaseOperationType.WRITE) as session:
60
61
  auth_result = rucio.gateway.permission.has_permission(issuer=issuer, vo=vo, action='add_account', kwargs=kwargs, session=session)
61
62
  if not auth_result.allowed:
62
- raise rucio.common.exception.AccessDenied('Account %s can not add account. %s' % (issuer, auth_result.message))
63
+ raise AccessDenied('Account %s can not add account. %s' % (issuer, auth_result.message))
63
64
 
64
65
  internal_account = InternalAccount(account, vo=vo)
65
66
 
@@ -83,7 +84,7 @@ def del_account(
83
84
  with db_session(DatabaseOperationType.WRITE) as session:
84
85
  auth_result = rucio.gateway.permission.has_permission(issuer=issuer, vo=vo, action='del_account', kwargs=kwargs, session=session)
85
86
  if not auth_result.allowed:
86
- raise rucio.common.exception.AccessDenied('Account %s can not delete account. %s' % (issuer, auth_result.message))
87
+ raise AccessDenied('Account %s can not delete account. %s' % (issuer, auth_result.message))
87
88
 
88
89
  internal_account = InternalAccount(account, vo=vo)
89
90
 
@@ -132,7 +133,7 @@ def update_account(
132
133
  with db_session(DatabaseOperationType.WRITE) as session:
133
134
  auth_result = rucio.gateway.permission.has_permission(issuer=issuer, vo=vo, action='update_account', kwargs=kwargs, session=session)
134
135
  if not auth_result.allowed:
135
- raise rucio.common.exception.AccessDenied('Account %s can not change %s of the account. %s' % (issuer, key, auth_result.message))
136
+ raise AccessDenied('Account %s can not change %s of the account. %s' % (issuer, key, auth_result.message))
136
137
 
137
138
  internal_account = InternalAccount(account, vo=vo)
138
139
 
@@ -242,7 +243,7 @@ def add_account_attribute(
242
243
  with db_session(DatabaseOperationType.WRITE) as session:
243
244
  auth_result = rucio.gateway.permission.has_permission(issuer=issuer, vo=vo, action='add_attribute', kwargs=kwargs, session=session)
244
245
  if not auth_result.allowed:
245
- raise rucio.common.exception.AccessDenied('Account %s can not add attributes. %s' % (issuer, auth_result.message))
246
+ raise AccessDenied('Account %s can not add attributes. %s' % (issuer, auth_result.message))
246
247
 
247
248
  internal_account = InternalAccount(account, vo=vo)
248
249
 
@@ -268,7 +269,7 @@ def del_account_attribute(
268
269
  with db_session(DatabaseOperationType.WRITE) as session:
269
270
  auth_result = rucio.gateway.permission.has_permission(issuer=issuer, vo=vo, action='del_attribute', kwargs=kwargs, session=session)
270
271
  if not auth_result.allowed:
271
- raise rucio.common.exception.AccessDenied('Account %s can not delete attribute. %s' % (issuer, auth_result.message))
272
+ raise AccessDenied('Account %s can not delete attribute. %s' % (issuer, auth_result.message))
272
273
 
273
274
  internal_account = InternalAccount(account, vo=vo)
274
275
 
rucio/gateway/config.py CHANGED
@@ -47,23 +47,6 @@ def sections(issuer: str, vo: str = DEFAULT_VO) -> list[str]:
47
47
  return config.sections(session=session)
48
48
 
49
49
 
50
- def add_section(section: str, issuer: str, vo: str = DEFAULT_VO) -> None:
51
- """
52
- Add a section to the configuration.
53
-
54
- :param section: The name of the section.
55
- :param issuer: The issuer account.
56
- :param vo: The VO to act on.
57
- """
58
-
59
- kwargs = {'issuer': issuer, 'section': section}
60
- with db_session(DatabaseOperationType.WRITE) as session:
61
- auth_result = permission.has_permission(issuer=issuer, vo=vo, action='config_add_section', kwargs=kwargs, session=session)
62
- if not auth_result.allowed:
63
- raise exception.AccessDenied('%s cannot add section %s. %s' % (issuer, section, auth_result.message))
64
- return config.add_section(section, session=session)
65
-
66
-
67
50
  def has_section(section: str, issuer: str, vo: str = DEFAULT_VO) -> bool:
68
51
  """
69
52
  Indicates whether the named section is present in the configuration.
@@ -79,25 +62,7 @@ def has_section(section: str, issuer: str, vo: str = DEFAULT_VO) -> bool:
79
62
  auth_result = permission.has_permission(issuer=issuer, vo=vo, action='config_has_section', kwargs=kwargs, session=session)
80
63
  if not auth_result.allowed:
81
64
  raise exception.AccessDenied('%s cannot check existence of section %s. %s' % (issuer, section, auth_result.message))
82
- return config.has_section(section, session=session)
83
-
84
-
85
- def options(section: str, issuer: str, vo: str = DEFAULT_VO) -> list[str]:
86
- """
87
- Returns a list of options available in the specified section.
88
-
89
- :param section: The name of the section.
90
- :param issuer: The issuer account.
91
- :param vo: The VO to act on.
92
- :returns: ['option', ...]
93
- """
94
-
95
- kwargs = {'issuer': issuer, 'section': section}
96
- with db_session(DatabaseOperationType.READ) as session:
97
- auth_result = permission.has_permission(issuer=issuer, vo=vo, action='config_options', kwargs=kwargs, session=session)
98
- if auth_result.allowed:
99
- raise exception.AccessDenied('%s cannot retrieve options from section %s. %s' % (issuer, section, auth_result.message))
100
- return config.options(section, session=session)
65
+ return config.has_section(section, session=session, use_cache=False)
101
66
 
102
67
 
103
68
  def has_option(section: str, option: str, issuer: str, vo: str = DEFAULT_VO) -> bool:
@@ -116,7 +81,7 @@ def has_option(section: str, option: str, issuer: str, vo: str = DEFAULT_VO) ->
116
81
  auth_result = permission.has_permission(issuer=issuer, vo=vo, action='config_has_option', kwargs=kwargs, session=session)
117
82
  if not auth_result.allowed:
118
83
  raise exception.AccessDenied('%s cannot check existence of option %s from section %s. %s' % (issuer, option, section, auth_result.message))
119
- return config.has_option(section, option, session=session)
84
+ return config.has_option(section, option, session=session, use_cache=False)
120
85
 
121
86
 
122
87
  def get(section: str, option: str, issuer: str, vo: str = DEFAULT_VO) -> Any:
rucio/gateway/opendata.py CHANGED
@@ -151,7 +151,7 @@ def update_opendata_did(
151
151
  meta: Optional[dict] = None,
152
152
  doi: Optional[str] = None,
153
153
  vo: str = DEFAULT_VO,
154
- ) -> None:
154
+ ) -> dict[str, Any]:
155
155
  """
156
156
  Update an existing Opendata DID in the Opendata catalog.
157
157
 
@@ -164,7 +164,7 @@ def update_opendata_did(
164
164
  vo: The virtual organization.
165
165
 
166
166
  Returns:
167
- None
167
+ A dictionary containing the scope and name of the DID and details of the updates performed. (e.g., new/old state, new/old DOI, etc.)
168
168
 
169
169
  Raises:
170
170
  ValueError: If meta is a string and cannot be parsed as valid JSON.
rucio/gateway/request.py CHANGED
@@ -20,7 +20,7 @@ from typing import TYPE_CHECKING, Any, Optional
20
20
 
21
21
  from rucio.common import exception
22
22
  from rucio.common.constants import DEFAULT_VO, TransferLimitDirection
23
- from rucio.common.types import InternalAccount, InternalScope, RequestGatewayDict
23
+ from rucio.common.types import InternalScope
24
24
  from rucio.common.utils import gateway_update_return_dict
25
25
  from rucio.core import request
26
26
  from rucio.core.rse import get_rse_id
@@ -31,122 +31,7 @@ from rucio.gateway import permission
31
31
  if TYPE_CHECKING:
32
32
  from collections.abc import Iterable, Iterator, Sequence
33
33
 
34
- from rucio.db.sqla.constants import RequestState, RequestType
35
-
36
-
37
- def queue_requests(
38
- requests: "Iterable[RequestGatewayDict]",
39
- issuer: str,
40
- vo: str = DEFAULT_VO,
41
- ) -> list[dict[str, Any]]:
42
- """
43
- Submit transfer or deletion requests on destination RSEs for data identifiers.
44
-
45
- :param requests: List of dictionaries containing 'scope', 'name', 'dest_rse_id', 'request_type', 'attributes'
46
- :param issuer: Issuing account as a string.
47
- :param vo: The VO to act on.
48
- :returns: List of Request-IDs as 32 character hex strings
49
- """
50
-
51
- kwargs = {'requests': requests, 'issuer': issuer}
52
- with db_session(DatabaseOperationType.WRITE) as session:
53
- auth_result = permission.has_permission(issuer=issuer, vo=vo, action='queue_requests', kwargs=kwargs, session=session)
54
- if not auth_result.allowed:
55
- raise exception.AccessDenied(f'{issuer} can not queue request. {auth_result.message}')
56
-
57
- for req in requests:
58
- req['scope'] = InternalScope(req['scope'], vo=vo) # type: ignore (type reassignment)
59
- if 'account' in req:
60
- req['account'] = InternalAccount(req['account'], vo=vo) # type: ignore (type reassignment)
61
-
62
- new_requests = request.queue_requests(requests, session=session)
63
- return [gateway_update_return_dict(r, session=session) for r in new_requests]
64
-
65
-
66
- def cancel_request(
67
- request_id: str,
68
- issuer: str,
69
- account: str,
70
- vo: str = DEFAULT_VO,
71
- ) -> None:
72
- """
73
- Cancel a request.
74
-
75
- :param request_id: Request Identifier as a 32 character hex string.
76
- :param issuer: Issuing account as a string.
77
- :param account: Account identifier as a string.
78
- :param vo: The VO to act on.
79
- """
80
-
81
- kwargs = {'account': account, 'issuer': issuer, 'request_id': request_id}
82
- with db_session(DatabaseOperationType.WRITE) as session:
83
- auth_result = permission.has_permission(issuer=issuer, vo=vo, action='cancel_request_', kwargs=kwargs, session=session)
84
- if not auth_result.allowed:
85
- raise exception.AccessDenied('%s cannot cancel request %s. %s' % (account, request_id, auth_result.message))
86
-
87
- raise NotImplementedError
88
-
89
-
90
- def cancel_request_did(
91
- scope: str,
92
- name: str,
93
- dest_rse: str,
94
- request_type: str,
95
- issuer: str,
96
- account: str,
97
- vo: str = DEFAULT_VO,
98
- ) -> dict[str, Any]:
99
- """
100
- Cancel a request based on a DID and request type.
101
-
102
- :param scope: Data identifier scope as a string.
103
- :param name: Data identifier name as a string.
104
- :param dest_rse: RSE name as a string.
105
- :param request_type: Type of the request as a string.
106
- :param issuer: Issuing account as a string.
107
- :param account: Account identifier as a string.
108
- :param vo: The VO to act on.
109
- """
110
-
111
- with db_session(DatabaseOperationType.WRITE) as session:
112
- dest_rse_id = get_rse_id(rse=dest_rse, vo=vo, session=session)
113
-
114
- kwargs = {'account': account, 'issuer': issuer}
115
- auth_result = permission.has_permission(issuer=issuer, vo=vo, action='cancel_request_did', kwargs=kwargs, session=session)
116
- if not auth_result.allowed:
117
- raise exception.AccessDenied(f'{account} cannot cancel {request_type} request for {scope}:{name}. {auth_result.message}')
118
-
119
- internal_scope = InternalScope(scope, vo=vo)
120
- return request.cancel_request_did(internal_scope, name, dest_rse_id, request_type, session=session)
121
-
122
-
123
- def get_next(
124
- request_type: "RequestType",
125
- state: "RequestState",
126
- issuer: str,
127
- account: str,
128
- vo: str = DEFAULT_VO,
129
- ) -> list[dict[str, Any]]:
130
- """
131
- Retrieve the next request matching the request type and state.
132
-
133
- :param request_type: Type of the request as a string.
134
- :param state: State of the request as a string.
135
- :param issuer: Issuing account as a string.
136
- :param account: Account identifier as a string.
137
- :param vo: The VO to act on.
138
- :returns: Request as a dictionary.
139
- """
140
-
141
- kwargs = {'account': account, 'issuer': issuer, 'request_type': request_type, 'state': state}
142
-
143
- with db_session(DatabaseOperationType.WRITE) as session:
144
- auth_result = permission.has_permission(issuer=issuer, vo=vo, action='get_next', kwargs=kwargs, session=session)
145
- if not auth_result.allowed:
146
- raise exception.AccessDenied(f'{account} cannot get the next request of type {request_type} in state {state}. {auth_result.message}')
147
-
148
- reqs = request.get_and_mark_next(request_type, state, session=session)
149
- return [gateway_update_return_dict(r, session=session) for r in reqs]
34
+ from rucio.db.sqla.constants import RequestState
150
35
 
151
36
 
152
37
  def get_request_by_did(