suite-py 1.41.3__py3-none-any.whl → 1.41.5__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/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
3
+ Version: 1.41.5
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,<25.0.0)
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=_n_EAZVSfeYAG6Un5ksmgHsMYicx6e8oFtLYMdsWLts,49
3
+ suite_py/cli.py,sha256=I4IbkCRCprI73-pjllFzDX8UY3lVUNOIbZAPRgK2ukw,14953
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=xZ9NueGfh14L46S8AWSCZAGJdOFUCa2cFbbguq5SVv8,3281
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.5.dist-info/METADATA,sha256=6wXinzivnOCIO246fhb5p4LV592QPKJYA2sZu7SrtWw,1531
52
+ suite_py-1.41.5.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
53
+ suite_py-1.41.5.dist-info/entry_points.txt,sha256=dVKLC-9Infy-dHJT_MkK6LcDjOgBCJ8lfPkURJhBjxE,46
54
+ suite_py-1.41.5.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