suite-py 1.41.3__py3-none-any.whl → 1.41.4__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.
- suite_py/__version__.py +1 -1
- suite_py/cli.py +60 -192
- suite_py/commands/aggregator.py +3 -5
- suite_py/commands/ask_review.py +3 -5
- suite_py/commands/batch_job.py +1 -2
- suite_py/commands/bump.py +1 -2
- suite_py/commands/check.py +3 -5
- suite_py/commands/context.py +26 -0
- suite_py/commands/create_branch.py +1 -2
- suite_py/commands/deploy.py +3 -5
- suite_py/commands/docker.py +1 -2
- suite_py/commands/generator.py +1 -2
- suite_py/commands/id.py +1 -2
- suite_py/commands/ip.py +1 -2
- suite_py/commands/login.py +4 -173
- suite_py/commands/merge_pr.py +3 -4
- suite_py/commands/open_pr.py +4 -5
- suite_py/commands/project_lock.py +3 -5
- suite_py/commands/release.py +3 -5
- suite_py/commands/secret.py +1 -2
- suite_py/commands/set_token.py +1 -2
- suite_py/commands/status.py +3 -4
- suite_py/lib/handler/captainhook_handler.py +16 -9
- suite_py/lib/handler/metrics_handler.py +8 -6
- suite_py/lib/handler/okta_handler.py +81 -0
- suite_py/lib/logger.py +1 -0
- suite_py/lib/metrics.py +4 -2
- suite_py/lib/oauth.py +156 -0
- suite_py/lib/tokens.py +4 -0
- {suite_py-1.41.3.dist-info → suite_py-1.41.4.dist-info}/METADATA +2 -4
- suite_py-1.41.4.dist-info/RECORD +54 -0
- suite_py/commands/qa.py +0 -424
- suite_py/lib/handler/qainit_handler.py +0 -259
- suite_py-1.41.3.dist-info/RECORD +0 -53
- /suite_py/{commands/templates → templates}/login.html +0 -0
- {suite_py-1.41.3.dist-info → suite_py-1.41.4.dist-info}/WHEEL +0 -0
- {suite_py-1.41.3.dist-info → suite_py-1.41.4.dist-info}/entry_points.txt +0 -0
suite_py/lib/oauth.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import secrets
|
|
4
|
+
import typing
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import webbrowser
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
from os import path
|
|
10
|
+
from urllib.parse import parse_qs, urlparse
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
import suite_py
|
|
15
|
+
from suite_py.lib import logger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class OAuthTokenResponse:
|
|
20
|
+
access_token: str
|
|
21
|
+
id_token: typing.Optional[str]
|
|
22
|
+
refresh_token: typing.Optional[str]
|
|
23
|
+
expires_in: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def retrieve_token(base_url, params) -> OAuthTokenResponse:
|
|
27
|
+
url = f"{base_url}/token"
|
|
28
|
+
headers = {
|
|
29
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
30
|
+
"Accept": "application/json",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
data = requests.post(url, headers=headers, data=params, timeout=30).json()
|
|
34
|
+
logger.debug(data)
|
|
35
|
+
|
|
36
|
+
if error := data.get("error_description", None):
|
|
37
|
+
raise Exception(f"OAuth error: {error}")
|
|
38
|
+
|
|
39
|
+
return OAuthTokenResponse(
|
|
40
|
+
access_token=data["access_token"],
|
|
41
|
+
expires_in=data["expires_in"],
|
|
42
|
+
id_token=data.get("id_token", None),
|
|
43
|
+
refresh_token=data.get("refresh_token", None),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OAuthCallbackServer(HTTPServer):
|
|
48
|
+
received_state: typing.Optional[str] = None
|
|
49
|
+
error_message: typing.Optional[str] = None
|
|
50
|
+
code: typing.Optional[str] = None
|
|
51
|
+
|
|
52
|
+
def __init__(self, server_address) -> None:
|
|
53
|
+
super().__init__(server_address, OAuthCallbackRequestHandler)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OAuthCallbackRequestHandler(BaseHTTPRequestHandler):
|
|
57
|
+
def do_GET(self):
|
|
58
|
+
assert isinstance(self.server, OAuthCallbackServer)
|
|
59
|
+
|
|
60
|
+
server = self.server
|
|
61
|
+
args = parse_qs(urlparse(self.path).query)
|
|
62
|
+
|
|
63
|
+
server.received_state = args["state"][0]
|
|
64
|
+
if "error" in args:
|
|
65
|
+
error = args["error"][0]
|
|
66
|
+
error_description = args["error_description"][0]
|
|
67
|
+
server.error_message = f"{error}: {error_description}"
|
|
68
|
+
else:
|
|
69
|
+
server.code = args["code"][0]
|
|
70
|
+
|
|
71
|
+
self.send_response(200)
|
|
72
|
+
self.send_header("content-type", "text/html")
|
|
73
|
+
self.end_headers()
|
|
74
|
+
|
|
75
|
+
template = path.join(path.dirname(suite_py.__file__), "templates/login.html")
|
|
76
|
+
with open(template, "rb") as f:
|
|
77
|
+
self.wfile.write(f.read())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _url_encode_no_padding(byte_data):
|
|
81
|
+
"""
|
|
82
|
+
Safe encoding handles + and /, and also replace = with nothing
|
|
83
|
+
"""
|
|
84
|
+
return base64.urlsafe_b64encode(byte_data).decode("utf-8").replace("=", "")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _generate_challenge(a_verifier):
|
|
88
|
+
return _url_encode_no_padding(hashlib.sha256(a_verifier.encode()).digest())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def authorization_code_flow(
|
|
92
|
+
client_id,
|
|
93
|
+
base_url,
|
|
94
|
+
scope,
|
|
95
|
+
redirect_uri="http://127.0.0.1:5000/callback",
|
|
96
|
+
listen=("0.0.0.0", 5000),
|
|
97
|
+
):
|
|
98
|
+
# from https://auth0.com/docs/flows/add-login-using-the-authorization-code-flow-with-pkce
|
|
99
|
+
# Step1: Create code verifier: Generate a code_verifier that will be sent to Auth0 to request tokens.
|
|
100
|
+
verifier = _url_encode_no_padding(secrets.token_bytes(32))
|
|
101
|
+
# Step2: Create code challenge: Generate a code_challenge from the code_verifier that will be sent to Auth0 to request an authorization_code.
|
|
102
|
+
challenge = _generate_challenge(verifier)
|
|
103
|
+
state = _url_encode_no_padding(secrets.token_bytes(32))
|
|
104
|
+
|
|
105
|
+
# We generate a nonce (state) that is used to protect against attackers invoking the callback
|
|
106
|
+
url = f"{base_url}/authorize?"
|
|
107
|
+
url_parameters = {
|
|
108
|
+
"scope": scope,
|
|
109
|
+
"response_type": "code",
|
|
110
|
+
"redirect_uri": redirect_uri,
|
|
111
|
+
"client_id": client_id,
|
|
112
|
+
"code_challenge": challenge.replace("=", ""),
|
|
113
|
+
"code_challenge_method": "S256",
|
|
114
|
+
"state": state,
|
|
115
|
+
}
|
|
116
|
+
url = url + urllib.parse.urlencode(url_parameters)
|
|
117
|
+
|
|
118
|
+
# Step3: Authorize user: Request the user's authorization and redirect back to your app with an authorization_code.
|
|
119
|
+
# Open the browser window to the login url
|
|
120
|
+
# Start the server
|
|
121
|
+
logger.info("A browser tab should've opened. If not manually navigate to: " + url)
|
|
122
|
+
webbrowser.open(url)
|
|
123
|
+
|
|
124
|
+
server = OAuthCallbackServer(listen)
|
|
125
|
+
server.handle_request()
|
|
126
|
+
|
|
127
|
+
if state != server.received_state:
|
|
128
|
+
raise Exception(
|
|
129
|
+
"Error: session replay or similar attack in progress. Please log out of all connections."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if server.error_message:
|
|
133
|
+
raise Exception(server.error_message)
|
|
134
|
+
|
|
135
|
+
# Step4: Request tokens: Exchange your authorization_code and code_verifier for tokens.
|
|
136
|
+
body = {
|
|
137
|
+
"grant_type": "authorization_code",
|
|
138
|
+
"client_id": client_id,
|
|
139
|
+
"code_verifier": verifier,
|
|
140
|
+
"code": server.code,
|
|
141
|
+
"redirect_uri": redirect_uri,
|
|
142
|
+
}
|
|
143
|
+
return retrieve_token(base_url, body)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def do_refresh_token(
|
|
147
|
+
client_id: str, base_url: str, scope: str, refresh_token: str
|
|
148
|
+
) -> OAuthTokenResponse:
|
|
149
|
+
# See https://developer.okta.com/docs/guides/refresh-tokens/main/
|
|
150
|
+
params = {
|
|
151
|
+
"scope": scope,
|
|
152
|
+
"client_id": client_id,
|
|
153
|
+
"grant_type": "refresh_token",
|
|
154
|
+
"refresh_token": refresh_token,
|
|
155
|
+
}
|
|
156
|
+
return retrieve_token(base_url, params)
|
suite_py/lib/tokens.py
CHANGED
|
@@ -149,6 +149,7 @@ class Tokens:
|
|
|
149
149
|
|
|
150
150
|
def edit(self, service, token):
|
|
151
151
|
self._tokens[service] = token
|
|
152
|
+
self.save()
|
|
152
153
|
|
|
153
154
|
def keys(self):
|
|
154
155
|
return self._tokens.keys()
|
|
@@ -164,3 +165,6 @@ class Tokens:
|
|
|
164
165
|
@property
|
|
165
166
|
def drone(self):
|
|
166
167
|
return self._tokens["drone"]
|
|
168
|
+
|
|
169
|
+
def okta(self):
|
|
170
|
+
return self._tokens.get("okta", {})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: suite-py
|
|
3
|
-
Version: 1.41.
|
|
3
|
+
Version: 1.41.4
|
|
4
4
|
Summary:
|
|
5
5
|
Author: larrywax, EugenioLaghi, michelangelomo
|
|
6
6
|
Author-email: devops@prima.it
|
|
@@ -12,12 +12,10 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Requires-Dist: Click (>=7.0)
|
|
15
|
-
Requires-Dist: Flask (==1.1.2)
|
|
16
15
|
Requires-Dist: InquirerPy (>=0.2.0)
|
|
17
16
|
Requires-Dist: Jinja2 (>=2.11,<3.0.0)
|
|
18
17
|
Requires-Dist: PyGithub (>=1.57)
|
|
19
18
|
Requires-Dist: PyYaml (>=5.4)
|
|
20
|
-
Requires-Dist: Werkzeug (==2.0.2)
|
|
21
19
|
Requires-Dist: autoupgrade-prima (>=0.6)
|
|
22
20
|
Requires-Dist: black (>=22.6,<25.0)
|
|
23
21
|
Requires-Dist: boto3 (>=1.17.84)
|
|
@@ -27,7 +25,7 @@ Requires-Dist: cryptography (==42.0.5)
|
|
|
27
25
|
Requires-Dist: halo (>=0.0.28)
|
|
28
26
|
Requires-Dist: inquirer (==3.1.4)
|
|
29
27
|
Requires-Dist: itsdangerous (==2.0.1)
|
|
30
|
-
Requires-Dist: keyring (>=23.9.1,<
|
|
28
|
+
Requires-Dist: keyring (>=23.9.1,<26.0.0)
|
|
31
29
|
Requires-Dist: kubernetes (==29.0.0)
|
|
32
30
|
Requires-Dist: logzero (==1.7.0)
|
|
33
31
|
Requires-Dist: markupsafe (==2.0.1)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
suite_py/__init__.py,sha256=REmi3D0X2G1ZWnYpKs8Ffm3NIj-Hw6dMuvz2b9NW344,142
|
|
2
|
+
suite_py/__version__.py,sha256=AhkyH0YLIU7gH3Em5wzBcxR3qqCht93E136t28Bl6Yk,49
|
|
3
|
+
suite_py/cli.py,sha256=LDv8ppD4Sua-TKeNnAtdUvkQ9qql6kMzOqGueyIepYw,14961
|
|
4
|
+
suite_py/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
suite_py/commands/aggregator.py,sha256=_xyyX7MOMZzDEvzj2AI03OF3sut5PpSIyuRjUiCp5aI,5747
|
|
6
|
+
suite_py/commands/ask_review.py,sha256=yN__Ac-fiZBPShjRDhyCCQZGfVlQE16KozoJk4UtiNw,3788
|
|
7
|
+
suite_py/commands/batch_job.py,sha256=pcSpDov9uNY4z9MjQ8KDapEC9zhEJDe7XcJV7uidHyE,7450
|
|
8
|
+
suite_py/commands/bump.py,sha256=oFZU1hPfD11ujFC5G7wFyQOf2alY3xp2SO1h1ldjf3s,5406
|
|
9
|
+
suite_py/commands/check.py,sha256=0e2NsPi3cqvCwtNYzhR1UroT59bCojLlGo69vv3FiOA,4074
|
|
10
|
+
suite_py/commands/common.py,sha256=aWCEvO3hqdheuMUmZcHuc9EGZPQTk7VkzkHJk283MxQ,566
|
|
11
|
+
suite_py/commands/context.py,sha256=coK1O1XZ1nhtduEvZNpYJQ0RkTCXhrLYCbK3IBniDkQ,753
|
|
12
|
+
suite_py/commands/create_branch.py,sha256=cDpeDsQk1AK70GkWz-hTduaEeU65x1wck1b-5nKIMew,4424
|
|
13
|
+
suite_py/commands/deploy.py,sha256=kadgbVKUMtE_X4b8oWaQ1ufS4Qy9b2WuutPDXnjm4t4,8088
|
|
14
|
+
suite_py/commands/docker.py,sha256=POz_VXOXEQaFZCafkH-grgB2_HZFrckAc0CpB9IgOiU,2932
|
|
15
|
+
suite_py/commands/generator.py,sha256=-wQFRS0UNc-EvuYvnj3gk6DHTVSsg9lA-NMU2kQewb8,8510
|
|
16
|
+
suite_py/commands/id.py,sha256=qMQMSH_bGDInarYaGOpX2lEGf1tyHBeAV5GQiNr5Kiw,1998
|
|
17
|
+
suite_py/commands/ip.py,sha256=pbyVuee_cN507qUYSBv5gWcvKLYolOeuU_w_P7P7nVc,2396
|
|
18
|
+
suite_py/commands/login.py,sha256=A59e1HsbN7Ocv2L_2H0Eb7MZK7AzLkLb72QxBthnIqU,258
|
|
19
|
+
suite_py/commands/merge_pr.py,sha256=gUpoDx3q23X6gF9PLXCZnIL9DfRw_3D0LlqBlGVt7rA,5676
|
|
20
|
+
suite_py/commands/open_pr.py,sha256=U7MVl-JFKu1mdfxC_UvxUHtPLEln0g4kKl-SvP-6zd8,7159
|
|
21
|
+
suite_py/commands/project_lock.py,sha256=b7OkGysue_Sl13VIT7B5CTBppCvrB_Q6iC0IJRBSHp8,1909
|
|
22
|
+
suite_py/commands/release.py,sha256=clKgmsNgR9DdgBkYjXI3NqVaw8_mCe2TZHegby3ESn4,16634
|
|
23
|
+
suite_py/commands/secret.py,sha256=IOPQBTXsi8qFd84yxGe38U9b1x53dHguoLolCTOtRoU,7995
|
|
24
|
+
suite_py/commands/set_token.py,sha256=fehIqKjKhE-BJGFhgkPTo3Ntr0MvpgLd6EC5yjKuRs8,1508
|
|
25
|
+
suite_py/commands/status.py,sha256=0JUK53_d1-U3WNS742JD2QTiGmCGZONo3jJx8WR7q70,1122
|
|
26
|
+
suite_py/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
|
+
suite_py/lib/config.py,sha256=4uKXAav8E-VXlXGPnkYQ_fZYyi6058S0FM2JmyV8L2k,3970
|
|
28
|
+
suite_py/lib/handler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
suite_py/lib/handler/aws_handler.py,sha256=dRvRDicikfRbuFtCLPbevaX-yC-fO4LwXFdyqLPJ8OI,8815
|
|
30
|
+
suite_py/lib/handler/captainhook_handler.py,sha256=vS-RNhUgQ7FriCdBDEd2Ci04B5FJ0p7gi5woCaoEeY8,3254
|
|
31
|
+
suite_py/lib/handler/changelog_handler.py,sha256=-ppnRl3smBA_ys8tPqXmytS4eyntlwfawC2fiXFcwlw,4818
|
|
32
|
+
suite_py/lib/handler/drone_handler.py,sha256=rmtzu30OQyG3vRPlbZKsQhHN9zbguPtXO0RpDjYOTPA,8967
|
|
33
|
+
suite_py/lib/handler/frequent_reviewers_handler.py,sha256=EIJX5FEMWzrxuXS9A17hu1vfxgJSOHSBX_ahCEZ2FVA,2185
|
|
34
|
+
suite_py/lib/handler/git_handler.py,sha256=boxhl9lQz6fjEJ10ib1KrDW-geCVjhA_6nKwv2ll01g,11333
|
|
35
|
+
suite_py/lib/handler/github_handler.py,sha256=AnFL54yOZ5GDIU91wQat4s-d1WTcmg_B_5M7-Rop3wA,2900
|
|
36
|
+
suite_py/lib/handler/metrics_handler.py,sha256=-Tp62pFIiYsBkDga0nQG3lWU-gxH68wEjIIIJeU1jHk,3159
|
|
37
|
+
suite_py/lib/handler/okta_handler.py,sha256=3GEnJxbLXlu2zjFWniYwG0m1mFKJGfske1t-uUUdpZU,2545
|
|
38
|
+
suite_py/lib/handler/prompt_utils.py,sha256=vgk1O7h-iYEAZv1sXtMh8xIgH1djI398rzxRIgZWZcg,2474
|
|
39
|
+
suite_py/lib/handler/vault_handler.py,sha256=r4osw7qwz3ZFmLg2U1oFPdtRFcXzDXiaWBZC01cYK_w,871
|
|
40
|
+
suite_py/lib/handler/version_handler.py,sha256=DXTx4yCAbFVC6CdMqPJ-LiN5YM-dT2zklG8POyKTP5A,6774
|
|
41
|
+
suite_py/lib/handler/youtrack_handler.py,sha256=eTGBBXjlN_ay1cawtnZ2IG6l78dDyKdMN1x6PxcvtA0,7499
|
|
42
|
+
suite_py/lib/logger.py,sha256=q_qRtpg0Eh7r5tB-Rwz87dnWBwP-a2dIvggkiQikH8M,782
|
|
43
|
+
suite_py/lib/metrics.py,sha256=hGGrWg_c3uTz4xhpb7POGZh_xhIAYU3drRo-KgwpBvM,1626
|
|
44
|
+
suite_py/lib/oauth.py,sha256=IFJNrVlUXZ7GewgUmwpVP3aZYFLVSQZPramlYjzqDq4,5012
|
|
45
|
+
suite_py/lib/requests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
46
|
+
suite_py/lib/requests/auth.py,sha256=wN_WtGFmDUWRFilWzOmUaRBvP2n3EPpPMqex9Zjddko,228
|
|
47
|
+
suite_py/lib/requests/session.py,sha256=P32H3cWnCWunu91WIj2iDM5U3HzaBglg60VN_C9JBL4,267
|
|
48
|
+
suite_py/lib/symbol.py,sha256=z3QYBuNIwD3qQ3zF-cLOomIr_-C3bO_u5UIDAHMiyTo,60
|
|
49
|
+
suite_py/lib/tokens.py,sha256=kK4vatd5iKEFaue0BZwK1f66YOh9zLdLzP41ZOhjMCU,5534
|
|
50
|
+
suite_py/templates/login.html,sha256=fJLls2SB84oZTSrxTdA5q1PqfvIHcCD4fhVWfyco7Ig,861
|
|
51
|
+
suite_py-1.41.4.dist-info/METADATA,sha256=1DDdO0XjzywlKTQtz0oevgk8Ds0j6XsoR4iF69ccXrw,1531
|
|
52
|
+
suite_py-1.41.4.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
|
53
|
+
suite_py-1.41.4.dist-info/entry_points.txt,sha256=dVKLC-9Infy-dHJT_MkK6LcDjOgBCJ8lfPkURJhBjxE,46
|
|
54
|
+
suite_py-1.41.4.dist-info/RECORD,,
|
suite_py/commands/qa.py
DELETED
|
@@ -1,424 +0,0 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
|
|
3
|
-
import copy
|
|
4
|
-
import datetime
|
|
5
|
-
import json
|
|
6
|
-
import re
|
|
7
|
-
import sys
|
|
8
|
-
|
|
9
|
-
from dateutil import parser, tz
|
|
10
|
-
from rich.console import Console
|
|
11
|
-
from rich.table import Table
|
|
12
|
-
|
|
13
|
-
from suite_py.commands.login import Login
|
|
14
|
-
from suite_py.lib import logger
|
|
15
|
-
from suite_py.lib import metrics
|
|
16
|
-
from suite_py.lib.handler import git_handler as git
|
|
17
|
-
from suite_py.lib.handler import prompt_utils
|
|
18
|
-
from suite_py.lib.handler.drone_handler import DroneHandler
|
|
19
|
-
from suite_py.lib.handler.git_handler import GitHandler
|
|
20
|
-
from suite_py.lib.handler.qainit_handler import QainitHandler
|
|
21
|
-
from suite_py.lib.handler.youtrack_handler import YoutrackHandler
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class QA:
|
|
25
|
-
def __init__(self, action, project, config, tokens, flags=None):
|
|
26
|
-
self._action = action
|
|
27
|
-
self._project = project
|
|
28
|
-
self._flags = flags
|
|
29
|
-
self._config = config
|
|
30
|
-
self._tokens = tokens
|
|
31
|
-
self._git = GitHandler(project, config)
|
|
32
|
-
self._qainit = QainitHandler(project, config, tokens)
|
|
33
|
-
self._youtrack = YoutrackHandler(config, tokens)
|
|
34
|
-
self._drone = DroneHandler(config, tokens)
|
|
35
|
-
|
|
36
|
-
@metrics.command("qa")
|
|
37
|
-
def run(self):
|
|
38
|
-
if not self._qainit.user_info():
|
|
39
|
-
logger.warning("You're not logged in.")
|
|
40
|
-
Login(self._config).run()
|
|
41
|
-
|
|
42
|
-
if self._action == "list":
|
|
43
|
-
self._list()
|
|
44
|
-
elif self._action == "create":
|
|
45
|
-
self._create()
|
|
46
|
-
elif self._action == "update":
|
|
47
|
-
self._update()
|
|
48
|
-
elif self._action == "delete":
|
|
49
|
-
self._delete()
|
|
50
|
-
elif self._action == "freeze":
|
|
51
|
-
self._freeze()
|
|
52
|
-
elif self._action == "unfreeze":
|
|
53
|
-
self._unfreeze()
|
|
54
|
-
elif self._action == "check":
|
|
55
|
-
self._check()
|
|
56
|
-
elif self._action == "describe":
|
|
57
|
-
self._describe()
|
|
58
|
-
elif self._action == "update-quota":
|
|
59
|
-
self._update_quota()
|
|
60
|
-
elif self._action == "toggle-maintenance":
|
|
61
|
-
self._toggle_maintenance()
|
|
62
|
-
|
|
63
|
-
def _check(self):
|
|
64
|
-
logger.info(
|
|
65
|
-
"Checking configuration. If there is an issue, check ~/.suite_py/config.yml file and execute: suite-py login"
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
self._qainit.user_info()
|
|
69
|
-
|
|
70
|
-
def _clean_date(self, datetime_str):
|
|
71
|
-
# expected format: '2021-07-23T14:04:12.000000Z'
|
|
72
|
-
datetime_object = datetime.datetime.strptime(
|
|
73
|
-
datetime_str, "%Y-%m-%dT%H:%M:%S.%fZ"
|
|
74
|
-
)
|
|
75
|
-
# Define time zones:
|
|
76
|
-
utc_time_zone = tz.tzutc()
|
|
77
|
-
local_time_zone = tz.tzlocal()
|
|
78
|
-
# Convert time zone
|
|
79
|
-
utc_datetime_object = datetime_object.replace(tzinfo=utc_time_zone)
|
|
80
|
-
local_datetime_object = utc_datetime_object.astimezone(local_time_zone)
|
|
81
|
-
return local_datetime_object.strftime("%d/%m/%Y %H:%M:%S %z")
|
|
82
|
-
|
|
83
|
-
def _create_instance_table(self):
|
|
84
|
-
instance_table = Table()
|
|
85
|
-
instance_table.add_column("Name", style="purple")
|
|
86
|
-
instance_table.add_column("Hash", style="green", width=32)
|
|
87
|
-
instance_table.add_column("Card", style="white")
|
|
88
|
-
instance_table.add_column("Created by", style="white")
|
|
89
|
-
instance_table.add_column("Updated by", style="white")
|
|
90
|
-
instance_table.add_column("Deleted by", style="white")
|
|
91
|
-
instance_table.add_column("Last update", style="white")
|
|
92
|
-
instance_table.add_column("Status", style="white")
|
|
93
|
-
|
|
94
|
-
return instance_table
|
|
95
|
-
|
|
96
|
-
def _insert_instance_record(self, table, qa):
|
|
97
|
-
table.add_row(
|
|
98
|
-
qa["name"],
|
|
99
|
-
qa["hash"],
|
|
100
|
-
qa["card"],
|
|
101
|
-
(
|
|
102
|
-
qa.get("created", {}).get("github_username", "/")
|
|
103
|
-
if qa["created"] is not None
|
|
104
|
-
else "/"
|
|
105
|
-
),
|
|
106
|
-
(
|
|
107
|
-
qa.get("updated", {}).get("github_username", "/")
|
|
108
|
-
if qa["updated"] is not None
|
|
109
|
-
else "/"
|
|
110
|
-
),
|
|
111
|
-
(
|
|
112
|
-
qa.get("deleted", {}).get("github_username", "/")
|
|
113
|
-
if qa["deleted"] is not None
|
|
114
|
-
else "/"
|
|
115
|
-
),
|
|
116
|
-
self._clean_date(qa["updated_at"]),
|
|
117
|
-
qa["status"],
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
def _list(self):
|
|
121
|
-
# init empty table with column (useful for reset)
|
|
122
|
-
empty_table = self._create_instance_table()
|
|
123
|
-
table = copy.deepcopy(empty_table)
|
|
124
|
-
console = Console()
|
|
125
|
-
|
|
126
|
-
# execute query with pagination and filtering
|
|
127
|
-
page_number = 1
|
|
128
|
-
while True:
|
|
129
|
-
r = self._qainit.list(
|
|
130
|
-
self._flags,
|
|
131
|
-
page=page_number,
|
|
132
|
-
page_size=self._config.qainit["table_size"],
|
|
133
|
-
)
|
|
134
|
-
response = r.json()
|
|
135
|
-
qa_list = response["list"]
|
|
136
|
-
for qa in qa_list:
|
|
137
|
-
self._insert_instance_record(table, qa)
|
|
138
|
-
|
|
139
|
-
console.print(table)
|
|
140
|
-
|
|
141
|
-
# break conditions
|
|
142
|
-
if response["page_number"] >= response["total_pages"]:
|
|
143
|
-
break
|
|
144
|
-
if not prompt_utils.ask_confirm(
|
|
145
|
-
f"I found {response['total_entries']} results. Do you want to load a few more?",
|
|
146
|
-
False,
|
|
147
|
-
):
|
|
148
|
-
break
|
|
149
|
-
page_number += 1
|
|
150
|
-
# table reset
|
|
151
|
-
table = copy.deepcopy(empty_table)
|
|
152
|
-
|
|
153
|
-
def _describe(self):
|
|
154
|
-
qa_hash = self._flags["hash"]
|
|
155
|
-
jsonify = self._flags["json"]
|
|
156
|
-
|
|
157
|
-
r = self._qainit.describe(qa_hash)
|
|
158
|
-
|
|
159
|
-
issues = self._check_instance_issues(r)
|
|
160
|
-
if issues:
|
|
161
|
-
msg = "an issue" if len(issues) == 1 else f"{len(issues)} issues"
|
|
162
|
-
logger.warning(f"⚠️ suite-py diagnostic tool found {msg}:")
|
|
163
|
-
for i in issues:
|
|
164
|
-
logger.warning(i)
|
|
165
|
-
logger.warning(
|
|
166
|
-
"If the problem persist write a detailed message on #team-platform-operations"
|
|
167
|
-
)
|
|
168
|
-
sys.exit(-1)
|
|
169
|
-
|
|
170
|
-
if jsonify:
|
|
171
|
-
print(json.dumps(r, sort_keys=True, indent=2))
|
|
172
|
-
else:
|
|
173
|
-
# RESOURCES TABLE
|
|
174
|
-
table = Table()
|
|
175
|
-
table.add_column("Microservice", style="purple", no_wrap=True)
|
|
176
|
-
table.add_column("Drone build")
|
|
177
|
-
table.add_column("Branch", style="white")
|
|
178
|
-
table.add_column("Last update", style="white")
|
|
179
|
-
table.add_column("Status", style="white")
|
|
180
|
-
|
|
181
|
-
# INSTANCE TABLE
|
|
182
|
-
instance_table = self._create_instance_table()
|
|
183
|
-
|
|
184
|
-
self._insert_instance_record(instance_table, r["list"])
|
|
185
|
-
|
|
186
|
-
# DNS TABLE
|
|
187
|
-
dns_table = Table()
|
|
188
|
-
dns_table.add_column("Name", style="purple", no_wrap=True)
|
|
189
|
-
dns_table.add_column("Record", style="green")
|
|
190
|
-
|
|
191
|
-
console = Console()
|
|
192
|
-
|
|
193
|
-
try:
|
|
194
|
-
resources_list = sorted(r["list"]["resources"], key=lambda k: k["name"])
|
|
195
|
-
for resource in resources_list:
|
|
196
|
-
if (
|
|
197
|
-
(
|
|
198
|
-
resource["type"] == "microservice"
|
|
199
|
-
or "service" in resource["name"]
|
|
200
|
-
)
|
|
201
|
-
and "dns" in resource
|
|
202
|
-
and resource["dns"] is not None
|
|
203
|
-
):
|
|
204
|
-
for key, value in resource["dns"].items():
|
|
205
|
-
dns_table.add_row(key, value)
|
|
206
|
-
if resource["type"] == "microservice":
|
|
207
|
-
drone_url = (
|
|
208
|
-
(
|
|
209
|
-
"[blue][u]"
|
|
210
|
-
+ "https://drone-1.prima.it/primait/"
|
|
211
|
-
+ resource["name"]
|
|
212
|
-
+ "/"
|
|
213
|
-
+ resource["promoted_build"]
|
|
214
|
-
+ "[/u][/blue]"
|
|
215
|
-
)
|
|
216
|
-
if resource["promoted_build"]
|
|
217
|
-
else "Not available"
|
|
218
|
-
)
|
|
219
|
-
table.add_row(
|
|
220
|
-
resource["name"],
|
|
221
|
-
drone_url,
|
|
222
|
-
(
|
|
223
|
-
resource["ref"]
|
|
224
|
-
if resource["ref"] == "master"
|
|
225
|
-
else f"[green]{resource['ref']}[/green]"
|
|
226
|
-
),
|
|
227
|
-
self._clean_date(resource["updated_at"]),
|
|
228
|
-
resource["status"],
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
console.print(instance_table)
|
|
232
|
-
console.print(dns_table)
|
|
233
|
-
console.print(table)
|
|
234
|
-
except TypeError as e:
|
|
235
|
-
logger.error(f"Unexpected response format: {e}")
|
|
236
|
-
|
|
237
|
-
def _delete(self):
|
|
238
|
-
qa_hashes = self._flags["hashes"]
|
|
239
|
-
force = self._flags["force"]
|
|
240
|
-
for qa_hash in qa_hashes:
|
|
241
|
-
if force:
|
|
242
|
-
if not self._qainit.force_update(qa_hash):
|
|
243
|
-
# this function fails only if the QA is effectively stuck
|
|
244
|
-
# and qa init it's unable to solve it
|
|
245
|
-
logger.error(
|
|
246
|
-
"QA force deletion has failed, please contact #platform-operations team."
|
|
247
|
-
)
|
|
248
|
-
return
|
|
249
|
-
self._qainit.delete(qa_hash, force)
|
|
250
|
-
|
|
251
|
-
def _freeze(self):
|
|
252
|
-
self._qainit.freeze(self._flags["hash"])
|
|
253
|
-
|
|
254
|
-
def _unfreeze(self):
|
|
255
|
-
self._qainit.unfreeze(self._flags["hash"])
|
|
256
|
-
|
|
257
|
-
def _create(self):
|
|
258
|
-
user = self._qainit.user_info()
|
|
259
|
-
|
|
260
|
-
if not user["quota"]["remaining"] > 0:
|
|
261
|
-
logger.error("There's no remaining quota for you.")
|
|
262
|
-
sys.exit("-1")
|
|
263
|
-
|
|
264
|
-
if "staging" in self._qainit.url:
|
|
265
|
-
qa_default_name = (
|
|
266
|
-
f"staging_{git.get_username()}_{self._git.current_branch_name()}"
|
|
267
|
-
)
|
|
268
|
-
else:
|
|
269
|
-
qa_default_name = f"{git.get_username()}_{self._git.current_branch_name()}"
|
|
270
|
-
|
|
271
|
-
qa_name = prompt_utils.ask_questions_input(
|
|
272
|
-
"Choose the QA name: ", default_text=qa_default_name
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
card_match = re.match(r"[^\/]*_(?P<name>[A-Z]+-\d+)\/", qa_name)
|
|
276
|
-
default_card_name = (
|
|
277
|
-
card_match["name"] if card_match else self._config.user["default_slug"]
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
qa_card = prompt_utils.ask_questions_input(
|
|
281
|
-
"Youtrack issue ID: ", default_text=default_card_name
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
if qa_card != "":
|
|
285
|
-
try:
|
|
286
|
-
self._youtrack.get_issue(qa_card)
|
|
287
|
-
except Exception:
|
|
288
|
-
logger.error("invalid Youtrack issue ID")
|
|
289
|
-
sys.exit(-1)
|
|
290
|
-
|
|
291
|
-
self._qainit.create(qa_name, qa_card, self._flags["services"])
|
|
292
|
-
|
|
293
|
-
def _update(self):
|
|
294
|
-
self._qainit.update(self._flags["hash"], self._flags["services"])
|
|
295
|
-
|
|
296
|
-
def _update_quota(self):
|
|
297
|
-
username = prompt_utils.ask_questions_input("Insert GitHub username: ")
|
|
298
|
-
quota = prompt_utils.ask_questions_input("Insert new quota value: ")
|
|
299
|
-
|
|
300
|
-
self._qainit.update_user_quota(username, quota)
|
|
301
|
-
|
|
302
|
-
def _toggle_maintenance(self):
|
|
303
|
-
self._qainit.maintenance()
|
|
304
|
-
|
|
305
|
-
def _check_instance_issues(self, response):
|
|
306
|
-
issues = []
|
|
307
|
-
|
|
308
|
-
if not response["list"]:
|
|
309
|
-
issues.append("QA not found")
|
|
310
|
-
|
|
311
|
-
# Check if instance status is still pending
|
|
312
|
-
if response["list"] and response["list"]["status"] in ["creating", "updating"]:
|
|
313
|
-
microservices = self._filter_list(
|
|
314
|
-
"type", ["microservice"], response["list"]["resources"]
|
|
315
|
-
)
|
|
316
|
-
# 1. Microservices list is empty?
|
|
317
|
-
if len(microservices) == 0:
|
|
318
|
-
issues.append("Microservices list is empty, please be patient.")
|
|
319
|
-
return issues
|
|
320
|
-
|
|
321
|
-
# 2. Check if qainit worker is still launching updates
|
|
322
|
-
if not self._is_update_initiated(microservices, response["list"]):
|
|
323
|
-
issues.append(
|
|
324
|
-
"Qainit is still working on microservices, try again in a while."
|
|
325
|
-
)
|
|
326
|
-
issues.append("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
|
|
327
|
-
return issues
|
|
328
|
-
|
|
329
|
-
# 3. Check every resource
|
|
330
|
-
stale_resources = self._filter_list(
|
|
331
|
-
"status", ["creating", "updating"], microservices
|
|
332
|
-
)
|
|
333
|
-
for resource in stale_resources:
|
|
334
|
-
issues += self._check_resource_issues(resource)
|
|
335
|
-
|
|
336
|
-
# 4. Check is instance is stuck in a stale status
|
|
337
|
-
if (
|
|
338
|
-
len(microservices) > 0
|
|
339
|
-
and len(stale_resources) == 0
|
|
340
|
-
and self._minutes_elapsed_since_update(response["list"]) >= 5
|
|
341
|
-
):
|
|
342
|
-
logger.warning("Trying to call qainit to force QA update...")
|
|
343
|
-
if self._qainit.force_update(response["list"]["hash"]):
|
|
344
|
-
issues.append(
|
|
345
|
-
"Your QA was in a stale status and has been fixed, launch `suite-py describe` again please."
|
|
346
|
-
)
|
|
347
|
-
else:
|
|
348
|
-
issues.append(
|
|
349
|
-
"Your QA was in a stale status but forced update has failed, please contact #platform-operations team."
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
return issues
|
|
353
|
-
|
|
354
|
-
def _is_update_initiated(self, microservices, instance):
|
|
355
|
-
microservices_updates = [
|
|
356
|
-
parser.parse(x["updated_at"]).timestamp() for x in microservices
|
|
357
|
-
]
|
|
358
|
-
max_microservice_update = max(microservices_updates)
|
|
359
|
-
instance_update = parser.parse(instance["updated_at"]).timestamp()
|
|
360
|
-
|
|
361
|
-
return max_microservice_update >= instance_update
|
|
362
|
-
|
|
363
|
-
def _minutes_elapsed_since_update(self, instance):
|
|
364
|
-
return (
|
|
365
|
-
datetime.datetime.now().timestamp()
|
|
366
|
-
- parser.parse(instance["updated_at"]).timestamp()
|
|
367
|
-
) / 60
|
|
368
|
-
|
|
369
|
-
def _check_resource_issues(self, resource):
|
|
370
|
-
issues = []
|
|
371
|
-
|
|
372
|
-
build = self._drone.get_repo_build(resource["name"], resource["promoted_build"])
|
|
373
|
-
|
|
374
|
-
if "stages" not in build:
|
|
375
|
-
issues.append("Suite-py is unable to locate drone build.")
|
|
376
|
-
return issues
|
|
377
|
-
|
|
378
|
-
qainit_step = self._filter_list("name", ["qainit-it"], build["stages"])[0]
|
|
379
|
-
# Check if build is succeded
|
|
380
|
-
if build["status"] == "success" or qainit_step["status"] == "error":
|
|
381
|
-
issues.append(
|
|
382
|
-
f"Something between Drone and Qainit went wrong, did you try to restart the build? {self._drone.get_repo_build_url(resource['name'], resource['promoted_build'])}"
|
|
383
|
-
)
|
|
384
|
-
|
|
385
|
-
# Check if build is killed
|
|
386
|
-
elif build["status"] == "killed":
|
|
387
|
-
issues.append(
|
|
388
|
-
f"Seems someone killed the build, did you try to restart the build? {self._drone.get_repo_build_url(resource['name'], resource['promoted_build'])}"
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
# Check if build is failed
|
|
392
|
-
elif build["status"] == "failure":
|
|
393
|
-
build_pipeline = self._filter_list("name", ["build_qa"], build["stages"])[0]
|
|
394
|
-
if build_pipeline and build_pipeline["status"] == "failed":
|
|
395
|
-
issues.append(
|
|
396
|
-
f"Something went wrong during microservice build step, check the logs {self._drone.get_build_and_pipeline_url(resource['name'], resource['promoted_build'], build_pipeline['number'])}"
|
|
397
|
-
)
|
|
398
|
-
|
|
399
|
-
deploy_pipeline = self._filter_list(
|
|
400
|
-
"name", ["deploy-it-qa"], build["stages"]
|
|
401
|
-
)[0]
|
|
402
|
-
if deploy_pipeline and deploy_pipeline["status"] == "failed":
|
|
403
|
-
issues.append(
|
|
404
|
-
f"Something went wront during microservice deploy step, check the logs {self._drone.get_build_and_pipeline_url(resource['name'], resource['promoted_build'], deploy_pipeline['number'])}"
|
|
405
|
-
)
|
|
406
|
-
# Check if build is running for over an hour
|
|
407
|
-
elif (
|
|
408
|
-
build["status"] == "running"
|
|
409
|
-
and self._difference_in_hours(build["started"]) >= 1
|
|
410
|
-
):
|
|
411
|
-
issues.append(
|
|
412
|
-
f"It looks like the build is stuck, did you try to restart the build? {self._drone.get_repo_build_url(resource['name'], resource['promoted_build'])}"
|
|
413
|
-
)
|
|
414
|
-
|
|
415
|
-
return issues
|
|
416
|
-
|
|
417
|
-
def _filter_list(self, search_key, in_list, search_list):
|
|
418
|
-
return list(filter(lambda l: l[search_key] in in_list, search_list))
|
|
419
|
-
|
|
420
|
-
def _difference_in_hours(self, unix_timestamp):
|
|
421
|
-
ts = datetime.datetime.fromtimestamp(unix_timestamp)
|
|
422
|
-
current_ts = datetime.datetime.utcnow()
|
|
423
|
-
|
|
424
|
-
return (current_ts - ts).total_seconds() / 60 / 60
|