diff --git a/CHANGELOG.md b/CHANGELOG.md index 2608b7e5..ed588975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,9 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. -## [0.6.0](https://github.com/Seven-of-Di/ben/compare/v0.5.0...v0.6.0) (2024-01-23) +### [0.6.2-stage.0](https://github.com/Seven-of-Di/ben/compare/v0.6.1-stage.0...v0.6.2-stage.0) (2024-02-05) ### Features -* Introduce better workflow for releasing ([9107542](https://github.com/Seven-of-Di/ben/commit/91075420cdde9aaaddb1c57c50dda86df5b74686)) -* Move the robot fullboard into Ben's chart ([95eb4e7](https://github.com/Seven-of-Di/ben/commit/95eb4e7313d8b1aef09acdb367d2df0484c09ec9)) -* Separate deployment ([#20](https://github.com/Seven-of-Di/ben/issues/20)) ([123dcdf](https://github.com/Seven-of-Di/ben/commit/123dcdfe127b2bb3e9fdef3f813b8882a7ca87e1)) -* Use secret as parameter from Values ([667cdbf](https://github.com/Seven-of-Di/ben/commit/667cdbf8dfaff5d85cc689d0abf9c811cfee9b1a)) - - -### Bug Fixes - -* Include sentryDSN in ben ([e465736](https://github.com/Seven-of-Di/ben/commit/e465736a4c717fea08750b0e7b43695f3f8d0c2b)) -* Use the right naming for the environment variables ([7590932](https://github.com/Seven-of-Di/ben/commit/75909323d3368a0aff7df031f7aef219e20d298e)) -* Versioning of Ben should go via version files ([231e777](https://github.com/Seven-of-Di/ben/commit/231e7773ed8ff52dec0923735942faedacbfe337)) +* Version bump ([52e8fa9](https://github.com/Seven-of-Di/ben/commit/52e8fa9bdf1adccd4c53da1671f2ebf438ea9271)) diff --git a/Dockerfile b/Dockerfile index cc24fbe1..2f25346b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,12 +31,13 @@ RUN cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo \ # (Required step because it can modify RUNPATH) env DESTDIR=install cmake -DCMAKE_INSTALL_COMPONENT=Runtime -P cmake_install.cmake -FROM python:3.7.16-slim +FROM python:3.11-slim-buster # Copy installed runtime files to real image COPY --from=dds-builder /app/.build/install/usr/lib /usr/lib RUN mkdir -p /app +RUN mkdir -p /tmp/metrics WORKDIR /app @@ -45,7 +46,7 @@ COPY requirements.txt . RUN apt-get update \ && apt-get install -y build-essential -RUN pip install -r requirements.txt +RUN pip3 install -r requirements.txt COPY . . diff --git a/VERSION b/VERSION index 09a3acfa..afb4b2c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 \ No newline at end of file +0.6.2-stage.0 diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index caa3b503..1e1a761f 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -17,6 +17,9 @@ spec: app: intobridge workload: robot component: ben + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "5001" spec: {{ if .Values.nodeSelector }} nodeSelector: @@ -90,6 +93,10 @@ spec: value: "3" - name: PORT value: "5001" + - name: ENVIRONMENT + value: {{ required "Environment is required!" .Values.environment }} + - name: PROMETHEUS_MULTIPROC_DIR + value: /tmp/metrics - name: SENTRY_DSN value: {{ required "Sentry DSN is required!" .Values.sentryDSN }} - name: SENTRY_ENVIRONMENT @@ -105,6 +112,6 @@ spec: value: "True" {{ end }} command: ["python"] - args: ["-m", "hypercorn", "--bind", "0.0.0.0:5001", "--workers", "4", "api:app"] + args: ["-m", "hypercorn", "--bind", "0.0.0.0:5001", "--workers", "4", "starlette_api:app"] ports: - containerPort: 5001 diff --git a/docker-compose.yml b/docker-compose.yml index a532426f..35aba9df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: user: root # command: python api.py # command: python create_alerts_database.py - command: python -m hypercorn --bind 0.0.0.0:5001 --workers 4 api:app + command: python -m hypercorn --bind 0.0.0.0:5001 --workers 4 starlette_api:app ports: - 5001:5001 volumes: @@ -24,9 +24,12 @@ services: - PORT=5001 - DEBUG=true - USE_RELOADER=true + - SENTRY_ENVIRONMENT=local + - ENVIRONMENT=local - OTEL_EXPORTER_OTLP_TRACES_INSECURE=True - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=otel:4317 - OTEL_SERVICE_NAME=robot + - PROMETHEUS_MULTIPROC_DIR=/tmp/metrics networks: - ben diff --git a/requirements.txt b/requirements.txt index 7ee278b1..e98d5cda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiofiles==23.1.0 +aioprometheus==23.12.0 asgiref==3.6.0 blinker==1.6.2 boto3==1.25.5 @@ -14,7 +15,7 @@ importlib-metadata==6.0.1 itsdangerous==2.1.2 Jinja2==3.1.2 MarkupSafe==2.1.2 -numpy==1.21.6 +numpy==1.24.3 opentelemetry-api==1.17.0 opentelemetry-sdk==1.17.0 opentelemetry-exporter-otlp-proto-grpc==1.17.0 @@ -22,20 +23,21 @@ opentelemetry-instrumentation==0.38b0 opentelemetry-instrumentation-asgi==0.38b0 opentelemetry-semantic-conventions==0.38b0 opentelemetry-util-http==0.38b0 -pickle5==0.0.11 priority==2.0.0 -protobuf==3.19.0 +protobuf==3.20.3 psutil==5.9.4 quart==0.18.3 -scipy==1.7.3 -sentry-sdk==1.16.0 +scipy==1.12.0 +sentry-sdk==1.40.0 +starlette==0.36.1 +starlette_exporter==0.19.0 sqlitedict==2.1.0 -tensorflow==2.11.0 +tensorflow==2.14.0 toml==0.10.2 typing_extensions==4.5.0 urllib3==1.26.15 Werkzeug==2.2.3 -wrapt==1.15.0 +wrapt==1.14.0 wsproto==1.2.0 zipp==3.15.0 diff --git a/src/api.py b/src/api.py index d940a409..0f87cea2 100644 --- a/src/api.py +++ b/src/api.py @@ -39,7 +39,7 @@ app.asgi_app = SentryAsgiMiddleware(app.asgi_app)._run_asgi3 app.asgi_app = OpenTelemetryMiddleware(app.asgi_app) -health_checker = HealthChecker(app.logger) +health_checker = HealthChecker() health_checker.start() @app.before_request @@ -340,6 +340,7 @@ async def play_full_board() -> Dict: req.hands, req.vuln, req.dealer, + PlayingMode.MATCHPOINTS, MODELS ) board_data = await bot.async_full_board() diff --git a/src/health_checker.py b/src/health_checker.py index 4aee8577..86f448bc 100644 --- a/src/health_checker.py +++ b/src/health_checker.py @@ -1,19 +1,18 @@ import psutil import threading from typing import List - +import logging class HealthChecker: cpu_usage: List[float] - def __init__(self, logger) -> None: + def __init__(self) -> None: self.cpu_usage = [] - self.logger = logger def healthy(self) -> bool: for cpu_per_core in self.cpu_usage: if cpu_per_core > 95: - self.logger.warning(self.cpu_usage) + logging.warning(self.cpu_usage) return False return True diff --git a/src/starlette_api.py b/src/starlette_api.py new file mode 100644 index 00000000..4e586c5f --- /dev/null +++ b/src/starlette_api.py @@ -0,0 +1,335 @@ +from copy import deepcopy +import logging +import time +from typing import Dict, List +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.responses import JSONResponse +from starlette.routing import Route +from starlette.requests import Request +from starlette.middleware.base import BaseHTTPMiddleware +from opentelemetry import trace +from starlette_exporter import PrometheusMiddleware, handle_metrics, CollectorRegistry, multiprocess # type: ignore +from prometheus_client import make_asgi_app +import os +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +import sentry_sdk +from health_checker import HealthChecker + +from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware +from tracing import tracing_enabled +from opentelemetry.propagate import extract +from opentelemetry.context import attach, detach +from sentry_sdk.integrations.logging import LoggingIntegration + +from utils import DIRECTIONS, VULNERABILITIES, PlayerHand, BiddingSuit,PlayingMode +from nn.models import MODELS +from play_card_pre_process import play_a_card +from game import AsyncBotBid, AsyncBotLead +from alerting import find_alert +from human_carding import lead_real_card +from claim_dds import check_claim_from_api +from FullBoardPlayer import AsyncFullBoardPlayer,PlayFullBoard + +logging.basicConfig(level=logging.WARNING) + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN", ""), + environment=os.environ.get("SENTRY_ENVIRONMENT", ""), + release=os.environ.get("SENTRY_RELEASE", ""), + integrations= [LoggingIntegration(level=logging.WARNING, event_level=logging.WARNING)], + max_request_body_size="always" +) + + +class PlaceBid: + def __init__(self, place_bid_request): + place_bid_request = dict(place_bid_request) + self.vuln = VULNERABILITIES[place_bid_request['vuln']] + self.hand = place_bid_request['hand'] + self.dealer = place_bid_request['dealer'] + self.auction = ['PAD_START'] * \ + DIRECTIONS.index(self.dealer) + place_bid_request['auction'] + + +class AlertBid: + def __init__(self, alert_bid_request) -> None: + self.vuln = VULNERABILITIES[alert_bid_request['vuln']] + self.dealer = alert_bid_request["dealer"] + self.auction = alert_bid_request['auction'] + + +class PlayCard: + def __init__(self, play_card_request): + self.hand = play_card_request['hand'] + self.dummy_hand = play_card_request['dummy_hand'] + self.dealer = play_card_request['dealer'] + self.vuln = VULNERABILITIES[play_card_request['vuln']] + self.auction = play_card_request['auction'] + self.contract = play_card_request['contract'] + self.contract_direction = play_card_request['contract_direction'] + self.next_player = play_card_request['next_player'] + self.tricks = play_card_request['tricks'] + self.cheating_diag_pbn = play_card_request[ + "cheating_diag_pbn"] if "cheating_diag_pbn" in play_card_request else None + self.playing_mode = PlayingMode.from_str(play_card_request[ + "playing_mode"]) if "playing_mode" in play_card_request else PlayingMode.MATCHPOINTS + + +class MakeLead: + def __init__(self, make_lead_request): + self.hand = make_lead_request['hand'] + self.dealer = make_lead_request['dealer'] + + self.vuln = VULNERABILITIES[make_lead_request['vuln']] + self.auction = ['PAD_START'] * \ + DIRECTIONS.index(self.dealer) + make_lead_request['auction'] + + +class CheckClaim: + def __init__(self, check_claim_request) -> None: + self.claiming_hand = check_claim_request["claiming_hand"] + self.dummy_hand = check_claim_request["dummy_hand"] + self.claiming_direction = check_claim_request["claiming_direction"] + self.contract_direction = check_claim_request["contract_direction"] + self.contract = check_claim_request["contract"] + self.tricks = check_claim_request['tricks'] + self.claim = check_claim_request['claim'] + +async def play_card(request: Request): + try: + data = await request.json() + req = PlayCard(data) + dict_result = await play_a_card( + req.hand, + req.dummy_hand, + req.dealer, + req.vuln, + req.auction, + req.contract, + req.contract_direction, + req.next_player, + req.tricks, + MODELS, + req.cheating_diag_pbn, + req.playing_mode + ) + return JSONResponse(dict_result) + except Exception as e: + logging.exception(e) + return JSONResponse({'error': str(e)}, 500) + +async def place_bid(request : Request): + try: + data = await request.json() + req = PlaceBid(data) + + if tracing_enabled: + current_span = trace.get_current_span() + current_span.set_attributes({ + "game.hand": req.hand, + "game.dealer": req.dealer, + "game.auction": ",".join(req.auction), + }) + + # 1NT - (P) + bot = AsyncBotBid( + req.vuln, + req.hand, + MODELS + ) + + bid_resp = await bot.async_bid(req.auction) + + new_auction: List[str] = deepcopy(req.auction) + new_auction.append(bid_resp.bid) + + alert = await find_alert(new_auction, req.vuln) + + if alert == None: + bot = AsyncBotBid( + req.vuln, + req.hand, + MODELS, + human_model=True + ) + bid_resp = await bot.async_bid(req.auction) + + resp = {'bid': bid_resp.bid} + if alert != None: + resp['alert'] = { 'text': alert, 'artificial': False } + + return JSONResponse(resp) + except Exception as e: + logging.exception(e) + return JSONResponse({'error': str(e)}, 500) + +async def make_lead(request : Request): + try: + data = await request.json() + req = MakeLead(data) + + if tracing_enabled: + current_span = trace.get_current_span() + current_span.set_attributes({ + "game.hand": req.hand, + "game.dealer": req.dealer, + "game.auction": ",".join(req.auction), + }) + + bot = AsyncBotLead(req.vuln, req.hand, MODELS) + + lead = bot.lead(req.auction) + card_str = lead.to_dict()['candidates'][0]['card'] + contract = next((bid for bid in reversed(req.auction) + if len(bid) == 2 and bid != "XX"), None) + if contract is None: + raise Exception("contract is None") + + return JSONResponse({ + 'card': lead_real_card(PlayerHand.from_pbn(req.hand), card_str, BiddingSuit.from_str(contract[1])).__str__() + }, 200) + except Exception as e: + logging.exception(e) + return JSONResponse({'error': str(e)}, 500) + +async def check_claim(request : Request): + try: + data = await request.json() + req = CheckClaim(data) + res = await check_claim_from_api( + req.claiming_hand, + req.dummy_hand, + req.claiming_direction, + req.contract_direction, + req.contract, + req.tricks, + req.claim) + + return JSONResponse({'claim_accepted': res}, 200) + except Exception as e: + logging.exception(e) + return JSONResponse({'error': str(e)}, 500) + +''' +{ + "hand": "N:J962.KA3.87.T983 Q7.QJ965.6.KJA54 KA84.872.TQJA2.6 T53.T4.K9543.Q72", + "dealer": "E", + "vuln": "None" +} +''' + + +async def play_full_board(request:Request) -> JSONResponse: + try : + data = await request.json() + req = PlayFullBoard(data) + bot = AsyncFullBoardPlayer( + req.hands, + req.vuln, + req.dealer, + PlayingMode.MATCHPOINTS, + MODELS + ) + board_data = await bot.async_full_board() + + return JSONResponse(board_data) + except Exception as e: + logging.exception(e) + return JSONResponse({'error': str(e)}, 500) + + +''' +{ + "dealer": "N", + "vuln": "None", + "auction": ["1C", "PASS", "PASS"] +} +''' + + +async def alert_bid(request: Request): + try: + data = await request.json() + req = AlertBid(data) + alert = await find_alert(req.auction, req.vuln) + + return {"alert": alert, "artificial" : False}, 200 + except Exception as e: + logging.exception(e) + return {'error': str(e)},500 + +health_checker = HealthChecker() + +async def healthz(): + healthy = health_checker.healthy() + if healthy: + return 'ok', 200 + + return 'unhealthy', 500 + +class TracingHeaderMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + otel_token = None + if tracing_enabled: + extracted_context = extract(request.headers) + span = trace.get_current_span(extracted_context) + if span is not None: + trace.use_span(span, end_on_exit=True) + otel_token = attach(trace.set_span_in_context(span)) + + response = await call_next(request) + + if not tracing_enabled or not otel_token: + return response + + current_span = trace.get_current_span() + if current_span: + current_span.end() + + detach(otel_token) + + return response + +class SlowAnswerMiddleWare(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + start = time.time() + response = await call_next(request) + process_time = time.time() - start + if process_time > 3 and call_next.__name__ != "play_full_board": + # , request: {await request.json()} + logging.warning("Slow answer",extra= {"time":round(process_time,2)}) + + return response + + + +app = Starlette( + routes=[ + Route('/play_card', play_card, methods=['POST']), + Route('/place_bid', place_bid, methods=['POST']), + Route('/make_lead', make_lead, methods=['POST']), + Route('/check_claim', check_claim, methods=['POST']), + Route('/play_full_board', play_full_board, methods=['POST']), + Route('/alert_bid', alert_bid, methods=['POST']), + # Route('/healthz', healthz, methods=['GET']) + ], + middleware=[ + Middleware(OpenTelemetryMiddleware), + Middleware(PrometheusMiddleware, app_name="ben"), + Middleware(TracingHeaderMiddleware), + Middleware(SentryAsgiMiddleware), + ] +) + +app.add_route("/metrics", handle_metrics) + +# Using multiprocess collector for registry +def make_metrics_app(): + registry = CollectorRegistry() + multiprocess.MultiProcessCollector(registry) + return make_asgi_app(registry=registry) + +metrics_app = make_metrics_app() +app.mount('/', metrics_app) + diff --git a/src/tests.py b/src/tests.py index 1ebff347..58d58c39 100644 --- a/src/tests.py +++ b/src/tests.py @@ -580,7 +580,7 @@ def compare_two_tests(set_of_boards_1: List[Board], set_of_boards_2: List[Board] if __name__ == "__main__": - # run_tm_btwn_ben_versions(force_same_lead=True,force_same_card_play=True,deal_random=True) + run_tm_btwn_ben_versions(force_same_lead=True,force_same_card_play=True,deal_random=True) # tests = run_tests() # compare_two_tests(load_test_pbn("avant.pbn"), # load_test_pbn("après.pbn")) diff --git a/version.json b/version.json index 424d6096..9066d324 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.6.0" + "version": "0.6.2-stage.0" }