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.
- {qontract_reconcile-0.10.2.dev430.dist-info → qontract_reconcile-0.10.2.dev474.dist-info}/METADATA +2 -2
- {qontract_reconcile-0.10.2.dev430.dist-info → qontract_reconcile-0.10.2.dev474.dist-info}/RECORD +47 -46
- {qontract_reconcile-0.10.2.dev430.dist-info → qontract_reconcile-0.10.2.dev474.dist-info}/WHEEL +1 -1
- reconcile/aus/aus_sts_gate_handler.py +59 -0
- reconcile/aus/base.py +9 -8
- reconcile/aus/version_gate_approver.py +1 -16
- reconcile/aus/version_gates/sts_version_gate_handler.py +5 -125
- reconcile/aws_account_manager/integration.py +13 -1
- reconcile/aws_account_manager/utils.py +1 -1
- reconcile/aws_ecr_image_pull_secrets.py +1 -1
- reconcile/change_owners/README.md +1 -1
- reconcile/change_owners/change_owners.py +108 -42
- reconcile/change_owners/decision.py +1 -1
- reconcile/cli.py +1 -1
- reconcile/external_resources/secrets_sync.py +2 -3
- reconcile/gql_definitions/aws_account_manager/aws_accounts.py +9 -0
- reconcile/gql_definitions/common/aws_vpc_requests.py +3 -0
- reconcile/gql_definitions/common/clusters.py +2 -0
- reconcile/gql_definitions/external_resources/external_resources_namespaces.py +6 -2
- reconcile/gql_definitions/fragments/aws_vpc_request.py +5 -0
- reconcile/gql_definitions/introspection.json +51 -7
- reconcile/gql_definitions/terraform_resources/terraform_resources_namespaces.py +8 -2
- reconcile/gql_definitions/vpc_peerings_validator/vpc_peerings_validator.py +3 -0
- reconcile/gql_definitions/vpc_peerings_validator/vpc_peerings_validator_peered_cluster_fragment.py +1 -0
- reconcile/openshift_namespaces.py +3 -4
- reconcile/quay_base.py +25 -6
- reconcile/quay_membership.py +55 -29
- reconcile/quay_mirror_org.py +6 -4
- reconcile/quay_permissions.py +81 -75
- reconcile/quay_repos.py +35 -37
- reconcile/queries.py +1 -1
- reconcile/templates/rosa-classic-cluster-creation.sh.j2 +1 -1
- reconcile/templates/rosa-hcp-cluster-creation.sh.j2 +1 -1
- reconcile/templating/validator.py +4 -4
- reconcile/terraform_vpc_resources/integration.py +10 -7
- reconcile/terraform_vpc_resources/merge_request.py +12 -2
- reconcile/terraform_vpc_resources/merge_request_manager.py +43 -19
- reconcile/typed_queries/saas_files.py +9 -4
- reconcile/utils/environ.py +5 -0
- reconcile/utils/external_resource_spec.py +2 -0
- reconcile/utils/gitlab_api.py +12 -0
- reconcile/utils/jjb_client.py +19 -3
- reconcile/utils/oc.py +8 -2
- reconcile/utils/quay_api.py +74 -87
- reconcile/utils/terrascript_aws_client.py +140 -50
- reconcile/vpc_peerings_validator.py +13 -0
- {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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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(
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
digest_size
|
|
84
|
-
|
|
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):
|
reconcile/utils/environ.py
CHANGED
|
@@ -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:
|
reconcile/utils/gitlab_api.py
CHANGED
|
@@ -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,
|
reconcile/utils/jjb_client.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
678
|
+
if self._use_oc_project(namespace=namespace):
|
|
673
679
|
cmd = ["delete", "project", namespace]
|
|
674
680
|
else:
|
|
675
681
|
cmd = ["delete", "namespace", namespace]
|
reconcile/utils/quay_api.py
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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"
|
|
65
|
-
|
|
66
|
-
|
|
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 =
|
|
84
|
+
url_team = (
|
|
85
|
+
f"/api/v1/organization/{self.organization}/team/{team}/members/{user}"
|
|
86
|
+
)
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
99
|
+
raise
|
|
83
100
|
|
|
84
|
-
url_org = f"
|
|
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"
|
|
100
|
-
|
|
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"
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
218
|
+
f"/api/v1/repository/{self.organization}/"
|
|
233
219
|
+ f"{repo_name}/permissions/team/{team}"
|
|
234
220
|
)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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"
|
|
238
|
+
f"/api/v1/repository/{self.organization}/"
|
|
249
239
|
+ f"{repo_name}/permissions/team/{team}"
|
|
250
240
|
)
|
|
251
241
|
body = {"role": role}
|
|
252
|
-
|
|
253
|
-
url, json=body, headers=self.auth_header, timeout=self._timeout
|
|
254
|
-
)
|
|
255
|
-
r.raise_for_status()
|
|
242
|
+
self._put(url, data=body)
|