Ansible — Хранить N последних директорий и артефактов

Цель:

Есть директория "/opt/application", в которую скачивается архив с приложением и распаковывается в директорию, где в качестве имени используется короткая производная от HASH коммита (8 символов). И создается символическая ссылка на данную директорию. Необходимо хранить только 3 последних версии приложения, как директорий, так и архивов, а так же не удалять директории которые называются:

  • logs
  • media

Так же удаление должно проходить используя regex, чтобы в будущем не удалить директории, которые могут быть созданы.

Playbook:

---
- hosts: all
  tasks:
    - name: Find all application directories
      find:
        paths: "/opt/application/"
        file_type: directory
        use_regex: yes
        patterns:
          - '[a-z0-9]{8,8}'
        excludes: 'logs,media'
      register: dirs

    - name: Keep only the last 3 directories
      file:
        path: "{{ item }}"
        state: absent
      with_items: "{{ (dirs.files | sort(attribute='atime', reverse=True))[3:] | map(attribute='path') | list }}"

    - name: Find application artifacts
      find:
        paths: "/opt/application/"
        file_type: file
        use_regex: yes
        patterns:
          - '^[a-z0-9]{8,8}.tar.gz$'
      register: artifacts

    - name: Keep only the last 3 artifacts
      file:
        path: "{{ item }}"
        state: absent
      with_items: "{{ (artifacts.files | sort(attribute='atime', reverse=True))[3:] | map(attribute='path') | list }}"

Python — AWS S3 хранить N последних артефактов

Данный скрипт получает список всех директорий в корзине и удаляет все объекты в каждой директории, кроме последних "N" указанных. Для запуска скрипта нужно передать два аргумента:

  • Имя корзины
  • Количество последних хранимых объектов

Пример запуска:

python3 main.py artifacts-artem-services 3

 

main.py:

import sys
import boto3

def cleanup():
    get_last_modified = lambda obj: int(obj['LastModified'].strftime('%s'))

    s3 = boto3.client('s3')
    result = s3.list_objects(Bucket=bucket, Delimiter='/')
    for dir in result.get('CommonPrefixes'):
        print('Directory: ' + str(dir['Prefix']))
        artifacts_listing = s3.list_objects_v2(Bucket = bucket, Prefix = dir.get('Prefix'))['Contents']
        artifacts_sorted = [obj['Key'] for obj in sorted(artifacts_listing, key=get_last_modified)]
        for artifact in artifacts_sorted[:-keep_last]:
            print('Deleting artifact: ' + str(artifact))
            s3.delete_object(Bucket = bucket, Key = artifact)

if sys.argv[1:] and sys.argv[2:]:
    bucket = sys.argv[1]
    keep_last = int(sys.argv[2])
    cleanup()
else:
    print("This script for cleanup old artifacts in S3 bucket")
    print("Usage  : python3 " + sys.argv[0] + " {BUCKET_NAME} " + "{NUMBER_OF_THE_LAST_KEEPING_ARTIFACTS}")
    print("Example: python3 " + sys.argv[0] + " artifacts-artem-services " + "3")

EKS — Зашифровать текущий PV (EBS Volume)

За основу был взят ответ на gitmemory

Для того, чтобы зашифровать уже созданный EBS Volume, нужно сделать из него снапшот. Затем из созданного снапшота создать диск в том же регионе, что и оригинальный, так же указать KMS ключ для шифрования.

Затем сохраняем манифест текущего PV в файл:

kubectl get pv <PV_NAME> -o yaml > /tmp/pv.yaml

 

Редактируем файл, заменив ID оригинального диска на зашифрованный.

После чего применяем изменения:

kubectl replace --cascade=false --force -f /tmp/pv.yaml

 

Предыдущая команда "застрянет" на выполнении, так ей препятствует параметр "finalizers", поэтому в соседней вкладке выполняем следующее:

kubectl edit pv <PV_NAME>

 

Находим и удаляем следующее:

  finalizers:
  - kubernetes.io/pv-protection

 

Сохраняем изменения, после чего команда в предыдущей вкладке должна отработать успешно.

 

После чего патчим PVC, к которому относится данная PV:

kubectl patch pvc <PVC_NAME> -p '{"metadata":{"finalizers": []}}' --type=merge

 

Теперь осталось только удалить под, к которому примонтирован PV и убедится, что он пересоздался с примонтированным новым PV. Так же не забываете о правах на использования KMS ключей для IAM роли, которая приатачена EKS воркерам.

Jenkins — Active Choice: PostgreSQL — Вернуть результат SELECT запроса

Для параметризованной сборки с выбором результата на основе SQL запроса, понадобится плагин Active Choices

Переходим в настройки Jenkins

 

Раздел "Управление плагинами"

 

Переходим к вкладке "Доступные" и в поиске указываем "Active Choices"

Устанавливаем его. Так же необходим плагины:

Создаем "New Item" — "Pipeline", указываем, что это будет параметризованная сборка, и добавляем параметр "Active Choices Parameter"

 

 

 

Указываем, что это "Groovy Script" и вставляем туда следующее:

import groovy.sql.Sql
import java.sql.Driver

credentialsId = 'artem-services-rds-credentials'
url = 'artem-services-rds.xxxxxxxxxxxx.eu-central-1.rds.amazonaws.com:5432/postgres'

def creds = com.cloudbees.plugins.credentials.CredentialsProvider.lookupCredentials(
  com.cloudbees.plugins.credentials.common.StandardUsernameCredentials.class, Jenkins.instance, null, null ).find{
    it.id == credentialsId
  }

def driver = Class.forName('org.postgresql.Driver').newInstance() as Driver 

def props = new Properties()
props.setProperty("user", "${creds.username}") 
props.setProperty("password", "${creds.password}")

def conn = driver.connect("jdbc:postgresql://${url}", props) 
def sql = new Sql(conn)

def artifact = []

artifact.add("Not selected")

def rows = sql.rows("select * from users").each { row ->
  artifact.add("$row.first_name")
}

return artifact

 

Где значение переменных, "credentialsId" — Jenkins Credentials ID с логином и паролем для подключения к базе данных;

"url" — строка подключения к базе (endpoint + port + db name);

 

Данный Active Choice делает SELECT запрос к таблице "users" и возвращает только значения полей "first_name", так же на первую позицию результата добавляет "Not selected".

AWS — S3 Разрешить доступ для членов организации

In order to allow read access from the S3 Bucket for all members included in the organization, the following policy must be applied to the S3 Bucket:

{
  "Version": "2012-10-17",
  "Statement": {
    "Sid": "AllowOrganizationToReadBucket",
    "Effect": "Allow",
    "Principal": "*",
    "Action": [
      "s3:GetObject",
      "s3:ListBucket"
    ],
    "Resource": [
      "arn:aws:s3:::stackset-lambdas",
      "arn:aws:s3:::stackset-lambdas/*"
    ],
    "Condition": {
      "StringEquals": {"aws:PrincipalOrgID":["o-xxxxxxxxxx"]}
    }
  }
}

 

Where "stackset-lambdas" is the S3 Bucket name and "o-xxxxxxxxxx" is your Organization ID.

Lambda — Для остановки EC2 инстансов, RDS инстансов и сдувания ASG во всех регионах

Данный Python скрипт получает список всех регионов, находит в них EC2 инстансы, RDS инстансы и ASG, и если на ресурсе нет тега "prevent_stop" равного значению "true", то останавливает данный ресурс, а в случае с ASG сжимает его до 0.

main.py:

import boto3

custom_ec2_filter = [
    {
        'Name': 'instance-state-name',
        'Values': ['running', 'pending']
    }
]

# Get list of EC2 regions
ec2 = boto3.client('ec2')
regions = [region['RegionName'] for region in ec2.describe_regions()['Regions']]

def main(event, context):
    for region in regions:
        print("Region: " + str(region))
        # ASG
        print("Searching for ASG...")
        asg = boto3.client('autoscaling', region_name = region)
        asg_list = [asg_name['AutoScalingGroupName'] for asg_name in asg.describe_auto_scaling_groups()['AutoScalingGroups']]
        for asg_name in asg_list:
            api_response = asg.describe_auto_scaling_groups(
                AutoScalingGroupNames = [
                    asg_name,
                ]
            )
            tags = (api_response["AutoScalingGroups"][0]["Tags"])
            asg_scaledown = False 
            if 'prevent_stop' not in [tag['Key'] for tag in tags]:
                asg_scaledown = True
            else:
                for tag in tags:
                    if tag["Key"] == 'prevent_stop' and tag["Value"] != 'true':
                        asg_scaledown = True
            if asg_scaledown == True:
                minSize = (api_response["AutoScalingGroups"][0]["MinSize"])
                maxSize = (api_response["AutoScalingGroups"][0]["MaxSize"])
                desiredCapacity = (api_response["AutoScalingGroups"][0]["DesiredCapacity"])
                if minSize != 0 or maxSize != 0 or desiredCapacity != 0:
                    print("Scalling down ASG: " + str(asg_name))
                    asg.update_auto_scaling_group(
                        AutoScalingGroupName = asg_name,
                        MinSize = 0,
                        MaxSize = 0,
                        DesiredCapacity = 0
                    )
        # EC2 Instances
        print("Searching for running instances...")
        ec2 = boto3.resource('ec2', region_name = region)
        instances_list = ec2.instances.filter(Filters = custom_ec2_filter)
        for instance in instances_list:
            if 'prevent_stop' not in [tag['Key'] for tag in instance.tags]:
                print("Stopping instance: " + str(instance.id))
                instance.stop()
            else:
                for tag in instance.tags:
                    if tag["Key"] == 'prevent_stop' and tag["Value"] != 'true':
                        print("Stopping instance: " + str(instance.id))
                        instance.stop()
        # RDS
        print("Searching for running RDS instances...")
        rds = boto3.client('rds', region_name = region)
        rds_list = [instance['DBInstanceIdentifier'] for instance in rds.describe_db_instances()['DBInstances']]
        for instance in rds_list:
            arn = rds.describe_db_instances(DBInstanceIdentifier = instance)['DBInstances'][0]['DBInstanceArn']
            tags = rds.list_tags_for_resource(ResourceName = arn)['TagList']
            stop_instance = False
            if 'prevent_stop' not in [tag['Key'] for tag in tags]:
                stop_instance = True
            else:
                for tag in tags:
                    if tag["Key"] == 'prevent_stop' and tag["Value"] != 'true':
                        stop_instance = True
            if stop_instance == True:
                instance_status = rds.describe_db_instances(DBInstanceIdentifier = instance)['DBInstances'][0]['DBInstanceStatus']
                if instance_status != "stopping" and instance_status != "stopped":
                    engine = rds.describe_db_instances(DBInstanceIdentifier = instance)['DBInstances'][0]['Engine']
                    print("Engine: " + str(engine))
                    if engine.startswith('aurora') == True:
                        cluster_id = rds.describe_db_instances(DBInstanceIdentifier = instance)['DBInstances'][0]['DBClusterIdentifier']
                        print("Stopping Aurora cluster: " + str(cluster_id))
                        rds.stop_db_cluster(DBClusterIdentifier = cluster_id)
                    else:
                        print("Stopping RDS instances: " + str(instance))
                        rds.stop_db_instance(DBInstanceIdentifier = instance)
        print("----------------------------------------")

if __name__ == '__main__':
    main()

 

Список нужных разрешений для запуска (помимо "AWSLambdaExecute" политики):

  • ec2:DescribeRegions
  • ec2:DescribeInstances
  • ec2:StopInstances
  • rds:ListTagsForResource
  • rds:DescribeDBInstances
  • rds:StopDBInstance
  • rds:StopDBCluster
  • autoscaling:DescribeAutoScalingGroups
  • autoscaling:UpdateAutoScalingGroup

PagerDuty — Python скрипт для создания событий

Данный Python скрипт создает события в PagerDuty используя APIv2.

За основу был взят следующий скрипт.

Для начала нужно создать "Routing Key", он же "Integration Key", не путать с "API Access Key", который можно использовать для любых API вызовов, нам же нужен только ключ от определенного сервиса.

Переходим в настройки сервиса, в моем случае он называется "AWS", и переходим к вкладке  "Integrations"

 

Добавляем новую интеграцию — "Add a new integration"

 

Задаем имя и выбираем "Integration Type" -> "Use our API directly" -> "Events API v2" и получаем "Integration Key"

main.py:

import requests
import json

from typing import Any, Dict, List, Optional

routing_key = 'abc123'
api_url = 'https://events.pagerduty.com/v2/enqueue'

def create_event(
    summary: str,
    severity: str,
    source: str = 'aws',
    action: str = 'trigger',
    dedup_key: Optional[str] = None,
    custom_details: Optional[Any] = None,
    group: Optional[str] = None,
    component: Optional[str] = None,
    class_type: Optional[str] = None,
    images: Optional[List[Any]] = None,
    links: Optional[List[Any]] = None
) -> Dict:
    payload = {
        "summary": summary,
        "severity": severity,
        "source": source,
    }
    if custom_details is not None:
        payload["custom_details"] = custom_details
    if component:
        payload["component"] = component
    if group:
        payload["group"] = group
    if class_type:
        payload["class"] = class_type

    actions = ('trigger', 'acknowledge', 'resolve')
    if action not in actions:
        raise ValueError("Event action must be one of: %s" % ', '.join(actions))
    data = {
        "event_action": action,
        "routing_key": routing_key,
        "payload": payload,
    }
    if dedup_key:
        data["dedup_key"] = dedup_key
    elif action != 'trigger':
        raise ValueError(
            "The dedup_key property is required for event_action=%s events, and it must \
            be a string."
            % action
        )
    if images is not None:
        data["images"] = images
    if links is not None:
        data["links"] = links

    response = requests.post(api_url, json = data)
    print(response.content)

def main():
    create_event(summary = "Instance is down", severity = 'critical', custom_details = "Instance ID: i-12345")
  
if __name__ == '__main__':
    main()

 

Замените значение переменной "routing_key" на значение "Integration Key"

В данном примере будет создано событие с уровнем "critical", заголовком "Instance is down" и в деталях будет указано "Instance ID: i-12345"

Так же скрипт можно использовать для создания событий на основе AWS CloudWatch Alarms, не используя AWS интеграцию в PagerDuty. Для этого создайте SNS топик, Lambda функцию и укажите SNS топик как источник для Lambda функции. После чего можно создавать CloudWatch Alarm и в качестве действия при срабатывании указываем SNS топик.

main.py (Lambda + SNS):

import requests
import json

from typing import Any, Dict, List, Optional

routing_key = 'abc123'
api_url = 'https://events.pagerduty.com/v2/enqueue'

def get_message_info():
    subject = event["Records"][0]["Sns"]["Subject"]
    message = event["Records"][0]["Sns"]["Message"]
    return subject, message

def create_event(
    summary: str,
    severity: str,
    source: str = 'cloudwatch',
    action: str = 'trigger',
    dedup_key: Optional[str] = None,
    custom_details: Optional[Any] = None,
    group: Optional[str] = None,
    component: Optional[str] = None,
    class_type: Optional[str] = None,
    images: Optional[List[Any]] = None,
    links: Optional[List[Any]] = None
) -> Dict:
    payload = {
        "summary": summary,
        "severity": severity,
        "source": source,
    }
    if custom_details is not None:
        payload["custom_details"] = custom_details
    if component:
        payload["component"] = component
    if group:
        payload["group"] = group
    if class_type:
        payload["class"] = class_type

    actions = ('trigger', 'acknowledge', 'resolve')
    if action not in actions:
        raise ValueError("Event action must be one of: %s" % ', '.join(actions))
    data = {
        "event_action": action,
        "routing_key": routing_key,
        "payload": payload,
    }
    if dedup_key:
        data["dedup_key"] = dedup_key
    elif action != 'trigger':
        raise ValueError(
            "The dedup_key property is required for event_action=%s events, and it must \
            be a string."
            % action
        )
    if images is not None:
        data["images"] = images
    if links is not None:
        data["links"] = links

    response = requests.post(api_url, json = data)
    print(response.content)

def main(event, context):
    info = get_message_info()
    create_event(summary = info[0], severity='critical', custom_details = info[1])
  
if __name__ == '__main__':
    main()

 

Python пакет "requests" придется упаковывать в zip архив с Lambda функцией, так как в Lambda его нет. Для этого можно воспользоваться "virtualenv".

AWS Transfer — Public FTP

AWS Transfer поддерживает 3 протокола: SFTP, FTP и FTPS. И только SFTP может иметь публичный эндпоинт, FTP/FTPS можно запускать только внутри VPC. Так же для авторизации по логину/паролю, необходимо использовать кастомный провайдер, больше информации об этом вы можете найти тут.

Цель:

Создать AWS Transfer сервер для протокола FTP, сервис должен быть публичным и так же авторизация должна проходить по логину/паролю.

Протокол FTP является небезопасным, AWS не рекомендует его использовать в публичных сетях.

 

Первое что понадобится, это установленный AWS SAM CLI.

Создаем директорию, куда будем скачивать темплейт, переходим в нее и скачиваем:

wget https://s3.amazonaws.com/aws-transfer-resources/custom-idp-templates/aws-transfer-custom-idp-secrets-manager-sourceip-protocol-support-apig.zip

 

Разархивируем и выполняем следующую команду:

sam deploy --guided --stack-name aws-transfer-ftp

 

Где, "aws-transfer-ftp" — имя создаваемого CloudFormation стека, если укажите имя существующего, он его обновит.

 

После чего запустится интерактивная установка, где будет предложено указать следующие параметры:

  • Stack Name — имя CloudFormation стека, по умолчанию берется параметр ключа "—stack-name";
  • AWS Region — регион, в котором будет развернут CloudFormation стек;
  • Parameter CreateServer — будет ли создан AWS Transfer сервис (по умолчанию — true);
  • Parameter SecretManagerRegion — если ваш регион не поддерживает SecretsManager, то для него вы можете указать отдельный регион;
  • Parameter TransferEndpointTypePUBLIC или VPC, так как FTP не поддерживает публичные эндпоинты, указываем VPC;
  • Parameter TransferSubnetIDsID's сетей, в которых будет AWS Transfer эндпоинт;
  • Parameter TransferVPCIDVPC ID, в которой находятся сети, указанные в предыдущем параметре.

 

 

Сразу создадим SecurityGroup для FTP сервиса в нужной VPC. И разрешим входящий траффик на TCP порты 21 и 81928200 с любого адреса. Пока сохраняем созданную SG, в дальнейшем мы ее приатачим.

После чего переходим в AWS консоль — "AWS Transfer Family", находим созданный AWS Transfer сервер и редактируем его прокол, снимает галочку с протокола "SFTP" и ставим галочку напротив "FTP" протокола и сохраняем изменения.

Теперь нужно добавить доступ к FTP из мира, для этого будем использовать NLB. Для начала узнаем приватные IP адреса VPC эндпоинтов для AWS Transfer, для этого в блоке "Endpoint details" кликнем на ссылку на VPC эндпоинт.

 

Переходим во вкладку "Subnets" и копируем все IP адреса, они понадобятся для создания таргет групп.

 

Тут же переходим во вкладку "Security Groups" и меняем дефолтную группу безопасности на созданную раннее.

Теперь создадим таргет группы, для это в AWS консоли переходим "EC2" -> "Load Balancing" -> "Target Groups" и создаем первую таргет группу для TCP 21 порта.

  • Target type: IP address
  • Protocol: TCP
  • Port: 21

В названии таргет группы в конце лучше указать номер порта, так как их будет 10 и можно легко запутаться.

Так же укажем VPC, в которой был создан сервис AWS Transfer. В следующей вкладке один за одним укажем IP адреса VPC эндпоинта, которые мы смотрели раннее. Сохраняем таргет группу.

Теперь нужно создать еще 9 таргет групп, для диапазона портов: TCP 81928200. Процедура такая же, как для таргет группы для порта 21, за исключением того, что нужно HeathCheck порт указать 21. Для этого в блоке "Health checks" открываем вкладку "Advanced health check setting", выбираем "Overrive" и указываем номер порта — 21.

После того, как закончили с таргет группами, нужно создать Network Load Balancer с типом "internet-facing" и разместить его в публичных сетях той же VPC, где и AWS Transfer сервис. Так же создаем 10 листенеров, для TCP портов 21-го, и диапазона 81928200, и для каждого листенера указываем нужную таргет группу, соответствующую номеру порта. После чего FTP сервис должен быть доступ из вне.

Для того, чтобы добавить FTP пользователя нужно в AWS консоли перейти в "Secrets Manager" и создать секрет с типом "Other type of secrets"

 

Создаем 3 пары "key/value":

  • Password — пароль для нового FTP пользователя;
  • RoleARN роли, у которой есть права записи в нужный S3 бакет;
  • HomeDirectoryDetails — [{"Entry": "/", "Target": "/s3-bucket/user-name"}]

Где "s3-bucket" — имя S3 корзины, "user-name" — имя директории, в которую будет попадать пользователь при подключении к FTP серверу (имя директории не обязательно должно соответствовать имени пользователя, и так же может находится не в корне корзины)

 

Сохраняем секрет обязательно с именем в формате: "server_id/user_name", где "server_id" — это ID сервера AWS Transfer, "user_name" — имя пользователя, которое будет использоваться для подключения к FTP серверу.

Так же для удобства можно создать DNS CNAME запись на NLB запись.