├── .env_template ├── .flake8 ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── app ├── api │ ├── __init__.py │ ├── api_v1 │ │ ├── __init__.py │ │ ├── api.py │ │ └── endpoints │ │ │ ├── __init__.py │ │ │ └── inference.py │ └── deps.py ├── config.py ├── core.py ├── decorator.py ├── feature_store │ ├── __init__.py │ ├── backends │ │ ├── __init__.py │ │ └── redis.py │ ├── core.py │ └── key_builder.py ├── logger.py ├── main.py └── schemas.py ├── data └── .gitkeep ├── deploy ├── client-sg.yml ├── cluster-fargate.yml ├── elasticcache-redis.yml ├── task-definition │ └── app-demo.yml ├── vpc-2azs.yml └── vpc-ssh-bastion.yml ├── docker-compose.yml ├── download_model.py ├── generate_torch_script.py ├── gunicorn_conf.py ├── pyproject.toml ├── scripts ├── build-push.sh ├── build.sh ├── deploy.sh ├── format.sh └── lint.sh └── visulization └── tutorial.ipynb /.env_template: -------------------------------------------------------------------------------- 1 | # APP env 2 | APP_ENV=demo 3 | TWITTER_CONSUMER_KEY= 4 | TWITTER_CONSUMER_SECRET= 5 | TWITTER_ACCESS_TOKEN_KEY= 6 | TWITTER_ACCESS_TOKEN_SECRET= 7 | FIRST_SUPERUSER= 8 | FIRST_SUPERUSER_PASSWORD= 9 | REDIS_HOST= 10 | REDIS_PORT= 11 | DOCKER_IMAGE_APP= 12 | 13 | # S3 model download 14 | BUCKET_NAME= 15 | S3_DATA_PATH= 16 | 17 | # AWS deploy env 18 | PROJECT_NAME=twitter-sentiment 19 | KEY_PAIR= 20 | AWS_DEFAULT_REGION= 21 | AWS_ACCOUNT_ID= 22 | ECR_IMAGE_PREFIX=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${PROJECT_NAME} 23 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache,.eggs,.idea,.pytest_cache,*.pyi,**/.vscode/*,**/site-packages/ 4 | ignore = E722,W503,E203 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | app.properties 2 | .idea 3 | **.DS_Store** 4 | .vscode 5 | .ipynb_checkpoints 6 | __pycache__ 7 | data/* 8 | *.bin 9 | *.db 10 | *.pickle 11 | .env 12 | *.pkl 13 | *.csv 14 | *.xlsx 15 | *.zip 16 | __MACOSX 17 | 18 | *.log 19 | .coverage 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG MODEL_ENV=copy 2 | 3 | FROM python:3.10-slim-bullseye as base_img 4 | 5 | ## Base working dir 6 | WORKDIR /app 7 | 8 | # Download model image from S3 9 | FROM base_img AS model-image-download 10 | 11 | ## Declare aws enviroment 12 | ARG APP_ENV 13 | 14 | ENV APP_ENV "$APP_ENV" 15 | 16 | ARG S3_DATA_PATH 17 | 18 | ENV S3_DATA_PATH "$S3_DATA_PATH" 19 | 20 | ARG BUCKET_NAME 21 | 22 | ENV BUCKET_NAME "$BUCKET_NAME" 23 | 24 | ARG AWS_ACCESS_KEY_ID 25 | 26 | ENV AWS_ACCESS_KEY_ID "$AWS_ACCESS_KEY_ID" 27 | 28 | ARG AWS_DEFAULT_REGION 29 | 30 | ENV AWS_DEFAULT_REGION "$AWS_DEFAULT_REGION" 31 | 32 | ARG AWS_SECRET_ACCESS_KEY 33 | 34 | ENV AWS_SECRET_ACCESS_KEY "$AWS_SECRET_ACCESS_KEY" 35 | 36 | ONBUILD COPY download_model.py ./download_model.py 37 | 38 | ONBUILD RUN pip install boto3 tqdm 39 | 40 | ONBUILD RUN python download_model.py 41 | 42 | 43 | # Copy local model image 44 | FROM base_img AS model-image-copy 45 | 46 | ONBUILD Add data ./data 47 | 48 | 49 | # Define general layer download cause the current docker is not support --from=$var 50 | # Ref: https://github.com/moby/buildkit/issues/2717 51 | FROM model-image-$MODEL_ENV AS model-image-general 52 | 53 | 54 | FROM base_img AS compile-image 55 | 56 | RUN echo "deb http://us.archive.ubuntu.com/ubuntu/ precise main universe" >> /etc/apt/source.list 57 | 58 | RUN apt-get update -qq && \ 59 | apt-get update -y && \ 60 | apt-get install curl python3-dev -y 61 | 62 | # Download rust-chain to install tokenizer 63 | RUN curl https://sh.rustup.rs -sSf | sh -s -- -y 64 | 65 | ENV PATH="/root/.cargo/bin:${PATH}" 66 | 67 | RUN python -m venv /opt/venv 68 | ## Make sure we use the virtualenv: 69 | ENV PATH="/opt/venv/bin:$PATH" 70 | 71 | ## Install Poetry 72 | RUN curl -sSL https://install.python-poetry.org | POETRY_VERSION=1.3.0 POETRY_HOME=/opt/poetry python3 && \ 73 | cd /usr/local/bin && \ 74 | ln -s /opt/poetry/bin/poetry && \ 75 | poetry config virtualenvs.create false --local && \ 76 | poetry config virtualenvs.prefer-active-python true 77 | 78 | 79 | ## Install dependency 80 | COPY pyproject.toml ./pyproject.toml 81 | RUN . /opt/venv/bin/activate && if [ $APP_ENV == 'dev' ] ; then poetry install --no-root ; else poetry install --no-root --only main ; fi 82 | 83 | 84 | # Last layer will use to serve API 85 | FROM base_img AS runtime-image 86 | 87 | COPY --from=compile-image /opt/venv /opt/venv 88 | ENV PATH="/opt/venv/bin:$PATH" 89 | 90 | COPY --from=model-image-general /app /app 91 | 92 | COPY gunicorn_conf.py ./gunicorn_conf.py 93 | 94 | ADD app ./app 95 | 96 | ENV PORT 2000 97 | ENV LOG_LEVEL info 98 | ENV TIMEOUT 120 99 | ENV MAX_REQUESTS 300 100 | CMD gunicorn -k uvicorn.workers.UvicornWorker -c gunicorn_conf.py app.main:app 101 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build-infra: 2 | . ./.env && ./scripts/deploy.sh 3 | 4 | build-local: 5 | echo "Note: set environment REDIS_HOST to redis" 6 | . ./.env && export DOCKER_IMAGE_APP=app && \ 7 | bash scripts/build.sh && \ 8 | docker compose up -d --force-recreate 9 | 10 | echo "Service is on http://localhost:2000/" 11 | 12 | delete-infra: 13 | echo "Warning: Will delete all infra in 3s" 14 | sleep 3 15 | . ./.env 16 | ./scripts/deploy.sh delete -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | For the hands-on AI project, we will use Twitter sentiment analyst to demonstrate full-stack machine learning inference. 3 | 4 | The idea of the project is to get metadata from Twitter URL and sentiment analyst content. After that, we will aggregate sentiment text from the individual user to analyze more sentiment for each user. 5 | 6 | 7 | ![ML Design](https://haicheviet.com/machine-learning-inference-on-industry-standard/ml-inference_huf62dc9e0d697df28df409b2b033d43b3_158144_2000x0_resize_q100_h2_box.webp) 8 | 9 | 10 | First, make sure you copy the following file and edit its contents: 11 | 12 | ```bash 13 | cp .env_template .env 14 | vim .env 15 | ``` 16 | 17 | ## Local setup 18 | 19 | Download model transfomer to local data 20 | ```bash 21 | pip install transformers 22 | python generate_torch_script.py 23 | cp -r twitter-roberta-base-sentiment/* data/* 24 | rm -rf twitter-roberta-base-sentiment # Remove nonuse model for faster build time 25 | ``` 26 | 27 | Set redis_host enviroment to `redis` 28 | 29 | Once you have the .env setup, run the following script to init docker-compose service: 30 | 31 | ```bash 32 | make build-local # Using sudo if your docker require sudo access 33 | ``` 34 | 35 | When done, you should have listed: 36 | 37 | ```bash 38 | Service is on http://localhost:2000/ 39 | ``` 40 | 41 | Access the swagger docs in 42 | 43 | ## Infrastructure setup 44 | 45 | Variables: 46 | 47 | * `PROJECT_NAME` Any arbitrary project name. Use 'echo' if you don't have any preference. 48 | * `AWS_DEFAULT_REGION` Your preferred AWS region 49 | * `AWS_ACCOUNT_ID` Your account ID as you see [here](https://console.aws.amazon.com/billing/home?#/account) 50 | * `KEY_PAIR` Name of Key Pair you'd like to use to setup the infrastructure. Find it [here](https://ap-northeast-1.console.aws.amazon.com/ec2/v2/home#KeyPairs) 51 | 52 | Once you have the .env setup, run the following script to initialize VPC, ECR/ECS and App Service. 53 | 54 | ```bash 55 | make build-infra 56 | ``` 57 | 58 | When done, you should have listed: 59 | 60 | ```bash 61 | Bastion endpoint: 62 | 54.65.206.60 63 | Public endpoint: 64 | http://clust-LoadB-4RPWCBUJAH83-1023823123.ap-southeast-1.elb.amazonaws.com 65 | ``` 66 | 67 | Access the above load balancer and make sure that you have output like this: 68 | 69 | ```bash 70 | $ curl -i "http://clust-LoadB-4RPWCBUJAH83-1023823123.ap-southeast-1.elb.amazonaws.com" 71 | HTTP/1.1 200 OK 72 | Date: Mon, 18 Jul 2022 03:29:51 GMT 73 | Content-Type: application/json 74 | Content-Length: 49 75 | Connection: keep-alive 76 | server: uvicorn 77 | 78 | {"statusCode":200,"body":"{\"message\": \"OK\"}"}% 79 | ``` 80 | 81 | Congrats! You're successfully done create AI service! 82 | 83 | For more service detail, go to [my blog](https://haicheviet.com) 84 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haicheviet/fullstack-machine-learning-inference/00b5d7fa99457ba785c523c5553450e8daaf281e/app/api/__init__.py -------------------------------------------------------------------------------- /app/api/api_v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haicheviet/fullstack-machine-learning-inference/00b5d7fa99457ba785c523c5553450e8daaf281e/app/api/api_v1/__init__.py -------------------------------------------------------------------------------- /app/api/api_v1/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from app.api.api_v1.endpoints import inference 4 | from app.api.deps import get_current_username 5 | 6 | api_router = APIRouter() 7 | 8 | api_router.include_router( 9 | inference.router, 10 | tags=["inference"], 11 | dependencies=[Depends(get_current_username)], 12 | ) 13 | -------------------------------------------------------------------------------- /app/api/api_v1/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haicheviet/fullstack-machine-learning-inference/00b5d7fa99457ba785c523c5553450e8daaf281e/app/api/api_v1/endpoints/__init__.py -------------------------------------------------------------------------------- /app/api/api_v1/endpoints/inference.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException 2 | from pydantic import HttpUrl 3 | from starlette.requests import Request 4 | from tweepy import API 5 | 6 | from app.api.deps import get_backend, get_twitter_api 7 | from app.core import TwitterSentiment 8 | from app.decorator import async_log_response 9 | from app.feature_store.backends import Backend 10 | from app.feature_store.core import Keys, get_cache, set_cache 11 | from app.schemas import SentimentResponse 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.get("/inference", response_model=SentimentResponse) 17 | @async_log_response 18 | async def inference( 19 | request: Request, 20 | tweetUrl: HttpUrl, 21 | background_tasks: BackgroundTasks, 22 | feature_store: Backend = Depends(get_backend), 23 | twitter_api: API = Depends(get_twitter_api), 24 | ): 25 | try: 26 | tweet = twitter_api.get_status(tweetUrl.split("/")[-1]) 27 | except Exception as e: 28 | raise HTTPException(status_code=400, detail=f"Error{type(e).__name__}: {e}") 29 | key = Keys(tweet=tweet) 30 | data = await get_cache(keys=key, feature_store=feature_store) 31 | if not data: 32 | request.app.logger.info("Prediction is not exist in feature store") 33 | twitter = TwitterSentiment(**request.app.model_params) 34 | 35 | prediction = twitter.prediction(tweet.text) 36 | if prediction: 37 | result = SentimentResponse( 38 | sentiment_analyst=prediction, text_input=tweet.text 39 | ) 40 | background_tasks.add_task(set_cache, result.dict(), key, feature_store) 41 | else: 42 | raise HTTPException(status_code=400, detail="Empty prediction") 43 | else: 44 | request.app.logger.info("Prediction hits") 45 | result = SentimentResponse(**data) 46 | 47 | return result 48 | 49 | 50 | @router.get("/clear") 51 | async def refresh(prefix: str, feature_store: Backend = Depends(get_backend)): 52 | flag_clear = await feature_store.clear(namespace=prefix) 53 | if flag_clear and any(flag_clear): 54 | return "success" 55 | return "fail" 56 | -------------------------------------------------------------------------------- /app/api/deps.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from typing import AsyncGenerator 3 | 4 | import tweepy 5 | from aioredis import create_redis_pool 6 | from fastapi import Depends, HTTPException, status 7 | from fastapi.security import HTTPBasic, HTTPBasicCredentials 8 | 9 | from app.config import settings 10 | from app.feature_store.backends.redis import RedisBackend 11 | 12 | security = HTTPBasic() 13 | 14 | 15 | def get_current_username(credentials: HTTPBasicCredentials = Depends(security)) -> str: 16 | correct_username = secrets.compare_digest( 17 | credentials.username, settings.FIRST_SUPERUSER 18 | ) 19 | correct_password = secrets.compare_digest( 20 | credentials.password, settings.FIRST_SUPERUSER_PASSWORD 21 | ) 22 | if not (correct_username and correct_password): 23 | raise HTTPException( 24 | status_code=status.HTTP_401_UNAUTHORIZED, 25 | detail="Incorrect email or password", 26 | headers={"WWW-Authenticate": "Basic"}, 27 | ) 28 | return credentials.username 29 | 30 | 31 | async def get_backend() -> AsyncGenerator: 32 | pool = await create_redis_pool((settings.REDIS_HOST, settings.REDIS_PORT)) 33 | yield RedisBackend(redis=pool) 34 | pool.close() 35 | await pool.wait_closed() 36 | 37 | 38 | def get_twitter_api(): 39 | # Authenticate to Twitter 40 | auth = tweepy.OAuthHandler( 41 | settings.TWITTER_CONSUMER_KEY, settings.TWITTER_CONSUMER_SECRET 42 | ) 43 | auth.set_access_token( 44 | settings.TWITTER_ACCESS_TOKEN_KEY, settings.TWITTER_ACCESS_TOKEN_SECRET 45 | ) 46 | 47 | yield tweepy.API(auth) 48 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | 3 | 4 | class Settings(BaseSettings): 5 | APP_ENV: str 6 | API_V1_STR: str = "/v1" 7 | PROJECT_NAME: str = "ml-template" 8 | DATADIR: str = "data" 9 | TWITTER_CONSUMER_KEY: str 10 | TWITTER_CONSUMER_SECRET: str 11 | TWITTER_ACCESS_TOKEN_KEY: str 12 | TWITTER_ACCESS_TOKEN_SECRET: str 13 | FIRST_SUPERUSER: str 14 | FIRST_SUPERUSER_PASSWORD: str 15 | REDIS_HOST: str 16 | REDIS_PORT: str 17 | 18 | 19 | settings = Settings() 20 | -------------------------------------------------------------------------------- /app/core.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Optional 4 | 5 | import numpy as np 6 | import torch 7 | from scipy.special import softmax 8 | from transformers import AutoTokenizer 9 | 10 | from app.config import settings 11 | from app.schemas import SentimentLabel 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | LABELS_INDEX = ["negative", "neutral", "positive"] 17 | 18 | 19 | def get_tokenizer(): 20 | tokenizer = AutoTokenizer.from_pretrained(settings.DATADIR) 21 | return tokenizer 22 | 23 | 24 | def get_model(): 25 | model = torch.jit.load(os.path.join(settings.DATADIR, "trace_model.pt")) 26 | return model 27 | 28 | 29 | def preprocess(text: str): 30 | new_text = [] 31 | 32 | for t in text.split(" "): 33 | t = "@user" if t.startswith("@") and len(t) > 1 else t 34 | t = "http" if t.startswith("http") else t 35 | new_text.append(t) 36 | return " ".join(new_text) 37 | 38 | 39 | class TwitterSentiment: 40 | def __init__(self, model, tokenizer) -> None: 41 | self.model = model 42 | self.tokenizer = tokenizer 43 | 44 | def prediction(self, text: str) -> Optional[SentimentLabel]: 45 | if not text.strip(): 46 | return None 47 | 48 | text = preprocess(text) 49 | encoded_input = self.tokenizer(text, return_tensors="pt") 50 | with torch.no_grad(): 51 | output = self.model(**encoded_input) 52 | scores = output[0][0].detach().numpy() 53 | scores = softmax(scores) 54 | 55 | ranking = np.argsort(scores) 56 | ranking = ranking[::-1] 57 | prediction = list( 58 | map( 59 | lambda i: (LABELS_INDEX[ranking[i]], scores[ranking[i]]), 60 | range(scores.shape[0]), 61 | ) 62 | ) 63 | text = text.replace("\n", " ") 64 | logger.info(f"{text} have prediction: {prediction}") 65 | 66 | return SentimentLabel(prediction[0][0]) 67 | -------------------------------------------------------------------------------- /app/decorator.py: -------------------------------------------------------------------------------- 1 | import time 2 | from functools import wraps 3 | 4 | 5 | def async_log_response(f): 6 | @wraps(f) 7 | async def decorated(*args, **kwargs): 8 | t0 = time.perf_counter() 9 | result = await f(*args, **kwargs) 10 | parameters = {k: v for k, v in kwargs.items() if k != "request"} 11 | kwargs["request"].app.logger.info( 12 | f"{f.__name__}_{parameters}: response: {result}" 13 | ) 14 | kwargs["request"].app.logger.info( 15 | f"{f.__name__} took: {time.perf_counter() - t0}" 16 | ) 17 | 18 | return result 19 | 20 | return decorated 21 | -------------------------------------------------------------------------------- /app/feature_store/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haicheviet/fullstack-machine-learning-inference/00b5d7fa99457ba785c523c5553450e8daaf281e/app/feature_store/__init__.py -------------------------------------------------------------------------------- /app/feature_store/backends/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Optional, Tuple 3 | 4 | 5 | class Backend: 6 | @abc.abstractmethod 7 | async def get_with_ttl(self, key: str) -> Tuple[int, Optional[str]]: 8 | raise NotImplementedError 9 | 10 | @abc.abstractmethod 11 | async def get(self, key: str) -> Optional[str]: 12 | raise NotImplementedError 13 | 14 | @abc.abstractmethod 15 | async def set(self, key: str, value: str, expire: int = None): 16 | raise NotImplementedError 17 | 18 | @abc.abstractmethod 19 | async def clear(self, namespace: str = None, key: str = None) -> int: 20 | raise NotImplementedError 21 | -------------------------------------------------------------------------------- /app/feature_store/backends/redis.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from aioredis import Redis 4 | 5 | from app.feature_store.backends import Backend 6 | 7 | 8 | class RedisBackend(Backend): 9 | def __init__(self, redis: Redis): 10 | self.redis = redis 11 | 12 | async def get_with_ttl(self, key: str) -> Tuple[int, str]: 13 | p = self.redis.pipeline() 14 | p.ttl(key) 15 | p.get(key) 16 | return await p.execute() 17 | 18 | async def get(self, key) -> str: 19 | return await self.redis.get(key) 20 | 21 | async def set(self, key: str, value: str, expire: int = None): 22 | return await self.redis.set(key, value, expire=expire) 23 | 24 | async def clear(self, namespace: str = None, key: str = None) -> int: 25 | if namespace: 26 | lua = ( 27 | "local foo = {}" 28 | f"for i, name in ipairs(redis.call('KEYS', '{namespace}:*')) " 29 | "do table.insert(foo,redis.call('DEL', name)); end; return foo" 30 | ) 31 | return await self.redis.eval(lua) 32 | elif key: 33 | return await self.redis.delete(key) 34 | else: 35 | return await self.redis.flushdb() 36 | -------------------------------------------------------------------------------- /app/feature_store/core.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Optional 3 | 4 | from app.feature_store.backends import Backend 5 | from app.feature_store.key_builder import Keys 6 | 7 | SIXTY_DAYS = 60 * 60 * 60 * 60 8 | 9 | 10 | async def set_cache(data, keys: Keys, feature_store: Backend): 11 | await feature_store.set( 12 | keys.cache_key(), 13 | json.dumps(data), 14 | expire=SIXTY_DAYS, 15 | ) 16 | 17 | 18 | async def get_cache(keys: Keys, feature_store: Backend) -> Optional[Dict]: 19 | data = await feature_store.get(keys.cache_key()) 20 | if data: 21 | return json.loads(data) 22 | return None 23 | -------------------------------------------------------------------------------- /app/feature_store/key_builder.py: -------------------------------------------------------------------------------- 1 | from tweepy import models 2 | 3 | 4 | def prefixed_key(f): 5 | """ 6 | A method decorator that prefixes return values. 7 | Prefixes any string that the decorated method `f` returns with the value of 8 | the `prefix` attribute on the owner object `self`. 9 | """ 10 | 11 | def prefixed_method(*args, **kwargs): 12 | self = args[0] 13 | key = f(*args, **kwargs) 14 | return f"{self.prefix}:{key}" 15 | 16 | return prefixed_method 17 | 18 | 19 | class Keys: 20 | def __init__(self, tweet: models.Status): 21 | self.prefix = self.generate_prefix(tweet) 22 | 23 | @staticmethod 24 | def generate_prefix(tweet: models.Status): 25 | return f"{tweet.author.id}:{tweet.id}" 26 | 27 | @prefixed_key 28 | def cache_key(self) -> str: 29 | """A time series containing 30-second snapshots of BTC sentiment.""" 30 | return "sentiment" 31 | -------------------------------------------------------------------------------- /app/logger.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | LOGGER_DIR = os.getenv("LOGGER_DIR", "logs") 4 | if not os.path.exists(LOGGER_DIR): 5 | os.makedirs(LOGGER_DIR) 6 | level_log = "DEBUG" 7 | format_log = ( 8 | "%(asctime)s [%(threadName)-12.12s] " 9 | "[%(levelname)-5.5s] " 10 | "%(filename)s:%(funcName)s:%(lineno)d: %(message)s" 11 | ) 12 | 13 | 14 | DEFAULT_LOGGER = { 15 | "version": 1, 16 | "disable_existing_loggers": False, 17 | "formatters": {"standard": {"format": format_log}}, 18 | "handlers": { 19 | "api": { 20 | "level": "INFO", 21 | "class": "logging.StreamHandler", 22 | "formatter": "standard", 23 | }, 24 | "model_log": { 25 | "level": level_log, 26 | "class": "logging.handlers.TimedRotatingFileHandler", 27 | "filename": "{}/{}".format(LOGGER_DIR, "model_log.log"), 28 | "formatter": "standard", 29 | "when": "d", 30 | "interval": 1, 31 | "backupCount": 5, 32 | }, 33 | }, 34 | "loggers": { 35 | "app.main": {"handlers": ["api"], "level": level_log}, 36 | "app.core": { 37 | "handlers": ["model_log"], 38 | "level": level_log, 39 | }, 40 | }, 41 | } 42 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | from logging.config import dictConfig 5 | 6 | from fastapi import FastAPI, Request 7 | 8 | from app.api.api_v1.api import api_router 9 | from app.config import settings 10 | from app.core import get_model, get_tokenizer 11 | from app.logger import DEFAULT_LOGGER 12 | 13 | dictConfig(DEFAULT_LOGGER) 14 | 15 | app = FastAPI( 16 | title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json" 17 | ) 18 | app.logger = logging.getLogger(__name__) # type: ignore 19 | 20 | 21 | @app.middleware("http") 22 | async def add_process_time_header(request: Request, call_next): 23 | start_time = time.perf_counter() 24 | response = await call_next(request) 25 | process_time = time.perf_counter() - start_time 26 | response.headers["X-Process-Time"] = str(process_time) 27 | return response 28 | 29 | 30 | model_params = { 31 | "model": get_model(), 32 | "tokenizer": get_tokenizer(), 33 | } 34 | app.model_params = model_params # type: ignore 35 | app.logger.info("Done loading model") # type: ignore 36 | 37 | 38 | # Register api router with app 39 | app.include_router(api_router, prefix=settings.API_V1_STR) 40 | 41 | 42 | @app.get("/") 43 | async def read_item(request: Request): 44 | return {"statusCode": 200, "body": json.dumps({"message": "OK"})} 45 | -------------------------------------------------------------------------------- /app/schemas.py: -------------------------------------------------------------------------------- 1 | import re 2 | from enum import Enum 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | def to_camel(text): 8 | text = re.sub(r"(_|-)+", " ", text).title().replace(" ", "") 9 | return text[0].lower() + text[1:] 10 | 11 | 12 | class CamelModel(BaseModel): 13 | class Config: 14 | allow_population_by_field_name = True 15 | alias_generator = to_camel 16 | 17 | 18 | class SentimentLabel(str, Enum): 19 | NEGATIVE: str = "negative" 20 | NEUTRAL: str = "neutral" 21 | POSITIVE: str = "positive" 22 | 23 | 24 | class SentimentResponse(CamelModel): 25 | sentiment_analyst: SentimentLabel 26 | text_input: str 27 | -------------------------------------------------------------------------------- /data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haicheviet/fullstack-machine-learning-inference/00b5d7fa99457ba785c523c5553450e8daaf281e/data/.gitkeep -------------------------------------------------------------------------------- /deploy/client-sg.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright 2018 widdix GmbH 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | AWSTemplateFormatVersion: '2010-09-09' 16 | Description: 'State: Client security group, a cloudonaut.io template, sponsored by https://github.com/ngault' 17 | Metadata: 18 | 'AWS::CloudFormation::Interface': 19 | ParameterGroups: 20 | - Label: 21 | default: 'Parent Stacks' 22 | Parameters: 23 | - ParentVPCStack 24 | Parameters: 25 | ParentVPCStack: 26 | Description: 'Stack name of parent VPC stack based on vpc/vpc-*azs.yaml template.' 27 | Type: String 28 | Resources: 29 | ClientSecurityGroup: 30 | Type: 'AWS::EC2::SecurityGroup' 31 | Properties: 32 | GroupDescription: !Ref 'AWS::StackName' 33 | VpcId: {'Fn::ImportValue': !Sub '${ParentVPCStack}-VPC'} 34 | Outputs: 35 | TemplateID: 36 | Description: 'cloudonaut.io template id.' 37 | Value: 'state/client-sg' 38 | TemplateVersion: 39 | Description: 'cloudonaut.io template version.' 40 | Value: '__VERSION__' 41 | StackName: 42 | Description: 'Stack name.' 43 | Value: !Sub '${AWS::StackName}' 44 | ClientSecurityGroup: 45 | Description: 'Use this Security Group to reference client traffic.' 46 | Value: !Ref ClientSecurityGroup 47 | Export: 48 | Name: !Sub '${AWS::StackName}-ClientSecurityGroup' -------------------------------------------------------------------------------- /deploy/cluster-fargate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright 2018 widdix GmbH 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | AWSTemplateFormatVersion: '2010-09-09' 16 | Description: 'Fargate: cluster, a cloudonaut.io template' 17 | Metadata: 18 | 'AWS::CloudFormation::Interface': 19 | ParameterGroups: 20 | - Label: 21 | default: 'Parent Stacks' 22 | Parameters: 23 | - ParentVPCStack 24 | - ParentAuthProxyStack 25 | - ParentAlertStack 26 | - ParentZoneStack 27 | - ParentS3StackAccessLog 28 | - ParentWAFStack 29 | - Label: 30 | default: 'Load Balancer Parameters' 31 | Parameters: 32 | - LoadBalancerScheme 33 | - LoadBalancerCertificateArn 34 | - LoadBalancerIdleTimeout 35 | - SubDomainNameWithDot 36 | Parameters: 37 | ParentVPCStack: 38 | Description: 'Stack name of parent VPC stack based on vpc/vpc-*azs.yaml template.' 39 | Type: String 40 | ParentAuthProxyStack: 41 | Description: 'Optional stack name of parent auth proxy stack based on security/auth-proxy-*.yaml template.' 42 | Type: String 43 | Default: '' 44 | ParentAlertStack: 45 | Description: 'Optional but recommended stack name of parent alert stack based on operations/alert.yaml template.' 46 | Type: String 47 | Default: '' 48 | ParentZoneStack: 49 | Description: 'Optional stack name of parent zone stack based on vpc/zone-*.yaml template.' 50 | Type: String 51 | Default: '' 52 | ParentS3StackAccessLog: 53 | Description: 'Optional stack name of parent s3 stack based on state/s3.yaml template (with Access set to ElbAccessLogWrite) to store access logs.' 54 | Type: String 55 | Default: '' 56 | ParentWAFStack: 57 | Description: 'Optional stack name of parent WAF stack based on the security/waf.yaml template.' 58 | Type: String 59 | Default: '' 60 | LoadBalancerScheme: 61 | Description: 'Indicates whether the load balancer in front of the ECS service is internet-facing or internal.' 62 | Type: String 63 | Default: 'internet-facing' 64 | AllowedValues: 65 | - 'internet-facing' 66 | - internal 67 | LoadBalancerCertificateArn: 68 | Description: 'Optional Amazon Resource Name (ARN) of the certificate to associate with the load balancer. If set, HTTP requests are redirected to HTTPS.' 69 | Type: String 70 | Default: '' 71 | LoadBalancerIdleTimeout: 72 | Description: 'The idle timeout value, in seconds.' 73 | Type: Number 74 | Default: 60 75 | MinValue: 1 76 | MaxValue: 4000 77 | SubDomainNameWithDot: 78 | Description: 'Name that is used to create the DNS entry with trailing dot, e.g. §{SubDomainNameWithDot}§{HostedZoneName}. Leave blank for naked (or apex and bare) domain. Requires ParentZoneStack parameter!' 79 | Type: String 80 | Default: '' 81 | Conditions: 82 | HasAuthProxySecurityGroup: !Not [!Equals [!Ref ParentAuthProxyStack, '']] 83 | HasNotAuthProxySecurityGroup: !Equals [!Ref ParentAuthProxyStack, ''] 84 | HasLoadBalancerSchemeInternetFacing: !Equals [!Ref LoadBalancerScheme, 'internet-facing'] 85 | HasLoadBalancerSchemeInternal: !Equals [!Ref LoadBalancerScheme, 'internal'] 86 | HasLoadBalancerCertificateArn: !Not [!Equals [!Ref LoadBalancerCertificateArn, '']] 87 | HasAuthProxySecurityGroupAndLoadBalancerCertificateArn: !And [!Condition HasAuthProxySecurityGroup, !Condition HasLoadBalancerCertificateArn] 88 | HasNotAuthProxySecurityGroupAndLoadBalancerCertificateArn: !And [!Condition HasNotAuthProxySecurityGroup, !Condition HasLoadBalancerCertificateArn] 89 | HasAlertTopic: !Not [!Equals [!Ref ParentAlertStack, '']] 90 | HasZone: !Not [!Equals [!Ref ParentZoneStack, '']] 91 | HasZoneAndLoadBalancerSchemeInternetFacing: !And [!Condition HasZone, !Condition HasLoadBalancerSchemeInternetFacing] 92 | HasS3Bucket: !Not [!Equals [!Ref ParentS3StackAccessLog, '']] 93 | HasWAF: !Not [!Equals [!Ref ParentWAFStack, '']] 94 | Resources: 95 | Cluster: 96 | Type: 'AWS::ECS::Cluster' 97 | Properties: {} 98 | RecordSet: 99 | Condition: HasZone 100 | Type: 'AWS::Route53::RecordSet' 101 | Properties: 102 | AliasTarget: 103 | HostedZoneId: !GetAtt LoadBalancer.CanonicalHostedZoneID 104 | DNSName: !GetAtt 'LoadBalancer.DNSName' 105 | HostedZoneId: {'Fn::ImportValue': !Sub '${ParentZoneStack}-HostedZoneId'} 106 | Name: !Sub 107 | - '${SubDomainNameWithDot}${HostedZoneName}' 108 | - SubDomainNameWithDot: !Ref SubDomainNameWithDot 109 | HostedZoneName: {'Fn::ImportValue': !Sub '${ParentZoneStack}-HostedZoneName'} 110 | Type: A 111 | RecordSetIPv6: 112 | Condition: HasZoneAndLoadBalancerSchemeInternetFacing 113 | Type: 'AWS::Route53::RecordSet' 114 | Properties: 115 | AliasTarget: 116 | HostedZoneId: !GetAtt LoadBalancer.CanonicalHostedZoneID 117 | DNSName: !GetAtt 'LoadBalancer.DNSName' 118 | HostedZoneId: {'Fn::ImportValue': !Sub '${ParentZoneStack}-HostedZoneId'} 119 | Name: !Sub 120 | - '${SubDomainNameWithDot}${HostedZoneName}' 121 | - SubDomainNameWithDot: !Ref SubDomainNameWithDot 122 | HostedZoneName: {'Fn::ImportValue': !Sub '${ParentZoneStack}-HostedZoneName'} 123 | Type: AAAA 124 | LoadBalancerSecurityGroup: 125 | Type: 'AWS::EC2::SecurityGroup' 126 | Properties: 127 | GroupDescription: !Sub '${AWS::StackName}-load-balancer' 128 | VpcId: {'Fn::ImportValue': !Sub '${ParentVPCStack}-VPC'} 129 | LoadBalancerSecurityGroupInHttpFromWorld: 130 | Type: 'AWS::EC2::SecurityGroupIngress' 131 | Condition: HasNotAuthProxySecurityGroup 132 | Properties: 133 | GroupId: !Ref LoadBalancerSecurityGroup 134 | IpProtocol: tcp 135 | FromPort: 80 136 | ToPort: 80 137 | CidrIp: '0.0.0.0/0' 138 | LoadBalancerSecurityGroupInHttpFromWorldIPv6: 139 | Type: 'AWS::EC2::SecurityGroupIngress' 140 | Condition: HasNotAuthProxySecurityGroup 141 | Properties: 142 | GroupId: !Ref LoadBalancerSecurityGroup 143 | IpProtocol: tcp 144 | FromPort: 80 145 | ToPort: 80 146 | CidrIpv6: '::/0' 147 | LoadBalancerSecurityGroupInHttpsFromWorld: 148 | Type: 'AWS::EC2::SecurityGroupIngress' 149 | Condition: HasNotAuthProxySecurityGroupAndLoadBalancerCertificateArn 150 | Properties: 151 | GroupId: !Ref LoadBalancerSecurityGroup 152 | IpProtocol: tcp 153 | FromPort: 443 154 | ToPort: 443 155 | CidrIp: '0.0.0.0/0' 156 | LoadBalancerSecurityGroupInHttpsFromWorldIPv6: 157 | Type: 'AWS::EC2::SecurityGroupIngress' 158 | Condition: HasNotAuthProxySecurityGroupAndLoadBalancerCertificateArn 159 | Properties: 160 | GroupId: !Ref LoadBalancerSecurityGroup 161 | IpProtocol: tcp 162 | FromPort: 443 163 | ToPort: 443 164 | CidrIpv6: '::/0' 165 | LoadBalancerSecurityGroupInHttpFromAuthProxy: 166 | Type: 'AWS::EC2::SecurityGroupIngress' 167 | Condition: HasAuthProxySecurityGroup 168 | Properties: 169 | GroupId: !Ref LoadBalancerSecurityGroup 170 | IpProtocol: tcp 171 | FromPort: 80 172 | ToPort: 80 173 | SourceSecurityGroupId: {'Fn::ImportValue': !Sub '${ParentAuthProxyStack}-SecurityGroup'} 174 | LoadBalancerSecurityGroupInHttpsFromAuthProxy: 175 | Type: 'AWS::EC2::SecurityGroupIngress' 176 | Condition: HasAuthProxySecurityGroupAndLoadBalancerCertificateArn 177 | Properties: 178 | GroupId: !Ref LoadBalancerSecurityGroup 179 | IpProtocol: tcp 180 | FromPort: 443 181 | ToPort: 443 182 | SourceSecurityGroupId: {'Fn::ImportValue': !Sub '${ParentAuthProxyStack}-SecurityGroup'} 183 | LoadBalancer: 184 | Type: 'AWS::ElasticLoadBalancingV2::LoadBalancer' 185 | Properties: 186 | IpAddressType: !If [HasLoadBalancerSchemeInternal, 'ipv4', 'dualstack'] 187 | LoadBalancerAttributes: 188 | - Key: 'idle_timeout.timeout_seconds' 189 | Value: !Ref LoadBalancerIdleTimeout 190 | - Key: 'routing.http2.enabled' 191 | Value: 'true' 192 | - Key: 'access_logs.s3.enabled' 193 | Value: !If [HasS3Bucket, 'true', 'false'] 194 | - !If [HasS3Bucket, {Key: 'access_logs.s3.prefix', Value: !Ref 'AWS::StackName'}, !Ref 'AWS::NoValue'] 195 | - !If [HasS3Bucket, {Key: 'access_logs.s3.bucket', Value: {'Fn::ImportValue': !Sub '${ParentS3StackAccessLog}-BucketName'}}, !Ref 'AWS::NoValue'] 196 | Scheme: !Ref LoadBalancerScheme 197 | SecurityGroups: 198 | - !Ref LoadBalancerSecurityGroup 199 | Subnets: !If 200 | - HasLoadBalancerSchemeInternal 201 | - !Split [',', {'Fn::ImportValue': !Sub '${ParentVPCStack}-SubnetsPrivate'}] 202 | - !Split [',', {'Fn::ImportValue': !Sub '${ParentVPCStack}-SubnetsPublic'}] 203 | Type: application 204 | WebACLAssociation: 205 | Condition: HasWAF 206 | Type: AWS::WAFv2::WebACLAssociation 207 | Properties: 208 | ResourceArn: !Ref LoadBalancer 209 | WebACLArn: {'Fn::ImportValue': !Sub '${ParentWAFStack}-WebACL'} 210 | HTTPCodeELB5XXTooHighAlarm: 211 | Condition: HasAlertTopic 212 | Type: 'AWS::CloudWatch::Alarm' 213 | Properties: 214 | AlarmDescription: 'Application load balancer returns 5XX HTTP status codes' 215 | Namespace: 'AWS/ApplicationELB' 216 | MetricName: HTTPCode_ELB_5XX_Count 217 | Statistic: Sum 218 | Period: 60 219 | EvaluationPeriods: 1 220 | ComparisonOperator: GreaterThanThreshold 221 | Threshold: 0 222 | AlarmActions: 223 | - {'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN'} 224 | Dimensions: 225 | - Name: LoadBalancer 226 | Value: !GetAtt 'LoadBalancer.LoadBalancerFullName' 227 | TreatMissingData: notBreaching 228 | RejectedConnectionCountTooHighAlarm: 229 | Condition: HasAlertTopic 230 | Type: 'AWS::CloudWatch::Alarm' 231 | Properties: 232 | AlarmDescription: 'Application load balancer rejected connections because the load balancer had reached its maximum number of connections' 233 | Namespace: 'AWS/ApplicationELB' 234 | MetricName: RejectedConnectionCount 235 | Statistic: Sum 236 | Period: 60 237 | EvaluationPeriods: 1 238 | ComparisonOperator: GreaterThanThreshold 239 | Threshold: 0 240 | AlarmActions: 241 | - {'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN'} 242 | Dimensions: 243 | - Name: LoadBalancer 244 | Value: !GetAtt 'LoadBalancer.LoadBalancerFullName' 245 | TreatMissingData: notBreaching 246 | HttpListener: 247 | Type: 'AWS::ElasticLoadBalancingV2::Listener' 248 | Properties: 249 | DefaultActions: 250 | - !If 251 | - HasLoadBalancerCertificateArn 252 | - RedirectConfig: 253 | Port: '443' 254 | Protocol: HTTPS 255 | StatusCode: 'HTTP_301' 256 | Type: redirect 257 | - FixedResponseConfig: 258 | ContentType: 'text/plain' 259 | MessageBody: default 260 | StatusCode: '404' 261 | Type: 'fixed-response' 262 | LoadBalancerArn: !Ref LoadBalancer 263 | Port: 80 264 | Protocol: HTTP 265 | HttpsListener: 266 | Condition: HasLoadBalancerCertificateArn 267 | Type: 'AWS::ElasticLoadBalancingV2::Listener' 268 | Properties: 269 | Certificates: 270 | - CertificateArn: !Ref LoadBalancerCertificateArn 271 | DefaultActions: 272 | - FixedResponseConfig: 273 | ContentType: 'text/plain' 274 | MessageBody: default 275 | StatusCode: '404' 276 | Type: 'fixed-response' 277 | LoadBalancerArn: !Ref LoadBalancer 278 | Port: 443 279 | Protocol: HTTPS 280 | SslPolicy: 'ELBSecurityPolicy-FS-1-2-Res-2019-08' 281 | Outputs: 282 | TemplateID: 283 | Description: 'cloudonaut.io template id.' 284 | Value: 'fargate/cluster' 285 | TemplateVersion: 286 | Description: 'cloudonaut.io template version.' 287 | Value: '__VERSION__' 288 | StackName: 289 | Description: 'Stack name.' 290 | Value: !Sub '${AWS::StackName}' 291 | Cluster: 292 | Description: 'Fargate cluster.' 293 | Value: !Ref Cluster 294 | Export: 295 | Name: !Sub '${AWS::StackName}-Cluster' 296 | DNSName: 297 | Description: 'The DNS name for the ECS cluster load balancer.' 298 | Value: !GetAtt 'LoadBalancer.DNSName' 299 | Export: 300 | Name: !Sub '${AWS::StackName}-DNSName' 301 | URL: 302 | Description: 'URL to the ECS cluster.' 303 | Value: !Sub 'http://${LoadBalancer.DNSName}' 304 | Export: 305 | Name: !Sub '${AWS::StackName}-URL' 306 | CanonicalHostedZoneID: 307 | Description: 'The ID of the Amazon Route 53 hosted zone associated with the load balancer.' 308 | Value: !GetAtt LoadBalancer.CanonicalHostedZoneID 309 | Export: 310 | Name: !Sub '${AWS::StackName}-CanonicalHostedZoneID' 311 | LoadBalancerFullName: 312 | Description: 'ALB full name for services.' 313 | Value: !GetAtt 'LoadBalancer.LoadBalancerFullName' 314 | Export: 315 | Name: !Sub '${AWS::StackName}-LoadBalancerFullName' 316 | LoadBalancerSecurityGroup: 317 | Description: 'The Security Group of the Load Balancer.' 318 | Value: !Ref LoadBalancerSecurityGroup 319 | Export: 320 | Name: !Sub '${AWS::StackName}-LoadBalancerSecurityGroup' 321 | HttpListener: 322 | Description: 'ALB HTTP listener for services.' 323 | Value: !Ref HttpListener 324 | Export: 325 | Name: !Sub '${AWS::StackName}-HttpListener' 326 | HttpsListener: 327 | Condition: HasLoadBalancerCertificateArn 328 | Description: 'ALB HTTPS listener for services.' 329 | Value: !Ref HttpsListener 330 | Export: 331 | Name: !Sub '${AWS::StackName}-HttpsListener' -------------------------------------------------------------------------------- /deploy/elasticcache-redis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright 2018 widdix GmbH 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | AWSTemplateFormatVersion: '2010-09-09' 16 | Description: 'State: ElastiCache redis, a cloudonaut.io template' 17 | Metadata: 18 | 'AWS::CloudFormation::Interface': 19 | ParameterGroups: 20 | - Label: 21 | default: 'Parent Stacks' 22 | Parameters: 23 | - ParentVPCStack 24 | - ParentClientStack 25 | - ParentKmsKeyStack 26 | - ParentZoneStack 27 | - ParentSSHBastionStack 28 | - ParentAlertStack 29 | - Label: 30 | default: 'ElastiCache Parameters' 31 | Parameters: 32 | - EngineVersion 33 | - CacheNodeType 34 | - TransitEncryption 35 | - AuthToken 36 | - SnapshotRetentionLimit 37 | - SnapshotName 38 | - SubDomainName 39 | - NumShards 40 | - NumReplicas 41 | - Label: 42 | default: 'Alerting Parameters' 43 | Parameters: 44 | - CPUUtilizationThreshold 45 | - DatabaseMemoryUsagePercentageThreshold 46 | - SwapUsageThreshold 47 | - EvictionsThreshold 48 | - ReplicationLagThreshold 49 | Parameters: 50 | ParentVPCStack: 51 | Description: 'Stack name of parent VPC stack based on vpc/vpc-*azs.yaml template.' 52 | Type: String 53 | ParentClientStack: 54 | Description: 'Stack name of parent client stack based on state/client-sg.yaml template.' 55 | Type: String 56 | ParentKmsKeyStack: 57 | Description: 'Optional stack name of parent KMS key stack based on security/kms-key.yaml template.' 58 | Type: String 59 | Default: '' 60 | ParentZoneStack: 61 | Description: 'Optional stack name of parent zone stack based on vpc/vpc-zone-*.yaml template.' 62 | Type: String 63 | Default: '' 64 | ParentSSHBastionStack: 65 | Description: 'Optional but recommended stack name of parent SSH bastion host/instance stack based on vpc/vpc-ssh-bastion.yaml template.' 66 | Type: String 67 | Default: '' 68 | ParentAlertStack: 69 | Description: 'Optional but recommended stack name of parent alert stack based on operations/alert.yaml template.' 70 | Type: String 71 | Default: '' 72 | EngineVersion: 73 | Description: 'Redis version' 74 | Type: String 75 | Default: '6.x' 76 | AllowedValues: # aws elasticache describe-cache-engine-versions --engine redis --query "CacheEngineVersions[].EngineVersion" 77 | - '3.2.6' # 3.2.4 and 3.2.10 do not support encryption 78 | - '4.0.10' 79 | - '5.0.0' 80 | - '5.0.3' 81 | - '5.0.4' 82 | - '5.0.5' 83 | - '5.0.6' 84 | - '6.x' 85 | CacheNodeType: 86 | Description: 'The compute and memory capacity of the nodes in the node group (shard).' 87 | Type: 'String' 88 | Default: 'cache.t3.small' 89 | TransitEncryption: 90 | Description: 'Enable encryption for data in transit? (see https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/in-transit-encryption.html)' 91 | Type: 'String' 92 | Default: 'false' 93 | AllowedValues: 94 | - 'true' 95 | - 'false' 96 | AuthToken: 97 | Description: 'Optional password (16 to 128 characters) used to authenticate against Redis (requires TransitEncryption := true; leave blank to disable password-protection).' 98 | Type: 'String' 99 | Default: '' 100 | MaxLength: 128 101 | SnapshotRetentionLimit: 102 | Description: 'The number of days for which ElastiCache retains automatic snapshots before deleting them (set to 0 to disable backups).' 103 | Type: Number 104 | Default: 35 105 | MinValue: 0 106 | MaxValue: 35 107 | SnapshotName: 108 | Description: 'Optional name of a snapshot from which you want to restore (leave blank to create an empty cache).' 109 | Type: 'String' 110 | Default: '' 111 | SubDomainName: 112 | Description: 'Name that is used to create the DNS entry §{SubDomainName}.§{HostedZoneName} (required when ParentZoneStack is set, otherwise not considered)' 113 | Type: String 114 | Default: redis 115 | NumShards: 116 | Description: 'Number of shards in the cluster.' 117 | Type: 'Number' 118 | Default: 1 119 | MinValue: 1 120 | MaxValue: 250 121 | NumReplicas: 122 | Description: 'Number of replicas per shard.' 123 | Type: 'Number' 124 | Default: 0 125 | MinValue: 0 126 | MaxValue: 5 127 | CPUUtilizationThreshold: 128 | Description: 'The maximum percentage of CPU usage (set to -1 to disable).' 129 | Type: Number 130 | Default: 80 131 | MinValue: -1 132 | MaxValue: 100 133 | DatabaseMemoryUsagePercentageThreshold: 134 | Description: 'The maximum percentage of memory usage (set to -1 to disable).' 135 | Type: Number 136 | Default: 90 137 | MinValue: -1 138 | MaxValue: 100 139 | SwapUsageThreshold: 140 | Description: 'The maximum bytes of swap usage (set to -1 to disable).' 141 | Type: Number 142 | Default: 67108864 # 64 MB in Bytes 143 | MinValue: -1 144 | EvictionsThreshold: 145 | Description: 'The maximum number of evictions (set to -1 to disable).' 146 | Type: Number 147 | Default: 1000 148 | MinValue: -1 149 | ReplicationLagThreshold: 150 | Description: 'The maximum seconds of replication lag (set to -1 to disable).' 151 | Type: Number 152 | Default: 30 153 | MinValue: -1 154 | Mappings: 155 | EngineVersionMap: 156 | '3.2.6': 157 | CacheParameterGroupFamily: 'redis3.2' 158 | '4.0.10': 159 | CacheParameterGroupFamily: 'redis4.0' 160 | '5.0.0': 161 | CacheParameterGroupFamily: 'redis5.0' 162 | '5.0.3': 163 | CacheParameterGroupFamily: 'redis5.0' 164 | '5.0.4': 165 | CacheParameterGroupFamily: 'redis5.0' 166 | '5.0.5': 167 | CacheParameterGroupFamily: 'redis5.0' 168 | '5.0.6': 169 | CacheParameterGroupFamily: 'redis5.0' 170 | '6.x': 171 | CacheParameterGroupFamily: 'redis6.x' 172 | Conditions: 173 | HasKmsKey: !Not [!Equals [!Ref ParentKmsKeyStack, '']] 174 | HasZone: !Not [!Equals [!Ref ParentZoneStack, '']] 175 | HasSSHBastionSecurityGroup: !Not [!Equals [!Ref ParentSSHBastionStack, '']] 176 | HasAlertTopic: !Not [!Equals [!Ref ParentAlertStack, '']] 177 | HasAuthToken: !Not [!Equals [!Ref AuthToken, '']] 178 | HasSnapshotName: !Not [!Equals [!Ref SnapshotName, '']] 179 | HasAutomaticFailoverEnabled: !Not [!Equals [!Ref NumReplicas, 0]] 180 | HasCPUUtilizationThresholdAndAlertTopic: !And [!Not [!Equals [!Ref CPUUtilizationThreshold, '']], !Condition HasAlertTopic] 181 | HasDatabaseMemoryUsagePercentageThresholdAndAlertTopic: !And [!Not [!Equals [!Ref DatabaseMemoryUsagePercentageThreshold, '']], !Condition HasAlertTopic] 182 | HasSwapUsageThresholdAndAlertTopic: !And [!Not [!Equals [!Ref SwapUsageThreshold, '']], !Condition HasAlertTopic] 183 | HasEvictionsThresholdAndAlertTopic: !And [!Not [!Equals [!Ref EvictionsThreshold, '']], !Condition HasAlertTopic] 184 | HasReplicationLagThresholdAndAlertTopic: !And [!Not [!Equals [!Ref ReplicationLagThreshold, '']], !Condition HasAlertTopic] 185 | Resources: 186 | RecordSet: 187 | Condition: HasZone 188 | Type: 'AWS::Route53::RecordSet' 189 | Properties: 190 | HostedZoneId: 191 | 'Fn::ImportValue': !Sub '${ParentZoneStack}-HostedZoneId' 192 | Name: !Sub 193 | - '${SubDomainName}.${HostedZoneName}' 194 | - SubDomainName: !Ref SubDomainName 195 | HostedZoneName: 196 | 'Fn::ImportValue': !Sub '${ParentZoneStack}-HostedZoneName' 197 | ResourceRecords: 198 | - !GetAtt 'ReplicationGroup.PrimaryEndPoint.Address' 199 | TTL: 60 200 | Type: CNAME 201 | CacheParameterGroup: 202 | Type: 'AWS::ElastiCache::ParameterGroup' 203 | Properties: 204 | CacheParameterGroupFamily: !FindInMap [EngineVersionMap, !Ref EngineVersion, CacheParameterGroupFamily] 205 | Description: !Ref 'AWS::StackName' 206 | Properties: {} 207 | CacheSubnetGroupName: 208 | Type: 'AWS::ElastiCache::SubnetGroup' 209 | Properties: 210 | Description: !Ref 'AWS::StackName' 211 | SubnetIds: !Split 212 | - ',' 213 | - 'Fn::ImportValue': !Sub '${ParentVPCStack}-SubnetsPrivate' 214 | SecurityGroup: 215 | Type: 'AWS::EC2::SecurityGroup' 216 | Properties: 217 | GroupDescription: !Ref 'AWS::StackName' 218 | VpcId: 219 | 'Fn::ImportValue': !Sub '${ParentVPCStack}-VPC' 220 | SecurityGroupIngress: 221 | - IpProtocol: tcp 222 | FromPort: 6379 223 | ToPort: 6379 224 | SourceSecurityGroupId: 225 | 'Fn::ImportValue': !Sub '${ParentClientStack}-ClientSecurityGroup' 226 | SecurityGroupInSSHBastion: 227 | Type: 'AWS::EC2::SecurityGroupIngress' 228 | Condition: HasSSHBastionSecurityGroup 229 | Properties: 230 | GroupId: !Ref SecurityGroup 231 | IpProtocol: tcp 232 | FromPort: 6379 233 | ToPort: 6379 234 | SourceSecurityGroupId: 235 | 'Fn::ImportValue': !Sub '${ParentSSHBastionStack}-BastionSecurityGroupID' 236 | ReplicationGroup: 237 | DeletionPolicy: Snapshot 238 | UpdateReplacePolicy: Snapshot 239 | Type: AWS::ElastiCache::ReplicationGroup 240 | Properties: 241 | ReplicationGroupDescription: !Ref 'AWS::StackName' 242 | AtRestEncryptionEnabled: true 243 | AuthToken: !If [HasAuthToken, !Ref AuthToken, !Ref 'AWS::NoValue'] 244 | AutomaticFailoverEnabled: !If [HasAutomaticFailoverEnabled, true, false] 245 | CacheNodeType: !Ref CacheNodeType 246 | CacheParameterGroupName: !Ref CacheParameterGroup 247 | CacheSubnetGroupName: !Ref CacheSubnetGroupName 248 | Engine: redis 249 | EngineVersion: !Ref EngineVersion 250 | KmsKeyId: !If [HasKmsKey, {'Fn::ImportValue': !Sub '${ParentKmsKeyStack}-KeyId'}, !Ref 'AWS::NoValue'] 251 | NotificationTopicArn: !If [HasAlertTopic, {'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN'}, !Ref 'AWS::NoValue'] 252 | NumNodeGroups: !Ref NumShards 253 | ReplicasPerNodeGroup: !Ref NumReplicas 254 | PreferredMaintenanceWindow: 'sat:07:00-sat:08:00' 255 | SecurityGroupIds: 256 | - !Ref SecurityGroup 257 | SnapshotName: !If [HasSnapshotName, !Ref SnapshotName, !Ref 'AWS::NoValue'] 258 | SnapshotRetentionLimit: !Ref SnapshotRetentionLimit 259 | SnapshotWindow: '00:00-03:00' 260 | TransitEncryptionEnabled: !Ref TransitEncryption 261 | UpdatePolicy: 262 | UseOnlineResharding: true 263 | Node1CPUUtilizationTooHighAlarm: 264 | Condition: HasCPUUtilizationThresholdAndAlertTopic 265 | Type: 'AWS::CloudWatch::Alarm' 266 | Properties: 267 | AlarmDescription: !Sub 'Average CPU utilization over last 10 minutes higher than ${CPUUtilizationThreshold}%' 268 | Namespace: 'AWS/ElastiCache' 269 | MetricName: CPUUtilization 270 | Statistic: Average 271 | Period: 600 272 | EvaluationPeriods: 1 273 | ComparisonOperator: GreaterThanThreshold 274 | Threshold: !Ref CPUUtilizationThreshold 275 | AlarmActions: 276 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 277 | Dimensions: 278 | - Name: CacheClusterId 279 | Value: !Sub '${ReplicationGroup}-001' 280 | Node2CPUUtilizationTooHighAlarm: 281 | Condition: HasCPUUtilizationThresholdAndAlertTopic 282 | Type: 'AWS::CloudWatch::Alarm' 283 | Properties: 284 | AlarmDescription: !Sub 'Average CPU utilization over last 10 minutes higher than ${CPUUtilizationThreshold}%' 285 | Namespace: 'AWS/ElastiCache' 286 | MetricName: CPUUtilization 287 | Statistic: Average 288 | Period: 600 289 | EvaluationPeriods: 1 290 | ComparisonOperator: GreaterThanThreshold 291 | Threshold: !Ref CPUUtilizationThreshold 292 | AlarmActions: 293 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 294 | Dimensions: 295 | - Name: CacheClusterId 296 | Value: !Sub '${ReplicationGroup}-002' 297 | Node1EngineCPUUtilizationTooHighAlarm: 298 | Condition: HasCPUUtilizationThresholdAndAlertTopic 299 | Type: 'AWS::CloudWatch::Alarm' 300 | Properties: 301 | AlarmDescription: !Sub 'Average engine CPU utilization over last 10 minutes higher than ${CPUUtilizationThreshold}%' 302 | Namespace: 'AWS/ElastiCache' 303 | MetricName: EngineCPUUtilization 304 | Statistic: Average 305 | Period: 600 306 | EvaluationPeriods: 1 307 | ComparisonOperator: GreaterThanThreshold 308 | Threshold: !Ref CPUUtilizationThreshold 309 | AlarmActions: 310 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 311 | Dimensions: 312 | - Name: CacheClusterId 313 | Value: !Sub '${ReplicationGroup}-001' 314 | Node2EngineCPUUtilizationTooHighAlarm: 315 | Condition: HasCPUUtilizationThresholdAndAlertTopic 316 | Type: 'AWS::CloudWatch::Alarm' 317 | Properties: 318 | AlarmDescription: !Sub 'Average engine CPU utilization over last 10 minutes higher than ${CPUUtilizationThreshold}%' 319 | Namespace: 'AWS/ElastiCache' 320 | MetricName: EngineCPUUtilization 321 | Statistic: Average 322 | Period: 600 323 | EvaluationPeriods: 1 324 | ComparisonOperator: GreaterThanThreshold 325 | Threshold: !Ref CPUUtilizationThreshold 326 | AlarmActions: 327 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 328 | Dimensions: 329 | - Name: CacheClusterId 330 | Value: !Sub '${ReplicationGroup}-002' 331 | Node1DatabaseMemoryUsagePercentageTooHighAlarm: 332 | Condition: HasDatabaseMemoryUsagePercentageThresholdAndAlertTopic 333 | Type: 'AWS::CloudWatch::Alarm' 334 | Properties: 335 | AlarmDescription: !Sub 'Average memory usage over last 10 minutes higher than ${DatabaseMemoryUsagePercentageThreshold}, performance may suffer' 336 | Namespace: 'AWS/ElastiCache' 337 | MetricName: DatabaseMemoryUsagePercentage 338 | Statistic: Average 339 | Period: 600 340 | EvaluationPeriods: 1 341 | ComparisonOperator: GreaterThanThreshold 342 | Threshold: !Ref DatabaseMemoryUsagePercentageThreshold 343 | AlarmActions: 344 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 345 | Dimensions: 346 | - Name: CacheClusterId 347 | Value: !Sub '${ReplicationGroup}-001' 348 | Node2DatabaseMemoryUsagePercentageTooHighAlarm: 349 | Condition: HasDatabaseMemoryUsagePercentageThresholdAndAlertTopic 350 | Type: 'AWS::CloudWatch::Alarm' 351 | Properties: 352 | AlarmDescription: !Sub 'Average memory usage over last 10 minutes higher than ${DatabaseMemoryUsagePercentageThreshold}, performance may suffer' 353 | Namespace: 'AWS/ElastiCache' 354 | MetricName: DatabaseMemoryUsagePercentage 355 | Statistic: Average 356 | Period: 600 357 | EvaluationPeriods: 1 358 | ComparisonOperator: GreaterThanThreshold 359 | Threshold: !Ref DatabaseMemoryUsagePercentageThreshold 360 | AlarmActions: 361 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 362 | Dimensions: 363 | - Name: CacheClusterId 364 | Value: !Sub '${ReplicationGroup}-002' 365 | Node1SwapUsageTooHighAlarm: 366 | Condition: HasSwapUsageThresholdAndAlertTopic 367 | Type: 'AWS::CloudWatch::Alarm' 368 | Properties: 369 | AlarmDescription: !Sub 'Average swap usage over last 10 minutes higher than ${SwapUsageThreshold} bytes, performance may suffer' 370 | Namespace: 'AWS/ElastiCache' 371 | MetricName: SwapUsage 372 | Statistic: Average 373 | Period: 600 374 | EvaluationPeriods: 1 375 | ComparisonOperator: GreaterThanThreshold 376 | Threshold: !Ref SwapUsageThreshold 377 | AlarmActions: 378 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 379 | Dimensions: 380 | - Name: CacheClusterId 381 | Value: !Sub '${ReplicationGroup}-001' 382 | Node2SwapUsageTooHighAlarm: 383 | Condition: HasSwapUsageThresholdAndAlertTopic 384 | Type: 'AWS::CloudWatch::Alarm' 385 | Properties: 386 | AlarmDescription: !Sub 'Average swap usage over last 10 minutes higher than ${SwapUsageThreshold} bytes, performance may suffer' 387 | Namespace: 'AWS/ElastiCache' 388 | MetricName: SwapUsage 389 | Statistic: Average 390 | Period: 600 391 | EvaluationPeriods: 1 392 | ComparisonOperator: GreaterThanThreshold 393 | Threshold: !Ref SwapUsageThreshold 394 | AlarmActions: 395 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 396 | Dimensions: 397 | - Name: CacheClusterId 398 | Value: !Sub '${ReplicationGroup}-002' 399 | Node1EvictionsTooHighAlarm: 400 | Condition: HasEvictionsThresholdAndAlertTopic 401 | Type: 'AWS::CloudWatch::Alarm' 402 | Properties: 403 | AlarmDescription: !Sub 'Average evictions over last 10 minutes higher than ${EvictionsThreshold}, cache hit ratio may suffer' 404 | Namespace: 'AWS/ElastiCache' 405 | MetricName: Evictions 406 | Statistic: Average 407 | Period: 600 408 | EvaluationPeriods: 1 409 | ComparisonOperator: GreaterThanThreshold 410 | Threshold: !Ref EvictionsThreshold 411 | AlarmActions: 412 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 413 | Dimensions: 414 | - Name: CacheClusterId 415 | Value: !Sub '${ReplicationGroup}-001' 416 | Node2EvictionsTooHighAlarm: 417 | Condition: HasEvictionsThresholdAndAlertTopic 418 | Type: 'AWS::CloudWatch::Alarm' 419 | Properties: 420 | AlarmDescription: !Sub 'Average evictions over last 10 minutes higher than ${EvictionsThreshold}, cache hit ratio may suffer' 421 | Namespace: 'AWS/ElastiCache' 422 | MetricName: Evictions 423 | Statistic: Average 424 | Period: 600 425 | EvaluationPeriods: 1 426 | ComparisonOperator: GreaterThanThreshold 427 | Threshold: !Ref EvictionsThreshold 428 | AlarmActions: 429 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 430 | Dimensions: 431 | - Name: CacheClusterId 432 | Value: !Sub '${ReplicationGroup}-002' 433 | Node1ReplicationLagTooHighAlarm: 434 | Condition: HasReplicationLagThresholdAndAlertTopic 435 | Type: 'AWS::CloudWatch::Alarm' 436 | Properties: 437 | AlarmDescription: !Sub 'Average replication lag over last 10 minutes higher than ${ReplicationLagThreshold} seconds' 438 | Namespace: 'AWS/ElastiCache' 439 | MetricName: ReplicationLag 440 | Statistic: Average 441 | Period: 600 442 | EvaluationPeriods: 1 443 | ComparisonOperator: GreaterThanThreshold 444 | Threshold: !Ref ReplicationLagThreshold 445 | AlarmActions: 446 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 447 | Dimensions: 448 | - Name: CacheClusterId 449 | Value: !Sub '${ReplicationGroup}-001' 450 | Node2ReplicationLagTooHighAlarm: 451 | Condition: HasReplicationLagThresholdAndAlertTopic 452 | Type: 'AWS::CloudWatch::Alarm' 453 | Properties: 454 | AlarmDescription: !Sub 'Average replication lag over last 10 minutes higher than ${ReplicationLagThreshold} seconds' 455 | Namespace: 'AWS/ElastiCache' 456 | MetricName: ReplicationLag 457 | Statistic: Average 458 | Period: 600 459 | EvaluationPeriods: 1 460 | ComparisonOperator: GreaterThanThreshold 461 | Threshold: !Ref ReplicationLagThreshold 462 | AlarmActions: 463 | - 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' 464 | Dimensions: 465 | - Name: CacheClusterId 466 | Value: !Sub '${ReplicationGroup}-002' 467 | Outputs: 468 | TemplateID: 469 | Description: 'cloudonaut.io template id.' 470 | Value: 'state/elasticache-redis' 471 | TemplateVersion: 472 | Description: 'cloudonaut.io template version.' 473 | Value: '__VERSION__' 474 | StackName: 475 | Description: 'Stack name.' 476 | Value: !Sub '${AWS::StackName}' 477 | ClusterName: 478 | Description: 'The name of the cluster' 479 | Value: !Ref ReplicationGroup 480 | Export: 481 | Name: !Sub '${AWS::StackName}-ClusterName' 482 | PrimaryEndPointAddress: 483 | Description: 'The DNS address of the primary read-write cache node.' 484 | Value: !GetAtt 'ReplicationGroup.PrimaryEndPoint.Address' 485 | Export: 486 | Name: !Sub '${AWS::StackName}-PrimaryEndPointAddress' 487 | PrimaryEndPointPort: 488 | Description: 'The port that the primary read-write cache engine is listening on.' 489 | Value: !GetAtt 'ReplicationGroup.PrimaryEndPoint.Port' 490 | Export: 491 | Name: !Sub '${AWS::StackName}-PrimaryEndPointPort' 492 | SecurityGroupId: 493 | Description: 'The security group used to manage access to Elasticache Redis.' 494 | Value: !Ref SecurityGroup 495 | Export: 496 | Name: !Sub '${AWS::StackName}-SecurityGroupId' -------------------------------------------------------------------------------- /deploy/task-definition/app-demo.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright 2018 widdix GmbH 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | AWSTemplateFormatVersion: "2010-09-09" 16 | Description: "Fargate: service that runs on a Fargate cluster based on fargate/cluster.yaml and uses the cluster ALB" 17 | Metadata: 18 | "AWS::CloudFormation::Interface": 19 | ParameterGroups: 20 | - Label: 21 | default: "Parent Stacks" 22 | Parameters: 23 | - ParentVPCStack 24 | - ParentClusterStack 25 | - ParentAlertStack 26 | - ParentZoneStack 27 | - ParentClientStack1 28 | - ParentClientStack2 29 | - ParentClientStack3 30 | - Label: 31 | default: "Load Balancer Parameters" 32 | Parameters: 33 | - LoadBalancerPriority 34 | - LoadBalancerHostPattern 35 | - LoadBalancerPathPattern 36 | - LoadBalancerHttps 37 | - LoadBalancerDeregistrationDelay 38 | - Label: 39 | default: "Task Parameters" 40 | Parameters: 41 | - TaskPolicies 42 | - ProxyImage 43 | - ProxyCommand 44 | - ProxyPort 45 | - ProxyEnvironment1Key 46 | - ProxyEnvironment1Value 47 | - ProxyEnvironment2Key 48 | - ProxyEnvironment2Value 49 | - ProxyEnvironment3Key 50 | - ProxyEnvironment3Value 51 | - AppImage 52 | - AppCommand 53 | - AppPort 54 | - AppEnvironmentS3Arn 55 | - AppEnvironment1Key 56 | - AppEnvironment1Value 57 | - AppEnvironment2Key 58 | - AppEnvironment2Value 59 | - AppEnvironment3Key 60 | - AppEnvironment3Value 61 | - SidecarImage 62 | - SidecarCommand 63 | - SidecarPort 64 | - SidecarEnvironment1Key 65 | - SidecarEnvironment1Value 66 | - SidecarEnvironment2Key 67 | - SidecarEnvironment2Value 68 | - SidecarEnvironment3Key 69 | - SidecarEnvironment3Value 70 | - Label: 71 | default: "Service Parameters" 72 | Parameters: 73 | - SubDomainNameWithDot 74 | - Cpu 75 | - Memory 76 | - SubnetsReach 77 | - AutoScaling 78 | - DesiredCount 79 | - MaxCapacity 80 | - MinCapacity 81 | - HealthCheckGracePeriod 82 | - LogsRetentionInDays 83 | - Label: 84 | default: "Permission Parameters" 85 | Parameters: 86 | - PermissionsBoundary 87 | Parameters: 88 | ParentVPCStack: 89 | Description: "Stack name of parent VPC stack based on vpc/vpc-*azs.yaml template." 90 | Type: String 91 | ParentClusterStack: 92 | Description: "Stack name of parent Cluster stack based on fargate/cluster.yaml template." 93 | Type: String 94 | ParentAlertStack: 95 | Description: "Optional but recommended stack name of parent alert stack based on operations/alert.yaml template." 96 | Type: String 97 | Default: "" 98 | ParentZoneStack: 99 | Description: "Optional stack name of parent zone stack based on vpc/zone-*.yaml template." 100 | Type: String 101 | Default: "" 102 | ParentClientStack1: 103 | Description: "Optional stack name of parent Client Security Group stack based on state/client-sg.yaml template to allow network access from the service to whatever uses the client security group." 104 | Type: String 105 | Default: "" 106 | ParentClientStack2: 107 | Description: "Optional stack name of parent Client Security Group stack based on state/client-sg.yaml template to allow network access from the service to whatever uses the client security group." 108 | Type: String 109 | Default: "" 110 | ParentClientStack3: 111 | Description: "Optional stack name of parent Client Security Group stack based on state/client-sg.yaml template to allow network access from the service to whatever uses the client security group." 112 | Type: String 113 | Default: "" 114 | PermissionsBoundary: 115 | Description: "Optional ARN for a policy that will be used as the permission boundary for all roles created by this template." 116 | Type: String 117 | Default: "" 118 | LoadBalancerPriority: 119 | Description: "The priority for the rule. Elastic Load Balancing evaluates rules in priority order, from the lowest value to the highest value. If a request satisfies a rule, Elastic Load Balancing ignores all subsequent rules. A target group can have only one rule with a given priority." 120 | Type: Number 121 | Default: 1 122 | ConstraintDescription: "Must be in the range [1-99999]" 123 | MinValue: 1 124 | MaxValue: 99999 125 | LoadBalancerHostPattern: 126 | Description: "Optional host pattern. Specify LoadBalancerPathPattern and/or LoadBalancerHostPattern." 127 | Type: String 128 | Default: "" 129 | ConstraintDescription: "Must not be longer than 255" 130 | MaxLength: 255 131 | LoadBalancerPathPattern: 132 | Description: "Optional path pattern. Specify LoadBalancerPathPattern and/or LoadBalancerHostPattern." 133 | Type: String 134 | Default: "/*" 135 | ConstraintDescription: "Must not be longer than 255" 136 | MaxLength: 255 137 | LoadBalancerHttps: 138 | Description: "If the cluster supports HTTPS (LoadBalancerCertificateArn is set) you can enable HTTPS for the service" 139 | Type: String 140 | Default: false 141 | AllowedValues: 142 | - true 143 | - false 144 | LoadBalancerDeregistrationDelay: 145 | Description: "The amount time (in seconds) to wait before changing the state of a deregistering target from draining to unused." 146 | Type: Number 147 | Default: 60 148 | ConstraintDescription: "Must be in the range [0-3600]" 149 | MinValue: 0 150 | MaxValue: 3600 151 | TaskPolicies: 152 | Description: "Comma-delimited list of IAM managed policy ARNs to attach to the task IAM role" 153 | Type: String 154 | Default: "" 155 | ProxyImage: 156 | Description: "Optional Docker image to use for the proxy container. You can use images in the Docker Hub registry or specify other repositories (repository-url/image:tag)." 157 | Type: String 158 | Default: "" 159 | ProxyCommand: 160 | Description: "Optional command used when starting the proxy container." 161 | Type: String 162 | Default: "" 163 | ProxyPort: 164 | Description: "The port exposed by the proxy container that receives traffic from the load balancer (ProxyPort <> AppPort <> SidecarPort; ignored if ProxyImage is not set)." 165 | Type: Number 166 | Default: 8000 167 | MinValue: 1 168 | MaxValue: 49150 169 | ProxyEnvironment1Key: 170 | Description: "Optional environment variable 1 key for proxy container." 171 | Type: String 172 | Default: "" 173 | ProxyEnvironment1Value: 174 | Description: "Optional environment variable 1 value for proxy container." 175 | Type: String 176 | Default: "" 177 | ProxyEnvironment2Key: 178 | Description: "Optional environment variable 2 key for proxy container." 179 | Type: String 180 | Default: "" 181 | ProxyEnvironment2Value: 182 | Description: "Optional environment variable 2 value for proxy container." 183 | Type: String 184 | Default: "" 185 | ProxyEnvironment3Key: 186 | Description: "Optional environment variable 3 key for proxy container." 187 | Type: String 188 | Default: "" 189 | ProxyEnvironment3Value: 190 | Description: "Optional environment variable 3 value for proxy container." 191 | Type: String 192 | Default: "" 193 | AppImage: 194 | Description: "The Docker image to use for the app container. You can use images in the Docker Hub registry or specify other repositories (repository-url/image:tag)." 195 | Type: String 196 | Default: "widdix/hello:v1" 197 | AppCommand: 198 | Description: "Optional commands (comma-delimited) used when starting the app container." 199 | Type: String 200 | Default: "" 201 | AppPort: 202 | Description: "The port exposed by the app container that receives traffic from the load balancer or the proxy container (AppPort <> ProxyPort <> SidecarPort)." 203 | Type: Number 204 | Default: 80 205 | MinValue: 1 206 | MaxValue: 49150 207 | AppEnvironmentS3Arn: 208 | Description: "Optional s3 arn enviroment file." 209 | Type: String 210 | Default: "" 211 | AppEnvironment1Key: 212 | Description: "Optional environment variable 1 key for app container." 213 | Type: String 214 | Default: "" 215 | AppEnvironment1Value: 216 | Description: "Optional environment variable 1 value for app container." 217 | Type: String 218 | Default: "" 219 | AppEnvironment2Key: 220 | Description: "Optional environment variable 2 key for app container." 221 | Type: String 222 | Default: "" 223 | AppEnvironment2Value: 224 | Description: "Optional environment variable 2 value for app container." 225 | Type: String 226 | Default: "" 227 | AppEnvironment3Key: 228 | Description: "Optional environment variable 3 key for app container." 229 | Type: String 230 | Default: "" 231 | AppEnvironment3Value: 232 | Description: "Optional environment variable 3 value for app container." 233 | Type: String 234 | Default: "" 235 | SidecarImage: 236 | Description: "Optional Docker image to use for the sidecar container. You can use images in the Docker Hub registry or specify other repositories (repository-url/image:tag)." 237 | Type: String 238 | Default: "" 239 | SidecarCommand: 240 | Description: "Optional command used when starting the sidecar container." 241 | Type: String 242 | Default: "" 243 | SidecarPort: 244 | Description: "The port exposed by the sidecar container reachable from the app container on host localhost (SidecarPort <> ProxyPort <> AppPort)." 245 | Type: Number 246 | Default: 9000 247 | MinValue: 1 248 | MaxValue: 49150 249 | SidecarEnvironment1Key: 250 | Description: "Optional environment variable 1 key for sidecar container." 251 | Type: String 252 | Default: "" 253 | SidecarEnvironment1Value: 254 | Description: "Optional environment variable 1 value for sidecar container." 255 | Type: String 256 | Default: "" 257 | SidecarEnvironment2Key: 258 | Description: "Optional environment variable 2 key for sidecar container." 259 | Type: String 260 | Default: "" 261 | SidecarEnvironment2Value: 262 | Description: "Optional environment variable 2 value for sidecar container." 263 | Type: String 264 | Default: "" 265 | SidecarEnvironment3Key: 266 | Description: "Optional environment variable 3 key for sidecar container." 267 | Type: String 268 | Default: "" 269 | SidecarEnvironment3Value: 270 | Description: "Optional environment variable 3 value for sidecar container." 271 | Type: String 272 | Default: "" 273 | SubDomainNameWithDot: 274 | Description: "Name that is used to create the DNS entry with trailing dot, e.g. §{SubDomainNameWithDot}§{HostedZoneName}. Leave blank for naked (or apex and bare) domain. Requires ParentZoneStack parameter!" 275 | Type: String 276 | Default: "" 277 | Cpu: 278 | Description: "The minimum number of vCPUs to reserve for the container." 279 | Type: String 280 | Default: "0.25" 281 | AllowedValues: ["0.25", "0.5", "1", "2", "4"] 282 | Memory: 283 | Description: "The amount (in GB) of memory used by the task." 284 | Type: String 285 | Default: "0.5" 286 | AllowedValues: 287 | [ 288 | "0.5", 289 | "1", 290 | "2", 291 | "3", 292 | "4", 293 | "5", 294 | "6", 295 | "7", 296 | "8", 297 | "9", 298 | "10", 299 | "11", 300 | "12", 301 | "13", 302 | "14", 303 | "15", 304 | "16", 305 | "17", 306 | "18", 307 | "19", 308 | "20", 309 | "21", 310 | "22", 311 | "23", 312 | "24", 313 | "25", 314 | "26", 315 | "27", 316 | "28", 317 | "29", 318 | "30", 319 | ] 320 | DesiredCount: 321 | Description: "The number of simultaneous tasks, that you want to run on the cluster." 322 | Type: Number 323 | Default: 2 324 | ConstraintDescription: "Must be >= 1" 325 | MinValue: 1 326 | MaxCapacity: 327 | Description: "The maximum number of simultaneous tasks, that you want to run on the cluster." 328 | Type: Number 329 | Default: 4 330 | ConstraintDescription: "Must be >= 1" 331 | MinValue: 1 332 | MinCapacity: 333 | Description: "The minimum number of simultaneous tasks, that you want to run on the cluster." 334 | Type: Number 335 | Default: 2 336 | ConstraintDescription: "Must be >= 1" 337 | MinValue: 1 338 | LogsRetentionInDays: 339 | Description: "Specifies the number of days you want to retain log events in the specified log group." 340 | Type: Number 341 | Default: 14 342 | AllowedValues: 343 | [ 344 | 1, 345 | 3, 346 | 5, 347 | 7, 348 | 14, 349 | 30, 350 | 60, 351 | 90, 352 | 120, 353 | 150, 354 | 180, 355 | 365, 356 | 400, 357 | 545, 358 | 731, 359 | 1827, 360 | 3653, 361 | ] 362 | SubnetsReach: 363 | Description: "Should the service have direct access to the Internet or do you prefer private subnets with NAT?" 364 | Type: String 365 | Default: Public 366 | AllowedValues: 367 | - Public 368 | - Private 369 | AutoScaling: 370 | Description: "Scale number of tasks based on CPU load?" 371 | Type: String 372 | Default: "true" 373 | AllowedValues: ["true", "false"] 374 | HealthCheckGracePeriod: 375 | Description: "The period of time, in seconds, that the Amazon ECS service scheduler ignores unhealthy Elastic Load Balancing target health checks after a task has first started." 376 | Type: Number 377 | Default: 60 378 | MinValue: 0 379 | MaxValue: 1800 380 | 381 | Mappings: 382 | CpuMap: 383 | "0.25": 384 | Cpu: 256 385 | "0.5": 386 | Cpu: 512 387 | "1": 388 | Cpu: 1024 389 | "2": 390 | Cpu: 2048 391 | "4": 392 | Cpu: 4096 393 | MemoryMap: 394 | "0.5": 395 | Memory: 512 396 | "1": 397 | Memory: 1024 398 | "2": 399 | Memory: 2048 400 | "3": 401 | Memory: 3072 402 | "4": 403 | Memory: 4096 404 | "5": 405 | Memory: 5120 406 | "6": 407 | Memory: 6144 408 | "7": 409 | Memory: 7168 410 | "8": 411 | Memory: 8192 412 | "9": 413 | Memory: 9216 414 | "10": 415 | Memory: 10240 416 | "11": 417 | Memory: 11264 418 | "12": 419 | Memory: 12288 420 | "13": 421 | Memory: 13312 422 | "14": 423 | Memory: 14336 424 | "15": 425 | Memory: 15360 426 | "16": 427 | Memory: 16384 428 | "17": 429 | Memory: 17408 430 | "18": 431 | Memory: 18432 432 | "19": 433 | Memory: 19456 434 | "20": 435 | Memory: 20480 436 | "21": 437 | Memory: 21504 438 | "22": 439 | Memory: 22528 440 | "23": 441 | Memory: 23552 442 | "24": 443 | Memory: 24576 444 | "25": 445 | Memory: 25600 446 | "26": 447 | Memory: 26624 448 | "27": 449 | Memory: 27648 450 | "28": 451 | Memory: 28672 452 | "29": 453 | Memory: 29696 454 | "30": 455 | Memory: 30720 456 | Conditions: 457 | HasPermissionsBoundary: !Not [!Equals [!Ref PermissionsBoundary, ""]] 458 | HasLoadBalancerHttps: !Equals [!Ref LoadBalancerHttps, "true"] 459 | HasLoadBalancerPathPattern: !Not [!Equals [!Ref LoadBalancerPathPattern, ""]] 460 | HasLoadBalancerHostPattern: !Not [!Equals [!Ref LoadBalancerHostPattern, ""]] 461 | HasAlertTopic: !Not [!Equals [!Ref ParentAlertStack, ""]] 462 | HasZone: !Not [!Equals [!Ref ParentZoneStack, ""]] 463 | HasSubnetsReachPublic: !Equals [!Ref SubnetsReach, Public] 464 | HasAutoScaling: !Equals [!Ref AutoScaling, "true"] 465 | HasClientSecurityGroup1: !Not [!Equals [!Ref ParentClientStack1, ""]] 466 | HasClientSecurityGroup2: !Not [!Equals [!Ref ParentClientStack2, ""]] 467 | HasClientSecurityGroup3: !Not [!Equals [!Ref ParentClientStack3, ""]] 468 | HasTaskPolicies: !Not [!Equals [!Ref TaskPolicies, ""]] 469 | HasAppCommand: !Not [!Equals [!Ref AppCommand, ""]] 470 | HasAppEnvironmentS3Arn: !Not [!Equals [!Ref AppEnvironmentS3Arn, ""]] 471 | HasAppEnvironment1Key: !Not [!Equals [!Ref AppEnvironment1Key, ""]] 472 | HasAppEnvironment2Key: !Not [!Equals [!Ref AppEnvironment2Key, ""]] 473 | HasAppEnvironment3Key: !Not [!Equals [!Ref AppEnvironment3Key, ""]] 474 | HasProxyImage: !Not [!Equals [!Ref ProxyImage, ""]] 475 | HasProxyCommand: !Not [!Equals [!Ref ProxyCommand, ""]] 476 | HasProxyEnvironment1Key: !Not [!Equals [!Ref ProxyEnvironment1Key, ""]] 477 | HasProxyEnvironment2Key: !Not [!Equals [!Ref ProxyEnvironment2Key, ""]] 478 | HasProxyEnvironment3Key: !Not [!Equals [!Ref ProxyEnvironment3Key, ""]] 479 | HasSidecarImage: !Not [!Equals [!Ref SidecarImage, ""]] 480 | HasSidecarCommand: !Not [!Equals [!Ref SidecarCommand, ""]] 481 | HasSidecarEnvironment1Key: !Not [!Equals [!Ref SidecarEnvironment1Key, ""]] 482 | HasSidecarEnvironment2Key: !Not [!Equals [!Ref SidecarEnvironment2Key, ""]] 483 | HasSidecarEnvironment3Key: !Not [!Equals [!Ref SidecarEnvironment3Key, ""]] 484 | Resources: 485 | RecordSet: 486 | Condition: HasZone 487 | Type: "AWS::Route53::RecordSet" 488 | Properties: 489 | AliasTarget: 490 | HostedZoneId: 491 | { 492 | "Fn::ImportValue": !Sub "${ParentClusterStack}-CanonicalHostedZoneID", 493 | } 494 | DNSName: { "Fn::ImportValue": !Sub "${ParentClusterStack}-DNSName" } 495 | HostedZoneId: 496 | { "Fn::ImportValue": !Sub "${ParentZoneStack}-HostedZoneId" } 497 | Name: !Sub 498 | - "${SubDomainNameWithDot}${HostedZoneName}" 499 | - SubDomainNameWithDot: !Ref SubDomainNameWithDot 500 | HostedZoneName: 501 | { "Fn::ImportValue": !Sub "${ParentZoneStack}-HostedZoneName" } 502 | Type: A 503 | RecordSetIPv6: # We can not conditionally create this only if the cluster's ALB has IPv6 turned on. Route53 does not let us query a broken AAAA record either. It just shows up as a Route53 record. 504 | Condition: HasZone 505 | Type: "AWS::Route53::RecordSet" 506 | Properties: 507 | AliasTarget: 508 | HostedZoneId: 509 | { 510 | "Fn::ImportValue": !Sub "${ParentClusterStack}-CanonicalHostedZoneID", 511 | } 512 | DNSName: { "Fn::ImportValue": !Sub "${ParentClusterStack}-DNSName" } 513 | HostedZoneId: 514 | { "Fn::ImportValue": !Sub "${ParentZoneStack}-HostedZoneId" } 515 | Name: !Sub 516 | - "${SubDomainNameWithDot}${HostedZoneName}" 517 | - SubDomainNameWithDot: !Ref SubDomainNameWithDot 518 | HostedZoneName: 519 | { "Fn::ImportValue": !Sub "${ParentZoneStack}-HostedZoneName" } 520 | Type: AAAA 521 | TargetGroup: 522 | Type: "AWS::ElasticLoadBalancingV2::TargetGroup" 523 | Properties: 524 | HealthCheckIntervalSeconds: 15 525 | HealthCheckPath: "/" 526 | HealthCheckProtocol: HTTP 527 | HealthCheckTimeoutSeconds: 10 528 | HealthyThresholdCount: 2 529 | UnhealthyThresholdCount: 2 530 | Matcher: 531 | HttpCode: "200-299" 532 | Port: 8080 # overriden when containers are attached 533 | Protocol: HTTP 534 | TargetType: ip 535 | TargetGroupAttributes: 536 | - Key: deregistration_delay.timeout_seconds 537 | Value: !Ref LoadBalancerDeregistrationDelay 538 | VpcId: { "Fn::ImportValue": !Sub "${ParentVPCStack}-VPC" } 539 | HTTPCodeTarget5XXTooHighAlarm: 540 | Condition: HasAlertTopic 541 | Type: "AWS::CloudWatch::Alarm" 542 | Properties: 543 | AlarmDescription: "Application load balancer receives 5XX HTTP status codes from targets" 544 | Namespace: "AWS/ApplicationELB" 545 | MetricName: HTTPCode_Target_5XX_Count 546 | Statistic: Sum 547 | Period: 60 548 | EvaluationPeriods: 1 549 | ComparisonOperator: GreaterThanThreshold 550 | Threshold: 0 551 | AlarmActions: 552 | - { "Fn::ImportValue": !Sub "${ParentAlertStack}-TopicARN" } 553 | Dimensions: 554 | - Name: LoadBalancer 555 | Value: 556 | { 557 | "Fn::ImportValue": !Sub "${ParentClusterStack}-LoadBalancerFullName", 558 | } 559 | - Name: TargetGroup 560 | Value: !GetAtt TargetGroup.TargetGroupFullName 561 | TreatMissingData: notBreaching 562 | TargetConnectionErrorCountTooHighAlarm: 563 | Condition: HasAlertTopic 564 | Type: "AWS::CloudWatch::Alarm" 565 | Properties: 566 | AlarmDescription: "Application load balancer could not connect to targets" 567 | Namespace: "AWS/ApplicationELB" 568 | MetricName: TargetConnectionErrorCount 569 | Statistic: Sum 570 | Period: 60 571 | EvaluationPeriods: 1 572 | ComparisonOperator: GreaterThanThreshold 573 | Threshold: 0 574 | AlarmActions: 575 | - { "Fn::ImportValue": !Sub "${ParentAlertStack}-TopicARN" } 576 | Dimensions: 577 | - Name: LoadBalancer 578 | Value: 579 | { 580 | "Fn::ImportValue": !Sub "${ParentClusterStack}-LoadBalancerFullName", 581 | } 582 | - Name: TargetGroup 583 | Value: !GetAtt TargetGroup.TargetGroupFullName 584 | TreatMissingData: notBreaching 585 | LoadBalancerListenerRule: 586 | Type: "AWS::ElasticLoadBalancingV2::ListenerRule" 587 | Properties: 588 | Actions: 589 | - Type: forward 590 | TargetGroupArn: !Ref TargetGroup 591 | Conditions: !If 592 | - HasLoadBalancerPathPattern 593 | - !If 594 | - HasLoadBalancerHostPattern 595 | - - Field: host-header 596 | Values: 597 | - !Ref LoadBalancerHostPattern 598 | - Field: path-pattern 599 | Values: 600 | - !Sub "${LoadBalancerPathPattern}" 601 | - - Field: path-pattern 602 | Values: 603 | - !Sub "${LoadBalancerPathPattern}" 604 | - !If 605 | - HasLoadBalancerHostPattern 606 | - - Field: host-header 607 | Values: 608 | - !Ref LoadBalancerHostPattern 609 | - [] # neither LoadBalancerHostPattern nor LoadBalancerPathPattern specified 610 | ListenerArn: 611 | !If [ 612 | HasLoadBalancerHttps, 613 | { "Fn::ImportValue": !Sub "${ParentClusterStack}-HttpsListener" }, 614 | { "Fn::ImportValue": !Sub "${ParentClusterStack}-HttpListener" }, 615 | ] 616 | Priority: !Ref LoadBalancerPriority 617 | TaskExecutionRole: 618 | Type: "AWS::IAM::Role" 619 | Properties: 620 | AssumeRolePolicyDocument: 621 | Statement: 622 | - Effect: Allow 623 | Principal: 624 | Service: "ecs-tasks.amazonaws.com" 625 | Action: "sts:AssumeRole" 626 | PermissionsBoundary: 627 | !If [ 628 | HasPermissionsBoundary, 629 | !Ref PermissionsBoundary, 630 | !Ref "AWS::NoValue", 631 | ] 632 | Policies: 633 | - PolicyName: AmazonECSTaskExecutionRolePolicy # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_execution_IAM_role.html 634 | PolicyDocument: 635 | Statement: 636 | - Effect: Allow 637 | Action: 638 | - "ecr:GetAuthorizationToken" 639 | - "ecr:BatchCheckLayerAvailability" 640 | - "ecr:GetDownloadUrlForLayer" 641 | - "ecr:BatchGetImage" 642 | Resource: "*" 643 | - Effect: Allow 644 | Action: 645 | - "s3:GetObject" 646 | - "s3:GetObjectVersion" 647 | Resource: "*" 648 | - Effect: Allow 649 | Action: 650 | - "logs:CreateLogStream" 651 | - "logs:PutLogEvents" 652 | Resource: !GetAtt "LogGroup.Arn" 653 | TaskRole: 654 | Type: "AWS::IAM::Role" 655 | Properties: 656 | AssumeRolePolicyDocument: 657 | Statement: 658 | - Effect: Allow 659 | Principal: 660 | Service: "ecs-tasks.amazonaws.com" 661 | Action: "sts:AssumeRole" 662 | ManagedPolicyArns: 663 | !If [ 664 | HasTaskPolicies, 665 | !Split [",", !Ref TaskPolicies], 666 | !Ref "AWS::NoValue", 667 | ] 668 | PermissionsBoundary: 669 | !If [ 670 | HasPermissionsBoundary, 671 | !Ref PermissionsBoundary, 672 | !Ref "AWS::NoValue", 673 | ] 674 | TaskDefinition: 675 | Type: "AWS::ECS::TaskDefinition" 676 | Properties: 677 | ContainerDefinitions: 678 | - !If 679 | - HasProxyImage 680 | - Name: proxy 681 | Image: !Ref ProxyImage 682 | Command: 683 | !If [HasProxyCommand, !Ref ProxyCommand, !Ref "AWS::NoValue"] 684 | PortMappings: 685 | - ContainerPort: !Ref ProxyPort 686 | Protocol: tcp 687 | Essential: true 688 | LogConfiguration: 689 | LogDriver: awslogs 690 | Options: 691 | "awslogs-region": !Ref "AWS::Region" 692 | "awslogs-group": !Ref LogGroup 693 | "awslogs-stream-prefix": proxy 694 | Environment: 695 | - !If [ 696 | HasProxyEnvironment1Key, 697 | { 698 | Name: !Ref ProxyEnvironment1Key, 699 | Value: !Ref ProxyEnvironment1Value, 700 | }, 701 | !Ref "AWS::NoValue", 702 | ] 703 | - !If [ 704 | HasProxyEnvironment2Key, 705 | { 706 | Name: !Ref ProxyEnvironment2Key, 707 | Value: !Ref ProxyEnvironment2Value, 708 | }, 709 | !Ref "AWS::NoValue", 710 | ] 711 | - !If [ 712 | HasProxyEnvironment3Key, 713 | { 714 | Name: !Ref ProxyEnvironment3Key, 715 | Value: !Ref ProxyEnvironment3Value, 716 | }, 717 | !Ref "AWS::NoValue", 718 | ] 719 | - !Ref "AWS::NoValue" 720 | - Name: app 721 | Image: !Ref AppImage 722 | Command: 723 | !If [ 724 | HasAppCommand, 725 | !Split [",", !Ref AppCommand], 726 | !Ref "AWS::NoValue", 727 | ] 728 | PortMappings: 729 | - ContainerPort: !Ref AppPort 730 | Protocol: tcp 731 | Essential: true 732 | LogConfiguration: 733 | LogDriver: awslogs 734 | Options: 735 | "awslogs-region": !Ref "AWS::Region" 736 | "awslogs-group": !Ref LogGroup 737 | "awslogs-stream-prefix": app 738 | EnvironmentFile: 739 | - !If [ 740 | HasAppEnvironmentS3Arn, 741 | { 742 | Type: s3, 743 | Value: !Ref AppEnvironmentS3Arn, 744 | }, 745 | !Ref "AWS::NoValue", 746 | ] 747 | Environment: 748 | - !If [ 749 | HasAppEnvironment1Key, 750 | { 751 | Name: !Ref AppEnvironment1Key, 752 | Value: !Ref AppEnvironment1Value, 753 | }, 754 | !Ref "AWS::NoValue", 755 | ] 756 | - !If [ 757 | HasAppEnvironment2Key, 758 | { 759 | Name: !Ref AppEnvironment2Key, 760 | Value: !Ref AppEnvironment2Value, 761 | }, 762 | !Ref "AWS::NoValue", 763 | ] 764 | - !If [ 765 | HasAppEnvironment3Key, 766 | { 767 | Name: !Ref AppEnvironment3Key, 768 | Value: !Ref AppEnvironment3Value, 769 | }, 770 | !Ref "AWS::NoValue", 771 | ] 772 | - !If 773 | - HasSidecarImage 774 | - Name: sidecar 775 | Image: !Ref SidecarImage 776 | Command: 777 | !If [HasSidecarCommand, !Ref SidecarCommand, !Ref "AWS::NoValue"] 778 | PortMappings: 779 | - ContainerPort: !Ref SidecarPort 780 | Protocol: tcp 781 | Essential: true 782 | LogConfiguration: 783 | LogDriver: awslogs 784 | Options: 785 | "awslogs-region": !Ref "AWS::Region" 786 | "awslogs-group": !Ref LogGroup 787 | "awslogs-stream-prefix": sidecar 788 | Environment: 789 | - !If [ 790 | HasSidecarEnvironment1Key, 791 | { 792 | Name: !Ref SidecarEnvironment1Key, 793 | Value: !Ref SidecarEnvironment1Value, 794 | }, 795 | !Ref "AWS::NoValue", 796 | ] 797 | - !If [ 798 | HasSidecarEnvironment2Key, 799 | { 800 | Name: !Ref SidecarEnvironment2Key, 801 | Value: !Ref SidecarEnvironment2Value, 802 | }, 803 | !Ref "AWS::NoValue", 804 | ] 805 | - !If [ 806 | HasSidecarEnvironment3Key, 807 | { 808 | Name: !Ref SidecarEnvironment3Key, 809 | Value: !Ref SidecarEnvironment3Value, 810 | }, 811 | !Ref "AWS::NoValue", 812 | ] 813 | - !Ref "AWS::NoValue" 814 | Cpu: !FindInMap [CpuMap, !Ref Cpu, Cpu] 815 | ExecutionRoleArn: !GetAtt "TaskExecutionRole.Arn" 816 | Family: !Ref "AWS::StackName" 817 | Memory: !FindInMap [MemoryMap, !Ref Memory, Memory] 818 | NetworkMode: awsvpc 819 | RequiresCompatibilities: [FARGATE] 820 | TaskRoleArn: !GetAtt "TaskRole.Arn" 821 | LogGroup: 822 | Type: "AWS::Logs::LogGroup" 823 | Properties: 824 | RetentionInDays: !Ref LogsRetentionInDays 825 | ServiceSecurityGroup: 826 | Type: "AWS::EC2::SecurityGroup" 827 | Properties: 828 | GroupDescription: !Sub "${AWS::StackName}-service" 829 | VpcId: { "Fn::ImportValue": !Sub "${ParentVPCStack}-VPC" } 830 | SecurityGroupIngress: 831 | - SourceSecurityGroupId: 832 | { 833 | "Fn::ImportValue": !Sub "${ParentClusterStack}-LoadBalancerSecurityGroup", 834 | } 835 | FromPort: !If [HasProxyImage, !Ref ProxyPort, !Ref AppPort] 836 | ToPort: !If [HasProxyImage, !Ref ProxyPort, !Ref AppPort] 837 | IpProtocol: tcp 838 | Service: 839 | DependsOn: LoadBalancerListenerRule 840 | Type: "AWS::ECS::Service" 841 | Properties: 842 | CapacityProviderStrategy: 843 | - Base: 0 844 | CapacityProvider: FARGATE 845 | Weight: 2 # 2 tasks run on FARGATE 846 | - Base: 0 847 | CapacityProvider: FARGATE_SPOT 848 | Weight: 1 # 1 tasks run on FARGATE_SPOT 849 | Cluster: { "Fn::ImportValue": !Sub "${ParentClusterStack}-Cluster" } 850 | DeploymentConfiguration: 851 | MaximumPercent: 200 852 | MinimumHealthyPercent: 100 853 | DeploymentCircuitBreaker: 854 | Enable: true 855 | Rollback: true 856 | DesiredCount: !Ref DesiredCount 857 | HealthCheckGracePeriodSeconds: !Ref HealthCheckGracePeriod 858 | LoadBalancers: 859 | - ContainerName: !If [HasProxyImage, proxy, app] 860 | ContainerPort: !If [HasProxyImage, !Ref ProxyPort, !Ref AppPort] 861 | TargetGroupArn: !Ref TargetGroup 862 | NetworkConfiguration: 863 | AwsvpcConfiguration: 864 | AssignPublicIp: !If [HasSubnetsReachPublic, ENABLED, DISABLED] 865 | SecurityGroups: 866 | - !Ref ServiceSecurityGroup 867 | - !If [ 868 | HasClientSecurityGroup1, 869 | { 870 | "Fn::ImportValue": !Sub "${ParentClientStack1}-ClientSecurityGroup", 871 | }, 872 | !Ref "AWS::NoValue", 873 | ] 874 | - !If [ 875 | HasClientSecurityGroup2, 876 | { 877 | "Fn::ImportValue": !Sub "${ParentClientStack2}-ClientSecurityGroup", 878 | }, 879 | !Ref "AWS::NoValue", 880 | ] 881 | - !If [ 882 | HasClientSecurityGroup3, 883 | { 884 | "Fn::ImportValue": !Sub "${ParentClientStack3}-ClientSecurityGroup", 885 | }, 886 | !Ref "AWS::NoValue", 887 | ] 888 | Subnets: 889 | !Split [ 890 | ",", 891 | { 892 | "Fn::ImportValue": !Sub "${ParentVPCStack}-Subnets${SubnetsReach}", 893 | }, 894 | ] 895 | PlatformVersion: "1.4.0" 896 | TaskDefinition: !Ref TaskDefinition 897 | CPUUtilizationTooHighAlarm: 898 | Condition: HasAlertTopic 899 | Type: "AWS::CloudWatch::Alarm" 900 | Properties: 901 | AlarmDescription: "Average CPU utilization over last 10 minutes higher than 80%" 902 | Namespace: "AWS/ECS" 903 | Dimensions: 904 | - Name: ClusterName 905 | Value: { "Fn::ImportValue": !Sub "${ParentClusterStack}-Cluster" } 906 | - Name: ServiceName 907 | Value: !GetAtt "Service.Name" 908 | MetricName: CPUUtilization 909 | ComparisonOperator: GreaterThanThreshold 910 | Statistic: Average 911 | Period: 300 912 | EvaluationPeriods: 1 913 | Threshold: 80 914 | AlarmActions: 915 | - { "Fn::ImportValue": !Sub "${ParentAlertStack}-TopicARN" } 916 | ServiceFailedNotification: 917 | Condition: HasAlertTopic 918 | Type: "AWS::Events::Rule" 919 | Properties: 920 | EventPattern: 921 | source: 922 | - "aws.ecs" 923 | "detail-type": 924 | - "ECS Service Action" 925 | resources: 926 | - !Ref Service 927 | detail: 928 | eventType: 929 | - ERROR 930 | - WARN 931 | State: ENABLED 932 | Targets: 933 | - Arn: { "Fn::ImportValue": !Sub "${ParentAlertStack}-TopicARN" } 934 | Id: rule 935 | ScalableTargetRole: # based on http://docs.aws.amazon.com/AmazonECS/latest/developerguide/autoscale_IAM_role.html 936 | Condition: HasAutoScaling 937 | Type: "AWS::IAM::Role" 938 | Properties: 939 | AssumeRolePolicyDocument: 940 | Version: "2012-10-17" 941 | Statement: 942 | - Effect: Allow 943 | Principal: 944 | Service: "application-autoscaling.amazonaws.com" 945 | Action: "sts:AssumeRole" 946 | PermissionsBoundary: 947 | !If [ 948 | HasPermissionsBoundary, 949 | !Ref PermissionsBoundary, 950 | !Ref "AWS::NoValue", 951 | ] 952 | Policies: 953 | - PolicyName: AmazonEC2ContainerServiceAutoscaleRole 954 | PolicyDocument: 955 | Version: "2012-10-17" 956 | Statement: 957 | - Effect: Allow 958 | Action: 959 | - "ecs:DescribeServices" 960 | - "ecs:UpdateService" 961 | Resource: "*" 962 | - PolicyName: cloudwatch 963 | PolicyDocument: 964 | Version: "2012-10-17" 965 | Statement: 966 | - Effect: Allow 967 | Action: 968 | - "cloudwatch:DescribeAlarms" 969 | Resource: "*" 970 | ScalableTarget: 971 | Condition: HasAutoScaling 972 | Type: "AWS::ApplicationAutoScaling::ScalableTarget" 973 | Properties: 974 | MaxCapacity: !Ref MaxCapacity 975 | MinCapacity: !Ref MinCapacity 976 | ResourceId: !Sub 977 | - "service/${Cluster}/${Service}" 978 | - Cluster: { "Fn::ImportValue": !Sub "${ParentClusterStack}-Cluster" } 979 | Service: !GetAtt "Service.Name" 980 | RoleARN: !GetAtt "ScalableTargetRole.Arn" 981 | ScalableDimension: "ecs:service:DesiredCount" 982 | ServiceNamespace: ecs 983 | ScaleUpPolicy: 984 | Condition: HasAutoScaling 985 | Type: "AWS::ApplicationAutoScaling::ScalingPolicy" 986 | Properties: 987 | PolicyName: !Sub "${AWS::StackName}-scale-up" 988 | PolicyType: StepScaling 989 | ScalingTargetId: !Ref ScalableTarget 990 | StepScalingPolicyConfiguration: 991 | AdjustmentType: PercentChangeInCapacity 992 | Cooldown: 300 993 | MinAdjustmentMagnitude: 1 994 | StepAdjustments: 995 | - MetricIntervalLowerBound: 0 996 | ScalingAdjustment: 25 997 | ScaleDownPolicy: 998 | Condition: HasAutoScaling 999 | Type: "AWS::ApplicationAutoScaling::ScalingPolicy" 1000 | Properties: 1001 | PolicyName: !Sub "${AWS::StackName}-scale-down" 1002 | PolicyType: StepScaling 1003 | ScalingTargetId: !Ref ScalableTarget 1004 | StepScalingPolicyConfiguration: 1005 | AdjustmentType: PercentChangeInCapacity 1006 | Cooldown: 300 1007 | MinAdjustmentMagnitude: 1 1008 | StepAdjustments: 1009 | - MetricIntervalUpperBound: 0 1010 | ScalingAdjustment: -25 1011 | CPUUtilizationHighAlarm: 1012 | Condition: HasAutoScaling 1013 | Type: "AWS::CloudWatch::Alarm" 1014 | Properties: 1015 | AlarmDescription: "Service is running out of CPU" 1016 | Namespace: "AWS/ECS" 1017 | Dimensions: 1018 | - Name: ClusterName 1019 | Value: { "Fn::ImportValue": !Sub "${ParentClusterStack}-Cluster" } 1020 | - Name: ServiceName 1021 | Value: !GetAtt "Service.Name" 1022 | MetricName: CPUUtilization 1023 | ComparisonOperator: GreaterThanThreshold 1024 | Statistic: Average 1025 | Period: 300 1026 | EvaluationPeriods: 1 1027 | Threshold: 60 1028 | AlarmActions: 1029 | - !Ref ScaleUpPolicy 1030 | CPUUtilizationLowAlarm: 1031 | Condition: HasAutoScaling 1032 | Type: "AWS::CloudWatch::Alarm" 1033 | Properties: 1034 | AlarmDescription: "Service is wasting CPU" 1035 | Namespace: "AWS/ECS" 1036 | Dimensions: 1037 | - Name: ClusterName 1038 | Value: { "Fn::ImportValue": !Sub "${ParentClusterStack}-Cluster" } 1039 | - Name: ServiceName 1040 | Value: !GetAtt "Service.Name" 1041 | MetricName: CPUUtilization 1042 | ComparisonOperator: LessThanThreshold 1043 | Statistic: Average 1044 | Period: 300 1045 | EvaluationPeriods: 3 1046 | Threshold: 30 1047 | AlarmActions: 1048 | - !Ref ScaleDownPolicy 1049 | Outputs: 1050 | TemplateID: 1051 | Description: "cloudonaut.io template id." 1052 | Value: "fargate/service-cluster-alb" 1053 | TemplateVersion: 1054 | Description: "cloudonaut.io template version." 1055 | Value: "__VERSION__" 1056 | StackName: 1057 | Description: "Stack name." 1058 | Value: !Sub "${AWS::StackName}" 1059 | -------------------------------------------------------------------------------- /deploy/vpc-2azs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright 2018 widdix GmbH 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | AWSTemplateFormatVersion: '2010-09-09' 16 | Description: 'VPC: public and private subnets in two availability zones, a cloudonaut.io template' 17 | Metadata: 18 | 'AWS::CloudFormation::Interface': 19 | ParameterGroups: 20 | - Label: 21 | default: 'VPC Parameters' 22 | Parameters: 23 | - ClassB 24 | Parameters: 25 | ClassB: 26 | Description: 'Class B of VPC (10.XXX.0.0/16)' 27 | Type: Number 28 | Default: 0 29 | ConstraintDescription: 'Must be in the range [0-255]' 30 | MinValue: 0 31 | MaxValue: 255 32 | Resources: 33 | VPC: 34 | Type: 'AWS::EC2::VPC' 35 | Properties: 36 | CidrBlock: !Sub '10.${ClassB}.0.0/16' 37 | EnableDnsSupport: true 38 | EnableDnsHostnames: true 39 | InstanceTenancy: default 40 | Tags: 41 | - Key: Name 42 | Value: !Sub '10.${ClassB}.0.0/16' 43 | VPCCidrBlock: 44 | Type: 'AWS::EC2::VPCCidrBlock' 45 | Properties: 46 | AmazonProvidedIpv6CidrBlock: true 47 | VpcId: !Ref VPC 48 | InternetGateway: 49 | Type: 'AWS::EC2::InternetGateway' 50 | Properties: 51 | Tags: 52 | - Key: Name 53 | Value: !Sub '10.${ClassB}.0.0/16' 54 | EgressOnlyInternetGateway: 55 | Type: 'AWS::EC2::EgressOnlyInternetGateway' 56 | Properties: 57 | VpcId: !Ref VPC 58 | VPCGatewayAttachment: 59 | Type: 'AWS::EC2::VPCGatewayAttachment' 60 | Properties: 61 | VpcId: !Ref VPC 62 | InternetGatewayId: !Ref InternetGateway 63 | SubnetAPublic: 64 | DependsOn: VPCCidrBlock 65 | Type: 'AWS::EC2::Subnet' 66 | Properties: 67 | #AssignIpv6AddressOnCreation: true # TODO can not be set if MapPublicIpOnLaunch is true as well 68 | AvailabilityZone: !Select [0, !GetAZs ''] 69 | CidrBlock: !Sub '10.${ClassB}.0.0/20' 70 | Ipv6CidrBlock: !Select [0, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] 71 | MapPublicIpOnLaunch: true 72 | VpcId: !Ref VPC 73 | Tags: 74 | - Key: Name 75 | Value: 'A public' 76 | - Key: Reach 77 | Value: public 78 | SubnetAPrivate: 79 | DependsOn: VPCCidrBlock 80 | Type: 'AWS::EC2::Subnet' 81 | Properties: 82 | AssignIpv6AddressOnCreation: false 83 | AvailabilityZone: !Select [0, !GetAZs ''] 84 | CidrBlock: !Sub '10.${ClassB}.16.0/20' 85 | Ipv6CidrBlock: !Select [1, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] 86 | VpcId: !Ref VPC 87 | Tags: 88 | - Key: Name 89 | Value: 'A private' 90 | - Key: Reach 91 | Value: private 92 | SubnetBPublic: 93 | DependsOn: VPCCidrBlock 94 | Type: 'AWS::EC2::Subnet' 95 | Properties: 96 | #AssignIpv6AddressOnCreation: true # TODO can not be set if MapPublicIpOnLaunch is true as well 97 | AvailabilityZone: !Select [1, !GetAZs ''] 98 | CidrBlock: !Sub '10.${ClassB}.32.0/20' 99 | Ipv6CidrBlock: !Select [2, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] 100 | MapPublicIpOnLaunch: true 101 | VpcId: !Ref VPC 102 | Tags: 103 | - Key: Name 104 | Value: 'B public' 105 | - Key: Reach 106 | Value: public 107 | SubnetBPrivate: 108 | DependsOn: VPCCidrBlock 109 | Type: 'AWS::EC2::Subnet' 110 | Properties: 111 | AssignIpv6AddressOnCreation: false 112 | AvailabilityZone: !Select [1, !GetAZs ''] 113 | CidrBlock: !Sub '10.${ClassB}.48.0/20' 114 | Ipv6CidrBlock: !Select [3, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] 115 | VpcId: !Ref VPC 116 | Tags: 117 | - Key: Name 118 | Value: 'B private' 119 | - Key: Reach 120 | Value: private 121 | RouteTablePublic: # should be RouteTableAPublic, but logical id was not changed for backward compatibility 122 | Type: 'AWS::EC2::RouteTable' 123 | Properties: 124 | VpcId: !Ref VPC 125 | Tags: 126 | - Key: Name 127 | Value: 'A Public' 128 | RouteTablePrivate: # should be RouteTableAPrivate, but logical id was not changed for backward compatibility 129 | Type: 'AWS::EC2::RouteTable' 130 | Properties: 131 | VpcId: !Ref VPC 132 | Tags: 133 | - Key: Name 134 | Value: 'A Private' 135 | RouteTableBPublic: 136 | Type: 'AWS::EC2::RouteTable' 137 | Properties: 138 | VpcId: !Ref VPC 139 | Tags: 140 | - Key: Name 141 | Value: 'B Public' 142 | RouteTableBPrivate: 143 | Type: 'AWS::EC2::RouteTable' 144 | Properties: 145 | VpcId: !Ref VPC 146 | Tags: 147 | - Key: Name 148 | Value: 'B Private' 149 | RouteTableAssociationAPublic: 150 | Type: 'AWS::EC2::SubnetRouteTableAssociation' 151 | Properties: 152 | SubnetId: !Ref SubnetAPublic 153 | RouteTableId: !Ref RouteTablePublic 154 | RouteTableAssociationAPrivate: 155 | Type: 'AWS::EC2::SubnetRouteTableAssociation' 156 | Properties: 157 | SubnetId: !Ref SubnetAPrivate 158 | RouteTableId: !Ref RouteTablePrivate 159 | RouteTableAssociationBPublic: 160 | Type: 'AWS::EC2::SubnetRouteTableAssociation' 161 | Properties: 162 | SubnetId: !Ref SubnetBPublic 163 | RouteTableId: !Ref RouteTableBPublic 164 | RouteTableAssociationBPrivate: 165 | Type: 'AWS::EC2::SubnetRouteTableAssociation' 166 | Properties: 167 | SubnetId: !Ref SubnetBPrivate 168 | RouteTableId: !Ref RouteTableBPrivate 169 | RouteTablePublicInternetRoute: # should be RouteTablePublicAInternetRoute, but logical id was not changed for backward compatibility 170 | Type: 'AWS::EC2::Route' 171 | DependsOn: VPCGatewayAttachment 172 | Properties: 173 | RouteTableId: !Ref RouteTablePublic 174 | DestinationCidrBlock: '0.0.0.0/0' 175 | GatewayId: !Ref InternetGateway 176 | RouteTablePublicAInternetRouteIPv6: 177 | Type: 'AWS::EC2::Route' 178 | DependsOn: VPCGatewayAttachment 179 | Properties: 180 | RouteTableId: !Ref RouteTablePublic 181 | DestinationIpv6CidrBlock: '::/0' 182 | GatewayId: !Ref InternetGateway 183 | RouteTablePrivateAInternetRouteIPv6: 184 | Type: 'AWS::EC2::Route' 185 | Properties: 186 | RouteTableId: !Ref RouteTablePrivate 187 | DestinationIpv6CidrBlock: '::/0' 188 | EgressOnlyInternetGatewayId: !Ref EgressOnlyInternetGateway 189 | RouteTablePublicBInternetRoute: 190 | Type: 'AWS::EC2::Route' 191 | DependsOn: VPCGatewayAttachment 192 | Properties: 193 | RouteTableId: !Ref RouteTableBPublic 194 | DestinationCidrBlock: '0.0.0.0/0' 195 | GatewayId: !Ref InternetGateway 196 | RouteTablePublicBInternetRouteIPv6: 197 | Type: 'AWS::EC2::Route' 198 | DependsOn: VPCGatewayAttachment 199 | Properties: 200 | RouteTableId: !Ref RouteTableBPublic 201 | DestinationIpv6CidrBlock: '::/0' 202 | GatewayId: !Ref InternetGateway 203 | RouteTablePrivateBInternetRouteIPv6: 204 | Type: 'AWS::EC2::Route' 205 | Properties: 206 | RouteTableId: !Ref RouteTableBPrivate 207 | DestinationIpv6CidrBlock: '::/0' 208 | EgressOnlyInternetGatewayId: !Ref EgressOnlyInternetGateway 209 | NetworkAclPublic: 210 | Type: 'AWS::EC2::NetworkAcl' 211 | Properties: 212 | VpcId: !Ref VPC 213 | Tags: 214 | - Key: Name 215 | Value: Public 216 | NetworkAclPrivate: 217 | Type: 'AWS::EC2::NetworkAcl' 218 | Properties: 219 | VpcId: !Ref VPC 220 | Tags: 221 | - Key: Name 222 | Value: Private 223 | SubnetNetworkAclAssociationAPublic: 224 | Type: 'AWS::EC2::SubnetNetworkAclAssociation' 225 | Properties: 226 | SubnetId: !Ref SubnetAPublic 227 | NetworkAclId: !Ref NetworkAclPublic 228 | SubnetNetworkAclAssociationAPrivate: 229 | Type: 'AWS::EC2::SubnetNetworkAclAssociation' 230 | Properties: 231 | SubnetId: !Ref SubnetAPrivate 232 | NetworkAclId: !Ref NetworkAclPrivate 233 | SubnetNetworkAclAssociationBPublic: 234 | Type: 'AWS::EC2::SubnetNetworkAclAssociation' 235 | Properties: 236 | SubnetId: !Ref SubnetBPublic 237 | NetworkAclId: !Ref NetworkAclPublic 238 | SubnetNetworkAclAssociationBPrivate: 239 | Type: 'AWS::EC2::SubnetNetworkAclAssociation' 240 | Properties: 241 | SubnetId: !Ref SubnetBPrivate 242 | NetworkAclId: !Ref NetworkAclPrivate 243 | NetworkAclEntryInPublicAllowAll: 244 | Type: 'AWS::EC2::NetworkAclEntry' 245 | Properties: 246 | NetworkAclId: !Ref NetworkAclPublic 247 | RuleNumber: 99 248 | Protocol: -1 249 | RuleAction: allow 250 | Egress: false 251 | CidrBlock: '0.0.0.0/0' 252 | NetworkAclEntryInPublicAllowAllIPv6: 253 | Type: 'AWS::EC2::NetworkAclEntry' 254 | Properties: 255 | NetworkAclId: !Ref NetworkAclPublic 256 | RuleNumber: 98 257 | Protocol: -1 258 | RuleAction: allow 259 | Egress: false 260 | Ipv6CidrBlock: '::/0' 261 | NetworkAclEntryOutPublicAllowAll: 262 | Type: 'AWS::EC2::NetworkAclEntry' 263 | Properties: 264 | NetworkAclId: !Ref NetworkAclPublic 265 | RuleNumber: 99 266 | Protocol: -1 267 | RuleAction: allow 268 | Egress: true 269 | CidrBlock: '0.0.0.0/0' 270 | NetworkAclEntryOutPublicAllowAllIPv6: 271 | Type: 'AWS::EC2::NetworkAclEntry' 272 | Properties: 273 | NetworkAclId: !Ref NetworkAclPublic 274 | RuleNumber: 98 275 | Protocol: -1 276 | RuleAction: allow 277 | Egress: true 278 | Ipv6CidrBlock: '::/0' 279 | NetworkAclEntryInPrivateAllowAll: 280 | Type: 'AWS::EC2::NetworkAclEntry' 281 | Properties: 282 | NetworkAclId: !Ref NetworkAclPrivate 283 | RuleNumber: 99 284 | Protocol: -1 285 | RuleAction: allow 286 | Egress: false 287 | CidrBlock: '0.0.0.0/0' 288 | NetworkAclEntryInPrivateAllowAllIPv6: 289 | Type: 'AWS::EC2::NetworkAclEntry' 290 | Properties: 291 | NetworkAclId: !Ref NetworkAclPrivate 292 | RuleNumber: 98 293 | Protocol: -1 294 | RuleAction: allow 295 | Egress: false 296 | Ipv6CidrBlock: '::/0' 297 | NetworkAclEntryOutPrivateAllowAll: 298 | Type: 'AWS::EC2::NetworkAclEntry' 299 | Properties: 300 | NetworkAclId: !Ref NetworkAclPrivate 301 | RuleNumber: 99 302 | Protocol: -1 303 | RuleAction: allow 304 | Egress: true 305 | CidrBlock: '0.0.0.0/0' 306 | NetworkAclEntryOutPrivateAllowAllIPv6: 307 | Type: 'AWS::EC2::NetworkAclEntry' 308 | Properties: 309 | NetworkAclId: !Ref NetworkAclPrivate 310 | RuleNumber: 98 311 | Protocol: -1 312 | RuleAction: allow 313 | Egress: true 314 | Ipv6CidrBlock: '::/0' 315 | Outputs: 316 | TemplateID: 317 | Description: 'cloudonaut.io template id.' 318 | Value: 'vpc/vpc-2azs' 319 | TemplateVersion: 320 | Description: 'cloudonaut.io template version.' 321 | Value: '__VERSION__' 322 | StackName: 323 | Description: 'Stack name.' 324 | Value: !Sub '${AWS::StackName}' 325 | AZs: # Better name would be NumberOfAZs, but we keep the name for backward compatibility 326 | Description: 'Number of AZs' 327 | Value: 2 328 | Export: 329 | Name: !Sub '${AWS::StackName}-AZs' 330 | AZList: # Better name would be AZs, but the name was already used 331 | Description: 'List of AZs' 332 | Value: !Join [',', [!Select [0, !GetAZs ''], !Select [1, !GetAZs '']]] 333 | Export: 334 | Name: !Sub '${AWS::StackName}-AZList' 335 | AZA: 336 | Description: 'AZ of A' 337 | Value: !Select [0, !GetAZs ''] 338 | Export: 339 | Name: !Sub '${AWS::StackName}-AZA' 340 | AZB: 341 | Description: 'AZ of B' 342 | Value: !Select [1, !GetAZs ''] 343 | Export: 344 | Name: !Sub '${AWS::StackName}-AZB' 345 | CidrBlock: 346 | Description: 'The set of IP addresses for the VPC.' 347 | Value: !GetAtt 'VPC.CidrBlock' 348 | Export: 349 | Name: !Sub '${AWS::StackName}-CidrBlock' 350 | CidrBlockIPv6: 351 | Description: 'The set of IPv6 addresses for the VPC.' 352 | Value: !Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'] 353 | Export: 354 | Name: !Sub '${AWS::StackName}-CidrBlockIPv6' 355 | VPC: 356 | Description: 'VPC.' 357 | Value: !Ref VPC 358 | Export: 359 | Name: !Sub '${AWS::StackName}-VPC' 360 | InternetGateway: 361 | Description: 'InternetGateway.' 362 | Value: !Ref InternetGateway 363 | Export: 364 | Name: !Sub '${AWS::StackName}-InternetGateway' 365 | SubnetsPublic: 366 | Description: 'Subnets public.' 367 | Value: !Join [',', [!Ref SubnetAPublic, !Ref SubnetBPublic]] 368 | Export: 369 | Name: !Sub '${AWS::StackName}-SubnetsPublic' 370 | SubnetsPrivate: 371 | Description: 'Subnets private.' 372 | Value: !Join [',', [!Ref SubnetAPrivate, !Ref SubnetBPrivate]] 373 | Export: 374 | Name: !Sub '${AWS::StackName}-SubnetsPrivate' 375 | RouteTablesPrivate: 376 | Description: 'Route tables private.' 377 | Value: !Join [',', [!Ref RouteTablePrivate, !Ref RouteTableBPrivate]] 378 | Export: 379 | Name: !Sub '${AWS::StackName}-RouteTablesPrivate' 380 | RouteTablesPublic: 381 | Description: 'Route tables public.' 382 | Value: !Join [',', [!Ref RouteTablePublic, !Ref RouteTableBPublic]] 383 | Export: 384 | Name: !Sub '${AWS::StackName}-RouteTablesPublic' 385 | SubnetAPublic: 386 | Description: 'Subnet A public.' 387 | Value: !Ref SubnetAPublic 388 | Export: 389 | Name: !Sub '${AWS::StackName}-SubnetAPublic' 390 | RouteTableAPublic: 391 | Description: 'Route table A public.' 392 | Value: !Ref RouteTablePublic 393 | Export: 394 | Name: !Sub '${AWS::StackName}-RouteTableAPublic' 395 | SubnetAPrivate: 396 | Description: 'Subnet A private.' 397 | Value: !Ref SubnetAPrivate 398 | Export: 399 | Name: !Sub '${AWS::StackName}-SubnetAPrivate' 400 | RouteTableAPrivate: 401 | Description: 'Route table A private.' 402 | Value: !Ref RouteTablePrivate 403 | Export: 404 | Name: !Sub '${AWS::StackName}-RouteTableAPrivate' 405 | SubnetBPublic: 406 | Description: 'Subnet B public.' 407 | Value: !Ref SubnetBPublic 408 | Export: 409 | Name: !Sub '${AWS::StackName}-SubnetBPublic' 410 | RouteTableBPublic: 411 | Description: 'Route table B public.' 412 | Value: !Ref RouteTableBPublic 413 | Export: 414 | Name: !Sub '${AWS::StackName}-RouteTableBPublic' 415 | SubnetBPrivate: 416 | Description: 'Subnet B private.' 417 | Value: !Ref SubnetBPrivate 418 | Export: 419 | Name: !Sub '${AWS::StackName}-SubnetBPrivate' 420 | RouteTableBPrivate: 421 | Description: 'Route table B private.' 422 | Value: !Ref RouteTableBPrivate 423 | Export: 424 | Name: !Sub '${AWS::StackName}-RouteTableBPrivate' -------------------------------------------------------------------------------- /deploy/vpc-ssh-bastion.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Description: LinuxBastion+VPC Jul,30,2020 (qs-1qup6ra99) (Please do not remove) 3 | Metadata: 4 | LICENSE: Apache License, Version 2.0 5 | "AWS::CloudFormation::Interface": 6 | ParameterGroups: 7 | - Label: 8 | default: "VPC parent stack" 9 | Parameters: 10 | - ParentVPCStack 11 | - Label: 12 | default: Amazon EC2 configuration 13 | Parameters: 14 | - KeyPairName 15 | - BastionAMIOS 16 | - BastionInstanceType 17 | - RootVolumeSize 18 | - Label: 19 | default: Linux bastion configuration 20 | Parameters: 21 | - NumBastionHosts 22 | - BastionHostName 23 | - BastionTenancy 24 | - EnableBanner 25 | - BastionBanner 26 | - EnableTCPForwarding 27 | - EnableX11Forwarding 28 | - Label: 29 | default: Alternative configurations 30 | Parameters: 31 | - AlternativeInitializationScript 32 | - OSImageOverride 33 | - AlternativeIAMRole 34 | - EnvironmentVariables 35 | - Label: 36 | default: AWS Quick Start configuration 37 | Parameters: 38 | - QSS3BucketName 39 | - QSS3KeyPrefix 40 | - QSS3BucketRegion 41 | ParameterLabels: 42 | AlternativeIAMRole: 43 | default: Alternative IAM role 44 | AlternativeInitializationScript: 45 | default: Alternative initialization script 46 | BastionAMIOS: 47 | default: Bastion AMI operating system 48 | BastionHostName: 49 | default: Bastion Host Name 50 | BastionTenancy: 51 | default: Bastion tenancy 52 | BastionBanner: 53 | default: Banner text 54 | QSS3BucketRegion: 55 | default: Quick Start S3 bucket region 56 | BastionInstanceType: 57 | default: Bastion instance type 58 | EnableBanner: 59 | default: Bastion banner 60 | EnableTCPForwarding: 61 | default: TCP forwarding 62 | EnableX11Forwarding: 63 | default: X11 forwarding 64 | EnvironmentVariables: 65 | default: Environment variables 66 | KeyPairName: 67 | default: Key pair name 68 | NumBastionHosts: 69 | default: Number of bastion hosts 70 | OSImageOverride: 71 | default: Operating system override 72 | QSS3BucketName: 73 | default: Quick Start S3 bucket name 74 | QSS3KeyPrefix: 75 | default: Quick Start S3 key prefix 76 | 77 | RootVolumeSize: 78 | default: Root volume size 79 | cfn-lint: { config: { ignore_checks: [E9007] } } 80 | Parameters: 81 | ParentVPCStack: 82 | Description: "Stack name of parent VPC stack based on vpc/vpc-*azs.yaml template." 83 | Type: String 84 | BastionAMIOS: 85 | AllowedValues: 86 | - Amazon-Linux2-HVM 87 | - CentOS-7-HVM 88 | - Ubuntu-Server-20.04-LTS-HVM 89 | - SUSE-SLES-15-HVM 90 | Default: Amazon-Linux2-HVM 91 | Description: The Linux distribution for the AMI to be used for the bastion instances. 92 | Type: String 93 | BastionHostName: 94 | Default: "LinuxBastion" 95 | Description: The value used for the name tag of the bastion host 96 | Type: String 97 | BastionBanner: 98 | Default: "" 99 | Description: Banner text to display upon login. 100 | Type: String 101 | BastionTenancy: 102 | Description: "VPC tenancy to launch the bastion in. Options: 'dedicated' or 'default'" 103 | Type: String 104 | Default: default 105 | AllowedValues: 106 | - dedicated 107 | - default 108 | BastionInstanceType: 109 | AllowedValues: 110 | - t2.nano 111 | - t2.micro 112 | - t2.small 113 | - t2.medium 114 | - t2.large 115 | - t3.micro 116 | - t3.small 117 | - t3.medium 118 | - t3.large 119 | - t3.xlarge 120 | - t3.2xlarge 121 | - m4.large 122 | - m4.xlarge 123 | - m4.2xlarge 124 | - m4.4xlarge 125 | Default: t2.micro 126 | Description: Amazon EC2 instance type for the bastion instances. 127 | Type: String 128 | EnableBanner: 129 | AllowedValues: 130 | - "true" 131 | - "false" 132 | Default: "false" 133 | Description: 134 | To include a banner to be displayed when connecting via SSH to the 135 | bastion, choose true. 136 | Type: String 137 | EnableTCPForwarding: 138 | Type: String 139 | Description: To enable TCP forwarding, choose true. 140 | Default: "false" 141 | AllowedValues: 142 | - "true" 143 | - "false" 144 | EnableX11Forwarding: 145 | Type: String 146 | Description: To enable X11 forwarding, choose true. 147 | Default: "false" 148 | AllowedValues: 149 | - "true" 150 | - "false" 151 | KeyPairName: 152 | Description: 153 | Name of an existing public/private key pair. If you do not have one in this AWS Region, 154 | please create it before continuing. 155 | Type: "AWS::EC2::KeyPair::KeyName" 156 | NumBastionHosts: 157 | AllowedValues: 158 | - "1" 159 | - "2" 160 | - "3" 161 | - "4" 162 | Default: "1" 163 | Description: The number of bastion hosts to create. The maximum number is four. 164 | Type: String 165 | QSS3BucketName: 166 | AllowedPattern: "^[0-9a-zA-Z]+([0-9a-zA-Z-]*[0-9a-zA-Z])*$" 167 | ConstraintDescription: 168 | Quick Start bucket name can include numbers, lowercase letters, uppercase 169 | letters, and hyphens (-). It cannot start or end with a hyphen (-). 170 | Default: aws-quickstart 171 | Description: 172 | S3 bucket name for the Quick Start assets. Quick Start bucket name can 173 | include numbers, lowercase letters, uppercase letters, and hyphens (-). It 174 | cannot start or end with a hyphen (-). 175 | Type: String 176 | QSS3BucketRegion: 177 | Default: "us-east-1" 178 | Description: The AWS Region where the Quick Start S3 bucket (QSS3BucketName) is hosted. When using your own bucket, you must specify this value. 179 | Type: String 180 | QSS3KeyPrefix: 181 | AllowedPattern: "^([0-9a-zA-Z-.]+/)*$" 182 | ConstraintDescription: 183 | Quick Start key prefix can include numbers, lowercase letters, uppercase 184 | letters, hyphens (-), dots (.) and forward slash (/). The prefix should 185 | end with a forward slash (/). 186 | Default: quickstart-linux-bastion/ 187 | Description: 188 | S3 key prefix for the Quick Start assets. Quick Start key prefix can 189 | include numbers, lowercase letters, uppercase letters, hyphens (-), dots 190 | (.) and forward slash (/) and it should end with a forward slash (/). 191 | Type: String 192 | 193 | AlternativeInitializationScript: 194 | AllowedPattern: ^http.*|^$ 195 | ConstraintDescription: URL must begin with http 196 | Description: An alternative initialization script to run during setup. 197 | Default: "" 198 | Type: String 199 | OSImageOverride: 200 | Description: The Region-specific image to use for the instance. 201 | Type: String 202 | Default: "" 203 | AlternativeIAMRole: 204 | Description: 205 | An existing IAM Role name to attach to the bastion. If left blank, 206 | a new role will be created. 207 | Default: "" 208 | Type: String 209 | EnvironmentVariables: 210 | Description: A comma-separated list of environment variables for use in 211 | bootstrapping. Variables must be in the format KEY=VALUE. VALUE cannot 212 | contain commas. 213 | Type: String 214 | Default: "" 215 | RootVolumeSize: 216 | Description: The size in GB for the root EBS volume. 217 | Type: Number 218 | Default: "10" 219 | 220 | Mappings: 221 | AWSAMIRegionMap: 222 | af-south-1: 223 | AMZNLINUX2: ami-0bb140f2ff1df29fc 224 | US2004HVM: ami-012b8921f84acdd04 225 | CENTOS7HVM: ami-0a2be7731769e6cc1 226 | # SLES15HVM: ami-EXAMPLE 227 | ap-northeast-1: 228 | AMZNLINUX2: ami-00f045aed21a55240 229 | US2004HVM: ami-0f2322bff98877761 230 | CENTOS7HVM: ami-06a46da680048c8ae 231 | SLES15HVM: ami-056ac8ad44e6a7e1f 232 | ap-northeast-2: 233 | AMZNLINUX2: ami-03461b78fdba0ff9d 234 | US2004HVM: ami-0cd95d39e3d87eb40 235 | CENTOS7HVM: ami-06e83aceba2cb0907 236 | SLES15HVM: ami-0f81fff879bafe6b8 237 | ap-south-1: 238 | AMZNLINUX2: ami-08f63db601b82ff5f 239 | US2004HVM: ami-07ab71173dc8c331e 240 | CENTOS7HVM: ami-026f33d38b6410e30 241 | SLES15HVM: ami-01be89269d32f2a16 242 | ap-southeast-1: 243 | AMZNLINUX2: ami-0d728fd4e52be968f 244 | US2004HVM: ami-086d2d413b385037d 245 | CENTOS7HVM: ami-07f65177cb990d65b 246 | SLES15HVM: ami-070356c21596ddc67 247 | ap-southeast-2: 248 | AMZNLINUX2: ami-09f765d333a8ebb4b 249 | US2004HVM: ami-061c4c77197bf567a 250 | CENTOS7HVM: ami-0b2045146eb00b617 251 | SLES15HVM: ami-0c4245381c67efb39 252 | ca-central-1: 253 | AMZNLINUX2: ami-0fca0f98dc87d39df 254 | US2004HVM: ami-0d4ae853aceec6074 255 | CENTOS7HVM: ami-04a25c39dc7a8aebb 256 | SLES15HVM: ami-0c97d9b588207dad6 257 | eu-central-1: 258 | AMZNLINUX2: ami-0bd39c806c2335b95 259 | US2004HVM: ami-0be656e75e69af1a9 260 | CENTOS7HVM: ami-0e8286b71b81c3cc1 261 | SLES15HVM: ami-05dfd265ea534a3e9 262 | me-south-1: 263 | AMZNLINUX2: ami-0b38d62acce7fb76a 264 | US2004HVM: ami-0147ed463d9315c94 265 | CENTOS7HVM: ami-011c71a894b10f35b 266 | SLES15HVM: ami-0252c6d3a59c7473b 267 | ap-east-1: 268 | AMZNLINUX2: ami-7284c903 269 | US2004HVM: ami-34cf8245 270 | CENTOS7HVM: ami-0e5c29e6c87a9644f 271 | SLES15HVM: ami-0ad6e15bcbb2dbe38 272 | eu-north-1: 273 | AMZNLINUX2: ami-02511cb3673b49e04 274 | US2004HVM: ami-0faf140cd5302841b 275 | CENTOS7HVM: ami-05788af9005ef9a93 276 | SLES15HVM: ami-0741fa1a008af40ad 277 | eu-south-1: 278 | AMZNLINUX2: ami-08a2aed6e0a6f9c7d 279 | US2004HVM: ami-01eec6bdfa20f008e 280 | CENTOS7HVM: ami-0a84267606bcea16b 281 | SLES15HVM: ami-051cbea0e7660063d 282 | eu-west-1: 283 | AMZNLINUX2: ami-0ce1e3f77cd41957e 284 | US2004HVM: ami-055958ae2f796344b 285 | CENTOS7HVM: ami-0b850cf02cc00fdc8 286 | SLES15HVM: ami-0a58a1b152ba55f1d 287 | eu-west-2: 288 | AMZNLINUX2: ami-08b993f76f42c3e2f 289 | US2004HVM: ami-09c4a4b013e66b291 290 | CENTOS7HVM: ami-09e5afc68eed60ef4 291 | SLES15HVM: ami-01497522185aaa4ee 292 | eu-west-3: 293 | AMZNLINUX2: ami-0e9c91a3fc56a0376 294 | US2004HVM: ami-0b14b90c53fdbb103 295 | CENTOS7HVM: ami-0cb72d2e599cffbf9 296 | SLES15HVM: ami-0f238bd4c6fdbefb0 297 | sa-east-1: 298 | AMZNLINUX2: ami-0096398577720a4a3 299 | US2004HVM: ami-0f1aecac8376e25fe 300 | CENTOS7HVM: ami-0b30f38d939dd4b54 301 | SLES15HVM: ami-0772af912976aa692 302 | us-east-1: 303 | AMZNLINUX2: ami-04d29b6f966df1537 304 | US2004HVM: ami-0be3f0371736d5394 305 | CENTOS7HVM: ami-0affd4508a5d2481b 306 | SLES15HVM: ami-0b1764f3d7d2e2316 307 | us-gov-west-1: 308 | AMZNLINUX2: ami-cb2a11aa 309 | SLES15HVM: ami-57c0ba36 310 | us-gov-east-1: 311 | AMZNLINUX2: ami-c2e209b3 312 | SLES15HVM: ami-05e4bedfad53425e9 313 | us-east-2: 314 | AMZNLINUX2: ami-09558250a3419e7d0 315 | US2004HVM: ami-0b289b3e97908e84e 316 | CENTOS7HVM: ami-01e36b7901e884a10 317 | SLES15HVM: ami-05ea824317ffc0c20 318 | us-west-1: 319 | AMZNLINUX2: ami-08d9a394ac1c2994c 320 | US2004HVM: ami-05ddb1bcba9ace858 321 | CENTOS7HVM: ami-098f55b4287a885ba 322 | SLES15HVM: ami-00e34a7624e5a7107 323 | us-west-2: 324 | AMZNLINUX2: ami-0e472933a1395e172 325 | US2004HVM: ami-0c007ac192ba0744b 326 | CENTOS7HVM: ami-0bc06212a56393ee1 327 | SLES15HVM: ami-0f1e3b3fb0fec0361 328 | cn-north-1: 329 | AMZNLINUX2: ami-0cf913cef98c31648 330 | CENTOS7HVM: ami-08c16f7e830c0e393 331 | SLES15HVM: ami-021392849b6221a81 332 | cn-northwest-1: 333 | AMZNLINUX2: ami-0a12cb9cd7fea53e7 334 | CENTOS7HVM: ami-0f21aa96a61df8c44 335 | SLES15HVM: ami-00e1de3ee6d0d28ea 336 | LinuxAMINameMap: 337 | Amazon-Linux2-HVM: 338 | Code: AMZNLINUX2 339 | OS: Amazon 340 | CentOS-7-HVM: 341 | Code: CENTOS7HVM 342 | OS: CentOS 343 | Ubuntu-Server-18.04-LTS-HVM: 344 | Code: US1804HVM 345 | OS: Ubuntu 346 | Ubuntu-Server-20.04-LTS-HVM: 347 | Code: US2004HVM 348 | OS: Ubuntu 349 | SUSE-SLES-15-HVM: 350 | Code: SLES15HVM 351 | OS: SLES 352 | Conditions: 353 | 2BastionCondition: !Or 354 | - !Equals 355 | - !Ref NumBastionHosts 356 | - "2" 357 | - !Condition 3BastionCondition 358 | - !Condition 4BastionCondition 359 | 3BastionCondition: !Or 360 | - !Equals 361 | - !Ref NumBastionHosts 362 | - "3" 363 | - !Condition 4BastionCondition 364 | 4BastionCondition: !Equals 365 | - !Ref NumBastionHosts 366 | - "4" 367 | UseAlternativeInitialization: !Not 368 | - !Equals 369 | - !Ref AlternativeInitializationScript 370 | - "" 371 | CreateIAMRole: !Equals 372 | - !Ref AlternativeIAMRole 373 | - "" 374 | UseOSImageOverride: !Not 375 | - !Equals 376 | - !Ref OSImageOverride 377 | - "" 378 | UsingDefaultBucket: !Equals 379 | - !Ref QSS3BucketName 380 | - "aws-quickstart" 381 | DefaultBanner: !Equals [!Ref BastionBanner, ""] 382 | Resources: 383 | BastionMainLogGroup: 384 | Type: "AWS::Logs::LogGroup" 385 | SSHMetricFilter: 386 | Type: "AWS::Logs::MetricFilter" 387 | Properties: 388 | LogGroupName: !Ref BastionMainLogGroup 389 | FilterPattern: ON FROM USER PWD 390 | MetricTransformations: 391 | - MetricName: SSHCommandCount 392 | MetricValue: "1" 393 | MetricNamespace: !Sub "AWSQuickStart/${AWS::StackName}" 394 | BastionHostRole: 395 | Condition: CreateIAMRole 396 | Type: "AWS::IAM::Role" 397 | Properties: 398 | Path: / 399 | AssumeRolePolicyDocument: 400 | Statement: 401 | - Action: 402 | - "sts:AssumeRole" 403 | Principal: 404 | Service: 405 | - !Sub "ec2.${AWS::URLSuffix}" 406 | Effect: Allow 407 | Version: 2012-10-17 408 | ManagedPolicyArns: 409 | - !Sub "arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore" 410 | - !Sub "arn:${AWS::Partition}:iam::aws:policy/CloudWatchAgentServerPolicy" 411 | BastionHostPolicy: 412 | Type: "AWS::IAM::Policy" 413 | Properties: 414 | PolicyName: BastionPolicy 415 | PolicyDocument: 416 | Version: 2012-10-17 417 | Statement: 418 | - Action: 419 | - "s3:GetObject" 420 | Resource: !Sub 421 | - arn:${AWS::Partition}:s3:::${S3Bucket}/${QSS3KeyPrefix}* 422 | - S3Bucket: 423 | !If [ 424 | UsingDefaultBucket, 425 | !Sub "${QSS3BucketName}-${AWS::Region}", 426 | !Ref QSS3BucketName, 427 | ] 428 | Effect: Allow 429 | - Action: 430 | - "logs:CreateLogStream" 431 | - "logs:GetLogEvents" 432 | - "logs:PutLogEvents" 433 | - "logs:DescribeLogGroups" 434 | - "logs:DescribeLogStreams" 435 | - "logs:PutRetentionPolicy" 436 | - "logs:PutMetricFilter" 437 | - "logs:CreateLogGroup" 438 | Resource: !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:${BastionMainLogGroup}:*" 439 | Effect: Allow 440 | - Action: 441 | - "ec2:AssociateAddress" 442 | - "ec2:DescribeAddresses" 443 | Resource: "*" 444 | Effect: Allow 445 | Roles: 446 | - !If 447 | - CreateIAMRole 448 | - !Ref BastionHostRole 449 | - !Ref AlternativeIAMRole 450 | BastionHostProfile: 451 | DependsOn: BastionHostPolicy 452 | Type: "AWS::IAM::InstanceProfile" 453 | Properties: 454 | Roles: 455 | - !If 456 | - CreateIAMRole 457 | - !Ref BastionHostRole 458 | - !Ref AlternativeIAMRole 459 | Path: / 460 | EIP1: 461 | Type: "AWS::EC2::EIP" 462 | Properties: 463 | Domain: vpc 464 | EIP2: 465 | Type: "AWS::EC2::EIP" 466 | Condition: 2BastionCondition 467 | Properties: 468 | Domain: vpc 469 | EIP3: 470 | Type: "AWS::EC2::EIP" 471 | Condition: 3BastionCondition 472 | Properties: 473 | Domain: vpc 474 | EIP4: 475 | Type: "AWS::EC2::EIP" 476 | Condition: 4BastionCondition 477 | Properties: 478 | Domain: vpc 479 | BastionAutoScalingGroup: 480 | Type: "AWS::AutoScaling::AutoScalingGroup" 481 | Properties: 482 | LaunchConfigurationName: !Ref BastionLaunchConfiguration 483 | VPCZoneIdentifier: 484 | !Split [ 485 | ",", 486 | { "Fn::ImportValue": !Sub "${ParentVPCStack}-SubnetsPublic" }, 487 | ] 488 | MinSize: !Ref NumBastionHosts 489 | MaxSize: !Ref NumBastionHosts 490 | Cooldown: "900" 491 | DesiredCapacity: !Ref NumBastionHosts 492 | Tags: 493 | - Key: Name 494 | Value: !Ref BastionHostName 495 | PropagateAtLaunch: true 496 | CreationPolicy: 497 | ResourceSignal: 498 | Count: !Ref NumBastionHosts 499 | Timeout: PT60M 500 | AutoScalingCreationPolicy: 501 | MinSuccessfulInstancesPercent: 100 502 | UpdatePolicy: 503 | AutoScalingReplacingUpdate: 504 | WillReplace: true 505 | BastionLaunchConfiguration: 506 | Type: "AWS::AutoScaling::LaunchConfiguration" 507 | Metadata: 508 | "AWS::CloudFormation::Authentication": 509 | S3AccessCreds: 510 | type: S3 511 | roleName: !If 512 | - CreateIAMRole 513 | - !Ref BastionHostRole 514 | - !Ref AlternativeIAMRole 515 | buckets: 516 | - !If [ 517 | UsingDefaultBucket, 518 | !Sub "${QSS3BucketName}-${AWS::Region}", 519 | !Ref QSS3BucketName, 520 | ] 521 | "AWS::CloudFormation::Init": 522 | config: 523 | files: 524 | /tmp/auditd.rules: 525 | mode: "000550" 526 | owner: root 527 | group: root 528 | content: | 529 | -a exit,always -F arch=b64 -S execve 530 | -a exit,always -F arch=b32 -S execve 531 | /tmp/auditing_configure.sh: 532 | source: !Sub 533 | - https://${S3Bucket}.s3.${S3Region}.${AWS::URLSuffix}/${QSS3KeyPrefix}scripts/auditing_configure.sh 534 | - S3Bucket: !If 535 | - UsingDefaultBucket 536 | - !Sub "aws-quickstart-${AWS::Region}" 537 | - !Ref "QSS3BucketName" 538 | S3Region: !If 539 | - UsingDefaultBucket 540 | - !Ref "AWS::Region" 541 | - !Ref "QSS3BucketRegion" 542 | mode: "000550" 543 | owner: root 544 | group: root 545 | authentication: S3AccessCreds 546 | /tmp/bastion_bootstrap.sh: 547 | source: !If 548 | - UseAlternativeInitialization 549 | - !Ref AlternativeInitializationScript 550 | - !Sub 551 | - https://${S3Bucket}.s3.${S3Region}.${AWS::URLSuffix}/${QSS3KeyPrefix}scripts/bastion_bootstrap.sh 552 | - S3Bucket: !If 553 | - UsingDefaultBucket 554 | - !Sub "aws-quickstart-${AWS::Region}" 555 | - !Ref "QSS3BucketName" 556 | S3Region: !If 557 | - UsingDefaultBucket 558 | - !Ref "AWS::Region" 559 | - !Ref "QSS3BucketRegion" 560 | mode: "000550" 561 | owner: root 562 | group: root 563 | authentication: S3AccessCreds 564 | commands: 565 | a-add_auditd_rules: 566 | cwd: "/tmp/" 567 | env: 568 | BASTION_OS: !FindInMap [LinuxAMINameMap, !Ref BastionAMIOS, OS] 569 | command: "./auditing_configure.sh" 570 | # command: 571 | # - !If [ ] 572 | # - "cat /tmp/auditd.rules >> /etc/audit/rules.d/audit.rules && service auditd restart" 573 | b-bootstrap: 574 | cwd: "/tmp/" 575 | env: 576 | REGION: !Sub ${AWS::Region} 577 | URL_SUFFIX: !Sub ${AWS::URLSuffix} 578 | BANNER_REGION: 579 | !If [ 580 | UsingDefaultBucket, 581 | !Ref "AWS::Region", 582 | !Ref "QSS3BucketRegion", 583 | ] 584 | command: !Sub 585 | - "./bastion_bootstrap.sh --banner ${BannerUrl} --enable ${EnableBanner} --tcp-forwarding ${EnableTCPForwarding} --x11-forwarding ${EnableX11Forwarding}" 586 | - BannerUrl: !If 587 | - DefaultBanner 588 | - !Sub 589 | - s3://${S3Bucket}/${QSS3KeyPrefix}scripts/banner_message.txt 590 | - S3Bucket: 591 | !If [ 592 | UsingDefaultBucket, 593 | !Sub "aws-quickstart-${AWS::Region}", 594 | !Ref "QSS3BucketName", 595 | ] 596 | - !Ref BastionBanner 597 | Properties: 598 | AssociatePublicIpAddress: true 599 | PlacementTenancy: !Ref BastionTenancy 600 | KeyName: !Ref KeyPairName 601 | IamInstanceProfile: !Ref BastionHostProfile 602 | ImageId: !If 603 | - UseOSImageOverride 604 | - !Ref OSImageOverride 605 | - !FindInMap 606 | - AWSAMIRegionMap 607 | - !Ref "AWS::Region" 608 | - !FindInMap 609 | - LinuxAMINameMap 610 | - !Ref BastionAMIOS 611 | - Code 612 | SecurityGroups: 613 | - !Ref BastionSecurityGroup 614 | InstanceType: !Ref BastionInstanceType 615 | BlockDeviceMappings: 616 | - DeviceName: /dev/xvda 617 | Ebs: 618 | VolumeSize: !Ref RootVolumeSize 619 | VolumeType: gp2 620 | Encrypted: true 621 | DeleteOnTermination: true 622 | UserData: 623 | Fn::Base64: !Sub 624 | - | 625 | #!/bin/bash 626 | set -x 627 | for e in $(echo "${EnvironmentVariables}" | tr ',' ' '); do 628 | export $e 629 | done 630 | export PATH=$PATH:/usr/local/bin 631 | #cfn signaling functions 632 | yum install git -y || apt-get install -y git || zypper -n install git 633 | 634 | function cfn_fail 635 | { 636 | cfn-signal -e 1 --stack ${AWS::StackName} --region ${AWS::Region} --resource BastionAutoScalingGroup 637 | exit 1 638 | } 639 | 640 | function cfn_success 641 | { 642 | cfn-signal -e 0 --stack ${AWS::StackName} --region ${AWS::Region} --resource BastionAutoScalingGroup 643 | exit 0 644 | } 645 | 646 | until git clone https://github.com/aws-quickstart/quickstart-linux-utilities.git ; do echo "Retrying"; done 647 | cd /quickstart-linux-utilities; 648 | source quickstart-cfn-tools.source; 649 | qs_update-os || qs_err; 650 | qs_bootstrap_pip || qs_err " pip bootstrap failed "; 651 | qs_aws-cfn-bootstrap || qs_err " cfn bootstrap failed "; 652 | 653 | EIP_LIST="${EIP1},${EIP2},${EIP3},${EIP4}" 654 | CLOUDWATCHGROUP=${BastionMainLogGroup} 655 | cfn-init -v --stack '${AWS::StackName}' --resource BastionLaunchConfiguration --region ${AWS::Region} || cfn_fail 656 | [ $(qs_status) == 0 ] && cfn_success || cfn_fail 657 | - EIP2: !If 658 | - 2BastionCondition 659 | - !Ref EIP2 660 | - "Null" 661 | EIP3: !If 662 | - 3BastionCondition 663 | - !Ref EIP3 664 | - "Null" 665 | EIP4: !If 666 | - 4BastionCondition 667 | - !Ref EIP4 668 | - "Null" 669 | BastionSecurityGroup: 670 | Type: "AWS::EC2::SecurityGroup" 671 | Properties: 672 | GroupDescription: Enables SSH Access to Bastion Hosts 673 | VpcId: { "Fn::ImportValue": !Sub "${ParentVPCStack}-VPC" } 674 | SecurityGroupIngress: 675 | - IpProtocol: tcp 676 | FromPort: 22 677 | ToPort: 22 678 | CidrIp: "0.0.0.0/0" 679 | - IpProtocol: icmp 680 | FromPort: -1 681 | ToPort: -1 682 | CidrIp: "0.0.0.0/0" 683 | Outputs: 684 | BastionAutoScalingGroup: 685 | Description: Auto Scaling Group Reference ID 686 | Value: !Ref BastionAutoScalingGroup 687 | Export: 688 | Name: !Sub "${AWS::StackName}-BastionAutoScalingGroup" 689 | EIP1: 690 | Description: Elastic IP 1 for Bastion 691 | Value: !Ref EIP1 692 | Export: 693 | Name: !Sub "${AWS::StackName}-EIP1" 694 | EIP2: 695 | Condition: 2BastionCondition 696 | Description: Elastic IP 2 for Bastion 697 | Value: !Ref EIP2 698 | Export: 699 | Name: !Sub "${AWS::StackName}-EIP2" 700 | EIP3: 701 | Condition: 3BastionCondition 702 | Description: Elastic IP 3 for Bastion 703 | Value: !Ref EIP3 704 | Export: 705 | Name: !Sub "${AWS::StackName}-EIP3" 706 | EIP4: 707 | Condition: 4BastionCondition 708 | Description: Elastic IP 4 for Bastion 709 | Value: !Ref EIP4 710 | Export: 711 | Name: !Sub "${AWS::StackName}-EIP4" 712 | CloudWatchLogs: 713 | Description: CloudWatch Logs GroupName. Your SSH logs will be stored here. 714 | Value: !Ref BastionMainLogGroup 715 | Export: 716 | Name: !Sub "${AWS::StackName}-CloudWatchLogs" 717 | BastionSecurityGroupID: 718 | Description: Bastion Security Group ID 719 | Value: !Ref BastionSecurityGroup 720 | Export: 721 | Name: !Sub "${AWS::StackName}-BastionSecurityGroupID" 722 | BastionHostRole: 723 | Description: Bastion IAM Role name 724 | Value: !If 725 | - CreateIAMRole 726 | - !Ref BastionHostRole 727 | - !Ref AlternativeIAMRole 728 | Export: 729 | Name: !Sub "${AWS::StackName}-BastionHostRole" 730 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | image: "${DOCKER_IMAGE_APP?Variable-not-set}:${TAG:-latest}" 5 | env_file: 6 | - .env 7 | ports: 8 | - 2000:2000 9 | links: 10 | - redis 11 | volumes: 12 | - "./logs:/app/logs" 13 | - "./app:/app/app" 14 | 15 | redis: 16 | image: redis:latest 17 | ports: 18 | - 6379:6379 19 | -------------------------------------------------------------------------------- /download_model.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import boto3 4 | from tqdm import tqdm 5 | 6 | 7 | def download_all_objects_in_folder(bucket, prefix, local): 8 | s3_resource = boto3.resource("s3") 9 | my_bucket = s3_resource.Bucket(bucket) 10 | objects = my_bucket.objects.filter(Prefix=prefix) 11 | if not os.path.exists(local): 12 | os.mkdir(local) 13 | for obj in tqdm(objects): 14 | path, filename = os.path.split(obj.key) 15 | if not filename: 16 | continue 17 | dest_pathname = obj.key.replace(prefix, f"{local}/") 18 | if not os.path.exists(os.path.dirname(dest_pathname)): 19 | os.makedirs(os.path.dirname(dest_pathname)) 20 | my_bucket.download_file(obj.key, dest_pathname) 21 | 22 | 23 | def main(): 24 | if not os.getenv("S3_DATA_PATH"): 25 | raise Exception("S3_DATA_PATH haven't define") 26 | if not os.getenv("BUCKET_NAME"): 27 | raise Exception("BUCKET_NAME haven't define") 28 | download_all_objects_in_folder( 29 | prefix=os.getenv("S3_DATA_PATH"), 30 | local=os.getenv("DATADIR", "data"), 31 | bucket=os.getenv("BUCKET_NAME"), 32 | ) 33 | 34 | 35 | if __name__ == "__main__": 36 | main() 37 | -------------------------------------------------------------------------------- /generate_torch_script.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import torch 4 | from scipy.special import softmax 5 | import numpy as np 6 | 7 | from transformers import AutoTokenizer, AutoModelForSequenceClassification 8 | import urllib.request 9 | import csv 10 | 11 | tokenizer = AutoTokenizer.from_pretrained("cardiffnlp/twitter-roberta-base-sentiment") 12 | 13 | origin_model = AutoModelForSequenceClassification.from_pretrained("cardiffnlp/twitter-roberta-base-sentiment", return_dict=False) 14 | 15 | 16 | 17 | text = "Good night 😊" 18 | encoded_input = tokenizer(text, return_tensors='pt') 19 | folder_save = "twitter-roberta-base-sentiment" 20 | if not os.path.exists(folder_save): 21 | os.mkdir(folder_save) 22 | 23 | traced_cpu = torch.jit.trace(origin_model, (encoded_input["input_ids"], encoded_input["attention_mask"])) 24 | 25 | torch.jit.save(traced_cpu, os.path.join(folder_save, "trace_model.pt")) 26 | tokenizer.save_pretrained(folder_save) 27 | 28 | # Load model to verify 29 | model = torch.jit.load(os.path.join(folder_save, "trace_model.pt")) 30 | 31 | output = model(**encoded_input) 32 | scores = output[0][0].detach().numpy() 33 | scores = softmax(scores) 34 | task='sentiment' 35 | 36 | labels=[] 37 | mapping_link = f"https://raw.githubusercontent.com/cardiffnlp/tweeteval/main/datasets/{task}/mapping.txt" 38 | with urllib.request.urlopen(mapping_link) as f: 39 | html = f.read().decode('utf-8').split("\n") 40 | csvreader = csv.reader(html, delimiter='\t') 41 | labels = [row[1] for row in csvreader if len(row) > 1] 42 | 43 | ranking = np.argsort(scores) 44 | ranking = ranking[::-1] 45 | for i in range(scores.shape[0]): 46 | l = labels[ranking[i]] 47 | s = scores[ranking[i]] 48 | print(f"{i+1}) {l} {np.round(float(s), 4)}") 49 | -------------------------------------------------------------------------------- /gunicorn_conf.py: -------------------------------------------------------------------------------- 1 | import json 2 | import multiprocessing 3 | import os 4 | 5 | workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1") 6 | max_workers_str = os.getenv("MAX_WORKERS") 7 | use_max_workers = None 8 | if max_workers_str: 9 | use_max_workers = int(max_workers_str) 10 | web_concurrency_str = os.getenv("WEB_CONCURRENCY", None) 11 | 12 | host = os.getenv("HOST", "0.0.0.0") 13 | port = os.getenv("PORT", "80") 14 | bind_env = os.getenv("BIND", None) 15 | use_loglevel = os.getenv("LOG_LEVEL", "info") 16 | if bind_env: 17 | use_bind = bind_env 18 | else: 19 | use_bind = f"{host}:{port}" 20 | 21 | cores = multiprocessing.cpu_count() 22 | workers_per_core = float(workers_per_core_str) 23 | default_web_concurrency = workers_per_core * cores 24 | if web_concurrency_str: 25 | web_concurrency = int(web_concurrency_str) 26 | assert web_concurrency > 0 27 | else: 28 | web_concurrency = max(int(default_web_concurrency), 2) 29 | if use_max_workers: 30 | web_concurrency = min(web_concurrency, use_max_workers) 31 | accesslog_var = os.getenv("ACCESS_LOG", "-") 32 | use_accesslog = accesslog_var or None 33 | errorlog_var = os.getenv("ERROR_LOG", "-") 34 | use_errorlog = errorlog_var or None 35 | graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "60") 36 | timeout_str = os.getenv("TIMEOUT", "60") 37 | keepalive_str = os.getenv("KEEP_ALIVE", "5") 38 | max_requests_str = os.getenv("MAX_REQUESTS", "100") 39 | 40 | # Gunicorn config variables 41 | loglevel = use_loglevel 42 | workers = web_concurrency 43 | bind = use_bind 44 | errorlog = use_errorlog 45 | worker_tmp_dir = "/dev/shm" 46 | accesslog = use_accesslog 47 | graceful_timeout = int(graceful_timeout_str) 48 | timeout = int(timeout_str) 49 | keepalive = int(keepalive_str) 50 | max_requests = int(max_requests_str) 51 | 52 | # For debugging and testing 53 | log_data = { 54 | "loglevel": loglevel, 55 | "workers": workers, 56 | "bind": bind, 57 | "graceful_timeout": graceful_timeout, 58 | "timeout": timeout, 59 | "max_requests": max_requests, 60 | "keepalive": keepalive, 61 | "errorlog": errorlog, 62 | "accesslog": accesslog, 63 | # Additional, non-gunicorn variables 64 | "workers_per_core": workers_per_core, 65 | "use_max_workers": use_max_workers, 66 | "host": host, 67 | "port": port, 68 | } 69 | print(json.dumps(log_data)) 70 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "ml template project" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["hai che viet "] 6 | 7 | [tool.poetry.dependencies] 8 | python = ">=3.9, < 3.11" 9 | requests = "^2.24.0" 10 | aioredis = "1.3.1" 11 | uvicorn = "0.13.4" 12 | fastapi = "0.60.1" 13 | torch = "1.12.0" 14 | transformers = "4.20.1" 15 | gunicorn = "*" 16 | gevent = "*" 17 | cryptography = "*" 18 | uvloop = "*" 19 | httptools = "*" 20 | tweepy = "*" 21 | scipy = "*" 22 | 23 | 24 | [tool.poetry.dev-dependencies] 25 | mypy = "0.981" 26 | black = "^21.10b0" 27 | isort = "^5.9.3" 28 | autoflake = "^1.4" 29 | flake8 = "^4.0.1" 30 | types-redis = "3.5.11" 31 | 32 | 33 | [tool.mypy] 34 | ignore_missing_imports = true 35 | implicit_reexport = true 36 | 37 | [tool.isort] 38 | profile = "black" 39 | 40 | [tool.black] 41 | line-length = 88 42 | 43 | 44 | [build-system] 45 | requires = ["poetry>=0.12"] 46 | build-backend = "poetry.masonry.api" 47 | -------------------------------------------------------------------------------- /scripts/build-push.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG?Variable not set} 7 | sh ./scripts/build.sh 8 | 9 | docker push $DOCKER_IMAGE_APP:$TAG -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG:-latest} 7 | DOCKER_IMAGE_APP=${DOCKER_IMAGE_APP?Variable-not-set} 8 | S3_DATA_PATH=${S3_DATA_PATH} 9 | BUCKET_NAME=${BUCKET_NAME} 10 | AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} 11 | AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION} 12 | AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} 13 | 14 | if [[ -z "${S3_DATA_PATH}" ]]; then 15 | MODEL_ENV="copy" 16 | else 17 | MODEL_ENV="download" 18 | fi 19 | 20 | docker pull $DOCKER_IMAGE_APP:model-image-stage-$TAG || true 21 | docker pull $DOCKER_IMAGE_APP:compile-stage-$TAG || true 22 | docker pull $DOCKER_IMAGE_APP:$TAG_LATEST || true 23 | 24 | 25 | # Build the download data stage: 26 | docker build --file Dockerfile \ 27 | --target model-image-general \ 28 | --label git-commit=$CI_COMMIT_SHORT_SHA \ 29 | --build-arg APP_ENV="$APP_ENV" \ 30 | --build-arg MODEL_ENV="$MODEL_ENV" \ 31 | --build-arg S3_DATA_PATH="$S3_DATA_PATH" \ 32 | --build-arg BUCKET_NAME="$BUCKET_NAME" \ 33 | --build-arg AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" \ 34 | --build-arg AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" \ 35 | --build-arg AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \ 36 | --tag $DOCKER_IMAGE_APP:model-image-stage-$TAG . 37 | 38 | # Build the compile stage: 39 | docker build --file Dockerfile \ 40 | --target compile-image \ 41 | --label git-commit=$CI_COMMIT_SHORT_SHA \ 42 | --build-arg APP_ENV="$APP_ENV" \ 43 | --build-arg S3_DATA_PATH="$S3_DATA_PATH" \ 44 | --build-arg BUCKET_NAME="$BUCKET_NAME" \ 45 | --build-arg AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" \ 46 | --build-arg AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" \ 47 | --build-arg AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \ 48 | --cache-from $DOCKER_IMAGE_APP:compile-stage-$TAG \ 49 | --cache-from $DOCKER_IMAGE_APP:model-image-stage-$TAG \ 50 | --tag $DOCKER_IMAGE_APP:compile-stage-$TAG . 51 | 52 | # Build the runtime stage, using cached compile stage: 53 | docker build --file Dockerfile \ 54 | --target runtime-image \ 55 | --label git-commit=$CI_COMMIT_SHORT_SHA \ 56 | --build-arg APP_ENV="$APP_ENV" \ 57 | --build-arg S3_DATA_PATH="$S3_DATA_PATH" \ 58 | --build-arg BUCKET_NAME="$BUCKET_NAME" \ 59 | --build-arg AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" \ 60 | --build-arg AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" \ 61 | --build-arg AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \ 62 | --cache-from $DOCKER_IMAGE_APP:compile-stage-$TAG \ 63 | --cache-from $DOCKER_IMAGE_APP:model-image-stage-$TAG \ 64 | --cache-from $DOCKER_IMAGE_APP:$TAG \ 65 | --tag $DOCKER_IMAGE_APP:$TAG . 66 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | source .env 6 | 7 | if [ -z $PROJECT_NAME ]; then 8 | echo "PROJECT_NAME environment variable is not set." 9 | exit 1 10 | fi 11 | 12 | if [ -z $AWS_ACCOUNT_ID ]; then 13 | echo "AWS_ACCOUNT_ID environment variable is not set." 14 | exit 1 15 | fi 16 | 17 | if [ -z $AWS_DEFAULT_REGION ]; then 18 | echo "AWS_DEFAULT_REGION environment variable is not set." 19 | exit 1 20 | fi 21 | 22 | if [ -z $KEY_PAIR ]; then 23 | echo "KEY_PAIR environment variable is not set. This must be the name of an SSH key pair, see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html" 24 | exit 1 25 | fi 26 | 27 | DOCKER_IMAGE_APP=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${PROJECT_NAME} 28 | TAG=latest 29 | 30 | APP_IMAGE=$DOCKER_IMAGE_APP:$TAG 31 | BUCKET_NAME=twitter-bucket 32 | 33 | deploy_images() { 34 | echo "Deploying App images to ECR..." 35 | aws ecr get-login-password --region ${AWS_DEFAULT_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com 36 | aws ecr describe-repositories --repository-name ${PROJECT_NAME} >/dev/null 2>&1 || aws ecr create-repository --repository-name ${PROJECT_NAME} 37 | bash scripts/build-push.sh 38 | } 39 | 40 | deploy_env(){ 41 | echo "Deploying App enviroment to s3..." 42 | aws s3api create-bucket \ 43 | --bucket $BUCKET_NAME \ 44 | --region AWS_DEFAULT_REGION 45 | aws s3 cp .env s3:://$BUCKET_NAME/enviroment/.env 46 | } 47 | 48 | deploy_infra() { 49 | echo "Deploying Cloud Formation stack: \"VPC-$APP_ENV-$PROJECT_NAME\"" 50 | aws cloudformation deploy \ 51 | --no-fail-on-empty-changeset \ 52 | --stack-name vpc-$APP_ENV-$PROJECT_NAME \ 53 | --template-file "deploy/vpc-2azs.yml" \ 54 | --capabilities CAPABILITY_IAM \ 55 | --tags $PROJECT_NAME-$APP_ENV-cluster=vpc 56 | 57 | echo "Deploying Cloud Formation stack: \"SG-$APP_ENV-$PROJECT_NAME\"" 58 | aws cloudformation deploy \ 59 | --no-fail-on-empty-changeset \ 60 | --template-file "deploy/client-sg.yml" \ 61 | --stack-name client-$APP_ENV-$PROJECT_NAME \ 62 | --capabilities CAPABILITY_NAMED_IAM \ 63 | --parameter-overrides ParentVPCStack=vpc-$APP_ENV-$PROJECT_NAME \ 64 | --tags $PROJECT_NAME-$APP_ENV-cluster=client 65 | 66 | echo "Deploying Cloud Formation stack: \"BastionHost-$APP_ENV-$PROJECT_NAME\"" 67 | aws cloudformation deploy \ 68 | --template-file "deploy/vpc-ssh-bastion.yml" \ 69 | --stack-name ssh-bastion-$APP_ENV-$PROJECT_NAME \ 70 | --capabilities CAPABILITY_NAMED_IAM \ 71 | --parameter-overrides ParentVPCStack=vpc-$APP_ENV-$PROJECT_NAME \ 72 | KeyPairName=$KEY_PAIR \ 73 | EnableTCPForwarding=true \ 74 | --tags $PROJECT_NAME-$APP_ENV--cluster=$APP_ENV-ssh-bastion 75 | 76 | echo "Deploying Cloud Formation stack: \"Cluster-$APP_ENV-$PROJECT_NAME\"" 77 | aws cloudformation deploy \ 78 | --no-fail-on-empty-changeset \ 79 | --template-file "deploy/cluster-fargate.yml" \ 80 | --stack-name cluster-fargate-$APP_ENV-$PROJECT_NAME \ 81 | --capabilities CAPABILITY_NAMED_IAM \ 82 | --parameter-overrides ParentVPCStack=vpc-$APP_ENV-$PROJECT_NAME \ 83 | --tags $PROJECT_NAME-$APP_ENV-cluster=ecs-cluster 84 | } 85 | 86 | deploy_app() { 87 | echo "Deploying Cloud Formation stack: \"${PROJECT_NAME}-app\" containing ALB, ECS Tasks..." 88 | aws cloudformation deploy \ 89 | --no-fail-on-empty-changeset \ 90 | --template-file "deploy/task-definition/app-demo.yml" \ 91 | --stack-name app-$APP_ENV-$PROJECT_NAME \ 92 | --capabilities CAPABILITY_NAMED_IAM \ 93 | --parameter-overrides ParentVPCStack=vpc-$APP_ENV-$PROJECT_NAME \ 94 | AppEnvironment1Key=APP_ENV \ 95 | ParentClusterStack=cluster-fargate-$APP_ENV-$PROJECT_NAME \ 96 | ParentClientStack1=client-$APP_ENV-$PROJECT_NAME \ 97 | AppEnvironment1Value=$APP_ENV \ 98 | AppImage=$APP_IMAGE \ 99 | AutoScaling=true \ 100 | Cpu=0.25 \ 101 | Memory=0.5 \ 102 | DesiredCount=3 \ 103 | MinCapacity=3 \ 104 | AppEnvironmentS3Arn=arn:aws:s3:::$BUCKET_NAME/enviroment/.env 105 | --tags $PROJECT_NAME-$APP_ENV-cluster=service-$APP_IMAGE 106 | } 107 | 108 | print_bastion() { 109 | echo "Bastion endpoint:" 110 | ip=$(aws cloudformation describe-stacks \ 111 | --stack-name=ssh-bastion-$APP_ENV-$PROJECT_NAME\ --query="Stacks[0].Outputs[?OutputKey=='EIP1'].OutputValue" \ 112 | --output=text) 113 | echo "${ip}" 114 | } 115 | 116 | print_endpoint() { 117 | echo "Public endpoint:" 118 | prefix=$(aws cloudformation describe-stacks \ 119 | --stack-name=cluster-fargate-$APP_ENV-$PROJECT_NAME \ 120 | --query="Stacks[0].Outputs[?OutputKey=='URL'].OutputValue" \ 121 | --output=text) 122 | echo "${prefix}" 123 | } 124 | 125 | deploy_stacks() { 126 | deploy_images 127 | deploy_env 128 | deploy_infra 129 | deploy_app 130 | 131 | print_bastion 132 | print_endpoint 133 | } 134 | 135 | delete_cfn_stack() { 136 | stack_name=$1 137 | echo "Deleting Cloud Formation stack: \"${stack_name}\"..." 138 | aws cloudformation delete-stack --stack-name $stack_name 139 | echo 'Waiting for the stack to be deleted, this may take a few minutes...' 140 | aws cloudformation wait stack-delete-complete --stack-name $stack_name 141 | echo 'Done' 142 | } 143 | 144 | delete_images() { 145 | echo "deleting repository \"${PROJECT_NAME}\"..." 146 | aws ecr delete-repository \ 147 | --repository-name $PROJECT_NAME \ 148 | --force 149 | } 150 | 151 | delete_stacks() { 152 | delete_cfn_stack app-$APP_ENV-$PROJECT_NAME 153 | 154 | delete_cfn_stack cluster-fargate-$APP_ENV-$PROJECT_NAME 155 | 156 | delete_cfn_stack ssh-bastion-$APP_ENV-$PROJECT_NAME 157 | 158 | delete_cfn_stack client-$APP_ENV-$PROJECT_NAME 159 | 160 | delete_cfn_stack app-$APP_ENV-$PROJECT_NAME 161 | 162 | delete_cfn_stack vpc-$APP_ENV-$PROJECT_NAME 163 | 164 | delete_images 165 | 166 | echo "all resources from this tutorial have been removed" 167 | } 168 | 169 | action=${1:-"deploy"} 170 | if [ "$action" == "delete" ]; then 171 | delete_stacks 172 | exit 0 173 | fi 174 | 175 | deploy_stacks 176 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | set -x 4 | 5 | autoflake --ignore-init-module-imports --remove-all-unused-imports --recursive --remove-unused-variables --in-place app 6 | black app 7 | isort app -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | set -e 4 | 5 | black --check app 6 | isort --check-only app 7 | flake8 app 8 | mypy app 9 | -------------------------------------------------------------------------------- /visulization/tutorial.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "3ca15787", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "import tweepy\n", 11 | "import os\n", 12 | "import pandas as pd\n", 13 | "import json\n", 14 | "from typing import List\n", 15 | "from collections import defaultdict\n", 16 | "import matplotlib.pyplot as plt\n" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": 2, 22 | "id": "fd2d068a", 23 | "metadata": {}, 24 | "outputs": [], 25 | "source": [ 26 | "auth = tweepy.OAuthHandler(\n", 27 | " os.getenv(\"TWITTER_CONSUMER_KEY\"), os.getenv(\"TWITTER_CONSUMER_SECRET\")\n", 28 | " )\n", 29 | "auth.set_access_token(\n", 30 | " os.getenv(\"TWITTER_ACCESS_TOKEN_KEY\"), os.getenv(\"TWITTER_ACCESS_TOKEN_SECRET\")\n", 31 | ")\n", 32 | "\n", 33 | "api = tweepy.API(auth)" 34 | ] 35 | }, 36 | { 37 | "cell_type": "markdown", 38 | "id": "74971c7a", 39 | "metadata": {}, 40 | "source": [ 41 | "## Populate data" 42 | ] 43 | }, 44 | { 45 | "cell_type": "code", 46 | "execution_count": null, 47 | "id": "d2625626", 48 | "metadata": {}, 49 | "outputs": [], 50 | "source": [ 51 | "def insert_data(tweetUrl: str):\n", 52 | " import requests\n", 53 | "\n", 54 | " reqUrl = f\"http://localhost:2000/v1/inference?tweetUrl={tweetUrl}\"\n", 55 | "\n", 56 | " headersList = {\n", 57 | " \"User-Agent\": \"Thunder Client (https://www.thunderclient.com)\",\n", 58 | " \"Authorization\": \"Basic am9iaG9wYWk6aG9sYQ==\" \n", 59 | " }\n", 60 | "\n", 61 | " payload = \"\"\n", 62 | "\n", 63 | " response = requests.request(\"GET\", reqUrl, data=payload, headers=headersList)\n", 64 | "\n", 65 | " print(response.text)" 66 | ] 67 | }, 68 | { 69 | "cell_type": "code", 70 | "execution_count": null, 71 | "id": "6db66449", 72 | "metadata": {}, 73 | "outputs": [], 74 | "source": [ 75 | "for _ in range(10):\n", 76 | "\n", 77 | " tweets = api.home_timeline()\n", 78 | "\n", 79 | " for tweet in tweets:\n", 80 | " link = f\"https://twitter.com/{tweet.user.screen_name}/status/{tweet.id}\" \n", 81 | " insert_data(link)\n", 82 | " " 83 | ] 84 | }, 85 | { 86 | "cell_type": "code", 87 | "execution_count": null, 88 | "id": "1f407191", 89 | "metadata": {}, 90 | "outputs": [], 91 | "source": [ 92 | "for tweet in tweepy.Cursor(api.search_tweets, q='#bitcoin', lang=\"en\").items(100):\n", 93 | " link = f\"https://twitter.com/{tweet.user.screen_name}/status/{tweet.id}\" \n", 94 | " insert_data(link)" 95 | ] 96 | }, 97 | { 98 | "cell_type": "markdown", 99 | "id": "d49d66b9", 100 | "metadata": {}, 101 | "source": [ 102 | "## Visualize" 103 | ] 104 | }, 105 | { 106 | "cell_type": "code", 107 | "execution_count": 3, 108 | "id": "8037b298", 109 | "metadata": {}, 110 | "outputs": [], 111 | "source": [ 112 | "from redis import Redis\n", 113 | "\n", 114 | "redis = Redis(host=\"localhost\", port=6379)" 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": 4, 120 | "id": "71796ce3", 121 | "metadata": {}, 122 | "outputs": [ 123 | { 124 | "data": { 125 | "text/plain": [ 126 | "True" 127 | ] 128 | }, 129 | "execution_count": 4, 130 | "metadata": {}, 131 | "output_type": "execute_result" 132 | } 133 | ], 134 | "source": [ 135 | "redis.ping()" 136 | ] 137 | }, 138 | { 139 | "cell_type": "code", 140 | "execution_count": 5, 141 | "id": "d5f684f8", 142 | "metadata": {}, 143 | "outputs": [], 144 | "source": [ 145 | "def get_username(list_user_id: List[int]):\n", 146 | " return [i.screen_name for i in api.lookup_users(user_id=list_user_id)]" 147 | ] 148 | }, 149 | { 150 | "cell_type": "code", 151 | "execution_count": 6, 152 | "id": "0f9e2a6e", 153 | "metadata": {}, 154 | "outputs": [], 155 | "source": [ 156 | "df = {}" 157 | ] 158 | }, 159 | { 160 | "cell_type": "code", 161 | "execution_count": 7, 162 | "id": "fed89a7a", 163 | "metadata": {}, 164 | "outputs": [], 165 | "source": [ 166 | "pipeline = redis.pipeline()\n", 167 | "keys = redis.keys()\n", 168 | "for k in keys:\n", 169 | " pipeline.get(k)\n", 170 | " \n", 171 | "prediction = pipeline.execute()\n", 172 | "df = pd.DataFrame([{\"id\": k.decode(\"utf-8\").split(\":\")[0], \"prediction\": json.loads(v)} for k, v in zip(keys, prediction)])" 173 | ] 174 | }, 175 | { 176 | "cell_type": "markdown", 177 | "id": "ed0eee4f", 178 | "metadata": {}, 179 | "source": [ 180 | "### Top most active user" 181 | ] 182 | }, 183 | { 184 | "cell_type": "code", 185 | "execution_count": 12, 186 | "id": "412026ec", 187 | "metadata": {}, 188 | "outputs": [ 189 | { 190 | "data": { 191 | "image/png": "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mc0335wpU6bw/e9/H4CHH36YCRMmcOedd9K3b1+uvfZaHn74YW6//XZ++MMf0pUpusONMAiCHsmwYcMYOXIkABtssAHTp0/n3nvvZbfddmv7zIcffpi732233Zall14acNe/Y445hrvuuotevXrx8ssv8/rrr/OZz3ymSi+NIQR4EAQ9kkUXXbTt/969e/P6668zcOBApkyZMt9nF1lkET755BMAPvnkEz766KNO+1188cXb/h83bhwzZ85k8uTJ9OnTh6FDh3ZpAFOoUIIgWCgYMGAAw4YN4+qrrwZ89vzoo48Cnil18uTJAFx//fV8/PHHACyxxBK88847nfY5Z84clltuOfr06cPtt9/Of/5TNmngAiMEeBAECw3jxo3jggsuYN1112WttdZqMzoeeOCB3Hnnnay77rrcd999bbPsddZZh969e7Puuuty+umnz9ff3nvvzaRJkxgxYgSXXnopw4cP79Lz6dKamKNGjbLifODhRhgEPY9p06axxhprNHsYLUu56ydpspmNKv1szMCDIAhalBDgQRAELUoI8CAIghYlBHgQBEGLUlWAS7pQ0huSHi/a9xtJT0maKulaSQMX6CiDIAiC+cgyA78Y2L5k3y3A2ma2DvBv4KcNHlcQBEFQhaoC3MzuAt4q2Xezmc1Lm/cDgxfA2IIgCJrGOeecw6WXXgrAxRdfzCuvvNL23re//W2efPLJZg2tjUaE0h8AjG9AP0EQ9EROWLLB/c1pbH+dcMghh7T9f/HFF7P22muzwgorAHD++ed3yRiqUZcRU9L/AfOAcRU+c5CkSZImzZw5s57DBUEQZGL69OkMHz6cvffemzXWWIMxY8bw/vvvc+utt7LeeusxYsQIDjjggLZkVkcffTRrrrkm66yzDj/60Y8AOOGEEzj11FOZMGECkyZNYu+992bkyJF88MEHbLnllkyaNIlzzjmHo446qu24F198MYcffjgAl112GRtttBEjR47k4IMP5n//+1/Dz7NmAS5pLLAjsLdVCOc0s3PNbJSZjRo0aFCthwuCIMjF008/zaGHHsq0adMYMGAAp512GmPHjmX8+PE89thjzJs3j7PPPptZs2Zx7bXX8sQTTzB16lSOPfbYDv2MGTOGUaNGMW7cOKZMmUK/fv3a3vv617/Otdde27Y9fvx49txzT6ZNm8b48eO55557mDJlCr1792bcuE7nuTVTkwCXtD3wY2AnM3u/sUMKgiCon5VWWonNNtsMgH322Ydbb72VYcOGsdpqqwGw3377cdddd7HkkkvSt29fvvWtb3HNNdew2GKLZT7GoEGD+OxnP8v999/PrFmzeOqpp9hss8249dZbmTx5MhtuuCEjR47k1ltv5fnnn2/4OVbVgUu6AtgSWFbSDOB43OtkUeCWVITzfjM7pNNOgiAIupjSAsEDBw5k1qxZ831ukUUW4cEHH+TWW29lwoQJ/OEPf+C2227LfJw999yTq666iuHDh7PLLrsgCTNjv/3246STTqr7PCqRxQtlLzNb3sz6mNlgM7vAzD5nZiuZ2cj0CuEdBEG34sUXX+S+++4D4PLLL2fUqFFMnz6dZ599FoA///nPbLHFFrz77rvMmTOHr3zlK5x++ultKWaLqZRWdpddduG6667jiiuuYM899wRg6623ZsKECbzxxhsAvPXWWwsk1WwUdAiCoEey+uqrc9ZZZ3HAAQew5ppr8vvf/57Ro0ez2267MW/ePDbccEMOOeQQ3nrrLXbeeWfmzp2LmXHaaafN19fYsWM55JBD6NevX9tDocBSSy3FGmuswZNPPslGG20EwJprrsmJJ57IdtttxyeffEKfPn0466yzWHnllRt6jpFONgiChtId0slOnz6dHXfckccff7z6h7sZkU42CIJgISAEeBAEPY6hQ4e25Ow7LyHAgyAIWpQQ4EEQBC1KCPAgCIIWJQR4EARBixICPAiCICezZ8/mj3/8Y9v2K6+8wpgxY7p8HBHIEwTBAiVLvEceukNsSEGAH3rooQCssMIKTJgwocvHETPwIAh6HNOnT2eNNdbgwAMPZK211mK77bbjgw8+4LnnnmP77bdngw02YPPNN+epp54C4LnnnmP06NGMGDGCY489lv79+wPw7rvvsvXWW7P++uszYsQIrrvuOsDTzz733HOMHDmSo446iunTp7P22msDMHr0aJ544om2sRRSz7733nsccMABbLTRRqy33nptfdVD68/AqyWL76Lk70EQdC+eeeYZrrjiCs477zx23313Jk6cyEUXXcQ555zDqquuygMPPMChhx7KbbfdxpFHHsmRRx7JXnvtxTnnnNPWR9++fbn22msZMGAAb775JqNHj2annXbi5JNP5vHHH2fKlCmAPzAK7LHHHlx11VX8/Oc/59VXX+XVV19l1KhRHHPMMXzxi1/kwgsvZPbs2Wy00UZss802LL744jWfY8zAgyDokQwbNoyRI0cCsMEGGzB9+nTuvfdedtttt7YiC6+++ioA9913H7vtthsA3/jGN9r6MDOOOeYY1llnHbbZZhtefvllXn/99YrH3X333dvUKVdddVWbbvzmm2/m5JNPZuTIkWy55ZbMnTuXF198sa5zbP0ZeBAEQRkWXXTRtv979+7N66+/zsCBA9tmzVkYN24cM2fOZPLkyfTp04ehQ4cyd+7cim1WXHFFlllmGaZOncr48ePbZvRmxsSJE1l99dVrOp9yxAw8CIKFggEDBjBs2DCuvvpqwAVqIXXs6NGjmThxIgBXXnllW5s5c+aw3HLL0adPH26//fa2lLCV0suCq1FOOeUU5syZwzrrrAPAl770Jc4880wKCQQfeeSRus8pBHgQBAsN48aN44ILLmDddddlrbXWajMknnHGGZx22mmss846PPvssyy5pNvW9t57byZNmsSIESO49NJLGT58OADLLLMMm222GWuvvXaHmpgFxowZw5VXXsnuu+/etu+4447j448/Zp111mGttdbiuOOOq/t8Wj+dbBgxg6Bb0R3Syebl/fffp1+/fkjiyiuv5IorrmiIl0gt5EknGzrwIAgWeiZPnszhhx+OmTFw4EAuvPDCZg8pEyHAgyBY6Nl8883LllLr7oQOPAiCoEUJAR4EQcPpSttaTyLvdQsBHgRBQ+nbty+zZs0KIZ4TM2PWrFn07ds3c5vQgQdB0FAGDx7MjBkzmDlzZrOH0nL07duXwYMHZ/58VQEu6UJgR+ANM1s77VsaGA8MBaYDu5vZf2sYbxAEPYw+ffowbNiwZg9joSCLCuViYPuSfUcDt5rZqsCtaTsIgiDoQqoKcDO7C3irZPfOwCXp/0uArzV2WEEQBEE1ajViftrMXk3/vwZ8ukHjCYIgCDJStxeKuam5U3OzpIMkTZI0KYwaQRAEjaNWAf66pOUB0t83OvugmZ1rZqPMbNSgQYNqPFwQBEFQSq0C/Hpgv/T/fkBzsr4EQRAsxFQV4JKuAO4DVpc0Q9K3gJOBbSU9A2yTtoMgCIIupKofuJnt1clbWzd4LEEQBEEOIpQ+CIKgRQkBHgRB0KKEAA+CIGhRQoAHQRC0KCHAgyAIWpQQ4EEQBC1KCPAgCIIWJQR4EARBixICPAiCoEUJAR4EQdCihAAPgiBoUZpa1PixF15s5uGDIAhampiBB0EQtCghwIMgCFqUEOBBEAQtSgjwIAiCFiUEeBAEQYsSAjwIgqBFCQEeBEHQooQAD4IgaFFCgAdBELQoIcCDIAhalBDgQRAELUpdAlzS9yU9IelxSVdI6tuogQVBEASVqVmAS1oR+C4wyszWBnoDezZqYEEQBEFl6lWhLAL0k7QIsBjwSv1DCoIgCLJQswA3s5eBU4EXgVeBOWZ2c6MGFgRBEFSm5nzgkpYCdgaGAbOBqyXtY2aXlXzuIOAggCFDhnToY+jcy6seZ3qtA8zBiEtGVHz/sf0e64JRBEEQ5KMeFco2wAtmNtPMPgauATYt/ZCZnWtmo8xs1KBBg+o4XBAEQVBMPQL8RWC0pMUkCdgamNaYYQVBEATVqEcH/gAwAXgYeCz1dW6DxhUEQRBUoa6amGZ2PHB8g8YSBEEQ5CAiMYMgCFqUEOBBEAQtSgjwIAiCFiUEeBAEQYsSAjwIgqBFCQEeBEHQooQAD4IgaFFCgAdBELQoIcCDIAhalBDgQRAELUoI8CAIghalrlwoQeKEJTN8Zk7FtyMneRAEeYkZeBAEQYsSAjwIgqBFCQEeBEHQooQAD4IgaFFCgAdBELQoIcCDIAhalBDgQRAELUoI8CAIghYlBHgQBEGLEgI8CIKgRQkBHgRB0KLUJcAlDZQ0QdJTkqZJ2qRRAwuCIAgqU28yq98BN5rZGEmfAhZrwJiCIAiCDNQswCUtCXwBGAtgZh8BHzVmWEEQBEE16lGhDANmAhdJekTS+ZIWb9C4giAIgirUo0JZBFgfOMLMHpD0O+Bo4LjiD0k6CDgIYMiQIXUcLqhG5BQPgoWLembgM4AZZvZA2p6AC/QOmNm5ZjbKzEYNGjSojsMFQRAExdQswM3sNeAlSaunXVsDTzZkVEEQBEFV6vVCOQIYlzxQngf2r39IQRAEQRbqEuBmNgUY1ZihBEEQBHmISMwgCIIWJQR4EARBixICPAiCoEUJAR4EQdCihAAPgiBoUUKAB0EQtCghwIMgCFqUEOBBEAQtSgjwIAiCFiUEeBAEQYsSAjwIgqBFqTeZVQAMnXt51c9Mr/L+Yy+82JCx1M0JS1Z5f0597bP0EQRBJmIGHgRB0KKEAA+CIGhRQoAHQRC0KCHAgyAIWpQQ4EEQBC1KCPAgCIIWJQR4EARBixICPAiCoEUJAR4EQdCihAAPgiBoUUKAB0EQtCh1C3BJvSU9IulvjRhQEARBkI1GzMCPBKY1oJ8gCIIgB3UJcEmDgR2A8xsznCAIgiAr9c7AzwB+DHxS/1CCIAiCPNScD1zSjsAbZjZZ0pYVPncQcBDAkCFDaj3cAuWdaSc3ewhVc4pPz9BHdziPhlBvTvIgWEioZwa+GbCTpOnAlcAXJV1W+iEzO9fMRpnZqEGDBtVxuCAIgqCYmgW4mf3UzAab2VBgT+A2M9unYSMLgiAIKhJ+4EEQBC1KQ2pimtkdwB2N6CsIgiDIRszAgyAIWpQQ4EEQBC1KCPAgCIIWJQR4EARBixICPAiCoEUJAR4EQdCihAAPgiBoUUKAB0EQtCghwIMgCFqUEOBBEAQtSgjwIAiCFqUhuVCCnkO9ecmrtW9EH9XaAw3JKT706Bsqj+PkHbKMJAgWGDEDD4IgaFFCgAdBELQoIcCDIAhalBDgQRAELUoI8CAIghYlBHgQBEGLEgI8CIKgRQkBHgRB0KKEAA+CIGhRQoAHQRC0KCHAgyAIWpSaBbiklSTdLulJSU9IOrKRAwuCIAgqU08yq3nAD83sYUlLAJMl3WJmTzZobEEQBEEFap6Bm9mrZvZw+v8dYBqwYqMGFgRBEFSmITpwSUOB9YAHGtFfEARBUJ2684FL6g9MBL5nZm+Xef8g4CCAIUOG1Hu4IMhEQ3KK1zuGKvnEoXpO8RGXjKj4/mP7PVZ9IHXmRq82hkzjqDaGDONoRI73evtoxHfakGuRqGsGLqkPLrzHmdk15T5jZuea2SgzGzVo0KB6DhcEQRAUUY8XioALgGlmdlrjhhQEQRBkoZ4Z+GbAvsAXJU1Jr680aFxBEARBFWrWgZvZvwA1cCxBEARBDiISMwiCoEUJAR4EQdCihAAPgiBoUUKAB0EQtCghwIMgCFqUEOBBEAQtSgjwIAiCFiUEeBAEQYsSAjwIgqBFCQEeBEHQooQAD4IgaFHqzgceBEH3pt7c6I+98OICH0OWcTQix3u3yBPfgGtRIGbgQRAELUoI8CAIghYlBHgQBEGLEgI8CIKgRQkBHgRB0KKEAA+CIGhRQoAHQRC0KCHAgyAIWpQQ4EEQBC1KCPAgCIIWJQR4EARBi1KXAJe0vaSnJT0r6ehGDSoIgiCoTs0CXFJv4Czgy8CawF6S1mzUwIIgCILK1DMD3wh41syeN7OPgCuBnRszrCAIgqAa9QjwFYGXirZnpH1BEARBFyAzq62hNAbY3sy+nbb3BTY2s8NLPncQcFDaXB14ukK3ywJv1jSgntdHdxhDd+mjO4yhEX10hzF0lz66wxi6Sx9Z2q9sZoPm22tmNb2ATYCbirZ/Cvy01v5SH5Pqad+T+ugOY+gufXSHMcR5xLXojteiHhXKQ8CqkoZJ+hSwJ3B9Hf0FQRAEOai5pJqZzZN0OHAT0Bu40MyeaNjIgiAIgorUVRPTzP4O/L1BYwE4N/roVmPoLn10hzE0oo/uMIbu0kd3GEN36aPm9jUbMYMgCILmEqH0QRAELUpTBLikARXeG9KVYwm6L5IWa/YYgqA706wZ+B2FfyTdWvLeX7p0JD0ISb0lfb/Z46gXSZtKehJ4Km2vK+mPTR5WSyNptyz7FtCxN5K0Yfp/TUk/kPSVrjj2gkJSP0mrN30czdCBS3rEzNYr/b/cdh3H+JmZ/aLefnIcbxVghpl9KGlLYB3gUjObnaOP3wNXmtm9dYzjQTPbqNb2JX3dZmZfrLHt+sDnAQPuMbOHc7R9ABgDXF90nzxuZmvnHMNSwKpA38I+M7srZx+LAx+Y2SeSVgOGA/8ws49z9PFpYMO0+aCZvZGx3dKV3jezt3KM4WEzW7/avip9/KyTcXT6O5N0PJ4vaRHgFmBj4HZgWzyO5P9lPPayZvZm0fY+eDqPx4HzLIMgS/dkp2S9RyV9FTgV+JSZDZM0EviFme2UpX3q4wudjCHX/VmXF0odWCf/l9uulW8DFQW4pBdKjqeibTOzVXIcbyIwStLncKvydcDlQJ6ZxmTg2PRkvxYX5pNytAe4R9IfgPHAe4Wd1W5OSVNLdwGrFfab2TpZB5B+6LsB16RdF0m62sxOzNqHmb0kqXjX/7K2TWP4NnAkMBiYAowG7gPyPpDuAjZPD4Ob8fiHPYC9M45jd+A3+KpTwJmSjjKzCRmaT8bvR5V5z4DPZjj+l/F7cMU0QSgwAJiXYQzFvFf0f19gR2BalTZjgJHAosBrwGAze1vSqcADQCYBjl/79QEkHQtsjv++dgTWALKsPH9bNPZRwKP4tV0HmIQHJ2bhBPzhcQeAmU2RNCxj2wJHFf3fN/U3mZz3Z7ME+HKSfoBfvML/pO35w0U7QdLbnb0F9MvQxaiS7V7A7sCPgEeyjiPxSfKN3wU408zOlJSrDzO7BLgkzby+Dvxa0hAzWzVHNyPT3+KHl1H9xpgOvA2cCHyAX8O7ga/mOHaBvYF1zWwugKSTcSGaVYC/JGlTwCT1wQVxNUFRypH4rPd+M9tK0nDgVzn7AF+lvi/pW8AfzewUSVNytP8/YMPCrFvSIOCfQFUBbmZ5hUI5XsGF0064gCjwDtmEXvF4flu8nYTwTVWazTOz/wHvS3rOzN5OfX0g6ZMchy9+iO0KbG5m70m6HMg0czazrdK4rwHWN7PH0vbauFDOysdmNqdkgpFr4mlmHX5XklYCzsjTBzRPgJ8HLFHmf4Dzc/QzG/9xvF76hqSX5v94R8xsVvpsL2Bf/Kk4BdjBzJ7MMQ6AjyXtBexHu9Drk7OPAp/Dl+ork1NwFW7SvJjZTunhcy5wqpldL+ljM/tPDd29gs8q5qbtRYGXc7Q/BPgdnhztZXz2dVjOMcw1s7mSkLSomT1Vo85SkjbBH0rfSvt652jfq0RlMosabE+SdgIKy+47zOxvWdqZ2aPAo0nQCb+vDHjaPItoPSyGr3Aq8ZGkxczsfWCDwk5JSwJ5BHg/Sevh1663mb0HYGYfS8q1OgNWLwjv1MfjktbI0f4JSd8AektaFfguULPaMzEDX0nkoikC3Mx+3qCuLsWF3HwCHF9eVSTN7g7AZyL/Ar5mZs/WOJb9ccHz/8zshbSk+nOeDiSdAuwCPIerQH6ZR4ee+lgSOJ72H/uduH5uTrW2ZnatpJuBX6YZ56fyHLuIOfhNfgsuLLYFHiws4c3su1XG8SYZVRQVmCFpIG4Uv0XSf4FaHkbfw/P8XGtmT0j6LK7DzcqNkm4CrkjbewD/yDOAtILZEBiXdh0paVMzOyZHN9sCf8LvLQHDJB1sZpnHIukx2meavfHVcjU70xfM7EMAMysW2H3wyU5WXgVOS/+/JWl5M3tV0jLkVwVNlXQ+cFna3hsoVSFW4gh8ZfUh/r3eBPwyzwAknUn7teyFr5wz24na+mmSEfP3ld6v9gNv4Dhm4F/+GcCLZcZxTem+Kv31A4aYWaWMi5XaHwxMLDbW1NDHRNywc0natS+uztg1Zz/rApuY2Tk1jKHiDzOpiiq1vwQ4svDwSvrn35rZAXnHktpvASwJ3FjrrFNSfwAze7eGtrviBl2Au83s2pztpwIjCwJQXkzlkZx2iaeAHQsTlGR0v8HMhufoY+WizXnA62aWSXgm1dFg3JbxfC3XsZN+ewOLphl+1jZ9ge/QPsm5Czi7oPLrCkp+I/OA6WZ2T+5+miTAP8KFzFX4crujMqnKD7ykryWB7WnPRf4ybt2enaHtxXSuu7I8AqMRlunUT/FS+U4z+2vO9lPMbGS1fRXajwJWwn9o/zazp/IcvxGojCdSuX1V+qjboyf1MwJf6S2N36czgW9axrw/kn5tZj+ptq9KH1OBLQteJ8lGckdOAf6QmW1YtC3cI2bDCs3K9bMUfn+0rd4rGcjlVbp+DwwFhuC2peXwleGRWVaGRX19Jh3vtfRA2BxXBXVpDqb0GzkGP6fi65Dp+0gPnUvNrN5VZtN04MvjXgp74E+f8cCEGtQF38TVBTfTrmPdCviVpJ+b2aWV2pvZ2HzDrsgJzG+ZruolUIykk1IfhaXydyVtknOp/IGkz5vZv1Kfm+FGyWrH3gK30s/GdZX3AEtJ+hjY18yq2hSK+ir17gHAzLJej16SljKz/6b+lib/vdoIjx5wtcMPzOz2NJYtcbvNphnbbwuUCusvl9lXiZOARyTdjj9EvoCrdaqSZv8AkyT9HZ80Gf77eyjHGJD0S2AsroZp89aisoH8QmA/M3ta0kbAYWa2saQDgQtwL5Usxz4YONr/1a/TOB4HTpJ0ipldkKGPYhXQfOR4II7D7WWPkU+PXzjO/yStLOlT9dohmp4LRdJgPBXtD4CfmFlmvbGkp/EiErNL9i8FPGBmq1Vp/80Kb1vOsdxvZqPV0cd9as5ZUiOWyuviM8Yl067/4j+gijo+ucfMdmY2M+nvTzOzXSRtCxxlZtvlGMMyRZt9cWGxtJmV9SMu0/6b+AznalxgjcFtC7lsCqmvgkfPnrh6K49HD5IeNbN1q+0r0+47wKG4q99zhd1Af9wvfp+c41iejr7kr2Vsd1GFt/OuMp8GRuQROqXXSkW+55KmmVkmw10Svhvj3mX/AT6XZuJLAbdnWWGWqIDmwzIa7CX9y8w+X/2TFfu4FDdaXk9Hd9/TOm1UhmbNwAEKjvV74bOUf9DRzSlTF5R/on5Ced/ZUjpbPu6Eq2TyCIxGWaYHAoUAjSUrfK4z3jazdZXSFZj73GZxR+ttZjPT/y/ixmHM7BZJZ+QZgCXvniLOkDQZyCTAzezS9PmCR82ult8rqEDNHj2J5yUdR/u9sA/wfIZ2l+P39En4zLHAO5YjAAdA0q1mtjVF+faL9lXEzPbPc6wqPI7fn5kCkRLPpet3G+7+NwXaHAjyeON8nPTcBXfE1wDM7L+SMs1Czew/aVL0T6vRWytxfDKC3oobMgv957GZPZdevejohZeLpghwSb8AdsB/UFfilXzyWpLBgwAelntOFJb4Q/AHQlWrsJkdUTQm4dbonwD3kz3AoEDdlmnKL5WPrtxkPibiPq7FPvITKHLh6oRJki7Af2g7kVRB8nwkedzmSiPeeuH+9nnvtafw1cMiqc8hZjafobnCGOr26EkcAPyc9qCku9O+iiTd7hxgL5VEpdL+gK5IMrYtBiybZpqFSckActafTdej4ON/Ix688n0zu6xiw44U7s/H6Si4Ktl5DsBXUz/FA2eOTPsXI58XiknqYx4Bu0NhZ7pGmR8ESX3xiaQl8+jfS9gfnxT0oV2FYrTfI1nG0RBPvGYZMT8BXgAKluPCIIQv6/KoDJYCvsT8Rsz/Zmy/CK5P+xEuuE+yGr1IGkEdS+XhwFrAKXSM8hqAq0DWqtK+D3AgsCb+Q7sw3ez9gOWyLi9TX8VudvPwIKFTs15XSUfgto3XcWNqLfdF3R49Jf0tiQdrvZOz3XF4cFjhx/01IFNUqqQjcTfGFfD7uiDA38bDx/+QYxxTzGyk3Nd/R1xleVc1VVBJH0/gNoEOul8zuzNrH7UiT3L3SulET9KKwBpm9s8cfV0HrIeH9herLzJ5v0l62szqyoOSjLA/xn+zxakeckViNkuAN0QX1YBxHIbPCG4Ffm1m0+voqy7LdFE/65Tpo+qTXdLOuHDYiY6l7d4hhzeG3JvmBuvos9ulSHoWt22UqmKytB1uHrRTNu9FJY+JTvrbEDfEFZa5c4ADzCyTui/pjde19qjUfsCUPAJA0hFmdmaecZfp43EzWzst/SeY2Y1ZdPklfXTwZMnY5jO46szS3yNwm8Q03Avl1Tz9lfS9k5nlLuOoTtxcLaP3W7Ir/KYOtR5JazAenzgegq9GZloO7yToBkbMBYWkx8xsRJXPfILr82ZS3msiz4zvacpYpnPOXC/El7ZPFPWR19C0iZndl/XzZdpfhueEmIjPwnO7EcqTN/0KWMHMvix3JdvEMngKpPa3A9vWolaTdK6ZHVSyCihguWc4blg+zMzuTtufx0Pqs7qM3Q7sYu0+7QOBa/KMQ5418EYze0eeB2R94MQ8DyN5MNDXcBXKRrgu+29mtnGOPk7DVSfX01GFUsmN8EbgBmBx4Bu4B8flaSzbmNnOGY9dGscg4CzcUFxLzMangIKTw9OWLznZNGAVXIvwIbWtECeb2QYqcnSo6QHZpBn4e5RPTlS4EJ3mCy/pp7PgFAHnmFnFvCrJ2Php2vXnBVYCXrMcUZkNskw/aWZr1tlHXzzku3RpluchMAA3Lu+PP9guAq7Iqj6Q9I/U5v+SQXUR3Jum4gO1qP0FwOr4D79YUOSy0DcClfdJz5zFT9JfcJVYh6hUPHQ607K98CNPD48T8eRYP8sjfFM/SwNzkmpsMWBAVhVdap/7oaiOXlkvmtmQovfyxCd8jNuV3qBdlTQGt+/kneRsiQe6TU99rYR7amXKBNiZBiHnZK3gtXYT7if/Cr4yypNAr2leKP8u/VHUyHj8iV7uKdS3zL5STscNqB0ufBJgp5MvkVMjLNP3SVqznqUZ7i3xFG4X+AVumM2bT+VtSRNwl63v4cbAoyT9PuNSflkzu0rST1N/85QvX8WL6fUpagznT+qxcdYxmnMvM8ubV/xOSX/CDdOGxy7cUVDRZJgFX5teBe7IeXxon+zsAJxrZjdIypzZsYgVgG3SQ75AxViJYqw2z41iA2PpsfJ4oWwKnAw8ZGZngwtiq83L5re4y+zTqZ/V8O+3mqEfaPNm+TywqpldlPTZ/XOO4cRkV/khcCZuq8qdy787pJOth6m4cezx0jckbZOh/aetKKlNATN7TNLQnGOp2zKN3+D3SXqNGpdmuH/sbpJ2NrNL5EmM7s7aOOnSx+Lud5cCG5nZG2nG9iR+s1XjPbkvuKU+R+O640xYstCrPQlSLRxoZmcV9flfefBIXgFe0BEfX7J/PbJleXyL+m0KL6eHyLZ4hspFyZkQS56Xe0vcSP13PJjoX+QQ4KmfHZh/dVcpH8p1kvqb2btmdmxRP58D/p31uGb2kDwm4Yi0EvgJtcuRPlZkUDezf8uN+JlI13IUvkq8CP/NXwZslrUPa09GNod2d9ncNDudbFlyLJW/h1vky7FLhvYDK7yXJR1tMRvmMUx1wgV47pKaIrwSBV3ebHmazNfw0OWs7AKcXrqctPaUqln4Aa4nXUXSPXjSo0wRd+B6fPxa9AeGyIOTDjazQ7P2gfvjy5KOUO7/m3s2X+Oss5g9cD/4mm0KuBfL9vhkZbbcU+moKm1KGYM/jB4xs/2TnSKPCyGSzsHd/7bCs4aOwdVBnWKdBG8l9WTmeyK1+QT4XVodnp6nbQmTNH8yqzxRurvgD/CH07hekZTLl1sem3EE8zss5Eq90SwB3hv/cWYJtumUgmGpk/eyfCGTJB1oZucV75QXA8gbVHRvA9QfM60Gq3oJ5yZ1wXG4EO1PxgCaJORW7kwXaGal5e8647/AFvgMRcDTtOcpz8IZuAro+nTcR9VJBZMK3ASMTzNXgINx/+dc1GuQNbN9imwKF8uDTjLZFCQNMPfn70u7X/7S+Oosb1qAueZVheal8byB637zsGnSxU81s59L+i1VMium8R6O63gvwD21NsHVer+yjO6+xZjZy5LG1rE6+w6enrhgf7ibfCuzj8zM0neJvGpTXv6CX4+/UvtkrWkC/NUqy67MSNoKd0tqS8AEnJ/RAPk94FpJe9MusEfhM7UsM/hiRgNT5HlAalV/PJJUHn+lRj26mRXyqd9JhootJW0bEeQAbljayVKSoSR8zwIyGTHTWOqqyIPPUA/Gf6zgRsQ8ueYLXEwyyKbtf+O2l0wCHOqyKRQqzpSrzGNk/H7lF3Kq3APmvNTfu3iFojwUcuq8L2kFPLf58lXaXIavKDfAo1gfA36Nq4MuBjJ5oRSQF/o4nxpWZ5Jm4VWA7sGjpM+p8SFwVZoYDExquQPw65qHuWZWMStrFpolwOuaebd14smfPoMbDj+Du/U8B1wt6VdmdnWl9uaFIDZND4FCvcUbzOy2GoazfQ1tSumHC+7ivCO59OhJP/p15l+aZX1gvgs8Js/lnTvIIXEI8Be5T/n6eARfntJydVXkSSuJJ8xTpeZOh1tCXQbZemwKZrZjEr5bWI4o1DL9mKSNkkH3HLlr3wCrkh+nDH9LD4Hf4OoDo/pDcQUz+0o6jxlmtmXaf7fyVTYqcDq1r86G4ROtTfHI0PUlTccF+j1mdlWWTszs1KSPfxtfZf7MzG7JdRauCiok4svkklmOZrkRLm0580F00k+br7fcVe1OM9ssqRDutpxFcGscw4A0w1q63PuNOM+c47kRN4xMpmjWaiXlsCq0ryvIoaifTfCovbl4haOZVZoUt10Wr8izDf6wvxkP+sgc2COPtjuiHsGX+rkDfyDeYmbrJ4Psr81si4ztL8Z13/OppSRtnUUtpQwxDRn6uAT4g5nlykBYob9Fgb7VVmpyP/ot8ECox/CgpunJyH235XSblfSAeTbDR6zdPTFXQFJRX4vjzgffA4aZWa6UEfWQJp/74hPO4piPXHEKzarI0yih9knRw2AFUs6O5HHQkFl+BhqyzIXG+HDjRWNrXg3kFdTFSPorHT0DFsMfJhdIymygscZU5FkKTzD2IB1XErmMRNRhkG2gTeFhSRvWKXw3BvaW9B/8emRW8Un6opndpjJxF0kP/BbwL/Pal6WchLu1gqsazk8/zTXwHDN5qXl1ltQ+m6ZXIWBmMnAsGdRJkt6hvOdLrviVxG7AZ63OdLJNzUbYAH6F643/jS9lvgMg98t8tCsGYGY7pr9ZMv5Vo24fbtyYOsLKuEdmQR7cdBLublb8EMnyIDq1lmMWHfvH5kWDi8tNtZFTjXNcPWMpOubD8lzpxQbZjTK2bZRNoWbhW8SX6jj+FniSs87iIpbBheC2pW+Y2RWSrsJX+/PSymgk8LLVFkZfT73UGbjq53Tg6BqEZ0FVew2enqKe1V0tmR3no+VD6ZPq4rPAs1ZbtrlGjWO+9J7l9lXp4xEzW0/tkXd98GXm6Bx9PInrW2sypkr6F+7zXAhk2h8vzJvJk6UeJH3VzP7aQDXOyniwxT+Tzrm3ZY8m7Y27760I/MO8HuaOuBdFP8sYiKY6EycVnUcpfa2JSddKkUfP3lXuO0o66tfNizpsRvJCMbMbuniMm6Rjb4rrw6fjM+/7gEmWandW6WNJPC3unvgEZzwuzPOmCL4DT5vxENkzO87fT6sLcAA1sQyY2lN+3o4HShSn/LzR8tUcfNDMNpJ0F57j4TU8I2EeNUxdYb5qz9FQbF+YbGaZotTS50fjxrk1cI+e3sB7OZeYdZG8Aw7CC0msklYW52R9oCbd9Uq4n/PGuBvcBnjk7l9yjKPmh5Gkn5UzPsvdAK8vMgh2C1QmxYA8l/xG+Gr/JmBr3PVwC9wnPZc/uxpYL1UerPdVXA0z2MyyRG8X2vbChfjvcXfIXGke0qpuPixvZkcza9kXfhNMAv6J+x7/Dbco3wGs1EVjOJL22e4LRa9HgcNz9vVtXHf7BbxowBu4i1SWtgPS36XLvXKM4V48yu8a3H93FzzZT57zmISvAh7Bhff+eJrerO1vAQYWbS+FpwjOM4Yp+MPjkaJ9j+Vo/zi+8gCfac0GlsnRfhCwZpn9awGDMvZxM16JqHjfp9O5/WxB3te1vIqvddG+J/BJzWLpN7pY2t8HeLxBx5hvX4X2w0m6eFw9+Sqe6uBHGdtvik9OpgB/ADZv6jVv9pde7w1T+DHgS6Jr0//bAjd38ViOqLN9L2D3Otr/Lf19IQn/4ofJ8zn62RD3sR2M+z9fA4zOOZZJ6e/U4u8qR/sp5b7rnGN4oLgdPgOcmqP9w5W2M7S/EvhCmf2bA5dn7KMvPik5LW2vCjwLHFLPvbagXuWuUUFIp3P5L65+An+wP1nDMR4FliraXpqMD2bgTTz9xp+Ab+JpJ/Ice3oS3Efjq4r1i18Z+3gHdz8s+8p7PVrdiNmwMmANYGzyD77catDFm0fJ/RgvOpsba5Ax1do9Hd7FZ8618L48XecUeSWYV8mXu+N/KqrAk9RCeXV9d0o6BuiXfHYPxQOksjI8ucCBzyBXSdtZbQqfszLeJ2Z2t6SzswzAzObKCzCMl3QFPvv7npldm/00upRynl83SLobF+Dn40Ew9+Or50zZ/0r4LZ4vqEO91IxtV7H6jMnT8fvwS8xvFDaq58XBzJYAkBeIfhV3XBDusFAtKGo+WloHLs+fbbSXAXvZzH6QDFYPWw79cwPG8jlc4O2BqxAuwlcBmS+wPGfzm7hhpNjglddAsivtJbzutnw621F41OHK1FiYIgncN/Bl8vfx2p5/tIzpeSVtD5yLR5MKn7UeZGY35RhDL9wlc7vUx014hG6m76MzW0IBq2JTUIWqLZXeK/lcIV9QH7x6y90UCT1rQnrdSkj6g5kdXmb/JvhD735Jq+BquRfx9Km5w8glrUV7AqjbLGP6Ckm/wZ0d/lSy/2DcDzxv+cKaKee7Xos/e6sL8IaVAWvgmHrhfuFn40bVi4DfZRHC8jD8UszyGTH/iOufr0i79gCeM7NMrlZqQGGKRiAP5hmNP4QesBpKo6VVwPDUx9NWg8+tpF9bSZWUcvvKtLsBOMvM/l6y/8vAd83syxmOfXyl961BdRWzkjwwTsAfqOAP2F/UOautdSzL0dHNtapLn7xQ9qjSh3j6zU61jIF/aYL4A2CIefGQVYHVrT3DYJY+7sXTS1yJ35974YVDNs3aB9DaOvDu9sLdgs7AfYV/j3sv/JAyOt0FOIanSA/mtN0Ld9nK2v5fDRjDjrh94i1ct/cOGfR7+Kx/yaLtrXCf3x8An8o5hh3wQh134ILmReDLNZxLOb1uVV06rq/+N57v44j0uiTtW62r780G3VsT8eCbz6bX8Xh1oVr7y2xULmqzE/AMvkJ9AZ9kPJGxbadG06x9pM+Ox1dEBf3+Ynl/43iqi+vwFfdMPLnV0LzXo6V14JL64xdyV9zl6yM8NPUcM7u4i8cyGfdUOB/4ibX7lD6QfF+z9NEX19W2qT/wc5mbYyjPAkOAwox5pbQvK40oTHEG/p08ZuluzchV+PJ6jqSRwNV4UNG6eLa4b+fo67fAVpbUNmnpfgNVsucVkPQd/Lso6L4LLIF7OlXEzJ6RNAIvI1aY2d2JexVl+j4lVUx2ZPkCmxrBKmb29aLtn6tKPhNVrpr1mRrG8Et8ZfZP85iJrfAkWVn4QNKqZvZMyRhXpT1RVxZWMbM9JO0FbamWM0d+pxiDwy1jOblKtLQAx6vxXIsnktodr7t3JXCspNXM7JguHMu+eMDGMOAnhe/TzH5hZp3dxKVcis9WCwmOvoEbOXar1lDtYexLANPkIeSGrwIq5mwuoRGFKV7CZyd59XP9zOyV9P8+uErst2mJOyVnX+9YR5378/i1zcrluLA/Cfc6KO43k00iPcQvKgko6idpCcsWUJQ3pfGC5gNJnzezfwGkiUk1wVdv1axSPjazWZJ6SeplZrfncFj4GfAPeTWj4uyjP8XzoWTlo6SmNWibHFQNAipgruatq/xigVbXgXdQ+isVBU0/+Ceta42YN+Iz8IepIYlU6mO+mpjl9nXSdotK71vGAIGsBrYqfWyIz5TuJEdNy5LgoYfxoJmb0nZb8deMYzgbV8lchf/QdsPVKP9MY8n0QEo/zhlm9qGkLXE12aWW0dOo3oCi7kRaFV2CG6XB3QL3swpZDdPKdD8rXzXrJTPLlZNc0j/xgsgn4yH8b+DFVDLpjuVFTo6ifVX0BF5hPnPqieTVdCxue7sZr8Qz1szuyNHH2XiU79V0dFjIVZy51Wfg7xVmBJJ2wnWumLvkdVUyqwJ1JZFKPCxptJndDyBpYzIm7i8V0PJovVq+30YUpvh/uBtiX/JVwblNnjfjVTx45zYAeQWavAbIvsDruLsauJ6xLx55l2dFMREYlbyMzsX1lpeTPT3uYbjP8APQplrJVCFJ8ycH64DlT8xVL9OAU/CK7APxRGVfw32rO+N71Fc1q5Sd8Vn/93DXuyXxvEGZSA+S/Wo4bnEft6QJxmhcFXSk5Tey98XzqRe7HuZd6ba8AD8Ez262Kv4kPQAoJLM6q1LDBUDNSaQkPYZ/eX1SPy+m7ZVpz+SWta+D8Bt6Lq4CEfmyIjaiMMUKVlsq3+/hXjPLA583s0J5uM/QXlAhK70oH3Kd17f9E/MkTLsCZ5rZmZIeydH+QzP7qDCfkKc9zrrsrSs52ALgOtpXmS9naWD1V80qbfNekUrqkuQRkikNbJIT/4dP9E7DizBsjtvNvm35sj2umI67CPAFebbNPIVXao2x6EBLq1C6E6ojiVS9PsclfT2Dl/zK7XZXaSw5x3AKbmS6uZYxNAIV5YuutC9DPw/gRtn/A75qZi9IejzrAypdi9l45N8RuGH0STPL+0BqOnnOu6jNssX3oqR98BXJ48B5ee0k9aik5InaLqW9Avz38OCuzYETzWzjjGO4EFelPUHHXN6Z87GoMamjW34GPh+SbrOcSdEbRFW/3s4oFY6lPq45eQ6otVYg5I94LMd3gB9J+hAvspwrX7Iakwyrl6SlLNVclGetrOV+3x9f6f2/JLyH4YblrByN/1Afw0u8/Z2Mpd0kXWVmuxet0DqQc1XUCGpZZd6Mh5oj6VhSKgHc1XQNXJDmoWaVFNDfzM5NYznE2it23SIP8snK6Cx2qSo0InV0awvwEvcucEGxWmF/V97geWaonZH0+L/Fi1O8gatQpuFP6az8FP+hPUBHA2JWl7MbaC9M0Rf3qnk6zxgshQvXwR/wTG9X414C3wRWy9lHccg1uBEza8h1G8kW8N2i7Rfwmo5Z6Yd705wHbS5k/cj2kD0y/d0xx/EWJJ/HU0bkWWUW26J2xZM/vSev/ZqrfFiiHpVUcdRnqV4+T0TofQ2wE33OzHaTtHNSBV2Ouw3noqUFOJ6b4G3gRNywIfwidJZ4vrtTj49rgT/hxr8OkZRZsZLSXZLWx5f9VUmfrdR35h+smT0rqbd5lZeLkt75pznaXyppEu1Gol1r+cElYVVu9pvVpnArXhru3bTdD5+VVvWasFTwoHhyII9QnZVX9dAgalll9pO0Hm6T6G1m7wGY2cfKUVu0iDtVe46bQn6b4tw2pO08BcAvxYX4a9RuJyrYd2Ynz5jXgKwriTZaWoCb2U7yZD/nAqea2fWSPm7EbLhJ1OPjWqCPmf2g+seyYV6RJpNuEJ/1gs/cR+HpDYTrCyfhyfSzUG8yLKBt9lzPLAn8PAr0xWfyZeufdkJfMysIb8zs3WR4q0pSJZ2MG91+iS+7l8XVQ980sxtzjKNuavxdvYobDAHekrS8mb0qr4k5r4b+alZJ4SqbRnABHvdR0yQpcW4yrB+Ll+zrTw1VpHqEEVNenPSXuHvTBmY2uMlDqokiH9eT8B9qLh/X1Mev8JXJX+moQskUfKL2BErgQnN9PA925pJckq4Bji/oStMM4wQzy1pLcmXcBfBTtCfDOsvMnss6hgWJchS4kNfRPKKw+pC0AV5cuOrDLK0gjsHP/1w8FcD9koYDV+Q1yHYn5LEafc0st71GDchxU9RX7hWNpPuyfH9V+lgUL5Y9FPc+A5/FZ3aJhB4iwAtIWhf3wDin2WOphfQg+gAXnAUf13GWrxp7XQmx1DGB0jz8YTDRcoTzS3rCzNaqtq9C+yPN7HfV9nUFJWqhXviM/DuWMWucPKjpSryiTyF8fA8zqxplKWmKmY1M/08zszWK3svtUdMs1MCSapJ2AM7BjfXCbTQHm1nVFAmVVjRA5hWNPGHcQOafJGV2I5QH/s3BI0JrCvyDHiLAJfWxdp/hwr4O7ks9gUY8+TMc48ulP4Zksc/8UJTnrn4PuCzt2hv3ANgrY/typbmaIrAk3V60WXig/cbM/p2jjz54UWTwGePHlT5f1K7tOpRek3LXqDuixpdUewrY0Upy3FiGqOtGrWgkXVRmd143wtwumWX7aWUBnox8f8Z1kw/jOaOnp/da4gbPQxYhloTFd/CybODZ+P6UQ2jcCxxrZoUoyB8DW1mG9KdFffQtGcNdwNnVZvHy5EDfwL0dii3yS+ABNU0PP09eJHua2bgqn/uimd2mTpI5ZZmtJSNfoQp9seeKcPVDn87adhckPYGHrffDg39WNE/+1AcX4Hn9yh8ysw2LtoXXjd2wQrPCZ7vNikbSuXhgWO7Av2Ja2oiJh/V+ybxi+Bjcn3Nf81D0rg6l7wqyPG3PxnVqf0zb+6Z9WTP57QT8TdJReJKw4Xj4cvZBeiWZs/C8IwU9ZZYHyL240WtZ2g2i4EmoKoVrNxx5KoLD8Ii76/BzOQxPDzwVT9BUiS1wb6ByHlGZQqbNLFOEYTfHzMwkFSdGAzf+5TZMA5Mk/Z2OOW4eKjwoqzwYiw2OpUm48ujAB+NxCoUso3fjUb8zMrQt+PQvAuwv6Xlq92Rp+Rl4aTKrtfAfxk/woq89bQZedVVRek0621elj+VwgTUZOCCPgSe13xJPejQdvzFXwhMa1VJCqylIug5P1nQfvuxfDtryXkzJ2EcvYIyZ1VQmrycg6de4y2RffDU4HCiUVHvezA7J2V859UWBimqMRq1oJN2CByMVArr2AfY2s20ztG1Y1DW0vgCfhOvDXivaNxgvBLuK1R9Q0q3IqEJ5GNit4LEh6bN46apqgv8dOs5CPoXrfI0cUZSpr8nAN8zs6bS9Gq5jzOq50YhIzLpQx8yIvfGVwZA8xtzUdpKZjar+yZ6LOpZU+xzuaVVzSbVmU6yKqbSvK2h1FcrRwKdxJ3gAzGxGmgFmKiHWYuyb4TNHAbenpZnwaM6qiXMa/LDrUxDeqe9/J51nVhoRiVkvbSof8/zNM/IK78Q/Jf2IOuuctjJmdl/R/89SQ5IuSWdSOTNjVxa3mCXP6VIoW7gXnlmwy2npGXhPI+nxfk37cj1XDpGifhalo9dD5mTzqf2KzF/UOLP6Iy1z/0dHL5TeWa30hVmrinKAN8HIVFhuQ8cld968LnXXOe0J1HtvSyqkgN0Mz8M9Pm3vhicHy6WKqYekBjmT9sC0e/A6p1XrcjZ8LD1BgMszkp2Ef7HFmb1a6kci6Vk8413upDZFfRyG+47PTttLAXuZ2R8rNmxv/2s8peuTtPunmuXIPZ0eIIfh3iTgRp4/Zn2QSLoLDz8/H19dvYonzM+sxw+6F424t1M/9+Ophuel7T7A3WY2ugHDbDlqsQJ3Ry7CPS3mAVvhuQouq9iie/J6vTc4cKAVVYsxz8Z3YI72X8MrbH/FzL6aXnmEd2/gUTM7zcx2Ta/Tc64C9sXvzcPxWfBKeNRayyBpY0mPSnpX0n2SGhXG3ao04t4GL/RRPGvvn/Z1GZJOkTRAUh9Jt0qamVQqXU6r68AL9DOzWyUpWXFPSIa0nzV7YDmZJGk8XqG61oLCvdN1KNTr602+qjjP426IudQuBZK++GlJQ2pdUprZf+RFOTCzn9fSRzfgLOBHuA/8TnhO8czpCHoKRX7wjbi3wSMpH5EHWAmPNTih/pHmYjsz+7E8D9N0PMviXTRh0thTBPiHyWXrGUmH4wED/Zs8ploYgOtZtyval7fM0k3AeEl/StsHA1VDhIuMRO/jSaRKq9LnMRItBTwhL6xcbLirOJNPQRnH4zPvXmnXPDzgIVeOiG5ALzO7Jf1/taTMmRR7GMV+8PXe25jZRZL+gRfrBvhJsRdaF1GQmzsAV5vZHHV5BUenp+jAN8TzZg/EcxwsCZySAnoWKtKD7GDcdxngFuB887SsldpVrBNoZpfkGEPZAstWpbCyPJHWl/GI2hfSvs/i6rEbzez0rGNoNskL6EdFu04t3q5h5rlQowamKm7AWE7GVY0f4GkCBgJ/s4wVfRo6lp4gwHsKakCZJUlfxXND1O1fmwygK1mFquONRJ7ze1sryWGT1Ck3d6UXSr3UE3DSE6n33lbHnDSlmHVxFS55hac5SWW4OLBEE1YCPUOFkgJFjmJ+17dmlFarh0aUWdoDOEPSRLwSTN6iyHfgOttF8EjMNyTdYxlyjJcJBmp7i2wuY31KhTfecGZOP/KmYw0qWtuDqOveNrOt0upyEzO7Z8EMMRvyfO6HAkPw+pwr4G67f+vysfSEGbikR/EUk6WpGaum7OxOFHydC/7PtbpIyfN47IUH8BjupXOFmb2TYwzfxmffxxf7Yy9IVCFVQKX3ujOSPg38CljBzL4saU1cCF3Q5KF1KQ28t7s0HqCTMYzHZc03zWztJNDvbUYkZk9xI5xnZmeb2YNmNrnwavagaqC0zNKS1FBmyczeBibgeaiXB3YBHpZ0RIbmi0haHtidrp9RjJT0dpnXO8CIqq27JxfjhuUV0va/8WroCxsNubeBWyV9Xc2yGjqrmNkppHMyL0rRlPH0FAH+V0mHSlpe0tKFV7MHVQOlZZaeJF8BXSTtJOlaPHFQH2Aj81Sw6+KZ9KrxC1zgPGtmDyUj4jN5xlAHj5rZgDKvJawFUqd2wrLmyaw+AUgBKLXUgmx1Cvf2cdR4bycOxlMsfFR4uEsqLVC8oPlIUj+SulCek7wmt9t66SkqlJYOV1bHMmZtu9NfM7PTyrzfWV+XABdYmdB3SVub2a01DnOB06pqkkokm8LXgVvMbH15oq5fm1lZT52g+yMvpnwsHvl9Mx7eP9bM7ujysfQEAd7qqL2M2erAhvgMBdyH9kEzyxzllSziH5jZJ8m4Oxz4h1XJxy3px2Z2ijpJGpTTD7wmJM2gvQDufOR5kHUX5DUwf48XNXgcGISnmO3S/ObNInlFTU0Bdkj6Gf5A+w+emrfc5KtanztRVLDEzLreeOhFmUfjE637yxnfu2QcPUWAJ71aaS6US5s3ovzIc4DsUDA2SloCdwn8QuWWHfqYDGyOB9PcAzwEfGRme1dp91Uz+2tn/uB5/MBrRdKruM93WX1iq0ZlSloEfziLHCXVegKSpgKjzavw7Ig/oPcC1sPTHueKTk0+2BvSXlBjL2CSmS3wQKnu5IteoEcI8DSD3RIX4H/Hg0H+ZRmroHcXJD0NrGMpb4g8KdRUM1u9cssOfTyclupH4CkGTlGTchXnpYeqUKbixuTxlnK0L0yoqJiIpAvxB9iv03bu7ztdz5GFOAd5qohHushLqlv5okMP8QMHxuBGukfMbP/kutWKyawuBR5MRkjwaK+Lc/YheQL9vfHACfCCCFkbr4ZHDA6l633qe2IZvK/ivvlXycuKjQeusiakHm0SktQfD6PfmvZSf1C0Ws7JQLyyPLg3S5dgZlt11bGy0lMEeEHnOy/5QL+BZ7BrKczs/8nzPGyedu1vZo/k7OZI4KfAtea1Qj8LVJo5lHI17lN/Pl3vLdH0osWNJul+TwFOkac9Pg73vugJ9S6zcAYwBXgbmGZmkwAkrYenCc7Lr3CX2DtoT2Z1dCMGmhXVWTi8oWPpISqUPwLH4FVcfgi8C0xZGKPhJK1Sz1Jd0mTLWPosyIa8AMAe6fU/XJ3y28qteg7yAiHL4W6iBdXH8njkba6ViKTLcF/6/+KZAB/q6hB2SefjLroFu9C+wP/MLGvh8MaNpScI8GIkDQUGLCxW/lIk3QkMxo2XdwN3mdljGdoV/Oa/i69grqVjNsKFpgRYI5H0AP5jvxoX3M83eUhNQVJZQ3w5d9cq/WyFr1A3B1YBHsHv8d/VPcjsY6i7cHjDxtLKAlzScDN7qjPrcDOswt0BSZ/CLfVb4oEP/c2sYmBT8qU3yuuhW8anvrshaXUrqg+6sCLpr0WbffEsfpNrsa0kw+WGePGWQ3AV6vCGDDTb8WsqHL4gaHUd+A/wZDLllqMGtFoyq7qR9HnaZygD8XD4u6u1M7NhqX1fKyneK88kF+RA0j5mdhmwg6QdSt9vRZ/2ejCz4rzgSFoJ14/nQp6nfnHgPvy+3tDM3mjEGHNQU+HwBUFLC3AzOyj97XbW4SZyB55o5yTg72b2Uc729wKlM4ly+4LKLJ7+LlHmvdZd9jaOGUAtZeamAhvggVFz8Nwq95nZB40cXCXMq3+tSh2FwxtFSwvwAqqzkG8PY1k8tPcLwHeT69p9ZnZcpUaSPgOsCPRLHgIFVcoAYLEFON4eiZn9Kf2dL/hI0ve6fEBNpiTCtxcwEsit4jSz76f+lgDG4pk2PwMs2ohx5mAD2l1tR0pqSuBgS+vAC5QLVFE3SDvZLOQFdLfA1SibAi9Wy72RIjDHAqOASUVvvQNcbFFBpmFIetHMhjR7HF1JSYTvPGC61ZDXW14ycXNcgE7H1Sh3m9ltjRhnxjH8GTegTqHd1da6It3EfGPpIQL8MTyCsbiQ71QzW6u5I+t6kl7uKeBfeKHVB/OoUSR93cwmLqjxBSDpJTNruTiFeknG9eH4TPzpGtR7SPoRLrQnm2d27HIkTQPWtG4gPHuECgUv2pu7kG8P5XNWRzk1M5uYjG6lpa9arahwd6bpP/yuRtJXgD8Bz+HquWGSDjazf+Tpx8xOXRDjy8njuNqmlkCkhtJTZuA1FfLtiaRQ+LOBT5tXC1kH2MnMTszY/hxc570VHo05Bp/Ff6tiw6ADqlxerp+Z9ZTJUyYkPQXsaGbPpu1V8ERtXeb+Vy/JFdJww/RI4EE6xkrs1OVj6gkCPGgnBfIchYf2rpf2PW5ma2dsXyh5VfjbH09Hu3nVxkHQCZIeMrMNi7aFTww2rNCsWyHp+3hQ1sO0Vxhqw8zu7Oox9YhZQFEQSgcW0uCTxczsQXWsOJVHV1jwAX9f0gp40qDlGzW4YKFlkqS/A1fhv9XdgIck7QrQIkbyFXGngJ/i7oz34C629zYrUrlHCHDcc6JAX/zmaMWSao3gzbQ8LRh0x5BPV/dXSQOB3+AzDQPOa/Qgg4WOvsDruHcUwEygH56t0YBuL8DN7EfQZowdhQvz/fFycbPNbM2uHlOPEOBmNqtk1xnywgY/a8Z4msxhwLnAcEkvAy/gqWWz8hSemGeivIL6+sBfGj7KYKGihyWW64fHRyyZXq8AVfMNLQh6hA68JBdKL/zp+J1mJJdpNqkIxBg8yGBpPI2nZfUiKdJ9fx74JXAq8DMz23gBDTlYCEjpGL7F/N5NBzRtUDmRdC4+/neAB4D78XJq/23WmHrEDJyOuVDm4bPO3Zo0lmZzHTAbV3+8UkP7gufODsB5ZnaDpEweLEFQgT/jq7svAb/AV4XTmjqi/AzBIz6fAV7G0wHMbuaAesQMvBySvmdmZzR7HF1NHo+TTtr/Db85t8XVJx/g3gIL3WomqB9Ji5jZvEJkdNEKrw8eQTm62WPMQ/KeWQvXf2+K52R5C09XcXyltguCXl19wC7kB80eQJO4V9KIOtrvDtwEfCnlllkad0sMglp4MP0tuN3NlhcgXxIv8tBSmPM4Xnv3H7gnyip4Jawup6eoUMrRE+srZuHzwNjkWvkhfh3MMhZ9NbP3KfIIMLNX6QYRZ0HLc25KMncscD3QHy8v1zJI+i7tM++PSS6EwIWEEbOxLIwJg6CtfNd8mNdmDIIuRdIMoDT3eWFyZa2UF13SaSTf7zSxaTotPQOvFq7cxcPpFoSgDroZvfHZdtlKT108lrows26nlu2xM/AgCJqPpIebUWpsYaEnGzGDIGg+C6stqkuIGXgQBAsMSUs3K0/IwkAI8CAIghYlVChBEAQtSgjwIAiCFiUEeBAEQYsSAjwIciCppWMngp5F3IxBj0bSUOBvhQRfqap5fzwB0SF49sonzWxPSYsDZ+IJivoAJ5jZdZLGArumdr1pL0oQBE0lBHiwsHI0MMzMPkwViAD+D7jNzA5I+x6U9M/03vrAOuESF3QnQoUSLKxMBcZJ2of2mqHbAUdLmgLcgRceKOTTuSWEd9DdCAEe9HTm0fE+L1SD2QE4C59ZP5R02wK+bmYj02uImRWKDrzXZSMOgoyEAA96Oq8Dy0laJpWb2xG/71cys9uBn+C5qfvjedCPSEn7kbRek8YcBJkIHXjQozGzjyX9Ai8s8DJe1qs3cJmkJfFZ9+/NbLakXwJnAFMl9cJL8+3YnJEHQXUilD4IgqBFCRVKEARBixICPAiCoEUJAR4EQdCihAAPgiBoUUKAB0EQtCghwIMgCFqUEOBBEAQtSgjwIAiCFuX/AyzTyjmM7e4DAAAAAElFTkSuQmCC\n", 192 | "text/plain": [ 193 | "
" 194 | ] 195 | }, 196 | "metadata": { 197 | "needs_background": "light" 198 | }, 199 | "output_type": "display_data" 200 | } 201 | ], 202 | "source": [ 203 | "df_most_activte_user = []\n", 204 | "test = df['id'].value_counts()[:20]\n", 205 | "\n", 206 | "for idx, item in enumerate(get_username([int(i) for i in test.index])):\n", 207 | " matched_column = df[df[\"id\"] == test.index[idx]]\n", 208 | " count_sentiment = defaultdict(int)\n", 209 | " for _, item_2 in matched_column.iterrows():\n", 210 | " if item_2[\"prediction\"]:\n", 211 | " count_sentiment[item_2[\"prediction\"][\"sentiment_analyst\"]] += 1\n", 212 | " df_most_activte_user.append({\n", 213 | " \"user\": item,\n", 214 | " **count_sentiment\n", 215 | " })\n", 216 | "df_most_activte_user = pd.DataFrame(df_most_activte_user)\n", 217 | "df_most_activte_user.fillna(0, inplace=True)\n", 218 | "# plot data in stack manner of bar type\n", 219 | "ax = df_most_activte_user.plot(x='user', kind='bar', stacked=True,\n", 220 | " title='Stacked Bar Graph by dataframe')\n", 221 | "fig = ax.get_figure()\n", 222 | "fig.savefig('top_active_user.png')" 223 | ] 224 | }, 225 | { 226 | "cell_type": "markdown", 227 | "id": "8c8b13ad", 228 | "metadata": {}, 229 | "source": [ 230 | "### Sentiment for hashtag bitcoin" 231 | ] 232 | }, 233 | { 234 | "cell_type": "code", 235 | "execution_count": 14, 236 | "id": "80f4428b", 237 | "metadata": {}, 238 | "outputs": [ 239 | { 240 | "data": { 241 | "image/png": "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\n", 242 | "text/plain": [ 243 | "
" 244 | ] 245 | }, 246 | "metadata": {}, 247 | "output_type": "display_data" 248 | } 249 | ], 250 | "source": [ 251 | "bitcoin_sent = defaultdict(int)\n", 252 | "\n", 253 | "for _, row in df.iterrows():\n", 254 | " if \"bitcoin\" in row[\"prediction\"][\"text_input\"]:\n", 255 | " bitcoin_sent[row[\"prediction\"][\"sentiment_analyst\"]] += 1\n", 256 | "labels = []\n", 257 | "sizes = []\n", 258 | "for k, v in bitcoin_sent.items():\n", 259 | " labels.append(k)\n", 260 | " sizes.append(v)\n", 261 | "fig1, ax1 = plt.subplots()\n", 262 | "ax1.pie(sizes, labels=labels, autopct='%1.1f%%',\n", 263 | " shadow=True, startangle=90)\n", 264 | "ax1.axis('equal') # Equal aspect ratio ensures that pie is drawn as a circle.\n", 265 | "plt.savefig('bitcoin_sentiment.png')\n", 266 | "plt.show()" 267 | ] 268 | }, 269 | { 270 | "cell_type": "code", 271 | "execution_count": null, 272 | "id": "e9041c38", 273 | "metadata": {}, 274 | "outputs": [], 275 | "source": [] 276 | } 277 | ], 278 | "metadata": { 279 | "kernelspec": { 280 | "display_name": "Python 3 (ipykernel)", 281 | "language": "python", 282 | "name": "python3" 283 | }, 284 | "language_info": { 285 | "codemirror_mode": { 286 | "name": "ipython", 287 | "version": 3 288 | }, 289 | "file_extension": ".py", 290 | "mimetype": "text/x-python", 291 | "name": "python", 292 | "nbconvert_exporter": "python", 293 | "pygments_lexer": "ipython3", 294 | "version": "3.7.13" 295 | } 296 | }, 297 | "nbformat": 4, 298 | "nbformat_minor": 5 299 | } 300 | --------------------------------------------------------------------------------