Skip to content

Commit 7aae33b

Browse files
committed
Address PR comment/request
Signed-off-by: Bartlomiej P Kus <bartekus@gmail.com>
1 parent 4fc2cae commit 7aae33b

File tree

3 files changed

+277
-14
lines changed

3 files changed

+277
-14
lines changed

docker/manage

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/bin/bash
22
export MSYS_NO_PATHCONV=1
33
# getDockerHost; for details refer to https://github.com/bcgov/DITP-DevOps/tree/main/code/snippets#getdockerhost
4-
. /dev/stdin <<<"$(cat <(curl -s --raw https://raw.githubusercontent.com/bcgov/DITP-DevOps/main/code/snippets/getDockerHost))"
4+
. /dev/stdin <<<"$(cat <(curl -s --raw https://raw.githubusercontent.com/bcgov/DITP-DevOps/main/code/snippets/getDockerHost))"
55
export DOCKERHOST=$(getDockerHost)
66
set -e
77

@@ -57,7 +57,7 @@ function logs() {
5757
while getopts ":f-:" FLAG; do
5858
case $FLAG in
5959
f ) local _force=1 ;;
60-
- )
60+
- )
6161
case ${OPTARG} in
6262
"no-tail"*) no_tail=1
6363
;;
@@ -88,15 +88,15 @@ build)
8888
;;
8989
start|up)
9090
exportEnvironment "$@"
91-
docker-compose up -d ngrok-tails-server tails-server
91+
docker-compose up --build --force-recreate -d ngrok-tails-server tails-server
9292
logs
93-
echo "Run './manage logs' for logs"
93+
echo "Run './manage logs' for logs"
9494
;;
9595
test)
9696
exportEnvironment "$@"
97-
docker-compose up -d ngrok-tails-server tails-server
97+
docker-compose up --build --force-recreate -d ngrok-tails-server tails-server
9898
docker-compose run tester --genesis-url $GENESIS_URL --tails-server-url $TAILS_SERVER_URL
99-
# docker-compose down
99+
# docker-compose down --volumes --remove-orphans
100100
;;
101101
logs)
102102
docker-compose logs -f
@@ -105,11 +105,11 @@ stop)
105105
docker-compose stop
106106
;;
107107
down|rm)
108-
docker-compose down
108+
docker-compose down --volumes --remove-orphans
109109
;;
110110
*)
111111
usage
112112
;;
113113
esac
114114

115-
popd >/dev/null
115+
popd >/dev/null

tails_server/health.py

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import asyncio
2+
import imp
3+
import json
4+
import os
5+
import logging
6+
import socket
7+
import sys
8+
import time
9+
import traceback
10+
from aiohttp import web
11+
12+
13+
try:
14+
from functools import reduce
15+
except Exception:
16+
pass
17+
18+
19+
def basic_exception_handler(_, e):
20+
return False, str(e)
21+
22+
23+
def json_success_handler(results):
24+
data = {
25+
'hostname': socket.gethostname(),
26+
'status': 'success',
27+
'timestamp': time.time(),
28+
'results': results,
29+
}
30+
31+
return json.dumps(data)
32+
33+
34+
def json_failed_handler(results):
35+
data = {
36+
'hostname': socket.gethostname(),
37+
'status': 'failure',
38+
'timestamp': time.time(),
39+
'results': results,
40+
}
41+
42+
return json.dumps(data)
43+
44+
45+
def check_reduce(passed, result):
46+
return passed and result.get('passed')
47+
48+
49+
class Check(object):
50+
def __init__(self, success_status=200, success_headers=None,
51+
success_handler=json_success_handler, success_ttl=None,
52+
failed_status=500, failed_headers=None,
53+
failed_handler=json_failed_handler, failed_ttl=None,
54+
exception_handler=basic_exception_handler, checkers=None,
55+
logger=None, **options):
56+
self.cache = dict()
57+
58+
self.success_status = success_status
59+
self.success_headers = success_headers or {'Content-Type': 'application/json'}
60+
self.success_handler = success_handler
61+
self.success_ttl = float(success_ttl or 0)
62+
63+
self.failed_status = failed_status
64+
self.failed_headers = failed_headers or {'Content-Type': 'application/json'}
65+
self.failed_handler = failed_handler
66+
self.failed_ttl = float(failed_ttl or 0)
67+
68+
self.exception_handler = exception_handler
69+
70+
self.options = options
71+
self.checkers = checkers or []
72+
73+
self.logger = logger
74+
if not self.logger:
75+
self.logger = logging.getLogger('HealthCheck')
76+
77+
@asyncio.coroutine
78+
def __call__(self, request):
79+
message, status, headers = yield from self.check()
80+
return web.Response(text=message, status=status, headers=headers)
81+
82+
def add_check(self, func):
83+
if not asyncio.iscoroutinefunction(func):
84+
func = asyncio.coroutine(func)
85+
86+
self.checkers.append(func)
87+
88+
@asyncio.coroutine
89+
def run_check(self, checker):
90+
try:
91+
passed, output = yield from checker()
92+
except Exception:
93+
traceback.print_exc()
94+
e = sys.exc_info()[0]
95+
self.logger.exception(e)
96+
passed, output = self.exception_handler(checker, e)
97+
98+
if not passed:
99+
msg = 'Health check "{}" failed with output "{}"'.format(checker.__name__, output)
100+
self.logger.error(msg)
101+
102+
timestamp = time.time()
103+
if passed:
104+
expires = timestamp + self.success_ttl
105+
else:
106+
expires = timestamp + self.failed_ttl
107+
108+
result = {'checker': checker.__name__,
109+
'output': output,
110+
'passed': passed,
111+
'timestamp': timestamp,
112+
'expires': expires}
113+
return result
114+
115+
@asyncio.coroutine
116+
def check(self):
117+
results = []
118+
for checker in self.checkers:
119+
if checker in self.cache and self.cache[checker].get('expires') >= time.time():
120+
result = self.cache[checker]
121+
else:
122+
result = yield from self.run_check(checker)
123+
self.cache[checker] = result
124+
results.append(result)
125+
126+
passed = reduce(check_reduce, results, True)
127+
128+
if passed:
129+
message = "OK"
130+
if self.success_handler:
131+
message = self.success_handler(results)
132+
133+
return message, self.success_status, self.success_headers
134+
else:
135+
message = "NOT OK"
136+
if self.failed_handler:
137+
message = self.failed_handler(results)
138+
139+
return message, self.failed_status, self.failed_headers
140+
141+
142+
class EnvDump(object):
143+
def __init__(self,
144+
include_os=False,
145+
include_python=False,
146+
include_process=False):
147+
148+
self.functions = {}
149+
150+
if include_os:
151+
self.functions['os'] = self.get_os
152+
if include_python:
153+
self.functions['python'] = self.get_python
154+
if include_process:
155+
self.functions['process'] = self.get_process
156+
157+
@asyncio.coroutine
158+
def __call__(self, request):
159+
data = yield from self.dump_environment(request)
160+
return web.json_response(data)
161+
162+
@asyncio.coroutine
163+
def dump_environment(self, request):
164+
data = {}
165+
data['storage'] = yield from self.get_storage_info(request)
166+
167+
for name, func in self.functions.items():
168+
data[name] = yield from func()
169+
170+
return data
171+
172+
@asyncio.coroutine
173+
def get_os(self):
174+
return {'platform': sys.platform,
175+
'name': os.name,
176+
'uname': os.uname()}
177+
178+
@asyncio.coroutine
179+
def get_python(self):
180+
result = {'version': sys.version,
181+
'executable': sys.executable,
182+
'pythonpath': sys.path,
183+
'version_info': {'major': sys.version_info.major,
184+
'minor': sys.version_info.minor,
185+
'micro': sys.version_info.micro,
186+
'releaselevel': sys.version_info.releaselevel,
187+
'serial': sys.version_info.serial}}
188+
if imp.find_module('pkg_resources'):
189+
import pkg_resources
190+
packages = dict([(p.project_name, p.version) for p in pkg_resources.working_set])
191+
result['packages'] = packages
192+
193+
return result
194+
195+
@asyncio.coroutine
196+
def get_login(self):
197+
# Based on https://github.com/gitpython-developers/GitPython/pull/43/
198+
# Fix for 'Inappropriate ioctl for device' on posix systems.
199+
if os.name == "posix":
200+
import pwd
201+
username = pwd.getpwuid(os.geteuid()).pw_name
202+
else:
203+
username = os.environ.get('USER', os.environ.get('USERNAME', 'UNKNOWN'))
204+
if username == 'UNKNOWN' and hasattr(os, 'getlogin'):
205+
username = os.getlogin()
206+
return username
207+
208+
@asyncio.coroutine
209+
def get_process(self):
210+
return {'argv': sys.argv,
211+
'cwd': os.getcwd(),
212+
'user': (yield from self.get_login()),
213+
'pid': os.getpid(),
214+
'environ': self.safe_dump(os.environ)}
215+
216+
@asyncio.coroutine
217+
def get_storage_info(self, request):
218+
storage_path = request.app["settings"]["storage_path"]
219+
dir_count = 0
220+
file_count = 0
221+
total = 0
222+
with os.scandir(storage_path) as it:
223+
for entry in it:
224+
if entry.is_file():
225+
file_count += 1
226+
total += entry.stat().st_size
227+
elif entry.is_dir():
228+
dir_count += 1
229+
total += get_dir_size(entry.path)
230+
231+
return {'number_of_files': file_count,
232+
'number_of_directories': dir_count,
233+
'used_space': total}
234+
235+
@staticmethod
236+
def safe_dump(dictionary):
237+
result = {}
238+
for key in dictionary.keys():
239+
if 'key' in key.lower() or 'token' in key.lower() or 'pass' in key.lower():
240+
# Try to avoid listing passwords and access tokens or keys in the output
241+
result[key] = "********"
242+
else:
243+
try:
244+
json.dumps(dictionary[key])
245+
result[key] = dictionary[key]
246+
except TypeError:
247+
pass
248+
return result

tails_server/web.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,13 @@
1414
BadGenesisError,
1515
BadRevocationRegistryIdError,
1616
)
17+
from .health import Check, EnvDump
1718

1819
LOGGER = logging.getLogger(__name__)
1920

2021
routes = web.RouteTableDef()
2122

2223

23-
@routes.get("/health/check")
24-
async def health(request):
25-
return web.json_response({"Status": "OK"})
26-
27-
2824
@routes.get("/match/{substring}")
2925
async def match_files(request):
3026
substring = request.match_info["substring"] # e.g., cred def id, issuer DID, tag
@@ -112,7 +108,7 @@ async def put_file(request):
112108
text="Second field in multipart request must have name 'tails'"
113109
)
114110

115-
# Process the file in chunks so we don't explode on large files.
111+
# Process the file in chunks, so we don't explode on large files.
116112
# Construct hash and write file in chunks.
117113
sha256 = hashlib.sha256()
118114
try:
@@ -151,13 +147,32 @@ async def put_file(request):
151147
return web.Response(text=tails_hash)
152148

153149

150+
def custom_check(): # An example of custom check to be enacted as part of the "/health/check"
151+
if 1 + 1 == 2:
152+
return True, "It works!"
153+
else:
154+
return False, "It doesn't work!!! :("
155+
156+
154157
def start(settings):
155158
app = web.Application()
156159
app["settings"] = settings
157160

158161
# Add routes
159162
app.add_routes(routes)
160163

164+
# To avoid putting too much strain on backend services, health check results can be cached in process memory.
165+
# By default, they are set to None, so we need to set them to a specific time intervals for the cache to function
166+
check = Check(success_ttl=30, failed_ttl=10)
167+
# EnvDump at minimum will return storage statistics, while OS/Python/process can be toggled on/off
168+
env_dump = EnvDump(include_os=True, include_python=True, include_process=True)
169+
170+
app.router.add_get("/health/check", check)
171+
app.router.add_get("/health/env", env_dump)
172+
173+
# To enable extensibility, we can use `add_check` to inject our own custom check method (example above)
174+
check.add_check(custom_check)
175+
161176
web.run_app(
162177
app,
163178
host=settings.get("host") or DEFAULT_WEB_HOST,

0 commit comments

Comments
 (0)