waldur-site-agent-moab 0.8.2__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ venv/
2
+ .idea/
3
+ .vscode/
4
+ dist/
5
+ **__pycache__
6
+ .DS_Store
7
+ .coverage
@@ -0,0 +1 @@
1
+ 3.9
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: waldur-site-agent-moab
3
+ Version: 0.8.2
4
+ Summary: MOAB plugin for Waldur Site Agent
5
+ Author-email: OpenNode Team <info@opennodecloud.com>
6
+ Requires-Python: <4,>=3.9
7
+ Requires-Dist: waldur-site-agent>=0.7.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # MOAB plugin for Waldur Site Agent
11
+
12
+ This plugin provides MOAB cluster management capabilities for Waldur Site Agent.
13
+
14
+ ## Installation
15
+
16
+ See the main [Installation Guide](../../docs/installation.md) for platform-specific installation instructions.
@@ -0,0 +1,7 @@
1
+ # MOAB plugin for Waldur Site Agent
2
+
3
+ This plugin provides MOAB cluster management capabilities for Waldur Site Agent.
4
+
5
+ ## Installation
6
+
7
+ See the main [Installation Guide](../../docs/installation.md) for platform-specific installation instructions.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "waldur-site-agent-moab"
3
+ version = "0.8.2"
4
+ description = "MOAB plugin for Waldur Site Agent"
5
+ readme = "README.md"
6
+ authors = [{ name = "OpenNode Team", email = "info@opennodecloud.com" }]
7
+ requires-python = ">=3.9, <4"
8
+ dependencies = [
9
+ "waldur-site-agent>=0.7.0"
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.uv.sources]
17
+ waldur-site-agent = { workspace = true }
18
+
19
+ # Entry points for exporting backends
20
+ [project.entry-points."waldur_site_agent.backends"]
21
+ moab = "waldur_site_agent_moab.backend:MoabBackend"
@@ -0,0 +1 @@
1
+ """Module for MOAB-related functions and classes."""
@@ -0,0 +1,124 @@
1
+ """Moab-specific backend classes and functions."""
2
+
3
+ from typing import Optional
4
+
5
+ from waldur_api_client.models.resource import Resource as WaldurResource
6
+
7
+ from waldur_site_agent.backend import BackendType, logger
8
+ from waldur_site_agent.backend import utils as backend_utils
9
+ from waldur_site_agent.backend.backends import BaseBackend
10
+ from waldur_site_agent.backend.exceptions import BackendError
11
+ from waldur_site_agent_moab.client import MoabClient
12
+ from waldur_site_agent_moab.parser import MoabReportLine
13
+
14
+
15
+ class MoabBackend(BaseBackend):
16
+ """MOAB backend class."""
17
+
18
+ def __init__(self, moab_settings: dict, moab_components: dict[str, dict]) -> None:
19
+ """Init backend data and creates a corresponding client."""
20
+ super().__init__(moab_settings, moab_components)
21
+ self.backend_type = BackendType.MOAB.value
22
+ self.client = MoabClient()
23
+ self.backend_components["deposit"]["unit_factor"] = 1
24
+
25
+ def ping(self, raise_exception: bool = False) -> bool:
26
+ """Check if MOAB is online."""
27
+ try:
28
+ self.client.list_resources()
29
+ except BackendError as err:
30
+ if raise_exception:
31
+ raise
32
+ logger.info("Error: %s", err)
33
+ return False
34
+ else:
35
+ return True
36
+
37
+ def diagnostics(self) -> bool:
38
+ """Logs info about the MOAB cluster."""
39
+ return True
40
+
41
+ def list_components(self) -> list[str]:
42
+ """Return deposit component."""
43
+ return ["deposit"]
44
+
45
+ def _get_usage_report(self, resource_backend_ids: list[str]) -> dict:
46
+ """Get usage report."""
47
+ report: dict[str, dict[str, dict[str, float]]] = {}
48
+ lines: list[MoabReportLine] = self.client.get_usage_report(resource_backend_ids)
49
+
50
+ for line in lines:
51
+ report.setdefault(line.account, {}).setdefault(line.user, {})
52
+ user_usage_existing = report[line.account][line.user]
53
+ user_usage_new = backend_utils.sum_dicts([user_usage_existing, line.usages])
54
+ report[line.account][line.user] = user_usage_new
55
+
56
+ for account_usage in report.values():
57
+ usages_per_user = list(account_usage.values())
58
+ total = backend_utils.sum_dicts(usages_per_user)
59
+ account_usage["TOTAL_ACCOUNT_USAGE"] = {
60
+ key: float(round(value, 2)) for key, value in total.items()
61
+ }
62
+ for username, user_usage in account_usage.items():
63
+ account_usage[username] = {
64
+ key: float(round(value, 2)) for key, value in user_usage.items()
65
+ }
66
+
67
+ return report
68
+
69
+ def _pre_create_resource(
70
+ self, waldur_resource: WaldurResource, user_context: Optional[dict] = None
71
+ ) -> None:
72
+ """Override parent method to validate slug fields."""
73
+ if not waldur_resource.project_slug:
74
+ logger.warning(
75
+ "Resource %s has unset or missing project slug. project_slug: %s",
76
+ waldur_resource.uuid,
77
+ waldur_resource.project_slug,
78
+ )
79
+ msg = (
80
+ f"Resource {waldur_resource.uuid} has unset or missing slug fields. "
81
+ f"project_slug: {waldur_resource.project_slug}. "
82
+ "Cannot create backend resources with invalid slug values."
83
+ )
84
+ raise BackendError(msg)
85
+
86
+ del user_context
87
+
88
+ project_backend_id = self._get_project_backend_id(waldur_resource.project_slug)
89
+
90
+ customer_backend_id = None
91
+
92
+ # Create project resource
93
+ self._create_backend_resource(
94
+ project_backend_id,
95
+ waldur_resource.project_name,
96
+ project_backend_id,
97
+ customer_backend_id,
98
+ )
99
+
100
+ def downscale_resource(self, resource_backend_id: str) -> bool:
101
+ """Temporary placeholder."""
102
+ del resource_backend_id
103
+ return False
104
+
105
+ def pause_resource(self, resource_backend_id: str) -> bool:
106
+ """Temporary placeholder."""
107
+ del resource_backend_id
108
+ return False
109
+
110
+ def restore_resource(self, resource_backend_id: str) -> bool:
111
+ """Temporary placeholder."""
112
+ del resource_backend_id
113
+ return False
114
+
115
+ def get_resource_metadata(self, _: str) -> dict:
116
+ """Temporary placeholder."""
117
+ return {}
118
+
119
+ def _collect_resource_limits(
120
+ self, waldur_resource: WaldurResource
121
+ ) -> tuple[dict[str, int], dict[str, int]]:
122
+ """Collect deposit limit only with no conversion."""
123
+ deposit_limit = {"deposit": waldur_resource["limits"]["deposit"]}
124
+ return deposit_limit, deposit_limit
@@ -0,0 +1,172 @@
1
+ """CLI-client for MOAB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from waldur_site_agent.backend import clients, exceptions, logger
8
+ from waldur_site_agent.backend import utils as backend_utils
9
+ from waldur_site_agent.backend.structures import Association, ClientResource
10
+ from waldur_site_agent_moab.parser import MoabReportLine
11
+
12
+
13
+ class MoabClient(clients.BaseClient):
14
+ """This class implements Python client for MOAB.
15
+
16
+ See also MOAB Accounting Manager 9.1.1 Administrator Guide
17
+ http://docs.adaptivecomputing.com/9-1-1/MAM/help.htm
18
+ """
19
+
20
+ def list_resources(self) -> list[ClientResource]:
21
+ """Return list of accounts in MOAB."""
22
+ output = self.execute_command(
23
+ ["mam-list-accounts", "--raw", "--quiet", "--show", "Name,Description,Organization"]
24
+ )
25
+ return [self._parse_account(line) for line in output.splitlines() if "|" in line]
26
+
27
+ def _parse_account(self, line: str) -> ClientResource:
28
+ parts = line.split("|")
29
+ return ClientResource(name=parts[0], description=parts[1], organization=parts[2])
30
+
31
+ def _get_fund_id(self, account: str) -> Optional[int]:
32
+ command_fund = f"mam-list-funds --raw --quiet -a {account} --show Id"
33
+ fund_output = self.execute_command(command_fund.split())
34
+ fund_data = fund_output.splitlines()
35
+
36
+ if len(fund_data) == 0:
37
+ logger.warning("No funds were found for account %s", account)
38
+ return None
39
+
40
+ # Assuming an account has only one fund
41
+ fund_id_str = fund_data[0].strip()
42
+
43
+ return int(fund_id_str)
44
+
45
+ def get_resource(self, resource_id: str) -> ClientResource | None:
46
+ """Get MOAB account info."""
47
+ command = (
48
+ f"mam-list-accounts --raw --quiet --show Name,Description,Organization -a {resource_id}"
49
+ )
50
+ output = self.execute_command(command.split())
51
+ lines = [line for line in output.splitlines() if "|" in line]
52
+ if len(lines) == 0:
53
+ return None
54
+ return self._parse_account(lines[0])
55
+
56
+ def create_resource(
57
+ self, name: str, description: str, organization: str, parent_name: Optional[str] = None
58
+ ) -> str:
59
+ """Create account in MOAB."""
60
+ del parent_name
61
+ command_account = f'mam-create-account -a {name} -d "{description}" -o {organization}'
62
+ self.execute_command(command_account.split())
63
+
64
+ logger.info("Creating fund for the account")
65
+ command_fund = f"mam-create-fund -a {name}"
66
+ return self.execute_command(command_fund.split())
67
+
68
+ def delete_resource(self, name: str) -> str:
69
+ """Delete account from MOAB."""
70
+ command_account = f"mam-delete-account -a {name}"
71
+ self.execute_command(command_account.split())
72
+
73
+ fund_id = self._get_fund_id(name)
74
+
75
+ if fund_id is None:
76
+ logger.warning("Skipping fund deletion.")
77
+ return ""
78
+
79
+ logger.info("Deleting the account fund %s", fund_id)
80
+
81
+ command_fund = f"mam-delete-fund -f {fund_id}"
82
+ return self.execute_command(command_fund.split())
83
+
84
+ def set_resource_limits(self, resource_id: str, limits_dict: dict[str, int]) -> str | None:
85
+ """Set the limits for the account with the specified name."""
86
+ if limits_dict.get("deposit", 0) < 0:
87
+ logger.warning(
88
+ "Skipping limit update because pricing "
89
+ "package is not created for the related service settings."
90
+ )
91
+ return None
92
+
93
+ fund_id = self._get_fund_id(resource_id)
94
+
95
+ if fund_id is None:
96
+ raise exceptions.BackendError(
97
+ f"The account {resource_id} does not have a linked fund, unable to set a deposit"
98
+ )
99
+
100
+ command_deposit = f"mam-deposit -a {resource_id} -z {limits_dict['deposit']} -f {fund_id}"
101
+ return self.execute_command(command_deposit.split())
102
+
103
+ def get_resource_limits(self, _: str) -> dict[str, int]:
104
+ """Get account limits."""
105
+ return {}
106
+
107
+ def get_resource_user_limits(self, _: str) -> dict[str, dict[str, int]]:
108
+ """Get per-user limits for the account."""
109
+ return {}
110
+
111
+ def set_resource_user_limits(
112
+ self, resource_id: str, username: str, limits_dict: dict[str, int]
113
+ ) -> str:
114
+ """Set account limits for a specific user."""
115
+ # The method is a placeholder and is not implemented yet
116
+ del resource_id, username, limits_dict
117
+ return ""
118
+
119
+ def get_association(self, user: str, resource_id: str) -> Association | None:
120
+ """Get association between user and account."""
121
+ command = (
122
+ f"mam-list-funds --raw --quiet -u {user} -a {resource_id} --show Constraints,Balance"
123
+ )
124
+ output = self.execute_command(command.split())
125
+ lines = [line for line in output.splitlines() if "|" in line]
126
+ if len(lines) == 0:
127
+ return None
128
+
129
+ return Association(
130
+ account=resource_id, user=user, value=int(float(lines[0].split("|")[-1]))
131
+ )
132
+
133
+ def create_association(self, username: str, resource_id: str, _: Optional[str] = None) -> str:
134
+ """Create association between user and account in MOAB."""
135
+ command = f"mam-modify-account --add-user +{username} -a {resource_id}"
136
+ return self.execute_command(command.split())
137
+
138
+ def delete_association(self, username: str, resource_id: str) -> str:
139
+ """Delete association between user and account."""
140
+ command = f"mam-modify-account --del-user {username} -a {resource_id}"
141
+ return self.execute_command(command.split())
142
+
143
+ def get_usage_report(self, resource_ids: list[str]) -> list:
144
+ """Get usages records from MOAB."""
145
+ template = (
146
+ "mam-list-usagerecords --raw --quiet --show "
147
+ "Account,User,Charge "
148
+ "-a %(account)s -s %(start)s -e %(end)s"
149
+ )
150
+ month_start, month_end = backend_utils.format_current_month()
151
+
152
+ report_lines = []
153
+ for account in resource_ids:
154
+ command = template % {
155
+ "account": account,
156
+ "start": month_start,
157
+ "end": month_end,
158
+ }
159
+ lines = self.execute_command(command.split()).splitlines()
160
+ report_lines_to_add = [MoabReportLine(line) for line in lines if "|" in line]
161
+ report_lines.extend(report_lines_to_add)
162
+
163
+ return report_lines
164
+
165
+ def list_resource_users(self, resource_id: str) -> list[str]:
166
+ """Returns list of users linked to the account."""
167
+ # TODO: make use of -A flag (fetch only active users)
168
+ command = f"mam-list-users -a {resource_id} --raw --show Name,DefaultAccount --quiet"
169
+ output = self.execute_command(command.split())
170
+ return [
171
+ line.split("|")[0] for line in output.splitlines() if "|" in line and line[-1] != "|"
172
+ ]
@@ -0,0 +1,32 @@
1
+ """Parsing classes for MOAB."""
2
+
3
+ import decimal
4
+ from functools import cached_property
5
+
6
+
7
+ class MoabReportLine:
8
+ """Parser for report lines from MOAB."""
9
+
10
+ def __init__(self, line: str) -> None:
11
+ """Constructor."""
12
+ self._parts = line.split("|")
13
+
14
+ @cached_property
15
+ def account(self) -> str:
16
+ """Parses account name."""
17
+ return self._parts[0].strip()
18
+
19
+ @cached_property
20
+ def user(self) -> str:
21
+ """Parses username."""
22
+ return self._parts[1]
23
+
24
+ @cached_property
25
+ def charge(self) -> decimal.Decimal:
26
+ """Parses charge."""
27
+ return decimal.Decimal(self._parts[2])
28
+
29
+ @cached_property
30
+ def usages(self) -> dict[str, decimal.Decimal]:
31
+ """Return deposit usage from the report line."""
32
+ return {"deposit": self.charge}