diff --git a/.github/workflows/docker-publish.yaml b/.github/workflows/docker-publish.yaml new file mode 100644 index 0000000..3c8e0fc --- /dev/null +++ b/.github/workflows/docker-publish.yaml @@ -0,0 +1,83 @@ +name: Docker + +on: + push: + branches: [ main, github-actions ] + tags: [ 'v*.*.*' ] + pull_request: + branches: [ main ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad #v4.0.0 + + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} + + - name: Checkov Dockerfile + uses: bridgecrewio/checkov-action@99bb2caf247dfd9f03cf984373bc6043d4e32ebf # v12.1347.0 + with: + quiet: true + docker_image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + dockerfile_path: ./Dockerfile + container_user: 10001 diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml new file mode 100644 index 0000000..088da8e --- /dev/null +++ b/.github/workflows/python-app.yaml @@ -0,0 +1,31 @@ +name: Python application + +on: + push: + branches: [ main, github-actions ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + lint-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python 3.12.12 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: "3.12.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + - name: Lint with pylint + run: | + pylint src/ tests/ --fail-under=9.0 + - name: Test with pytest + run: | + pytest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 87a0c88..9ffe961 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM python:3.9.25-alpine3.22 +FROM python:3.12.12-alpine3.23@sha256:f47255bb0de452ac59afc49eaabe992720fe282126bb6a4f62de9dd3db1742dc ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 WORKDIR /app -RUN apk add --no-cache curl=8.14.1-r2 jq=1.8.1-r0 +RUN apk add --no-cache jq=1.8.1-r0 COPY requirements.txt requirements.txt @@ -16,4 +16,9 @@ USER tokentx COPY src/ /app/src/ +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health/ready || exit 1 + ENTRYPOINT ["python", "src/main.py"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..5457703 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==9.0.1 +pytest-asyncio==1.3.0 +pylint==4.0.4 diff --git a/src/main.py b/src/main.py index cf71199..c7887b7 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,18 @@ -import argparse, os, asyncio, signal, json, logging, threading +"""Token Transaction Service - Sends EOA transactions at regular intervals.""" +import argparse +import asyncio +import json +import logging +import os +import signal +import threading from datetime import datetime, timezone -from web3.providers.rpc.async_rpc import AsyncHTTPProvider -from web3 import AsyncWeb3, Web3, AsyncHTTPProvider -from web3.middleware import SignAndSendRawMiddlewareBuilder, ExtraDataToPOAMiddleware from typing import Optional + from aiohttp import web +from web3 import AsyncWeb3, Web3 +from web3.middleware import ExtraDataToPOAMiddleware, SignAndSendRawMiddlewareBuilder +from web3.providers.rpc.async_rpc import AsyncHTTPProvider # Shutdown flag for graceful termination (thread-safe for signal handlers) shutdown_event = threading.Event() @@ -45,36 +53,41 @@ def format(self, record): def init_logger(): """Initialize logger with JSON formatter""" - logger = logging.getLogger(SERVICE_NAME) - logger.setLevel(logging.INFO) + app_logger = logging.getLogger(SERVICE_NAME) + app_logger.setLevel(logging.INFO) # Create console handler with JSON formatter handler = logging.StreamHandler() handler.setFormatter(JSONFormatter()) - logger.addHandler(handler) + app_logger.addHandler(handler) - return logger + return app_logger logger = init_logger() -async def send_funds(w3: AsyncWeb3, amount: int, pk: str, to_address: str, interval: int, use_legacy_gas: bool = False): +async def send_funds(w3: AsyncWeb3, tx_data: dict): """ Send funds to an account on a regular interval. Args: w3: AsyncWeb3 object - amount: Amount in wei to send to the account - pk: Private key of the account that will send the funds - to_address: Address of the account that will receive the funds - interval: Interval in seconds to send the funds - use_legacy_gas: If True, use legacy gas pricing - useful for local networks (fixed) + tx_data: Dictionary containing the transaction data """ + + amount = tx_data['value'] + pk = tx_data['pk'] + to_address = tx_data['to'] + interval = tx_data['interval'] + use_legacy_gas = tx_data['use_legacy_gas'] + # Derive account address from private key account = w3.eth.account.from_key(pk) w3.eth.default_account = account.address # SignAndSendRawMiddlewareBuilder handles signing automatically # https://web3py.readthedocs.io/en/stable/middleware.html#web3.middleware.SignAndSendRawMiddlewareBuilder - w3.middleware_onion.inject(SignAndSendRawMiddlewareBuilder.build(account), layer=0) + middleware = SignAndSendRawMiddlewareBuilder.build(account) # pylint: disable=no-value-for-parameter + w3.middleware_onion.inject(middleware, layer=0) + # Use legacy gas if flag is set if use_legacy_gas: @@ -83,7 +96,13 @@ async def send_funds(w3: AsyncWeb3, amount: int, pk: str, to_address: str, inter logger.info("Using EIP-1559 dynamic fee transactions") logger.info("Starting transaction service") - logger.info(f"from: {account.address}, to: {to_address}, amount: {amount} wei, interval: {interval} seconds") + logger.info( + "from: %s, to: %s, amount: %s wei, interval: %s seconds", + account.address, + to_address, + amount, + interval + ) # Considered in ready state service_state['ready'] = True @@ -112,7 +131,12 @@ async def send_funds(w3: AsyncWeb3, amount: int, pk: str, to_address: str, inter tx_hash = await w3.eth.send_transaction(tx_dict) - logger.info(f"Sent {amount} wei to {to_address}, TX: {tx_hash.hex()}") + logger.info( + "Sent %s wei to %s, TX: %s", + amount, + to_address, + tx_hash.hex() + ) # Update state service_state['last_tx_time'] = datetime.now().isoformat() @@ -124,20 +148,23 @@ async def send_funds(w3: AsyncWeb3, amount: int, pk: str, to_address: str, inter except asyncio.CancelledError: logger.info("Transaction service cancelled, shutting down") break - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught service_state['error_count'] += 1 - logger.error(f"Error sending transaction: {e}") + logger.error("Error sending transaction: %s", e) # Considered not ready if too many consecutive errors (threshold: 5) if service_state['error_count'] >= 5: service_state['ready'] = False - logger.warning(f"Service marked as not ready due to {service_state['error_count']} consecutive errors") + logger.warning( + "Service marked as not ready due to %s consecutive errors", + service_state['error_count'] + ) if shutdown_event.is_set(): logger.info("Shutdown signal received during error handling, stopping") break - logger.info(f"Retrying in {interval} seconds") + logger.info("Retrying in %s seconds", interval) await asyncio.sleep(interval) logger.info("Transaction service stopped") @@ -164,12 +191,23 @@ async def connect_with_retry(rpc_url: str, max_attempts: int = 3) -> Optional[As return w3 attempt += 1 wait_time = 2 ** attempt - logger.warning(f"Failed to connect to RPC node (attempt {attempt}/{max_attempts}), retrying in {wait_time} seconds") + logger.warning( + "Failed to connect to RPC node (attempt %s/%s), retrying in %s seconds", + attempt, + max_attempts, + wait_time + ) await asyncio.sleep(wait_time) - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught attempt += 1 wait_time = 2 ** attempt - logger.error(f"Error connecting to RPC node: {e} (attempt {attempt}/{max_attempts}), retrying in {wait_time} seconds") + logger.error( + "Error connecting to RPC node: %s (attempt %s/%s), retrying in %s seconds", + e, + attempt, + max_attempts, + wait_time + ) await asyncio.sleep(wait_time) logger.error("Unable to connect to RPC node after maximum retry attempts") @@ -196,14 +234,13 @@ async def readiness_handler(_request): 'last_tx_hash': service_state['last_tx_hash'], 'error_count': service_state['error_count'] }, status=200) - else: - return web.json_response({ - 'status': 'not_ready', - 'ready': service_state['ready'], - 'connected': service_state['connected'], - 'error_count': service_state['error_count'], - 'reason': 'too_many_errors' if service_state['error_count'] >= 5 else 'not_initialized' - }, status=503) + return web.json_response({ + 'status': 'not_ready', + 'ready': service_state['ready'], + 'connected': service_state['connected'], + 'error_count': service_state['error_count'], + 'reason': 'too_many_errors' if service_state['error_count'] >= 5 else 'not_initialized' + }, status=503) async def health_handler(_request): """Combined health check endpoint.""" @@ -223,7 +260,7 @@ def signal_handler(signum, _frame): signum: Signal number _frame: Current stack frame """ - logger.info(f"Received signal {signum}, initiating graceful shutdown") + logger.info("Received signal %s, initiating graceful shutdown", signum) shutdown_event.set() # https://docs.python.org/3/library/signal.html#signal.signal @@ -245,14 +282,24 @@ async def start_health_server(port: int = 8080): await runner.setup() site = web.TCPSite(runner, '0.0.0.0', port) await site.start() - logger.info(f"Health check server started on http://0.0.0.0:{port}") - logger.info(f"Health endpoints - Liveness: http://localhost:{port}/health/live, Readiness: http://localhost:{port}/health/ready, Health: http://localhost:{port}/health") + logger.info("Health check server started on http://0.0.0.0:%s", port) + logger.info( + "Health endpoints - Liveness: http://localhost:%s/health/live, " + "Readiness: http://localhost:%s/health/ready, " + "Health: http://localhost:%s/health", + port, + port, + port + ) return runner async def main(): """Parses arguments and runs the tx service.""" parser = argparse.ArgumentParser( - description="Token Transaction Service - Sends ETH transactions to a specified address at regular intervals.", + description=( + "Token Transaction Service - Sends ETH transactions to a " + "specified address at regular intervals." + ), epilog=""" Examples: # Basic usage with private key (EIP-1559 dynamic fees) @@ -288,7 +335,10 @@ async def main(): type=str, required=True, metavar='URL', - help="RPC URL for the blockchain node (e.g., https://linea-mainnet.infura.io/v3/YOUR_KEY)" + help=( + "RPC URL for the blockchain node " + "(e.g., https://linea-mainnet.infura.io/v3/YOUR_KEY)" + ) ) parser.add_argument( '--amount', @@ -347,7 +397,10 @@ async def main(): pk = args.pk or os.environ.get('SENDER_PK') if not pk: - parser.error("--pk must be provided or SENDER_PK must be set in your environment variables.") + parser.error( + "--pk must be provided or SENDER_PK must be set in your " + "environment variables." + ) if not args.to_address: parser.error("--to-address must be provided.") @@ -376,11 +429,20 @@ async def main(): return # Run transaction service (this will run until shutdown_event is set) - await send_funds(w3, args.amount, pk, to_address, args.interval, use_legacy_gas=args.legacy_gas) + await send_funds( + w3, + { + 'value': args.amount, + 'pk': pk, + 'to': to_address, + 'interval': args.interval, + 'use_legacy_gas': args.legacy_gas + } + ) except asyncio.CancelledError: logger.info("Service cancelled") - except Exception as e: - logger.error(f"Unexpected error: {e}") + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Unexpected error: %s", e) finally: # Cleanup logger.info("Cleaning up") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..bc91efd --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,124 @@ +"""Tests for the token transaction service.""" +import json +import logging +import re +import sys + +import pytest +from aiohttp.test_utils import make_mocked_request + +sys.path.insert(0, 'src') +from main import ( # pylint: disable=wrong-import-position + JSONFormatter, + health_handler, + init_logger, + liveness_handler, + readiness_handler, + service_state +) + + +def test_json_formatter(): + """Test that JSONFormatter creates valid JSON log entries""" + formatter = JSONFormatter() + record = logging.LogRecord( + name='test', + level=logging.INFO, + pathname='test.py', + lineno=1, + msg='Test message', + args=(), + exc_info=None + ) + + result = formatter.format(record) + log_data = json.loads(result) + + assert 'timestamp' in log_data + # Verify timestamp format: YYYY-MM-DDTHH:MM:SS.sssZ + timestamp_pattern = r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$' + timestamp = log_data['timestamp'] + assert re.match(timestamp_pattern, timestamp), ( + f"Timestamp {timestamp} does not match expected format " + "YYYY-MM-DDTHH:MM:SS.sssZ" + ) + assert 'level' in log_data + assert 'service' in log_data + assert 'message' in log_data + assert log_data['level'] == 'info' + assert log_data['message'] == 'Test message' + assert log_data['service'] == 'token-tx' + + +def test_init_logger(): + """Test that logger is initialized correctly""" + # Clear existing handlers to test fresh initialization + logger = logging.getLogger('token-tx') + logger.handlers.clear() + + logger = init_logger() + + assert logger is not None + assert logger.name == 'token-tx' + assert logger.level == logging.INFO + assert len(logger.handlers) >= 1 + + +@pytest.mark.asyncio +async def test_liveness_handler(): + """Test that liveness handler returns correct response""" + request = make_mocked_request('GET', '/health/live') + response = await liveness_handler(request) + + assert response.status == 200 + data = json.loads(response.text) + assert data['status'] == 'alive' + + +@pytest.mark.asyncio +async def test_readiness_handler_ready(): + """Test readiness handler when service is ready""" + # Set service state to ready + service_state['ready'] = True + service_state['connected'] = True + service_state['error_count'] = 0 + + request = make_mocked_request('GET', '/health/ready') + response = await readiness_handler(request) + + assert response.status == 200 + data = json.loads(response.text) + assert data['status'] == 'ready' + assert data['connected'] is True + + +@pytest.mark.asyncio +async def test_readiness_handler_not_ready(): + """Test readiness handler when service is not ready""" + # Set service state to not ready + service_state['ready'] = False + service_state['connected'] = False + service_state['error_count'] = 0 + + request = make_mocked_request('GET', '/health/ready') + response = await readiness_handler(request) + + assert response.status == 503 + data = json.loads(response.text) + assert data['status'] == 'not_ready' + + +@pytest.mark.asyncio +async def test_health_handler(): + """Test health handler returns service state""" + service_state['ready'] = True + service_state['connected'] = True + + request = make_mocked_request('GET', '/health') + response = await health_handler(request) + + assert response.status == 200 + data = json.loads(response.text) + assert 'status' in data + assert 'ready' in data + assert 'connected' in data