qontract-reconcile 0.10.1rc696__py3-none-any.whl → 0.10.1rc702__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.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/METADATA +1 -1
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/RECORD +42 -18
- reconcile/aws_account_manager/__init__.py +0 -0
- reconcile/aws_account_manager/integration.py +342 -0
- reconcile/aws_account_manager/merge_request_manager.py +111 -0
- reconcile/aws_account_manager/reconciler.py +353 -0
- reconcile/aws_account_manager/utils.py +38 -0
- reconcile/aws_saml_idp/integration.py +2 -0
- reconcile/aws_version_sync/integration.py +12 -11
- reconcile/aws_version_sync/merge_request_manager/merge_request_manager.py +39 -112
- reconcile/cli.py +79 -0
- reconcile/gql_definitions/aws_account_manager/__init__.py +0 -0
- reconcile/gql_definitions/aws_account_manager/aws_accounts.py +163 -0
- reconcile/gql_definitions/cost_report/__init__.py +0 -0
- reconcile/gql_definitions/cost_report/app_names.py +68 -0
- reconcile/gql_definitions/cost_report/settings.py +77 -0
- reconcile/gql_definitions/fragments/aws_account_managed.py +49 -0
- reconcile/queries.py +7 -1
- reconcile/templating/lib/merge_request_manager.py +8 -82
- reconcile/templating/renderer.py +2 -2
- reconcile/typed_queries/cost_report/__init__.py +0 -0
- reconcile/typed_queries/cost_report/app_names.py +22 -0
- reconcile/typed_queries/cost_report/settings.py +15 -0
- reconcile/utils/aws_api_typed/api.py +49 -6
- reconcile/utils/aws_api_typed/iam.py +22 -7
- reconcile/utils/aws_api_typed/organization.py +78 -30
- reconcile/utils/aws_api_typed/service_quotas.py +79 -0
- reconcile/utils/aws_api_typed/support.py +79 -0
- reconcile/utils/merge_request_manager/merge_request_manager.py +102 -0
- reconcile/utils/oauth2_backend_application_session.py +102 -0
- reconcile/utils/state.py +42 -38
- tools/cli_commands/cost_report/__init__.py +0 -0
- tools/cli_commands/cost_report/command.py +172 -0
- tools/cli_commands/cost_report/cost_management_api.py +57 -0
- tools/cli_commands/cost_report/model.py +29 -0
- tools/cli_commands/cost_report/response.py +48 -0
- tools/cli_commands/cost_report/view.py +333 -0
- tools/qontract_cli.py +10 -2
- tools/test/test_qontract_cli.py +20 -0
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/WHEEL +0 -0
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/entry_points.txt +0 -0
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/top_level.txt +0 -0
reconcile/cli.py
CHANGED
@@ -951,6 +951,85 @@ def aws_saml_roles(
|
|
951
951
|
)
|
952
952
|
|
953
953
|
|
954
|
+
@integration.command(short_help="Create and manage AWS accounts.")
|
955
|
+
@account_name
|
956
|
+
@click.option(
|
957
|
+
"--flavor",
|
958
|
+
help="Flavor of the AWS account manager.",
|
959
|
+
required=True,
|
960
|
+
default="app-interface-commercial",
|
961
|
+
)
|
962
|
+
@click.option(
|
963
|
+
"--tag",
|
964
|
+
"-t",
|
965
|
+
type=(str, str),
|
966
|
+
multiple=True,
|
967
|
+
default=[("managed-by", "app-interface")],
|
968
|
+
)
|
969
|
+
@click.option(
|
970
|
+
"--initial-user-name",
|
971
|
+
help="The name of the initial user to be created in the account.",
|
972
|
+
required=True,
|
973
|
+
default="terraform",
|
974
|
+
)
|
975
|
+
@click.option(
|
976
|
+
"--initial-user-policy-arn",
|
977
|
+
help="The ARN of the policy that is attached to the initial user.",
|
978
|
+
required=True,
|
979
|
+
default="arn:aws:iam::aws:policy/AdministratorAccess",
|
980
|
+
)
|
981
|
+
@click.option(
|
982
|
+
"--initial-user-secret-vault-path",
|
983
|
+
help="The path in Vault to store the initial user secret. Python format string with access to 'account_name' attribute.",
|
984
|
+
required=True,
|
985
|
+
default="app-sre/creds/terraform/{account_name}/config",
|
986
|
+
)
|
987
|
+
@click.option(
|
988
|
+
"--account-tmpl-resource",
|
989
|
+
help="Resource name of the account template-collection template in the app-interface.",
|
990
|
+
required=True,
|
991
|
+
default="/aws-account-manager/account-tmpl.yml",
|
992
|
+
)
|
993
|
+
@click.option(
|
994
|
+
"--template-collection-root-path",
|
995
|
+
help="File path to the root directory to store new account template-collections.",
|
996
|
+
required=True,
|
997
|
+
default="data/templating/collections/aws-account",
|
998
|
+
)
|
999
|
+
@click.pass_context
|
1000
|
+
def aws_account_manager(
|
1001
|
+
ctx,
|
1002
|
+
account_name,
|
1003
|
+
flavor,
|
1004
|
+
tag,
|
1005
|
+
initial_user_name,
|
1006
|
+
initial_user_policy_arn,
|
1007
|
+
initial_user_secret_vault_path,
|
1008
|
+
account_tmpl_resource,
|
1009
|
+
template_collection_root_path,
|
1010
|
+
):
|
1011
|
+
from reconcile.aws_account_manager.integration import (
|
1012
|
+
AwsAccountMgmtIntegration,
|
1013
|
+
AwsAccountMgmtIntegrationParams,
|
1014
|
+
)
|
1015
|
+
|
1016
|
+
run_class_integration(
|
1017
|
+
integration=AwsAccountMgmtIntegration(
|
1018
|
+
AwsAccountMgmtIntegrationParams(
|
1019
|
+
account_name=account_name,
|
1020
|
+
flavor=flavor,
|
1021
|
+
default_tags=dict(tag),
|
1022
|
+
initial_user_name=initial_user_name,
|
1023
|
+
initial_user_policy_arn=initial_user_policy_arn,
|
1024
|
+
initial_user_secret_vault_path=initial_user_secret_vault_path,
|
1025
|
+
account_tmpl_resource=account_tmpl_resource,
|
1026
|
+
template_collection_root_path=template_collection_root_path,
|
1027
|
+
)
|
1028
|
+
),
|
1029
|
+
ctx=ctx.obj,
|
1030
|
+
)
|
1031
|
+
|
1032
|
+
|
954
1033
|
@integration.command(short_help="Manage Jenkins roles association via REST API.")
|
955
1034
|
@click.pass_context
|
956
1035
|
def jenkins_roles(ctx):
|
File without changes
|
@@ -0,0 +1,163 @@
|
|
1
|
+
"""
|
2
|
+
Generated by qenerate plugin=pydantic_v1. DO NOT MODIFY MANUALLY!
|
3
|
+
"""
|
4
|
+
from collections.abc import Callable # noqa: F401 # pylint: disable=W0611
|
5
|
+
from datetime import datetime # noqa: F401 # pylint: disable=W0611
|
6
|
+
from enum import Enum # noqa: F401 # pylint: disable=W0611
|
7
|
+
from typing import ( # noqa: F401 # pylint: disable=W0611
|
8
|
+
Any,
|
9
|
+
Optional,
|
10
|
+
Union,
|
11
|
+
)
|
12
|
+
|
13
|
+
from pydantic import ( # noqa: F401 # pylint: disable=W0611
|
14
|
+
BaseModel,
|
15
|
+
Extra,
|
16
|
+
Field,
|
17
|
+
Json,
|
18
|
+
)
|
19
|
+
|
20
|
+
from reconcile.gql_definitions.fragments.aws_account_managed import AWSAccountManaged
|
21
|
+
from reconcile.gql_definitions.fragments.vault_secret import VaultSecret
|
22
|
+
|
23
|
+
|
24
|
+
DEFINITION = """
|
25
|
+
fragment AWSAccountManaged on AWSAccount_v1 {
|
26
|
+
name
|
27
|
+
uid
|
28
|
+
alias
|
29
|
+
premiumSupport
|
30
|
+
organization {
|
31
|
+
ou
|
32
|
+
tags
|
33
|
+
}
|
34
|
+
quotaLimits {
|
35
|
+
name
|
36
|
+
quotas {
|
37
|
+
serviceCode
|
38
|
+
quotaCode
|
39
|
+
value
|
40
|
+
}
|
41
|
+
}
|
42
|
+
}
|
43
|
+
|
44
|
+
fragment VaultSecret on VaultSecret_v1 {
|
45
|
+
path
|
46
|
+
field
|
47
|
+
version
|
48
|
+
format
|
49
|
+
}
|
50
|
+
|
51
|
+
query AWSAccountManagerAccounts {
|
52
|
+
accounts: awsaccounts_v1 {
|
53
|
+
... AWSAccountManaged
|
54
|
+
resourcesDefaultRegion
|
55
|
+
automationToken {
|
56
|
+
...VaultSecret
|
57
|
+
}
|
58
|
+
disable {
|
59
|
+
integrations
|
60
|
+
}
|
61
|
+
automationRole {
|
62
|
+
awsAccountManager
|
63
|
+
}
|
64
|
+
# for the requests via "payer account"
|
65
|
+
account_requests {
|
66
|
+
path
|
67
|
+
name
|
68
|
+
description
|
69
|
+
accountOwner {
|
70
|
+
name
|
71
|
+
email
|
72
|
+
}
|
73
|
+
organization {
|
74
|
+
ou
|
75
|
+
tags
|
76
|
+
payerAccount {
|
77
|
+
path
|
78
|
+
}
|
79
|
+
}
|
80
|
+
quotaLimits {
|
81
|
+
path
|
82
|
+
}
|
83
|
+
}
|
84
|
+
organization_accounts {
|
85
|
+
... AWSAccountManaged
|
86
|
+
}
|
87
|
+
}
|
88
|
+
}
|
89
|
+
"""
|
90
|
+
|
91
|
+
|
92
|
+
class ConfiguredBaseModel(BaseModel):
|
93
|
+
class Config:
|
94
|
+
smart_union=True
|
95
|
+
extra=Extra.forbid
|
96
|
+
|
97
|
+
|
98
|
+
class DisableClusterAutomationsV1(ConfiguredBaseModel):
|
99
|
+
integrations: Optional[list[str]] = Field(..., alias="integrations")
|
100
|
+
|
101
|
+
|
102
|
+
class AWSAutomationRoleV1(ConfiguredBaseModel):
|
103
|
+
aws_account_manager: Optional[str] = Field(..., alias="awsAccountManager")
|
104
|
+
|
105
|
+
|
106
|
+
class OwnerV1(ConfiguredBaseModel):
|
107
|
+
name: str = Field(..., alias="name")
|
108
|
+
email: str = Field(..., alias="email")
|
109
|
+
|
110
|
+
|
111
|
+
class AWSOrganizationV1_AWSAccountV1(ConfiguredBaseModel):
|
112
|
+
path: str = Field(..., alias="path")
|
113
|
+
|
114
|
+
|
115
|
+
class AWSOrganizationV1(ConfiguredBaseModel):
|
116
|
+
ou: str = Field(..., alias="ou")
|
117
|
+
tags: Json = Field(..., alias="tags")
|
118
|
+
payer_account: AWSOrganizationV1_AWSAccountV1 = Field(..., alias="payerAccount")
|
119
|
+
|
120
|
+
|
121
|
+
class AWSQuotaLimitsV1(ConfiguredBaseModel):
|
122
|
+
path: str = Field(..., alias="path")
|
123
|
+
|
124
|
+
|
125
|
+
class AWSAccountRequestV1(ConfiguredBaseModel):
|
126
|
+
path: str = Field(..., alias="path")
|
127
|
+
name: str = Field(..., alias="name")
|
128
|
+
description: str = Field(..., alias="description")
|
129
|
+
account_owner: OwnerV1 = Field(..., alias="accountOwner")
|
130
|
+
organization: AWSOrganizationV1 = Field(..., alias="organization")
|
131
|
+
quota_limits: Optional[list[AWSQuotaLimitsV1]] = Field(..., alias="quotaLimits")
|
132
|
+
|
133
|
+
|
134
|
+
class AWSAccountV1(AWSAccountManaged):
|
135
|
+
resources_default_region: str = Field(..., alias="resourcesDefaultRegion")
|
136
|
+
automation_token: VaultSecret = Field(..., alias="automationToken")
|
137
|
+
disable: Optional[DisableClusterAutomationsV1] = Field(..., alias="disable")
|
138
|
+
automation_role: Optional[AWSAutomationRoleV1] = Field(..., alias="automationRole")
|
139
|
+
account_requests: Optional[list[AWSAccountRequestV1]] = Field(..., alias="account_requests")
|
140
|
+
organization_accounts: Optional[list[AWSAccountManaged]] = Field(..., alias="organization_accounts")
|
141
|
+
|
142
|
+
|
143
|
+
class AWSAccountManagerAccountsQueryData(ConfiguredBaseModel):
|
144
|
+
accounts: Optional[list[AWSAccountV1]] = Field(..., alias="accounts")
|
145
|
+
|
146
|
+
|
147
|
+
def query(query_func: Callable, **kwargs: Any) -> AWSAccountManagerAccountsQueryData:
|
148
|
+
"""
|
149
|
+
This is a convenience function which queries and parses the data into
|
150
|
+
concrete types. It should be compatible with most GQL clients.
|
151
|
+
You do not have to use it to consume the generated data classes.
|
152
|
+
Alternatively, you can also mime and alternate the behavior
|
153
|
+
of this function in the caller.
|
154
|
+
|
155
|
+
Parameters:
|
156
|
+
query_func (Callable): Function which queries your GQL Server
|
157
|
+
kwargs: optional arguments that will be passed to the query function
|
158
|
+
|
159
|
+
Returns:
|
160
|
+
AWSAccountManagerAccountsQueryData: queried data parsed into generated classes
|
161
|
+
"""
|
162
|
+
raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
|
163
|
+
return AWSAccountManagerAccountsQueryData(**raw_data)
|
File without changes
|
@@ -0,0 +1,68 @@
|
|
1
|
+
"""
|
2
|
+
Generated by qenerate plugin=pydantic_v1. DO NOT MODIFY MANUALLY!
|
3
|
+
"""
|
4
|
+
from collections.abc import Callable # noqa: F401 # pylint: disable=W0611
|
5
|
+
from datetime import datetime # noqa: F401 # pylint: disable=W0611
|
6
|
+
from enum import Enum # noqa: F401 # pylint: disable=W0611
|
7
|
+
from typing import ( # noqa: F401 # pylint: disable=W0611
|
8
|
+
Any,
|
9
|
+
Optional,
|
10
|
+
Union,
|
11
|
+
)
|
12
|
+
|
13
|
+
from pydantic import ( # noqa: F401 # pylint: disable=W0611
|
14
|
+
BaseModel,
|
15
|
+
Extra,
|
16
|
+
Field,
|
17
|
+
Json,
|
18
|
+
)
|
19
|
+
|
20
|
+
|
21
|
+
DEFINITION = """
|
22
|
+
query AppNames {
|
23
|
+
apps: apps_v1 {
|
24
|
+
name
|
25
|
+
parentApp {
|
26
|
+
name
|
27
|
+
}
|
28
|
+
}
|
29
|
+
}
|
30
|
+
"""
|
31
|
+
|
32
|
+
|
33
|
+
class ConfiguredBaseModel(BaseModel):
|
34
|
+
class Config:
|
35
|
+
smart_union=True
|
36
|
+
extra=Extra.forbid
|
37
|
+
|
38
|
+
|
39
|
+
class AppV1_AppV1(ConfiguredBaseModel):
|
40
|
+
name: str = Field(..., alias="name")
|
41
|
+
|
42
|
+
|
43
|
+
class AppV1(ConfiguredBaseModel):
|
44
|
+
name: str = Field(..., alias="name")
|
45
|
+
parent_app: Optional[AppV1_AppV1] = Field(..., alias="parentApp")
|
46
|
+
|
47
|
+
|
48
|
+
class AppNamesQueryData(ConfiguredBaseModel):
|
49
|
+
apps: Optional[list[AppV1]] = Field(..., alias="apps")
|
50
|
+
|
51
|
+
|
52
|
+
def query(query_func: Callable, **kwargs: Any) -> AppNamesQueryData:
|
53
|
+
"""
|
54
|
+
This is a convenience function which queries and parses the data into
|
55
|
+
concrete types. It should be compatible with most GQL clients.
|
56
|
+
You do not have to use it to consume the generated data classes.
|
57
|
+
Alternatively, you can also mime and alternate the behavior
|
58
|
+
of this function in the caller.
|
59
|
+
|
60
|
+
Parameters:
|
61
|
+
query_func (Callable): Function which queries your GQL Server
|
62
|
+
kwargs: optional arguments that will be passed to the query function
|
63
|
+
|
64
|
+
Returns:
|
65
|
+
AppNamesQueryData: queried data parsed into generated classes
|
66
|
+
"""
|
67
|
+
raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
|
68
|
+
return AppNamesQueryData(**raw_data)
|
@@ -0,0 +1,77 @@
|
|
1
|
+
"""
|
2
|
+
Generated by qenerate plugin=pydantic_v1. DO NOT MODIFY MANUALLY!
|
3
|
+
"""
|
4
|
+
from collections.abc import Callable # noqa: F401 # pylint: disable=W0611
|
5
|
+
from datetime import datetime # noqa: F401 # pylint: disable=W0611
|
6
|
+
from enum import Enum # noqa: F401 # pylint: disable=W0611
|
7
|
+
from typing import ( # noqa: F401 # pylint: disable=W0611
|
8
|
+
Any,
|
9
|
+
Optional,
|
10
|
+
Union,
|
11
|
+
)
|
12
|
+
|
13
|
+
from pydantic import ( # noqa: F401 # pylint: disable=W0611
|
14
|
+
BaseModel,
|
15
|
+
Extra,
|
16
|
+
Field,
|
17
|
+
Json,
|
18
|
+
)
|
19
|
+
|
20
|
+
from reconcile.gql_definitions.fragments.vault_secret import VaultSecret
|
21
|
+
|
22
|
+
|
23
|
+
DEFINITION = """
|
24
|
+
fragment VaultSecret on VaultSecret_v1 {
|
25
|
+
path
|
26
|
+
field
|
27
|
+
version
|
28
|
+
format
|
29
|
+
}
|
30
|
+
|
31
|
+
query CostReportAppInterfaceSettings {
|
32
|
+
settings: app_interface_settings_v1 {
|
33
|
+
costReport {
|
34
|
+
credentials {
|
35
|
+
...VaultSecret
|
36
|
+
}
|
37
|
+
}
|
38
|
+
}
|
39
|
+
}
|
40
|
+
"""
|
41
|
+
|
42
|
+
|
43
|
+
class ConfiguredBaseModel(BaseModel):
|
44
|
+
class Config:
|
45
|
+
smart_union=True
|
46
|
+
extra=Extra.forbid
|
47
|
+
|
48
|
+
|
49
|
+
class CostReportSettingsV1(ConfiguredBaseModel):
|
50
|
+
credentials: VaultSecret = Field(..., alias="credentials")
|
51
|
+
|
52
|
+
|
53
|
+
class AppInterfaceSettingsV1(ConfiguredBaseModel):
|
54
|
+
cost_report: Optional[CostReportSettingsV1] = Field(..., alias="costReport")
|
55
|
+
|
56
|
+
|
57
|
+
class CostReportAppInterfaceSettingsQueryData(ConfiguredBaseModel):
|
58
|
+
settings: Optional[list[AppInterfaceSettingsV1]] = Field(..., alias="settings")
|
59
|
+
|
60
|
+
|
61
|
+
def query(query_func: Callable, **kwargs: Any) -> CostReportAppInterfaceSettingsQueryData:
|
62
|
+
"""
|
63
|
+
This is a convenience function which queries and parses the data into
|
64
|
+
concrete types. It should be compatible with most GQL clients.
|
65
|
+
You do not have to use it to consume the generated data classes.
|
66
|
+
Alternatively, you can also mime and alternate the behavior
|
67
|
+
of this function in the caller.
|
68
|
+
|
69
|
+
Parameters:
|
70
|
+
query_func (Callable): Function which queries your GQL Server
|
71
|
+
kwargs: optional arguments that will be passed to the query function
|
72
|
+
|
73
|
+
Returns:
|
74
|
+
CostReportAppInterfaceSettingsQueryData: queried data parsed into generated classes
|
75
|
+
"""
|
76
|
+
raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
|
77
|
+
return CostReportAppInterfaceSettingsQueryData(**raw_data)
|
@@ -0,0 +1,49 @@
|
|
1
|
+
"""
|
2
|
+
Generated by qenerate plugin=pydantic_v1. DO NOT MODIFY MANUALLY!
|
3
|
+
"""
|
4
|
+
from collections.abc import Callable # noqa: F401 # pylint: disable=W0611
|
5
|
+
from datetime import datetime # noqa: F401 # pylint: disable=W0611
|
6
|
+
from enum import Enum # noqa: F401 # pylint: disable=W0611
|
7
|
+
from typing import ( # noqa: F401 # pylint: disable=W0611
|
8
|
+
Any,
|
9
|
+
Optional,
|
10
|
+
Union,
|
11
|
+
)
|
12
|
+
|
13
|
+
from pydantic import ( # noqa: F401 # pylint: disable=W0611
|
14
|
+
BaseModel,
|
15
|
+
Extra,
|
16
|
+
Field,
|
17
|
+
Json,
|
18
|
+
)
|
19
|
+
|
20
|
+
|
21
|
+
class ConfiguredBaseModel(BaseModel):
|
22
|
+
class Config:
|
23
|
+
smart_union=True
|
24
|
+
extra=Extra.forbid
|
25
|
+
|
26
|
+
|
27
|
+
class AWSOrganizationV1(ConfiguredBaseModel):
|
28
|
+
ou: str = Field(..., alias="ou")
|
29
|
+
tags: Json = Field(..., alias="tags")
|
30
|
+
|
31
|
+
|
32
|
+
class AWSQuotaV1(ConfiguredBaseModel):
|
33
|
+
service_code: str = Field(..., alias="serviceCode")
|
34
|
+
quota_code: str = Field(..., alias="quotaCode")
|
35
|
+
value: float = Field(..., alias="value")
|
36
|
+
|
37
|
+
|
38
|
+
class AWSQuotaLimitsV1(ConfiguredBaseModel):
|
39
|
+
name: str = Field(..., alias="name")
|
40
|
+
quotas: list[AWSQuotaV1] = Field(..., alias="quotas")
|
41
|
+
|
42
|
+
|
43
|
+
class AWSAccountManaged(ConfiguredBaseModel):
|
44
|
+
name: str = Field(..., alias="name")
|
45
|
+
uid: str = Field(..., alias="uid")
|
46
|
+
alias: Optional[str] = Field(..., alias="alias")
|
47
|
+
premium_support: bool = Field(..., alias="premiumSupport")
|
48
|
+
organization: Optional[AWSOrganizationV1] = Field(..., alias="organization")
|
49
|
+
quota_limits: Optional[list[AWSQuotaLimitsV1]] = Field(..., alias="quotaLimits")
|
reconcile/queries.py
CHANGED
@@ -583,7 +583,13 @@ def get_aws_accounts(
|
|
583
583
|
ecrs=ecrs,
|
584
584
|
cleanup=cleanup,
|
585
585
|
)
|
586
|
-
|
586
|
+
accounts = gqlapi.query(query)["accounts"]
|
587
|
+
if terraform_state:
|
588
|
+
# a new account does not have a terraform state yet, ignore it until terraform-init does its job
|
589
|
+
return [
|
590
|
+
account for account in accounts if account.get("terraformState") is not None
|
591
|
+
]
|
592
|
+
return accounts
|
587
593
|
|
588
594
|
|
589
595
|
def get_state_aws_accounts(reset_passwords=False):
|
@@ -1,17 +1,16 @@
|
|
1
1
|
import logging
|
2
2
|
import re
|
3
3
|
import string
|
4
|
-
from dataclasses import dataclass
|
5
4
|
|
6
|
-
from gitlab.v4.objects import ProjectMergeRequest
|
7
5
|
from pydantic import BaseModel
|
8
6
|
|
9
7
|
from reconcile.templating.lib.model import TemplateOutput
|
10
8
|
from reconcile.utils.gitlab_api import GitLabApi
|
9
|
+
from reconcile.utils.merge_request_manager.merge_request_manager import (
|
10
|
+
MergeRequestManagerBase,
|
11
|
+
)
|
11
12
|
from reconcile.utils.merge_request_manager.parser import (
|
12
13
|
Parser,
|
13
|
-
ParserError,
|
14
|
-
ParserVersionError,
|
15
14
|
)
|
16
15
|
from reconcile.utils.mr import MergeRequestBase
|
17
16
|
from reconcile.utils.vcs import VCS
|
@@ -79,12 +78,6 @@ class TemplateInfo(BaseModel):
|
|
79
78
|
collection_hash: str
|
80
79
|
|
81
80
|
|
82
|
-
@dataclass
|
83
|
-
class OpenMergeRequest:
|
84
|
-
raw: ProjectMergeRequest
|
85
|
-
template_info: TemplateInfo
|
86
|
-
|
87
|
-
|
88
81
|
class TemplateRenderingMR(MergeRequestBase):
|
89
82
|
name = "TemplateRendering"
|
90
83
|
|
@@ -127,78 +120,11 @@ class TemplateRenderingMR(MergeRequestBase):
|
|
127
120
|
)
|
128
121
|
|
129
122
|
|
130
|
-
class MergeRequestManager:
|
131
|
-
# TODO: Create base class for Merge Request Manager, to make it reusable
|
132
|
-
""" """
|
133
|
-
|
123
|
+
class MergeRequestManager(MergeRequestManagerBase[TemplateInfo]):
|
134
124
|
def __init__(self, vcs: VCS, parser: Parser):
|
135
|
-
|
136
|
-
self._parser = parser
|
137
|
-
self._open_mrs: list[OpenMergeRequest] = []
|
138
|
-
self._open_mrs_with_problems: list[OpenMergeRequest] = []
|
139
|
-
self._housekeeping_ran = False
|
140
|
-
|
141
|
-
def _merge_request_already_exists(
|
142
|
-
self,
|
143
|
-
collection: str,
|
144
|
-
) -> OpenMergeRequest | None:
|
145
|
-
for mr in self._open_mrs:
|
146
|
-
if mr.template_info.collection == collection:
|
147
|
-
return mr
|
148
|
-
|
149
|
-
return None
|
150
|
-
|
151
|
-
def _fetch_avs_managed_open_merge_requests(self) -> list[ProjectMergeRequest]:
|
152
|
-
all_open_mrs = self._vcs.get_open_app_interface_merge_requests()
|
153
|
-
return [mr for mr in all_open_mrs if TR_LABEL in mr.labels]
|
154
|
-
|
155
|
-
def housekeeping(self) -> None:
|
156
|
-
"""
|
157
|
-
Close bad MRs:
|
158
|
-
- bad description format
|
159
|
-
- wrong version
|
160
|
-
- merge conflict
|
161
|
-
|
162
|
-
--> if we update the template output, we automatically close
|
163
|
-
old open MRs and replace them with new ones.
|
164
|
-
"""
|
165
|
-
for mr in self._fetch_avs_managed_open_merge_requests():
|
166
|
-
attrs = mr.attributes
|
167
|
-
desc = attrs.get("description")
|
168
|
-
has_conflicts = attrs.get("has_conflicts", False)
|
169
|
-
if has_conflicts:
|
170
|
-
logging.info(
|
171
|
-
"Merge-conflict detected. Closing %s",
|
172
|
-
mr.attributes.get("web_url", "NO_WEBURL"),
|
173
|
-
)
|
174
|
-
self._vcs.close_app_interface_mr(
|
175
|
-
mr, "Closing this MR because of a merge-conflict."
|
176
|
-
)
|
177
|
-
continue
|
178
|
-
try:
|
179
|
-
template_info = self._parser.parse(description=desc)
|
180
|
-
except ParserVersionError:
|
181
|
-
logging.info(
|
182
|
-
"Old MR version detected! Closing %s",
|
183
|
-
mr.attributes.get("web_url", "NO_WEBURL"),
|
184
|
-
)
|
185
|
-
self._vcs.close_app_interface_mr(
|
186
|
-
mr, "Closing this MR because it has an outdated integration version"
|
187
|
-
)
|
188
|
-
continue
|
189
|
-
except ParserError:
|
190
|
-
logging.info(
|
191
|
-
"Bad MR description format. Closing %s",
|
192
|
-
mr.attributes.get("web_url", "NO_WEBURL"),
|
193
|
-
)
|
194
|
-
self._vcs.close_app_interface_mr(
|
195
|
-
mr, "Closing this MR because of bad description format."
|
196
|
-
)
|
197
|
-
continue
|
198
|
-
self._open_mrs.append(OpenMergeRequest(raw=mr, template_info=template_info))
|
199
|
-
self._housekeeping_ran = True
|
125
|
+
super().__init__(vcs, parser, TR_LABEL)
|
200
126
|
|
201
|
-
def
|
127
|
+
def create_merge_request(self, output: list[TemplateOutput]) -> None:
|
202
128
|
if not self._housekeeping_ran:
|
203
129
|
self.housekeeping()
|
204
130
|
|
@@ -211,8 +137,8 @@ class MergeRequestManager:
|
|
211
137
|
collection_hash = collection_hashes.pop()
|
212
138
|
|
213
139
|
"""Create a new MR with the rendered template."""
|
214
|
-
if mr := self._merge_request_already_exists(collection):
|
215
|
-
if mr.
|
140
|
+
if mr := self._merge_request_already_exists({"collection": collection}):
|
141
|
+
if mr.mr_info.collection_hash == collection_hash:
|
216
142
|
logging.info(
|
217
143
|
"MR already exists and has the same template hash. Skipping",
|
218
144
|
)
|
reconcile/templating/renderer.py
CHANGED
@@ -97,7 +97,7 @@ class GitlabFilePersistence(FilePersistence):
|
|
97
97
|
|
98
98
|
def write(self, outputs: list[TemplateOutput]) -> None:
|
99
99
|
self.mr_manager.housekeeping()
|
100
|
-
self.mr_manager.
|
100
|
+
self.mr_manager.create_merge_request(outputs)
|
101
101
|
|
102
102
|
def read(self, path: str) -> Optional[str]:
|
103
103
|
try:
|
@@ -182,10 +182,10 @@ class TemplateRendererIntegration(QontractReconcileIntegration):
|
|
182
182
|
ruamel_instance: yaml.YAML,
|
183
183
|
state: Optional[State] = None,
|
184
184
|
) -> None:
|
185
|
-
outputs: list[TemplateOutput] = []
|
186
185
|
gql_no_validation = init_from_config(validate_schemas=False)
|
187
186
|
|
188
187
|
for c in get_template_collections():
|
188
|
+
outputs: list[TemplateOutput] = []
|
189
189
|
variables = {}
|
190
190
|
if c.variables:
|
191
191
|
variables = {
|
File without changes
|
@@ -0,0 +1,22 @@
|
|
1
|
+
from pydantic import BaseModel
|
2
|
+
|
3
|
+
from reconcile.gql_definitions.cost_report.app_names import query
|
4
|
+
from reconcile.utils.gql import GqlApi
|
5
|
+
|
6
|
+
|
7
|
+
class App(BaseModel):
|
8
|
+
name: str
|
9
|
+
parent_app_name: str | None
|
10
|
+
|
11
|
+
|
12
|
+
def get_app_names(
|
13
|
+
gql_api: GqlApi,
|
14
|
+
) -> list[App]:
|
15
|
+
apps = query(gql_api.query).apps or []
|
16
|
+
return [
|
17
|
+
App(
|
18
|
+
name=app.name,
|
19
|
+
parent_app_name=app.parent_app.name if app.parent_app else None,
|
20
|
+
)
|
21
|
+
for app in apps
|
22
|
+
]
|
@@ -0,0 +1,15 @@
|
|
1
|
+
from reconcile.gql_definitions.cost_report.settings import CostReportSettingsV1, query
|
2
|
+
from reconcile.utils.exceptions import AppInterfaceSettingsError
|
3
|
+
from reconcile.utils.gql import GqlApi
|
4
|
+
|
5
|
+
|
6
|
+
def get_cost_report_settings(
|
7
|
+
gql_api: GqlApi,
|
8
|
+
) -> CostReportSettingsV1:
|
9
|
+
data = query(gql_api.query)
|
10
|
+
if not data.settings:
|
11
|
+
raise AppInterfaceSettingsError("No settings configured")
|
12
|
+
cost_report_settings = data.settings[0].cost_report
|
13
|
+
if cost_report_settings is None:
|
14
|
+
raise AppInterfaceSettingsError("No cost report configured")
|
15
|
+
return cost_report_settings
|