gitlabform 4.6.1__tar.gz → 4.7.0__tar.gz

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.
Files changed (87) hide show
  1. {gitlabform-4.6.1 → gitlabform-4.7.0}/PKG-INFO +10 -10
  2. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/__init__.py +33 -11
  3. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/core.py +30 -15
  4. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/__init__.py +2 -0
  5. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/files_processor.py +8 -4
  6. gitlabform-4.7.0/gitlabform/processors/project/remote_mirrors_processor.py +280 -0
  7. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform.egg-info/PKG-INFO +10 -10
  8. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform.egg-info/SOURCES.txt +1 -0
  9. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform.egg-info/requires.txt +9 -9
  10. {gitlabform-4.6.1 → gitlabform-4.7.0}/pyproject.toml +10 -10
  11. {gitlabform-4.6.1 → gitlabform-4.7.0}/LICENSE +0 -0
  12. {gitlabform-4.6.1 → gitlabform-4.7.0}/README.md +0 -0
  13. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/__init__.py +0 -0
  14. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/configuration/__init__.py +0 -0
  15. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/configuration/common.py +0 -0
  16. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/configuration/core.py +0 -0
  17. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/configuration/groups.py +0 -0
  18. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/configuration/projects.py +0 -0
  19. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/configuration/transform.py +0 -0
  20. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/constants.py +0 -0
  21. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/commits.py +0 -0
  22. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/group_badges.py +0 -0
  23. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/group_ldap_links.py +0 -0
  24. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/group_variables.py +0 -0
  25. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/groups.py +0 -0
  26. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/merge_requests.py +0 -0
  27. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/pipelines.py +0 -0
  28. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/project_badges.py +0 -0
  29. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/project_deploy_keys.py +0 -0
  30. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/project_merge_requests_approvals.py +0 -0
  31. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/project_protected_environments.py +0 -0
  32. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/project_security_settings.py +0 -0
  33. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/projects.py +0 -0
  34. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/python_gitlab.py +0 -0
  35. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/gitlab/variables.py +0 -0
  36. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/lists/__init__.py +0 -0
  37. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/lists/filter.py +0 -0
  38. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/lists/groups.py +0 -0
  39. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/lists/projects.py +0 -0
  40. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/output.py +0 -0
  41. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/__init__.py +0 -0
  42. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/abstract_processor.py +0 -0
  43. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/application/__init__.py +0 -0
  44. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/application/application_settings_processor.py +0 -0
  45. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/defining_keys.py +0 -0
  46. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/group/__init__.py +0 -0
  47. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/group/group_badges_processor.py +0 -0
  48. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/group/group_hooks_processor.py +0 -0
  49. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/group/group_labels_processor.py +0 -0
  50. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/group/group_ldap_links_processor.py +0 -0
  51. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/group/group_members_processor.py +0 -0
  52. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/group/group_push_rules_processor.py +0 -0
  53. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/group/group_saml_links_processor.py +0 -0
  54. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/group/group_settings_processor.py +0 -0
  55. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/group/group_variables_processor.py +0 -0
  56. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/multiple_entities_processor.py +0 -0
  57. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/badges_processor.py +0 -0
  58. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/branches_processor.py +0 -0
  59. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/deploy_keys_processor.py +0 -0
  60. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/hooks_processor.py +0 -0
  61. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/integrations_processor.py +0 -0
  62. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/job_token_scope_processor.py +0 -0
  63. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/members_processor.py +0 -0
  64. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/merge_requests_approval_rules.py +0 -0
  65. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/merge_requests_approvals.py +0 -0
  66. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/project_labels_processor.py +0 -0
  67. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/project_processor.py +0 -0
  68. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/project_push_rules_processor.py +0 -0
  69. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/project_security_settings.py +0 -0
  70. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/project_settings_processor.py +0 -0
  71. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/resource_groups_processor.py +0 -0
  72. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/schedules_processor.py +0 -0
  73. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/tags_processor.py +0 -0
  74. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/project/variables_processor.py +0 -0
  75. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/shared/__init__.py +0 -0
  76. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/shared/protected_environments_processor.py +0 -0
  77. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/single_entity_processor.py +0 -0
  78. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/util/__init__.py +0 -0
  79. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/util/decorators.py +0 -0
  80. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/util/difference_logger.py +0 -0
  81. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/processors/util/labels_processor.py +0 -0
  82. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/run.py +0 -0
  83. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform/util.py +0 -0
  84. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform.egg-info/dependency_links.txt +0 -0
  85. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform.egg-info/entry_points.txt +0 -0
  86. {gitlabform-4.6.1 → gitlabform-4.7.0}/gitlabform.egg-info/top_level.txt +0 -0
  87. {gitlabform-4.6.1 → gitlabform-4.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlabform
3
- Version: 4.6.1
3
+ Version: 4.7.0
4
4
  Summary: 🏗 Specialized configuration as a code tool for GitLab projects, groups and more using hierarchical configuration written in YAML
5
5
  Author: Greg Dubicki and Contributors
6
6
  Project-URL: Homepage, https://gitlabform.github.io/gitlabform/
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Version Control :: Git
20
20
  Requires-Python: >=3.12.0
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: certifi==2025.10.5
23
+ Requires-Dist: certifi==2026.1.4
24
24
  Requires-Dist: cli-ui==0.19.0
25
25
  Requires-Dist: ez-yaml==1.2.0
26
26
  Requires-Dist: Jinja2==3.1.6
@@ -28,24 +28,24 @@ Requires-Dist: luddite==1.0.4
28
28
  Requires-Dist: MarkupSafe==3.0.3
29
29
  Requires-Dist: mergedeep==1.3.4
30
30
  Requires-Dist: packaging==25.0
31
- Requires-Dist: python-gitlab==7.0.0
32
- Requires-Dist: python-gitlab[graphql]==7.0.0
31
+ Requires-Dist: python-gitlab==7.1.0
32
+ Requires-Dist: python-gitlab[graphql]==7.1.0
33
33
  Requires-Dist: requests==2.32.5
34
34
  Requires-Dist: ruamel.yaml==0.17.21
35
- Requires-Dist: types-requests==2.32.4.20250913
36
- Requires-Dist: types-setuptools==80.9.0.20250822
35
+ Requires-Dist: types-requests==2.32.4.20260107
36
+ Requires-Dist: types-setuptools==80.9.0.20251223
37
37
  Requires-Dist: yamlpath==3.8.2
38
38
  Provides-Extra: test
39
- Requires-Dist: coverage==7.11.0; extra == "test"
39
+ Requires-Dist: coverage==7.13.1; extra == "test"
40
40
  Requires-Dist: cryptography==46.0.3; extra == "test"
41
41
  Requires-Dist: deepdiff==8.6.1; extra == "test"
42
- Requires-Dist: mypy==1.18.2; extra == "test"
42
+ Requires-Dist: mypy==1.19.1; extra == "test"
43
43
  Requires-Dist: mypy-extensions==1.1.0; extra == "test"
44
44
  Requires-Dist: pre-commit==2.21.0; extra == "test"
45
- Requires-Dist: pytest==8.4.2; extra == "test"
45
+ Requires-Dist: pytest==9.0.2; extra == "test"
46
46
  Requires-Dist: pytest-cov==7.0.0; extra == "test"
47
47
  Requires-Dist: pytest-rerunfailures==16.1; extra == "test"
48
- Requires-Dist: xkcdpass==1.20.0; extra == "test"
48
+ Requires-Dist: xkcdpass==1.30.0; extra == "test"
49
49
  Provides-Extra: docs
50
50
  Requires-Dist: mkdocs; extra == "docs"
51
51
  Requires-Dist: mkdocs-material; extra == "docs"
@@ -1,8 +1,9 @@
1
1
  import enum
2
+ import inspect
2
3
 
3
4
  from typing import List
4
5
 
5
- from gitlab import GraphQL
6
+ from gitlab import Gitlab as GitlabClient, GraphQL
6
7
 
7
8
  from gitlabform.gitlab.commits import GitLabCommits
8
9
  from gitlabform.gitlab.group_badges import GitLabGroupBadges
@@ -67,24 +68,45 @@ class GitLab(
67
68
 
68
69
 
69
70
  class GitlabWrapper:
71
+ # Parameters accepted by python-gitlab's Gitlab.__init__
72
+ # Other config keys (like max_retries) are used elsewhere in gitlabform
73
+ # or passed to specific components like GraphQL
74
+ GITLAB_CLIENT_PARAMS = set(inspect.signature(GitlabClient.__init__).parameters.keys()) - {"self"}
75
+
76
+ # Parameters accepted by python-gitlab's GraphQL.__init__
77
+ GRAPHQL_PARAMS = set(inspect.signature(GraphQL.__init__).parameters.keys()) - {"self"}
78
+
70
79
  def __init__(self, gitlabform: GitLab):
71
- url = gitlabform.url
72
- token = gitlabform.token
73
- ssl_verify = gitlabform.ssl_verify
74
- timeout = gitlabform.timeout
75
80
  session = gitlabform.session
76
81
 
77
- graphql = GraphQL(url=url, token=token)
82
+ graphql_kwargs = {k: v for k, v in gitlabform.gitlab_config.items() if k in self.GRAPHQL_PARAMS}
83
+ graphql = GraphQL(**graphql_kwargs)
84
+
85
+ default_gitlab_kwargs = {
86
+ "retry_transient_errors": True,
87
+ }
88
+ renamed_gitlab_kwargs = {
89
+ # Bandit is used for security scanning and it complains about 'private_token' being
90
+ # a hardcoded secret. However, in this case we are just renaming a config key
91
+ # provided by the user to match the parameter name expected by python-gitlab.
92
+ # Hence, we can safely ignore this code security warning here.
93
+ "token": "private_token", # nosec B105
94
+ }
95
+ extra_gitlab_kwargs = {
96
+ **default_gitlab_kwargs,
97
+ **{
98
+ k: v
99
+ for k, v in gitlabform.gitlab_config.items()
100
+ if k not in renamed_gitlab_kwargs and k in self.GITLAB_CLIENT_PARAMS
101
+ },
102
+ **{renamed_gitlab_kwargs[k]: v for k, v in gitlabform.gitlab_config.items() if k in renamed_gitlab_kwargs},
103
+ }
78
104
 
79
105
  self._gitlab: PythonGitlab = PythonGitlab(
80
- url=url,
81
- private_token=token,
82
- ssl_verify=ssl_verify,
83
- timeout=timeout,
84
106
  api_version="4",
85
- retry_transient_errors=True,
86
107
  graphql=graphql,
87
108
  session=session,
109
+ **extra_gitlab_kwargs,
88
110
  )
89
111
 
90
112
  def get_gitlab(self):
@@ -22,32 +22,47 @@ class GitLabCore:
22
22
  def __init__(self, config_path=None, config_string=None):
23
23
  self.configuration = Configuration(config_path, config_string)
24
24
 
25
- self.url = self.configuration.get("gitlab|url", os.getenv("GITLAB_URL"))
26
- self.token = self.configuration.get("gitlab|token", os.getenv("GITLAB_TOKEN"))
27
- self.ssl_verify = self.configuration.get("gitlab|ssl_verify", True)
28
- self.timeout = self.configuration.get("gitlab|timeout", 10)
25
+ default_gitlab_config = {
26
+ "url": os.getenv("GITLAB_URL"),
27
+ "token": os.getenv("GITLAB_TOKEN"),
28
+ "ssl_verify": True,
29
+ "timeout": 10,
30
+ "max_retries": 3,
31
+ "backoff_factor": 0.25,
32
+ "retry_transient_errors": True,
33
+ }
34
+ gitlab_config_from_file = self.configuration.get("gitlab", {})
35
+ self.gitlab_config = {**default_gitlab_config, **gitlab_config_from_file}
29
36
 
30
37
  self.session = requests.Session()
31
38
 
39
+ retries_status_forcelist = []
40
+ if self.gitlab_config["retry_transient_errors"]:
41
+ # 429 Too Many Requests is included to handle rate limiting
42
+ # Ideally we would like to handle Rate Limiting retrys based on 'Retry-After' header
43
+ # As done in python-gitlab: https://github.com/python-gitlab/python-gitlab/blob/main/docs/api-usage-advanced.rst#rate-limits
44
+ # 5xx status codes are included to retry after transient server errors
45
+ retries_status_forcelist = [429, 500, 502, 503, 504] + list(range(520, 531))
46
+
32
47
  retries = Retry(
33
- total=3,
34
- backoff_factor=0.25,
35
- status_forcelist=[500, 502, 503, 504] + list(range(520, 531)),
48
+ total=self.gitlab_config["max_retries"],
49
+ backoff_factor=self.gitlab_config["backoff_factor"],
50
+ status_forcelist=retries_status_forcelist,
36
51
  )
37
52
 
38
53
  self.session.mount("http://", HTTPAdapter(max_retries=retries))
39
54
  self.session.mount("https://", HTTPAdapter(max_retries=retries))
40
55
 
41
- self.session.verify = self.ssl_verify
42
- if not self.ssl_verify:
56
+ self.session.verify = self.gitlab_config["ssl_verify"]
57
+ if not self.gitlab_config["ssl_verify"]:
43
58
  urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
44
59
 
45
60
  self.gitlabform_version = package_version("gitlabform")
46
61
  self.requests_version = package_version("requests")
47
62
  self.session.headers.update(
48
63
  {
49
- "private-token": self.token,
50
- "authorization": f"Bearer {self.token}",
64
+ "private-token": self.gitlab_config["token"],
65
+ "authorization": f"Bearer {self.gitlab_config['token']}",
51
66
  "user-agent": f"GitLabForm/{self.gitlabform_version} (python-requests/{self.requests_version})",
52
67
  }
53
68
  )
@@ -178,15 +193,15 @@ class GitLabCore:
178
193
  if dict_data and json_data:
179
194
  raise Exception("You need to pass the data either as dict (dict_data) or JSON (json_data), not both!")
180
195
 
181
- url = f"{self.url}/api/v4/{self._format_with_url_encoding(path_as_format_string, args)}"
196
+ url = f"{self.gitlab_config['url']}/api/v4/{self._format_with_url_encoding(path_as_format_string, args)}"
182
197
  if dict_data:
183
- response = self.session.request(method, url, data=dict_data, timeout=self.timeout)
198
+ response = self.session.request(method, url, data=dict_data, timeout=self.gitlab_config["timeout"])
184
199
  debug(f"===> data = {to_str(dict_data)}")
185
200
  elif json_data:
186
- response = self.session.request(method, url, json=json_data, timeout=self.timeout)
201
+ response = self.session.request(method, url, json=json_data, timeout=self.gitlab_config["timeout"])
187
202
  debug(f"===> json = {to_str(json_data)}")
188
203
  else:
189
- response = self.session.request(method, url, timeout=self.timeout)
204
+ response = self.session.request(method, url, timeout=self.gitlab_config["timeout"])
190
205
 
191
206
  if response.status_code in expected_codes:
192
207
  # if we accept error responses then they will likely not contain a JSON body
@@ -43,6 +43,7 @@ from gitlabform.processors.shared.protected_environments_processor import (
43
43
  from gitlabform.processors.project.project_security_settings import (
44
44
  ProjectSecuritySettingsProcessor,
45
45
  )
46
+ from gitlabform.processors.project.remote_mirrors_processor import RemoteMirrorsProcessor
46
47
 
47
48
 
48
49
  class ProjectProcessors(AbstractProcessors):
@@ -75,4 +76,5 @@ class ProjectProcessors(AbstractProcessors):
75
76
  ProtectedEnvironmentsProcessor(gitlab),
76
77
  MergeRequestsApprovals(gitlab),
77
78
  MergeRequestsApprovalRules(gitlab),
79
+ RemoteMirrorsProcessor(gitlab),
78
80
  ]
@@ -189,10 +189,14 @@ class FilesProcessor(AbstractProcessor):
189
189
  )
190
190
 
191
191
  except GitlabUpdateError as e:
192
- if e.response_code == 400 and "You are not allowed to push into this branch" in e.error_message:
193
- # ...but if not, then we can unprotect the branch, but only if we know how to
194
- # protect it again...
195
-
192
+ if (
193
+ e.response_code == 400 or e.response_code == 403
194
+ ) and "You are not allowed to push into this branch" in e.error_message:
195
+ # If the project is archived, modifying files is not allowed
196
+ if project.archived:
197
+ fatal(f"Project is archived, cannot modify files in it.: {e.error_message}")
198
+
199
+ # Otherwise, unprotect the branch but only if we know how to protect it again
196
200
  if configuration.get("branches|" + branch.name + "|protected"):
197
201
  debug(f"> Temporarily unprotecting the branch to '{operation}' a file in it...")
198
202
  # Delete operation on protected branch removes the protection only
@@ -0,0 +1,280 @@
1
+ from typing import Any, cast, Dict, Set, List, Optional
2
+ from urllib.parse import urlparse
3
+
4
+ from cli_ui import debug as verbose, info_1 as info, warning
5
+
6
+ from gitlab.exceptions import GitlabCreateError, GitlabUpdateError, GitlabDeleteError
7
+ from gitlab.v4.objects import Project, ProjectRemoteMirror
8
+ from gitlabform.gitlab import GitLab
9
+ from gitlabform.processors.abstract_processor import AbstractProcessor
10
+
11
+
12
+ class RemoteMirrorsProcessor(AbstractProcessor):
13
+ """
14
+ A processor for the "remote_mirrors" configuration section.
15
+
16
+ Allows creating, updating, and deleting remote mirrors (push mirrors) for a project.
17
+
18
+ GitLabForm follows "raw parameter passing" pattern, which means that any parameter
19
+ supported by the GitLab API for remote mirrors can be used here.
20
+ The URL containing credentials (if any) is used as the config key for each mirror.
21
+ All other attributes are passed as-is to the GitLab API.
22
+ See: https://docs.gitlab.com/ee/api/remote_mirrors.html
23
+
24
+ Additionally, the following GitLabForm-specific keys are supported:
25
+ * enforce: (boolean) If true, mirrors not defined in the config will be deleted. This is a global option, not per-mirror.
26
+ * print_details: (boolean) If true, prints the full details of all mirrors found in GitLab for the project. This is a global option.
27
+ * force_push: (boolean) If true, triggers an immediate push sync.
28
+ * force_update: (boolean) If true, forces an update call even if the config looks unchanged (useful for updating credentials).
29
+ * print_public_key: (boolean) If true, retrieves and prints the SSH public key for the mirror (if applicable).
30
+ * delete: (boolean) If true, deletes the specified mirror.
31
+
32
+ Configuration example:
33
+
34
+ remote_mirrors:
35
+ enforce: true
36
+ "https://username:password@example.com/gitlab/project.git":
37
+ enabled: true
38
+ auth_method: password
39
+ only_protected_branches: true
40
+ force_push: true
41
+ """
42
+
43
+ def __init__(self, gitlab: GitLab):
44
+ super().__init__("remote_mirrors", gitlab)
45
+
46
+ @staticmethod
47
+ def _normalize_url_for_comparison(url: str) -> str:
48
+ """Normalize URL for comparison by removing credentials.
49
+
50
+ Given a mirror URL for password-based authentication,
51
+ this method returns the corresponding URL without credentials.
52
+
53
+ Example:
54
+ http://username:password@host/path.git -> http://host/path.git
55
+
56
+ This can be used to compare mirror URLs without credentials to
57
+ find matching mirrors.
58
+ """
59
+ from urllib.parse import urlparse
60
+
61
+ parsed = urlparse(url)
62
+ # Remove credentials (user:pass@) from netloc
63
+ clean_netloc = parsed.netloc.split("@")[-1]
64
+ return parsed._replace(netloc=clean_netloc).geturl()
65
+
66
+ def _process_configuration(self, project_and_group: str, configuration: Dict[str, Any]) -> None:
67
+ project: Project = self.gl.get_project_by_path_cached(project_and_group)
68
+
69
+ # 1. PREPARATION & OPTIMIZATION
70
+ mirrors_in_gitlab: List[ProjectRemoteMirror] = project.remote_mirrors.list(get_all=True)
71
+ gitlab_mirrors_map: Dict[str, ProjectRemoteMirror] = {
72
+ self._normalize_url_for_comparison(m.url): cast(ProjectRemoteMirror, m) for m in mirrors_in_gitlab
73
+ }
74
+
75
+ mirrors_in_config: Dict[str, Any] = configuration.get("remote_mirrors", {}).copy()
76
+
77
+ # --- GLOBAL OPTIONS ---
78
+ enforce_mirrors: bool = mirrors_in_config.pop("enforce", False)
79
+ print_details: bool = mirrors_in_config.pop("print_details", False)
80
+
81
+ urls_to_keep: Set[str] = set()
82
+
83
+ # 2. PROCESS CONFIGURATION (Create / Update / Delete)
84
+ for mirror_url in sorted(mirrors_in_config.keys()):
85
+ mirror_settings: Dict[str, Any] = mirrors_in_config[mirror_url]
86
+ norm_url: str = self._normalize_url_for_comparison(mirror_url)
87
+ mirror_in_gitlab = gitlab_mirrors_map.get(norm_url)
88
+
89
+ # --- CASE: EXPLICIT DELETE ---
90
+ if mirror_settings.get("delete"):
91
+ if mirror_in_gitlab:
92
+ self._delete_remote_mirror(mirror_in_gitlab)
93
+ gitlab_mirrors_map.pop(norm_url, None)
94
+ else:
95
+ verbose(f"Skip deleting remote mirror '{norm_url}', because it doesn't exist")
96
+ continue
97
+
98
+ # --- CASE: CREATE OR UPDATE ---
99
+ urls_to_keep.add(norm_url)
100
+
101
+ # Prepare payload: Extract local-only options
102
+ mirror_payload: Dict[str, Any] = {"url": mirror_url, **mirror_settings}
103
+ force_push: bool = mirror_payload.pop("force_push", False)
104
+ force_update: bool = mirror_payload.pop("force_update", False)
105
+ print_public_key: bool = mirror_payload.pop("print_public_key", False)
106
+ mirror_payload.pop("delete", None)
107
+
108
+ if mirror_in_gitlab:
109
+ self._update_existing_mirror(project, mirror_in_gitlab, mirror_payload, norm_url, force_update)
110
+ else:
111
+ mirror_in_gitlab = self._create_new_mirror(project, mirror_payload, mirror_url)
112
+
113
+ if print_public_key and mirror_in_gitlab:
114
+ self._handle_public_key_display(project, mirror_in_gitlab, norm_url)
115
+
116
+ if force_push and mirror_in_gitlab:
117
+ self._sync_remote_mirror(mirror_in_gitlab)
118
+
119
+ # 3. ENFORCEMENT PHASE
120
+ if enforce_mirrors:
121
+ self._enforce_mirrors(gitlab_mirrors_map, urls_to_keep)
122
+
123
+ # 4. REPORTING PHASE (Final State)
124
+ if print_details:
125
+ # We fetch a fresh list to show the final state after all updates/syncs
126
+ final_mirrors: List[ProjectRemoteMirror] = project.remote_mirrors.list(get_all=True)
127
+ if not final_mirrors:
128
+ info("🔍 No remote mirrors found for this project.")
129
+ else:
130
+ info(f"📋 Final Remote Mirror Report for '{project_and_group}':")
131
+ for mirror in final_mirrors:
132
+ info(" " + "─" * 30) # Visual separator using a light line
133
+ self._report_mirror_details(mirror)
134
+ info(" " + "─" * 30)
135
+
136
+ def _handle_public_key_display(self, project: Project, mirror_obj: ProjectRemoteMirror, norm_url: str) -> None:
137
+ """
138
+ Retrieves and prints the SSH public key for a mirror.
139
+ GitLab only provides this for mirrors configured with 'ssh_public_key' auth.
140
+ """
141
+ # Only attempt retrieval if the auth method supports it
142
+ if getattr(mirror_obj, "auth_method", None) != "ssh_public_key":
143
+ verbose(f"Skipping public key display for '{norm_url}': auth_method is not 'ssh_public_key'")
144
+ return
145
+
146
+ public_key: Optional[str] = None
147
+ try:
148
+ # TODO: python-gitlab does not yet support retrieving the public key via
149
+ # ProjectRemoteMirror object (e.g., mirror_obj.get_public_key()).
150
+ # Switch to native method once supported in the library.
151
+
152
+ # Mypy fix: cast the union return type (dict | Response) to dict[str, Any]
153
+ response = cast(
154
+ Dict[str, Any],
155
+ project.manager.gitlab.http_get(f"/projects/{project.id}/remote_mirrors/{mirror_obj.id}/public_key"),
156
+ )
157
+ public_key = response.get("public_key")
158
+ except Exception as e:
159
+ warning(f"Failed to retrieve SSH public key for mirror {norm_url}: {e}")
160
+
161
+ if public_key:
162
+ info(f"🔑 SSH Public Key for mirror '{norm_url}':")
163
+ info(public_key)
164
+ info("👆 This public key must be added to the target repository to authorize the mirror.")
165
+ info(
166
+ "Please consult the GitLab documentation on 'Repository Mirroring' for specific setup instructions for your target platform."
167
+ )
168
+ else:
169
+ verbose(f"No public key available to display for mirror '{norm_url}'")
170
+
171
+ def _needs_update(self, existing_mirror: Dict[str, Any], config_payload: Dict[str, Any]) -> bool:
172
+ """
173
+ Overrides the base comparison to handle GitLab's URL credential masking.
174
+ Normalization is applied so that 'user:pass@host' matches '*****:*****@host'.
175
+ """
176
+ comparison_payload: Dict[str, Any] = config_payload.copy()
177
+ if "url" in comparison_payload:
178
+ comparison_payload["url"] = self._normalize_url_for_comparison(comparison_payload["url"])
179
+
180
+ existing_mirror_dict = existing_mirror.copy()
181
+
182
+ existing_mirror_dict["url"] = self._normalize_url_for_comparison(existing_mirror_dict.get("url", ""))
183
+
184
+ return super()._needs_update(existing_mirror_dict, comparison_payload)
185
+
186
+ def _update_existing_mirror(
187
+ self,
188
+ project: Project,
189
+ mirror_obj: ProjectRemoteMirror,
190
+ payload: Dict[str, Any],
191
+ norm_url: str,
192
+ force_update: bool,
193
+ ) -> None:
194
+ """Compares and updates an existing mirror if changed or if force_update is set."""
195
+
196
+ should_update: bool = force_update or self._needs_update(mirror_obj.asdict(), payload)
197
+
198
+ if should_update:
199
+ if force_update:
200
+ verbose(f"Mirror '{norm_url}' update is being forced via 'force_update' flag.")
201
+
202
+ verbose(f"Updating remote mirror '{norm_url}' with latest config")
203
+ try:
204
+ project.remote_mirrors.update(id=mirror_obj.id, new_data=payload)
205
+ verbose(f"Updated remote mirror '{norm_url}'")
206
+
207
+ if force_update:
208
+ info(
209
+ f"!!! REMINDER: 'force_update' was used for mirror '{norm_url}'. "
210
+ "Please remove this flag from your configuration to avoid unnecessary API calls in future runs."
211
+ )
212
+ except GitlabUpdateError as e:
213
+ warning(f"Failed to update remote mirror {norm_url}: {e}")
214
+ else:
215
+ verbose(f"Remote mirror '{norm_url}' remains unchanged")
216
+
217
+ def _create_new_mirror(
218
+ self, project: Project, payload: Dict[str, Any], raw_url: str
219
+ ) -> Optional[ProjectRemoteMirror]:
220
+ """Creates a new remote mirror and handles API errors."""
221
+ norm_url = self._normalize_url_for_comparison(raw_url)
222
+ verbose(f"Creating remote mirror '{norm_url}'")
223
+ try:
224
+ return cast(ProjectRemoteMirror, project.remote_mirrors.create(payload))
225
+ except GitlabCreateError as e:
226
+ warning(f"Failed to create remote mirror {norm_url}: {e}")
227
+ return None
228
+
229
+ def _enforce_mirrors(self, gitlab_mirrors_map: Dict[str, ProjectRemoteMirror], urls_to_keep: Set[str]) -> None:
230
+ """Deletes mirrors present in GitLab that are not in the configuration."""
231
+ for norm_url, gm in gitlab_mirrors_map.items():
232
+ if norm_url not in urls_to_keep:
233
+ verbose(f"Enforce: Deleting remote mirror '{gm.url}' as it is not in the configuration")
234
+ self._delete_remote_mirror(gm)
235
+
236
+ def _delete_remote_mirror(self, mirror: ProjectRemoteMirror) -> None:
237
+ """Delete the given `ProjectRemoteMirror` and handle errors."""
238
+ verbose(f"Deleting remote mirror '{mirror.url}'")
239
+ try:
240
+ mirror.delete()
241
+ except GitlabDeleteError as e:
242
+ warning(
243
+ f"Failed to delete remote mirror id={getattr(mirror, 'id', None)} url={getattr(mirror, 'url', None)}: {e}"
244
+ )
245
+ verbose(f"Failed to delete remote mirror '{mirror.url}'")
246
+
247
+ def _sync_remote_mirror(self, mirror: ProjectRemoteMirror) -> None:
248
+ """Trigger sync for remote mirror when `force_push` is requested."""
249
+ mirror_id = getattr(mirror, "id", None)
250
+ mirror_url = getattr(mirror, "url", None)
251
+ verbose(f"Attempting sync for remote mirror id={mirror_id} url={mirror_url}")
252
+
253
+ try:
254
+ result = mirror.sync()
255
+ verbose(f"Triggered sync for remote mirror '{mirror_url}' result={result}")
256
+ except Exception as e:
257
+ warning(f"Failed to trigger sync for remote mirror id={mirror_id} url={mirror_url}: {e}")
258
+
259
+ def _report_mirror_details(self, mirror: ProjectRemoteMirror) -> None:
260
+ """Prints every attribute of the mirror object, one per line."""
261
+
262
+ mirror_data = mirror.asdict()
263
+
264
+ # Mapping statuses to helpful visual cues
265
+ status_icons = {
266
+ "finished": "✅",
267
+ "started": "⏳",
268
+ "scheduled": "📅",
269
+ "failed": "❌",
270
+ "none": "⚪",
271
+ }
272
+
273
+ for key, value in sorted(mirror_data.items()):
274
+ if key == "update_status":
275
+ icon = status_icons.get(value, "❓")
276
+ info(f" - {key}: {icon} {value}")
277
+ elif key == "last_error" and value:
278
+ info(f" - {key}: ⚠️ {value}")
279
+ else:
280
+ info(f" - {key}: {value}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlabform
3
- Version: 4.6.1
3
+ Version: 4.7.0
4
4
  Summary: 🏗 Specialized configuration as a code tool for GitLab projects, groups and more using hierarchical configuration written in YAML
5
5
  Author: Greg Dubicki and Contributors
6
6
  Project-URL: Homepage, https://gitlabform.github.io/gitlabform/
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Version Control :: Git
20
20
  Requires-Python: >=3.12.0
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: certifi==2025.10.5
23
+ Requires-Dist: certifi==2026.1.4
24
24
  Requires-Dist: cli-ui==0.19.0
25
25
  Requires-Dist: ez-yaml==1.2.0
26
26
  Requires-Dist: Jinja2==3.1.6
@@ -28,24 +28,24 @@ Requires-Dist: luddite==1.0.4
28
28
  Requires-Dist: MarkupSafe==3.0.3
29
29
  Requires-Dist: mergedeep==1.3.4
30
30
  Requires-Dist: packaging==25.0
31
- Requires-Dist: python-gitlab==7.0.0
32
- Requires-Dist: python-gitlab[graphql]==7.0.0
31
+ Requires-Dist: python-gitlab==7.1.0
32
+ Requires-Dist: python-gitlab[graphql]==7.1.0
33
33
  Requires-Dist: requests==2.32.5
34
34
  Requires-Dist: ruamel.yaml==0.17.21
35
- Requires-Dist: types-requests==2.32.4.20250913
36
- Requires-Dist: types-setuptools==80.9.0.20250822
35
+ Requires-Dist: types-requests==2.32.4.20260107
36
+ Requires-Dist: types-setuptools==80.9.0.20251223
37
37
  Requires-Dist: yamlpath==3.8.2
38
38
  Provides-Extra: test
39
- Requires-Dist: coverage==7.11.0; extra == "test"
39
+ Requires-Dist: coverage==7.13.1; extra == "test"
40
40
  Requires-Dist: cryptography==46.0.3; extra == "test"
41
41
  Requires-Dist: deepdiff==8.6.1; extra == "test"
42
- Requires-Dist: mypy==1.18.2; extra == "test"
42
+ Requires-Dist: mypy==1.19.1; extra == "test"
43
43
  Requires-Dist: mypy-extensions==1.1.0; extra == "test"
44
44
  Requires-Dist: pre-commit==2.21.0; extra == "test"
45
- Requires-Dist: pytest==8.4.2; extra == "test"
45
+ Requires-Dist: pytest==9.0.2; extra == "test"
46
46
  Requires-Dist: pytest-cov==7.0.0; extra == "test"
47
47
  Requires-Dist: pytest-rerunfailures==16.1; extra == "test"
48
- Requires-Dist: xkcdpass==1.20.0; extra == "test"
48
+ Requires-Dist: xkcdpass==1.30.0; extra == "test"
49
49
  Provides-Extra: docs
50
50
  Requires-Dist: mkdocs; extra == "docs"
51
51
  Requires-Dist: mkdocs-material; extra == "docs"
@@ -72,6 +72,7 @@ gitlabform/processors/project/project_processor.py
72
72
  gitlabform/processors/project/project_push_rules_processor.py
73
73
  gitlabform/processors/project/project_security_settings.py
74
74
  gitlabform/processors/project/project_settings_processor.py
75
+ gitlabform/processors/project/remote_mirrors_processor.py
75
76
  gitlabform/processors/project/resource_groups_processor.py
76
77
  gitlabform/processors/project/schedules_processor.py
77
78
  gitlabform/processors/project/tags_processor.py
@@ -1,4 +1,4 @@
1
- certifi==2025.10.5
1
+ certifi==2026.1.4
2
2
  cli-ui==0.19.0
3
3
  ez-yaml==1.2.0
4
4
  Jinja2==3.1.6
@@ -6,12 +6,12 @@ luddite==1.0.4
6
6
  MarkupSafe==3.0.3
7
7
  mergedeep==1.3.4
8
8
  packaging==25.0
9
- python-gitlab==7.0.0
10
- python-gitlab[graphql]==7.0.0
9
+ python-gitlab==7.1.0
10
+ python-gitlab[graphql]==7.1.0
11
11
  requests==2.32.5
12
12
  ruamel.yaml==0.17.21
13
- types-requests==2.32.4.20250913
14
- types-setuptools==80.9.0.20250822
13
+ types-requests==2.32.4.20260107
14
+ types-setuptools==80.9.0.20251223
15
15
  yamlpath==3.8.2
16
16
 
17
17
  [docs]
@@ -19,13 +19,13 @@ mkdocs
19
19
  mkdocs-material
20
20
 
21
21
  [test]
22
- coverage==7.11.0
22
+ coverage==7.13.1
23
23
  cryptography==46.0.3
24
24
  deepdiff==8.6.1
25
- mypy==1.18.2
25
+ mypy==1.19.1
26
26
  mypy-extensions==1.1.0
27
27
  pre-commit==2.21.0
28
- pytest==8.4.2
28
+ pytest==9.0.2
29
29
  pytest-cov==7.0.0
30
30
  pytest-rerunfailures==16.1
31
- xkcdpass==1.20.0
31
+ xkcdpass==1.30.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitlabform"
7
- version = "4.6.1"
7
+ version = "4.7.0"
8
8
  authors = [{ name = "Greg Dubicki and Contributors" }]
9
9
  description = "🏗 Specialized configuration as a code tool for GitLab projects, groups and more using hierarchical configuration written in YAML"
10
10
  keywords = ["cli", "yaml", "gitlab", "configuration-as-code"]
@@ -23,7 +23,7 @@ classifiers = [
23
23
  ]
24
24
  requires-python = ">=3.12.0"
25
25
  dependencies = [
26
- "certifi==2025.10.5",
26
+ "certifi==2026.1.4",
27
27
  "cli-ui==0.19.0",
28
28
  "ez-yaml==1.2.0",
29
29
  "Jinja2==3.1.6",
@@ -31,12 +31,12 @@ dependencies = [
31
31
  "MarkupSafe==3.0.3",
32
32
  "mergedeep==1.3.4",
33
33
  "packaging==25.0",
34
- "python-gitlab==7.0.0",
35
- "python-gitlab[graphql]==7.0.0",
34
+ "python-gitlab==7.1.0",
35
+ "python-gitlab[graphql]==7.1.0",
36
36
  "requests==2.32.5",
37
37
  "ruamel.yaml==0.17.21",
38
- "types-requests==2.32.4.20250913",
39
- "types-setuptools==80.9.0.20250822",
38
+ "types-requests==2.32.4.20260107",
39
+ "types-setuptools==80.9.0.20251223",
40
40
  "yamlpath==3.8.2",
41
41
  ]
42
42
 
@@ -52,16 +52,16 @@ content-type = "text/markdown"
52
52
 
53
53
  [project.optional-dependencies]
54
54
  test = [
55
- "coverage==7.11.0",
55
+ "coverage==7.13.1",
56
56
  "cryptography==46.0.3",
57
57
  "deepdiff==8.6.1",
58
- "mypy==1.18.2",
58
+ "mypy==1.19.1",
59
59
  "mypy-extensions==1.1.0",
60
60
  "pre-commit==2.21.0",
61
- "pytest==8.4.2",
61
+ "pytest==9.0.2",
62
62
  "pytest-cov==7.0.0",
63
63
  "pytest-rerunfailures==16.1",
64
- "xkcdpass==1.20.0",
64
+ "xkcdpass==1.30.0",
65
65
  ]
66
66
  docs = ["mkdocs", "mkdocs-material"]
67
67
 
File without changes
File without changes
File without changes
File without changes