qontract-reconcile 0.10.2.dev430__py3-none-any.whl → 0.10.2.dev474__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.
Files changed (47) hide show
  1. {qontract_reconcile-0.10.2.dev430.dist-info → qontract_reconcile-0.10.2.dev474.dist-info}/METADATA +2 -2
  2. {qontract_reconcile-0.10.2.dev430.dist-info → qontract_reconcile-0.10.2.dev474.dist-info}/RECORD +47 -46
  3. {qontract_reconcile-0.10.2.dev430.dist-info → qontract_reconcile-0.10.2.dev474.dist-info}/WHEEL +1 -1
  4. reconcile/aus/aus_sts_gate_handler.py +59 -0
  5. reconcile/aus/base.py +9 -8
  6. reconcile/aus/version_gate_approver.py +1 -16
  7. reconcile/aus/version_gates/sts_version_gate_handler.py +5 -125
  8. reconcile/aws_account_manager/integration.py +13 -1
  9. reconcile/aws_account_manager/utils.py +1 -1
  10. reconcile/aws_ecr_image_pull_secrets.py +1 -1
  11. reconcile/change_owners/README.md +1 -1
  12. reconcile/change_owners/change_owners.py +108 -42
  13. reconcile/change_owners/decision.py +1 -1
  14. reconcile/cli.py +1 -1
  15. reconcile/external_resources/secrets_sync.py +2 -3
  16. reconcile/gql_definitions/aws_account_manager/aws_accounts.py +9 -0
  17. reconcile/gql_definitions/common/aws_vpc_requests.py +3 -0
  18. reconcile/gql_definitions/common/clusters.py +2 -0
  19. reconcile/gql_definitions/external_resources/external_resources_namespaces.py +6 -2
  20. reconcile/gql_definitions/fragments/aws_vpc_request.py +5 -0
  21. reconcile/gql_definitions/introspection.json +51 -7
  22. reconcile/gql_definitions/terraform_resources/terraform_resources_namespaces.py +8 -2
  23. reconcile/gql_definitions/vpc_peerings_validator/vpc_peerings_validator.py +3 -0
  24. reconcile/gql_definitions/vpc_peerings_validator/vpc_peerings_validator_peered_cluster_fragment.py +1 -0
  25. reconcile/openshift_namespaces.py +3 -4
  26. reconcile/quay_base.py +25 -6
  27. reconcile/quay_membership.py +55 -29
  28. reconcile/quay_mirror_org.py +6 -4
  29. reconcile/quay_permissions.py +81 -75
  30. reconcile/quay_repos.py +35 -37
  31. reconcile/queries.py +1 -1
  32. reconcile/templates/rosa-classic-cluster-creation.sh.j2 +1 -1
  33. reconcile/templates/rosa-hcp-cluster-creation.sh.j2 +1 -1
  34. reconcile/templating/validator.py +4 -4
  35. reconcile/terraform_vpc_resources/integration.py +10 -7
  36. reconcile/terraform_vpc_resources/merge_request.py +12 -2
  37. reconcile/terraform_vpc_resources/merge_request_manager.py +43 -19
  38. reconcile/typed_queries/saas_files.py +9 -4
  39. reconcile/utils/environ.py +5 -0
  40. reconcile/utils/external_resource_spec.py +2 -0
  41. reconcile/utils/gitlab_api.py +12 -0
  42. reconcile/utils/jjb_client.py +19 -3
  43. reconcile/utils/oc.py +8 -2
  44. reconcile/utils/quay_api.py +74 -87
  45. reconcile/utils/terrascript_aws_client.py +140 -50
  46. reconcile/vpc_peerings_validator.py +13 -0
  47. {qontract_reconcile-0.10.2.dev430.dist-info → qontract_reconcile-0.10.2.dev474.dist-info}/entry_points.txt +0 -0
@@ -24,6 +24,7 @@ from reconcile.typed_queries.external_resources import get_settings
24
24
  from reconcile.typed_queries.github_orgs import get_github_orgs
25
25
  from reconcile.typed_queries.gitlab_instances import get_gitlab_instances
26
26
  from reconcile.utils import gql
27
+ from reconcile.utils.disabled_integrations import integration_is_enabled
27
28
  from reconcile.utils.runtime.integration import (
28
29
  DesiredStateShardConfig,
29
30
  PydanticRunParams,
@@ -62,12 +63,14 @@ class TerraformVpcResources(QontractReconcileIntegration[TerraformVpcResourcesPa
62
63
  ) -> list[AWSAccountV1]:
63
64
  """Return a list of accounts extracted from the provided VPCRequests.
64
65
  If account_name is given returns the account object with that name."""
65
- accounts = [vpc.account for vpc in data]
66
-
67
- if account_name:
68
- accounts = [account for account in accounts if account.name == account_name]
69
-
70
- return accounts
66
+ return [
67
+ vpc.account
68
+ for vpc in data
69
+ if (
70
+ integration_is_enabled(self.name, vpc.account)
71
+ and (not account_name or vpc.account.name == account_name)
72
+ )
73
+ ]
71
74
 
72
75
  def _handle_outputs(
73
76
  self, requests: Iterable[VPCRequest], outputs: Mapping[str, Any]
@@ -155,7 +158,7 @@ class TerraformVpcResources(QontractReconcileIntegration[TerraformVpcResourcesPa
155
158
  if data:
156
159
  accounts = self._filter_accounts(data, account_name)
157
160
  if account_name and not accounts:
158
- msg = f"The account {account_name} doesn't have any managed vpc. Verify your input"
161
+ msg = f"The account {account_name} doesn't have any managed vpcs or the {QONTRACT_INTEGRATION} integration is disabled for this account. Verify your input"
159
162
  logging.debug(msg)
160
163
  sys.exit(ExitCodes.SUCCESS)
161
164
  else:
@@ -1,5 +1,6 @@
1
1
  import re
2
2
  import string
3
+ from enum import StrEnum
3
4
 
4
5
  from pydantic import BaseModel
5
6
 
@@ -9,6 +10,12 @@ PROMOTION_DATA_SEPARATOR = "**DO NOT MANUALLY CHANGE ANYTHING BELOW THIS LINE**"
9
10
  VERSION = "0.1.0"
10
11
  LABEL = "terraform-vpc-resources"
11
12
 
13
+
14
+ class Action(StrEnum):
15
+ CREATE = "create"
16
+ UPDATE = "update"
17
+
18
+
12
19
  VERSION_REF = "tf_vpc_resources_version"
13
20
  ACCOUNT_REF = "account"
14
21
  COMPILED_REGEXES = {
@@ -53,5 +60,8 @@ class Renderer:
53
60
  def render_description(self, account: str) -> str:
54
61
  return DESC.safe_substitute(account=account)
55
62
 
56
- def render_title(self, account: str) -> str:
57
- return f"[auto] VPC data file creation to {account}"
63
+ def render_title(self, account: str, action: Action) -> str:
64
+ return f"[auto] {action} VPC data file for {account}"
65
+
66
+ def render_update_title(self, account: str) -> str:
67
+ return f"[auto] VPC data file update for {account}"
@@ -5,6 +5,7 @@ from pydantic import BaseModel
5
5
 
6
6
  from reconcile.terraform_vpc_resources.merge_request import (
7
7
  LABEL,
8
+ Action,
8
9
  Info,
9
10
  Renderer,
10
11
  )
@@ -28,6 +29,7 @@ class VPCRequestMR(MergeRequestBase):
28
29
  vpc_tmpl_file_path: str,
29
30
  vpc_tmpl_file_content: str,
30
31
  labels: list[str],
32
+ action: Action,
31
33
  ):
32
34
  super().__init__()
33
35
  self._title = title
@@ -35,6 +37,7 @@ class VPCRequestMR(MergeRequestBase):
35
37
  self._vpc_tmpl_file_path = vpc_tmpl_file_path
36
38
  self._vpc_tmpl_file_content = vpc_tmpl_file_content
37
39
  self.labels = labels
40
+ self._action = action
38
41
 
39
42
  @property
40
43
  def title(self) -> str:
@@ -45,12 +48,21 @@ class VPCRequestMR(MergeRequestBase):
45
48
  return self._description
46
49
 
47
50
  def process(self, gitlab_cli: GitLabApi) -> None:
48
- gitlab_cli.create_file(
49
- branch_name=self.branch,
50
- file_path=self._vpc_tmpl_file_path,
51
- commit_message="add vpc datafile",
52
- content=self._vpc_tmpl_file_content,
53
- )
51
+ # Create or update file based on whether it already exists
52
+ if self._action == Action.UPDATE:
53
+ gitlab_cli.update_file(
54
+ branch_name=self.branch,
55
+ file_path=self._vpc_tmpl_file_path,
56
+ commit_message="update vpc datafile",
57
+ content=self._vpc_tmpl_file_content,
58
+ )
59
+ else:
60
+ gitlab_cli.create_file(
61
+ branch_name=self.branch,
62
+ file_path=self._vpc_tmpl_file_path,
63
+ commit_message="add vpc datafile",
64
+ content=self._vpc_tmpl_file_content,
65
+ )
54
66
 
55
67
 
56
68
  class MrData(BaseModel):
@@ -73,26 +85,37 @@ class MergeRequestManager(MergeRequestManagerBase[Info]):
73
85
  self._renderer = renderer
74
86
  self._auto_merge_enabled = auto_merge_enabled
75
87
 
76
- def create_merge_request(self, data: MrData) -> None:
77
- """Open a new MR, if not already present, for a VPC datafile and close any outdated before."""
78
- if not self._housekeeping_ran:
79
- self.housekeeping()
80
-
88
+ def _create_action(self, data: MrData) -> Action | None:
81
89
  if self._merge_request_already_exists({"account": data.account}):
82
90
  logging.info("MR already exists for %s", data.account)
83
91
  return None
84
-
85
92
  try:
86
- self._vcs.get_file_content_from_app_interface_ref(file_path=data.path)
87
- # the file exists, nothing to do
88
- return None
93
+ existing_content = self._vcs.get_file_content_from_app_interface_ref(
94
+ file_path=data.path
95
+ )
89
96
  except GitlabGetError as e:
90
- if e.response_code != 404:
91
- raise
97
+ if e.response_code == 404:
98
+ return Action.CREATE
99
+ raise
100
+
101
+ if existing_content.strip() != data.content.strip():
102
+ return Action.UPDATE
103
+
104
+ logging.info("VPC data file exists and is up-to-date for %s", data.account)
105
+ return None
106
+
107
+ def create_merge_request(self, data: MrData) -> None:
108
+ """Open a new MR for VPC datafile updates, or update existing if changed."""
109
+ if not self._housekeeping_ran:
110
+ self.housekeeping()
111
+ action = self._create_action(data)
112
+ if action is None:
113
+ return
92
114
 
93
115
  description = self._renderer.render_description(account=data.account)
94
- title = self._renderer.render_title(account=data.account)
95
- logging.info("Open MR for %s", data.account)
116
+ title = self._renderer.render_title(account=data.account, action=action)
117
+
118
+ logging.info("Open MR for %s (%s)", data.account, action)
96
119
  mr_labels = [LABEL]
97
120
  if self._auto_merge_enabled:
98
121
  mr_labels.append(AUTO_MERGE)
@@ -103,5 +126,6 @@ class MergeRequestManager(MergeRequestManagerBase[Info]):
103
126
  description=description,
104
127
  vpc_tmpl_file_content=data.content,
105
128
  labels=mr_labels,
129
+ action=action,
106
130
  )
107
131
  )
@@ -42,6 +42,7 @@ from reconcile.gql_definitions.fragments.saas_target_namespace import (
42
42
  SaasTargetNamespace,
43
43
  )
44
44
  from reconcile.utils import gql
45
+ from reconcile.utils.environ import used_for_security_is_enabled
45
46
  from reconcile.utils.exceptions import (
46
47
  AppInterfaceSettingsError,
47
48
  ParameterError,
@@ -78,10 +79,14 @@ class SaasResourceTemplateTarget(
78
79
  self, parent_saas_file_name: str, parent_resource_template_name: str
79
80
  ) -> str:
80
81
  """Returns a unique identifier for a target."""
81
- return hashlib.blake2s(
82
- f"{parent_saas_file_name}:{parent_resource_template_name}:{self.name or 'default'}:{self.namespace.cluster.name}:{self.namespace.name}".encode(),
83
- digest_size=20,
84
- ).hexdigest()
82
+ data = f"{parent_saas_file_name}:{parent_resource_template_name}:{self.name or 'default'}:{self.namespace.cluster.name}:{self.namespace.name}".encode()
83
+ if used_for_security_is_enabled():
84
+ # When USED_FOR_SECURITY is enabled, use blake2s without digest_size and truncate to 20 bytes
85
+ # This is needed for FIPS compliance where digest_size parameter is not supported
86
+ return hashlib.sha256(data).digest()[:20].hex()
87
+ else:
88
+ # Default behavior: use blake2s with digest_size=20
89
+ return hashlib.blake2s(data, digest_size=20).hexdigest()
85
90
 
86
91
 
87
92
  class SaasResourceTemplate(ConfiguredBaseModel, validate_by_alias=True):
@@ -4,6 +4,11 @@ from functools import wraps
4
4
  from typing import Any
5
5
 
6
6
 
7
+ def used_for_security_is_enabled() -> bool:
8
+ used_for_security_env = os.getenv("USED_FOR_SECURITY", "false")
9
+ return used_for_security_env.lower() == "true"
10
+
11
+
7
12
  def environ(variables: Iterable[str] | None = None) -> Callable:
8
13
  """Check that environment variables are set before execution."""
9
14
  if variables is None:
@@ -157,6 +157,8 @@ class ExternalResourceSpec:
157
157
  tags["cost-center"] = cost_center
158
158
  if service_phase := self.namespace["environment"].get("servicePhase"):
159
159
  tags["service-phase"] = service_phase
160
+ if cost_center := self.namespace["environment"].get("costCenter"):
161
+ tags["cost-center"] = cost_center
160
162
 
161
163
  resource_tags_str = self.resource.get("tags")
162
164
  if resource_tags_str:
@@ -444,6 +444,8 @@ class GitLabApi:
444
444
  def get_merge_request_comments(
445
445
  merge_request: ProjectMergeRequest,
446
446
  include_description: bool = False,
447
+ include_approvals: bool = False,
448
+ approval_body: str = "",
447
449
  ) -> list[Comment]:
448
450
  comments = []
449
451
  if include_description:
@@ -455,6 +457,16 @@ class GitLabApi:
455
457
  created_at=merge_request.created_at,
456
458
  )
457
459
  )
460
+ if include_approvals:
461
+ comments.extend(
462
+ Comment(
463
+ id=approval["user"]["id"],
464
+ username=approval["user"]["username"],
465
+ body=approval_body,
466
+ created_at=approval["approved_at"],
467
+ )
468
+ for approval in merge_request.approvals.get().approved_by
469
+ )
458
470
  comments.extend(
459
471
  Comment(
460
472
  id=note.id,
@@ -33,6 +33,10 @@ from reconcile.utils.vcs import GITHUB_BASE_URL
33
33
  JJB_INI = "[jenkins]\nurl = https://JENKINS_URL"
34
34
 
35
35
 
36
+ class MissingJobUrlError(Exception):
37
+ pass
38
+
39
+
36
40
  class JJB:
37
41
  """Wrapper around Jenkins Jobs"""
38
42
 
@@ -335,7 +339,7 @@ class JJB:
335
339
  job_name = job["name"]
336
340
  try:
337
341
  repos.add(self.get_repo_url(job))
338
- except KeyError:
342
+ except MissingJobUrlError:
339
343
  logging.debug(f"missing github url: {job_name}")
340
344
  return repos
341
345
 
@@ -355,7 +359,19 @@ class JJB:
355
359
 
356
360
  @staticmethod
357
361
  def get_repo_url(job: Mapping[str, Any]) -> str:
358
- repo_url_raw = job["properties"][0]["github"]["url"]
362
+ repo_url_raw = job.get("properties", [{}])[0].get("github", {}).get("url")
363
+
364
+ # we may be in a Github Branch Source type of job
365
+ if not repo_url_raw:
366
+ gh_org = job.get("scm", [{}])[0].get("github", {}).get("repo-owner")
367
+ gh_repo = job.get("scm", [{}])[0].get("github", {}).get("repo")
368
+ if gh_org and gh_repo:
369
+ repo_url_raw = f"https://github.com/{gh_org}/{gh_repo}/"
370
+ else:
371
+ raise MissingJobUrlError(
372
+ f"Cannot find job url for {job['display-name']}"
373
+ )
374
+
359
375
  return repo_url_raw.strip("/").replace(".git", "")
360
376
 
361
377
  @staticmethod
@@ -404,7 +420,7 @@ class JJB:
404
420
  try:
405
421
  if self.get_repo_url(job).lower() == repo_url.rstrip("/").lower():
406
422
  return job
407
- except KeyError:
423
+ except MissingJobUrlError:
408
424
  # something wrong here. ignore this job
409
425
  pass
410
426
  raise ValueError(f"job with {job_type=} and {repo_url=} not found")
reconcile/utils/oc.py CHANGED
@@ -651,9 +651,15 @@ class OCCli:
651
651
  raise e
652
652
  return True
653
653
 
654
+ def _use_oc_project(self, namespace: str) -> bool:
655
+ # Note, that openshift-* namespaces cannot be created via new-project
656
+ return self.is_kind_supported(PROJECT_KIND) and not namespace.startswith(
657
+ "openshift-"
658
+ )
659
+
654
660
  @OCDecorators.process_reconcile_time
655
661
  def new_project(self, namespace: str) -> OCProcessReconcileTimeDecoratorMsg:
656
- if self.is_kind_supported(PROJECT_KIND):
662
+ if self._use_oc_project(namespace=namespace):
657
663
  cmd = ["new-project", namespace]
658
664
  else:
659
665
  cmd = ["create", "namespace", namespace]
@@ -669,7 +675,7 @@ class OCCli:
669
675
 
670
676
  @OCDecorators.process_reconcile_time
671
677
  def delete_project(self, namespace: str) -> OCProcessReconcileTimeDecoratorMsg:
672
- if self.is_kind_supported(PROJECT_KIND):
678
+ if self._use_oc_project(namespace=namespace):
673
679
  cmd = ["delete", "project", namespace]
674
680
  else:
675
681
  cmd = ["delete", "namespace", namespace]
@@ -1,13 +1,16 @@
1
+ import contextlib
1
2
  from typing import Any
2
3
 
3
4
  import requests
4
5
 
6
+ from reconcile.utils.rest_api_base import ApiBase, BearerTokenAuth
7
+
5
8
 
6
9
  class QuayTeamNotFoundError(Exception):
7
10
  pass
8
11
 
9
12
 
10
- class QuayApi:
13
+ class QuayApi(ApiBase):
11
14
  LIMIT_FOLLOWS = 15
12
15
 
13
16
  def __init__(
@@ -17,14 +20,18 @@ class QuayApi:
17
20
  base_url: str = "quay.io",
18
21
  timeout: int = 60,
19
22
  ) -> None:
20
- self.token = token
23
+ # Support both hostname (e.g., "quay.io") and full URLs (e.g., "http://localhost:12345")
24
+ if base_url.startswith(("http://", "https://")):
25
+ host = base_url
26
+ else:
27
+ host = f"https://{base_url}"
28
+ super().__init__(
29
+ host=host,
30
+ auth=BearerTokenAuth(token),
31
+ read_timeout=timeout,
32
+ )
21
33
  self.organization = organization
22
- self.auth_header = {"Authorization": "Bearer %s" % (token,)}
23
34
  self.team_members: dict[str, Any] = {}
24
- self.api_url = f"https://{base_url}/api/v1"
25
-
26
- self._timeout = timeout
27
- """Timeout to use for HTTP calls to Quay (seconds)."""
28
35
 
29
36
  def list_team_members(self, team: str, **kwargs: Any) -> list[dict]:
30
37
  """
@@ -38,19 +45,20 @@ class QuayApi:
38
45
  if cache_members:
39
46
  return cache_members
40
47
 
41
- url = f"{self.api_url}/organization/{self.organization}/team/{team}/members?includePending=true"
42
-
43
- r = requests.get(url, headers=self.auth_header, timeout=self._timeout)
44
- if r.status_code == 404:
45
- raise QuayTeamNotFoundError(
46
- f"team {team} is not found in "
47
- f"org {self.organization}. "
48
- f"contact org owner to create the "
49
- f"team manually."
50
- )
51
- r.raise_for_status()
52
-
53
- body = r.json()
48
+ url = f"/api/v1/organization/{self.organization}/team/{team}/members"
49
+ params = {"includePending": "true"}
50
+
51
+ try:
52
+ body = self._get(url, params=params)
53
+ except requests.exceptions.HTTPError as e:
54
+ if e.response.status_code == 404:
55
+ raise QuayTeamNotFoundError(
56
+ f"team {team} is not found in "
57
+ f"org {self.organization}. "
58
+ f"contact org owner to create the "
59
+ f"team manually."
60
+ ) from e
61
+ raise
54
62
 
55
63
  # Using a set because members may be repeated
56
64
  members = {member["name"] for member in body["members"]}
@@ -61,30 +69,37 @@ class QuayApi:
61
69
  return members_list
62
70
 
63
71
  def user_exists(self, user: str) -> bool:
64
- url = f"{self.api_url}/users/{user}"
65
- r = requests.get(url, headers=self.auth_header, timeout=self._timeout)
66
- return r.ok
72
+ url = f"/api/v1/users/{user}"
73
+ try:
74
+ self._get(url)
75
+ return True
76
+ except requests.exceptions.HTTPError:
77
+ return False
67
78
 
68
79
  def remove_user_from_team(self, user: str, team: str) -> bool:
69
80
  """Deletes an user from a team.
70
81
 
71
82
  :raises HTTPError if there are any problems with the request
72
83
  """
73
- url_team = f"{self.api_url}/organization/{self.organization}/team/{team}/members/{user}"
84
+ url_team = (
85
+ f"/api/v1/organization/{self.organization}/team/{team}/members/{user}"
86
+ )
74
87
 
75
- r = requests.delete(url_team, headers=self.auth_header, timeout=self._timeout)
76
- if not r.ok:
77
- message = r.json().get("message", "")
88
+ try:
89
+ self._delete(url_team)
90
+ except requests.exceptions.HTTPError as e:
91
+ message = ""
92
+ if e.response is not None:
93
+ with contextlib.suppress(ValueError, AttributeError):
94
+ message = e.response.json().get("message", "")
78
95
 
79
96
  expected_message = f"User {user} does not belong to team {team}"
80
97
 
81
98
  if message != expected_message:
82
- r.raise_for_status()
99
+ raise
83
100
 
84
- url_org = f"{self.api_url}/organization/{self.organization}/members/{user}"
85
-
86
- r = requests.delete(url_org, headers=self.auth_header, timeout=self._timeout)
87
- r.raise_for_status()
101
+ url_org = f"/api/v1/organization/{self.organization}/members/{user}"
102
+ self._delete(url_org)
88
103
 
89
104
  return True
90
105
 
@@ -96,9 +111,8 @@ class QuayApi:
96
111
  if user in self.list_team_members(team, cache=True):
97
112
  return True
98
113
 
99
- url = f"{self.api_url}/organization/{self.organization}/team/{team}/members/{user}"
100
- r = requests.put(url, headers=self.auth_header, timeout=self._timeout)
101
- r.raise_for_status()
114
+ url = f"/api/v1/organization/{self.organization}/team/{team}/members/{user}"
115
+ self._put(url)
102
116
  return True
103
117
 
104
118
  def create_or_update_team(
@@ -115,17 +129,14 @@ class QuayApi:
115
129
  :raises HTTPError: unsuccessful attempt to create the team
116
130
  """
117
131
 
118
- url = f"{self.api_url}/organization/{self.organization}/team/{team}"
132
+ url = f"/api/v1/organization/{self.organization}/team/{team}"
119
133
 
120
134
  payload = {"role": role}
121
135
 
122
136
  if description:
123
137
  payload.update({"description": description})
124
138
 
125
- r = requests.put(
126
- url, headers=self.auth_header, json=payload, timeout=self._timeout
127
- )
128
- r.raise_for_status()
139
+ self._put(url, data=payload)
129
140
 
130
141
  def list_images(
131
142
  self, images: list | None = None, page: str | None = None, count: int = 0
@@ -140,7 +151,7 @@ class QuayApi:
140
151
  if count > self.LIMIT_FOLLOWS:
141
152
  raise ValueError("Too many page follows")
142
153
 
143
- url = f"{self.api_url}/repository"
154
+ url = "/api/v1/repository"
144
155
 
145
156
  # params
146
157
  params = {"namespace": self.organization}
@@ -148,13 +159,7 @@ class QuayApi:
148
159
  params["next_page"] = page
149
160
 
150
161
  # perform request
151
- r = requests.get(
152
- url, params=params, headers=self.auth_header, timeout=self._timeout
153
- )
154
- r.raise_for_status()
155
-
156
- # read body
157
- body = r.json()
162
+ body = self._get(url, params=params)
158
163
  repositories = body.get("repositories", [])
159
164
  next_page = body.get("next_page")
160
165
 
@@ -176,7 +181,7 @@ class QuayApi:
176
181
  """
177
182
  visibility = "public" if public else "private"
178
183
 
179
- url = f"{self.api_url}/repository"
184
+ url = "/repository"
180
185
 
181
186
  params = {
182
187
  "repo_kind": "image",
@@ -186,29 +191,16 @@ class QuayApi:
186
191
  "description": description,
187
192
  }
188
193
 
189
- # perform request
190
- r = requests.post(
191
- url, json=params, headers=self.auth_header, timeout=self._timeout
192
- )
193
- r.raise_for_status()
194
+ self._post(url, data=params)
194
195
 
195
196
  def repo_delete(self, repo_name: str) -> None:
196
- url = f"{self.api_url}/repository/{self.organization}/{repo_name}"
197
-
198
- # perform request
199
- r = requests.delete(url, headers=self.auth_header, timeout=self._timeout)
200
- r.raise_for_status()
197
+ url = f"/api/v1/repository/{self.organization}/{repo_name}"
198
+ self._delete(url)
201
199
 
202
200
  def repo_update_description(self, repo_name: str, description: str) -> None:
203
- url = f"{self.api_url}/repository/{self.organization}/{repo_name}"
204
-
201
+ url = f"/api/v1/repository/{self.organization}/{repo_name}"
205
202
  params = {"description": description}
206
-
207
- # perform request
208
- r = requests.put(
209
- url, json=params, headers=self.auth_header, timeout=self._timeout
210
- )
211
- r.raise_for_status()
203
+ self._put(url, data=params)
212
204
 
213
205
  def repo_make_public(self, repo_name: str) -> None:
214
206
  self._repo_change_visibility(repo_name, "public")
@@ -217,39 +209,34 @@ class QuayApi:
217
209
  self._repo_change_visibility(repo_name, "private")
218
210
 
219
211
  def _repo_change_visibility(self, repo_name: str, visibility: str) -> None:
220
- url = f"{self.api_url}/repository/{self.organization}/{repo_name}/changevisibility"
221
-
212
+ url = f"/api/v1/repository/{self.organization}/{repo_name}/changevisibility"
222
213
  params = {"visibility": visibility}
223
-
224
- # perform request
225
- r = requests.post(
226
- url, json=params, headers=self.auth_header, timeout=self._timeout
227
- )
228
- r.raise_for_status()
214
+ self._post(url, data=params)
229
215
 
230
216
  def get_repo_team_permissions(self, repo_name: str, team: str) -> str | None:
231
217
  url = (
232
- f"{self.api_url}/repository/{self.organization}/"
218
+ f"/api/v1/repository/{self.organization}/"
233
219
  + f"{repo_name}/permissions/team/{team}"
234
220
  )
235
- r = requests.get(url, headers=self.auth_header, timeout=self._timeout)
236
- if not r.ok:
237
- message = r.json().get("message")
221
+ try:
222
+ body = self._get(url)
223
+ return body.get("role") or None
224
+ except requests.exceptions.HTTPError as e:
225
+ message = ""
226
+ if e.response is not None:
227
+ with contextlib.suppress(ValueError, AttributeError):
228
+ message = e.response.json().get("message", "")
229
+
238
230
  expected_message = "Team does not have permission for repo."
239
231
  if message == expected_message:
240
232
  return None
241
233
 
242
- r.raise_for_status()
243
-
244
- return r.json().get("role") or None
234
+ raise
245
235
 
246
236
  def set_repo_team_permissions(self, repo_name: str, team: str, role: str) -> None:
247
237
  url = (
248
- f"{self.api_url}/repository/{self.organization}/"
238
+ f"/api/v1/repository/{self.organization}/"
249
239
  + f"{repo_name}/permissions/team/{team}"
250
240
  )
251
241
  body = {"role": role}
252
- r = requests.put(
253
- url, json=body, headers=self.auth_header, timeout=self._timeout
254
- )
255
- r.raise_for_status()
242
+ self._put(url, data=body)