Skip to content

Commit ef1f87e

Browse files
author
Alan Christie
committed
2 parents f6986a1 + e0efa24 commit ef1f87e

File tree

10 files changed

+317
-35
lines changed

10 files changed

+317
-35
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ set of examples so that users can create their own utilities.
1313
> Most tools need to be executed by a user with admin privileges.
1414
1515
## Usage
16-
The tools utilise the Pyhon client's `Environment` module, which expects
16+
The tools utilise the Python client's `Environment` module, which expects
1717
you to create an `Envrionments` file - a YAML file that defines the
1818
variables used to connect to the corresponding installation. The environments
19-
file (typically `~/.squonk2/environmemnts`) allows you to creat variables
19+
file (typically `~/.squonk2/environmemnts`) allows you to create variables
2020
for multiple installations identified by name.
2121

2222
See the **Environment module** section of the [Squonk2 Python Client].
@@ -43,11 +43,13 @@ display the tool's help.
4343
You should find the following tools in this repository: -
4444

4545
- `coins`
46+
- `create-organisations-and-units`
4647
- `delete-all-instances`
4748
- `delete-old-instances`
4849
- `delete-test-projects`
4950
- `list-environments`
5051
- `load-er`
52+
- `load-job-manifests`
5153
- `save-er`
5254

5355
---

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
im-squonk2-client >= 3.0.2, < 4.0.0
2-
python-dateutil == 2.8.2
2+
python-dateutil == 2.9.0
33
rich == 12.6.0
44
pyyaml == 6.0.1

tools/coins.py

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
"""
44
import argparse
55
from collections import namedtuple
6+
import decimal
67
from decimal import Decimal
78
import sys
8-
from typing import Any, Dict
9+
from typing import Any, Dict, Optional
10+
from attr import dataclass
911
import urllib3
1012

1113
from rich.pretty import pprint
@@ -16,8 +18,12 @@
1618

1719
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
1820

19-
AdjustedCoins: namedtuple = namedtuple("AdjustedCoins",
20-
["coins", "fc", "ac", "aac"])
21+
@dataclass
22+
class AdjustedCoins:
23+
coins: Decimal
24+
fc: Decimal
25+
ac: Decimal
26+
aac: Decimal
2127

2228

2329
def main(c_args: argparse.Namespace) -> None:
@@ -36,6 +42,9 @@ def main(c_args: argparse.Namespace) -> None:
3642
username=env.admin_user,
3743
password=env.admin_password,
3844
)
45+
if not token:
46+
console.log("[bold red]ERROR[/bold red] Failed to get token")
47+
sys.exit(1)
3948

4049
# Get the product details.
4150
# This gives us the product's allowance, limit and overspend multipliers
@@ -55,6 +64,11 @@ def main(c_args: argparse.Namespace) -> None:
5564

5665
remaining_days: int = p_rv.msg["product"]["coins"]["remaining_days"]
5766

67+
# What's the 'billing prediction' in the /product response?
68+
# We'll compare this later to ensure it matches what we find
69+
# when we calculate the cost to the user using the product charges.
70+
product_response_billing_prediction: Decimal = round(Decimal(p_rv.msg["product"]["coins"]["billing_prediction"]), 2)
71+
5872
# Get the product's charges...
5973
pc_rv: AsApiRv = AsApi.get_product_charges(token, product_id=args.product, pbp=args.pbp)
6074
if not pc_rv.success:
@@ -65,16 +79,16 @@ def main(c_args: argparse.Namespace) -> None:
6579
pprint(pc_rv.msg)
6680

6781
# Accumulate all the storage costs
68-
# (excluding the current which will be interpreted as the "burn rate")
82+
# (the current record wil be used to set the future the "burn rate")
6983
num_storage_charges: int = 0
7084
burn_rate: Decimal = Decimal()
7185
total_storage_coins: Decimal = Decimal()
7286
if "items" in pc_rv.msg["storage_charges"]:
7387
for item in pc_rv.msg["storage_charges"]["items"]:
88+
total_storage_coins += Decimal(item["coins"])
7489
if "current_bytes" in item["additional_data"]:
75-
burn_rate = Decimal(item["coins"])
90+
burn_rate = Decimal(item["burn_rate"])
7691
else:
77-
total_storage_coins += Decimal(item["coins"])
7892
num_storage_charges += 1
7993

8094
# Accumulate all the processing costs
@@ -128,31 +142,58 @@ def main(c_args: argparse.Namespace) -> None:
128142
"Coins (Adjusted)": f"{ac.fc} + {ac.aac} = {ac.coins}",
129143
}
130144

131-
additional_coins: Decimal = total_uncommitted_processing_coins + burn_rate * remaining_days
145+
# We've accumulated today's storage costs (based on the current 'peak'),
146+
# so we can only predict further storage costs if there's more than
147+
# 1 day left until the billing day. And that 'burn rate' is based on today's
148+
# 'current' storage, not its 'peak'.
149+
burn_rate_contribution: Decimal = Decimal()
150+
burn_rate_days: int = max(remaining_days - 1, 0)
151+
if burn_rate_days > 0:
152+
burn_rate_contribution = burn_rate * burn_rate_days
153+
additional_coins: Decimal = total_uncommitted_processing_coins + burn_rate_contribution
132154
predicted_total_coins: Decimal = total_coins
133155
zero: Decimal = Decimal()
134-
if remaining_days > 0:
135-
if burn_rate > zero:
136-
137-
predicted_total_coins += additional_coins
138-
p_ac: AdjustedCoins = _calculate_adjusted_coins(
139-
predicted_total_coins,
140-
allowance,
141-
allowance_multiplier)
142-
143-
invoice["Prediction"] = {
144-
"Coins (Burn Rate)": str(burn_rate),
145-
"Coins (Additional Spend)": f"{total_uncommitted_processing_coins} + {remaining_days} x {burn_rate} = {additional_coins}",
146-
"Coins (Total Raw)": f"{total_coins} + {additional_coins} = {predicted_total_coins}",
147-
"Coins (Penalty Free)": str(p_ac.fc),
148-
"Coins (In Allowance Band)": str(p_ac.ac),
149-
"Coins (Allowance Charge)": f"{p_ac.ac} x {allowance_multiplier} = {p_ac.aac}",
150-
"Coins (Adjusted)": f"{p_ac.fc} + {p_ac.aac} = {p_ac.coins}",
151-
}
156+
calculated_billing_prediction: Decimal = Decimal()
157+
158+
if remaining_days > 0 and burn_rate > zero:
159+
160+
predicted_total_coins += additional_coins
161+
p_ac: AdjustedCoins = _calculate_adjusted_coins(
162+
predicted_total_coins,
163+
allowance,
164+
allowance_multiplier)
165+
166+
invoice["Prediction"] = {
167+
"Coins (Burn Rate)": str(burn_rate),
168+
"Coins (Expected Burn Rate Contribution)": f"{burn_rate_days} x {burn_rate} = {burn_rate_contribution}",
169+
"Coins (Additional Spend)": f"{total_uncommitted_processing_coins} + {burn_rate_contribution} = {additional_coins}",
170+
"Coins (Total Raw)": f"{total_coins} + {additional_coins} = {predicted_total_coins}",
171+
"Coins (Penalty Free)": str(p_ac.fc),
172+
"Coins (In Allowance Band)": str(p_ac.ac),
173+
"Coins (Allowance Charge)": f"{p_ac.ac} x {allowance_multiplier} = {p_ac.aac}",
174+
"Coins (Adjusted)": f"{p_ac.fc} + {p_ac.aac} = {p_ac.coins}",
175+
}
176+
177+
calculated_billing_prediction = p_ac.coins
152178

153179
# Now just pre-tty-print the invoice
154180
pprint(invoice)
155181

182+
console.log(f"Calculated billing prediction is {calculated_billing_prediction}")
183+
console.log(f"Product response billing prediction is {product_response_billing_prediction}")
184+
185+
if calculated_billing_prediction == product_response_billing_prediction:
186+
console.log(":white_check_mark: CORRECT - Predictions match")
187+
else:
188+
discrepancy: Decimal = abs(calculated_billing_prediction - product_response_billing_prediction)
189+
if calculated_billing_prediction > product_response_billing_prediction:
190+
who_is_higher: str = "Calculated"
191+
else:
192+
who_is_higher: str = "Product response"
193+
console.log(":cross_mark: ERROR - Predictions do not match.")
194+
console.log(f"There's a discrepancy of {discrepancy} and the {who_is_higher} value is higher.")
195+
sys.exit(1)
196+
156197

157198
def _calculate_adjusted_coins(total_coins: Decimal,
158199
allowance: Decimal,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env python
2+
"""Creates organisations using a YAML file to define their names and owners.
3+
The file is simply a list of organisations that have a `name`, and `owner`
4+
with an optional list of `units` with names and billing days (with a default of '3)
5+
(which are created in the same way).
6+
"""
7+
import argparse
8+
from pathlib import Path
9+
import sys
10+
from typing import Any, Dict, List
11+
import urllib3
12+
13+
from rich.console import Console
14+
from squonk2.auth import Auth
15+
from squonk2.as_api import AsApi, AsApiRv
16+
from squonk2.environment import Environment
17+
import yaml
18+
19+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
20+
21+
22+
def main(c_args: argparse.Namespace, filename: str) -> None:
23+
"""Main function."""
24+
25+
console = Console()
26+
27+
_ = Environment.load()
28+
env: Environment = Environment(c_args.environment)
29+
AsApi.set_api_url(env.as_api)
30+
31+
token: str = Auth.get_access_token(
32+
keycloak_url=env.keycloak_url,
33+
keycloak_realm=env.keycloak_realm,
34+
keycloak_client_id=env.keycloak_as_client_id,
35+
username=env.admin_user,
36+
password=env.
37+
admin_password,
38+
)
39+
if not token:
40+
print("Failed to get token")
41+
sys.exit(1)
42+
43+
# Get the current organisations (as an admin user you should see them all)
44+
org_rv: AsApiRv = AsApi.get_organisations(token)
45+
if not org_rv.success:
46+
console.log(':boom: Failed to get existing organisations')
47+
sys.exit(1)
48+
existing_org_names: List[str] = []
49+
existing_orgs: Dict[str, str] = {}
50+
for org in org_rv.msg['organisations']:
51+
if org['name'] not in ['Default']:
52+
existing_orgs[org['name']] = org['id']
53+
existing_org_names.append(org['name'])
54+
55+
# Just read the list from the chosen file
56+
file_content: str = Path(filename).read_text(encoding='utf8')
57+
orgs: List[Dict[str, Any]] = yaml.load(file_content, Loader=yaml.FullLoader)
58+
# Create the organisations one at a time (to handle any errors gracefully)
59+
for org in orgs:
60+
org_name: str = org.get('name')
61+
if not org_name:
62+
console.log(':boom: File has an organisation without a name')
63+
sys.exit(1)
64+
owner: str = org.get('owner')
65+
if not owner:
66+
console.log(':boom: File has an organisation without an owner')
67+
sys.exit(1)
68+
# Now try and create the organisation (if it's new)...
69+
if org_name in existing_org_names:
70+
console.log(f':white_check_mark: Skipping organisation "{org_name}" - it already exists')
71+
else:
72+
org_rv: AsApiRv = AsApi.create_organisation(token, org_name=org_name, org_owner=owner)
73+
if org_rv.success:
74+
emoji = ':white_check_mark:'
75+
existing_orgs[org_name] = org_rv.msg['id']
76+
else:
77+
emoji = ':cross_mark:'
78+
# Log
79+
console.log(f'{emoji} {org_name} ({owner})')
80+
# Units?
81+
if 'units' in org:
82+
org_rv: AsApiRv = AsApi.get_units(token, org_id=existing_orgs[org_name])
83+
existing_unit_names: List[str] = [unit['name'] for unit in org_rv.msg['units']]
84+
for unit in org['units']:
85+
unit_name: str = unit.get('name')
86+
if not unit_name:
87+
console.log(':boom: File has a unit without a name')
88+
sys.exit(1)
89+
if unit_name in existing_unit_names:
90+
console.log(f':white_check_mark: Skipping unit "{org_name}/{unit_name}" - it already exists')
91+
else:
92+
billing_day: int = unit.get('billing_day', 3)
93+
# Now try and create the unit (if it's new)...
94+
unit_rv: AsApiRv = AsApi.create_unit(token, org_id=existing_orgs[org_name], unit_name=unit_name, billing_day=billing_day)
95+
emoji = ':white_check_mark:' if unit_rv.success else ':cross_mark:'
96+
# Log
97+
console.log(f' {emoji} {unit_name} (billing day {billing_day})')
98+
99+
100+
if __name__ == "__main__":
101+
102+
# Parse command line arguments
103+
parser = argparse.ArgumentParser(
104+
prog="create-organisations",
105+
description="Creates Organisations and Units (from a YAML file). You will need admin privileges to use this tool."
106+
)
107+
parser.add_argument('environment', type=str, help='The environment name')
108+
parser.add_argument('file', type=str, help='The source file')
109+
args: argparse.Namespace = parser.parse_args()
110+
111+
filename: str = args.file
112+
if not filename.endswith('.yaml'):
113+
filename += '.yaml'
114+
115+
# File must exist
116+
if not Path(filename).is_file():
117+
parser.error(f"File '{filename}' does not exist")
118+
119+
main(args, filename)

tools/delete-all-instances.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
prior to a major upgrade.
1212
"""
1313
import argparse
14+
import sys
1415
from typing import Dict, List, Optional, Tuple
1516
import urllib3
1617

@@ -34,13 +35,18 @@ def main(c_args: argparse.Namespace) -> None:
3435
username=env.admin_user,
3536
password=env.admin_password,
3637
)
38+
if not token:
39+
print("Failed to get token")
40+
sys.exit(1)
3741

3842
# The collection of instances
3943
project_instances: Dict[str, List[Tuple[str, str]]] = {}
4044

4145
# To see everything we need to become admin...
4246
rv: DmApiRv = DmApi.set_admin_state(token, admin=True)
43-
assert rv.success
47+
if not rv.success:
48+
print("Failed to set admin state")
49+
sys.exit(1)
4450

4551
# Iterate through projects to get instances...
4652
num_instances: int = 0
@@ -82,7 +88,9 @@ def main(c_args: argparse.Namespace) -> None:
8288
# Revert to a non-admin state
8389
# To see everything we need to become admin...
8490
rv = DmApi.set_admin_state(token, admin=False)
85-
assert rv.success
91+
if not rv.success:
92+
print("Failed to unset admin state")
93+
sys.exit(1)
8694

8795
print(f"Found {num_instances}")
8896
print(f"Deleted {num_deleted}")

tools/delete-old-instances.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
is that the user has admin rights.
99
"""
1010
import argparse
11+
import sys
1112
from datetime import datetime, timedelta
1213
from typing import List, Optional, Tuple
1314
import urllib3
@@ -33,10 +34,15 @@ def main(c_args: argparse.Namespace) -> None:
3334
username=env.admin_user,
3435
password=env.admin_password,
3536
)
37+
if not token:
38+
print("Failed to get token")
39+
sys.exit(1)
3640

3741
# To see everything we need to become admin...
3842
rv: DmApiRv = DmApi.set_admin_state(token, admin=True)
39-
assert rv.success
43+
if not rv.success:
44+
print("Failed to set admin state")
45+
sys.exit(1)
4046

4147
# Max age?
4248
max_stopped_age: timedelta = timedelta(hours=args.age)
@@ -76,7 +82,9 @@ def main(c_args: argparse.Namespace) -> None:
7682
# Revert to a non-admin state
7783
# To see everything we need to become admin...
7884
rv = DmApi.set_admin_state(token, admin=False)
79-
assert rv.success
85+
if not rv.success:
86+
print("Failed to unset admin state")
87+
sys.exit(1)
8088

8189
print(f"Found {len(old_instances)}")
8290
print(f"Deleted {num_deleted}")

tools/delete-test-projects.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""Deletes projects in the DM created by a built-in test user.
55
"""
66
import argparse
7+
import sys
78
from typing import Any, Dict, List, Optional
89
import urllib3
910

@@ -29,6 +30,9 @@ def main(c_args: argparse.Namespace) -> None:
2930
username=env.admin_user,
3031
password=env.admin_password,
3132
)
33+
if not token:
34+
print("Failed to get token")
35+
sys.exit(1)
3236

3337
ret_val: DmApiRv = DmApi.get_available_projects(token)
3438
assert ret_val.success

0 commit comments

Comments
 (0)