1
0
Fork 0
bill_man/bill_man.py

524 lines
19 KiB
Python
Raw Normal View History

2018-08-21 10:27:31 +09:00
#!/usr/bin/env python3
import csv
import functools
import ujson
from collections import namedtuple, defaultdict
from datetime import date, datetime, time
from decimal import Decimal
from functools import reduce
from gzip import GzipFile
from io import TextIOWrapper
from itertools import groupby
from os import path, kill
import pytz
import requests
from dateutil.relativedelta import relativedelta
from util import EnteringLoop
from util.log import wrap
requests.models.json = ujson
from babel.numbers import format_currency
from botocore.exceptions import ClientError
from botocore.session import get_session
from requests import adapters
from apscheduler.util import undefined
from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.jobstores.memory import MemoryJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
import argparse
import logging
from util import install_except_hook
from signal import SIGQUIT, SIGINT, SIGABRT, SIGTERM
UTCTZ = pytz.utc
# localize 안하면 timezone이 canonical하게 표현되지 않는다 .
KSTTZ = pytz.timezone('Asia/Seoul').localize(datetime.now()).tzinfo
class ObjEntry(namedtuple('ModifiedEntry',
('bucket',
'path',
'etags',
'modified',
))
):
def __new__(cls, *args, **kwargs):
item = super(ObjEntry, cls).__new__(cls, *args, **kwargs)
return item
def params(self, bucket, path):
if (self.bucket, self.path) != (bucket, path):
return ()
if self.etags is not None:
yield ('IfNoneMatch', self.etags)
if self.modified is not None:
yield ('IfModifiedSince', self.modified)
class Cost(namedtuple('Cost',
('amount',
'currency',
'service',
'type',
'start',
'end'
))
):
__slots__ = ()
def __new__(cls, *args, **kwargs):
argdict = defaultdict(lambda: None)
argdict.update(**kwargs)
parse_date = lambda item: datetime.strptime(item, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=UTCTZ).astimezone(KSTTZ)
args = (
(Decimal(argdict['lineItem/BlendedCost']) if argdict['lineItem/BlendedCost'] else None),
(argdict['lineItem/CurrencyCode'] if argdict['lineItem/CurrencyCode'] else None),
(argdict['product/ProductName'] if argdict['product/ProductName'] else None),
(argdict['lineItem/UsageType'] if argdict['lineItem/UsageType'] else None),
(parse_date(argdict['lineItem/UsageStartDate']) if argdict['lineItem/UsageStartDate'] else None),
(parse_date(argdict['lineItem/UsageEndDate']) if argdict['lineItem/UsageEndDate'] else None)
)
item = super(Cost, cls).__new__(cls, *args)
return item
@property
def key(self):
return CostSummery(self.currency, self.service, self.type)
class CostSubItem(namedtuple('CostSubItem',
('currency',
'service',
))
):
__slots__ = ()
def __new__(cls, *args):
item = super(CostSubItem, cls).__new__(cls, *args)
return item
class CostSummery(namedtuple('CostSummery',
('currency',
'service',
'type'
))
):
__slots__ = ()
def __new__(cls, *args):
item = super(CostSummery, cls).__new__(cls, *args)
return item
@property
def key(self):
return CostSubItem(self.currency, self.service)
def memoized(func):
cache = None
entry = None
async def callee(*args, **kwargs):
nonlocal entry, cache, func
kwargs['modified_entry'] = entry
returned, entry = await func(*args, **kwargs)
if returned is not None:
cache = returned
return returned
elif cache is None:
raise RuntimeError('no ret value')
else:
return cache
# @functools.wraps 대신
functools.update_wrapper(callee, func)
return callee
class Session:
def __init__(
self,
loop,
aws_access_key_id,
aws_secret_access_key,
aws_region_name,
s3_bucket_name,
s3_bucket_key,
s3_manifest_name,
slack_webhook_url,
):
self.log = logging.getLogger('bill_man.session')
self.loop = loop
self.webhook_url = slack_webhook_url
self.s3 = self.s3_client(aws_access_key_id, aws_secret_access_key, aws_region_name)
self.region_name = aws_region_name
self.bucket_name = s3_bucket_name
self.bucket_path = s3_bucket_key
self.manifest_name = s3_manifest_name
self.handler = self.request_hander()
def request_hander(self):
# 커넥션 관리 max_retries?
adapter = adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100)
# 이것은 알아서 cookie를 관리해준다.
handler = requests.Session()
# handler.stream = True
handler.mount("http://", adapter)
handler.mount("https://", adapter)
handler.headers.update({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3514.2 Safari/537.36'
})
return handler
def close(self):
if self.handler is not None:
self.handler.close()
self.handler = None
def s3_client(self, aws_access_key_id, aws_secret_access_key, aws_region_name):
return get_session().create_client(
's3',
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
region_name=aws_region_name,
)
async def send_msg(self, messages):
self.log.info("sending a message")
req_args = (
self.webhook_url,
{
'headers': {'Content-Type': 'application/json'},
'json': messages
}
)
response = await self.loop.run_in_executor(
None,
lambda url, kwargs: self.handler.post(url, **kwargs),
*req_args
)
if response.status_code != 200:
raise ValueError(
'Request to slack returned an error %s, the response is:\n%s'
% (response.status_code, response.text)
)
self.log.info("message sent")
async def get_resources(self, standard_time):
billing_url = 'https://console.aws.amazon.com/billing/home?region=%s#/' % (self.region_name)
now=datetime.utcnow().replace(tzinfo=UTCTZ).astimezone(KSTTZ)
today = date.fromordinal(now.toordinal())
if datetime.combine(today, standard_time) >= now:
today -= relativedelta(days=1)
report_time = datetime.combine(today, standard_time)
start_month = today.replace(day=1)
report_file_path = await self.get_current_manifest(standard_date=today)
costs = filter(
lambda cost: cost.end < report_time,
await self.get_current_cost_csv(report_file_path)
)
'''
{
"fallback": "Required plain-text summary of the attachment.",
"color": "#36a64f",
"pretext": "Optional text that appears above the attachment block",
"author_name": "Bobby Tables",
"author_link": "http://flickr.com/bobby/",
"author_icon": "http://flickr.com/icons/bobby.jpg",
"title": "Slack API Documentation",
"title_link": "https://api.slack.com/",
"text": "Optional text that appears within the attachment",
"fields": [
{
"title": "Priority",
"value": "High",
"short": False
}
],
"image_url": "http://my-website.com/path/to/image.jpg",
"thumb_url": "http://example.com/path/to/thumb.png",
"footer": "Slack API",
"footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png",
"ts": 123456789
},
'''
def service_formetter(summeries):
total_amount = defaultdict(Decimal)
for service_type, type_costs, service_amount in sorted(summeries, key=lambda service: service[2],
reverse=True):
message = '\n'.join(
'%s: %s' % (type, format_currency(cost, service_type.currency, decimal_quantization=False))
for type, cost in type_costs
)
service_name = '%s(%s) : %s ' % (
service_type.service,
service_type.currency,
format_currency(service_amount, service_type.currency, decimal_quantization=False)
)
yield {
'fallback': '%s\n%s' % (service_name, message),
"color": "#ffffff",
'title': service_name,
'text': message
}
total_amount[service_type.currency] += service_amount
# totals
for currency, total in total_amount.items():
total_amount_text = format_currency(total, currency, decimal_quantization=False)
message = 'TOT(%s): %s' % (currency, total_amount_text)
yield {
'fallback': message,
"color": "#ffffff",
'title': message,
}
slack_data = {
'text': "Daily AWS Billing Report :macos: from %s to %s" % (
start_month.strftime('%y-%m-%d'), today.strftime('%y-%m-%d')),
"attachments": [
*service_formetter(self.summery_costs(costs)),
{
"fallback": "Check out more at %s" % billing_url,
"color": "#eeeeee",
"title": "Check out more detils from AWS billing site",
"title_link": billing_url,
"footer": "Linus Billing Bot :confused_parrot:",
}
]
}
await self.send_msg(slack_data)
@memoized
async def get_current_manifest(
self,
standard_date=date.today(),
modified_entry=None
):
duration = '%s-%s' % (standard_date.replace(day=1).strftime('%Y%m%d'),
(standard_date.replace(day=1) + relativedelta(months=1)).strftime('%Y%m%d'))
request_path = path.join(self.bucket_path, duration, self.manifest_name)
try:
req_args = (
self.bucket_name,
request_path,
dict(modified_entry.params(self.bucket_name, request_path)) if modified_entry is not None else {}
)
response = await self.loop.run_in_executor(
None,
lambda name, key, kwargs: self.s3.get_object(Bucket=name, Key=key, **kwargs),
*req_args
)
obj = ujson.load(response['Body'])
return (
obj['reportKeys'][0],
ObjEntry(self.bucket_name, request_path, response['ETag'], response['LastModified'])
)
except ClientError as e:
if e.response['Error']['Code'] == '304':
return None, modified_entry
raise e
@memoized
async def get_current_cost_csv(self, report_path, modified_entry=None):
try:
req_args = (
self.bucket_name,
report_path,
dict(modified_entry.params(self.bucket_name, report_path)) if modified_entry is not None else {}
)
response = await self.loop.run_in_executor(
None,
lambda name, key, kwargs: self.s3.get_object(Bucket=name, Key=key, **kwargs),
*req_args
)
unzipped = TextIOWrapper(GzipFile(mode='rb', fileobj=response['Body']), encoding='utf-8')
cost_reader = csv.reader(unzipped)
keys = next(cost_reader)
return (
[
item for item in filter(
lambda cost: cost.amount != 0,
map(
lambda usage: Cost(**{k: v for k, v in zip(keys, usage)}),
cost_reader
)
)
],
ObjEntry(self.bucket_name, report_path, response['ETag'], response['LastModified'])
)
except ClientError as e:
if e.response['Error']['Code'] == '304':
return None, modified_entry
raise e
def summery_costs(self, costs):
def cost_summation(amounts, cost):
amounts[cost.key] += cost.amount
return amounts
amount_sumation = reduce(cost_summation, costs, defaultdict(Decimal))
def subcost_exteact(type_cost_item):
for vk, amt in type_cost_item:
yield vk.type, amt
for k, v in groupby(sorted(amount_sumation.items(), key=lambda item: item[0]), key=lambda item: item[0].key):
sv = sorted(subcost_exteact(v), key=lambda item: item[1], reverse=True)
yield (
k,
sv,
sum(map(lambda v: v[1], sv))
)
def handle_daemon(name, action, args):
log = logging.getLogger("main_loop")
if action == 'start':
# load config
with EnteringLoop(
name,
log_dir=args.log_location,
log_level=args.log_level,
is_forground=args.foreground,
pid_path=args.pid_path
) as loop:
jobstores = {
'default': MemoryJobStore()
}
executors = {
'default': AsyncIOExecutor(),
}
job_defaults = {
'coalesce': True,
'max_instances': 2
}
scheduler = AsyncIOScheduler(
jobstores=jobstores,
executors=executors,
job_defaults=job_defaults,
timezone=KSTTZ,
event_loop=loop)
with open(args.config) as cfgFile:
config = ujson.load(cfgFile)
sess = Session(loop=loop, **config)
del config
job = scheduler.add_job(sess.get_resources,
name="charger",
trigger='cron',
day_of_week='mon-fri',
hour=args.standard_time.hour,
minute=args.standard_time.minute,
second=args.standard_time.second,
args=(args.standard_time,),
next_run_time=datetime.utcnow().replace(tzinfo=UTCTZ).astimezone(KSTTZ) if args.immediately else undefined
)
scheduler.start()
if not args.immediately:
log.info("job(%s) may be start in %s", job.name, job.next_run_time)
def stopme(*_):
scheduler.shutdown()
sess.close()
log.info("handler closed")
loop.stop()
log.info("loop closed")
loop.add_signal_handler(SIGQUIT, stopme)
loop.add_signal_handler(SIGTERM, stopme)
loop.add_signal_handler(SIGINT, stopme)
loop.add_signal_handler(SIGABRT, stopme)
loop.run_forever()
log.info("bye!")
elif action == 'stop':
wrap(name, level=args.log_level, stderr=True)
if not path.exists(args.pid_path):
log.warning("cannot find pidfile(%s)", args.pid_path)
return
with open(args.pid_path, 'r') as pidFile:
pid = int(pidFile.readline())
kill(pid, SIGTERM)
log.warning("pid(%d) sigterm!", pid)
else:
raise NotImplementedError()
if __name__ == '__main__':
install_except_hook()
# 실행 플래그 파싱
parser = argparse.ArgumentParser(
prog='bill_man',
epilog='contact @spi-ca.',
description='report aws billing .',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--pid_path',
help='specify pidPath',
default='bill_man.pid')
parser.add_argument('--log_level',
help='set logging level',
type=lambda level: logging._nameToLevel[level.upper()],
choices=logging._nameToLevel.keys(),
default='INFO')
parser.set_defaults(func=lambda *_: parser.print_help())
sp = parser.add_subparsers()
sp_start = sp.add_parser('start', help='Starts %(prog)s daemon',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
sp_start.add_argument('--config',
help='specify config file path',
default='bill_man.json')
sp_start.add_argument('--foreground',
help='Don\'t daemonize!',
default=False,
action='store_true')
sp_start.add_argument('--immediately',
help='run batch now!',
default=False,
action='store_true')
sp_start.add_argument('--log_location',
help='specify location of logs!',
default='log')
sp_start.add_argument('--standard_time',
help='set standard time/HHMMSS',
type=lambda ti: time(
hour=int(ti[0:2]),
minute=int(ti[2:4]),
second=int(ti[4:6]),
tzinfo=KSTTZ
),
default='120000')
sp_start.set_defaults(func=functools.partial(handle_daemon, parser.prog, 'start'))
sp_stop = sp.add_parser('stop', help='Stop %(prog)s daemon',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
sp_stop.set_defaults(func=functools.partial(handle_daemon, parser.prog, 'stop'))
args = parser.parse_args()
args.func(args)