diff --git a/.github/workflows/deploy.py b/.github/workflows/deploy.py index bcdb2ce8..70fbdb01 100644 --- a/.github/workflows/deploy.py +++ b/.github/workflows/deploy.py @@ -1,8 +1,9 @@ import multiprocessing import os import re -import time +import statistics import sys +from collections import defaultdict import docker import subprocess @@ -15,6 +16,10 @@ from python_terraform import Terraform from paramiko import SSHClient from scp import SCPClient +try: + import pandas as pd +except ImportError: + print("Please install pandas library: 'pip install pandas' and 'pip install tabulate' for requests statistics") try: import click @@ -62,6 +67,8 @@ def cli(): __file__).parent / "full_test_suite") VERSION_BRANCH_TEMPLATE = r"[vt]{1}\d{1,2}\.\d{1,2}\.x.*" +SOLANA_REQUESTS_TITLE = "Solana Requests Statistics" + def docker_compose(args: str): command = f'docker login -u {DOCKER_USERNAME} -p {DOCKER_PASSWORD}' @@ -467,5 +474,103 @@ def process_output(output): raise SystemError(message) +# Regular expression to match the log format +log_pattern = re.compile(r"{.*}") + + +def extract_method(request_body): + try: + request_json = json.loads(request_body) + return request_json.get("method", "unknown") + except json.JSONDecodeError: + return "unknown" + + +def parse_log_file(log_file_path) -> dict: + # Read and parse the log file + stats = defaultdict(lambda: {"times": list()}) + + lines = (line for line in log_file_path.split("\n") if log_pattern.match(line)) + log_entries = (json.loads(line) for line in lines) + formated_requests = ( + { + "request_time": float(log_entry.get("request_time", 0)), + "method": extract_method(log_entry.get("jsonrpc_method", "")), + } + for log_entry in log_entries if extract_method(log_entry.get("jsonrpc_method", "")) != "unknown" + ) + for formated_request in formated_requests: + method = formated_request["method"] + stats[method]["times"].append(formated_request["request_time"]) + return stats + + +def calculate_stats(stats): + formated_stats = {key: {} for key in stats.keys()} + for method, data in stats.items(): + formated_stats[method]["count"] = len(data["times"]) + formated_stats[method]["average_time"] = statistics.mean(data["times"]) + formated_stats[method]["max_time"] = max(data["times"]) + formated_stats[method]["min_time"] = min(data["times"]) + formated_stats[method]["median_time"] = statistics.median(data["times"]) + return {k: v for k, v in sorted(formated_stats.items(), key=lambda item: item[1]["count"], reverse=True)} + + +@cli.command("parse_logs", help="Get logs from nginx") +@click.option("--solana_ip", default="localhost", help="Solana IP") +def parse_logs(solana_ip): + try: + content = requests.get(f"http://{solana_ip}:8080/logs/access.log").text + except requests.exceptions.InvalidURL as e: + print(f"Error: {e}") + sys.exit(1) + stats = parse_log_file(content) + calculated_stats = calculate_stats(stats) + df = pd.DataFrame.from_dict(calculated_stats, orient="index", columns=["count", "min_time", "max_time", "average_time", "median_time"]) + print(df.to_markdown()) + + +class GithubClient: + + def __init__(self, token): + self.headers = {"Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28"} + + def remove_comment_with_title(self, pull_request, title): + response = requests.get(pull_request, headers=self.headers) + if response.status_code != 200: + raise RuntimeError(f"Attempt to get comments on a PR failed: {response.text}") + comments = response.json() + for comment in comments: + if f"
{title}" in comment["body"]: + response = requests.delete(comment["url"], headers=self.headers) + if response.status_code != 204: + raise RuntimeError(f"Attempt to delete a comment on a PR failed: {response.text} {response.request.url}") + + def add_comment_to_pr(self, msg, pull_request, title = SOLANA_REQUESTS_TITLE, remove_previous_comments=True): + if remove_previous_comments: + self.remove_comment_with_title(pull_request, title) + message = f"\n\n{msg}\n\n" + if title: + message = f"
{title}\n\n{message}
" + data = {"body": message} + click.echo(f"Sent data: {data}") + click.echo(f"Headers: {self.headers}") + response = requests.post(pull_request, json=data, headers=self.headers) + click.echo(f"Status code: {response.status_code}") + if response.status_code != 201: + raise RuntimeError(f"Attempt to leave a comment on a PR failed: {response.text}") + + +@cli.command("post_comment", help="Post comment to the PR") +@click.option("--message", help="Message to post") +@click.option("--pull_request", help="Pull Request URL") +@click.option("--token", help="Github token") +def post_comment(message, pull_request, token): + gh_client = GithubClient(token) + gh_client.add_comment_to_pr(message, pull_request) + + if __name__ == "__main__": cli() diff --git a/.github/workflows/full_test_suite/main.tf b/.github/workflows/full_test_suite/main.tf index 8fec4815..b7528651 100644 --- a/.github/workflows/full_test_suite/main.tf +++ b/.github/workflows/full_test_suite/main.tf @@ -77,6 +77,18 @@ resource "hcloud_server" "solana" { data.hcloud_ssh_key.ci-ssh-key.id ] + provisioner "file" { + source = "../../../docker-compose/nginx.conf" + destination = "/tmp/nginx.conf" + + connection { + type = "ssh" + user = "root" + host = hcloud_server.solana.ipv4_address + private_key = file("~/.ssh/ci-stands") + } + } + provisioner "file" { source = "../../../docker-compose/docker-compose-ci.yml" destination = "/tmp/docker-compose-ci.yml" diff --git a/.github/workflows/full_test_suite/proxy_init.sh b/.github/workflows/full_test_suite/proxy_init.sh index f4c14c3c..ac526e34 100644 --- a/.github/workflows/full_test_suite/proxy_init.sh +++ b/.github/workflows/full_test_suite/proxy_init.sh @@ -21,7 +21,7 @@ cd /tmp # Set required environment variables export REVISION=${proxy_image_tag} -export SOLANA_URL=http:\/\/${solana_ip}:8899 +export SOLANA_URL=http:\/\/${solana_ip}:8080 export NEON_EVM_COMMIT=${neon_evm_commit} export FAUCET_COMMIT=${faucet_model_commit} export CI_PP_SOLANA_URL=${ci_pp_solana_url} @@ -29,7 +29,6 @@ export DOCKERHUB_ORG_NAME=${dockerhub_org_name} export PROXY_IMAGE_NAME=${proxy_image_name} - # Generate docker-compose override file cat > docker-compose-ci.override.yml < + /bin/sh -c "echo 'Nginx Configuration:' && cat /etc/nginx/nginx.conf && nginx -g 'daemon off;'" EOF - # wake up Solana -docker-compose -f docker-compose-ci.yml -f docker-compose-ci.override.yml pull solana -docker-compose -f docker-compose-ci.yml -f docker-compose-ci.override.yml up -d solana +docker-compose -f docker-compose-ci.yml -f docker-compose-ci.override.yml pull nginx solana +docker-compose -f docker-compose-ci.yml -f docker-compose-ci.override.yml up -d nginx solana diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index c1104eb4..55450696 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -355,6 +355,45 @@ jobs: solana_ip: ${{ needs.prepare-infrastructure.outputs.solana_ip }} external_call: true + requests-report: + needs: + - prepare-infrastructure + - basic-tests + - build-image + - dapps-tests + - openzeppelin-tests + if: | + always() && + contains(fromJSON('["success", "skipped"]'), needs.openzeppelin-tests.result) && + contains(fromJSON('["success", "skipped"]'), needs.dapps-tests.result) + runs-on: test-runner + env: + SOLANA_IP: ${{ needs.prepare-infrastructure.outputs.solana_ip }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: "Request report" + id: request_report + run: | + stats=$(python3 ./.github/workflows/deploy.py parse_logs --solana_ip=${{ env.SOLANA_IP }}) + echo "stats<> $GITHUB_OUTPUT + echo "$stats" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "stats=$stats" + + if [[ "${{ github.event.inputs.initial_pr }}" != "" ]]; then + echo "pull_request=${{ github.event.inputs.initial_pr }}" >> $GITHUB_OUTPUT + else + echo "pull_request=${{ github.event.pull_request.issue_url }}/comments" >> $GITHUB_OUTPUT + fi; + + - name: "Post a comment with the report" + run: | + python3 ./.github/workflows/deploy.py post_comment --message="${{ steps.request_report.outputs.stats }}" \ + --pull_request="${{ steps.request_report.outputs.pull_request }}" \ + --token=${{ secrets.GHTOKEN }} + # will be fixed by NDEV-2766 # economy-tests: # if: needs.build-image.outputs.full_test_suite=='true' @@ -391,6 +430,7 @@ jobs: - prepare-infrastructure - openzeppelin-tests - basic-tests + - requests-report - dapps-tests - build-image runs-on: test-runner diff --git a/docker-compose/nginx.conf b/docker-compose/nginx.conf new file mode 100644 index 00000000..e07109c6 --- /dev/null +++ b/docker-compose/nginx.conf @@ -0,0 +1,34 @@ +events { + worker_connections 1024; +} + +http { + log_format json_combined escape=json + '{ "@timestamp": "$time_iso8601", ' + '"remote_addr": "$remote_addr", ' + '"request": "$request", ' + '"status": "$status", ' + '"body_bytes_sent": "$body_bytes_sent", ' + '"http_referer": "$http_referer", ' + '"http_user_agent": "$http_user_agent", ' + '"request_time": "$request_time", ' + '"jsonrpc_method": "$request_body" }'; + + access_log /var/log/nginx/access.log json_combined buffer=8k flush=20s; + + server { + listen 8080; + + location / { + proxy_pass http://solana:8899; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /logs { + alias /var/log/nginx/; + } + } +} \ No newline at end of file