This commit is contained in:
2024-12-24 20:49:41 +03:00
parent f8febafacb
commit 2b3521544b
35 changed files with 2043 additions and 767 deletions
+336
View File
@@ -0,0 +1,336 @@
import itertools
import json
import logging
import pprint
import re
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import quote_plus
import boto3
import httpx
import urllib.parse
import subprocess
import typing
def list_repo_tags(*, repository_name: str, region: str, username: str, password: str) -> typing.List[dict]:
"""
Fetches the list of tags in a Codecommit repo using git ls-remote command
:returns: List of {tag, commit} items
"""
url = "https://{username}:{password}@git-codecommit.{region}.amazonaws.com/v1/repos/{repository_name}".format(
username=quote_plus(username),
password=quote_plus(password), # passwords are base64-encoded and might contain "/"
region=region,
repository_name=repository_name,
)
result = subprocess.run(["git", "ls-remote", "--refs", "--tags", url], text=True, stdout=subprocess.PIPE)
tags = []
for line in result.stdout.splitlines(keepends=False):
# lines are formatted as:
# 070161940636bab5d934af2702f4248e5a13eb29 refs/tags/ui_sandbox_acc104
# and columns are delimited with "\t"
if "refs/tags/" in line:
commit_hash, tag_ref = line.split("\t", maxsplit=1)
tag = tag_ref.replace("refs/tags/", "")
tags.append({"commit": commit_hash, "tag": tag})
# natural-sort by tag name
# flora_prod_1 < ... < flora_prod_9 > flora_prod_10
# vs lexical sort would give:
# flora_prod_1 < flora_prod_10 < ... < flora_prod_9
tags = sorted(tags, key=lambda t: [int(it) if it.isdigit() else it for it in re.split(r"(\d+)", t["tag"])])
return tags
def chunk(it, size):
it = iter(it)
sentinel = ()
return iter(lambda: tuple(itertools.islice(it, size)), sentinel)
def summarize_tags(tags: list[dict], take: int = 10) -> list[dict]:
re_prefix = re.compile(r"^(\D*)")
re_digits = re.compile(r"(\d+)")
grouped = defaultdict(list)
for t in tags:
tag_name = t["tag"]
if m := re_prefix.search(tag_name):
prefix = m.group(1)
grouped[prefix].append(t)
else:
print("no match", m)
flattened = [*grouped.pop("", [])]
for g, items in grouped.items():
sorted_items = sorted(
items, key=lambda t: [int(it) if it.isdigit() else it for it in re_digits.split(t["tag"])], reverse=True
)
flattened.extend(sorted_items[:take])
return flattened
abdus_creds = {
"username": "coder+1-at-400344683105",
"password": "4xdnGOtghKiT0M5CjulWf5raWwBSqT0Df2YkIY9Ws68=",
}
def get_commit_content():
client = boto3.client("codecommit")
res = client.get_file(
repositoryName="testing",
filePath="content.txt1",
commitSpecifier="vnext",
)
print(res)
def main():
import logging
from botocore.endpoint import Endpoint
logging.basicConfig(level=logging.INFO)
raw_make_request = Endpoint.make_request
def intercepted_make_request(*args, **kwargs):
aws_response, parsed_response = raw_make_request(*args, **kwargs)
logging.info('AWS response: %s', parsed_response)
return aws_response, parsed_response
Endpoint.make_request = intercepted_make_request
session = boto3.Session(
aws_access_key_id="AKIAQKZTNEVLBHDG5Q3X",
aws_secret_access_key="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",
region_name="eu-central-1",
)
cc = session.client("rds")
_ = cc.describe_db_clusters()
if __name__ == "__main__":
main()
exit()
# get_commit_content()
# flora b1cd47a221b84b2884b3d24198085a9f-f0dc2e747ff241649715c20d48232d8e
# oms 36363cc2f43b4cf5b1965af9f8925a4d-6deff091ce2d4c2483d8834c532a6f36
# omnitron 36363cc2f43b4cf5b1965af9f8925a4d-97400227a37940539c55489e1354ffc2
all_tags = list_repo_tags(
repository_name="b1cd47a221b84b2884b3d24198085a9f-f0dc2e747ff241649715c20d48232d8e",
username="sandbox-acc-api-user-at-023193265494",
password="sYPWKdhSx4JliXNI4ffRb4qWYBHLxA54nczSJ6CmYCY=",
region="eu-central-1",
)
print(all_tags)
exit(0)
all_tags.append({"tag": "1asd", "commit": "asdf"})
summarized = summarize_tags(all_tags, take=5)
commits = {t["commit"] for t in summarized}
session = boto3.Session(
aws_access_key_id="AKIAQKZTNEVLBHDG5Q3X",
aws_secret_access_key="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",
region_name="eu-central-1",
)
client = session.client("codecommit")
def get_commit_infos(commits: list[str]):
res = client.batch_get_commits(
repositoryName="36363cc2f43b4cf5b1965af9f8925a4d-97400227a37940539c55489e1354ffc2",
commitIds=commits,
)
return res["commits"]
with ThreadPoolExecutor() as pool:
results = list(pool.map(get_commit_infos, chunk(commits, 100)))
clone_ssh_url = repo["repositoryMetadata"]["cloneUrlSsh"]
print(repo)
exit(0)
# exit()
from botocore.awsrequest import AWSRequest
from botocore.signers import RequestSigner
# AWS Version 4 signing example
# DynamoDB API (CreateTable)
# See: http://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
# This version makes a POST request and passes request parameters
# in the body (payload) of the request. Auth information is passed in
# an Authorization header.
import sys, os, base64, datetime, hashlib, hmac
import requests # pip install requests
# ************* REQUEST VALUES *************
method = "POST"
service = "codecommit"
host = "codecommit.eu-central-1.amazonaws.com"
region = "eu-central-1"
endpoint = "https://codecommit.eu-central-1.amazonaws.com/"
# POST requests use a content type header. For DynamoDB,
# the content is JSON.
content_type = "application/x-amz-json-1.1"
# DynamoDB requires an x-amz-target header that has this format:
# DynamoDB_<API version>.<operationName>
amz_target = "CodeCommit_20150413.GetReferences"
# Request parameters for CreateTable--passed in a JSON block.
request_body = {"repositoryName": "36363cc2f43b4cf5b1965af9f8925a4d-6deff091ce2d4c2483d8834c532a6f36"}
request_parameters = json.dumps(request_body)
# Key derivation functions. See:
# http://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-python
def sign(key, msg):
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def getSignatureKey(key, date_stamp, regionName, serviceName):
kDate = sign(("AWS4" + key).encode("utf-8"), date_stamp)
kRegion = sign(kDate, regionName)
kService = sign(kRegion, serviceName)
kSigning = sign(kService, "aws4_request")
return kSigning
# Read AWS access key from env. variables or configuration file. Best practice is NOT
# to embed credentials in code.
access_key = os.environ.get("AWS_ACCESS_KEY_ID", "AKIAQKZTNEVLBHDG5Q3X")
secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "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")
if access_key is None or secret_key is None:
print("No access key is available.")
sys.exit()
# Create a date for headers and the credential string
t = datetime.datetime.utcnow()
amz_date = t.strftime("%Y%m%dT%H%M%SZ")
date_stamp = t.strftime("%Y%m%d") # Date w/o time, used in credential scope
# ************* TASK 1: CREATE A CANONICAL REQUEST *************
# http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
# Step 1 is to define the verb (GET, POST, etc.)--already done.
# Step 2: Create canonical URI--the part of the URI from domain to query
# string (use '/' if no path)
canonical_uri = "/"
## Step 3: Create the canonical query string. In this example, request
# parameters are passed in the body of the request and the query string
# is blank.
canonical_querystring = ""
# Step 4: Create the canonical headers. Header names must be trimmed
# and lowercase, and sorted in code point order from low to high.
# Note that there is a trailing \n.
canonical_headers = (
"content-type:"
+ content_type
+ "\n"
+ "host:"
+ host
+ "\n"
+ "x-amz-date:"
+ amz_date
+ "\n"
+ "x-amz-target:"
+ amz_target
+ "\n"
)
# Step 5: Create the list of signed headers. This lists the headers
# in the canonical_headers list, delimited with ";" and in alpha order.
# Note: The request can include any headers; canonical_headers and
# signed_headers include those that you want to be included in the
# hash of the request. "Host" and "x-amz-date" are always required.
# For DynamoDB, content-type and x-amz-target are also required.
signed_headers = "content-type;host;x-amz-date;x-amz-target"
# Step 6: Create payload hash. In this example, the payload (body of
# the request) contains the request parameters.
payload_hash = hashlib.sha256(request_parameters.encode("utf-8")).hexdigest()
# Step 7: Combine elements to create canonical request
canonical_request = (
method
+ "\n"
+ canonical_uri
+ "\n"
+ canonical_querystring
+ "\n"
+ canonical_headers
+ "\n"
+ signed_headers
+ "\n"
+ payload_hash
)
# ************* TASK 2: CREATE THE STRING TO SIGN*************
# Match the algorithm to the hashing algorithm you use, either SHA-1 or
# SHA-256 (recommended)
algorithm = "AWS4-HMAC-SHA256"
credential_scope = date_stamp + "/" + region + "/" + service + "/" + "aws4_request"
string_to_sign = (
algorithm
+ "\n"
+ amz_date
+ "\n"
+ credential_scope
+ "\n"
+ hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
)
# ************* TASK 3: CALCULATE THE SIGNATURE *************
# Create the signing key using the function defined above.
signing_key = getSignatureKey(secret_key, date_stamp, region, service)
# Sign the string_to_sign using the signing_key
signature = hmac.new(signing_key, (string_to_sign).encode("utf-8"), hashlib.sha256).hexdigest()
# ************* TASK 4: ADD SIGNING INFORMATION TO THE REQUEST *************
# Put the signature information in a header named Authorization.
authorization_header = (
algorithm
+ " "
+ "Credential="
+ access_key
+ "/"
+ credential_scope
+ ", "
+ "SignedHeaders="
+ signed_headers
+ ", "
+ "Signature="
+ signature
)
# For DynamoDB, the request can include any headers, but MUST include "host", "x-amz-date",
# "x-amz-target", "content-type", and "Authorization". Except for the authorization
# header, the headers must be included in the canonical_headers and signed_headers values, as
# noted earlier. Order here is not significant.
# # Python note: The 'host' header is added automatically by the Python 'requests' library.
headers = {
"User-Agent": "aws-sdk-js/2.627.0 promise",
"Content-Type": content_type,
"X-Amz-Date": amz_date,
"X-Amz-Target": amz_target,
"Authorization": authorization_header,
}
# ************* SEND THE REQUEST *************
print("\nBEGIN REQUEST++++++++++++++++++++++++++++++++++++")
print("Request URL = " + endpoint)
r = httpx.post(endpoint, json=request_body, headers=headers)
print("\nRESPONSE++++++++++++++++++++++++++++++++++++")
print("Response code: %d\n" % r.status_code)
print(r.text)
+438
View File
@@ -0,0 +1,438 @@
import dataclasses
import functools
import itertools
import logging
import re
import time
import typing
import uuid
from typing import Optional
import boto3
import botocore.exceptions
from botocore.exceptions import ClientError
logger = logging.getLogger(__name__)
def retry(timeout: int, backoff_seconds: int = 10, bubble_errors: typing.List[typing.Type[Exception]] = None):
"""
Tries calling a function {timeout} seconds until it succeeds or gives up and throw an error
:param timeout: Timeout in seconds
:param backoff_seconds: Wait time between retries
:param bubble_errors: List of exceptions to bubble up
:return: Wrapped function
"""
bubble_errors = tuple(bubble_errors or [])
def wrapped(func: typing.Callable):
@functools.wraps(func)
def inner(*args, **kwargs):
waited = 0
while True:
try:
logger.debug(f"Calling {func}")
return func(*args, **kwargs)
except KeyboardInterrupt:
raise
except tuple(bubble_errors):
raise
except Exception as e:
doze = min(timeout - waited, backoff_seconds)
times = (timeout - waited) // backoff_seconds
logger.debug(f"{func} failed, will try {times} times in {doze} sec")
time.sleep(doze)
waited += backoff_seconds
if waited >= timeout:
raise TimeoutError(f"Couldn't get a successful result from {func} in {timeout} seconds") from e
return inner
return wrapped
@dataclasses.dataclass
class RedisInstance:
cluster_id: str
host: str
port: int
arn: str
user_group_ids: typing.List[str] = dataclasses.field(default_factory=list)
def paginate_by_marker(
func: callable,
list_field: str,
) -> typing.Iterable:
"""
Simplifies paginating over AWS results with a simpler interface than Paginators.
:param func: A function that accepts a parameter named `Marker`
:param list_field: Result field to iterate on
"""
marker = ""
while True:
result = func(Marker=marker)
items = result.get(list_field, [])
yield from items
if "Marker" not in result:
break
marker = result["Marker"]
@dataclasses.dataclass
class RedisUser:
username: str
password: str
redis_key_prefix: str
class CreateUserResult(typing.TypedDict):
user_group_id: str
users: typing.List[RedisUser]
status: str
class RedisService:
def __init__(self, region: str):
self.elasticache = boto3.client("elasticache", region_name=region)
def create_redis_acl_user(
self,
replication_group_id: str,
users: typing.List[RedisUser],
) -> None:
"""
Creates a Redis ACL user. It will throw an error if the user group attached to the replication group is not ready.
This prevents us from quickly creating users. In that case, pass in a list of users instead.
It takes about 2 minutes until the user is ready.
"""
repl_groups = self.elasticache.describe_replication_groups(ReplicationGroupId=replication_group_id)
user_group_id: str = repl_groups['ReplicationGroups'][0]['UserGroupIds'][0]
group = self.elasticache.describe_user_groups(UserGroupId=user_group_id)['UserGroups'][0]
"""
Limits imposed by AWS:
- User groups per replication group = 1
- Users per user group = 100
- Number of users = 1000
- Number of user groups = 100
https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/Clusters.RBAC.html#Users-groups-to-RGs
In short, we can't place more than 100 users on a single Redis instance.
"""
total_users = len(group['UserIds'])
if total_users >= 100:
raise Exception('Replication group reached the limit of 100 users')
self._update_or_create_user_group(user_group_id, users)
def delete_redis_acl_user(self, username: str) -> None:
logger.info(f'Deleting user {username}')
self.elasticache.delete_user(UserId=username)
# Even though we just deleted the user, it takes a couple of seconds until AWS updates user group status.
# So, we have to wait a bit before we can actually wait and check that the changes have propagated.
# Overall, it should take about 2-3 minutes to delete a user.
def create_redis_acl(
self,
*,
replication_group_id: str,
description: str = None,
node_type: str,
cache_subnet_group_name: str,
security_group_id: str,
tags: typing.Dict[str, str] = None,
users: typing.List[RedisUser],
) -> dict:
user_group_id = f"{replication_group_id}-ug"
# we have to wait (~1m) until the user group is ready before we can create the ACL
self._update_or_create_user_group(user_group_id, users, wait=True)
cache_param_group = 'redis-acl-with-100-db'
self._ensure_cache_parameter_group(cache_param_group)
logger.info(f"Creating Redis replication group {replication_group_id=}")
default_replication_group_kwargs = dict(
ReplicasPerNodeGroup=0,
Engine="redis",
EngineVersion="6.x",
TransitEncryptionEnabled=True, # must be True to use Redis ACL
MultiAZEnabled=False,
AutomaticFailoverEnabled=False,
)
repl_result = self.elasticache.create_replication_group(
**default_replication_group_kwargs,
ReplicationGroupId=replication_group_id,
ReplicationGroupDescription=description or f'{replication_group_id} replication group',
CacheNodeType=node_type,
CacheSubnetGroupName=cache_subnet_group_name,
CacheParameterGroupName=cache_param_group,
UserGroupIds=[
user_group_id,
],
SecurityGroupIds=[
security_group_id,
],
Tags=[{"Key": k, "Value": v} for k, v in (tags or {}).items()],
)
# replication group takes ~8m minutes to be ready
return {
"replication_group_id": repl_result["ReplicationGroup"]["ReplicationGroupId"],
'arn': repl_result["ReplicationGroup"]["ARN"],
'status': repl_result["ReplicationGroup"]["Status"],
'users': users,
'_response': repl_result,
}
def _update_or_create_user_group(
self, user_group_id: str, users: typing.List[RedisUser], wait: bool = False
) -> None:
"""
Creates or updates a user group with the given users. Waits until the user group is active.
:param user_group_id:
:param users:
"""
# we have to add the `default` user for backwards compatibility
existing_users = self.elasticache.describe_users(
Filters=[{"Name": "user-id", "Values": [u.username for u in users]}],
)["Users"]
existing_user_ids = [it["UserId"] for it in existing_users]
created_user_ids = ["default"]
for user in users:
if user.username in existing_user_ids:
logger.info(f"User {user.username} is already exists")
created_user_ids.append(user.username)
continue
redis_acl = f"on +@all -@dangerous ~{user.redis_key_prefix}*"
logger.info(f"Creating tenant {user.username=}")
user_result = self.elasticache.create_user(
UserId=user.username,
UserName=user.username,
Passwords=[user.password],
AccessString=redis_acl,
Engine="redis",
)
created_user_ids.append(user_result["UserId"])
try:
group = self.elasticache.describe_user_groups(UserGroupId=user_group_id)["UserGroups"][0]
member_user_ids: typing.List[str] = group["UserIds"]
users_to_add = set(created_user_ids) - set(member_user_ids)
if users_to_add:
self.elasticache.modify_user_group(
UserGroupId=user_group_id,
UserIdsToAdd=list(users_to_add),
)
# this will take some time (~45s) until the changes propagate
except self.elasticache.exceptions.UserGroupNotFoundFault:
logger.info('User group does not exist, creating')
_ = self.elasticache.create_user_group(
UserGroupId=user_group_id,
Engine="redis",
UserIds=created_user_ids,
)
# user group creation takes around 60s
if wait:
self._wait_user_group(user_group_id)
@retry(timeout=60, backoff_seconds=5)
def _wait_user_group(self, user_group_id: str) -> None:
logger.info(f'Checking status of user group {user_group_id=}')
g = self.elasticache.describe_user_groups(UserGroupId=user_group_id)["UserGroups"][0]
assert g["Status"] == "active"
def is_redis_acl_ready(self, replication_group_id: str) -> bool:
repl = self.elasticache.describe_replication_groups(ReplicationGroupId=replication_group_id)[
'ReplicationGroups'
][0]
is_redis_available = repl['Status'] == 'available'
user_group_id = repl['UserGroupIds'][0]
group = self.elasticache.describe_user_groups(UserGroupId=user_group_id)['UserGroups'][0]
is_group_available = group['Status'] == 'active'
return is_group_available and is_redis_available
def get_redis_acl(self, replication_group_id: str) -> dict:
redis = self.elasticache.describe_replication_groups(ReplicationGroupId=replication_group_id)[
'ReplicationGroups'
][0]
return dict(
replication_group_id=redis['ReplicationGroupId'],
arn=redis['ARN'],
host=redis["NodeGroups"][0]["PrimaryEndpoint"]["Address"],
port=redis["NodeGroups"][0]["PrimaryEndpoint"]["Port"],
)
def delete_redis_acl(self, replication_group_id: str) -> None:
self.elasticache.delete_replication_group(ReplicationGroupId=replication_group_id)
def _ensure_cache_parameter_group(self, parameter_group_name: str) -> None:
try:
_ = self.elasticache.describe_cache_parameter_groups(CacheParameterGroupName=parameter_group_name)[
'CacheParameterGroups'
][0]
return
except self.elasticache.exceptions.CacheParameterGroupNotFoundFault:
pass
logger.info(f"Creating cache parameter group {parameter_group_name=}")
_ = self.elasticache.create_cache_parameter_group(
CacheParameterGroupName=parameter_group_name,
CacheParameterGroupFamily="redis6.x",
Description="Redis parameter group for the Redis ACL",
)
_ = self.elasticache.modify_cache_parameter_group(
CacheParameterGroupName=parameter_group_name,
ParameterNameValues=[
{
"ParameterName": "databases",
"ParameterValue": "100",
},
],
)
def akinon_create_redis(
*,
k8s_cluster_name: str,
region: str,
node_type: str,
replicas: int,
owner_arn: str,
app_name: str,
role: str,
):
subnet_group_name = f"{k8s_cluster_name}-redis-subg"
security_group_name = f"{k8s_cluster_name}-redis-sg"
# owner arn is formatted like:
# arn:aws:iam::412344683105:user/myusername
username = owner_arn.split("/")[1]
tags = {
"CostCenter": f"{username}-{app_name}-{role}-redis",
}
cache_cluster_id = uuid.uuid4().hex
r = RedisService(region)
return r.create_redis(
cluster_id=cache_cluster_id,
node_type=node_type,
replicas=replicas,
security_group_name=security_group_name,
subnet_group_name=subnet_group_name,
tags=tags,
)
def akinon_create_redis_with_acl(
k8s_cluster_name: str,
owner_arn: str,
node_type: str,
app_name: str,
role: str,
users: typing.List[RedisUser],
) -> RedisInstance:
cache_cluster_id = uuid.uuid4().hex
subnet_group_name = f"{k8s_cluster_name}-redis-subg"
security_group_name = f"{k8s_cluster_name}-redis-sg"
# owner arn is formatted as:
# arn:aws:iam::412344683105:user/myusername
username = owner_arn.split("/")[1]
tags = {
"CostCenter": f"{username}-{app_name}-{role}-redis",
}
return r.create_redis_acl(
cluster_id=cache_cluster_id,
node_type=node_type,
subnet_group_name=subnet_group_name,
security_group_name=security_group_name,
tags=tags,
users=users,
)
def chunk(it, size):
it = iter(it)
sentinel = ()
return iter(lambda: tuple(itertools.islice(it, size)), sentinel)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format=f"%(asctime)s: {logging.BASIC_FORMAT}")
r = RedisService(region="eu-central-1")
# res = r.create_redis_acl(
# replication_group_id="zerodev3",
# description="zero redis dev",
# node_type="cache.t3.micro",
# cache_subnet_group_name="dev",
# security_group_id="sg-c11a77ac",
# tags={"CostCenter": "zero123"},
# users=[RedisUser(username="u1", password="zeropassword1234", redis_key_prefix="zero:")],
# )
# print(res)
r.create_redis_acl_user(
replication_group_id='zerodev3',
users=[
RedisUser(
username='u2',
password='testtesttesttest123',
redis_key_prefix='test:',
)
],
)
# r.delete_redis_acl_user('test6')
while True:
if r.is_redis_acl_ready('zerodev3'):
logger.info('ready')
print(r.get_redis_acl('zerodev3'))
break
logger.info('not ready')
time.sleep(10)
exit()
res = r.create_redis_acl(
replication_group_id="zerodev",
description="zero redis dev",
node_type="cache.t3.micro",
cache_subnet_group_name="dev",
security_group_id="sg-c11a77ac",
tags={"CostCenter": "zero123"},
users=[RedisUser(username="zerouser", password="zeropassword1234", redis_key_prefix="zero:")],
)
print(res)
#
# redis = r.get_redis_acl(cluster_arn="arn:aws:elasticache:eu-central-1:400344683105:replicationgroup:zero")
# r.add_redis_user(
# cluster_id=redis.cluster_id,
# users=[RedisUser(username="zero27", password="zero27password123", redis_key_prefix="zero27:")],
# )
# print(r.add_redis_user(cluster_id="zero1", users=[RedisUser("zero26", "zero26password123", "zero26:")]))
#
# print(
# r.create_redis_with_acl(
# cluster_id="zero",
# cluster_description="zero redis",
# node_type="cache.t3.micro",
# subnet_group_name="testing-subnet",
# security_group_name="default",
# tags={"CostCenter": "zero123"},
# users=[RedisUser(username="zerouser", password="zeropassword1234", redis_key_prefix="zero:")],
# )
# )
+238
View File
@@ -0,0 +1,238 @@
import dataclasses
import datetime
import functools
import json
import logging
import time
import typing
import uuid
import boto3
import botocore
import requests
from botocore.client import BaseClient
from botocore.errorfactory import BaseClientExceptions
from botocore.exceptions import ClientError
logger = logging.getLogger(__name__)
# fmt: off
_response_describe_elasticsearch_domain_success = {'ResponseMetadata': {'RequestId': '38d7b555-2699-4868-9139-c47b1c2d70db', 'HTTPStatusCode': 200, 'HTTPHeaders': {'x-amzn-requestid': '38d7b555-2699-4868-9139-c47b1c2d70db', 'content-type': 'application/json', 'content-length': '2314', 'date': 'Tue, 11 Jan 2022 07:52:24 GMT'}, 'RetryAttempts': 0}, 'DomainStatus': {'DomainId': '400344683105/es-d8a24850a0ae4b82af26', 'DomainName': 'es-d8a24850a0ae4b82af26', 'ARN': 'arn:aws:es:eu-central-1:400344683105:domain/es-d8a24850a0ae4b82af26', 'Created': True, 'Deleted': False, 'Endpoints': {'vpc': 'vpc-es-d8a24850a0ae4b82af26-gcw46l5kiiy6oylbhpqpr7s454.eu-central-1.es.amazonaws.com'}, 'Processing': False, 'UpgradeProcessing': False, 'ElasticsearchVersion': '7.8', 'ElasticsearchClusterConfig': {'InstanceType': 't2.medium.elasticsearch', 'InstanceCount': 2, 'DedicatedMasterEnabled': False, 'ZoneAwarenessEnabled': True, 'ZoneAwarenessConfig': {'AvailabilityZoneCount': 2}, 'WarmEnabled': False, 'ColdStorageOptions': {'Enabled': False}}, 'EBSOptions': {'EBSEnabled': True, 'VolumeType': 'gp2', 'VolumeSize': 10}, 'AccessPolicies': '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"es:*","Resource":"*"}]}', 'SnapshotOptions': {'AutomatedSnapshotStartHour': 0}, 'VPCOptions': {'VPCId': 'vpc-8c28b5e7', 'SubnetIds': ['subnet-004909d95a03c5fcd', 'subnet-b8f596f5'], 'AvailabilityZones': ['eu-central-1a', 'eu-central-1c'], 'SecurityGroupIds': ['sg-c11a77ac']}, 'CognitoOptions': {'Enabled': False}, 'EncryptionAtRestOptions': {'Enabled': False}, 'NodeToNodeEncryptionOptions': {'Enabled': False}, 'AdvancedOptions': {'override_main_response_version': 'false', 'rest.action.multi.allow_explicit_index': 'true'}, 'ServiceSoftwareOptions': {'CurrentVersion': 'R20211203-P2', 'NewVersion': '', 'UpdateAvailable': False, 'Cancellable': False, 'UpdateStatus': 'COMPLETED', 'Description': 'There is no software update available for this domain.', 'AutomatedUpdateDate': datetime.datetime(2021, 12, 14, 3, 38, 38), 'OptionalDeployment': False}, 'DomainEndpointOptions': {'EnforceHTTPS': True, 'TLSSecurityPolicy': 'Policy-Min-TLS-1-0-2019-07', 'CustomEndpointEnabled': False}, 'AdvancedSecurityOptions': {'Enabled': False, 'InternalUserDatabaseEnabled': False}, 'AutoTuneOptions': {'State': 'ENABLE_IN_PROGRESS'}}}
_response_list_domain_names_success = {'ResponseMetadata': {'RequestId': '25923514-a426-4223-939a-4436f1d6e243', 'HTTPStatusCode': 200, 'HTTPHeaders': {'x-amzn-requestid': '25923514-a426-4223-939a-4436f1d6e243', 'content-type': 'application/json', 'content-length': '87', 'date': 'Tue, 11 Jan 2022 07:53:37 GMT'}, 'RetryAttempts': 0}, 'DomainNames': [{'DomainName': 'es-d8a24850a0ae4b82af26', 'EngineType': 'Elasticsearch'}]}
_response_describe_elasticsearch_domains_success = {'ResponseMetadata': {'RequestId': '61cfe5f4-42e7-4233-bca9-53f0ca62099d', 'HTTPStatusCode': 200, 'HTTPHeaders': {'x-amzn-requestid': '61cfe5f4-42e7-4233-bca9-53f0ca62099d', 'content-type': 'application/json', 'content-length': '2320', 'date': 'Tue, 11 Jan 2022 07:55:25 GMT'}, 'RetryAttempts': 0}, 'DomainStatusList': [{'DomainId': '400344683105/es-d8a24850a0ae4b82af26', 'DomainName': 'es-d8a24850a0ae4b82af26', 'ARN': 'arn:aws:es:eu-central-1:400344683105:domain/es-d8a24850a0ae4b82af26', 'Created': True, 'Deleted': False, 'Endpoints': {'vpc': 'vpc-es-d8a24850a0ae4b82af26-gcw46l5kiiy6oylbhpqpr7s454.eu-central-1.es.amazonaws.com'}, 'Processing': False, 'UpgradeProcessing': False, 'ElasticsearchVersion': '7.8', 'ElasticsearchClusterConfig': {'InstanceType': 't2.medium.elasticsearch', 'InstanceCount': 2, 'DedicatedMasterEnabled': False, 'ZoneAwarenessEnabled': True, 'ZoneAwarenessConfig': {'AvailabilityZoneCount': 2}, 'WarmEnabled': False, 'ColdStorageOptions': {'Enabled': False}}, 'EBSOptions': {'EBSEnabled': True, 'VolumeType': 'gp2', 'VolumeSize': 10}, 'AccessPolicies': '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"es:*","Resource":"*"}]}', 'SnapshotOptions': {'AutomatedSnapshotStartHour': 0}, 'VPCOptions': {'VPCId': 'vpc-8c28b5e7', 'SubnetIds': ['subnet-004909d95a03c5fcd', 'subnet-b8f596f5'], 'AvailabilityZones': ['eu-central-1a', 'eu-central-1c'], 'SecurityGroupIds': ['sg-c11a77ac']}, 'CognitoOptions': {'Enabled': False}, 'EncryptionAtRestOptions': {'Enabled': False}, 'NodeToNodeEncryptionOptions': {'Enabled': False}, 'AdvancedOptions': {'override_main_response_version': 'false', 'rest.action.multi.allow_explicit_index': 'true'}, 'ServiceSoftwareOptions': {'CurrentVersion': 'R20211203-P2', 'NewVersion': '', 'UpdateAvailable': False, 'Cancellable': False, 'UpdateStatus': 'COMPLETED', 'Description': 'There is no software update available for this domain.', 'AutomatedUpdateDate': datetime.datetime(2021, 12, 14, 3, 38, 38), 'OptionalDeployment': False}, 'DomainEndpointOptions': {'EnforceHTTPS': True, 'TLSSecurityPolicy': 'Policy-Min-TLS-1-0-2019-07', 'CustomEndpointEnabled': False}, 'AdvancedSecurityOptions': {'Enabled': False, 'InternalUserDatabaseEnabled': False}, 'AutoTuneOptions': {'State': 'ENABLE_IN_PROGRESS'}}]}
_response_delete_elasticsearch_domain_not_found = ClientError({'Error': {'Message': 'Domain not found: asd', 'Code': 'ResourceNotFoundException'}, 'ResponseMetadata': {'RequestId': 'd0669560-77f6-424c-bbc6-fb5878daa494', 'HTTPStatusCode': 409, 'HTTPHeaders': {'x-amzn-requestid': 'd0669560-77f6-424c-bbc6-fb5878daa494', 'x-amzn-errortype': 'ResourceNotFoundException', 'content-type': 'application/json', 'content-length': '35', 'date': 'Tue, 11 Jan 2022 08:06:07 GMT'}, 'RetryAttempts': 0}}, 'DeleteElasticsearchDomain')
_response_delete_elasticsearch_domain_success = {'ResponseMetadata': {'RequestId': '1e9046f4-c4ee-4fda-8dfe-dd23f0161b7e', 'HTTPStatusCode': 200, 'HTTPHeaders': {'x-amzn-requestid': '1e9046f4-c4ee-4fda-8dfe-dd23f0161b7e', 'content-type': 'application/json', 'content-length': '2312', 'date': 'Tue, 11 Jan 2022 08:31:33 GMT'}, 'RetryAttempts': 0}, 'DomainStatus': {'DomainId': '400344683105/es-d8a24850a0ae4b82af26', 'DomainName': 'es-d8a24850a0ae4b82af26', 'ARN': 'arn:aws:es:eu-central-1:400344683105:domain/es-d8a24850a0ae4b82af26', 'Created': True, 'Deleted': True, 'Endpoints': {'vpc': 'vpc-es-d8a24850a0ae4b82af26-gcw46l5kiiy6oylbhpqpr7s454.eu-central-1.es.amazonaws.com'}, 'Processing': True, 'UpgradeProcessing': False, 'ElasticsearchVersion': '7.8', 'ElasticsearchClusterConfig': {'InstanceType': 't2.medium.elasticsearch', 'InstanceCount': 2, 'DedicatedMasterEnabled': False, 'ZoneAwarenessEnabled': True, 'ZoneAwarenessConfig': {'AvailabilityZoneCount': 2}, 'WarmEnabled': False, 'ColdStorageOptions': {'Enabled': False}}, 'EBSOptions': {'EBSEnabled': True, 'VolumeType': 'gp2', 'VolumeSize': 10}, 'AccessPolicies': '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"es:*","Resource":"*"}]}', 'SnapshotOptions': {'AutomatedSnapshotStartHour': 0}, 'VPCOptions': {'VPCId': 'vpc-8c28b5e7', 'SubnetIds': ['subnet-004909d95a03c5fcd', 'subnet-b8f596f5'], 'AvailabilityZones': ['eu-central-1a', 'eu-central-1c'], 'SecurityGroupIds': ['sg-c11a77ac']}, 'CognitoOptions': {'Enabled': False}, 'EncryptionAtRestOptions': {'Enabled': False}, 'NodeToNodeEncryptionOptions': {'Enabled': False}, 'AdvancedOptions': {'override_main_response_version': 'false', 'rest.action.multi.allow_explicit_index': 'true'}, 'ServiceSoftwareOptions': {'CurrentVersion': 'R20211203-P2', 'NewVersion': '', 'UpdateAvailable': False, 'Cancellable': False, 'UpdateStatus': 'COMPLETED', 'Description': 'There is no software update available for this domain.', 'AutomatedUpdateDate': datetime.datetime(2021, 12, 14, 3, 38, 38), 'OptionalDeployment': False}, 'DomainEndpointOptions': {'EnforceHTTPS': True, 'TLSSecurityPolicy': 'Policy-Min-TLS-1-0-2019-07', 'CustomEndpointEnabled': False}, 'AdvancedSecurityOptions': {'Enabled': False, 'InternalUserDatabaseEnabled': False}, 'AutoTuneOptions': {'State': 'ENABLE_IN_PROGRESS'}}}
# fmt: on
def retry(timeout: int, backoff_seconds: int = 10, bubble_errors: typing.List[typing.Type[Exception]] = None):
"""
Tries calling a function {timeout} seconds until it succeeds or gives up and throw an error
:param timeout: Timeout in seconds
:param backoff_seconds: Wait time between retries
:param bubble_errors: List of exceptions to bubble up
:return: Wrapped function
"""
bubble_errors = tuple(bubble_errors or [])
def wrapped(func: typing.Callable):
@functools.wraps(func)
def func_retrier(*args, **kwargs):
waited = 0
started_at = datetime.datetime.now()
while True:
try:
logger.debug(f"Calling {func}")
result = func(*args, **kwargs)
logger.debug(f"{func} completed in {(datetime.datetime.now() - started_at)}")
return result
except KeyboardInterrupt:
raise
except tuple(bubble_errors):
raise
except Exception as e:
doze = min(timeout - waited, backoff_seconds)
times = (timeout - waited) // backoff_seconds
logger.debug(f"{func} failed, will try {times} times in {doze} sec", exc_info=True)
time.sleep(doze)
waited += backoff_seconds
if waited >= timeout:
raise TimeoutError(f"Couldn't get a successful result from {func} in {timeout} seconds") from e
return func_retrier
return wrapped
@dataclasses.dataclass
class ElasticsearchInstance:
domain_name: str
endpoint: str
arn: str
class ElasticsearchService:
domain_name_format = "es-{}"
default_version = "7.8"
valid_versions = {"7.10", "7.9", "7.8", "7.7", "7.4", "7.1", "6.8", "6.7", "6.5", "6.4", "6.3", "6.2", "6.0", "5.6", "5.5"} # fmt: skip
def __init__(self, region: str):
self.es = boto3.client("es", region_name=region)
def create_instance(
self,
node_type: str,
total_nodes: int,
subnet_group_ids: typing.List[str],
security_group_id: str,
version: str = None,
tags: dict = None,
volume_size_gb: int = 30,
total_availability_zones: int = 3,
) -> ElasticsearchInstance:
if not version:
version = self.default_version
assert version in self.valid_versions, "Invalid version"
domain_name = self.domain_name_format.format(uuid.uuid4().hex[:20])
logger.info(
"Creating a new Elasticsearch domain",
extra=dict(version=version, domain_name=domain_name, node_type=node_type, total_nodes=total_nodes),
)
es_result = self.es.create_elasticsearch_domain(
DomainName=domain_name,
ElasticsearchVersion=version,
ElasticsearchClusterConfig={
"InstanceType": node_type,
"InstanceCount": total_nodes,
"ZoneAwarenessEnabled": True,
"ZoneAwarenessConfig": {
"AvailabilityZoneCount": total_availability_zones,
},
},
VPCOptions={
"SubnetIds": subnet_group_ids,
"SecurityGroupIds": [security_group_id],
},
EBSOptions={
"EBSEnabled": True,
"VolumeType": "gp2",
"VolumeSize": volume_size_gb,
},
AccessPolicies=json.dumps(
{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Principal": "*", "Action": "es:*", "Resource": "*"}],
}
),
DomainEndpointOptions={"EnforceHTTPS": True},
TagList=[{"Key": k, "Value": v} for k, v in (tags or {}).items()],
)
logger.info("Waiting until ES domain becomes available. This will take a while (~15 min)")
return self._find_instance(domain_name)
@retry(timeout=20 * 60, backoff_seconds=30)
def _find_instance(self, domain_name: str) -> ElasticsearchInstance:
result = self.es.describe_elasticsearch_domain(DomainName=domain_name)["DomainStatus"]
return ElasticsearchInstance(endpoint=result["Endpoints"]["vpc"], arn=result["ARN"], domain_name=domain_name)
def find_domain_name_by_arn(self, arn: str):
res = self.es.list_domain_names(EngineType='Elasticsearch')
domain_names = [it['DomainName'] for it in res['DomainNames']]
res = self.es.describe_elasticsearch_domains(DomainNames=domain_names)
domain_name_by_arn = {it['ARN']: it['DomainName'] for it in res['DomainStatusList']}
print(res)
def main():
username, password = 'bgNNs4EWPTsuQJ24', '3asinFjikT5MgwRz!'
"""
GET _search
{
"query": {
"match_all": {}
}
}
###
PUT /my-index
PUT /my-index2
PUT /my-index3
PUT /my-index3-4
###
DELETE /my-index*?expand_wildcards=all
###
DELETE /my-index1
###
GET /my-index
GET /_stats/indexing
###
GET /_cat/indices?v
###
GET /kibana_sample_data_ecommerce/
###
GET /_all/_mapping
## delete multiple
PUT /my-index
PUT /my-index2
DELETE /my-index,my-index2
GET /my-index
###
GET /_aliases
###
GET /_stats/
user: qdSmWKS8p3VrUyBC
pwd: un6f2vTrRjoa8orn!
"""
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format=f"%(asctime)s: {logging.BASIC_FORMAT}")
logging.getLogger("botocore").setLevel(logging.INFO)
# ElasticsearchService('eu-central-1')
e = ElasticsearchService("eu-central-1")
esi = ElasticsearchInstance(
endpoint='vpc-es-d8a24850a0ae4b82af26-gcw46l5kiiy6oylbhpqpr7s454.eu-central-1.es.amazonaws.com',
arn='arn:aws:es:eu-central-1:400344683105:domain/es-d8a24850a0ae4b82af26',
domain_name='es-d8a24850a0ae4b82af26',
)
# e.find_instance_by_arn(esi.arn)
# exit()
try:
es = boto3.client('es')
res = es.delete_elasticsearch_domain(DomainName=esi.domain_name)
print(res)
except ClientError as e:
error_code = e.__class__.__name__
print(error_code)
e = ElasticsearchService("eu-central-1")
# print(
# e.create_instance(
# node_type="t2.medium.elasticsearch",
# total_nodes=2,
# volume_size_gb=10,
# total_availability_zones=2,
# security_group_id="sg-c11a77ac",
# subnet_group_ids=["subnet-b8f596f5", "subnet-004909d95a03c5fcd"],
# tags={"CostCenter": "my"},
# )
# )
+401
View File
@@ -0,0 +1,401 @@
import dataclasses
import datetime
import functools
import logging
import time
import typing
import uuid
import boto3
# import mysql.connector.connection
import psycopg2
import pymysql
from psycopg2 import sql
import psycopg2.extensions
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class Credentials:
username: str
password: str
@classmethod
def new(cls):
username_format = "u{}" # must start with a letter
return cls(
username=username_format.format(uuid.uuid4().hex),
password=uuid.uuid4().hex,
) # make sure it starts with a letter
@dataclasses.dataclass
class DbConnection:
endpoint: str
port: int
@dataclasses.dataclass
class DbInstance(DbConnection):
cluster_id: str
arn: str
reader_endpoint: typing.Optional[str] = None
database_name: typing.Optional[str] = None
user: typing.Optional[Credentials] = None
master_user: typing.Optional[Credentials] = None
def retry(timeout: int, backoff_seconds: int = 10, bubble_errors: typing.List[typing.Type[Exception]] = None):
"""
Tries calling a function {timeout} seconds until it succeeds or gives up and throw an error
:param timeout: Timeout in seconds
:param backoff_seconds: Wait time between retries
:param bubble_errors: List of exceptions to bubble up
:return: Wrapped function
"""
bubble_errors = tuple(bubble_errors or [])
def wrapped(func: typing.Callable):
@functools.wraps(func)
def func_retrier(*args, **kwargs):
waited = 0
started_at = datetime.datetime.now()
while True:
try:
logger.debug(f"Calling {func}")
result = func(*args, **kwargs)
logger.debug(f"{func} completed in {(datetime.datetime.now() - started_at)}")
return result
except KeyboardInterrupt:
raise
except tuple(bubble_errors):
raise
except Exception as e:
doze = min(timeout - waited, backoff_seconds)
times = (timeout - waited) // backoff_seconds
logger.debug(f"{func} failed, will try {times} times in {doze} sec", exc_info=True)
time.sleep(doze)
waited += backoff_seconds
if waited >= timeout:
raise TimeoutError(f"Couldn't get a successful result from {func} in {timeout} seconds") from e
return func_retrier
return wrapped
def paginate_by_marker(
func: callable,
list_field: str,
) -> typing.Iterable:
"""
Simplifies paginating over AWS results with a simpler interface than Paginators.
:param func: A function that accepts a parameter named `Marker`
:param list_field: Result field to iterate on
"""
marker = ""
while True:
result = func(Marker=marker)
items = result.get(list_field, [])
yield from items
if "Marker" not in result:
break
marker = result["Marker"]
class PostgresqlService:
db_type = "Postgresql"
db_engine = "aurora-postgresql"
db_engine_version = "10.11"
default_master_database_name = "postgres"
cluster_id_format = "pg-{}" # must start with a letter
tenant_database_name_format = "db{}" # must start with a letter
def __init__(self, region: str):
self.rds = boto3.client("rds", region_name=region)
def create_db(
self,
total_instances: int,
db_subnet_group_name: str,
node_type: str,
security_group_id: str,
engine_version: str = None,
backup_retention_days: int = 30,
public: bool = False,
tags: dict = None,
):
if not engine_version:
engine_version = self.db_engine_version
cluster_id = self.cluster_id_format.format(uuid.uuid4().hex)
master_creds = Credentials.new()
logger.info(
f"Creating {self.db_type} cluster",
extra=dict(
cluster_id=cluster_id,
engine_version=engine_version,
db_subnet_group_name=db_subnet_group_name,
security_group_id=security_group_id,
),
)
db_result = self.rds.create_db_cluster(
DBClusterIdentifier=cluster_id,
Engine=self.db_engine,
EngineVersion=engine_version,
MasterUsername=master_creds.username,
MasterUserPassword=master_creds.password,
Tags=[{"Key": k, "Value": v} for k, v in (tags or {}).items()],
DBSubnetGroupName=db_subnet_group_name,
VpcSecurityGroupIds=[security_group_id],
BackupRetentionPeriod=backup_retention_days,
)["DBCluster"]
endpoint = db_result["Endpoint"]
reader_endpoint = db_result["ReaderEndpoint"]
port = db_result["Port"]
arn = db_result["DBClusterArn"]
for i in range(total_instances):
instance_id = f"{cluster_id}-Instance-{i}"
logger.info(
"Creating instances on the cluster",
extra=dict(
cluster_id=cluster_id,
instance_id=instance_id,
node_type=node_type,
),
)
instance_result = self.rds.create_db_instance(
DBInstanceIdentifier=instance_id,
DBClusterIdentifier=cluster_id,
DBInstanceClass=node_type,
Engine=self.db_engine,
PubliclyAccessible=public,
Tags=[{"Key": k, "Value": v} for k, v in (tags or {}).items()],
)
tenant_database_name = self.tenant_database_name_format.format(uuid.uuid4().hex)
db = DbInstance(
cluster_id=cluster_id,
arn=arn,
endpoint=endpoint,
reader_endpoint=reader_endpoint,
port=port,
database_name=tenant_database_name,
master_user=master_creds,
)
print(db) # TODO: remove
logger.info("Waiting until DB instances are online. This will take a while (~5 min)")
logger.info("Connecting DB instance using master credentials", extra=dict(endpoint=db.endpoint))
self._check_connection(
connection=db,
credentials=master_creds,
)
tenant_creds = Credentials.new()
logger.info(
"Connection successful. Creating a new database and user",
extra=dict(
endpoint=endpoint,
username=tenant_creds.username,
database_name=tenant_database_name,
),
)
self.create_tenant(
connection=db,
master_user=master_creds,
database_name=tenant_database_name,
tenant_user=tenant_creds,
)
db.user = tenant_creds
return db
@retry(timeout=10 * 60)
def _check_connection(self, connection: DbConnection, credentials: Credentials, database_name: str = "postgres"):
psycopg2.connect(
host=connection.endpoint,
port=connection.port,
dbname=database_name,
user=credentials.username,
password=credentials.password,
connect_timeout=15,
).close()
def _find_instance(self, endpoint: str) -> typing.Optional[DbInstance]:
func = functools.partial(
self.rds.describe_db_clusters, Filters=[{"Name": "engine", "Values": [self.db_engine]}], MaxRecords=100
)
cluster = None
for it in paginate_by_marker(func, "DBClusters"):
if it["Endpoint"] == endpoint:
cluster = it
break
if cluster:
return DbInstance(
endpoint=cluster["Endpoint"],
port=cluster["Port"],
cluster_id=cluster["DBClusterIdentifier"],
arn=cluster["DBClusterArn"],
reader_endpoint=cluster["ReaderEndpoint"],
)
return None
def create_tenant(
self,
connection: DbConnection,
master_user: Credentials,
tenant_user: Credentials,
database_name: typing.Optional[str] = None,
) -> DbInstance:
if not database_name:
database_name = self.tenant_database_name_format.format(uuid.uuid4().hex)
try:
con = psycopg2.connect(
host=connection.endpoint,
port=connection.port,
dbname=self.default_master_database_name,
user=master_user.username,
password=master_user.password,
connect_timeout=30,
)
con.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
cur = con.cursor()
cur.execute(
sql.SQL("CREATE DATABASE {}").format(
sql.Identifier(database_name),
),
)
cur.execute(
sql.SQL("CREATE USER {} WITH ENCRYPTED PASSWORD {}").format(
sql.Identifier(tenant_user.username),
sql.Placeholder(),
),
[tenant_user.password],
)
cur.execute(
sql.SQL("GRANT ALL PRIVILEGES ON DATABASE {} TO {}").format(
sql.Identifier(database_name),
sql.Identifier(tenant_user.username),
)
)
# `with` block would create an implicit transaction
con.close()
db = self._find_instance(connection.endpoint)
db.database_name = database_name
db.user = tenant_user
db.master_user = master_user
return db
except psycopg2.Error:
logging.error(
f"Failed to create database and user",
exc_info=True,
extra=dict(endpoint=connection.endpoint, database_name=database_name),
)
raise
class MysqlService(PostgresqlService):
db_type = "MySQL"
db_engine = "aurora-mysql"
db_engine_version = "5.7.12"
default_master_database_name = "mysql"
cluster_id_format = "mysql-{}" # must start with a letter
@retry(timeout=10 * 60)
def _check_connection(self, connection: DbConnection, credentials: Credentials, database_name: str = "postgres"):
pymysql.connect(
host=connection.endpoint,
port=connection.port,
user=credentials.username,
password=credentials.password,
connect_timeout=15,
).close()
def create_tenant(
self,
connection: DbConnection,
master_user: Credentials,
tenant_user: Credentials,
database_name: typing.Optional[str] = None,
) -> DbInstance:
if not database_name:
database_name = self.tenant_database_name_format.format(uuid.uuid4().hex)
try:
with pymysql.connect(
host=connection.endpoint,
port=connection.port,
user=master_user.username,
password=master_user.password,
connect_timeout=15,
) as con:
cur = con.cursor()
cur.execute(f"CREATE DATABASE {database_name}")
cur.execute(f"CREATE USER {tenant_user.username} IDENTIFIED BY '{tenant_user.password}'")
cur.execute(
f"GRANT ALL PRIVILEGES ON {database_name}.* TO {tenant_user.username}@'%' IDENTIFIED BY '{tenant_user.password}'"
)
db = self._find_instance(endpoint=connection.endpoint)
db.database_name = database_name
db.master_user = master_user
db.user = tenant_user
return db
except pymysql.Error:
logger.error(
"Failed to create database and user",
exc_info=True,
extra=dict(endpoint=connection.endpoint, database_name=database_name),
)
raise
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format=f"%(asctime)s: {logging.BASIC_FORMAT}")
logging.getLogger("botocore").setLevel(logging.INFO)
# m = MysqlService("eu-central-1")
# db = m.create_db(
# total_instances=1,
# db_subnet_group_name="mydbsubnet",
# security_group_id="sg-c11a77ac",
# node_type="db.t3.medium",
# tags={"CostCenter": "hello"},
# public=True,
# )
# print(db)
# p = PostgresqlService("eu-central-1")
# db = p.create_db(
# total_instances=1,
# db_subnet_group_name="mydbsubnet",
# security_group_id="sg-c11a77ac",
# node_type="db.t3.medium",
# tags={"CostCenter": "hello"},
# public=True,
# )
# p.create_tenant(
# connection=DbConnection(
# endpoint="pg0a085614d5eb4f34affd2a24b1fb18c4.cluster-c8v5rp0ouaey.eu-central-1.rds.amazonaws.com",
# port=5432,
# ),
# master_user=Credentials(
# username="u7d0f9621f5a5419288a4ef7ce9fd5fe", password="ca9473879b794dd3a0b0f0d2a8d4aebb"
# ),
# tenant_user=Credentials.new(),
# database_name="mydb",
# )
+107
View File
@@ -0,0 +1,107 @@
import logging
import typing
import boto3
import botocore.client
def paginate_by_marker(
func: callable,
list_field: str,
) -> typing.Iterable:
"""
Simplifies paginating over AWS results with a simpler interface than Paginators.
:param func: A function that accepts a parameter named `Marker`
:param list_field: Result field to iterate on
"""
marker = ""
while True:
result = func(Marker=marker)
items = result.get(list_field, [])
yield from items
if "Marker" not in result:
break
marker = result["Marker"]
logger = logging.getLogger(__name__)
class Rediser:
@classmethod
def new(cls):
return cls(boto3.client("elasticache", region_name='eu-central-1'))
def __init__(self, elasticache: botocore.client.BaseClient):
self.elasticache = elasticache
def _get_redis_acl(
self,
cluster_id: str = None,
status: str = "available",
) -> typing.Optional[dict]:
"""
Finds a Redis replication group by its ID
:param cluster_id: Replication group ID
:param status: Replication status. One of available/starting/modifying/deleting.
:return: RedisInstance or throws LookupError error
"""
try:
redis = self.elasticache.describe_replication_groups(ReplicationGroupId=cluster_id)["ReplicationGroups"][0]
if redis["Status"] != status:
raise Exception(f"Found redis with {cluster_id=} but its status={redis['Status']}")
except self.elasticache.exceptions.ReplicationGroupNotFoundFault:
return None
return dict(
cluster_id=redis["ReplicationGroupId"],
host=redis["NodeGroups"][0]["PrimaryEndpoint"]["Address"],
port=redis["NodeGroups"][0]["PrimaryEndpoint"]["Port"],
arn=redis["ARN"],
user_group_ids=redis["UserGroupIds"],
)
def create_redis_acl(
self,
*,
cluster_id: str,
cluster_description: typing.Optional[str] = None,
node_type: str,
subnet_group_name: str,
security_group_id: str,
):
redis = self._get_redis_acl(cluster_id=cluster_id)
if not redis:
res = self.elasticache.create_replication_group(
ReplicationGroupId=cluster_id,
ReplicationGroupDescription=cluster_description or cluster_id,
ReplicasPerNodeGroup=0,
Engine="redis",
EngineVersion="6.x",
CacheNodeType=node_type,
CacheSubnetGroupName=subnet_group_name,
TransitEncryptionEnabled=True, # must be True to use Redis ACL
UserGroupIds=[
user_group_id,
],
SecurityGroupIds=[
security_group_id,
],
Tags=[{"Key": k, "Value": v} for k, v in tags.items()],
MultiAZEnabled=False,
AutomaticFailoverEnabled=False,
)
def main():
r = Rediser.new()
res = r.create_redis_acl(cluster_id='hey', node_type='cache.t2.micro')
if __name__ == '__main__':
main()
+202
View File
@@ -0,0 +1,202 @@
import concurrent.futures
import functools
import logging
import pprint
import time
import typing
import boto3
import botocore.client
DnsRecord = typing.TypedDict(
"DnsRecord",
{
"purpose": str,
"type": str,
"name": str,
"value": str,
"priority": typing.Optional[str],
},
)
VerificationStatus = typing.Literal["Pending", "Success", "Failed", "TemporaryFailure", "NotStarted"]
logger = logging.getLogger(__name__)
def retry(tries: int = 5, delay: int = 1.5):
def wrapper(fn):
@functools.wraps(fn)
def wrapped(*args, **kwargs):
nonlocal tries
while tries:
try:
return fn(*args, **kwargs)
except Exception:
logger.exception(f'{fn} returned error, retrying {tries} more times')
time.sleep(delay)
tries -= 1
return wrapped
return wrapper
class AwsSesService:
def __init__(self, client: typing.Optional[botocore.client.BaseClient] = None, timeout: int = 5):
self.ses = client or boto3.client("ses")
self.region = self.ses._client_config.region_name
self.timeout = timeout
# @retry()
def configure_domain_identity(
self, domain: str, mail_subdomain: typing.Optional[str] = None
) -> typing.List[DnsRecord]:
"""
Creates up mail identities for sending email through a domain.
DKIM-signed messages help receiving mail servers validate that a message was not forged or altered in transit.
:param domain: domain name to set up email identity
:return: DNS records that need to be set
"""
res_id = self.ses.verify_domain_identity(Domain=domain)
id_token = res_id["VerificationToken"]
res_dkim = self.ses.verify_domain_dkim(Domain=domain)
dns_records = [
{
"purpose": "identity",
"type": "TXT",
"name": domain,
"value": id_token,
},
*[
{
"purpose": "dkim",
"type": "CNAME",
"name": f"{dkim_token}._domainkey.{domain}",
"value": f"{dkim_token}.dkim.amazonses.com",
}
for dkim_token in res_dkim["DkimTokens"]
],
]
if mail_subdomain:
more_dns = self.configure_from_domain(domain, mail_subdomain)
dns_records.extend(more_dns)
return dns_records
@retry()
def check_dkim_verification_status(self, domain: str) -> VerificationStatus:
"""
Returns the DKIM verification status for an email identity.
:param domain: domain used to set up the mail identity
:return: one of {Pending, Success, Failed, TemporaryFailure, NotStarted}
"""
res = self.ses.get_identity_dkim_attributes(Identities=[domain])
return res["DkimAttributes"][domain]["DkimVerificationStatus"]
@retry()
def check_id_verification_status(self, domain: str) -> VerificationStatus:
"""
Returns the id verification status of an email identity.
:param domain: domain used to set up the mail identity
:return: one of {Pending, Success, Failed, TemporaryFailure, NotStarted}
"""
res = self.ses.get_identity_verification_attributes(Identities=[domain])
return res["VerificationAttributes"][domain]["VerificationStatus"]
def configure_from_domain(self, domain: str, mail_subdomain: str) -> typing.List[DnsRecord]:
"""
Allows using `mail_subdomain` as FROM address when sending emails.
Messages sent through Amazon SES will be marked as originating from your domain instead of a subdomain of amazon.com.
:param domain: mail identity
:param mail_subdomain: a subdomain
:return: DNS records that need to be set
"""
_ = self.ses.set_identity_mail_from_domain(
Identity=domain,
MailFromDomain=mail_subdomain,
BehaviorOnMXFailure="UseDefaultValue", # uses $region.amazonses.com if MX records not present on the mail subdomain
)
return [
{
"purpose": "mail_from_domain",
"type": "MX",
"name": mail_subdomain,
"value": f"feedback-smtp.{self.region}.amazonses.com",
"priority": "10",
},
{
"purpose": "mail_from_domain",
"type": "TXT",
"name": mail_subdomain,
"value": f'"v=spf1 include:amazonses.com ~all"',
},
]
@retry()
def check_custom_from_domain_status(self, domain: str) -> VerificationStatus:
"""
Returns the status of custom email FROM request verification.
:param domain: domain used to set up email identity
:return: one of {Pending, Success, Failed, TemporaryFailure}
"""
res = self.ses.get_identity_mail_from_domain_attributes(Identities=[domain])
return res["MailFromDomainAttributes"][domain].get("MailFromDomainStatus", 'NotStarted')
def bulk_check_domain_verification(self, identity_domain: str, timeout: int = 5) -> dict:
tasks = {
"status_id": functools.partial(self.check_id_verification_status, identity_domain),
"status_dkim": functools.partial(self.check_dkim_verification_status, identity_domain),
"status_custom": functools.partial(self.check_custom_from_domain_status, identity_domain),
}
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
futures = [pool.submit(t) for _, t in tasks.items()]
results = dict(zip(tasks.keys(), [f.result(timeout=timeout) for f in futures]))
return {
**results,
"identity_domain": identity_domain,
}
@retry()
def bulk_configure_email_domains(
self,
identity_domain: str,
mail_subdomain: typing.Optional[str] = None,
timeout: int = 10,
) -> dict:
tasks = {
"dns": functools.partial(self.configure_domain_identity, identity_domain, mail_subdomain),
}
# send all requests in parallel
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
futures = [pool.submit(t) for _, t in tasks.items()]
results = dict(zip(tasks.keys(), [f.result(timeout=timeout) for f in futures]))
return {
**results,
"identity_domain": identity_domain,
"mail_subdomain": mail_subdomain,
}
if __name__ == "__main__":
ses = AwsSesService()
logging.basicConfig(level=logging.INFO)
# print(ses.bulk_configure_email_domains('abdus100.dev', 'mail.abdus100.dev'))
# print(ses.check_custom_from_domain_status('abdus101.dev'))
tasks = [
functools.partial(ses.bulk_check_domain_verification, d) for d in ['abdus.dev', 'abdus1.dev', 'abdus2.dev']
] * 3
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
futures = [pool.submit(t) for t in tasks]
results = [f.result(timeout=5) for f in futures]
pprint.pp(results)
+133
View File
@@ -0,0 +1,133 @@
from unittest import mock
import pytest
from aws_ses import AwsSesService
# fmt: off
_response_verify_domain_dkim = {'DkimTokens': ['token1', 'token2', 'token3'], 'ResponseMetadata': {'RequestId': '6e4da72e-3063-41da-9434-dae7ef19c78e', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 03 Jan 2022 17:27:40 GMT', 'content-type': 'text/xml', 'content-length': '469', 'connection': 'keep-alive', 'x-amzn-requestid': '6e4da72e-3063-41da-9434-dae7ef19c78e'}, 'RetryAttempts': 0}}
_response_verify_domain_identity = {'VerificationToken': 'token', 'ResponseMetadata': {'RequestId': 'b733069c-115f-4457-b6cb-14e44f29d17f', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 03 Jan 2022 17:27:12 GMT', 'content-type': 'text/xml', 'content-length': '370', 'connection': 'keep-alive', 'x-amzn-requestid': 'b733069c-115f-4457-b6cb-14e44f29d17f'}, 'RetryAttempts': 0}}
_response_get_identity_dkim_attributes = {'DkimAttributes': {'example.com': {'DkimEnabled': True, 'DkimVerificationStatus': 'Success', 'DkimTokens': ['bgobmdqs2vbxr6ygwbmg753knfu6tos7', 'm6o6w6twpmwzwe3igpxxo7qsvwp3hlgj', 'zqfz4ekdelp7m2u2phsyad3h5dsfle4v']}}, 'ResponseMetadata': {'RequestId': 'ba466be1-79bc-499b-9766-ca1acd7d1c08', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 03 Jan 2022 16:49:53 GMT', 'content-type': 'text/xml', 'content-length': '833', 'connection': 'keep-alive', 'x-amzn-requestid': 'ba466be1-79bc-499b-9766-ca1acd7d1c08'}, 'RetryAttempts': 0}}
_response_get_identity_dkim_attributes_pending = {'DkimAttributes': {'example.com': {'DkimEnabled': True, 'DkimVerificationStatus': 'Pending', 'DkimTokens': ['bgobmdqs2vbxr6ygwbmg753knfu6tos7', 'm6o6w6twpmwzwe3igpxxo7qsvwp3hlgj', 'zqfz4ekdelp7m2u2phsyad3h5dsfle4v']}}, 'ResponseMetadata': {'RequestId': 'b24732d9-01e1-4266-8b34-550a9ecf634d', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 03 Jan 2022 17:30:03 GMT', 'content-type': 'text/xml', 'content-length': '833', 'connection': 'keep-alive', 'x-amzn-requestid': 'b24732d9-01e1-4266-8b34-550a9ecf634d'}, 'RetryAttempts': 0}}
_response_get_identity_verification_attributes = {'VerificationAttributes': {'example.com': {'VerificationStatus': 'Success', 'VerificationToken': 'XzMvLQqOPdC0iJ02YTdjqWBJr7FTqhGtAsEcfpUwfoA='}}, 'ResponseMetadata': {'RequestId': '022c3e6e-a6e8-426f-bf4d-149f71ca7913', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 03 Jan 2022 16:56:19 GMT', 'content-type': 'text/xml', 'content-length': '637', 'connection': 'keep-alive', 'x-amzn-requestid': '022c3e6e-a6e8-426f-bf4d-149f71ca7913'}, 'RetryAttempts': 0}}
_response_get_identity_verification_attributes_pending = {'VerificationAttributes': {'example.com': {'VerificationStatus': 'Pending', 'VerificationToken': 'XzMvLQqOPdC0iJ02YTdjqWBJr7FTqhGtAsEcfpUwfoA='}}, 'ResponseMetadata': {'RequestId': '022c3e6e-a6e8-426f-bf4d-149f71ca7913', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 03 Jan 2022 16:56:19 GMT', 'content-type': 'text/xml', 'content-length': '637', 'connection': 'keep-alive', 'x-amzn-requestid': '022c3e6e-a6e8-426f-bf4d-149f71ca7913'}, 'RetryAttempts': 0}}
_response_set_identity_mail_from_domain = {'ResponseMetadata': {'RequestId': 'c04acd33-40be-46cd-9960-c9e51397dc48', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 03 Jan 2022 17:03:50 GMT', 'content-type': 'text/xml', 'content-length': '266', 'connection': 'keep-alive', 'x-amzn-requestid': 'c04acd33-40be-46cd-9960-c9e51397dc48'}, 'RetryAttempts': 0}}
_response_get_identity_mail_from_domain_attributes = {'MailFromDomainAttributes': {'example.com': {'MailFromDomain': 'email.abdus.dev', 'MailFromDomainStatus': 'Success', 'BehaviorOnMXFailure': 'UseDefaultValue'}}, 'ResponseMetadata': {'RequestId': '63049966-c062-47f4-8238-145bd533d880', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 03 Jan 2022 17:37:06 GMT', 'content-type': 'text/xml', 'content-length': '687', 'connection': 'keep-alive', 'x-amzn-requestid': '63049966-c062-47f4-8238-145bd533d880'}, 'RetryAttempts': 0}}
_response_get_identity_mail_from_domain_attributes_pending = {'MailFromDomainAttributes': {'example.com': {'MailFromDomain': 'email.abdus.dev', 'MailFromDomainStatus': 'Pending', 'BehaviorOnMXFailure': 'UseDefaultValue'}}, 'ResponseMetadata': {'RequestId': '63049966-c062-47f4-8238-145bd533d880', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 03 Jan 2022 17:37:06 GMT', 'content-type': 'text/xml', 'content-length': '687', 'connection': 'keep-alive', 'x-amzn-requestid': '63049966-c062-47f4-8238-145bd533d880'}, 'RetryAttempts': 0}}
# fmt: on
@pytest.fixture()
def mock_ses() -> mock.MagicMock:
with mock.patch("boto3.client") as m:
m_ses = m.return_value
yield m_ses
def test_init_domain_verification(mock_ses):
m_verify_id = mock_ses.verify_domain_identity
m_verify_dkim = mock_ses.verify_domain_dkim
m_verify_id.return_value = _response_verify_domain_identity
m_verify_dkim.return_value = _response_verify_domain_dkim
domain = "example.com"
dns = AwsSesService(mock_ses).configure_domain_identity(domain)
m_verify_id.assert_called_once_with(Domain=domain)
m_verify_dkim.assert_called_once_with(Domain=domain)
assert dns == [
{
"type": "TXT",
"name": domain,
"value": "token",
},
{
"type": "CNAME",
"name": f"token1._domainkey.{domain}",
"value": f"token1.dkim.amazonses.com",
},
{
"type": "CNAME",
"name": f"token2._domainkey.{domain}",
"value": f"token2.dkim.amazonses.com",
},
{
"type": "CNAME",
"name": f"token3._domainkey.{domain}",
"value": f"token3.dkim.amazonses.com",
},
]
def test_get_dkim_status(mock_ses):
m = mock_ses.get_identity_dkim_attributes
m.return_value = _response_get_identity_dkim_attributes_pending
domain = "example.com"
status = AwsSesService(mock_ses).check_dkim_verification_status(domain)
assert status == "Pending"
m.assert_called_once_with(Identities=[domain])
# ===
m.reset_mock()
m.return_value = _response_get_identity_dkim_attributes
status = AwsSesService(mock_ses).check_dkim_verification_status(domain)
assert status == "Success"
def test_get_id_status(mock_ses):
m = mock_ses.get_identity_verification_attributes
m.return_value = _response_get_identity_verification_attributes
domain = "example.com"
status = AwsSesService(mock_ses).check_id_verification_status(domain)
assert status == "Success"
m.assert_called_once_with(Identities=[domain])
def test_set_custom_from_domain(mock_ses):
mock_ses._client_config.region_name = "fake-region"
m = mock_ses.set_identity_mail_from_domain
m.return_value = _response_set_identity_mail_from_domain
dns = AwsSesService(mock_ses).configure_from_domain("example.com", "mail.example.com")
assert dns == [
{
"type": "MX",
"name": "mail.example.com",
"value": f"feedback-smtp.fake-region.amazonses.com",
"priority": "10",
},
{"type": "TXT", "name": "mail.example.com", "value": f'"v=spf1 include:amazonses.com ~all"'},
]
m.assert_called_once_with(
Identity="example.com",
MailFromDomain="mail.example.com",
BehaviorOnMXFailure="UseDefaultValue",
)
def test_get_mail_from_status(mock_ses):
m = mock_ses.get_identity_mail_from_domain_attributes
m.return_value = _response_get_identity_mail_from_domain_attributes_pending
domain = "example.com"
status = AwsSesService(mock_ses).check_custom_from_domain_status(domain)
assert status == "Pending"
m.assert_called_once_with(Identities=[domain])
# ===
m.reset_mock()
m.return_value = _response_get_identity_mail_from_domain_attributes
status = AwsSesService(mock_ses).check_custom_from_domain_status(domain)
assert status == "Success"
m.assert_called_once_with(Identities=[domain])