saltext.github 1.1.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.
- saltext/github/__init__.py +31 -0
- saltext/github/modules/__init__.py +0 -0
- saltext/github/modules/github.py +1873 -0
- saltext/github/modules/github_org.py +667 -0
- saltext/github/modules/github_repo.py +1658 -0
- saltext/github/modules/github_user.py +75 -0
- saltext/github/resources/__init__.py +0 -0
- saltext/github/resources/github/__init__.py +251 -0
- saltext/github/resources/github/modules/__init__.py +0 -0
- saltext/github/resources/github/modules/actions.py +175 -0
- saltext/github/resources/github/modules/config.py +69 -0
- saltext/github/resources/github/modules/deploy_key.py +122 -0
- saltext/github/resources/github/modules/environment.py +241 -0
- saltext/github/resources/github/modules/grains.py +18 -0
- saltext/github/resources/github/modules/issue.py +157 -0
- saltext/github/resources/github/modules/label.py +161 -0
- saltext/github/resources/github/modules/milestone.py +103 -0
- saltext/github/resources/github/modules/pages.py +149 -0
- saltext/github/resources/github/modules/pull.py +90 -0
- saltext/github/resources/github/modules/repo.py +155 -0
- saltext/github/resources/github/modules/ruleset.py +144 -0
- saltext/github/resources/github/modules/saltutil.py +24 -0
- saltext/github/resources/github/modules/state.py +116 -0
- saltext/github/resources/github/modules/test.py +29 -0
- saltext/github/resources/github/states/__init__.py +0 -0
- saltext/github/resources/github/states/actions.py +117 -0
- saltext/github/resources/github/states/deploy_key.py +130 -0
- saltext/github/resources/github/states/environment.py +99 -0
- saltext/github/resources/github/states/label.py +71 -0
- saltext/github/resources/github/states/pages.py +92 -0
- saltext/github/resources/github/states/repo.py +108 -0
- saltext/github/resources/github/states/ruleset.py +81 -0
- saltext/github/states/__init__.py +0 -0
- saltext/github/states/github.py +966 -0
- saltext/github/states/github_org.py +369 -0
- saltext/github/states/github_repo.py +722 -0
- saltext/github/utils/__init__.py +72 -0
- saltext/github/utils/gh/__init__.py +14 -0
- saltext/github/utils/gh/actions.py +195 -0
- saltext/github/utils/gh/cache.py +126 -0
- saltext/github/utils/gh/client.py +534 -0
- saltext/github/utils/gh/deploy_keys.py +121 -0
- saltext/github/utils/gh/environments.py +271 -0
- saltext/github/utils/gh/exceptions.py +28 -0
- saltext/github/utils/gh/grains_files.py +366 -0
- saltext/github/utils/gh/issues.py +193 -0
- saltext/github/utils/gh/labels.py +142 -0
- saltext/github/utils/gh/members.py +157 -0
- saltext/github/utils/gh/milestones.py +122 -0
- saltext/github/utils/gh/pages.py +128 -0
- saltext/github/utils/gh/profile.py +458 -0
- saltext/github/utils/gh/pulls.py +99 -0
- saltext/github/utils/gh/repos.py +465 -0
- saltext/github/utils/gh/resource.py +35 -0
- saltext/github/utils/gh/rulesets.py +191 -0
- saltext/github/utils/gh/statecores/__init__.py +70 -0
- saltext/github/utils/gh/statecores/actions.py +180 -0
- saltext/github/utils/gh/statecores/deploy_keys.py +241 -0
- saltext/github/utils/gh/statecores/environments.py +212 -0
- saltext/github/utils/gh/statecores/labels.py +116 -0
- saltext/github/utils/gh/statecores/pages.py +177 -0
- saltext/github/utils/gh/statecores/repo.py +249 -0
- saltext/github/utils/gh/statecores/rulesets.py +115 -0
- saltext/github/utils/gh/teams.py +375 -0
- saltext/github/utils/gh/users.py +87 -0
- saltext/github/version.py +1 -0
- saltext_github-1.1.0.dist-info/METADATA +151 -0
- saltext_github-1.1.0.dist-info/RECORD +73 -0
- saltext_github-1.1.0.dist-info/WHEEL +5 -0
- saltext_github-1.1.0.dist-info/entry_points.txt +2 -0
- saltext_github-1.1.0.dist-info/licenses/LICENSE +201 -0
- saltext_github-1.1.0.dist-info/licenses/NOTICE +10 -0
- saltext_github-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Operations on GitHub repository (deployment) environments.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from salt.exceptions import CommandExecutionError
|
|
9
|
+
|
|
10
|
+
from saltext.github.utils import filter_unset
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _path(*suffix):
|
|
16
|
+
# Environment names can contain characters that are invalid in URL paths
|
|
17
|
+
return ["environments"] + [quote(str(segment), safe="") for segment in suffix]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def branch_policy_from_patterns(deployment_branches):
|
|
21
|
+
"""
|
|
22
|
+
Map a ``deployment_branches`` specification (``all``, ``protected``
|
|
23
|
+
or a list of branch name patterns) onto the API's
|
|
24
|
+
``deployment_branch_policy`` setting.
|
|
25
|
+
|
|
26
|
+
deployment_branches
|
|
27
|
+
Which branches can deploy. Either ``all``, ``protected``
|
|
28
|
+
(branches with branch protection rules) or a list of branch
|
|
29
|
+
name patterns.
|
|
30
|
+
"""
|
|
31
|
+
if deployment_branches == "all":
|
|
32
|
+
return None
|
|
33
|
+
if deployment_branches == "protected":
|
|
34
|
+
return {"protected_branches": True, "custom_branch_policies": False}
|
|
35
|
+
if isinstance(deployment_branches, list):
|
|
36
|
+
return {"protected_branches": False, "custom_branch_policies": True}
|
|
37
|
+
raise CommandExecutionError(
|
|
38
|
+
"deployment_branches must be 'all', 'protected' or a list of branch "
|
|
39
|
+
f"name patterns, got: {deployment_branches!r}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize(env):
|
|
44
|
+
"""
|
|
45
|
+
Extract the managed settings from an environment API payload into a
|
|
46
|
+
flat mapping with ``wait_timer``, ``prevent_self_review``, ``reviewers``
|
|
47
|
+
and ``deployment_branch_policy``. The API scatters them over
|
|
48
|
+
``protection_rules`` entries.
|
|
49
|
+
|
|
50
|
+
env
|
|
51
|
+
Environment payload to normalize.
|
|
52
|
+
"""
|
|
53
|
+
ret = {
|
|
54
|
+
"wait_timer": 0,
|
|
55
|
+
"prevent_self_review": False,
|
|
56
|
+
"reviewers": [],
|
|
57
|
+
"deployment_branch_policy": env.get("deployment_branch_policy"),
|
|
58
|
+
}
|
|
59
|
+
for rule in env.get("protection_rules") or []:
|
|
60
|
+
if rule.get("type") == "wait_timer":
|
|
61
|
+
ret["wait_timer"] = rule.get("wait_timer", 0)
|
|
62
|
+
elif rule.get("type") == "required_reviewers":
|
|
63
|
+
ret["prevent_self_review"] = rule.get("prevent_self_review", False)
|
|
64
|
+
ret["reviewers"] = [
|
|
65
|
+
{"type": reviewer["type"], "id": reviewer["reviewer"]["id"]}
|
|
66
|
+
for reviewer in rule.get("reviewers") or []
|
|
67
|
+
]
|
|
68
|
+
return ret
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def list_all(conn, repo_name, *, owner=None):
|
|
72
|
+
"""
|
|
73
|
+
Return all environments of a repository.
|
|
74
|
+
|
|
75
|
+
conn
|
|
76
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
77
|
+
|
|
78
|
+
repo_name
|
|
79
|
+
Repository name.
|
|
80
|
+
|
|
81
|
+
owner
|
|
82
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
83
|
+
"""
|
|
84
|
+
return conn.repo_query(owner, repo_name, "environments", per_page=100)["environments"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get(conn, repo_name, name, *, owner=None):
|
|
88
|
+
"""
|
|
89
|
+
Return the data of a single environment.
|
|
90
|
+
|
|
91
|
+
conn
|
|
92
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
93
|
+
|
|
94
|
+
repo_name
|
|
95
|
+
Repository name.
|
|
96
|
+
|
|
97
|
+
name
|
|
98
|
+
Environment name.
|
|
99
|
+
|
|
100
|
+
owner
|
|
101
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
102
|
+
|
|
103
|
+
Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError`
|
|
104
|
+
if the environment does not exist.
|
|
105
|
+
"""
|
|
106
|
+
return conn.repo_query(owner, repo_name, *_path(name))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def set_(
|
|
110
|
+
conn,
|
|
111
|
+
repo_name,
|
|
112
|
+
name,
|
|
113
|
+
*,
|
|
114
|
+
wait_timer=None,
|
|
115
|
+
prevent_self_review=None,
|
|
116
|
+
reviewers=None,
|
|
117
|
+
deployment_branch_policy=None,
|
|
118
|
+
owner=None,
|
|
119
|
+
):
|
|
120
|
+
"""
|
|
121
|
+
Create or update an environment, replacing its whole configuration.
|
|
122
|
+
Unspecified settings are reset to their defaults.
|
|
123
|
+
Returns the data of the environment.
|
|
124
|
+
|
|
125
|
+
conn
|
|
126
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
127
|
+
|
|
128
|
+
repo_name
|
|
129
|
+
Repository name.
|
|
130
|
+
|
|
131
|
+
name
|
|
132
|
+
Environment name.
|
|
133
|
+
|
|
134
|
+
wait_timer
|
|
135
|
+
Amount of time to delay a job after it is initially triggered,
|
|
136
|
+
in minutes.
|
|
137
|
+
|
|
138
|
+
prevent_self_review
|
|
139
|
+
Prevent a user who created a job from approving it.
|
|
140
|
+
|
|
141
|
+
reviewers
|
|
142
|
+
People or teams that may review jobs that reference the
|
|
143
|
+
environment, as a list of mappings with ``type`` (``User`` or
|
|
144
|
+
``Team``) and ``id``.
|
|
145
|
+
|
|
146
|
+
deployment_branch_policy
|
|
147
|
+
Type of deployment branch policy for this environment, either
|
|
148
|
+
``None`` (default) to allow all branches or a mapping with
|
|
149
|
+
``protected_branches`` and ``custom_branch_policies`` booleans.
|
|
150
|
+
Branch name patterns for custom policies are managed separately,
|
|
151
|
+
see :func:`create_branch_policy`.
|
|
152
|
+
|
|
153
|
+
owner
|
|
154
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
155
|
+
"""
|
|
156
|
+
data = filter_unset(
|
|
157
|
+
{
|
|
158
|
+
"wait_timer": wait_timer,
|
|
159
|
+
"prevent_self_review": prevent_self_review,
|
|
160
|
+
"reviewers": reviewers,
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
data["deployment_branch_policy"] = deployment_branch_policy
|
|
164
|
+
return conn.repo_query(owner, repo_name, *_path(name), method="PUT", data=data)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def delete(conn, repo_name, name, *, owner=None):
|
|
168
|
+
"""
|
|
169
|
+
Delete an environment from a repository.
|
|
170
|
+
|
|
171
|
+
conn
|
|
172
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
173
|
+
|
|
174
|
+
repo_name
|
|
175
|
+
Repository name.
|
|
176
|
+
|
|
177
|
+
name
|
|
178
|
+
Environment name.
|
|
179
|
+
|
|
180
|
+
owner
|
|
181
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
182
|
+
"""
|
|
183
|
+
conn.repo_query(owner, repo_name, *_path(name), method="DELETE")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def list_branch_policies(conn, repo_name, environment, *, owner=None):
|
|
187
|
+
"""
|
|
188
|
+
Return all deployment branch policies of an environment.
|
|
189
|
+
|
|
190
|
+
conn
|
|
191
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
192
|
+
|
|
193
|
+
repo_name
|
|
194
|
+
Repository name.
|
|
195
|
+
|
|
196
|
+
environment
|
|
197
|
+
Environment name.
|
|
198
|
+
|
|
199
|
+
owner
|
|
200
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
201
|
+
"""
|
|
202
|
+
return conn.repo_query(
|
|
203
|
+
owner,
|
|
204
|
+
repo_name,
|
|
205
|
+
*_path(environment, "deployment-branch-policies"),
|
|
206
|
+
per_page=100,
|
|
207
|
+
)["branch_policies"]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def create_branch_policy(conn, repo_name, environment, name, *, typ="branch", owner=None):
|
|
211
|
+
"""
|
|
212
|
+
Add a deployment branch (or tag) policy to an environment.
|
|
213
|
+
Returns the data of the created policy.
|
|
214
|
+
|
|
215
|
+
The environment's ``deployment_branch_policy`` must have
|
|
216
|
+
``custom_branch_policies`` enabled.
|
|
217
|
+
|
|
218
|
+
conn
|
|
219
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
220
|
+
|
|
221
|
+
repo_name
|
|
222
|
+
Repository name.
|
|
223
|
+
|
|
224
|
+
environment
|
|
225
|
+
Environment name.
|
|
226
|
+
|
|
227
|
+
name
|
|
228
|
+
Name pattern that branches (or tags) must match in order to
|
|
229
|
+
deploy to the environment.
|
|
230
|
+
|
|
231
|
+
typ
|
|
232
|
+
Target of this rule, either ``branch`` or ``tag``.
|
|
233
|
+
Defaults to ``branch``.
|
|
234
|
+
|
|
235
|
+
owner
|
|
236
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
237
|
+
"""
|
|
238
|
+
return conn.repo_query(
|
|
239
|
+
owner,
|
|
240
|
+
repo_name,
|
|
241
|
+
*_path(environment, "deployment-branch-policies"),
|
|
242
|
+
method="POST",
|
|
243
|
+
data={"name": name, "type": typ},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def delete_branch_policy(conn, repo_name, environment, policy_id, *, owner=None):
|
|
248
|
+
"""
|
|
249
|
+
Remove a deployment branch policy from an environment.
|
|
250
|
+
|
|
251
|
+
conn
|
|
252
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
253
|
+
|
|
254
|
+
repo_name
|
|
255
|
+
Repository name.
|
|
256
|
+
|
|
257
|
+
environment
|
|
258
|
+
Environment name.
|
|
259
|
+
|
|
260
|
+
policy_id
|
|
261
|
+
Branch policy ID.
|
|
262
|
+
|
|
263
|
+
owner
|
|
264
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
265
|
+
"""
|
|
266
|
+
conn.repo_query(
|
|
267
|
+
owner,
|
|
268
|
+
repo_name,
|
|
269
|
+
*_path(environment, "deployment-branch-policies", policy_id),
|
|
270
|
+
method="DELETE",
|
|
271
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exceptions raised by the GitHub API helper modules.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from salt.exceptions import CommandExecutionError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GitHubApiError(CommandExecutionError):
|
|
9
|
+
"""
|
|
10
|
+
The GitHub API returned an error response.
|
|
11
|
+
|
|
12
|
+
status
|
|
13
|
+
HTTP status code of the response, if available.
|
|
14
|
+
|
|
15
|
+
data
|
|
16
|
+
Decoded response body, if available.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message, *, status=None, data=None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status = status
|
|
22
|
+
self.data = data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitHubNotFoundError(GitHubApiError):
|
|
26
|
+
"""
|
|
27
|
+
A requested GitHub resource does not exist.
|
|
28
|
+
"""
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom repository grains sourced from in-repo YAML/JSON files, as
|
|
3
|
+
declared via the :ghconf:`resource:grains_files` key of the ``github``
|
|
4
|
+
resource type.
|
|
5
|
+
|
|
6
|
+
A declaration maps grain namespaces to file specifications::
|
|
7
|
+
|
|
8
|
+
grains_files:
|
|
9
|
+
copier:
|
|
10
|
+
path: .copier-answers.yml
|
|
11
|
+
exclude:
|
|
12
|
+
- "_*"
|
|
13
|
+
data:
|
|
14
|
+
path:
|
|
15
|
+
- .github/metadata.json
|
|
16
|
+
- .github/other-metadata.json
|
|
17
|
+
include:
|
|
18
|
+
- team
|
|
19
|
+
- org:config
|
|
20
|
+
- service_tier: service:tier
|
|
21
|
+
|
|
22
|
+
``include``/``exclude`` entries are :py:mod:`fnmatch` patterns matched
|
|
23
|
+
case-sensitively against colon-delimited key paths, the same idiom Salt
|
|
24
|
+
uses for grain targeting. Note that ``*`` is not segment-aware (it can
|
|
25
|
+
cross ``:`` boundaries) and keys containing ``:`` cannot be addressed.
|
|
26
|
+
Mapping entries in ``include`` rename: the value at the exact source
|
|
27
|
+
path (scalar or subtree) is exposed under the new name as a flat
|
|
28
|
+
namespace key. Renames are not subject to ``exclude``. A plain mapping
|
|
29
|
+
as the ``include`` value is shorthand for a rename-only list.
|
|
30
|
+
|
|
31
|
+
The special ``_`` namespace merges its keys into the root grains level
|
|
32
|
+
instead of below a namespace key. Custom grains never overwrite
|
|
33
|
+
existing ones – the builtin identity grains and earlier namespaces
|
|
34
|
+
always win.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import copy
|
|
38
|
+
import fnmatch
|
|
39
|
+
import json
|
|
40
|
+
import logging
|
|
41
|
+
|
|
42
|
+
import salt.utils.data
|
|
43
|
+
import salt.utils.dictupdate
|
|
44
|
+
import salt.utils.yaml
|
|
45
|
+
import yaml
|
|
46
|
+
from salt.exceptions import CommandExecutionError
|
|
47
|
+
|
|
48
|
+
from saltext.github.utils.gh import repos as gh_repos
|
|
49
|
+
from saltext.github.utils.gh.exceptions import GitHubApiError
|
|
50
|
+
|
|
51
|
+
log = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
#: Grain keys provided by the resource type itself. ``grains_files``
|
|
54
|
+
#: namespaces must not shadow them — the resource identity is not
|
|
55
|
+
#: overridable by repository contents.
|
|
56
|
+
RESERVED_KEYS = frozenset(
|
|
57
|
+
{
|
|
58
|
+
"id",
|
|
59
|
+
"owner",
|
|
60
|
+
"repo",
|
|
61
|
+
"full_name",
|
|
62
|
+
"private",
|
|
63
|
+
"visibility",
|
|
64
|
+
"fork",
|
|
65
|
+
"archived",
|
|
66
|
+
"disabled",
|
|
67
|
+
"is_template",
|
|
68
|
+
"default_branch",
|
|
69
|
+
"language",
|
|
70
|
+
"license",
|
|
71
|
+
"topics",
|
|
72
|
+
"custom_properties",
|
|
73
|
+
"html_url",
|
|
74
|
+
"clone_url",
|
|
75
|
+
"ssh_url",
|
|
76
|
+
"description",
|
|
77
|
+
"homepage",
|
|
78
|
+
"created_at",
|
|
79
|
+
"has_discussions",
|
|
80
|
+
"has_pages",
|
|
81
|
+
"has_projects",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_SPEC_KEYS = frozenset({"path", "include", "exclude"})
|
|
86
|
+
|
|
87
|
+
#: Sentinel for failed path traversal (``None`` is a valid value).
|
|
88
|
+
_MISSING = object()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _invalid(namespace, msg):
|
|
92
|
+
return CommandExecutionError(f"Invalid grains_files namespace '{namespace}': {msg}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate(spec):
|
|
96
|
+
"""
|
|
97
|
+
Normalize and validate a ``grains_files`` declaration value.
|
|
98
|
+
|
|
99
|
+
Returns the normalized form, a mapping of namespace to
|
|
100
|
+
``{"path": [str, ...], "include": [str | (new_name, source), ...] | None,
|
|
101
|
+
"exclude": [str, ...]}``. Raises a ``CommandExecutionError`` when the
|
|
102
|
+
declaration is malformed.
|
|
103
|
+
|
|
104
|
+
spec
|
|
105
|
+
The declaration value. ``None`` is treated as unset.
|
|
106
|
+
"""
|
|
107
|
+
if spec is None:
|
|
108
|
+
return {}
|
|
109
|
+
if not isinstance(spec, dict):
|
|
110
|
+
raise CommandExecutionError(
|
|
111
|
+
"grains_files must be a mapping of namespace to file specification"
|
|
112
|
+
)
|
|
113
|
+
normalized = {}
|
|
114
|
+
for namespace, entry in spec.items():
|
|
115
|
+
if not isinstance(namespace, str) or not namespace:
|
|
116
|
+
raise CommandExecutionError(
|
|
117
|
+
f"grains_files namespaces must be non-empty strings, got: {namespace!r}"
|
|
118
|
+
)
|
|
119
|
+
if namespace in RESERVED_KEYS:
|
|
120
|
+
raise _invalid(namespace, "shadows a builtin resource grain")
|
|
121
|
+
if not isinstance(entry, dict):
|
|
122
|
+
raise _invalid(namespace, "the file specification must be a mapping")
|
|
123
|
+
if unknown := set(entry) - _SPEC_KEYS:
|
|
124
|
+
raise _invalid(namespace, f"unknown key(s): {', '.join(sorted(unknown))}")
|
|
125
|
+
paths = entry.get("path")
|
|
126
|
+
if isinstance(paths, str):
|
|
127
|
+
paths = [paths]
|
|
128
|
+
if (
|
|
129
|
+
not isinstance(paths, list)
|
|
130
|
+
or not paths
|
|
131
|
+
or not all(isinstance(path, str) and path for path in paths)
|
|
132
|
+
):
|
|
133
|
+
raise _invalid(namespace, "'path' is required and must be a string or list of strings")
|
|
134
|
+
normalized[namespace] = {
|
|
135
|
+
"path": paths,
|
|
136
|
+
"include": _validate_include(namespace, entry.get("include")),
|
|
137
|
+
"exclude": _validate_exclude(namespace, entry.get("exclude")),
|
|
138
|
+
}
|
|
139
|
+
return normalized
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _validate_include(namespace, include):
|
|
143
|
+
if include is None:
|
|
144
|
+
return None
|
|
145
|
+
if isinstance(include, str):
|
|
146
|
+
include = [include]
|
|
147
|
+
elif isinstance(include, dict):
|
|
148
|
+
# Mapping shorthand for rename includes (unset values still imply regular inclusions)
|
|
149
|
+
include = [
|
|
150
|
+
{new_name: source} if source else new_name for new_name, source in include.items()
|
|
151
|
+
]
|
|
152
|
+
elif not isinstance(include, list):
|
|
153
|
+
raise _invalid(namespace, "'include' must be a list, string or mapping")
|
|
154
|
+
res = []
|
|
155
|
+
for item in include:
|
|
156
|
+
if isinstance(item, str) and item:
|
|
157
|
+
res.append(item)
|
|
158
|
+
continue
|
|
159
|
+
if isinstance(item, dict) and len(item) == 1:
|
|
160
|
+
((new_name, source),) = item.items()
|
|
161
|
+
if isinstance(new_name, str) and new_name and isinstance(source, str) and source:
|
|
162
|
+
res.append((new_name, source))
|
|
163
|
+
continue
|
|
164
|
+
raise _invalid(
|
|
165
|
+
namespace,
|
|
166
|
+
"'include' entries must be patterns or single-entry "
|
|
167
|
+
f"'new_name: source:path' renames, got: {item!r}",
|
|
168
|
+
)
|
|
169
|
+
return res
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _validate_exclude(namespace, exclude):
|
|
173
|
+
if exclude is None:
|
|
174
|
+
return []
|
|
175
|
+
if isinstance(exclude, str):
|
|
176
|
+
return [exclude]
|
|
177
|
+
if not isinstance(exclude, list) or not all(isinstance(pat, str) and pat for pat in exclude):
|
|
178
|
+
raise _invalid(namespace, "'exclude' must be a list of patterns")
|
|
179
|
+
return list(exclude)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def load_mapping(text, *, path):
|
|
183
|
+
"""
|
|
184
|
+
Parse a file's text as YAML (which includes JSON) into a mapping of
|
|
185
|
+
JSON-plain values, or ``None`` (logging a warning) when the text does
|
|
186
|
+
not parse or does not represent a mapping.
|
|
187
|
+
|
|
188
|
+
text
|
|
189
|
+
The file content.
|
|
190
|
+
|
|
191
|
+
path
|
|
192
|
+
Identifier of the file's origin, used in log messages.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
parsed = salt.utils.yaml.safe_load(text)
|
|
196
|
+
except yaml.YAMLError as err:
|
|
197
|
+
log.warning("Failed parsing '%s' as YAML/JSON: %s", path, err)
|
|
198
|
+
return None
|
|
199
|
+
if not isinstance(parsed, dict):
|
|
200
|
+
log.warning("File '%s' does not contain a mapping, skipping", path)
|
|
201
|
+
return None
|
|
202
|
+
try:
|
|
203
|
+
# Normalize to JSON-plain types (e.g. YAML timestamps -> strings)
|
|
204
|
+
return json.loads(json.dumps(parsed, default=str))
|
|
205
|
+
except (TypeError, ValueError) as err:
|
|
206
|
+
log.warning("Failed normalizing the contents of '%s': %s", path, err)
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def filter_content(data, include=None, exclude=None):
|
|
211
|
+
"""
|
|
212
|
+
Apply a normalized (per :func:`validate`) include/exclude
|
|
213
|
+
specification to a parsed file mapping and return the result.
|
|
214
|
+
|
|
215
|
+
data
|
|
216
|
+
The parsed file content.
|
|
217
|
+
|
|
218
|
+
include
|
|
219
|
+
Normalized include entries: fnmatch patterns against
|
|
220
|
+
colon-delimited key paths (a match includes the whole subtree,
|
|
221
|
+
preserving nesting) and ``(new_name, source)`` rename tuples
|
|
222
|
+
(exact source path, value exposed as flat ``new_name`` key).
|
|
223
|
+
``None`` includes everything.
|
|
224
|
+
|
|
225
|
+
exclude
|
|
226
|
+
Fnmatch patterns pruning matching key paths at any depth after
|
|
227
|
+
includes. Does not apply to renames.
|
|
228
|
+
"""
|
|
229
|
+
patterns = None
|
|
230
|
+
# need to make sure the first rename wins for consistency
|
|
231
|
+
renames = {}
|
|
232
|
+
|
|
233
|
+
if include is not None:
|
|
234
|
+
patterns = []
|
|
235
|
+
for item in include:
|
|
236
|
+
if isinstance(item, str):
|
|
237
|
+
patterns.append(item)
|
|
238
|
+
else:
|
|
239
|
+
new_name, source = item
|
|
240
|
+
if new_name not in renames:
|
|
241
|
+
renames[new_name] = source
|
|
242
|
+
result = _filter_node(data, "", patterns, exclude or [], patterns is None)
|
|
243
|
+
for new_name, source in renames.items():
|
|
244
|
+
source = source or new_name # unnormalized mapping-style include with no rename intention
|
|
245
|
+
value = salt.utils.data.traverse_dict_and_list(data, source, ..., delimiter=":")
|
|
246
|
+
if value is not ...:
|
|
247
|
+
salt.utils.dictupdate.set_dict_key_value(
|
|
248
|
+
result, new_name, copy.deepcopy(value), delimiter=":"
|
|
249
|
+
)
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _filter_node(node, prefix, patterns, excludes, included_parent):
|
|
254
|
+
result = {}
|
|
255
|
+
for key, value in node.items():
|
|
256
|
+
path = f"{prefix}:{key}" if prefix else str(key)
|
|
257
|
+
if any(fnmatch.fnmatchcase(path, pat) for pat in excludes):
|
|
258
|
+
continue
|
|
259
|
+
included = included_parent or any(fnmatch.fnmatchcase(path, pat) for pat in patterns or ())
|
|
260
|
+
if isinstance(value, dict):
|
|
261
|
+
# Recurse into non-included subtrees as well since deeper
|
|
262
|
+
# paths can still match an include pattern.
|
|
263
|
+
sub = _filter_node(value, path, patterns, excludes, included)
|
|
264
|
+
if included or sub:
|
|
265
|
+
result[key] = sub
|
|
266
|
+
elif included:
|
|
267
|
+
result[key] = copy.deepcopy(value)
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def merge_grains(grains, custom):
|
|
272
|
+
"""
|
|
273
|
+
Merge collected (per :func:`collect`) custom grains into a grains
|
|
274
|
+
mapping and return it. The special ``_`` namespace contributes its
|
|
275
|
+
keys at the root level, other namespaces become a single key each.
|
|
276
|
+
Keys already present – the builtin identity grains or earlier
|
|
277
|
+
namespaces – are never overwritten (logged and skipped).
|
|
278
|
+
|
|
279
|
+
grains
|
|
280
|
+
The grains mapping to merge into (mutated).
|
|
281
|
+
|
|
282
|
+
custom
|
|
283
|
+
Mapping of namespace to grain data.
|
|
284
|
+
"""
|
|
285
|
+
for namespace, data in custom.items():
|
|
286
|
+
items = data.items() if namespace == "_" else ((namespace, data),)
|
|
287
|
+
for key, value in items:
|
|
288
|
+
ns_its = key.split(":")
|
|
289
|
+
ns_its.reverse()
|
|
290
|
+
|
|
291
|
+
tgt = grains
|
|
292
|
+
ns_it = ns_its.pop()
|
|
293
|
+
while ns_its:
|
|
294
|
+
if ns_it not in tgt:
|
|
295
|
+
tgt[ns_it] = {}
|
|
296
|
+
elif not isinstance(tgt[ns_it], dict):
|
|
297
|
+
log.warning("Custom grain '%s' shadows an existing grain, skipping", key)
|
|
298
|
+
break
|
|
299
|
+
tgt = tgt[ns_it]
|
|
300
|
+
ns_it = ns_its.pop()
|
|
301
|
+
else:
|
|
302
|
+
if ns_it not in tgt:
|
|
303
|
+
tgt[ns_it] = value
|
|
304
|
+
continue
|
|
305
|
+
if not isinstance(tgt[ns_it], dict) or not isinstance(value, dict):
|
|
306
|
+
log.warning("Custom grain '%s' shadows an existing grain, skipping", key)
|
|
307
|
+
continue
|
|
308
|
+
for k, v in value.items():
|
|
309
|
+
if k not in tgt[ns_it]:
|
|
310
|
+
tgt[ns_it][k] = v
|
|
311
|
+
continue
|
|
312
|
+
log.warning("Custom grain '%s:%s' shadows an existing grain, skipping", key, k)
|
|
313
|
+
return grains
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def collect(conn, repo_name, spec, *, owner=None):
|
|
317
|
+
"""
|
|
318
|
+
Fetch, parse, merge and filter all namespaces of a normalized (per
|
|
319
|
+
:func:`validate`) ``grains_files`` specification for one repository.
|
|
320
|
+
|
|
321
|
+
Returns a mapping of namespace to grain data; namespaces without any
|
|
322
|
+
resulting data are omitted. Per-file and per-namespace problems are
|
|
323
|
+
logged and skipped, never raised.
|
|
324
|
+
|
|
325
|
+
conn
|
|
326
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
327
|
+
|
|
328
|
+
repo_name
|
|
329
|
+
Repository name.
|
|
330
|
+
|
|
331
|
+
spec
|
|
332
|
+
The normalized ``grains_files`` specification.
|
|
333
|
+
|
|
334
|
+
owner
|
|
335
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
336
|
+
"""
|
|
337
|
+
owner = owner or conn.owner
|
|
338
|
+
ret = {}
|
|
339
|
+
for namespace, entry in spec.items():
|
|
340
|
+
try:
|
|
341
|
+
merged = {}
|
|
342
|
+
for path in entry["path"]:
|
|
343
|
+
try:
|
|
344
|
+
content = gh_repos.get_file_content(conn, repo_name, path, owner=owner)
|
|
345
|
+
except GitHubApiError as err:
|
|
346
|
+
log.warning("Failed fetching '%s' from %s/%s: %s", path, owner, repo_name, err)
|
|
347
|
+
continue
|
|
348
|
+
if content is None:
|
|
349
|
+
continue
|
|
350
|
+
parsed = load_mapping(content, path=f"{owner}/{repo_name}:{path}")
|
|
351
|
+
if parsed is not None:
|
|
352
|
+
merged.update((k, v) for k, v in parsed.items() if k not in merged)
|
|
353
|
+
if not merged:
|
|
354
|
+
continue
|
|
355
|
+
data = filter_content(merged, include=entry["include"], exclude=entry["exclude"])
|
|
356
|
+
if data:
|
|
357
|
+
ret[namespace] = data
|
|
358
|
+
except Exception as err: # pylint: disable=broad-except
|
|
359
|
+
log.warning(
|
|
360
|
+
"Failed collecting custom grains namespace '%s' for %s/%s: %s",
|
|
361
|
+
namespace,
|
|
362
|
+
owner,
|
|
363
|
+
repo_name,
|
|
364
|
+
err,
|
|
365
|
+
)
|
|
366
|
+
return ret
|