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,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared cores implementing the repository-scoped states: the full
|
|
3
|
+
inspect -> test-gate -> apply flow, including change reporting.
|
|
4
|
+
|
|
5
|
+
They are consumed by the classic :py:mod:`github_repo
|
|
6
|
+
<saltext.github.states.github_repo>` state module as well as the
|
|
7
|
+
``github`` resource type's state modules, which are both thin wrappers
|
|
8
|
+
handling identity and connection resolution.
|
|
9
|
+
|
|
10
|
+
The cores return complete state return dictionaries (``name``,
|
|
11
|
+
``changes``, ``result``, ``comment``) and raise
|
|
12
|
+
:py:class:`CommandExecutionError <salt.exceptions.CommandExecutionError>`
|
|
13
|
+
(usually the :py:mod:`gh <saltext.github.utils.gh>` subclasses) on
|
|
14
|
+
failures, which the wrappers translate into a failed state return.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import functools
|
|
18
|
+
|
|
19
|
+
from salt.exceptions import CommandExecutionError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StateError(CommandExecutionError):
|
|
23
|
+
"""
|
|
24
|
+
Raised when applying state changes failed. Compound states can
|
|
25
|
+
attach the changes that were applied before the failure, which
|
|
26
|
+
:func:`failed`/:func:`catch_errors` include in the state return.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message, *, changes=None):
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.changes = changes or {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def failed(name, err):
|
|
35
|
+
"""
|
|
36
|
+
Render a raised error into a failed state return, including
|
|
37
|
+
partially applied changes carried by the error (:class:`StateError`).
|
|
38
|
+
"""
|
|
39
|
+
return {
|
|
40
|
+
"name": name,
|
|
41
|
+
"changes": getattr(err, "changes", None) or {},
|
|
42
|
+
"result": False,
|
|
43
|
+
"comment": str(err),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def catch_errors(func):
|
|
48
|
+
"""
|
|
49
|
+
Wrap a state function to render raised
|
|
50
|
+
:class:`CommandExecutionError <salt.exceptions.CommandExecutionError>`
|
|
51
|
+
into a failed state return instead of a traceback.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
@functools.wraps(func)
|
|
55
|
+
def wrapper(name, *args, **kwargs):
|
|
56
|
+
try:
|
|
57
|
+
return func(name, *args, **kwargs)
|
|
58
|
+
except CommandExecutionError as err:
|
|
59
|
+
return failed(name, err)
|
|
60
|
+
|
|
61
|
+
return wrapper
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def differs(current, desired):
|
|
65
|
+
"""
|
|
66
|
+
Report whether a managed parameter needs updating. The identity check
|
|
67
|
+
makes boolean comparisons exact: since ``True == 1`` in Python, an
|
|
68
|
+
equality check alone would not register e.g. ``1`` -> ``True``.
|
|
69
|
+
"""
|
|
70
|
+
return current is not desired and current != desired
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
State cores managing repository Actions secrets and workflow permissions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from salt.exceptions import CommandExecutionError
|
|
8
|
+
|
|
9
|
+
from saltext.github.utils import filter_unset
|
|
10
|
+
from saltext.github.utils.gh import actions as gh_actions
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def secret_present(conn, name, repo_name, *, value=None, force=False, owner=None, test=False):
|
|
16
|
+
"""
|
|
17
|
+
Ensure an Actions secret is present in a repository.
|
|
18
|
+
|
|
19
|
+
Secret values are write-only in the GitHub API, so this core cannot
|
|
20
|
+
detect value drift. By default, an existing secret is left untouched.
|
|
21
|
+
Set ``force`` to push the value on every run.
|
|
22
|
+
|
|
23
|
+
conn
|
|
24
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
25
|
+
|
|
26
|
+
name
|
|
27
|
+
Name of the secret. GitHub treats secret names
|
|
28
|
+
case-insensitively.
|
|
29
|
+
|
|
30
|
+
repo_name
|
|
31
|
+
Name of the repository.
|
|
32
|
+
|
|
33
|
+
value
|
|
34
|
+
Plaintext secret value. Required when the secret does not exist
|
|
35
|
+
yet or ``force`` is set.
|
|
36
|
+
|
|
37
|
+
force
|
|
38
|
+
Push the value even when the secret already exists.
|
|
39
|
+
Defaults to false.
|
|
40
|
+
|
|
41
|
+
owner
|
|
42
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
43
|
+
|
|
44
|
+
test
|
|
45
|
+
Only project the changes without applying them (``test`` mode).
|
|
46
|
+
"""
|
|
47
|
+
ret = {"name": name, "changes": {}, "result": True, "comment": ""}
|
|
48
|
+
|
|
49
|
+
secrets = [sec["name"] for sec in gh_actions.list_secrets(conn, repo_name, owner=owner)]
|
|
50
|
+
current = next((sec for sec in secrets if sec.lower() == name.lower()), None)
|
|
51
|
+
|
|
52
|
+
if current is not None and not force:
|
|
53
|
+
ret["comment"] = (
|
|
54
|
+
f"Secret {name} is already present. Note that secret values are "
|
|
55
|
+
"write-only and cannot be verified, set force to push the value."
|
|
56
|
+
)
|
|
57
|
+
return ret
|
|
58
|
+
|
|
59
|
+
if value is None:
|
|
60
|
+
raise CommandExecutionError(f"A value is required to set secret {name}")
|
|
61
|
+
|
|
62
|
+
if current is None:
|
|
63
|
+
ret["changes"]["created"] = name
|
|
64
|
+
verb = "created"
|
|
65
|
+
else:
|
|
66
|
+
ret["changes"]["updated"] = name
|
|
67
|
+
verb = "updated"
|
|
68
|
+
|
|
69
|
+
if test:
|
|
70
|
+
ret["result"] = None
|
|
71
|
+
ret["comment"] = f"Secret {name} would have been {verb}"
|
|
72
|
+
return ret
|
|
73
|
+
|
|
74
|
+
gh_actions.set_secret(conn, repo_name, name, value, owner=owner)
|
|
75
|
+
ret["comment"] = f"Secret {name} has been {verb}"
|
|
76
|
+
return ret
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def secret_absent(conn, name, repo_name, *, owner=None, test=False):
|
|
80
|
+
"""
|
|
81
|
+
Ensure an Actions secret is absent from a repository.
|
|
82
|
+
|
|
83
|
+
conn
|
|
84
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
85
|
+
|
|
86
|
+
name
|
|
87
|
+
Name of the secret. GitHub treats secret names
|
|
88
|
+
case-insensitively.
|
|
89
|
+
|
|
90
|
+
repo_name
|
|
91
|
+
Name of the repository.
|
|
92
|
+
|
|
93
|
+
owner
|
|
94
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
95
|
+
|
|
96
|
+
test
|
|
97
|
+
Only project the changes without applying them (``test`` mode).
|
|
98
|
+
"""
|
|
99
|
+
ret = {"name": name, "changes": {}, "result": True, "comment": ""}
|
|
100
|
+
|
|
101
|
+
secrets = [sec["name"] for sec in gh_actions.list_secrets(conn, repo_name, owner=owner)]
|
|
102
|
+
current = next((sec for sec in secrets if sec.lower() == name.lower()), None)
|
|
103
|
+
if current is None:
|
|
104
|
+
ret["comment"] = f"Secret {name} is already absent"
|
|
105
|
+
return ret
|
|
106
|
+
|
|
107
|
+
ret["changes"]["deleted"] = current
|
|
108
|
+
if test:
|
|
109
|
+
ret["result"] = None
|
|
110
|
+
ret["comment"] = f"Secret {name} would have been removed"
|
|
111
|
+
return ret
|
|
112
|
+
|
|
113
|
+
gh_actions.delete_secret(conn, repo_name, current, owner=owner)
|
|
114
|
+
ret["comment"] = f"Secret {name} has been removed"
|
|
115
|
+
return ret
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def workflow_permissions_managed(
|
|
119
|
+
conn,
|
|
120
|
+
name,
|
|
121
|
+
repo_name,
|
|
122
|
+
*,
|
|
123
|
+
default_workflow_permissions=None,
|
|
124
|
+
can_approve_pull_request_reviews=None,
|
|
125
|
+
owner=None,
|
|
126
|
+
test=False,
|
|
127
|
+
):
|
|
128
|
+
"""
|
|
129
|
+
Manage the default workflow permissions granted to the ``GITHUB_TOKEN``
|
|
130
|
+
in a repository.
|
|
131
|
+
|
|
132
|
+
conn
|
|
133
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
134
|
+
|
|
135
|
+
name
|
|
136
|
+
State name (reported back unchanged).
|
|
137
|
+
|
|
138
|
+
repo_name
|
|
139
|
+
Name of the repository.
|
|
140
|
+
|
|
141
|
+
default_workflow_permissions
|
|
142
|
+
Default permissions for the ``GITHUB_TOKEN``, either ``read``
|
|
143
|
+
or ``write``.
|
|
144
|
+
|
|
145
|
+
can_approve_pull_request_reviews
|
|
146
|
+
Allow GitHub Actions to approve pull requests.
|
|
147
|
+
|
|
148
|
+
owner
|
|
149
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
150
|
+
|
|
151
|
+
test
|
|
152
|
+
Only project the changes without applying them (``test`` mode).
|
|
153
|
+
"""
|
|
154
|
+
ret = {"name": name, "changes": {}, "result": True, "comment": ""}
|
|
155
|
+
|
|
156
|
+
desired = filter_unset(
|
|
157
|
+
{
|
|
158
|
+
"default_workflow_permissions": default_workflow_permissions,
|
|
159
|
+
"can_approve_pull_request_reviews": can_approve_pull_request_reviews,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
if not desired:
|
|
163
|
+
raise CommandExecutionError("At least one setting to manage must be specified")
|
|
164
|
+
|
|
165
|
+
current = gh_actions.get_workflow_permissions(conn, repo_name, owner=owner)
|
|
166
|
+
diff = {key: val for key, val in desired.items() if current.get(key) != val}
|
|
167
|
+
if not diff:
|
|
168
|
+
ret["comment"] = "Workflow permissions are already set as specified"
|
|
169
|
+
return ret
|
|
170
|
+
|
|
171
|
+
ret["changes"]["old"] = {key: current.get(key) for key in diff}
|
|
172
|
+
ret["changes"]["new"] = diff
|
|
173
|
+
if test:
|
|
174
|
+
ret["result"] = None
|
|
175
|
+
ret["comment"] = "Workflow permissions would have been updated"
|
|
176
|
+
return ret
|
|
177
|
+
|
|
178
|
+
gh_actions.set_workflow_permissions(conn, repo_name, owner=owner, **diff)
|
|
179
|
+
ret["comment"] = "Workflow permissions have been updated"
|
|
180
|
+
return ret
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
State core managing repository deploy keys.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from salt.exceptions import CommandExecutionError
|
|
8
|
+
|
|
9
|
+
from saltext.github.utils.gh import actions as gh_actions
|
|
10
|
+
from saltext.github.utils.gh import deploy_keys as gh_deploy_keys
|
|
11
|
+
from saltext.github.utils.gh.statecores import StateError
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def present(conn, name, key, repo_name, *, read_only=False, owner=None, test=False):
|
|
17
|
+
"""
|
|
18
|
+
Ensure a deploy key with this title and public key is present
|
|
19
|
+
in a repository.
|
|
20
|
+
|
|
21
|
+
Since deploy keys are immutable in the GitHub API, a key whose
|
|
22
|
+
``key`` or ``read_only`` setting differs is replaced (removed
|
|
23
|
+
and recreated).
|
|
24
|
+
|
|
25
|
+
conn
|
|
26
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
27
|
+
|
|
28
|
+
name
|
|
29
|
+
Display title of the deploy key.
|
|
30
|
+
|
|
31
|
+
key
|
|
32
|
+
SSH public key.
|
|
33
|
+
|
|
34
|
+
repo_name
|
|
35
|
+
Name of the repository.
|
|
36
|
+
|
|
37
|
+
read_only
|
|
38
|
+
Deny write access to the repository for this key. Defaults to false.
|
|
39
|
+
|
|
40
|
+
owner
|
|
41
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
42
|
+
|
|
43
|
+
test
|
|
44
|
+
Only project the changes without applying them (``test`` mode).
|
|
45
|
+
"""
|
|
46
|
+
ret = {"name": name, "changes": {}, "result": True, "comment": ""}
|
|
47
|
+
|
|
48
|
+
keys = gh_deploy_keys.list_all(conn, repo_name, owner=owner)
|
|
49
|
+
current = next((cur for cur in keys if cur["title"] == name), None)
|
|
50
|
+
|
|
51
|
+
if current is not None:
|
|
52
|
+
if gh_deploy_keys.key_body(current["key"]) == gh_deploy_keys.key_body(key) and bool(
|
|
53
|
+
current["read_only"]
|
|
54
|
+
) is bool(read_only):
|
|
55
|
+
ret["comment"] = f"Deploy key {name} is present as specified"
|
|
56
|
+
return ret
|
|
57
|
+
ret["changes"]["old"] = {"key": current["key"], "read_only": current["read_only"]}
|
|
58
|
+
ret["changes"]["new"] = {"key": gh_deploy_keys.key_body(key), "read_only": bool(read_only)}
|
|
59
|
+
else:
|
|
60
|
+
ret["changes"]["created"] = name
|
|
61
|
+
|
|
62
|
+
verb = "added" if current is None else "replaced"
|
|
63
|
+
if test:
|
|
64
|
+
ret["result"] = None
|
|
65
|
+
ret["comment"] = f"Deploy key {name} would have been {verb}"
|
|
66
|
+
return ret
|
|
67
|
+
|
|
68
|
+
if current is not None:
|
|
69
|
+
gh_deploy_keys.delete(conn, repo_name, current["id"], owner=owner)
|
|
70
|
+
gh_deploy_keys.create(conn, repo_name, name, key, read_only=bool(read_only), owner=owner)
|
|
71
|
+
ret["comment"] = f"Deploy key {name} has been {verb}"
|
|
72
|
+
return ret
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def actions_present(
|
|
76
|
+
conn,
|
|
77
|
+
name,
|
|
78
|
+
secret_name,
|
|
79
|
+
repo_name,
|
|
80
|
+
*,
|
|
81
|
+
keygen,
|
|
82
|
+
read_only=False,
|
|
83
|
+
rotate=False,
|
|
84
|
+
owner=None,
|
|
85
|
+
test=False,
|
|
86
|
+
):
|
|
87
|
+
"""
|
|
88
|
+
Ensure a generated SSH keypair is distributed to a repository:
|
|
89
|
+
the public half as a deploy key, the private half as an Actions
|
|
90
|
+
secret. The keypair is generated in memory and never persisted;
|
|
91
|
+
only the public key's fingerprint is reported.
|
|
92
|
+
|
|
93
|
+
Since secret values are write-only in the GitHub API, an existing
|
|
94
|
+
pair cannot be verified to match and is left untouched unless
|
|
95
|
+
``rotate`` is set. A pair with a missing half is regenerated.
|
|
96
|
+
|
|
97
|
+
conn
|
|
98
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
99
|
+
|
|
100
|
+
name
|
|
101
|
+
Display title of the deploy key.
|
|
102
|
+
|
|
103
|
+
secret_name
|
|
104
|
+
Name of the Actions secret holding the private key. GitHub
|
|
105
|
+
treats secret names case-insensitively.
|
|
106
|
+
|
|
107
|
+
repo_name
|
|
108
|
+
Name of the repository.
|
|
109
|
+
|
|
110
|
+
keygen
|
|
111
|
+
Callable generating the keypair, returning a mapping with
|
|
112
|
+
``private_key`` (OpenSSH PEM format) and ``public_key``
|
|
113
|
+
(OpenSSH single-line format), like
|
|
114
|
+
:py:func:`ssh_pki.create_private_key
|
|
115
|
+
<salt.modules.ssh_pki.create_private_key>` does. Only invoked
|
|
116
|
+
when a new pair needs to be distributed.
|
|
117
|
+
|
|
118
|
+
read_only
|
|
119
|
+
Deny write access to the repository for this key. Defaults to false.
|
|
120
|
+
|
|
121
|
+
rotate
|
|
122
|
+
Generate and distribute a new keypair even when both halves
|
|
123
|
+
are present. Defaults to false.
|
|
124
|
+
|
|
125
|
+
owner
|
|
126
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
127
|
+
|
|
128
|
+
test
|
|
129
|
+
Only project the changes without applying them (``test`` mode).
|
|
130
|
+
"""
|
|
131
|
+
ret = {"name": name, "changes": {}, "result": True, "comment": ""}
|
|
132
|
+
|
|
133
|
+
keys = gh_deploy_keys.list_all(conn, repo_name, owner=owner)
|
|
134
|
+
current = next((cur for cur in keys if cur["title"] == name), None)
|
|
135
|
+
secrets = [sec["name"] for sec in gh_actions.list_secrets(conn, repo_name, owner=owner)]
|
|
136
|
+
current_secret = next((sec for sec in secrets if sec.lower() == secret_name.lower()), None)
|
|
137
|
+
|
|
138
|
+
if current is not None and current_secret is not None and not rotate:
|
|
139
|
+
if bool(current["read_only"]) is bool(read_only):
|
|
140
|
+
ret["comment"] = (
|
|
141
|
+
f"Deploy key {name} and secret {secret_name} are present. Note that "
|
|
142
|
+
"the private key cannot be verified to match the deploy key, "
|
|
143
|
+
"set rotate to regenerate the pair."
|
|
144
|
+
)
|
|
145
|
+
return ret
|
|
146
|
+
# Deploy keys are immutable, but the read_only flag can be
|
|
147
|
+
# flipped by re-adding the known public key. The secret is
|
|
148
|
+
# left untouched since the pair still matches.
|
|
149
|
+
ret["changes"]["old"] = {"read_only": current["read_only"]}
|
|
150
|
+
ret["changes"]["new"] = {"read_only": bool(read_only)}
|
|
151
|
+
if test:
|
|
152
|
+
ret["result"] = None
|
|
153
|
+
ret["comment"] = f"Deploy key {name} would have been replaced"
|
|
154
|
+
return ret
|
|
155
|
+
applied = {}
|
|
156
|
+
try:
|
|
157
|
+
gh_deploy_keys.delete(conn, repo_name, current["id"], owner=owner)
|
|
158
|
+
applied["deleted"] = name
|
|
159
|
+
gh_deploy_keys.create(
|
|
160
|
+
conn, repo_name, name, current["key"], read_only=bool(read_only), owner=owner
|
|
161
|
+
)
|
|
162
|
+
except CommandExecutionError as err:
|
|
163
|
+
raise StateError(str(err), changes=applied) from err
|
|
164
|
+
ret["comment"] = f"Deploy key {name} has been replaced"
|
|
165
|
+
return ret
|
|
166
|
+
|
|
167
|
+
key_verb = "created" if current is None else "replaced"
|
|
168
|
+
secret_verb = "created" if current_secret is None else "updated"
|
|
169
|
+
ret["changes"]["deploy_key"] = {key_verb: name}
|
|
170
|
+
ret["changes"]["secret"] = {secret_verb: secret_name}
|
|
171
|
+
|
|
172
|
+
if test:
|
|
173
|
+
ret["result"] = None
|
|
174
|
+
ret["comment"] = (
|
|
175
|
+
f"A keypair would have been generated and distributed as "
|
|
176
|
+
f"deploy key {name} and secret {secret_name}"
|
|
177
|
+
)
|
|
178
|
+
return ret
|
|
179
|
+
|
|
180
|
+
pair = keygen()
|
|
181
|
+
private, public = pair["private_key"], f"{pair['public_key'].strip()} {name}"
|
|
182
|
+
ret["changes"]["deploy_key"]["fingerprint"] = gh_deploy_keys.fingerprint(public)
|
|
183
|
+
|
|
184
|
+
# Remove a stale deploy key before pushing the new pair: if a later
|
|
185
|
+
# step fails, the missing deploy key makes the next run regenerate
|
|
186
|
+
# the pair. Other orderings can leave a mismatched key and secret
|
|
187
|
+
# both present, which is undetectable afterwards.
|
|
188
|
+
applied = {}
|
|
189
|
+
try:
|
|
190
|
+
if current is not None:
|
|
191
|
+
gh_deploy_keys.delete(conn, repo_name, current["id"], owner=owner)
|
|
192
|
+
applied["deploy_key"] = {"deleted": name}
|
|
193
|
+
gh_actions.set_secret(conn, repo_name, secret_name, private, owner=owner)
|
|
194
|
+
applied["secret"] = ret["changes"]["secret"]
|
|
195
|
+
gh_deploy_keys.create(conn, repo_name, name, public, read_only=bool(read_only), owner=owner)
|
|
196
|
+
except CommandExecutionError as err:
|
|
197
|
+
raise StateError(str(err), changes=applied) from err
|
|
198
|
+
|
|
199
|
+
ret["comment"] = (
|
|
200
|
+
f"A keypair has been generated and distributed as "
|
|
201
|
+
f"deploy key {name} and secret {secret_name}"
|
|
202
|
+
)
|
|
203
|
+
return ret
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def absent(conn, name, repo_name, *, owner=None, test=False):
|
|
207
|
+
"""
|
|
208
|
+
Ensure no deploy key with this title is present in a repository.
|
|
209
|
+
|
|
210
|
+
conn
|
|
211
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
212
|
+
|
|
213
|
+
name
|
|
214
|
+
Display title of the deploy key.
|
|
215
|
+
|
|
216
|
+
repo_name
|
|
217
|
+
Name of the repository.
|
|
218
|
+
|
|
219
|
+
owner
|
|
220
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
221
|
+
|
|
222
|
+
test
|
|
223
|
+
Only project the changes without applying them (``test`` mode).
|
|
224
|
+
"""
|
|
225
|
+
ret = {"name": name, "changes": {}, "result": True, "comment": ""}
|
|
226
|
+
|
|
227
|
+
keys = gh_deploy_keys.list_all(conn, repo_name, owner=owner)
|
|
228
|
+
current = next((cur for cur in keys if cur["title"] == name), None)
|
|
229
|
+
if current is None:
|
|
230
|
+
ret["comment"] = f"Deploy key {name} is already absent"
|
|
231
|
+
return ret
|
|
232
|
+
|
|
233
|
+
ret["changes"]["deleted"] = name
|
|
234
|
+
if test:
|
|
235
|
+
ret["result"] = None
|
|
236
|
+
ret["comment"] = f"Deploy key {name} would have been removed"
|
|
237
|
+
return ret
|
|
238
|
+
|
|
239
|
+
gh_deploy_keys.delete(conn, repo_name, current["id"], owner=owner)
|
|
240
|
+
ret["comment"] = f"Deploy key {name} has been removed"
|
|
241
|
+
return ret
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
State core managing repository (deployment) environments.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from salt.exceptions import CommandExecutionError
|
|
8
|
+
|
|
9
|
+
from saltext.github.utils import filter_unset
|
|
10
|
+
from saltext.github.utils.gh import environments as gh_environments
|
|
11
|
+
from saltext.github.utils.gh.exceptions import GitHubNotFoundError
|
|
12
|
+
from saltext.github.utils.gh.statecores import StateError
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _environment_settings_diff(ret, current_settings, desired):
|
|
18
|
+
"""
|
|
19
|
+
Record an existing environment's settings that need updating in
|
|
20
|
+
``ret`` and return whether there are any.
|
|
21
|
+
"""
|
|
22
|
+
diff = {}
|
|
23
|
+
for key, val in desired.items():
|
|
24
|
+
current_val = current_settings.get(key)
|
|
25
|
+
if key == "reviewers":
|
|
26
|
+
matches = sorted((reviewer["type"], reviewer["id"]) for reviewer in val) == sorted(
|
|
27
|
+
(reviewer["type"], reviewer["id"]) for reviewer in current_val
|
|
28
|
+
)
|
|
29
|
+
else:
|
|
30
|
+
matches = current_val == val
|
|
31
|
+
if not matches:
|
|
32
|
+
diff[key] = val
|
|
33
|
+
if diff:
|
|
34
|
+
ret["changes"]["old"] = {key: current_settings.get(key) for key in diff}
|
|
35
|
+
ret["changes"]["new"] = diff
|
|
36
|
+
return bool(diff)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def present(
|
|
40
|
+
conn,
|
|
41
|
+
name,
|
|
42
|
+
repo_name,
|
|
43
|
+
*,
|
|
44
|
+
wait_timer=None,
|
|
45
|
+
prevent_self_review=None,
|
|
46
|
+
reviewers=None,
|
|
47
|
+
deployment_branches=None,
|
|
48
|
+
owner=None,
|
|
49
|
+
test=False,
|
|
50
|
+
):
|
|
51
|
+
"""
|
|
52
|
+
Ensure an environment is present in a repository and configured
|
|
53
|
+
as specified. Unspecified settings are left unmanaged.
|
|
54
|
+
|
|
55
|
+
conn
|
|
56
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
57
|
+
|
|
58
|
+
name
|
|
59
|
+
Name of the environment.
|
|
60
|
+
|
|
61
|
+
repo_name
|
|
62
|
+
Name of the repository.
|
|
63
|
+
|
|
64
|
+
wait_timer
|
|
65
|
+
Amount of time to delay a job after it is initially triggered,
|
|
66
|
+
in minutes.
|
|
67
|
+
|
|
68
|
+
prevent_self_review
|
|
69
|
+
Prevent a user who created a job from approving it.
|
|
70
|
+
|
|
71
|
+
reviewers
|
|
72
|
+
People or teams that may review jobs that reference the
|
|
73
|
+
environment, as a list of mappings with ``type`` (``User`` or
|
|
74
|
+
``Team``) and ``id``.
|
|
75
|
+
|
|
76
|
+
deployment_branches
|
|
77
|
+
Which branches can deploy to this environment. Either ``all``,
|
|
78
|
+
``protected`` (branches with branch protection rules) or a list
|
|
79
|
+
of branch name patterns.
|
|
80
|
+
|
|
81
|
+
owner
|
|
82
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
83
|
+
|
|
84
|
+
test
|
|
85
|
+
Only project the changes without applying them (``test`` mode).
|
|
86
|
+
"""
|
|
87
|
+
ret = {
|
|
88
|
+
"name": name,
|
|
89
|
+
"changes": {},
|
|
90
|
+
"result": True,
|
|
91
|
+
"comment": f"Environment {name} is configured as specified",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
current = gh_environments.get(conn, repo_name, name, owner=owner)
|
|
96
|
+
except GitHubNotFoundError:
|
|
97
|
+
current = None
|
|
98
|
+
|
|
99
|
+
desired = filter_unset(
|
|
100
|
+
{
|
|
101
|
+
"wait_timer": wait_timer,
|
|
102
|
+
"prevent_self_review": prevent_self_review,
|
|
103
|
+
"reviewers": reviewers,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
if deployment_branches is not None:
|
|
107
|
+
desired["deployment_branch_policy"] = gh_environments.branch_policy_from_patterns(
|
|
108
|
+
deployment_branches
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
settings_diff = False
|
|
112
|
+
branches_added, branches_removed, current_policies = [], [], []
|
|
113
|
+
if current is None:
|
|
114
|
+
ret["changes"]["created"] = name
|
|
115
|
+
set_kwargs = desired
|
|
116
|
+
if isinstance(deployment_branches, list):
|
|
117
|
+
branches_added = sorted(set(deployment_branches))
|
|
118
|
+
else:
|
|
119
|
+
current_settings = gh_environments.normalize(current)
|
|
120
|
+
settings_diff = _environment_settings_diff(ret, current_settings, desired)
|
|
121
|
+
# The API call replaces the whole configuration, so settings
|
|
122
|
+
# that are not managed here need to be carried over.
|
|
123
|
+
set_kwargs = {**current_settings, **desired}
|
|
124
|
+
if isinstance(deployment_branches, list):
|
|
125
|
+
try:
|
|
126
|
+
current_policies = gh_environments.list_branch_policies(
|
|
127
|
+
conn, repo_name, name, owner=owner
|
|
128
|
+
)
|
|
129
|
+
except GitHubNotFoundError:
|
|
130
|
+
# Not found when the current policy type is not "custom"
|
|
131
|
+
current_policies = []
|
|
132
|
+
current_patterns = {policy["name"] for policy in current_policies}
|
|
133
|
+
branches_added = sorted(set(deployment_branches) - current_patterns)
|
|
134
|
+
branches_removed = sorted(current_patterns - set(deployment_branches))
|
|
135
|
+
|
|
136
|
+
if branches_added or branches_removed:
|
|
137
|
+
ret["changes"]["deployment_branches"] = {
|
|
138
|
+
"added": branches_added,
|
|
139
|
+
"removed": branches_removed,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if not ret["changes"]:
|
|
143
|
+
return ret
|
|
144
|
+
|
|
145
|
+
verb = "created" if current is None else "updated"
|
|
146
|
+
if test:
|
|
147
|
+
ret["result"] = None
|
|
148
|
+
ret["comment"] = f"Environment {name} would have been {verb}"
|
|
149
|
+
return ret
|
|
150
|
+
|
|
151
|
+
applied = {}
|
|
152
|
+
try:
|
|
153
|
+
if current is None or settings_diff:
|
|
154
|
+
gh_environments.set_(conn, repo_name, name, owner=owner, **set_kwargs)
|
|
155
|
+
if current is None:
|
|
156
|
+
applied["created"] = name
|
|
157
|
+
else:
|
|
158
|
+
applied["old"] = ret["changes"]["old"]
|
|
159
|
+
applied["new"] = ret["changes"]["new"]
|
|
160
|
+
for pattern in branches_added:
|
|
161
|
+
gh_environments.create_branch_policy(conn, repo_name, name, pattern, owner=owner)
|
|
162
|
+
applied.setdefault("deployment_branches", {}).setdefault("added", []).append(pattern)
|
|
163
|
+
for policy in current_policies:
|
|
164
|
+
if policy["name"] in branches_removed:
|
|
165
|
+
gh_environments.delete_branch_policy(
|
|
166
|
+
conn, repo_name, name, policy["id"], owner=owner
|
|
167
|
+
)
|
|
168
|
+
applied.setdefault("deployment_branches", {}).setdefault("removed", []).append(
|
|
169
|
+
policy["name"]
|
|
170
|
+
)
|
|
171
|
+
except CommandExecutionError as err:
|
|
172
|
+
raise StateError(str(err), changes=applied) from err
|
|
173
|
+
ret["comment"] = f"Environment {name} has been {verb}"
|
|
174
|
+
return ret
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def absent(conn, name, repo_name, *, owner=None, test=False):
|
|
178
|
+
"""
|
|
179
|
+
Ensure an environment is absent from a repository.
|
|
180
|
+
|
|
181
|
+
conn
|
|
182
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
183
|
+
|
|
184
|
+
name
|
|
185
|
+
Name of the environment.
|
|
186
|
+
|
|
187
|
+
repo_name
|
|
188
|
+
Name of the repository.
|
|
189
|
+
|
|
190
|
+
owner
|
|
191
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
192
|
+
|
|
193
|
+
test
|
|
194
|
+
Only project the changes without applying them (``test`` mode).
|
|
195
|
+
"""
|
|
196
|
+
ret = {"name": name, "changes": {}, "result": True, "comment": ""}
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
gh_environments.get(conn, repo_name, name, owner=owner)
|
|
200
|
+
except GitHubNotFoundError:
|
|
201
|
+
ret["comment"] = f"Environment {name} is already absent"
|
|
202
|
+
return ret
|
|
203
|
+
|
|
204
|
+
ret["changes"]["deleted"] = name
|
|
205
|
+
if test:
|
|
206
|
+
ret["result"] = None
|
|
207
|
+
ret["comment"] = f"Environment {name} would have been removed"
|
|
208
|
+
return ret
|
|
209
|
+
|
|
210
|
+
gh_environments.delete(conn, repo_name, name, owner=owner)
|
|
211
|
+
ret["comment"] = f"Environment {name} has been removed"
|
|
212
|
+
return ret
|