snapshot
This commit is contained in:
@@ -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="Q7K9r45M9kh8bGqU8gIslAWIA1eaNo2M7w3xOZ6j",
|
||||
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="Q7K9r45M9kh8bGqU8gIslAWIA1eaNo2M7w3xOZ6j",
|
||||
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", "Q7K9r45M9kh8bGqU8gIslAWIA1eaNo2M7w3xOZ6j")
|
||||
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)
|
||||
@@ -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:")],
|
||||
# )
|
||||
# )
|
||||
@@ -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"},
|
||||
# )
|
||||
# )
|
||||
@@ -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",
|
||||
# )
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user