diff --git a/README.md b/README.md index 4236e044..c2bb07f0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ cd framework # Mit Docker starten make up +# Console Commands ausfΓΌhren (empfohlen) +./bin/console # Docker-Wrapper fΓΌr Console +./bin/console routes:list +./bin/console db:migrate + # Oder: Manuelle Installation composer install npm install @@ -22,6 +27,9 @@ npm install # FΓΌr Backward Compatibility: cp .env.example .env (wird als Fallback geladen) ``` +> πŸ“ **Hinweis zu Permissions**: Verwende immer `./bin/console` fΓΌr Console-Commands statt `php console.php`. +> Details: [docs/PERMISSIONS.md](docs/PERMISSIONS.md) + ### Production Deployment **Neu im Projekt? Starte hier:** diff --git a/bin/console b/bin/console new file mode 100755 index 00000000..47c5a023 --- /dev/null +++ b/bin/console @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Console Command Wrapper - Runs console.php in Docker container +# Usage: ./bin/console [command] [arguments] + +cd "$(dirname "$0")/.." || exit 1 + +# Check if running in interactive terminal +if [ -t 0 ]; then + docker exec -it php php console.php "$@" +else + docker exec php php console.php "$@" +fi diff --git a/deployment/ansible/group_vars/production.yml b/deployment/ansible/group_vars/production.yml index a919bc3c..b5d12042 100644 --- a/deployment/ansible/group_vars/production.yml +++ b/deployment/ansible/group_vars/production.yml @@ -85,3 +85,9 @@ wireguard_config_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}.co wireguard_private_key_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}_private.key" wireguard_public_key_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}_public.key" wireguard_client_configs_path: "{{ wireguard_config_path }}/clients" + +# WireGuard DNS Configuration +# DNS server for VPN clients (points to VPN server IP) +# This ensures internal services are resolved to VPN IPs +wireguard_dns_servers: + - "{{ wireguard_server_ip_default }}" diff --git a/deployment/ansible/playbooks/regenerate-wireguard-client.yml b/deployment/ansible/playbooks/regenerate-wireguard-client.yml index faec0c68..5d6692c5 100644 --- a/deployment/ansible/playbooks/regenerate-wireguard-client.yml +++ b/deployment/ansible/playbooks/regenerate-wireguard-client.yml @@ -81,7 +81,7 @@ - name: Extract server IP from config set_fact: - server_vpn_ip: "{{ (wireguard_server_config_read.content | b64decode | regex_search('Address = ([0-9.]+)', '\\\\1')) | first | default('10.8.0.1') }}" + server_vpn_ip: "{{ (wireguard_server_config_read.content | b64decode | regex_search('Address\\s*=\\s*([0-9.]+)')) | default(['10.8.0.1']) | first }}" failed_when: false - name: Extract WireGuard server IP octets diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 3f09a021..115db877 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -41,7 +41,7 @@ services: php: container_name: php - user: "${PHP_USER:-1000:1000}" + user: "1000:1000" # Run as host user to prevent permission issues volumes: # Host-Mounts fΓΌr direkten Zugriff (Development-friendly) - ./:/var/www/html:${VOLUME_MODE:-cached} @@ -83,47 +83,8 @@ services: - ./:/var/www/html:${VOLUME_MODE:-cached} # NOTE: env_file not needed - Framework automatically loads .env.base β†’ .env.local - db: - container_name: db - ports: - - "${DB_EXTERNAL_PORT:-5433}:5432" - # Override environment to remove POSTGRES_PASSWORD (we use Docker Secrets via entrypoint) - environment: - POSTGRES_DB: ${DB_DATABASE:-michaelschiemer} - POSTGRES_USER: ${DB_USERNAME:-postgres} - # POSTGRES_PASSWORD is NOT set here - it's read from Docker Secret in entrypoint - # Performance & Connection Settings - POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" - PGDATA: /var/lib/postgresql/data/pgdata - secrets: - - db_user_password - # Use entrypoint to read password from Docker Secret - # This overrides the base.yml POSTGRES_PASSWORD environment variable - entrypoint: ["/bin/sh", "-c"] - command: - - | - POSTGRES_PASSWORD=$$(cat /run/secrets/db_user_password 2>/dev/null || echo '') - if [ -n "$$POSTGRES_PASSWORD" ]; then - export POSTGRES_PASSWORD - exec /usr/local/bin/docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf - else - echo "⚠️ Warning: db_user_password secret not found, PostgreSQL may fail to start" - exec /usr/local/bin/docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf - fi - restart: ${RESTART_POLICY:-unless-stopped} - logging: - driver: "${LOG_DRIVER:-local}" - options: - max-size: "${LOG_MAX_SIZE:-5m}" - max-file: "${LOG_MAX_FILE:-2}" - deploy: - resources: - limits: - memory: ${DB_MEMORY_LIMIT:-1G} - cpus: ${DB_CPU_LIMIT:-1.0} - reservations: - memory: ${DB_MEMORY_RESERVATION:-512M} - cpus: ${DB_CPU_RESERVATION:-0.5} + # Database service removed - using external PostgreSQL Stack + # Connection via app-internal network to external stack redis: container_name: redis diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index defc113d..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,367 +0,0 @@ -# ⚠️ DEPRECATED - Legacy Docker Compose Configuration ⚠️ -# -# This file is DEPRECATED and kept ONLY for backward compatibility during migration. -# ⚠️ DO NOT USE THIS FILE FOR NEW DEPLOYMENTS ⚠️ -# -# This file will be REMOVED after the migration period (planned: Q2 2025). -# All developers must migrate to the Base+Override Pattern before then. -# -# βœ… PREFERRED: Use Base+Override Pattern: -# - docker-compose.base.yml (shared services) -# - docker-compose.local.yml (local development overrides) -# - docker-compose.staging.yml (staging overrides) -# - docker-compose.production.yml (production overrides) -# -# πŸ“– Usage: -# Local: docker compose -f docker-compose.base.yml -f docker-compose.local.yml up -# Staging: docker compose -f docker-compose.base.yml -f docker-compose.staging.yml up -# Production: docker compose -f docker-compose.base.yml -f docker-compose.production.yml up -# -# πŸ”— See deployment/README.md for details on the Base+Override Pattern -# πŸ”— See ENV_SETUP.md for environment configuration guide -# -# ⚠️ Migration Required: -# 1. Create .env.base from .env.example (run: make env-base) -# 2. Create .env.local for local overrides (run: make env-local) -# 3. Update all docker compose commands to use Base+Override files -# 4. Test your local setup before removing this legacy file -# -# πŸ“… Deprecation Timeline: -# - Created: Base+Override Pattern introduced -# - Planned Removal: Q2 2025 (after all developers have migrated) -# - Action Required: Migrate before removal date - -services: - web: - container_name: web - build: - context: docker/nginx - dockerfile: Dockerfile - ports: - - "8888:80" - - "8443:443" - environment: - - APP_ENV=${APP_ENV:-development} - healthcheck: - test: ["CMD", "nc", "-z", "127.0.0.1", "443"] - interval: 30s - timeout: 10s - retries: 3 - start_period: ${HEALTHCHECK_START_PERIOD:-10s} - logging: - driver: "${LOG_DRIVER:-local}" - options: - max-size: "${LOG_MAX_SIZE:-5m}" - max-file: "${LOG_MAX_FILE:-2}" - volumes: - - ./:/var/www/html:${VOLUME_MODE:-cached} - - ./ssl:/var/www/ssl:ro - depends_on: - php: - condition: service_started - restart: ${RESTART_POLICY:-unless-stopped} - networks: - - frontend - - backend - # Legacy .env file (Fallback for backward compatibility) - # Preferred: Use docker-compose.base.yml + docker-compose.local.yml - # See ENV_SETUP.md for new Base+Override Pattern - env_file: - - .env - deploy: - resources: - limits: - memory: ${WEB_MEMORY_LIMIT:-256M} - cpus: ${WEB_CPU_LIMIT:-0.5} - reservations: - memory: ${WEB_MEMORY_RESERVATION:-128M} - cpus: ${WEB_CPU_RESERVATION:-0.25} - - php: - container_name: php - build: - context: . - dockerfile: docker/php/Dockerfile - args: - - ENV=${APP_ENV:-dev} - - COMPOSER_INSTALL_FLAGS=${COMPOSER_INSTALL_FLAGS:---no-scripts --no-autoloader} - user: "${PHP_USER:-1000:1000}" - logging: - driver: "${LOG_DRIVER:-local}" - options: - max-size: "${LOG_MAX_SIZE:-5m}" - max-file: "${LOG_MAX_FILE:-2}" - volumes: - # Shared Volume fΓΌr Composer-Cache ΓΌber Container-Neustarts hinweg - - composer-cache:/root/.composer/cache - # Bindet das Projektverzeichnis fΓΌr Produktivbetrieb ein - #- project-data:/var/www/html:cached - # Variante mit mounting: - - ./:/var/www/html:${VOLUME_MODE:-cached} - # Verhindert Überschreiben der Vendor-Verzeichnisse - #- /var/www/html/vendor - - # Host-Mounts fΓΌr direkten Zugriff (Development-friendly) - - ./storage/logs:/var/www/html/storage/logs:rw - - ./storage/uploads:/var/www/html/storage/uploads:rw - - ./storage/analytics:/var/www/html/storage/analytics:rw - - # Docker-Volumes fΓΌr Performance (keine Host-Sync nΓΆtig) - - storage-cache:/var/www/html/storage/cache:rw - - storage-queue:/var/www/html/storage/queue:rw - - storage-discovery:/var/www/html/storage/discovery:rw - - - var-data:/var/www/html/var:rw - environment: - PHP_IDE_CONFIG: "${PHP_IDE_CONFIG:-serverName=docker}" - APP_ENV: ${APP_ENV:-development} - APP_DEBUG: ${APP_DEBUG:-true} - XDEBUG_MODE: ${XDEBUG_MODE:-debug} - healthcheck: - test: [ "CMD", "php", "-v" ] - interval: 30s - timeout: 10s - retries: 3 - restart: ${RESTART_POLICY:-unless-stopped} - networks: - - backend - - cache - # Legacy .env file (Fallback for backward compatibility) - # Preferred: Use docker-compose.base.yml + docker-compose.local.yml - env_file: - - .env - deploy: - resources: - limits: - memory: ${PHP_MEMORY_LIMIT:-512M} - cpus: ${PHP_CPU_LIMIT:-1.0} - reservations: - memory: ${PHP_MEMORY_RESERVATION:-256M} - cpus: ${PHP_CPU_RESERVATION:-0.5} - - php-test: - container_name: php-test - build: - context: . - dockerfile: docker/php/Dockerfile.test - user: "1000:1000" - profiles: - - test - volumes: - - ./:/var/www/html:${VOLUME_MODE:-cached} - - composer-cache:/home/appuser/.composer/cache - - storage-cache:/var/www/html/storage/cache:rw - - storage-queue:/var/www/html/storage/queue:rw - - storage-discovery:/var/www/html/storage/discovery:rw - - var-data:/var/www/html/var:rw - environment: - APP_ENV: testing - APP_DEBUG: true - DB_HOST: db - REDIS_HOST: redis - networks: - - backend - - cache - # Legacy .env file (Fallback for backward compatibility) - env_file: - - .env - entrypoint: [] - command: ["php", "-v"] - - db: - container_name: db - image: postgres:16-alpine - restart: ${RESTART_POLICY:-unless-stopped} - environment: - POSTGRES_DB: ${DB_DATABASE:-michaelschiemer} - POSTGRES_USER: ${DB_USERNAME:-postgres} - POSTGRES_PASSWORD: ${DB_PASSWORD:-StartSimple2024!} - # Performance & Connection Settings - POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" - PGDATA: /var/lib/postgresql/data/pgdata - ports: - - "${DB_EXTERNAL_PORT:-5433}:5432" - volumes: - - db_data:/var/lib/postgresql/data - - "${DB_CONFIG_PATH:-./docker/postgres/postgresql.conf}:/etc/postgresql/postgresql.conf:ro" - - "${DB_INIT_PATH:-./docker/postgres/init}:/docker-entrypoint-initdb.d:ro" - command: - - "postgres" - - "-c" - - "config_file=/etc/postgresql/postgresql.conf" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_DATABASE:-michaelschiemer}"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - logging: - driver: "${LOG_DRIVER:-local}" - options: - max-size: "${LOG_MAX_SIZE:-5m}" - max-file: "${LOG_MAX_FILE:-2}" - networks: - - backend - deploy: - resources: - limits: - memory: ${DB_MEMORY_LIMIT:-1G} - cpus: ${DB_CPU_LIMIT:-1.0} - reservations: - memory: ${DB_MEMORY_RESERVATION:-512M} - cpus: ${DB_CPU_RESERVATION:-0.5} - - redis: - container_name: redis - image: redis:7-alpine - volumes: - - "${REDIS_CONFIG_PATH:-./docker/redis/redis.conf}:/usr/local/etc/redis/redis.conf:ro" - - redis_data:/data - command: ["redis-server", "/usr/local/etc/redis/redis.conf"] - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 30s - restart: ${RESTART_POLICY:-unless-stopped} - logging: - driver: "${LOG_DRIVER:-local}" - options: - max-size: "${LOG_MAX_SIZE:-5m}" - max-file: "${LOG_MAX_FILE:-2}" - networks: - - cache - # Legacy .env file (Fallback for backward compatibility) - env_file: - - .env - deploy: - resources: - limits: - memory: ${REDIS_MEMORY_LIMIT:-256M} - cpus: ${REDIS_CPU_LIMIT:-0.5} - reservations: - memory: ${REDIS_MEMORY_RESERVATION:-128M} - cpus: ${REDIS_CPU_RESERVATION:-0.25} - - queue-worker: - container_name: queue-worker - build: - context: . - dockerfile: docker/worker/Dockerfile - user: "1000:1000" # Same user ID as PHP container - entrypoint: "" # Override any entrypoint - command: ["php", "/var/www/html/worker.php"] # Direct command execution - depends_on: - php: - condition: service_healthy - redis: - condition: service_healthy - db: - condition: service_healthy - volumes: - - ./:/var/www/html:cached - # Use same storage volumes as PHP container for consistency - - storage-cache:/var/www/html/storage/cache:rw - - storage-queue:/var/www/html/storage/queue:rw - - storage-discovery:/var/www/html/storage/discovery:rw - - ./storage/logs:/var/www/html/storage/logs:rw - - var-data:/var/www/html/var:rw - environment: - - APP_ENV=${APP_ENV:-development} - - WORKER_DEBUG=${WORKER_DEBUG:-false} - - WORKER_SLEEP_TIME=${WORKER_SLEEP_TIME:-100000} - - WORKER_MAX_JOBS=${WORKER_MAX_JOBS:-1000} - restart: unless-stopped - networks: - - backend - - cache - # Legacy .env file (Fallback for backward compatibility) - env_file: - - .env - # Graceful shutdown timeout - stop_grace_period: 30s - # Resource limits for the worker - deploy: - resources: - limits: - memory: 1G - reservations: - memory: 512M - - minio: - container_name: minio - image: minio/minio:latest - restart: ${RESTART_POLICY:-unless-stopped} - environment: - - TZ=Europe/Berlin - - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} - - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} - command: server /data --console-address ":9001" - ports: - - "${MINIO_API_PORT:-9000}:9000" - - "${MINIO_CONSOLE_PORT:-9001}:9001" - volumes: - - minio_data:/data - - /etc/timezone:/etc/timezone:ro - - /etc/localtime:/etc/localtime:ro - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - networks: - - backend - logging: - driver: "${LOG_DRIVER:-local}" - options: - max-size: "${LOG_MAX_SIZE:-5m}" - max-file: "${LOG_MAX_FILE:-2}" - deploy: - resources: - limits: - memory: ${MINIO_MEMORY_LIMIT:-512M} - cpus: ${MINIO_CPU_LIMIT:-0.5} - reservations: - memory: ${MINIO_MEMORY_RESERVATION:-256M} - cpus: ${MINIO_CPU_RESERVATION:-0.25} - -# websocket: -# build: -# context: . -# container_name: websocket -# command: php websocket.php -# ports: -# - "8081:8081" -# networks: -# - frontend -# - backend -# volumes: -# - ./:/var/www/html - -networks: - frontend: - driver: bridge - backend: - driver: bridge - internal: ${NETWORK_BACKEND_INTERNAL:-false} - cache: - driver: bridge - internal: ${NETWORK_CACHE_INTERNAL:-false} - -volumes: - redis_data: - composer-cache: - # storage-data entfernt - wird jetzt granular gemountet - storage-cache: # Cache-Verzeichnis (Performance-kritisch) - storage-queue: # Queue-Verzeichnis (Performance-kritisch) - storage-discovery: # Discovery-Cache (Framework-intern) - var-data: - #cache-volume: - db_data: - project-data: - worker-logs: - worker-queue: - worker-storage: # Complete separate storage for worker with correct permissions - minio_data: # MinIO object storage data diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md new file mode 100644 index 00000000..0fe13475 --- /dev/null +++ b/docs/PERMISSIONS.md @@ -0,0 +1,226 @@ +# Storage Permissions Management + +## Problem + +Docker containers laufen standardmÀßig als `root` und erstellen Files/Directories mit root-Ownership. +Beim Zugriff vom Host aus (z.B. `console.php` direkt) gibt es dann Permission-Probleme. + +## Implementierte LΓΆsungen + +### 1. Docker-Wrapper Script ⭐ (EMPFOHLEN) + +**Location**: `./bin/console` + +**Usage**: +```bash +# Direkt ausfΓΌhren +./bin/console +./bin/console routes:list +./bin/console db:migrate + +# Optional: In PATH aufnehmen +export PATH="$PATH:/home/michael/dev/michaelschiemer/bin" +console routes:list # Jetzt direkt aufrufbar +``` + +**Features**: +- βœ… LΓ€uft automatisch im PHP-Container +- βœ… TTY-Detection (interaktiv/non-interaktiv) +- βœ… Keine Permission-Probleme +- βœ… Funktioniert in WSL, Linux, macOS + +**Implementation**: +```bash +#!/usr/bin/env bash +# Auto-detect TTY und nutze entsprechende docker exec Flags +if [ -t 0 ]; then + docker exec -it php php console.php "$@" +else + docker exec php php console.php "$@" +fi +``` + +--- + +### 2. Container User Configuration ⭐ + +**Status**: βœ… Konfiguriert in `docker-compose.local.yml` + +```yaml +php: + user: "1000:1000" # LΓ€uft als Host-User + +queue-worker: + user: "1000:1000" # Konsistent + +php-test: + user: "1000:1000" # Konsistent +``` + +**Vorteile**: +- Container erstellt keine root-Files mehr +- Konsistente Permissions ΓΌber alle Services +- Keine sudo-Rechte fΓΌr Storage-Zugriffe nΓΆtig + +**Einmalige Permissions-Korrektur**: +```bash +# Via Makefile (empfohlen) +make fix-perms + +# Oder manuell +sudo chown -R $(id -u):$(id -g) storage/ +``` + +--- + +### 3. Git Hooks (Automatisch) ⭐ + +**Location**: +- `.git/hooks/post-checkout` +- `.git/hooks/post-merge` + +**Funktion**: +Automatische Permissions-Korrektur nach `git checkout` oder `git pull` + +**Implementation**: +```bash +#!/usr/bin/env bash +# PrΓΌfe ob storage-Directories root gehΓΆren +if find storage -user root 2>/dev/null | grep -q .; then + echo "πŸ”§ Fixing storage permissions..." + sudo chown -R $(id -u):$(id -g) storage/ +fi +``` + +**Wann ausgefΓΌhrt**: +- Nach `git checkout ` +- Nach `git pull` +- Nach `git merge` + +--- + +## Troubleshooting + +### Problem: "Permission denied for create directory" + +**Symptom**: +``` +Fehler: Permission denied for create directory on file: /home/michael/dev/michaelschiemer/storage/queue/priority +Parent directory: /home/michael/dev/michaelschiemer/storage/queue (owner: root, group: root, writable: no) +``` + +**LΓΆsung 1** (Sofort): +```bash +make fix-perms +``` + +**LΓΆsung 2** (Dauerhaft - Container verwenden): +```bash +./bin/console # Statt: php console.php +``` + +--- + +### Problem: Console-Command vom Host aus + +**❌ Falsch**: +```bash +php console.php # Verwendet Host-PHP, hat keine Container-Permissions +console # Alias existiert nicht +``` + +**βœ… Richtig**: +```bash +./bin/console # Docker-Wrapper Script +docker exec php php console.php # Direkt im Container +make console ARGS="command" # Via Makefile +``` + +--- + +### Problem: Git Hook fragt nach sudo-Password + +**ErklΓ€rung**: +Hook versucht automatisch permissions zu fixen, benΓΆtigt sudo fΓΌr chown. + +**Optionen**: +1. **Password eingeben** - Hook korrigiert automatisch +2. **Abbrechen** - SpΓ€ter manuell `make fix-perms` ausfΓΌhren +3. **Sudo-less chown** (advanced): + ```bash + # In /etc/sudoers.d/storage-permissions + michael ALL=(ALL) NOPASSWD: /usr/bin/chown -R * /home/michael/dev/michaelschiemer/storage + ``` + +--- + +## Best Practices + +### Development Workflow + +```bash +# 1. Container starten +make up + +# 2. Console Commands im Container ausfΓΌhren +./bin/console routes:list +./bin/console db:migrate + +# 3. Bei Permission-Problemen +make fix-perms + +# 4. Container neu starten (bei Config-Γ„nderungen) +make restart +``` + +### Production Deployment + +- **KEINE** Container-User-Config in Production +- Storage-Directories gehΓΆren `www-data` User +- Container laufen als `www-data` (uid/gid in Dockerfile gesetzt) +- Keine Host-Mounts (nur Docker Volumes) + +--- + +## Maintenance + +### Permissions Check + +```bash +# Check storage permissions +ls -la storage/ + +# Find root-owned files +find storage -user root + +# Fix all at once +make fix-perms +``` + +### Git Hooks Update + +```bash +# Check if hooks are installed +ls -la .git/hooks/post-* + +# Test hook manually +.git/hooks/post-checkout + +# Remove hooks (disable auto-fix) +rm .git/hooks/post-checkout .git/hooks/post-merge +``` + +--- + +## Summary + +**Empfohlener Workflow**: +1. βœ… Verwende `./bin/console` fΓΌr Console-Commands +2. βœ… Container laufen als User 1000:1000 (development) +3. βœ… Git Hooks korrigieren automatisch nach pull/checkout +4. βœ… Bei Problemen: `make fix-perms` + +**Vermeiden**: +- ❌ `php console.php` vom Host aus +- ❌ Console-Commands außerhalb des Containers +- ❌ Manuelle chown-Befehle (verwende `make fix-perms`) diff --git a/docs/claude/deployment-architecture.md b/docs/claude/deployment-architecture.md new file mode 100644 index 00000000..ad145c13 --- /dev/null +++ b/docs/claude/deployment-architecture.md @@ -0,0 +1,777 @@ +# Deployment Architecture + +Comprehensive documentation of the deployment infrastructure for the Custom PHP Framework. + +## Overview + +The project uses a sophisticated multi-layered deployment approach: + +- **Local Development**: Docker Compose with Base+Override pattern +- **Production**: Ansible-orchestrated deployment of separate Docker stacks +- **CI/CD**: Gitea Actions triggering Ansible playbooks +- **Infrastructure**: Modular service stacks (Traefik, PostgreSQL, Registry, Gitea, Monitoring, WireGuard) + +**Deployment Status**: ~95% complete (Infrastructure and Application stacks complete, CI/CD pipeline configured but not tested) + +--- + +## Docker Compose Architecture + +### Base+Override Pattern + +The project uses a modern Base+Override pattern instead of a monolithic docker-compose.yml: + +``` +docker-compose.base.yml # Shared service definitions +docker-compose.local.yml # Local development overrides +docker-compose.staging.yml # Staging environment overrides +docker-compose.production.yml # Production environment overrides +``` + +**Usage**: +```bash +# Local Development +docker compose -f docker-compose.base.yml -f docker-compose.local.yml up + +# Production (via Ansible) +docker compose -f docker-compose.base.yml -f docker-compose.production.yml up +``` + +### Legacy docker-compose.yml + +**⚠️ DEPRECATED**: The root `docker-compose.yml` is marked as DEPRECATED and kept only for backward compatibility during migration. + +**Planned Removal**: Q2 2025 + +**DO NOT USE** for new deployments - use Base+Override pattern instead. + +--- + +## Production Deployment Configuration + +### docker-compose.production.yml + +Production environment configuration with security hardening: + +**Key Features**: +- **Docker Secrets**: Sensitive data managed via Docker Secrets pattern + - `DB_PASSWORD_FILE=/run/secrets/db_user_password` + - `REDIS_PASSWORD_FILE=/run/secrets/redis_password` + - `APP_KEY_FILE=/run/secrets/app_key` + - `VAULT_ENCRYPTION_KEY=/run/secrets/vault_encryption_key` + +- **Security Hardening**: + - Container starts as root for gosu, drops to www-data + - `no-new-privileges:true` security option + - Minimal capabilities (ALL dropped, only CHOWN and DAC_OVERRIDE added) + - Environment: `APP_ENV=production`, `APP_DEBUG=false` + +- **Services**: + - **web** (Nginx): Ports 80/443 exposed, SSL/TLS ready + - **php** (PHP-FPM): Application runtime with security constraints + - **redis**: Cache with Docker Secrets authentication + - **queue-worker**: Background job processing + - **scheduler**: Cron-like task scheduling + - **certbot**: Automatic SSL certificate management + +**Example Service Configuration**: +```yaml +php: + restart: always + user: "root" # Container starts as root for gosu + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + environment: + - APP_ENV=production + - APP_DEBUG=false + - DB_PASSWORD_FILE=/run/secrets/db_user_password + secrets: + - db_user_password + - redis_password + - app_key + - vault_encryption_key +``` + +--- + +## Deployment Directory Structure + +``` +deployment/ +β”œβ”€β”€ ansible/ # Ansible automation +β”‚ β”œβ”€β”€ playbooks/ # Deployment playbooks +β”‚ β”‚ β”œβ”€β”€ deploy-update.yml # Application deployment/update +β”‚ β”‚ β”œβ”€β”€ rollback.yml # Rollback to previous version +β”‚ β”‚ β”œβ”€β”€ setup-infrastructure.yml # Infrastructure provisioning +β”‚ β”‚ └── system-maintenance.yml # System maintenance tasks +β”‚ β”œβ”€β”€ roles/ # Ansible roles +β”‚ β”‚ β”œβ”€β”€ common/ # Common server setup +β”‚ β”‚ β”œβ”€β”€ docker/ # Docker installation +β”‚ β”‚ β”œβ”€β”€ firewall/ # Firewall configuration +β”‚ β”‚ β”œβ”€β”€ monitoring/ # Monitoring setup +β”‚ β”‚ β”œβ”€β”€ postgresql/ # PostgreSQL stack +β”‚ β”‚ β”œβ”€β”€ registry/ # Docker Registry +β”‚ β”‚ β”œβ”€β”€ traefik/ # Traefik reverse proxy +β”‚ β”‚ └── wireguard/ # WireGuard VPN +β”‚ β”œβ”€β”€ secrets/ # Ansible Vault encrypted secrets +β”‚ β”œβ”€β”€ templates/ # Configuration templates +β”‚ β”œβ”€β”€ group_vars/ # Group-specific variables +β”‚ β”‚ └── production.yml # Production environment config +β”‚ β”œβ”€β”€ host_vars/ # Host-specific variables +β”‚ β”œβ”€β”€ inventory/ # Server inventory +β”‚ β”‚ └── production # Production servers +β”‚ └── ansible.cfg # Ansible configuration +β”‚ +β”œβ”€β”€ stacks/ # Docker Stack definitions +β”‚ β”œβ”€β”€ traefik/ # Reverse proxy & SSL +β”‚ β”‚ β”œβ”€β”€ docker-compose.yml +β”‚ β”‚ β”œβ”€β”€ traefik.yml # Traefik configuration +β”‚ β”‚ └── README.md +β”‚ β”œβ”€β”€ postgresql/ # Database stack +β”‚ β”‚ β”œβ”€β”€ docker-compose.yml +β”‚ β”‚ └── README.md +β”‚ β”œβ”€β”€ registry/ # Docker Registry +β”‚ β”‚ β”œβ”€β”€ docker-compose.yml +β”‚ β”‚ └── README.md +β”‚ β”œβ”€β”€ gitea/ # Git server & CI/CD +β”‚ β”‚ β”œβ”€β”€ docker-compose.yml +β”‚ β”‚ └── README.md +β”‚ β”œβ”€β”€ monitoring/ # Prometheus & Grafana +β”‚ β”‚ β”œβ”€β”€ docker-compose.yml +β”‚ β”‚ β”œβ”€β”€ prometheus.yml +β”‚ β”‚ └── README.md +β”‚ β”œβ”€β”€ wireguard/ # VPN access +β”‚ β”‚ β”œβ”€β”€ docker-compose.yml +β”‚ β”‚ └── README.md +β”‚ └── application/ # Main application +β”‚ β”œβ”€β”€ docker-compose.yml +β”‚ β”œβ”€β”€ README.md +β”‚ └── configs/ +β”‚ +β”œβ”€β”€ docs/ # Documentation +β”‚ β”œβ”€β”€ guides/ # How-to guides +β”‚ β”‚ β”œβ”€β”€ setup-guide.md # Complete setup walkthrough +β”‚ β”‚ └── ... +β”‚ β”œβ”€β”€ status/ # Deployment status +β”‚ β”‚ β”œβ”€β”€ deployment-summary.md +β”‚ β”‚ └── ... +β”‚ β”œβ”€β”€ tests/ # Test documentation +β”‚ β”œβ”€β”€ history/ # Historical decisions +β”‚ └── reference/ # Reference material +β”‚ +└── .gitea/ # CI/CD workflows + └── workflows/ + └── deploy.yml # Gitea Actions deployment +``` + +--- + +## Ansible Automation + +### Playbook Overview + +**Infrastructure Setup** (`setup-infrastructure.yml`): +- Server provisioning and hardening +- Docker installation and configuration +- Firewall and security setup +- Stack deployment (Traefik, PostgreSQL, Registry, Gitea, Monitoring) + +**Application Deployment** (`deploy-update.yml`): +- Pull latest code from Git +- Build Docker images +- Push to private registry +- Deploy application stack via docker-compose +- Run database migrations +- Restart services with zero-downtime + +**Rollback** (`rollback.yml`): +- Rollback to previous Docker image tag +- Restore database from backup (if needed) +- Restart services + +**System Maintenance** (`system-maintenance.yml`): +- System updates and security patches +- Docker cleanup (unused images, volumes) +- Log rotation +- Backup tasks + +### Ansible Vault Secrets + +**Encrypted Secrets** in `deployment/ansible/secrets/`: +- `production.vault.yml`: Production credentials (DB passwords, API keys, etc.) +- `registry.vault.yml`: Docker Registry authentication +- `gitea.vault.yml`: Gitea runner tokens and SSH keys + +**Vault Operations**: +```bash +# Encrypt new file +ansible-vault encrypt deployment/ansible/secrets/production.vault.yml + +# Edit encrypted file +ansible-vault edit deployment/ansible/secrets/production.vault.yml + +# View encrypted file +ansible-vault view deployment/ansible/secrets/production.vault.yml + +# Run playbook with vault +ansible-playbook -i inventory/production playbooks/deploy-update.yml --ask-vault-pass +``` + +### Inventory Configuration + +**Production Inventory** (`inventory/production`): +```ini +[web_servers] +app.michaelschiemer.com ansible_host=YOUR_SERVER_IP ansible_user=deploy + +[db_servers] +app.michaelschiemer.com + +[all:vars] +ansible_python_interpreter=/usr/bin/python3 +``` + +**Group Variables** (`group_vars/production.yml`): +- Domain configuration +- SSL/TLS settings +- Docker Registry URL +- Application-specific variables +- Resource limits (CPU, Memory) + +--- + +## Docker Stacks + +### Stack Architecture + +Each service runs as an independent Docker stack for modularity and isolation: + +#### 1. Traefik Stack +**Purpose**: Reverse proxy, SSL/TLS termination, Let's Encrypt integration + +**Features**: +- Automatic SSL certificate management +- HTTP to HTTPS redirection +- Load balancing +- Service discovery via Docker labels + +**Configuration**: `deployment/stacks/traefik/traefik.yml` + +#### 2. PostgreSQL Stack +**Purpose**: Primary database for application + +**Features**: +- Automated backups +- Connection pooling +- Replication support (optional) +- Monitoring integration + +**Configuration**: `deployment/stacks/postgresql/docker-compose.yml` + +#### 3. Docker Registry Stack +**Purpose**: Private Docker image registry + +**Features**: +- Authentication via Basic Auth or LDAP +- SSL/TLS encryption +- Image scanning (optional) +- Integration with CI/CD pipeline + +**Configuration**: `deployment/stacks/registry/docker-compose.yml` + +#### 4. Gitea Stack +**Purpose**: Git server and CI/CD platform + +**Features**: +- Git repository hosting +- Gitea Actions (GitHub Actions compatible) +- Gitea Runner for CI/CD execution +- Webhook support + +**Configuration**: `deployment/stacks/gitea/docker-compose.yml` + +#### 5. Monitoring Stack +**Purpose**: Metrics collection and visualization + +**Features**: +- Prometheus for metrics collection +- Grafana for visualization +- Node Exporter for server metrics +- cAdvisor for container metrics +- Pre-configured dashboards + +**Configuration**: `deployment/stacks/monitoring/docker-compose.yml` + +#### 6. WireGuard Stack +**Purpose**: Secure VPN access to infrastructure + +**Features**: +- Encrypted VPN tunnel +- Access to internal services +- Multi-device support + +**Configuration**: `deployment/stacks/wireguard/docker-compose.yml` + +#### 7. Application Stack +**Purpose**: Main PHP application + +**Services**: +- **Nginx**: Web server +- **PHP-FPM**: PHP application runtime +- **Redis**: Cache and session storage +- **Queue Worker**: Background job processing +- **Scheduler**: Cron-like task scheduling + +**Configuration**: `deployment/stacks/application/docker-compose.yml` + +**Prerequisites**: +- Traefik stack running (for routing) +- PostgreSQL stack running (database) +- Docker Registry access (for image pulls) +- DNS configured (A records pointing to server) + +--- + +## CI/CD Pipeline + +### Gitea Actions Workflow + +**Workflow File**: `.gitea/workflows/deploy.yml` + +**Trigger Events**: +- Push to `main` branch (production deployment) +- Push to `staging` branch (staging deployment) +- Manual trigger via Gitea UI + +**Workflow Steps**: +1. **Checkout Code**: Clone repository +2. **Build Docker Image**: Build application Docker image +3. **Push to Registry**: Push image to private Docker Registry +4. **Run Tests**: Execute test suite (PHPUnit/Pest) +5. **Deploy via Ansible**: Trigger Ansible playbook for deployment +6. **Health Check**: Verify deployment success +7. **Rollback on Failure**: Automatic rollback if health check fails + +**Example Workflow**: +```yaml +name: Deploy to Production +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Build Docker image + run: | + docker build -t registry.michaelschiemer.com/app:${{ github.sha }} . + docker push registry.michaelschiemer.com/app:${{ github.sha }} + + - name: Deploy via Ansible + run: | + ansible-playbook -i deployment/ansible/inventory/production \ + deployment/ansible/playbooks/deploy-update.yml \ + --extra-vars "image_tag=${{ github.sha }}" \ + --vault-password-file=/secrets/vault-password +``` + +### Gitea Runner Setup + +**Installation** (via Ansible): +- Gitea Runner installed on deployment server +- Configured with runner token from Gitea +- Systemd service for automatic start +- Docker-in-Docker (DinD) support for builds + +**Configuration**: +```bash +# Register runner +gitea-runner register \ + --instance https://gitea.michaelschiemer.com \ + --token $RUNNER_TOKEN \ + --name production-runner + +# Start runner +systemctl start gitea-runner +systemctl enable gitea-runner +``` + +--- + +## Deployment Workflow + +### Phase 1: Initial Setup (One-Time) + +**1.1 Gitea Runner Registration**: +```bash +# On deployment server +ssh deploy@app.michaelschiemer.com + +# Register Gitea Runner +gitea-runner register \ + --instance https://gitea.michaelschiemer.com \ + --token \ + --name production-runner + +# Verify registration +gitea-runner list +``` + +**1.2 Ansible Vault Setup**: +```bash +# Create vault password file +echo "your-vault-password" > ~/.ansible/vault-password +chmod 600 ~/.ansible/vault-password + +# Encrypt production secrets +cd deployment/ansible/secrets +ansible-vault create production.vault.yml +ansible-vault create registry.vault.yml +ansible-vault create gitea.vault.yml +``` + +**1.3 Server Provisioning**: +```bash +# Run infrastructure setup playbook +cd deployment/ansible +ansible-playbook -i inventory/production \ + playbooks/setup-infrastructure.yml \ + --vault-password-file ~/.ansible/vault-password +``` + +This provisions: +- Docker installation +- Firewall configuration +- All infrastructure stacks (Traefik, PostgreSQL, Registry, Gitea, Monitoring, WireGuard) + +### Phase 2: Application Deployment (Repeatable) + +**2.1 Manual Deployment** (via Ansible): +```bash +# Deploy/Update application +cd deployment/ansible +ansible-playbook -i inventory/production \ + playbooks/deploy-update.yml \ + --vault-password-file ~/.ansible/vault-password \ + --extra-vars "image_tag=latest" +``` + +**2.2 Automated Deployment** (via CI/CD): +```bash +# Push to main branch triggers automatic deployment +git push origin main + +# Or trigger manually via Gitea UI +# Repository β†’ Actions β†’ Deploy to Production β†’ Run Workflow +``` + +### Phase 3: Rollback (If Needed) + +```bash +# Rollback to previous version +cd deployment/ansible +ansible-playbook -i inventory/production \ + playbooks/rollback.yml \ + --vault-password-file ~/.ansible/vault-password \ + --extra-vars "rollback_version=v1.2.3" +``` + +--- + +## Monitoring and Health Checks + +### Application Health Endpoint + +**URL**: `https://app.michaelschiemer.com/health` + +**Response** (Healthy): +```json +{ + "status": "healthy", + "checks": { + "database": "ok", + "redis": "ok", + "filesystem": "ok" + }, + "timestamp": "2025-01-28T12:34:56Z" +} +``` + +### Prometheus Metrics + +**URL**: `https://metrics.michaelschiemer.com` (via WireGuard VPN) + +**Key Metrics**: +- Application response times +- Database query performance +- Redis cache hit rate +- Queue job processing rate +- System resources (CPU, Memory, Disk) + +### Grafana Dashboards + +**URL**: `https://grafana.michaelschiemer.com` (via WireGuard VPN) + +**Pre-configured Dashboards**: +- Application Overview +- Database Performance +- Queue System Metrics +- Infrastructure Health +- Docker Container Stats + +--- + +## Troubleshooting + +### Deployment Failed + +**Check Ansible Logs**: +```bash +# View last deployment log +tail -f /var/log/ansible/deploy-update.log + +# Check playbook output +ansible-playbook -i inventory/production \ + playbooks/deploy-update.yml \ + -vvv # Verbose output +``` + +**Common Issues**: +- **Docker Registry Authentication Failed**: Check registry credentials in `secrets/registry.vault.yml` +- **Database Migration Failed**: Check database connectivity, review migration logs +- **Image Pull Failed**: Verify Docker Registry is accessible, image exists with specified tag + +### Application Not Starting + +**Check Docker Logs**: +```bash +# Application stack logs +ssh deploy@app.michaelschiemer.com +docker compose -f /opt/stacks/application/docker-compose.yml logs -f + +# Specific service logs +docker compose logs -f php +docker compose logs -f nginx +``` + +**Common Issues**: +- **PHP Fatal Error**: Check PHP logs in `/var/log/app/php-error.log` +- **Database Connection Refused**: Verify PostgreSQL stack is running, check credentials +- **Redis Connection Failed**: Verify Redis stack is running, check authentication + +### SSL Certificate Issues + +**Check Traefik Logs**: +```bash +docker compose -f /opt/stacks/traefik/docker-compose.yml logs -f +``` + +**Verify Let's Encrypt Certificate**: +```bash +# Check certificate expiry +openssl s_client -connect app.michaelschiemer.com:443 -servername app.michaelschiemer.com \ + | openssl x509 -noout -dates + +# Force certificate renewal (if needed) +docker exec traefik /usr/bin/traefik \ + --acme.email=admin@michaelschiemer.com \ + --certificatesresolvers.letsencrypt.acme.email=admin@michaelschiemer.com +``` + +### Rollback Procedure + +**Immediate Rollback**: +```bash +# 1. Identify previous working version +docker images | grep app + +# 2. Execute rollback playbook +cd deployment/ansible +ansible-playbook -i inventory/production \ + playbooks/rollback.yml \ + --vault-password-file ~/.ansible/vault-password \ + --extra-vars "rollback_version=" + +# 3. Verify health +curl -k https://app.michaelschiemer.com/health +``` + +--- + +## Best Practices + +### Development Workflow + +**1. Local Development**: +```bash +# Use local docker-compose override +docker compose -f docker-compose.base.yml -f docker-compose.local.yml up +``` + +**2. Test Before Push**: +```bash +# Run test suite +./vendor/bin/pest + +# Code style check +composer cs + +# Static analysis +composer analyze +``` + +**3. Feature Branch Deployment** (Optional): +```bash +# Create feature branch +git checkout -b feature/new-feature + +# Deploy to staging (if configured) +git push origin feature/new-feature +# Triggers staging deployment +``` + +### Production Deployment + +**1. Use Ansible for Deployments**: +- Never manually SSH and run docker commands +- Always use Ansible playbooks for consistency +- Ansible provides idempotency and rollback capability + +**2. Monitor Deployments**: +- Watch Grafana dashboards during deployment +- Check application logs for errors +- Verify health endpoint returns 200 OK + +**3. Database Migrations**: +- Always backup database before migrations +- Test migrations on staging first +- Migrations are automatically run by Ansible during deployment + +**4. Zero-Downtime Deployments**: +- Ansible uses rolling updates for PHP-FPM workers +- Old containers remain until new containers are healthy +- Traefik automatically routes to healthy containers + +### Security + +**1. Secrets Management**: +- All secrets stored in Ansible Vault (encrypted) +- Production credentials never committed to Git +- Vault password stored securely (not in repository) + +**2. Docker Secrets**: +- Sensitive environment variables use Docker Secrets (`_FILE` suffix) +- Secrets mounted at `/run/secrets/` (tmpfs, never written to disk) + +**3. Network Isolation**: +- Services communicate via internal Docker networks +- Only Traefik exposes ports to public internet +- Database and Redis not publicly accessible + +**4. SSL/TLS**: +- All traffic encrypted via Traefik +- Let's Encrypt automatic certificate renewal +- HTTP to HTTPS redirection enforced + +--- + +## Future Improvements + +### Planned Enhancements + +**1. Blue-Green Deployments** (Q2 2025): +- Run two identical production environments +- Switch traffic between blue and green +- Instant rollback capability + +**2. Database Replication** (Q3 2025): +- PostgreSQL primary-replica setup +- Read replicas for scaling +- Automatic failover + +**3. Multi-Region Deployment** (Q4 2025): +- Deploy to multiple geographic regions +- DNS-based load balancing +- Regional failover + +**4. Enhanced Monitoring** (Q1 2025): +- APM integration (Application Performance Monitoring) +- Distributed tracing +- Real-time alerting via PagerDuty/Slack + +--- + +## Related Documentation + +**Comprehensive Guides**: +- `deployment/docs/guides/setup-guide.md` - Complete 8-phase setup walkthrough +- `deployment/ansible/README.md` - Ansible automation details +- `deployment/stacks/application/README.md` - Application stack deep-dive + +**Status & Progress**: +- `deployment/docs/status/deployment-summary.md` - Current deployment status (~95% complete) + +**Framework Documentation**: +- `docs/claude/architecture.md` - Framework architecture overview +- `docs/claude/development-commands.md` - Development tools and commands +- `docs/claude/common-workflows.md` - Standard development workflows + +--- + +## Quick Reference + +### Common Commands + +**Local Development**: +```bash +# Start local environment +docker compose -f docker-compose.base.yml -f docker-compose.local.yml up + +# Run migrations +docker exec php php console.php db:migrate + +# Run tests +./vendor/bin/pest +``` + +**Deployment (via Ansible)**: +```bash +# Deploy to production +cd deployment/ansible +ansible-playbook -i inventory/production playbooks/deploy-update.yml --ask-vault-pass + +# Rollback +ansible-playbook -i inventory/production playbooks/rollback.yml --ask-vault-pass --extra-vars "rollback_version=v1.2.3" + +# System maintenance +ansible-playbook -i inventory/production playbooks/system-maintenance.yml --ask-vault-pass +``` + +**Monitoring**: +```bash +# Check application health +curl -k https://app.michaelschiemer.com/health + +# View logs +ssh deploy@app.michaelschiemer.com +docker compose -f /opt/stacks/application/docker-compose.yml logs -f + +# Access Grafana (via WireGuard VPN) +https://grafana.michaelschiemer.com +``` + +--- + +**Last Updated**: 2025-01-28 +**Deployment Status**: 95% Complete (CI/CD pipeline configured, pending testing) +**Next Critical Step**: Test CI/CD pipeline end-to-end diff --git a/docs/console-dialog-mode.md b/docs/console-dialog-mode.md new file mode 100644 index 00000000..e28c3b7a --- /dev/null +++ b/docs/console-dialog-mode.md @@ -0,0 +1,472 @@ +# Console Dialog Mode + +## Übersicht + +Der Console Dialog Mode bietet eine einfache, textbasierte interaktive Schnittstelle fΓΌr die Console, Γ€hnlich einem AI-Assistenten. Im Gegensatz zur grafischen TUI (Text User Interface) verwendet der Dialog-Modus eine klassische Prompt-Eingabe mit Command-VorschlΓ€gen und History-Support. + +## Features + +### βœ… Hauptfunktionen + +- **Prompt-basierte Eingabe**: Einfache Text-Eingabe wie bei einem AI-Assistenten +- **Readline-Support**: Tab-Completion und History-Navigation (falls php-readline installiert) +- **Command-VorschlΓ€ge**: Automatische VorschlΓ€ge bei Tippfehlern +- **Kontextuelle Hilfe**: Hilfe wΓ€hrend der Eingabe +- **Command-History**: Nachverfolgung und Anzeige von verwendeten Commands +- **Einfache Ausgabe**: Keine komplexe Terminal-Manipulation + +### 🎯 Optional Features + +- **Tab-Completion**: Automatische VervollstΓ€ndigung von Commands +- **History-Navigation**: ↑/↓ Tasten fΓΌr Command-History (mit Readline) +- **Command-Suggestions**: Intelligente VorschlΓ€ge bei Tippfehlern (Levenshtein-Similarity) +- **Kontextuelle Hilfe**: Detaillierte Hilfe wΓ€hrend der Eingabe +- **Quoted Arguments**: UnterstΓΌtzung fΓΌr Argumente in AnfΓΌhrungszeichen + +## Verwendung + +### Dialog-Modus starten + +```bash +# Dialog-Modus explizit starten +php console.php --dialog + +# Alternative (Alias) +php console.php --chat +``` + +### Vergleich: TUI vs. Dialog-Modus + +| Feature | TUI (Standard) | Dialog-Modus | +|---------|----------------|--------------| +| Start | `php console.php` | `php console.php --dialog` | +| Interface | Grafische Navigation | Prompt-Eingabe | +| Navigation | Maus + Tastatur | Nur Tastatur | +| Completion | Limited | Tab-Completion | +| History | βœ… | βœ… (mit Readline) | +| Suggestions | βœ… | βœ… (intelligent) | +| Terminal-Anforderungen | Kompatibles Terminal | Funktioniert ΓΌberall | + +### Standard-Verhalten + +Ohne Argumente startet die Console standardmÀßig die **TUI**: + +```bash +php console.php # Startet TUI +php console.php --interactive # Startet TUI explizit +php console.php --tui # Startet TUI explizit +php console.php -i # Startet TUI explizit +``` + +## Built-in Commands + +Der Dialog-Modus bietet mehrere Built-in Commands: + +### `help` - Hilfe anzeigen + +Zeigt alle verfΓΌgbaren Commands gruppiert nach Kategorien: + +```bash +console> help +console> ? +console> :help +console> h +``` + +### `help ` - Detaillierte Hilfe + +Zeigt detaillierte Hilfe fΓΌr einen spezifischen Command: + +```bash +console> help db:migrate +console> help user:create +``` + +### `history` - Command-History + +Zeigt die letzten verwendeten Commands: + +```bash +console> history +console> :history +``` + +### `clear` - Bildschirm lΓΆschen + +LΓΆscht den Bildschirm: + +```bash +console> clear +console> :clear +``` + +### `exit` / `quit` - Beenden + +Beendet den Dialog-Modus: + +```bash +console> exit +console> quit +console> q +console> :q +``` + +## Command-AusfΓΌhrung + +### Basis-Commands + +Commands werden direkt eingegeben: + +```bash +console> db:migrate +console> user:list +console> cache:clear +``` + +### Commands mit Argumenten + +Argumente werden nach dem Command angegeben: + +```bash +console> user:create alice@example.com 25 +console> db:migrate --force +``` + +### Quoted Arguments + +Argumente mit Leerzeichen kΓΆnnen in AnfΓΌhrungszeichen gesetzt werden: + +```bash +console> user:create "John Doe" "john@example.com" +console> deploy "production environment" +``` + +## Readline-Support + +### Installation + +FΓΌr optimale Erfahrung sollte die `php-readline` Extension installiert sein: + +```bash +# Ubuntu/Debian +sudo apt-get install php-readline + +# macOS (Homebrew) +brew install php-readline + +# Alpine +apk add php-readline +``` + +### Features mit Readline + +Wenn Readline verfΓΌgbar ist, werden folgende Features aktiviert: + +- **Tab-Completion**: Automatische VervollstΓ€ndigung von Commands +- **History-Navigation**: ↑/↓ Tasten fΓΌr Command-History +- **History-Persistenz**: History wird zwischen Sessions gespeichert + +### Ohne Readline + +Wenn Readline nicht verfΓΌgbar ist, funktioniert der Dialog-Modus weiterhin, aber ohne Tab-Completion und History-Navigation. Die History wird trotzdem gespeichert. + +## Command-VorschlΓ€ge + +### Automatische VorschlΓ€ge + +Bei Tippfehlern werden automatisch Γ€hnliche Commands vorgeschlagen: + +```bash +console> db:migrat +Command 'db:migrat' not found. + +Did you mean one of these? + β€’ db:migrate (95% match - prefix match) + β€’ db:migration:status (45% match - fuzzy match) +``` + +### Vorschlags-Algorithmus + +Die VorschlΓ€ge basieren auf: + +1. **Exact Match**: 100% Γ„hnlichkeit +2. **Prefix Match**: Command beginnt mit Eingabe (90% Γ„hnlichkeit) +3. **Contains Match**: Command enthΓ€lt Eingabe (70% Γ„hnlichkeit) +4. **Levenshtein Distance**: Edit-Distanz-basierte Γ„hnlichkeit +5. **Word Similarity**: Wort-basierte Γ„hnlichkeit fΓΌr Commands mit Doppelpunkt + +### History-basierte VorschlΓ€ge + +Commands aus der History haben PrioritΓ€t: + +- **Favorites**: Commands, die als Favoriten markiert sind +- **Recent**: KΓΌrzlich verwendete Commands +- **Frequency**: HΓ€ufig verwendete Commands + +## Kontextuelle Hilfe + +### WΓ€hrend der Eingabe + +Nach fehlgeschlagenen Commands wird kontextuelle Hilfe angezeigt: + +```bash +console> db:migrate +Executing: db:migrate +──────────────────────────────────────────────────────────── +βœ— Command failed with exit code: 1 +──────────────────────────────────────────────────────────── + +πŸ’‘ Tip: Use "help db:migrate" for detailed help. +Description: Run database migrations +``` + +### Detaillierte Hilfe + +Die detaillierte Hilfe zeigt: + +- Command-Name und Beschreibung +- Usage-Beispiele +- Parameter-Dokumentation +- Optionen und Flags +- Beispiele + +```bash +console> help db:migrate + +πŸ“– Command Help: db:migrate +════════════════════════════════════════════════════════════ + +Command: db:migrate +Description: Run database migrations + +Usage: + php console.php db:migrate [options] + +Options: + --force Force migration execution + --pretend Show SQL without executing + +Examples: + php console.php db:migrate + php console.php db:migrate --force + php console.php db:migrate --pretend +``` + +## Command-History + +### History-Verwaltung + +Die History wird automatisch verwaltet: + +- **Automatische Speicherung**: Jeder ausgefΓΌhrte Command wird gespeichert +- **Persistenz**: History wird zwischen Sessions gespeichert +- **Deduplizierung**: Doppelte Commands werden aktualisiert, nicht dupliziert +- **Limit**: StandardmÀßig werden 100 Commands gespeichert + +### History anzeigen + +```bash +console> history + +πŸ“œ Command History +════════════════════════════════════════════════════════════ + + 1. db:migrate (used 5 times, last: 2024-01-15 10:30:45) + 2. user:create (used 3 times, last: 2024-01-15 09:15:22) + 3. cache:clear (used 2 times, last: 2024-01-14 16:45:10) +``` + +### History-Navigation (mit Readline) + +Mit Readline kΓΆnnen Sie durch die History navigieren: + +- **↑**: Vorheriger Command +- **↓**: NΓ€chster Command +- **Tab**: Command-VervollstΓ€ndigung + +## Workflow-Beispiel + +```bash +$ php console.php --dialog + +πŸ€– Console Dialog Mode +════════════════════════════════════════════════════════════ + +Available commands: 42 +βœ“ Readline support enabled (Tab completion, ↑/↓ history) + +Type "help" for available commands or "exit" to quit. + +console> help +πŸ“š Available Commands +════════════════════════════════════════════════════════════ + +Database: + db:migrate + Run database migrations + db:seed + Seed database with test data + +User: + user:create + Create a new user + user:list + List all users + +console> user:create alice@example.com 25 +Executing: user:create +Arguments: alice@example.com 25 +──────────────────────────────────────────────────────────── +βœ“ Command completed successfully +──────────────────────────────────────────────────────────── + +console> help user:create +πŸ“– Command Help: user:create +════════════════════════════════════════════════════════════ + +Command: user:create +Description: Create a new user + +Usage: + php console.php user:create [age] [--admin] + +Parameters: + email (string, required) - User email address + age (int, optional, default: 18) - User age + --admin (bool, optional) - Create as admin user + +console> history +πŸ“œ Command History +════════════════════════════════════════════════════════════ + + 1. user:create (used 1 times, last: 2024-01-15 10:30:45) + 2. help (used 2 times, last: 2024-01-15 10:30:30) + +console> exit +Goodbye! πŸ‘‹ +``` + +## Tipps & Best Practices + +### Tab-Completion nutzen + +Verwenden Sie Tab fΓΌr Command-VervollstΓ€ndigung: + +```bash +console> db:mi # VervollstΓ€ndigt zu db:migrate +console> user:cr # VervollstΓ€ndigt zu user:create +``` + +### History effizient nutzen + +- **↑/↓**: Navigieren Sie durch die History +- **History-Command**: Zeigen Sie alle Commands an +- **Readline**: Nutzen Sie Readline fΓΌr bessere Erfahrung + +### Hilfe verwenden + +- **`help`**: Zeigt alle Commands +- **`help `**: Detaillierte Hilfe fΓΌr einen Command +- **Kontextuelle Hilfe**: Wird nach Fehlern automatisch angezeigt + +### Commands mit Argumenten + +- **Quoted Arguments**: Verwenden Sie AnfΓΌhrungszeichen fΓΌr Argumente mit Leerzeichen +- **Flags**: Verwenden Sie `--flag` fΓΌr boolesche Optionen +- **Positional Arguments**: Argumente werden in der Reihenfolge der Methoden-Parameter ΓΌbergeben + +## Troubleshooting + +### Readline nicht verfΓΌgbar + +Wenn Readline nicht verfΓΌgbar ist, sehen Sie: + +``` +⚠ Readline not available (install php-readline for better experience) +``` + +**LΓΆsung**: Installieren Sie die `php-readline` Extension. + +### Commands nicht gefunden + +Wenn ein Command nicht gefunden wird, werden VorschlΓ€ge angezeigt: + +```bash +console> db:migrat +Command 'db:migrat' not found. + +Did you mean one of these? + β€’ db:migrate (95% match) +``` + +### History funktioniert nicht + +- **Ohne Readline**: History-Navigation (↑/↓) funktioniert nicht, aber History wird gespeichert +- **Mit Readline**: History-Navigation funktioniert automatisch + +### Hilfe generiert Fehler + +Wenn die Hilfe-Generierung fehlschlΓ€gt, wird eine Fallback-Ausgabe angezeigt: + +```bash +console> help invalid:command +Command 'invalid:command' not found. + +Did you mean one of these? + β€’ valid:command (85% match) +``` + +## Architektur + +### Komponenten + +- **`ConsoleDialog`**: Haupt-Orchestrator fΓΌr den Dialog-Modus +- **`DialogCommandExecutor`**: Command-Execution fΓΌr Dialog-Modus +- **`CommandHistory`**: History-Verwaltung (geteilt mit TUI) +- **`CommandSuggestionEngine`**: Vorschlags-Engine (geteilt mit TUI) +- **`CommandHelpGenerator`**: Hilfe-Generator (geteilt mit TUI) + +### Integration + +Der Dialog-Modus ist vollstΓ€ndig in `ConsoleApplication` integriert: + +```php +// In ConsoleApplication::run() +if (in_array($commandName, ['--dialog', '--chat'])) { + return $this->launchDialogMode(); +} +``` + +### Wiederverwendung + +Der Dialog-Modus nutzt die gleichen Komponenten wie die TUI: + +- **CommandRegistry**: Command-Discovery und -Execution +- **CommandGroupRegistry**: Command-Kategorisierung +- **CommandHistory**: History-Verwaltung +- **CommandSuggestionEngine**: Vorschlags-Engine + +## Unterschiede zur TUI + +| Aspekt | TUI | Dialog-Modus | +|--------|-----|--------------| +| **Interface** | Grafisch mit Navigation | Prompt-basiert | +| **Terminal-Anforderungen** | Kompatibles Terminal erforderlich | Funktioniert ΓΌberall | +| **Maus-Support** | βœ… | ❌ | +| **Tab-Completion** | Limited | βœ… (mit Readline) | +| **History-Navigation** | Limited | βœ… (mit Readline) | +| **Command-Suggestions** | βœ… | βœ… (intelligenter) | +| **Einfachheit** | Komplexer | Einfacher | +| **Best fΓΌr** | Visuelle Navigation | Schnelle Eingabe | + +## Zusammenfassung + +Der Dialog-Modus bietet eine einfache, effiziente Alternative zur TUI fΓΌr Benutzer, die: + +- Eine klassische Prompt-Eingabe bevorzugen +- Schnelle Command-AusfΓΌhrung ohne Navigation benΓΆtigen +- Tab-Completion und History-Navigation nutzen mΓΆchten +- In Terminals ohne TUI-Support arbeiten + +**Start**: `php console.php --dialog` oder `php console.php --chat` + diff --git a/docs/deployment/WIREGUARD-DNS-FIX-IMPLEMENTED.md b/docs/deployment/WIREGUARD-DNS-FIX-IMPLEMENTED.md new file mode 100644 index 00000000..5477a123 --- /dev/null +++ b/docs/deployment/WIREGUARD-DNS-FIX-IMPLEMENTED.md @@ -0,0 +1,283 @@ +# WireGuard DNS Fix - Implementation Status + +**Status**: βœ… Phase 1 COMPLETED - DNS Configuration Added +**Datum**: 2025-01-29 +**Implementiert**: DNS-Konfiguration in Ansible Variables + +## Was wurde geΓ€ndert? + +### 1. Ansible Group Variables Update + +**Datei**: `deployment/ansible/group_vars/production.yml` + +**Γ„nderung**: +```yaml +# WireGuard DNS Configuration +# DNS server for VPN clients (points to VPN server IP) +# This ensures internal services are resolved to VPN IPs +wireguard_dns_servers: + - "{{ wireguard_server_ip_default }}" +``` + +**Effekt**: +- Template `wireguard-client.conf.j2` wird jetzt `DNS = 10.8.0.1` in Client-Configs generieren +- Die `{% if wireguard_dns_servers | length > 0 %}` Bedingung im Template wird jetzt TRUE +- Alle neu generierten Client-Configs enthalten DNS-Konfiguration + +## Wie funktioniert es? + +### Template Logic (bereits vorhanden) +```jinja2 +{% if wireguard_dns_servers | length > 0 %} +# DNS servers provided via Ansible (optional) +DNS = {{ wireguard_dns_servers | join(', ') }} +{% endif %} +``` + +### Generated Client Config (nach Regenerierung) +```ini +[Interface] +PrivateKey = +Address = 10.8.0.7/24 +DNS = 10.8.0.1 # ← JETZT ENTHALTEN! + +[Peer] +PublicKey = +Endpoint = michaelschiemer.de:51820 +AllowedIPs = 10.8.0.0/24 +PersistentKeepalive = 25 +``` + +## Erwartetes Verhalten + +### DNS Resolution (Windows Client) +```powershell +# Nach Import der neuen Config: +Get-DnsClientServerAddress | Where-Object {$_.InterfaceAlias -like "*WireGuard*"} + +# Expected Output: +InterfaceAlias : WireGuard Tunnel wg0 +ServerAddresses : {10.8.0.1} # ← VPN DNS Server +``` + +### Service Resolution +```powershell +Resolve-DnsName grafana.michaelschiemer.de + +# Expected Output: +Name Type TTL Section IPAddress +---- ---- --- ------- --------- +grafana.michaelschiemer.de A 300 Answer 10.8.0.1 # ← VPN IP statt Public IP! +``` + +### HTTP Traffic Routing +```bash +# Traefik Access Log (Server-Side): +# VORHER (ohne DNS): +89.246.96.244 - - [Date] "GET /grafana HTTP/2.0" 404 + ↑ Public IP (FALSCH) + +# NACHHER (mit DNS): +10.8.0.5 - - [Date] "GET /grafana HTTP/2.0" 200 + ↑ VPN IP (KORREKT) +``` + +## NΓ€chste Schritte (PENDING) + +### Phase 2: Client Config Regenerierung + +**FΓΌr Windows Client "mikepc"**: +```bash +cd ~/dev/michaelschiemer/deployment/ansible + +ansible-playbook -i inventory/production.yml \ + playbooks/regenerate-wireguard-client.yml \ + -e "client_name=mikepc" \ + -e "client_ip=10.8.0.5" +``` + +**Output**: +- Backup: `mikepc.conf.backup-` +- Neue Config: `deployment/ansible/wireguard-clients/mikepc.conf` +- QR Code: `deployment/ansible/wireguard-clients/mikepc.png` + +### Phase 3: Docker Container Test (OPTIONAL) + +Teste VPN-FunktionalitΓ€t in isolierter Umgebung: +```bash +ansible-playbook -i inventory/production.yml \ + playbooks/test-wireguard-docker-container.yml \ + -e "client_name=mikepc" +``` + +**Verifizierung**: +```bash +# Ping Test +docker exec wireguard-test-mikepc ping -c 4 10.8.0.1 + +# DNS Test +docker exec wireguard-test-mikepc nslookup grafana.michaelschiemer.de 10.8.0.1 + +# HTTP Test +docker exec wireguard-test-mikepc curl -v https://grafana.michaelschiemer.de +``` + +### Phase 4: Windows Client Import + +1. **WireGuard Application ΓΆffnen** +2. **Tunnel "wg0" deaktivieren** (falls aktiv) +3. **Tunnel "wg0" lΓΆschen** (alte Config entfernen) +4. **Neue Config importieren**: + - "Add Tunnel" β†’ "Import from file" + - Datei: `deployment/ansible/wireguard-clients/mikepc.conf` +5. **Tunnel "wg0" aktivieren** + +### Phase 5: Verification (Windows) + +**DNS Check**: +```powershell +Get-DnsClientServerAddress | Where-Object {$_.InterfaceAlias -like "*WireGuard*"} +# Expected: ServerAddresses = {10.8.0.1} + +Resolve-DnsName grafana.michaelschiemer.de +# Expected: IPAddress = 10.8.0.1 +``` + +**Browser Test**: +``` +https://grafana.michaelschiemer.de +Expected: Grafana Dashboard OHNE 404 Error +``` + +**Server-Side Verification**: +```bash +# Traefik Access Log +ssh deploy@michaelschiemer.de +docker logs traefik --tail 50 | grep grafana + +# Expected: +# 10.8.0.5 - - [Date] "GET /grafana HTTP/2.0" 200 +# ↑ VPN IP statt Public IP! +``` + +## Troubleshooting + +### Problem: DNS Still Not Working + +**Check 1: Verify Config Contains DNS Line** +```powershell +Get-Content "C:\Path\To\mikepc.conf" | Select-String -Pattern "DNS" + +# Expected: +DNS = 10.8.0.1 +``` + +**Check 2: Verify Windows Uses VPN DNS** +```powershell +Get-DnsClientServerAddress | Format-Table InterfaceAlias, ServerAddresses + +# WireGuard Interface should show 10.8.0.1 +``` + +**Check 3: Flush DNS Cache** +```powershell +ipconfig /flushdns +Clear-DnsClientCache +``` + +### Problem: VPN Connects But Still Uses Public IP + +**Check 1: Verify Routes** +```powershell +Get-NetRoute | Where-Object {$_.DestinationPrefix -eq "10.8.0.0/24"} + +# Should exist with WireGuard interface +``` + +**Check 2: Test DNS Resolution** +```powershell +Resolve-DnsName grafana.michaelschiemer.de -Server 10.8.0.1 + +# Direct query to VPN DNS should work +``` + +### Problem: Cannot Reach grafana.michaelschiemer.de + +**Check 1: CoreDNS on Server** +```bash +ssh deploy@michaelschiemer.de +docker ps | grep coredns +docker logs coredns +``` + +**Check 2: Traefik Configuration** +```bash +docker logs traefik | grep grafana +# Check for middleware configuration +``` + +## Rollback Plan + +Falls Probleme auftreten: + +### Rollback Client Config +```bash +# Restore backup on server +ssh deploy@michaelschiemer.de +cd /etc/wireguard/clients +cp mikepc.conf.backup- mikepc.conf + +# Re-import on Windows +``` + +### Rollback Ansible Variables +```bash +git diff deployment/ansible/group_vars/production.yml +git checkout deployment/ansible/group_vars/production.yml +``` + +## Success Criteria + +βœ… **DNS Configuration Added**: Ansible variables updated +⏳ **Client Config Regenerated**: PENDING +⏳ **Windows Client Import**: PENDING +⏳ **DNS Resolution Working**: PENDING +⏳ **HTTP/HTTPS via VPN**: PENDING +⏳ **Traefik Shows VPN IP**: PENDING + +## Alternative Options (If DNS Fix Fails) + +### Option B: Full Tunnel VPN +```yaml +# AllowedIPs = 0.0.0.0/0 statt 10.8.0.0/24 +# Routes ALL traffic through VPN +``` + +### Option C: Alternative VPN Software +- OpenVPN (bewΓ€hrt, stabil) +- Tailscale (managed, einfach) +- ZeroTier (mesh network) + +## Referenzen + +- **Implementation Plan**: `WIREGUARD-IMPLEMENTATION-PLAN.md` +- **Original Analysis**: `WIREGUARD-WINDOWS-ROUTING-FINAL-ANALYSIS.md` +- **DNS Solution**: `WIREGUARD-WINDOWS-DNS-FIX.md` +- **Template**: `deployment/ansible/templates/wireguard-client.conf.j2` +- **Variables**: `deployment/ansible/group_vars/production.yml` + +## Notes + +**Warum DNS-Konfiguration fehlt**: +- Template hatte bereits UnterstΓΌtzung via `{% if wireguard_dns_servers | length > 0 %}` +- Variable `wireguard_dns_servers` fehlte in group_vars +- Jetzt gesetzt auf `["{{ wireguard_server_ip_default }}"]` β†’ `["10.8.0.1"]` + +**Erwarteter Effekt**: +- Alle neuen Client-Configs enthalten `DNS = 10.8.0.1` +- Windows nutzt VPN-DNS fΓΌr NamensauflΓΆsung +- Interne Services (grafana.michaelschiemer.de) werden zu VPN-IP (10.8.0.1) aufgelΓΆst +- HTTP/HTTPS Traffic geht ΓΌber VPN statt Public Interface + +**NΓ€chster kritischer Schritt**: +Client Config fΓΌr "mikepc" regenerieren und auf Windows importieren diff --git a/docs/deployment/WIREGUARD-IMPLEMENTATION-PLAN.md b/docs/deployment/WIREGUARD-IMPLEMENTATION-PLAN.md new file mode 100644 index 00000000..3b51bf3f --- /dev/null +++ b/docs/deployment/WIREGUARD-IMPLEMENTATION-PLAN.md @@ -0,0 +1,1175 @@ +# WireGuard VPN Implementation Plan + +**Ziel**: FunktionsfΓ€higes WireGuard VPN fΓΌr sicheren Zugriff auf interne Services (Grafana, Traefik Dashboard) + +**Status**: Analysephase abgeschlossen, Implementierungsplan erstellt + +**Datum**: 2025-11-04 + +--- + +## 1. Current State Assessment + +### Server Configuration +- **Host**: michaelschiemer.de (94.16.110.151) +- **Port**: 51820/udp +- **VPN Network**: 10.8.0.0/24 +- **Server IP (VPN)**: 10.8.0.1 +- **Server Public Key**: hT3OCWZ6ElX79YdAdexSsZnbWLzRM/5szk+XNEBUaS8= + +### Existing Infrastructure +βœ… **Ansible Automation** (vollstΓ€ndig vorhanden): +- `setup-wireguard.yml` - Server Installation +- `add-wireguard-client.yml` - Client Creation mit Auto-IP +- `regenerate-wireguard-client.yml` - Client Regeneration +- `test-wireguard-docker-container.yml` - Docker Testing + +βœ… **Documentation** (umfassend): +- WIREGUARD-SETUP.md - Komplette Setup-Dokumentation +- WIREGUARD-WINDOWS-ROUTING-FINAL-ANALYSIS.md - Windows Routing Problem +- WIREGUARD-WINDOWS-DNS-FIX.md - DNS Konfiguration +- WIREGUARD-RECREATE-AND-TEST.md - Troubleshooting Strategy + +βœ… **Existing Clients** (4 Konfigurationen): +- `grafana-latest.conf` +- `test-client.conf` +- `mikepc.conf` +- `grafana-test.conf` + +### Protected Services +- **Traefik Dashboard**: VPN-only + BasicAuth +- **Grafana**: VPN-only middleware +- **Main App**: Bleibt ΓΆffentlich (michaelschiemer.de) + +--- + +## 2. Root Cause Analysis + +### Primary Issue: Windows Routing Problem + +**Symptome**: +- WireGuard verbindet erfolgreich βœ… +- Route vorhanden: `10.8.0.0/24` βœ… +- Ping zu 10.8.0.1 funktioniert βœ… +- **ABER**: HTTP/HTTPS Traffic kommt von Public IP (89.246.96.244) ❌ +- **Erwartet**: Traffic sollte von VPN IP (10.8.0.7) kommen ❌ + +**Diagnose** (aus WIREGUARD-WINDOWS-ROUTING-FINAL-ANALYSIS.md): + +``` +Traefik Access Log zeigt: +89.246.96.244 - - [Date] "GET /grafana HTTP/2.0" 404 + ↑ Public IP statt 10.8.0.7 +``` + +**Root Causes** (Priorisiert): + +1. **DNS fehlt in Client Config** (HΓΆchste Wahrscheinlichkeit): + - Windows nutzt Standard-DNS statt VPN-DNS + - AuflΓΆsung von `grafana.michaelschiemer.de` geht ΓΌber Public DNS + - Traffic wird entsprechend ΓΌber Public Interface geroutet + +2. **Windows Interface Metric Prioritization**: + - Standard-Netzwerk hat niedrigeren Metric (hΓΆhere PrioritΓ€t) + - WireGuard-Interface wird fΓΌr HTTP/HTTPS-Traffic ignoriert + +3. **Split Tunneling Configuration**: + - `AllowedIPs = 10.8.0.0/24` limitiert VPN auf internes Netz + - Windows kΓΆnnte HTTP/HTTPS als "nicht-VPN" klassifizieren + +**Versuchte LΓΆsungen** (bisher erfolglos): +- ❌ Interface Metric Adjustment: `Set-NetIPInterface -InterfaceMetric 1` +- ❌ Explicit Route mit Gateway: `route add 10.8.0.0 ... IF 18` +- ❌ Windows Firewall Check +- ❌ WireGuard Reinstallation + +**Fehlende LΓΆsung**: DNS Configuration in Client Config + +--- + +## 3. Implementation Strategy + +### **OPTION A: DNS Fix (EMPFOHLEN)** 🎯 + +**Warum diese Option**: +- Einfachste LΓΆsung (eine Zeile in Config) +- Dokumentiert in WIREGUARD-WINDOWS-DNS-FIX.md +- Adressiert wahrscheinlichste Root Cause +- Keine Server-Γ„nderungen nΓΆtig +- Geringe Auswirkung auf existierende Clients + +**Implementierung**: + +#### Schritt 1: Server Status verifizieren + +```bash +# SSH zum Server +ssh deploy@michaelschiemer.de + +# WireGuard Service prΓΌfen +sudo systemctl status wg-quick@wg0 + +# Erwartete Ausgabe: +# ● wg-quick@wg0.service - WireGuard via wg-quick(8) for wg0 +# Loaded: loaded +# Active: active (exited) + +# Interface prΓΌfen +sudo wg show + +# Erwartete Ausgabe: +# interface: wg0 +# public key: hT3OCWZ6ElX79YdAdexSsZnbWLzRM/5szk+XNEBUaS8= +# private key: (hidden) +# listening port: 51820 +``` + +#### Schritt 2: Client Config mit DNS regenerieren + +**Ansible Template Update** (`wireguard-client.conf.j2`): + +```ini +[Interface] +PrivateKey = {{ client_private_key.stdout }} +Address = {{ client_ip }}/24 +DNS = 10.8.0.1 # ← NEU: VPN-DNS Server + +[Peer] +PublicKey = {{ server_public_key_cmd.stdout }} +Endpoint = {{ server_external_ip_content }}:{{ wireguard_port }} +AllowedIPs = {{ allowed_ips }} +PersistentKeepalive = 25 +``` + +**Playbook AusfΓΌhren**: + +```bash +cd /home/michael/dev/michaelschiemer/deployment/ansible + +# Client Config regenerieren (mit DNS) +ansible-playbook -i inventory/production.yml \ + playbooks/regenerate-wireguard-client.yml \ + -e "client_name=mikepc" \ + -e "client_ip=10.8.0.5" +``` + +#### Schritt 3: Mit Docker Container testen + +```bash +# Container-Test fΓΌr Baseline +ansible-playbook -i inventory/production.yml \ + playbooks/test-wireguard-docker-container.yml \ + -e "client_name=mikepc" + +# Erwartete Ausgabe: +# βœ“ Container gestartet +# βœ“ WireGuard verbunden +# βœ“ Ping zu 10.8.0.1 erfolgreich +# βœ“ HTTP Request zu Grafana zeigt VPN IP im Log +``` + +#### Schritt 4: Windows Client Setup + +**Config herunterladen**: +```bash +# Config wird automatisch heruntergeladen nach: +# /home/michael/dev/michaelschiemer/deployment/ansible/wireguard-clients/mikepc.conf +``` + +**Windows Installation**: +1. WireGuard GUI ΓΆffnen +2. "Import tunnel(s) from file" +3. `mikepc.conf` auswΓ€hlen +4. Tunnel aktivieren + +**Verification (PowerShell als Admin)**: + +```powershell +# 1. DNS Check - WICHTIG! +Get-DnsClientServerAddress | Select-Object InterfaceAlias, ServerAddresses + +# Erwartete Ausgabe: +# InterfaceAlias ServerAddresses +# -------------- --------------- +# WireGuard Tunnel {10.8.0.1} ← VPN DNS! + +# 2. DNS Resolution Test +Resolve-DnsName grafana.michaelschiemer.de | Select-Object Name, IPAddress + +# Erwartete Ausgabe: +# Name IPAddress +# ---- --------- +# grafana.michaelschiemer.de 10.8.0.1 ← VPN IP, NICHT Public IP! + +# 3. Ping Test +ping 10.8.0.1 + +# Erwartete Ausgabe: +# Reply from 10.8.0.1: bytes=32 time=25ms TTL=64 + +# 4. HTTP Test (via Browser) +# https://grafana.michaelschiemer.de +# Sollte funktionieren und Traefik Log sollte 10.8.0.5 zeigen +``` + +#### Schritt 5: Traefik Log Verification + +```bash +# SSH zum Server +ssh deploy@michaelschiemer.de + +# Traefik Access Log live anzeigen +docker logs -f traefik 2>&1 | grep grafana + +# VORHER (falsch): +# 89.246.96.244 - - [Date] "GET /grafana HTTP/2.0" 404 + +# NACHHER (korrekt): +# 10.8.0.5 - - [Date] "GET /grafana HTTP/2.0" 200 +# ↑ VPN IP des Windows Client! +``` + +**SUCCESS CRITERIA**: +- βœ… DNS resolves `grafana.michaelschiemer.de` zu `10.8.0.1` +- βœ… Traefik Log zeigt VPN IP (`10.8.0.5`) statt Public IP +- βœ… Grafana Dashboard erreichbar ohne 404 +- βœ… Ping zu 10.8.0.1 erfolgreich + +--- + +### **OPTION B: Full Tunnel VPN (Fallback)** + +**Nur verwenden wenn Option A fehlschlΓ€gt!** + +**Γ„nderungen**: +```ini +[Peer] +AllowedIPs = 0.0.0.0/0 # ← Γ„ndere von 10.8.0.0/24 +# Routet ALLE Traffic durch VPN +``` + +**Vorteile**: +- Erzwingt VPN-Routing fΓΌr alle Verbindungen +- Umgeht Windows Routing-Probleme + +**Nachteile**: +- ❌ Alle Traffic ΓΌber VPN (Performance-Impact) +- ❌ Lokales Netzwerk (Drucker, etc.) nicht erreichbar +- ❌ SSH zu Server ΓΌber Public IP funktioniert nicht mehr +- ❌ Komplexere Firewall-Konfiguration nΓΆtig + +**Implementierung** (nur wenn nΓΆtig): +1. Template `wireguard-client.conf.j2` Γ€ndern +2. Server-Firewall fΓΌr NAT konfigurieren +3. Client regenerieren und testen + +--- + +### **OPTION C: Alternative VPN Software (Letzte Option)** + +**Nur wenn Option A und B fehlschlagen!** + +**Alternativen**: +- **OpenVPN**: Mature, Windows-freundlich, mehr Overhead +- **Tailscale**: Mesh VPN, einfacher Setup, closed source +- **ZeroTier**: Γ„hnlich zu Tailscale, eigenes Netzwerk-Paradigma + +**Nicht empfohlen weil**: +- WireGuard ist moderner und schneller +- Existing Infrastructure ist gut aufgebaut +- Problem ist wahrscheinlich nur DNS-Konfiguration +- Ansible Automation mΓΌsste neu geschrieben werden + +--- + +## 4. Step-by-Step Implementation Guide + +### Phase 1: Preparation (5 Minuten) + +```bash +# 1. Zum Ansible Verzeichnis navigieren +cd /home/michael/dev/michaelschiemer/deployment/ansible + +# 2. Inventory prΓΌfen +cat inventory/production.yml + +# 3. Ansible Connectivity Test +ansible -i inventory/production.yml production -m ping + +# Erwartete Ausgabe: +# michaelschiemer.de | SUCCESS => { +# "changed": false, +# "ping": "pong" +# } +``` + +### Phase 2: Template Update (2 Minuten) + +```bash +# DNS Zeile zu Client Template hinzufΓΌgen +# templates/wireguard-client.conf.j2 + +# Γ–ffne Template: +nano templates/wireguard-client.conf.j2 + +# FΓΌge unter [Interface] hinzu: +# DNS = {{ wireguard_server_ip }} +``` + +**Template-Inhalt** (complete): +```ini +[Interface] +PrivateKey = {{ client_private_key.stdout }} +Address = {{ client_ip }}/24 +DNS = {{ wireguard_server_ip }} # ← HINZUFÜGEN + +[Peer] +PublicKey = {{ server_public_key_cmd.stdout }} +Endpoint = {{ server_external_ip_content }}:{{ wireguard_port }} +AllowedIPs = {{ allowed_ips }} +PersistentKeepalive = 25 +``` + +### Phase 3: Client Regeneration (3 Minuten) + +```bash +# Windows Client neu generieren +ansible-playbook -i inventory/production.yml \ + playbooks/regenerate-wireguard-client.yml \ + -e "client_name=mikepc" \ + -e "client_ip=10.8.0.5" + +# Config wird automatisch heruntergeladen nach: +# wireguard-clients/mikepc.conf + +# Verify DNS in Config: +cat wireguard-clients/mikepc.conf | grep DNS + +# Erwartete Ausgabe: +# DNS = 10.8.0.1 +``` + +### Phase 4: Docker Container Test (5 Minuten) + +```bash +# Container-Test durchfΓΌhren +ansible-playbook -i inventory/production.yml \ + playbooks/test-wireguard-docker-container.yml \ + -e "client_name=mikepc" + +# Playbook fΓΌhrt aus: +# 1. Container Start mit WireGuard +# 2. Ping zu 10.8.0.1 +# 3. HTTP Test zu Grafana +# 4. Log Verification + +# Check Container Logs: +docker logs wireguard-test-mikepc + +# Erwartete Ausgabe: +# [cont-init.d] 10-adduser: exited 0. +# [cont-init.d] 30-config: executing... +# [cont-init.d] 30-config: exited 0. +# [cont-init.d] 99-custom-scripts: executing... +# [cont-init.d] done. +# [services.d] starting services +# [services.d] done. +``` + +### Phase 5: Windows Client Import (3 Minuten) + +**Schritte**: +1. Alte WireGuard Konfiguration entfernen: + - WireGuard GUI ΓΆffnen + - Rechtsklick auf "mikepc" Tunnel + - "Remove" auswΓ€hlen + +2. Neue Konfiguration importieren: + - "Import tunnel(s) from file" + - Navigation zu: `\\wsl$\Ubuntu\home\michael\dev\michaelschiemer\deployment\ansible\wireguard-clients\mikepc.conf` + - Import bestΓ€tigen + +3. Tunnel aktivieren: + - Klick auf "Activate" + +### Phase 6: Verification (5 Minuten) + +**PowerShell Tests** (als Administrator): + +```powershell +# 1. Interface Check +Get-NetAdapter | Where-Object {$_.InterfaceDescription -like "*WireGuard*"} + +# Erwartete Ausgabe: +# Name Status MacAddress LinkSpeed +# ---- ------ ---------- --------- +# WireGuard Up 00-00-00-00-00 Gbps + +# 2. DNS Configuration Check +Get-DnsClientServerAddress | Where-Object {$_.InterfaceAlias -like "*WireGuard*"} + +# Erwartete Ausgabe: +# InterfaceAlias ServerAddresses +# -------------- --------------- +# WireGuard {10.8.0.1} + +# 3. DNS Resolution Test +Resolve-DnsName grafana.michaelschiemer.de + +# Erwartete Ausgabe: +# Name IPAddress +# ---- --------- +# grafana.michaelschiemer.de 10.8.0.1 + +# 4. Connectivity Test +Test-NetConnection 10.8.0.1 -Port 443 + +# Erwartete Ausgabe: +# ComputerName : 10.8.0.1 +# RemoteAddress : 10.8.0.1 +# RemotePort : 443 +# TcpTestSucceeded : True + +# 5. Traefik Dashboard Test (Browser) +# https://traefik.michaelschiemer.de +# Username: admin +# Password: [aus .env] +``` + +**Server-Side Verification**: + +```bash +# SSH zum Server +ssh deploy@michaelschiemer.de + +# 1. WireGuard Status +sudo wg show + +# Erwartete Ausgabe: +# interface: wg0 +# public key: hT3OCWZ6ElX79YdAdexSsZnbWLzRM/5szk+XNEBUaS8= +# private key: (hidden) +# listening port: 51820 +# +# peer: [mikepc public key] +# endpoint: 89.246.96.244:xxxxx +# allowed ips: 10.8.0.5/32 +# latest handshake: X seconds ago +# transfer: X.XX GiB received, X.XX GiB sent + +# 2. Traefik Access Log (live) +docker logs -f traefik 2>&1 | grep -E "10\.8\.0\.[0-9]+" + +# Erwartete Ausgabe: +# 10.8.0.5 - - [Date] "GET /grafana HTTP/2.0" 200 ... +# ↑ VPN IP sichtbar! + +# 3. CoreDNS Logs (falls DNS problematisch) +docker logs coredns 2>&1 | tail -20 +``` + +### Phase 7: Troubleshooting (falls nΓΆtig) + +**Problem: DNS nicht gesetzt** + +```powershell +# Manual DNS Override +Set-DnsClientServerAddress -InterfaceAlias "WireGuard" -ServerAddresses "10.8.0.1" + +# Verify +Get-DnsClientServerAddress | Where-Object {$_.InterfaceAlias -like "*WireGuard*"} +``` + +**Problem: Route nicht aktiv** + +```powershell +# Check Routing Table +route print | findstr "10.8.0.0" + +# Erwartete Ausgabe: +# 10.8.0.0 255.255.255.0 On-link 10.8.0.5 281 + +# Falls fehlt, manuell hinzufΓΌgen: +route add 10.8.0.0 MASK 255.255.255.0 10.8.0.1 IF [Interface-Index] +``` + +**Problem: Traffic kommt weiterhin von Public IP** + +```bash +# Server: tcpdump auf WireGuard Interface +ssh deploy@michaelschiemer.de +sudo tcpdump -i wg0 -n + +# Erwartete Ausgabe beim Browser-Zugriff: +# IP 10.8.0.5.xxxxx > 10.8.0.1.443: Flags [S], seq ... +# ↑ Client IP ↑ Server IP +``` + +--- + +## 5. Testing Checklist + +### βœ… Server-Side Tests + +```bash +# Test 1: Service Running +sudo systemctl status wg-quick@wg0 +# Expected: active (exited) + +# Test 2: Interface Up +ip addr show wg0 +# Expected: inet 10.8.0.1/24 scope global wg0 + +# Test 3: Firewall Rule +sudo ufw status | grep 51820 +# Expected: 51820/udp ALLOW Anywhere + +# Test 4: Peer Connected +sudo wg show +# Expected: peer: [client public key] +# latest handshake: < 3 minutes ago + +# Test 5: IP Forwarding Enabled +sysctl net.ipv4.ip_forward +# Expected: net.ipv4.ip_forward = 1 + +# Test 6: NAT Masquerading Active +sudo iptables -t nat -L POSTROUTING -n -v +# Expected: MASQUERADE all -- 0.0.0.0/0 0.0.0.0/0 +``` + +### βœ… Client-Side Tests (Windows) + +```powershell +# Test 1: WireGuard Connected +Get-NetAdapter | Where-Object {$_.InterfaceDescription -like "*WireGuard*"} +# Expected: Status = Up + +# Test 2: DNS Configured +Get-DnsClientServerAddress -InterfaceAlias "WireGuard" +# Expected: ServerAddresses = {10.8.0.1} + +# Test 3: Ping VPN Gateway +ping 10.8.0.1 +# Expected: Reply from 10.8.0.1: bytes=32 time<50ms + +# Test 4: DNS Resolution +Resolve-DnsName grafana.michaelschiemer.de +# Expected: IPAddress = 10.8.0.1 + +# Test 5: HTTPS Connectivity +Test-NetConnection 10.8.0.1 -Port 443 +# Expected: TcpTestSucceeded = True + +# Test 6: Browser Access +# https://grafana.michaelschiemer.de +# Expected: Grafana Dashboard loads (no 404) + +# Test 7: Traefik Dashboard Access +# https://traefik.michaelschiemer.de +# Expected: Traefik Dashboard loads (BasicAuth prompt) +``` + +### βœ… Log Verification + +```bash +# Test 1: Traefik Access Log shows VPN IP +ssh deploy@michaelschiemer.de +docker logs traefik 2>&1 | grep -E "10\.8\.0\.[0-9]+" | tail -5 + +# Expected: +# 10.8.0.5 - - [Date] "GET /grafana HTTP/2.0" 200 ... + +# Test 2: CoreDNS Resolution Log +docker logs coredns 2>&1 | grep grafana | tail -5 + +# Expected: +# [INFO] 10.8.0.5:xxxxx - "A IN grafana.michaelschiemer.de udp 45 false 512" +``` + +--- + +## 6. Client Setup Instructions + +### Windows Client + +**Prerequisites**: +- Windows 10/11 +- Administrator Rechte +- WireGuard for Windows installiert + +**Setup**: +1. Config generieren lassen (siehe Phase 3) +2. Config importieren (siehe Phase 5) +3. Tunnel aktivieren +4. Tests durchfΓΌhren (siehe Phase 6) + +**Config Location**: `C:\Program Files\WireGuard\Data\Configurations\mikepc.conf` + +### Linux Client + +**Prerequisites**: +```bash +sudo apt update +sudo apt install wireguard wireguard-tools +``` + +**Setup**: +```bash +# 1. Config generieren +ansible-playbook -i inventory/production.yml \ + playbooks/add-wireguard-client.yml \ + -e "client_name=linux-laptop" \ + -e "client_ip=10.8.0.10" + +# 2. Config kopieren +sudo cp wireguard-clients/linux-laptop.conf /etc/wireguard/wg0.conf +sudo chmod 600 /etc/wireguard/wg0.conf + +# 3. Interface starten +sudo wg-quick up wg0 + +# 4. Bei Bedarf: Auto-Start bei Boot +sudo systemctl enable wg-quick@wg0 + +# 5. Status prΓΌfen +sudo wg show + +# 6. Test +ping 10.8.0.1 +curl -k https://grafana.michaelschiemer.de +``` + +### macOS Client + +**Prerequisites**: +```bash +brew install wireguard-tools +``` + +**Setup**: +1. WireGuard App aus App Store installieren +2. Config importieren (wie Windows) +3. Tunnel aktivieren + +### Android/iOS Client + +**Setup**: +1. WireGuard App installieren (Play Store/App Store) +2. QR Code scannen: + ```bash + # QR Code fΓΌr mobilen Import generieren + ansible-playbook -i inventory/production.yml \ + playbooks/add-wireguard-client.yml \ + -e "client_name=android-phone" \ + -e "client_ip=10.8.0.15" + + # QR Code wird automatisch generiert: + # wireguard-clients/android-phone.conf.png + ``` +3. In App: "Add Tunnel" β†’ "Create from QR Code" +4. Scannen und aktivieren + +--- + +## 7. Monitoring & Maintenance + +### Daily Checks + +```bash +# Server Health Check +ssh deploy@michaelschiemer.de 'sudo wg show | grep "latest handshake"' + +# Erwartete Ausgabe: +# latest handshake: 2 minutes 30 seconds ago +# latest handshake: 5 minutes 10 seconds ago +``` + +### Weekly Maintenance + +```bash +# 1. Check for security updates +ssh deploy@michaelschiemer.de 'sudo apt update && sudo apt list --upgradable | grep wireguard' + +# 2. Review Traefik Logs fΓΌr VPN Traffic +ssh deploy@michaelschiemer.de \ + 'docker logs traefik 2>&1 | grep -E "10\.8\.0\.[0-9]+" | wc -l' + +# 3. Unused Clients entfernen +# Liste aller Clients mit letztem Handshake +sudo wg show | grep -E "(peer|latest handshake)" +``` + +### Backup Strategy + +```bash +# 1. Server Config Backup +ssh deploy@michaelschiemer.de 'sudo cat /etc/wireguard/wg0.conf' > backup-wg0.conf-$(date +%F) + +# 2. Client Configs Backup +tar -czf wireguard-clients-backup-$(date +%F).tar.gz wireguard-clients/ + +# 3. Ansible Playbooks Backup (via git) +cd /home/michael/dev/michaelschiemer/deployment/ansible +git add playbooks/ templates/ group_vars/ +git commit -m "backup: WireGuard configuration $(date +%F)" +``` + +--- + +## 8. Troubleshooting Guide + +### Problem: Client verbindet nicht + +**Symptome**: +- WireGuard Status: "Disconnected" +- Keine Handshake im Server Log + +**Diagnose**: +```bash +# Server: Check Firewall +ssh deploy@michaelschiemer.de 'sudo ufw status | grep 51820' + +# Server: Check Service +ssh deploy@michaelschiemer.de 'sudo systemctl status wg-quick@wg0' + +# Client: Check Config Syntax +# Windows: C:\Program Files\WireGuard\log.txt +# Linux: journalctl -u wg-quick@wg0 +``` + +**LΓΆsungen**: +1. Firewall ΓΆffnen: `sudo ufw allow 51820/udp` +2. Service neu starten: `sudo systemctl restart wg-quick@wg0` +3. Config-Syntax prΓΌfen: `sudo wg-quick up wg0` (zeigt Fehler) + +### Problem: DNS Resolution schlΓ€gt fehl + +**Symptome**: +- Ping zu 10.8.0.1 funktioniert βœ… +- `Resolve-DnsName grafana.michaelschiemer.de` zeigt Public IP ❌ + +**Diagnose**: +```powershell +# Check DNS Server +Get-DnsClientServerAddress -InterfaceAlias "WireGuard" + +# Expected: ServerAddresses = {10.8.0.1} +# Actual: ServerAddresses = {} (leer) +``` + +**LΓΆsung**: +```powershell +# Manual DNS Set +Set-DnsClientServerAddress -InterfaceAlias "WireGuard" -ServerAddresses "10.8.0.1" + +# Persistent DNS (in Config): +# [Interface] +# DNS = 10.8.0.1 ← Diese Zeile muss vorhanden sein! +``` + +### Problem: Traefik zeigt weiterhin Public IP + +**Symptome**: +- WireGuard verbunden βœ… +- DNS resolved zu 10.8.0.1 βœ… +- Traefik Log zeigt: `89.246.96.244 - - "GET /grafana ..."` ❌ + +**Diagnose**: +```powershell +# Routing Table Check +route print | findstr "10.8.0.0" + +# Interface Metric Check +Get-NetIPInterface | Where-Object {$_.AddressFamily -eq "IPv4"} | Sort-Object InterfaceMetric +``` + +**LΓΆsung**: +```powershell +# Option 1: Interface Metric senken +$WgIndex = (Get-NetAdapter | Where-Object {$_.InterfaceDescription -like "*WireGuard*"}).ifIndex +Set-NetIPInterface -InterfaceIndex $WgIndex -InterfaceMetric 1 + +# Option 2: Explicit Route mit niedrigem Metric +route add 10.8.0.0 MASK 255.255.255.0 10.8.0.1 METRIC 1 IF $WgIndex + +# Verify +route print | findstr "10.8.0.0" +``` + +### Problem: Handshake schlΓ€gt fehl + +**Symptome**: +- Server Log: `latest handshake: never` +- Client bleibt auf "Connecting" + +**Diagnose**: +```bash +# Server: tcpdump auf Port 51820 +ssh deploy@michaelschiemer.de +sudo tcpdump -i any -n port 51820 + +# Erwartete Ausgabe: +# IP [client-public-ip].xxxxx > 94.16.110.151.51820: UDP +``` + +**LΓΆsungen**: +1. **Firewall Issue**: + ```bash + sudo ufw allow 51820/udp + sudo ufw reload + ``` + +2. **NAT Issue** (Client hinter Router): + - Router-Firewall prΓΌfen + - Port Forwarding nicht nΓΆtig (ausgehend) + - PersistentKeepalive erhΓΆhen: `PersistentKeepalive = 25` + +3. **Clock Skew Issue**: + ```bash + # Server: Zeit prΓΌfen + ssh deploy@michaelschiemer.de 'date' + + # Client: Zeit prΓΌfen + date # Linux/macOS + Get-Date # Windows + + # Bei Abweichung: NTP sync + sudo ntpdate pool.ntp.org + ``` + +### Problem: Peer-to-Peer nicht mΓΆglich + +**Symptome**: +- Ping zu 10.8.0.1 funktioniert βœ… +- Ping zu anderem Client (z.B. 10.8.0.10) schlΓ€gt fehl ❌ + +**Root Cause**: +- AllowedIPs limitiert auf Server IP nur + +**LΓΆsung**: +```ini +# Server Config Γ€ndern (/etc/wireguard/wg0.conf) +[Peer] +# For client 10.8.0.5 +PublicKey = [client-public-key] +AllowedIPs = 10.8.0.5/32, 10.8.0.0/24 # ← Network hinzufΓΌgen + +# Restart +sudo wg-quick down wg0 +sudo wg-quick up wg0 +``` + +--- + +## 9. Performance Optimization + +### Server-Side Tuning + +```bash +# /etc/sysctl.conf +net.ipv4.ip_forward = 1 +net.core.default_qdisc = fq +net.ipv4.tcp_congestion_control = bbr +net.core.rmem_max = 2500000 +net.core.wmem_max = 2500000 + +# Apply +sudo sysctl -p +``` + +### Client-Side Tuning (Windows) + +```powershell +# MTU Optimization +netsh interface ipv4 set subinterface "WireGuard" mtu=1420 store=persistent + +# Registry Tweaks (optional) +# HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters +# - TcpAckFrequency = 1 +# - TCPNoDelay = 1 +``` + +--- + +## 10. Security Hardening + +### Key Rotation Policy + +**Empfehlung**: Keys alle 6 Monate rotieren + +```bash +# 1. Neuen Client mit neuen Keys generieren +ansible-playbook -i inventory/production.yml \ + playbooks/regenerate-wireguard-client.yml \ + -e "client_name=mikepc" \ + -e "client_ip=10.8.0.5" + +# 2. Alte Config entfernen (nach Verification) +# Server: /etc/wireguard/wg0.conf - alten Peer-Block lΓΆschen + +# 3. Server neu starten +ssh deploy@michaelschiemer.de 'sudo wg-quick down wg0 && sudo wg-quick up wg0' +``` + +### Access Logging + +**Traefik Access Log fΓΌr VPN-Only Services**: + +```yaml +# docker-compose.yml (bereits konfiguriert) +services: + traefik: + command: + - --accesslog=true + - --accesslog.filepath=/var/log/traefik/access.log + - --accesslog.filters.statusCodes=200,404,403 +``` + +**Log Analysis**: + +```bash +# Daily Report: VPN Zugriffe +ssh deploy@michaelschiemer.de \ + 'docker exec traefik cat /var/log/traefik/access.log | grep -E "10\.8\.0\.[0-9]+" | tail -20' + +# Weekly Report: Failed Auth Attempts +ssh deploy@michaelschiemer.de \ + 'docker exec traefik cat /var/log/traefik/access.log | grep "401\|403" | wc -l' +``` + +### Fail2Ban Integration (Optional) + +```bash +# /etc/fail2ban/filter.d/wireguard.conf +[Definition] +failregex = Invalid handshake from +ignoreregex = + +# /etc/fail2ban/jail.local +[wireguard] +enabled = true +port = 51820 +protocol = udp +filter = wireguard +logpath = /var/log/syslog +maxretry = 5 +bantime = 3600 +``` + +--- + +## 11. Migration Path (Falls Option A fehlschlΓ€gt) + +### Plan B: Full Tunnel VPN + +**Nur wenn DNS-Fix nicht funktioniert!** + +**Changes Required**: + +1. **Client Config Template Update**: +```ini +[Peer] +AllowedIPs = 0.0.0.0/0, ::/0 # ← Γ„ndere von 10.8.0.0/24 +``` + +2. **Server NAT Configuration**: +```bash +# /etc/wireguard/wg0.conf +PostUp = iptables -A FORWARD -i %i -j ACCEPT; \ + iptables -A FORWARD -o %i -j ACCEPT; \ + iptables -t nat -A POSTROUTING -o ens3 -j MASQUERADE + +PostDown = iptables -D FORWARD -i %i -j ACCEPT; \ + iptables -D FORWARD -o %i -j ACCEPT; \ + iptables -t nat -D POSTROUTING -o ens3 -j MASQUERADE +``` + +3. **Split Tunneling Exceptions** (fΓΌr lokales Netzwerk): +```powershell +# Windows: Route fΓΌr lokales Netz außerhalb VPN +route add 192.168.0.0 MASK 255.255.255.0 192.168.0.1 METRIC 1 +``` + +**Trade-offs**: +- βœ… Pro: Forciert VPN fΓΌr alle Verbindungen +- ❌ Con: Performance-Impact (alle Traffic durch VPN) +- ❌ Con: Lokales Netzwerk (Drucker, NAS) nicht direkt erreichbar +- ❌ Con: SSH zu Server nur ΓΌber VPN mΓΆglich + +--- + +## 12. Next Steps + +### Immediate Actions (NΓ€chste 30 Minuten) + +1. βœ… **Template Update**: + ```bash + nano /home/michael/dev/michaelschiemer/deployment/ansible/templates/wireguard-client.conf.j2 + # DNS = {{ wireguard_server_ip }} hinzufΓΌgen + ``` + +2. βœ… **Client Regeneration**: + ```bash + cd /home/michael/dev/michaelschiemer/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/regenerate-wireguard-client.yml \ + -e "client_name=mikepc" \ + -e "client_ip=10.8.0.5" + ``` + +3. βœ… **Docker Test**: + ```bash + ansible-playbook -i inventory/production.yml \ + playbooks/test-wireguard-docker-container.yml \ + -e "client_name=mikepc" + ``` + +4. βœ… **Windows Import**: + - Config importieren + - Tunnel aktivieren + - Tests durchfΓΌhren + +### Short-Term (NΓ€chste Woche) + +1. βœ… Alle existierenden Clients mit DNS-Config aktualisieren +2. βœ… Monitoring Setup fΓΌr WireGuard Handshakes +3. βœ… Backup-Strategie implementieren +4. βœ… Dokumentation in Wiki ΓΌbertragen + +### Long-Term (NΓ€chster Monat) + +1. βœ… Key Rotation Policy dokumentieren und schedulen +2. βœ… Fail2Ban Integration evaluieren +3. βœ… Performance Tuning basierend auf Logs +4. βœ… Mobile Clients fΓΌr Admin Team einrichten + +--- + +## 13. Success Metrics + +### KPIs fΓΌr erfolgreiche Implementation + +| Metric | Target | Current | Status | +|--------|--------|---------|--------| +| VPN Handshake Success Rate | >99% | TBD | 🟑 | +| DNS Resolution zu VPN IP | 100% | TBD | 🟑 | +| Traefik Log zeigt VPN IP | 100% | 0% | πŸ”΄ | +| Grafana Erreichbarkeit | 100% | TBD | 🟑 | +| Latenz zu 10.8.0.1 | <50ms | TBD | 🟑 | +| Client Setup Time | <10min | TBD | 🟑 | + +**Update nach Implementation**: Metrics werden nach Phase 6 aktualisiert. + +--- + +## 14. Risk Assessment + +### Risks & Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|------------|--------|------------| +| DNS-Fix lΓΆst Problem nicht | Low | Medium | Plan B: Full Tunnel VPN | +| Windows Routing weiterhin problematisch | Medium | High | Plan C: OpenVPN als Fallback | +| Existing Clients brechen | Low | Low | Backup Configs vorhanden | +| Performance Degradation | Low | Medium | Performance Monitoring, Tuning | +| Security Incident | Very Low | High | Key Rotation, Access Logging, Fail2Ban | + +### Rollback Plan + +**Falls Implementation fehlschlΓ€gt**: + +1. **Clients zurΓΌcksetzen**: + ```bash + # Restore old config + cp wireguard-clients/mikepc.conf.backup-[timestamp] \ + wireguard-clients/mikepc.conf + ``` + +2. **Template zurΓΌcksetzen**: + ```bash + git checkout templates/wireguard-client.conf.j2 + ``` + +3. **Server unverΓ€ndert**: Keine Server-Γ„nderungen nΓΆtig fΓΌr Option A + +**Rollback Time**: <5 Minuten + +--- + +## 15. Contacts & Support + +### Internal Contacts + +**Primary Admin**: michael@michaelschiemer.de +**Server**: michaelschiemer.de (94.16.110.151) +**SSH User**: deploy +**Ansible Location**: `/home/michael/dev/michaelschiemer/deployment/ansible` + +### External Resources + +- **WireGuard Docs**: https://www.wireguard.com/quickstart/ +- **Ansible WireGuard Module**: https://galaxy.ansible.com/githubixx/ansible_role_wireguard +- **Windows Troubleshooting**: https://www.wireguard.com/known-limitations/ + +### Support Channels + +- **GitHub Issues**: (Falls applicable) +- **WireGuard Mailing List**: wireguard@lists.zx2c4.com + +--- + +## Appendix: Quick Reference + +### Useful Commands + +```bash +# Server Status +ssh deploy@michaelschiemer.de 'sudo wg show' + +# Client Generation +ansible-playbook -i inventory/production.yml playbooks/add-wireguard-client.yml \ + -e "client_name=NEW_CLIENT" -e "client_ip=10.8.0.X" + +# Container Test +ansible-playbook -i inventory/production.yml playbooks/test-wireguard-docker-container.yml \ + -e "client_name=CLIENT" + +# Log Monitoring +ssh deploy@michaelschiemer.de 'docker logs -f traefik 2>&1 | grep -E "10\.8\.0\.[0-9]+"' + +# Client Regeneration +ansible-playbook -i inventory/production.yml playbooks/regenerate-wireguard-client.yml \ + -e "client_name=CLIENT" -e "client_ip=10.8.0.X" +``` + +### Config Locations + +- **Server Config**: `/etc/wireguard/wg0.conf` +- **Client Configs**: `/home/michael/dev/michaelschiemer/deployment/ansible/wireguard-clients/` +- **Templates**: `/home/michael/dev/michaelschiemer/deployment/ansible/templates/` +- **Windows Config**: `C:\Program Files\WireGuard\Data\Configurations\` +- **Linux Config**: `/etc/wireguard/wg0.conf` + +### IP Allocation + +| IP Address | Hostname | Client | Status | +|------------|----------|--------|--------| +| 10.8.0.1 | michaelschiemer.de | Server | Active | +| 10.8.0.5 | mikepc | Windows Client | Pending | +| 10.8.0.7 | grafana-test | Test Client | Unknown | +| 10.8.0.10-254 | - | Available | Free | + +--- + +**Plan Status**: βœ… READY FOR IMPLEMENTATION +**Empfohlene Option**: Option A (DNS Fix) +**Estimated Implementation Time**: 30 Minuten +**Rollback Risk**: LOW +**Success Probability**: HIGH (>80%) + +--- + +**Ende des Implementierungsplans** + +**NΓ€chster Schritt**: Template Update (Phase 2) durchfΓΌhren und Client regenerieren (Phase 3). diff --git a/src/Framework/Console/CommandRegistry.php b/src/Framework/Console/CommandRegistry.php index 06efd1f0..3bd71ecc 100644 --- a/src/Framework/Console/CommandRegistry.php +++ b/src/Framework/Console/CommandRegistry.php @@ -228,10 +228,24 @@ final readonly class CommandRegistry private function normalizeCommandResult($result): ExitCode { + // Handle ActionResult (including ConsoleResult) + if ($result instanceof \App\Framework\Router\ActionResult) { + // If it's already a ConsoleResult, render it and return exit code + if ($result instanceof \App\Framework\Console\Result\ConsoleResult) { + // Rendering will be handled by ConsoleApplication::processCommandResult + return $result->exitCode; + } + + // Convert other ActionResult types to ConsoleResult + return $this->convertActionResultToConsoleResult($result)->exitCode; + } + + // Legacy ExitCode pattern if ($result instanceof ExitCode) { return $result; } + // Legacy int pattern (for backwards compatibility) if (is_int($result)) { try { return ExitCode::from($result); @@ -240,6 +254,7 @@ final readonly class CommandRegistry } } + // Legacy bool pattern if (is_bool($result)) { return $result ? ExitCode::SUCCESS : ExitCode::GENERAL_ERROR; } @@ -247,6 +262,42 @@ final readonly class CommandRegistry return ExitCode::SUCCESS; } + /** + * Convert ActionResult to ConsoleResult + */ + private function convertActionResultToConsoleResult(\App\Framework\Router\ActionResult $result): \App\Framework\Console\Result\ConsoleResult + { + // GenericResult: Use built-in conversion + if ($result instanceof \App\Framework\Router\GenericResult) { + return $result->toConsoleResult(); + } + + // Convert JsonResult to TextResult + if ($result instanceof \App\Framework\Router\Result\JsonResult) { + $json = json_encode($result->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + return \App\Framework\Console\Result\TextResult::info($json); + } + + // Convert ToolResult to TextResult + if ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) { + $message = $result->success + ? 'Operation completed successfully' + : 'Operation failed: ' . ($result->error ?? 'Unknown error'); + + $data = $result->data !== null ? json_encode($result->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : ''; + if ($data) { + $message .= "\n" . $data; + } + + return $result->success + ? \App\Framework\Console\Result\TextResult::success($message) + : \App\Framework\Console\Result\TextResult::error($message); + } + + // Default: convert to info text + return \App\Framework\Console\Result\TextResult::info('Command executed successfully'); + } + /** * Execute command with automatic parameter resolution */ @@ -278,6 +329,18 @@ final readonly class CommandRegistry $result = $method->invokeArgs($instance, $resolvedParams); + // Handle ActionResult - if it's a ConsoleResult, render it + if ($result instanceof \App\Framework\Router\ActionResult) { + if ($result instanceof \App\Framework\Console\Result\ConsoleResult) { + $result->render($progressAwareOutput); + return $result->exitCode; + } + // Convert other ActionResult types + $consoleResult = $this->convertActionResultToConsoleResult($result); + $consoleResult->render($progressAwareOutput); + return $consoleResult->exitCode; + } + return $this->normalizeCommandResult($result); }; diff --git a/src/Framework/Console/Components/ConsoleDialog.php b/src/Framework/Console/Components/ConsoleDialog.php index 77dc845a..b8924555 100644 --- a/src/Framework/Console/Components/ConsoleDialog.php +++ b/src/Framework/Console/Components/ConsoleDialog.php @@ -23,7 +23,7 @@ use App\Framework\Discovery\Results\DiscoveryRegistry; */ final readonly class ConsoleDialog { - private bool $readlineAvailable = false; + private bool $readlineAvailable; private CommandSuggestionEngine $suggestionEngine; diff --git a/src/Framework/Console/ConsoleCommandMapper.php b/src/Framework/Console/ConsoleCommandMapper.php index 04c36da7..0f27a224 100644 --- a/src/Framework/Console/ConsoleCommandMapper.php +++ b/src/Framework/Console/ConsoleCommandMapper.php @@ -4,12 +4,22 @@ declare(strict_types=1); namespace App\Framework\Console; +use App\Framework\Attributes\Route; use App\Framework\Core\AttributeMapper; +use App\Framework\Core\ParameterTypeValidator; +use App\Framework\Mcp\McpTool; use App\Framework\Reflection\WrappedReflectionClass; use App\Framework\Reflection\WrappedReflectionMethod; final readonly class ConsoleCommandMapper implements AttributeMapper { + private ParameterTypeValidator $typeValidator; + + public function __construct() + { + $this->typeValidator = new ParameterTypeValidator(); + } + public function getAttributeClass(): string { return ConsoleCommand::class; @@ -21,6 +31,26 @@ final readonly class ConsoleCommandMapper implements AttributeMapper return null; // ConsoleCommand can only be applied to methods } + // Check if method has multiple attributes (multi-purpose) + $hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget); + + // If multi-purpose, validate that all parameters are builtin types + if ($hasMultipleAttributes) { + $parameters = $reflectionTarget->getParameters()->toArray(); + $reflectionParameters = []; + foreach ($parameters as $param) { + $reflectionParameters[] = $param->getType(); + } + + if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) { + // Skip this attribute if parameters are not all builtin + return null; + } + } + + // Get other attributes for metadata + $otherAttributes = $this->getOtherPurposeAttributes($reflectionTarget); + return [ 'attribute_data' => [ 'name' => $attributeInstance->name, @@ -28,6 +58,53 @@ final readonly class ConsoleCommandMapper implements AttributeMapper ], 'class' => $reflectionTarget->getDeclaringClass(), 'method' => $reflectionTarget->getName(), + 'multi_purpose' => $hasMultipleAttributes, + 'other_attributes' => $otherAttributes, ]; } + + /** + * Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route) + */ + private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool + { + $attributes = $method->getAttributes(); + $purposeAttributeCount = 0; + + foreach ($attributes as $attribute) { + $attributeName = $attribute->getName(); + if (in_array($attributeName, [ + McpTool::class, + ConsoleCommand::class, + Route::class, + ], true)) { + $purposeAttributeCount++; + } + } + + return $purposeAttributeCount > 1; + } + + /** + * Get other purpose attributes on the same method + * + * @return array + */ + private function getOtherPurposeAttributes(WrappedReflectionMethod $method): array + { + $attributes = $method->getAttributes(); + $otherAttributes = []; + + foreach ($attributes as $attribute) { + $attributeName = $attribute->getName(); + if (in_array($attributeName, [ + McpTool::class, + Route::class, + ], true)) { + $otherAttributes[] = $attributeName; + } + } + + return $otherAttributes; + } } diff --git a/src/Framework/Console/MethodSignatureAnalyzer.php b/src/Framework/Console/MethodSignatureAnalyzer.php index 074912c4..3d66215e 100644 --- a/src/Framework/Console/MethodSignatureAnalyzer.php +++ b/src/Framework/Console/MethodSignatureAnalyzer.php @@ -330,7 +330,15 @@ final readonly class MethodSignatureAnalyzer $returnType = $method->getReturnType(); if ($returnType instanceof ReflectionNamedType) { $returnTypeName = $returnType->getName(); - if (! in_array($returnTypeName, ['int', ExitCode::class], true)) { + // Accept: int, ExitCode, ActionResult, or array + $validReturnTypes = [ + 'int', + ExitCode::class, + 'App\Framework\MagicLinks\Actions\ActionResult', + 'array' + ]; + + if (! in_array($returnTypeName, $validReturnTypes, true)) { return false; } } diff --git a/src/Framework/Console/README.md b/src/Framework/Console/README.md index 4d965e5b..c1ac0bf1 100644 --- a/src/Framework/Console/README.md +++ b/src/Framework/Console/README.md @@ -2,6 +2,31 @@ Dieses Modul bietet eine flexible und benutzerfreundliche Konsolen-Schnittstelle fΓΌr Ihre PHP-Anwendung. Es ermΓΆglicht die Erstellung von CLI-Befehlen mit einfacher Eingabe- und Ausgabehandlung. +## Interaktive Modi + +Das Console-Modul bietet zwei interaktive Modi: + +### TUI (Text User Interface) - Standard + +Grafische Terminal-UI mit Maus-UnterstΓΌtzung und Navigation: + +```bash +php console.php # Startet TUI (Standard) +php console.php --interactive # Startet TUI explizit +php console.php --tui # Startet TUI explizit +``` + +### Dialog-Modus - AI-Assistent-Γ€hnlich + +Einfache Prompt-Eingabe mit Tab-Completion und History: + +```bash +php console.php --dialog # Startet Dialog-Modus +php console.php --chat # Startet Dialog-Modus (Alias) +``` + +**Weitere Informationen**: Siehe [Console Dialog Mode Dokumentation](../../docs/console-dialog-mode.md) + ## Hauptkomponenten ### ConsoleApplication diff --git a/src/Framework/Console/Result/ConsoleResult.php b/src/Framework/Console/Result/ConsoleResult.php index 47188c17..dd8b19cd 100644 --- a/src/Framework/Console/Result/ConsoleResult.php +++ b/src/Framework/Console/Result/ConsoleResult.php @@ -6,6 +6,7 @@ namespace App\Framework\Console\Result; use App\Framework\Console\ConsoleOutputInterface; use App\Framework\Console\ExitCode; +use App\Framework\Router\ActionResult; /** * Console Result Interface @@ -18,7 +19,7 @@ use App\Framework\Console\ExitCode; * - Rendering logic * - Metadata for testing/introspection */ -interface ConsoleResult +interface ConsoleResult extends ActionResult { /** * Exit code for this result diff --git a/src/Framework/Core/ParameterTypeValidator.php b/src/Framework/Core/ParameterTypeValidator.php new file mode 100644 index 00000000..01804b53 --- /dev/null +++ b/src/Framework/Core/ParameterTypeValidator.php @@ -0,0 +1,101 @@ +isBuiltinTypeName($type->getName()); + } + + if ($type instanceof ReflectionUnionType) { + return $this->isBuiltinUnionType($type); + } + + if ($type instanceof ReflectionIntersectionType) { + return false; // Intersection types are not builtin + } + + return false; + } + + /** + * Check if all parameters of a method are builtin types + * + * @param array<\ReflectionType|null> $types + */ + public function hasOnlyBuiltinParameters(array $types): bool + { + foreach ($types as $type) { + if (! $this->isBuiltinType($type)) { + return false; + } + } + + return true; + } + + /** + * Check if a type name is a builtin type + */ + private function isBuiltinTypeName(string $typeName): bool + { + $builtinTypes = [ + 'string', + 'int', + 'float', + 'bool', + 'array', + 'null', + 'mixed', + ]; + + return in_array($typeName, $builtinTypes, true); + } + + /** + * Check if a union type only contains builtin types + */ + private function isBuiltinUnionType(ReflectionUnionType $type): bool + { + foreach ($type->getTypes() as $unionType) { + if (! $unionType instanceof ReflectionNamedType) { + return false; + } + + $typeName = $unionType->getName(); + + // Allow null in unions (e.g., ?string, string|null) + if ($typeName === 'null') { + continue; + } + + if (! $this->isBuiltinTypeName($typeName)) { + return false; + } + } + + return true; + } +} + diff --git a/src/Framework/Core/RouteMapper.php b/src/Framework/Core/RouteMapper.php index c196fac4..b9d8d921 100644 --- a/src/Framework/Core/RouteMapper.php +++ b/src/Framework/Core/RouteMapper.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Framework\Core; use App\Framework\Attributes\Route; +use App\Framework\Console\ConsoleCommand; +use App\Framework\Mcp\McpTool; use App\Framework\Reflection\WrappedReflectionClass; use App\Framework\Reflection\WrappedReflectionMethod; use App\Framework\Router\ValueObjects\MethodParameter; @@ -12,6 +14,13 @@ use App\Framework\Router\ValueObjects\ParameterCollection; final readonly class RouteMapper implements AttributeMapper { + private ParameterTypeValidator $typeValidator; + + public function __construct() + { + $this->typeValidator = new ParameterTypeValidator(); + } + public function getAttributeClass(): string { return Route::class; @@ -28,11 +37,44 @@ final readonly class RouteMapper implements AttributeMapper return null; } + // Check if method has multiple attributes (multi-purpose) + $hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget); + + // If multi-purpose, validate that all parameters are builtin types + if ($hasMultipleAttributes) { + $parameters = $reflectionTarget->getParameters()->toArray(); + $reflectionParameters = []; + foreach ($parameters as $param) { + $reflectionParameters[] = $param->getType(); + } + + if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) { + // Skip this attribute if parameters are not all builtin + return null; + } + } + // Collect all non-Route attributes on the method $attributes = []; + $otherPurposeAttributes = []; + $hasWebhookEndpoint = false; foreach ($reflectionTarget->getAttributes() as $attribute) { - if ($attribute->getName() !== Route::class) { - $attributes[] = $attribute->getName(); + $attributeName = $attribute->getName(); + if ($attributeName !== Route::class) { + $attributes[] = $attributeName; + + // Track purpose attributes + if (in_array($attributeName, [ + McpTool::class, + ConsoleCommand::class, + ], true)) { + $otherPurposeAttributes[] = $attributeName; + } + + // Track WebhookEndpoint attribute + if ($attributeName === \App\Framework\Webhook\Attributes\WebhookEndpoint::class) { + $hasWebhookEndpoint = true; + } } } @@ -52,6 +94,31 @@ final readonly class RouteMapper implements AttributeMapper 'parameters' => $parameterCollection->toLegacyArray(), // Backward compatibility 'parameter_collection' => $parameterCollection, // New type-safe collection 'attributes' => $attributes, + 'multi_purpose' => $hasMultipleAttributes, + 'other_attributes' => $otherPurposeAttributes, + 'has_webhook_endpoint' => $hasWebhookEndpoint, ]; } + + /** + * Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route) + */ + private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool + { + $attributes = $method->getAttributes(); + $purposeAttributeCount = 0; + + foreach ($attributes as $attribute) { + $attributeName = $attribute->getName(); + if (in_array($attributeName, [ + McpTool::class, + ConsoleCommand::class, + Route::class, + ], true)) { + $purposeAttributeCount++; + } + } + + return $purposeAttributeCount > 1; + } } diff --git a/src/Framework/Core/ValueObjects/ClassName.php b/src/Framework/Core/ValueObjects/ClassName.php index 0636b5a6..955f9b86 100644 --- a/src/Framework/Core/ValueObjects/ClassName.php +++ b/src/Framework/Core/ValueObjects/ClassName.php @@ -284,6 +284,39 @@ final readonly class ClassName implements Stringable private function isValidClassName(string $className): bool { // Basic validation: should contain only alphanumeric, underscore, and backslash - return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $className) === 1; + if (preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $className) !== 1) { + return false; + } + + // Extract the short name (last part after backslash) + $lastBackslash = strrpos($className, '\\'); + $shortName = $lastBackslash === false ? $className : substr($className, $lastBackslash + 1); + + // Reject known invalid class names (PHP keywords and common false positives) + $invalidNames = [ + 'implementation', 'interface', 'extends', 'implements', + 'class', 'trait', 'enum', 'namespace', 'use', 'as', + 'public', 'private', 'protected', 'static', 'final', 'abstract', + 'function', 'return', 'if', 'else', 'foreach', 'while', 'for', + 'true', 'false', 'null', 'void', 'mixed', 'array', 'object', + 'string', 'int', 'float', 'bool', 'resource', 'callable', 'iterable' + ]; + + if (in_array(strtolower($shortName), $invalidNames, true)) { + return false; + } + + // Reject camelCase names (starting with lowercase) - class names should be PascalCase + // camelCase names are almost always methods, functions, or variables, not classes + if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '_') && ! str_contains($shortName, '\\')) { + // This is camelCase - reject it as a class name + // Only allow if it's a known valid exception (very rare in PHP) + $validCamelCaseExceptions = []; + if (! in_array($shortName, $validCamelCaseExceptions, true)) { + return false; + } + } + + return true; } } diff --git a/src/Framework/Database/Monitoring/Dashboard/DatabaseDashboardController.php b/src/Framework/Database/Monitoring/Dashboard/DatabaseDashboardController.php index 56bd6cd0..76066da5 100644 --- a/src/Framework/Database/Monitoring/Dashboard/DatabaseDashboardController.php +++ b/src/Framework/Database/Monitoring/Dashboard/DatabaseDashboardController.php @@ -12,7 +12,6 @@ use App\Framework\Database\Driver\Optimization\SQLiteOptimizer; use App\Framework\Database\Profiling\ProfilingDashboard; use App\Framework\Database\Profiling\QueryProfiler; use App\Framework\Database\Profiling\SlowQueryDetector; -use App\Framework\Http\Controller; use App\Framework\Http\Response\JsonResponse; use App\Framework\Http\Response\Response; use App\Framework\Http\Response\ViewResponse; @@ -21,7 +20,7 @@ use App\Framework\View\ViewRenderer; /** * Controller for the database performance dashboard */ -final readonly class DatabaseDashboardController implements Controller +final readonly class DatabaseDashboardController { public function __construct( private DatabaseManager $databaseManager, diff --git a/src/Framework/Database/Monitoring/Dashboard/DatabaseHealthController.php b/src/Framework/Database/Monitoring/Dashboard/DatabaseHealthController.php index 76dc517c..0cfc85a7 100644 --- a/src/Framework/Database/Monitoring/Dashboard/DatabaseHealthController.php +++ b/src/Framework/Database/Monitoring/Dashboard/DatabaseHealthController.php @@ -7,7 +7,6 @@ namespace App\Framework\Database\Monitoring\Dashboard; use App\Framework\Attributes\Route; use App\Framework\Database\DatabaseManager; use App\Framework\Database\Monitoring\Health\DatabaseHealthChecker; -use App\Framework\Http\Controller; use App\Framework\Http\Response\JsonResponse; use App\Framework\Http\Response\Response; use App\Framework\Http\Response\ViewResponse; @@ -16,7 +15,7 @@ use App\Framework\View\ViewRenderer; /** * Controller for the database health dashboard */ -final readonly class DatabaseHealthController implements Controller +final readonly class DatabaseHealthController { public function __construct( private DatabaseManager $databaseManager, diff --git a/src/Framework/Database/Monitoring/Dashboard/QueryHistoryController.php b/src/Framework/Database/Monitoring/Dashboard/QueryHistoryController.php index 7be3ecba..585ff48c 100644 --- a/src/Framework/Database/Monitoring/Dashboard/QueryHistoryController.php +++ b/src/Framework/Database/Monitoring/Dashboard/QueryHistoryController.php @@ -6,7 +6,6 @@ namespace App\Framework\Database\Monitoring\Dashboard; use App\Framework\Attributes\Route; use App\Framework\Database\Monitoring\History\QueryHistoryLogger; -use App\Framework\Http\Controller; use App\Framework\Http\Response\JsonResponse; use App\Framework\Http\Response\Response; use App\Framework\Http\Response\ViewResponse; @@ -15,7 +14,7 @@ use App\Framework\View\ViewRenderer; /** * Controller for displaying historical query performance data */ -final readonly class QueryHistoryController implements Controller +final readonly class QueryHistoryController { public function __construct( private QueryHistoryLogger $historyLogger, diff --git a/src/Framework/Discovery/Processing/ClassExtractor.php b/src/Framework/Discovery/Processing/ClassExtractor.php index 87009548..3315969c 100644 --- a/src/Framework/Discovery/Processing/ClassExtractor.php +++ b/src/Framework/Discovery/Processing/ClassExtractor.php @@ -38,6 +38,7 @@ final readonly class ClassExtractor } $classes = $this->tokenizer->extractClasses($content); + $fileNamespace = $this->extractFileNamespace($content); $validClassNames = []; foreach ($classes as $class) { @@ -52,6 +53,22 @@ final readonly class ClassExtractor continue; } + // CRITICAL: Validate that the extracted class belongs to this file's namespace + // Classes should only be extracted from files where they are declared, + // not from files where they are only used (via use statements) + if (! $this->belongsToFileNamespace($fqn, $fileNamespace, $class['namespace'] ?? null)) { + continue; + } + + // ADDITIONAL SAFETY: Verify that the class is actually declared in this file + // Search for "class " or "interface " etc. in the file content + // This catches cases where the tokenizer might extract wrong names + $shortClassName = $class['name'] ?? null; + if ($shortClassName !== null && ! $this->isClassDeclaredInFile($content, $shortClassName, $class['type'] ?? 'class')) { + // Class name found but not actually declared in this file - skip it + continue; + } + try { $className = ClassName::create($fqn); $validClassNames[] = $className; @@ -96,30 +113,107 @@ final readonly class ClassExtractor // Single lowercase word is suspicious - likely a property/method name // But we'll be conservative and only reject known problematic names - $knownInvalid = ['state', 'container', 'get', 'set', 'map', 'compile', 'install', 'shouldretry', 'additionaldata']; + $knownInvalid = [ + 'state', 'container', 'get', 'set', 'map', 'compile', 'install', + 'shouldretry', 'additionaldata', + // PHP keywords and common false positives from class extraction + 'implementation', 'interface', 'extends', 'implements' + ]; if (in_array(strtolower($shortName), $knownInvalid, true)) { return false; } } - // camelCase starting with lowercase (likely method names) - if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '\\')) { - $methodPrefixes = ['get', 'set', 'is', 'has', 'should', 'can', 'will', 'do', 'add', 'remove', 'update', 'delete']; - $lowercase = strtolower($shortName); - foreach ($methodPrefixes as $prefix) { - if (str_starts_with($lowercase, $prefix) && strlen($shortName) > strlen($prefix)) { - // Check if it's in our known invalid list - $knownInvalid = ['shouldretry', 'additionaldata']; - if (in_array($lowercase, $knownInvalid, true)) { - return false; - } - } + // camelCase starting with lowercase (likely method names) - STRICT REJECTION + // PHP class names should be PascalCase (start with uppercase) + // camelCase names are almost always methods, functions, or variables + if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '\\') && ! str_contains($shortName, '_')) { + // This is camelCase - reject it as a class name + // Only allow if it's a known valid exception (very rare) + $validCamelCaseExceptions = []; + if (! in_array($shortName, $validCamelCaseExceptions, true)) { + return false; } } return true; } + /** + * Extract namespace from file content + */ + private function extractFileNamespace(string $content): ?string + { + // Extract namespace from file using regex + if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) { + return trim($matches[1]); + } + + return null; + } + + /** + * Validate that extracted class belongs to the file's namespace + * This prevents extracting classes that are only used (via use statements) but not declared + */ + private function belongsToFileNamespace(string $fqn, ?string $fileNamespace, ?string $extractedNamespace): bool + { + // If file has no namespace (global namespace), only classes without namespace should be extracted + if ($fileNamespace === null || $fileNamespace === '') { + // Check if FQN has no namespace (global namespace) - no backslash or only one at start + return ! str_contains($fqn, '\\') || (str_starts_with($fqn, '\\') && substr_count($fqn, '\\') === 1); + } + + // Extract namespace from FQN + $lastBackslash = strrpos($fqn, '\\'); + if ($lastBackslash === false) { + // Class name without namespace - in a namespaced file, this shouldn't happen + // But if it does, it's invalid (classes in namespaced files should have namespace) + return false; + } + + $fqnNamespace = substr($fqn, 0, $lastBackslash); + + // Primary validation: Use the extracted namespace from tokenizer if available + // This is the most reliable source as it comes directly from the namespace declaration + // CRITICAL: Must match EXACTLY, not just start with + if ($extractedNamespace !== null && $extractedNamespace !== '') { + // Exact match required - no sub-namespaces allowed + if ($extractedNamespace !== $fileNamespace) { + return false; + } + // Also validate that FQN namespace matches + return $fqnNamespace === $fileNamespace; + } + + // Fallback: Check if FQN namespace matches file namespace EXACTLY + // This ensures the full qualified name matches the file's namespace + // No partial matches - must be identical + return $fqnNamespace === $fileNamespace; + } + + /** + * Check if a class is actually declared in the file content + * This prevents extracting classes that are only used (via use statements) but not declared + */ + private function isClassDeclaredInFile(string $content, string $className, string $type): bool + { + // Build pattern to match class/interface/trait/enum declarations + $keyword = match($type) { + 'class' => 'class', + 'interface' => 'interface', + 'trait' => 'trait', + 'enum' => 'enum', + default => 'class' + }; + + // Match: optional modifiers (final, abstract, readonly) + keyword + whitespace + class name + // Must be followed by whitespace, {, or extends/implements + $pattern = '/\b(?:final\s+|abstract\s+|readonly\s+)*' . preg_quote($keyword, '/') . '\s+' . preg_quote($className, '/') . '(?:\s|{|extends|implements)/'; + + return preg_match($pattern, $content) === 1; + } + /** * Check if content contains actual PHP code */ diff --git a/src/Framework/Discovery/Processing/FileStreamProcessor.php b/src/Framework/Discovery/Processing/FileStreamProcessor.php index 6dbf18dd..6f2cdc65 100644 --- a/src/Framework/Discovery/Processing/FileStreamProcessor.php +++ b/src/Framework/Discovery/Processing/FileStreamProcessor.php @@ -38,6 +38,7 @@ final readonly class FileStreamProcessor callable $fileProcessor ): int { $totalFiles = 0; + $failedFiles = []; error_log("FileStreamProcessor: Processing directories: " . implode(', ', $directories)); @@ -45,6 +46,7 @@ final readonly class FileStreamProcessor $directoryPath = FilePath::create($directory); foreach ($this->streamPhpFiles($directoryPath) as $file) { + $fileContext = null; try { // Extract classes from file $classNames = $this->classExtractor->extractFromFile($file); @@ -67,11 +69,13 @@ final readonly class FileStreamProcessor $this->processingContext->maybeCollectGarbage($totalFiles); } catch (\Throwable $e) { - // Only log errors, not every processed file - $this->logger?->warning( - "Failed to process file {$file->getPath()->toString()}: {$e->getMessage()} in FileStreamProcessor", - LogContext::withException($e) - ); + // Collect error information for aggregated reporting + $filePath = $file->getPath()->toString(); + $failedFiles[] = [ + 'file' => $filePath, + 'error' => $e->getMessage(), + 'type' => $e::class, + ]; } finally { // Always cleanup after processing a file $this->processingContext->cleanup(); @@ -79,6 +83,28 @@ final readonly class FileStreamProcessor } } + // Log aggregated summary instead of individual errors + if (!empty($failedFiles)) { + $failedCount = count($failedFiles); + $errorMessage = sprintf( + 'Failed to read %d file(s) because of syntax errors or other issues.', + $failedCount + ); + + $context = LogContext::create()->withData([ + 'failed_files_count' => $failedCount, + 'total_files_processed' => $totalFiles, + 'failed_files' => array_map(fn($f) => [ + 'file' => $f['file'], + 'error' => $f['error'], + 'type' => $f['type'], + ], $failedFiles) + ]); + + $this->logger?->warning($errorMessage, $context); + error_log("FileStreamProcessor: {$errorMessage}"); + } + error_log("FileStreamProcessor: Total files processed: " . $totalFiles); return $totalFiles; diff --git a/src/Framework/Discovery/Processing/VisitorCoordinator.php b/src/Framework/Discovery/Processing/VisitorCoordinator.php index 6b683c59..3753f35f 100644 --- a/src/Framework/Discovery/Processing/VisitorCoordinator.php +++ b/src/Framework/Discovery/Processing/VisitorCoordinator.php @@ -16,6 +16,7 @@ use App\Framework\Discovery\ValueObjects\TemplateMapping; use App\Framework\Filesystem\File; use App\Framework\Http\Method; use App\Framework\Logging\Logger; +use App\Framework\Logging\ValueObjects\LogContext; /** * Coordinates visitor execution with shared reflection context @@ -72,19 +73,41 @@ final class VisitorCoordinator FileContext $fileContext, DiscoveryDataCollector $collector ): void { - // Get shared reflection instance - $reflection = $this->processingContext->getReflection($className); - if ($reflection === null) { - return; - } + try { + // Get shared reflection instance + $reflection = $this->processingContext->getReflection($className); + if ($reflection === null) { + return; + } - // Process attributes - $this->processClassAttributes($className, $fileContext, $reflection, $collector); - $this->processMethodAttributes($className, $fileContext, $reflection, $collector); + // Process attributes + $this->processClassAttributes($className, $fileContext, $reflection, $collector); + $this->processMethodAttributes($className, $fileContext, $reflection, $collector); - // Process interface implementations - if (! empty($this->targetInterfaces)) { - $this->processInterfaces($className, $reflection, $collector); + // Process interface implementations + if (! empty($this->targetInterfaces)) { + $this->processInterfaces($className, $reflection, $collector); + } + } catch (\Throwable $e) { + $errorMessage = sprintf( + 'Failed to process class "%s" in file %s: %s', + $className->getFullyQualified(), + $fileContext->path->toString(), + $e->getMessage() + ); + + $context = LogContext::withException($e)->withData([ + 'class_name' => $className->getFullyQualified(), + 'file_path' => $fileContext->path->toString(), + 'exception_class' => $e::class, + 'exception_file' => $e->getFile(), + 'exception_line' => $e->getLine(), + ]); + + $this->logger?->warning($errorMessage, $context); + error_log($errorMessage); + + // Don't re-throw - continue processing other classes } } @@ -130,29 +153,78 @@ final class VisitorCoordinator $reflection, DiscoveryDataCollector $collector ): void { - foreach ($reflection->getMethods() as $method) { - foreach ($method->getAttributes() as $attribute) { - $attributeClass = $attribute->getName(); + try { + $methods = $reflection->getMethods(); + } catch (\Throwable $e) { + $errorMessage = sprintf( + 'Failed to get methods for class "%s" in file %s: %s', + $className->getFullyQualified(), + $fileContext->path->toString(), + $e->getMessage() + ); + + $context = LogContext::withException($e)->withData([ + 'class_name' => $className->getFullyQualified(), + 'file_path' => $fileContext->path->toString(), + 'exception_class' => $e::class, + 'exception_file' => $e->getFile(), + 'exception_line' => $e->getLine(), + ]); + + $this->logger?->warning($errorMessage, $context); + error_log($errorMessage); + + // Return early if we can't get methods + return; + } + + foreach ($methods as $method) { + try { + foreach ($method->getAttributes() as $attribute) { + $attributeClass = $attribute->getName(); - if ($this->shouldIgnoreAttribute($attributeClass)) { - continue; + if ($this->shouldIgnoreAttribute($attributeClass)) { + continue; + } + + $mappedData = $this->applyMapper($attributeClass, $method, $attribute); + + $discovered = new DiscoveredAttribute( + className: $className, + attributeClass: $attributeClass, + target: AttributeTarget::METHOD, + methodName: MethodName::create($method->getName()), + propertyName: null, + arguments: $this->extractAttributeArguments($attribute), + filePath: $fileContext->path, + additionalData: $mappedData ?? [] + ); + + $collector->getAttributeRegistry()->add($attributeClass, $discovered); } - - - $mappedData = $this->applyMapper($attributeClass, $method, $attribute); - - $discovered = new DiscoveredAttribute( - className: $className, - attributeClass: $attributeClass, - target: AttributeTarget::METHOD, - methodName: MethodName::create($method->getName()), - propertyName: null, - arguments: $this->extractAttributeArguments($attribute), - filePath: $fileContext->path, - additionalData: $mappedData ?? [] + } catch (\Throwable $e) { + $errorMessage = sprintf( + 'Failed to process method "%s" in class "%s" in file %s: %s', + $method->getName(), + $className->getFullyQualified(), + $fileContext->path->toString(), + $e->getMessage() ); - - $collector->getAttributeRegistry()->add($attributeClass, $discovered); + + $context = LogContext::withException($e)->withData([ + 'class_name' => $className->getFullyQualified(), + 'method_name' => $method->getName(), + 'file_path' => $fileContext->path->toString(), + 'exception_class' => $e::class, + 'exception_file' => $e->getFile(), + 'exception_line' => $e->getLine(), + ]); + + $this->logger?->warning($errorMessage, $context); + error_log($errorMessage); + + // Continue with next method + continue; } } } diff --git a/src/Framework/Examples/MultiPurposeAction.php b/src/Framework/Examples/MultiPurposeAction.php new file mode 100644 index 00000000..0df62f46 --- /dev/null +++ b/src/Framework/Examples/MultiPurposeAction.php @@ -0,0 +1,164 @@ + 1, 'name' => 'John Doe', 'status' => 'active'], + ['id' => 2, 'name' => 'Jane Smith', 'status' => 'active'], + ['id' => 3, 'name' => 'Bob Johnson', 'status' => 'inactive'], + ]; + + // Filter by status + $filteredUsers = array_filter($users, fn($user) => $user['status'] === $status); + + // Limit results + $limitedUsers = array_slice($filteredUsers, 0, $limit); + + // Add details if requested + if ($includeDetails) { + foreach ($limitedUsers as &$user) { + $user['email'] = strtolower(str_replace(' ', '.', $user['name'])) . '@example.com'; + $user['created_at'] = '2024-01-01'; + } + } + + // Return unified ActionResult + return new JsonResult([ + 'users' => array_values($limitedUsers), + 'total' => count($limitedUsers), + 'status' => $status, + 'limit' => $limit, + ]); + } + + /** + * Get user by ID + * + * This method can be called via: + * - MCP: {"name": "get_user", "arguments": {"userId": "1"}} + * - Console: users:get --userId=1 + * - HTTP: GET /api/users/1 + */ + #[McpTool( + name: 'get_user', + description: 'Get a specific user by ID' + )] + #[ConsoleCommand( + name: 'users:get', + description: 'Get a specific user by ID' + )] + #[Route( + path: '/api/users/{userId}', + method: Method::GET + )] + public function getUser(int $userId): ActionResult + { + // Simulate user data + $users = [ + 1 => ['id' => 1, 'name' => 'John Doe', 'status' => 'active', 'email' => 'john.doe@example.com'], + 2 => ['id' => 2, 'name' => 'Jane Smith', 'status' => 'active', 'email' => 'jane.smith@example.com'], + 3 => ['id' => 3, 'name' => 'Bob Johnson', 'status' => 'inactive', 'email' => 'bob.johnson@example.com'], + ]; + + if (! isset($users[$userId])) { + return new JsonResult([ + 'error' => 'User not found', + 'user_id' => $userId, + ]); + } + + return new JsonResult([ + 'user' => $users[$userId], + ]); + } + + /** + * Create a new user + * + * This method can be called via: + * - MCP: {"name": "create_user", "arguments": {"name": "Alice", "email": "alice@example.com"}} + * - Console: users:create --name=Alice --email=alice@example.com + * - HTTP: POST /api/users with JSON body + */ + #[McpTool( + name: 'create_user', + description: 'Create a new user' + )] + #[ConsoleCommand( + name: 'users:create', + description: 'Create a new user' + )] + #[Route( + path: '/api/users', + method: Method::POST + )] + public function createUser( + string $name, + string $email, + bool $active = true + ): ActionResult { + // Simulate user creation + $newUser = [ + 'id' => 4, + 'name' => $name, + 'email' => $email, + 'status' => $active ? 'active' : 'inactive', + 'created_at' => date('Y-m-d H:i:s'), + ]; + + return new JsonResult([ + 'user' => $newUser, + 'message' => 'User created successfully', + ]); + } +} + diff --git a/src/Framework/Http/Middlewares/WafMiddleware.php b/src/Framework/Http/Middlewares/WafMiddleware.php index cbf78000..2511c33b 100644 --- a/src/Framework/Http/Middlewares/WafMiddleware.php +++ b/src/Framework/Http/Middlewares/WafMiddleware.php @@ -99,7 +99,7 @@ final readonly class WafMiddleware implements HttpMiddleware // Debug log analysis result $this->logger->debug('WAF analysis complete', LogContext::withData([ - 'result_status' => $wafResult->getStatus()->value ?? 'unknown', + 'result_status' => $wafResult->status->value ?? 'unknown', 'result_action' => $wafResult->getAction(), 'layer_name' => $wafResult->getLayerName(), 'message' => $wafResult->getMessage(), diff --git a/src/Framework/HttpClient/CurlHttpClient.php b/src/Framework/HttpClient/CurlHttpClient.php index 925edcdc..64861c2e 100644 --- a/src/Framework/HttpClient/CurlHttpClient.php +++ b/src/Framework/HttpClient/CurlHttpClient.php @@ -58,6 +58,148 @@ final readonly class CurlHttpClient implements HttpClient // Wrap any exception in CurlExecutionFailed for backward compatibility throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e); } + } + + /** + * Send request with streaming response to destination + * + * Streams HTTP response directly to a writable stream resource. + * Useful for large file downloads without loading entire response into memory. + * + * @param ClientRequest $request HTTP request to send + * @param resource $destination Writable stream resource (e.g., fopen('file.txt', 'w')) + * @return StreamingResponse Response with headers and status, but no body (streamed to destination) + * @throws CurlExecutionFailed If request execution fails + */ + public function sendStreaming(ClientRequest $request, $destination): StreamingResponse + { + if (! is_resource($destination)) { + throw new \InvalidArgumentException('Destination must be a valid stream resource'); + } + + $handle = new Handle(); + + try { + // Build options using HandleOption enum + $options = $this->requestBuilder->buildOptions($request); + + // Remove CURLOPT_RETURNTRANSFER (we're streaming to destination) + unset($options[\App\Framework\HttpClient\Curl\HandleOption::ReturnTransfer->value]); + + // Handle authentication + if ($request->options->auth !== null) { + $authResult = $this->authenticationHandler->configure($request->options->auth, $request->headers); + + if ($authResult->headers !== $request->headers) { + $updatedRequest = $request->with(['headers' => $authResult->headers]); + $options = $this->requestBuilder->buildOptions($updatedRequest); + unset($options[\App\Framework\HttpClient\Curl\HandleOption::ReturnTransfer->value]); + } + + if (! empty($authResult->curlOptions)) { + $options = array_replace($options, $authResult->curlOptions); + } + } + + // Enable header capture for streaming + $headerBuffer = ''; + $headerFunction = function ($ch, $header) use (&$headerBuffer) { + $headerBuffer .= $header; + return strlen($header); + }; + $options[\App\Framework\HttpClient\Curl\HandleOption::HeaderFunction->value] = $headerFunction; + + // Set all options + $handle->setOptions($options); + + // Execute and stream directly to destination + $handle->execute($destination); + + // Parse response headers from buffer + $statusCode = $handle->getInfo(\App\Framework\HttpClient\Curl\Info::ResponseCode); + $status = \App\Framework\Http\Status::from((int) $statusCode); + $headers = $this->responseParser->parseHeaders($headerBuffer); + + // Get bytes written (if available) + $bytesWritten = (int) ($handle->getInfo(\App\Framework\HttpClient\Curl\Info::SizeDownload) ?: 0); + + return new StreamingResponse( + status: $status, + headers: $headers, + bytesWritten: $bytesWritten + ); + } catch (\Throwable $e) { + throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Send request with streaming body from source + * + * Streams HTTP request body from a readable stream resource. + * Useful for large file uploads without loading entire file into memory. + * + * @param ClientRequest $request HTTP request to send (body will be replaced by stream) + * @param resource $source Readable stream resource (e.g., fopen('file.txt', 'r')) + * @param int|null $contentLength Content-Length in bytes (null for chunked transfer) + * @return ClientResponse HTTP response + * @throws CurlExecutionFailed If request execution fails + */ + public function sendStreamingUpload(ClientRequest $request, $source, ?int $contentLength = null): ClientResponse + { + if (! is_resource($source)) { + throw new \InvalidArgumentException('Source must be a valid stream resource'); + } + + $handle = new Handle(); + + try { + // Build options using HandleOption enum + $options = $this->requestBuilder->buildOptions($request); + + // Remove CURLOPT_POSTFIELDS (we're using stream) + unset($options[\App\Framework\HttpClient\Curl\HandleOption::PostFields->value]); + + // Set streaming upload options + $options[\App\Framework\HttpClient\Curl\HandleOption::Upload->value] = true; + $options[\App\Framework\HttpClient\Curl\HandleOption::InFile->value] = $source; + + if ($contentLength !== null) { + $options[\App\Framework\HttpClient\Curl\HandleOption::InFileSize->value] = $contentLength; + } + + // Handle authentication + if ($request->options->auth !== null) { + $authResult = $this->authenticationHandler->configure($request->options->auth, $request->headers); + + if ($authResult->headers !== $request->headers) { + $updatedRequest = $request->with(['headers' => $authResult->headers]); + $options = array_replace($options, $this->requestBuilder->buildOptions($updatedRequest)); + unset($options[\App\Framework\HttpClient\Curl\HandleOption::PostFields->value]); + $options[\App\Framework\HttpClient\Curl\HandleOption::Upload->value] = true; + $options[\App\Framework\HttpClient\Curl\HandleOption::InFile->value] = $source; + if ($contentLength !== null) { + $options[\App\Framework\HttpClient\Curl\HandleOption::InFileSize->value] = $contentLength; + } + } + + if (! empty($authResult->curlOptions)) { + $options = array_replace($options, $authResult->curlOptions); + } + } + + // Set all options + $handle->setOptions($options); + + // Execute request (automatically uses CURLOPT_RETURNTRANSFER) + $rawResponse = $handle->fetch(); + + // Parse response + return $this->responseParser->parse($rawResponse, $handle->getResource()); + } catch (\Throwable $e) { + throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e); + } + } @@ -127,6 +269,7 @@ final readonly class CurlHttpClient implements HttpClient status: Status::from($status), headers: $headers, body: $body - );*/ + ); } +*/ } diff --git a/src/Framework/HttpClient/CurlResponseParser.php b/src/Framework/HttpClient/CurlResponseParser.php index fde48532..30e9fe3b 100644 --- a/src/Framework/HttpClient/CurlResponseParser.php +++ b/src/Framework/HttpClient/CurlResponseParser.php @@ -23,7 +23,7 @@ final readonly class CurlResponseParser return new ClientResponse($status, $headers, $body); } - private function parseHeaders(string $headersRaw): Headers + public function parseHeaders(string $headersRaw): Headers { $headers = new Headers(); $lines = explode("\r\n", trim($headersRaw)); diff --git a/src/Framework/HttpClient/StreamingResponse.php b/src/Framework/HttpClient/StreamingResponse.php new file mode 100644 index 00000000..3dd385ee --- /dev/null +++ b/src/Framework/HttpClient/StreamingResponse.php @@ -0,0 +1,30 @@ +status->isSuccess(); + } +} + diff --git a/src/Framework/Logging/Handlers/InMemoryHandler.php b/src/Framework/Logging/Handlers/InMemoryHandler.php index a927069c..69215761 100644 --- a/src/Framework/Logging/Handlers/InMemoryHandler.php +++ b/src/Framework/Logging/Handlers/InMemoryHandler.php @@ -4,29 +4,36 @@ declare(strict_types=1); namespace App\Framework\Logging\Handlers; +use App\Framework\Logging\LogHandler; use App\Framework\Logging\LogLevel; +use App\Framework\Logging\LogRecord; use App\Framework\Logging\ValueObjects\LogContext; -use App\Framework\Logging\ValueObjects\LogEntry; /** * In-memory log handler for testing * - * Stores all log entries in memory for inspection and assertions in tests + * Stores all log records in memory for inspection and assertions in tests */ final class InMemoryHandler implements LogHandler { - /** @var LogEntry[] */ + /** @var LogRecord[] */ private array $entries = []; - public function handle(LogEntry $entry): void + public function isHandling(LogRecord $record): bool { - $this->entries[] = $entry; + // In-memory handler captures all log records for testing + return true; + } + + public function handle(LogRecord $record): void + { + $this->entries[] = $record; } /** * Get all logged entries * - * @return LogEntry[] + * @return LogRecord[] */ public function getEntries(): array { @@ -36,13 +43,13 @@ final class InMemoryHandler implements LogHandler /** * Get entries by log level * - * @return LogEntry[] + * @return LogRecord[] */ public function getEntriesByLevel(LogLevel $level): array { return array_filter( $this->entries, - fn(LogEntry $entry) => $entry->level === $level + fn(LogRecord $entry) => $entry->level === $level ); } diff --git a/src/Framework/Logging/Processors/PerformanceProcessor.php b/src/Framework/Logging/Processors/PerformanceProcessor.php index f1c29f91..c7c54037 100644 --- a/src/Framework/Logging/Processors/PerformanceProcessor.php +++ b/src/Framework/Logging/Processors/PerformanceProcessor.php @@ -36,7 +36,7 @@ final class PerformanceProcessor implements LogProcessor } } - public function process(LogRecord $record): LogRecord + public function processRecord(LogRecord $record): LogRecord { $performance = []; @@ -54,3 +54,22 @@ final class PerformanceProcessor implements LogProcessor $elapsed = Timestamp::now()->diffInMilliseconds(self::$requestStartTime); $performance['execution_time_ms'] = round($elapsed, 2); } + + // Merge performance data into log record context + return $record->withContext(array_merge( + $record->context, + ['performance' => $performance] + )); + } + + public function getPriority(): int + { + // Low priority - add performance data at the end + return 10; + } + + public function getName(): string + { + return 'performance'; + } +} \ No newline at end of file diff --git a/src/Framework/Mcp/Core/ValueObjects/ToolResult.php b/src/Framework/Mcp/Core/ValueObjects/ToolResult.php index 449328bd..5ad930f2 100644 --- a/src/Framework/Mcp/Core/ValueObjects/ToolResult.php +++ b/src/Framework/Mcp/Core/ValueObjects/ToolResult.php @@ -4,12 +4,14 @@ declare(strict_types=1); namespace App\Framework\Mcp\Core\ValueObjects; +use App\Framework\Router\ActionResult; + /** * Value Object fΓΌr MCP Tool Ergebnisse * * Standardisiert die RΓΌckgabe aller MCP Tools */ -final readonly class ToolResult +final readonly class ToolResult implements ActionResult { public function __construct( public mixed $data, diff --git a/src/Framework/Mcp/McpServer.php b/src/Framework/Mcp/McpServer.php index 459f9ba7..67d0c524 100644 --- a/src/Framework/Mcp/McpServer.php +++ b/src/Framework/Mcp/McpServer.php @@ -110,13 +110,24 @@ final readonly class McpServer $result = $instance->$method(...$this->prepareArguments($tool['parameters'], $arguments)); + // Handle ActionResult - convert to ToolResult if needed + if ($result instanceof \App\Framework\Router\ActionResult) { + $toolResult = $this->convertActionResultToToolResult($result); + } elseif ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) { + $toolResult = $result; + } else { + // Convert plain result to ToolResult + $toolResult = \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result); + } + + // Convert ToolResult to MCP response $response = [ 'jsonrpc' => '2.0', 'result' => [ 'content' => [ [ 'type' => 'text', - 'text' => is_string($result) ? $result : json_encode($result, JSON_PRETTY_PRINT), + 'text' => json_encode($toolResult->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), ], ], ], @@ -130,6 +141,35 @@ final readonly class McpServer } } + /** + * Convert ActionResult to ToolResult + */ + private function convertActionResultToToolResult(\App\Framework\Router\ActionResult $result): \App\Framework\Mcp\Core\ValueObjects\ToolResult + { + // If it's already a ToolResult, return it + if ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) { + return $result; + } + + // Convert JsonResult to ToolResult + if ($result instanceof \App\Framework\Router\Result\JsonResult) { + return \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result->data); + } + + // Convert ConsoleResult to ToolResult + if ($result instanceof \App\Framework\Console\Result\ConsoleResult) { + $success = $result->exitCode->value === 0; + $message = $result->data['message'] ?? ($success ? 'Command executed successfully' : 'Command failed'); + + return $success + ? \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result->data, ['message' => $message]) + : \App\Framework\Mcp\Core\ValueObjects\ToolResult::failure($message, $result->data); + } + + // Default: convert to success ToolResult + return \App\Framework\Mcp\Core\ValueObjects\ToolResult::success(['result' => 'Operation completed']); + } + private function listResources($requestId = null): string { $resources = []; diff --git a/src/Framework/Mcp/McpToolMapper.php b/src/Framework/Mcp/McpToolMapper.php index 4f23f529..7a5fe594 100644 --- a/src/Framework/Mcp/McpToolMapper.php +++ b/src/Framework/Mcp/McpToolMapper.php @@ -4,7 +4,10 @@ declare(strict_types=1); namespace App\Framework\Mcp; +use App\Framework\Console\ConsoleCommand; use App\Framework\Core\AttributeMapper; +use App\Framework\Core\ParameterTypeValidator; +use App\Framework\Attributes\Route; use App\Framework\Mcp\Core\ValueObjects\OutputFormat; use App\Framework\Reflection\WrappedReflectionClass; use App\Framework\Reflection\WrappedReflectionMethod; @@ -15,6 +18,13 @@ use ReflectionUnionType; final readonly class McpToolMapper implements AttributeMapper { + private ParameterTypeValidator $typeValidator; + + public function __construct() + { + $this->typeValidator = new ParameterTypeValidator(); + } + public function getAttributeClass(): string { return McpTool::class; @@ -28,6 +38,26 @@ final readonly class McpToolMapper implements AttributeMapper $class = $reflectionTarget->getDeclaringClass(); + // Check if method has multiple attributes (multi-purpose) + $hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget); + + // If multi-purpose, validate that all parameters are builtin types + if ($hasMultipleAttributes) { + $parameters = $reflectionTarget->getParameters()->toArray(); + $reflectionParameters = []; + foreach ($parameters as $param) { + $reflectionParameters[] = $param->getType(); + } + + if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) { + // Skip this attribute if parameters are not all builtin + return null; + } + } + + // Get other attributes for metadata + $otherAttributes = $this->getOtherPurposeAttributes($reflectionTarget); + return [ 'name' => $attributeInstance->name, 'description' => $attributeInstance->description, @@ -35,9 +65,56 @@ final readonly class McpToolMapper implements AttributeMapper 'class' => $class->getFullyQualified(), 'method' => $reflectionTarget->getName(), 'parameters' => $this->extractParameters($reflectionTarget), + 'multi_purpose' => $hasMultipleAttributes, + 'other_attributes' => $otherAttributes, ]; } + /** + * Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route) + */ + private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool + { + $attributes = $method->getAttributes(); + $purposeAttributeCount = 0; + + foreach ($attributes as $attribute) { + $attributeName = $attribute->getName(); + if (in_array($attributeName, [ + McpTool::class, + ConsoleCommand::class, + Route::class, + ], true)) { + $purposeAttributeCount++; + } + } + + return $purposeAttributeCount > 1; + } + + /** + * Get other purpose attributes on the same method + * + * @return array + */ + private function getOtherPurposeAttributes(WrappedReflectionMethod $method): array + { + $attributes = $method->getAttributes(); + $otherAttributes = []; + + foreach ($attributes as $attribute) { + $attributeName = $attribute->getName(); + if (in_array($attributeName, [ + ConsoleCommand::class, + Route::class, + ], true)) { + $otherAttributes[] = $attributeName; + } + } + + return $otherAttributes; + } + private function generateInputSchema(WrappedReflectionMethod $method, McpTool $tool): array { $schema = [ @@ -262,7 +339,7 @@ final readonly class McpToolMapper implements AttributeMapper $type = $param->getType(); $parameters[] = [ 'name' => $param->getName(), - 'type' => $type ? $type->getName() : 'mixed', + 'type' => $type ? ($type instanceof \ReflectionNamedType ? $type->getName() : 'mixed') : 'mixed', 'required' => ! $param->isOptional(), 'default' => $param->isOptional() ? $param->getDefaultValue() : null, ]; diff --git a/src/Framework/Mcp/Tools/GiteaTools.php b/src/Framework/Mcp/Tools/GiteaTools.php index 7bfd27eb..6c3bdbd1 100644 --- a/src/Framework/Mcp/Tools/GiteaTools.php +++ b/src/Framework/Mcp/Tools/GiteaTools.php @@ -4,26 +4,29 @@ declare(strict_types=1); namespace App\Framework\Mcp\Tools; -use App\Framework\HttpClient\HttpClient; -use App\Framework\HttpClient\HttpMethod; +use App\Framework\Console\ConsoleCommand; use App\Framework\Mcp\McpTool; +use App\Framework\Router\GenericResult; +use App\Infrastructure\Api\Gitea\GiteaClient; /** * Gitea Repository Management MCP Tools * * Provides AI-accessible Gitea API operations for repository management, - * SSH key setup, and deployment automation. + * issue tracking, CI/CD workflows, and deployment automation. + * + * Architecture: Leverages Infrastructure/Api/Gitea service classes for + * clean separation and better maintainability. */ final readonly class GiteaTools { public function __construct( - private HttpClient $httpClient, - private string $giteaUrl, - private string $giteaUsername, - private string $giteaPassword + private GiteaClient $giteaClient ) { } + // ==================== Repository Management ==================== + #[McpTool( name: 'gitea_create_repository', description: 'Create a new repository in Gitea' @@ -35,67 +38,51 @@ final readonly class GiteaTools bool $autoInit = false, string $defaultBranch = 'main' ): array { - $url = "{$this->giteaUrl}/api/v1/user/repos"; + try { + $data = [ + 'name' => $name, + 'description' => $description, + 'private' => $private, + 'auto_init' => $autoInit, + 'default_branch' => $defaultBranch, + ]; - $data = [ - 'name' => $name, - 'description' => $description, - 'private' => $private, - 'auto_init' => $autoInit, - 'default_branch' => $defaultBranch, - ]; + $repository = $this->giteaClient->repositories->create($data); - $result = $this->makeRequest(HttpMethod::POST, $url, $data); - - if ($result['success']) { return [ 'success' => true, - 'repository' => [ - 'name' => $result['response']['name'] ?? $name, - 'full_name' => $result['response']['full_name'] ?? "{$this->giteaUsername}/$name", - 'clone_url' => $result['response']['clone_url'] ?? null, - 'ssh_url' => $result['response']['ssh_url'] ?? null, - 'html_url' => $result['response']['html_url'] ?? null, - 'private' => $result['response']['private'] ?? $private, - 'id' => $result['response']['id'] ?? null, - ], + 'repository' => $this->formatRepository($repository), ]; + } catch (\Exception $e) { + return $this->formatError($e, 'Failed to create repository'); } - - return $result; } #[McpTool( name: 'gitea_list_repositories', description: 'List all repositories for the authenticated user' )] - public function listRepositories(): array + public function listRepositories(int $page = 1, int $limit = 30): array { - $url = "{$this->giteaUrl}/api/v1/user/repos"; - - $result = $this->makeRequest(HttpMethod::GET, $url); - - if ($result['success']) { - $repos = array_map(function ($repo) { - return [ - 'name' => $repo['name'] ?? 'unknown', - 'full_name' => $repo['full_name'] ?? 'unknown', - 'description' => $repo['description'] ?? '', - 'private' => $repo['private'] ?? false, - 'clone_url' => $repo['clone_url'] ?? null, - 'ssh_url' => $repo['ssh_url'] ?? null, - 'html_url' => $repo['html_url'] ?? null, - ]; - }, $result['response'] ?? []); + try { + $repositories = $this->giteaClient->repositories->list([ + 'page' => $page, + 'limit' => $limit, + ]); return [ 'success' => true, - 'repositories' => $repos, - 'count' => count($repos), + 'repositories' => array_map( + fn($repo) => $this->formatRepository($repo), + $repositories + ), + 'count' => count($repositories), + 'page' => $page, + 'limit' => $limit, ]; + } catch (\Exception $e) { + return $this->formatError($e, 'Failed to list repositories'); } - - return $result; } #[McpTool( @@ -104,198 +91,374 @@ final readonly class GiteaTools )] public function getRepository(string $owner, string $repo): array { - $url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo"; - - $result = $this->makeRequest(HttpMethod::GET, $url); - - if ($result['success']) { - $repo = $result['response']; + try { + $repository = $this->giteaClient->repositories->get($owner, $repo); return [ 'success' => true, - 'repository' => [ - 'name' => $repo['name'] ?? 'unknown', - 'full_name' => $repo['full_name'] ?? 'unknown', - 'description' => $repo['description'] ?? '', - 'private' => $repo['private'] ?? false, - 'clone_url' => $repo['clone_url'] ?? null, - 'ssh_url' => $repo['ssh_url'] ?? null, - 'html_url' => $repo['html_url'] ?? null, - 'default_branch' => $repo['default_branch'] ?? 'main', - 'created_at' => $repo['created_at'] ?? null, - 'updated_at' => $repo['updated_at'] ?? null, - ], + 'repository' => $this->formatRepository($repository), ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to get repository {$owner}/{$repo}"); } + } - return $result; + #[McpTool( + name: 'gitea_update_repository', + description: 'Update repository settings' + )] + public function updateRepository( + string $owner, + string $repo, + ?string $description = null, + ?bool $private = null, + ?string $website = null, + ?bool $hasIssues = null, + ?bool $hasWiki = null, + ?string $defaultBranch = null + ): array { + try { + $data = array_filter([ + 'description' => $description, + 'private' => $private, + 'website' => $website, + 'has_issues' => $hasIssues, + 'has_wiki' => $hasWiki, + 'default_branch' => $defaultBranch, + ], fn($value) => $value !== null); + + $repository = $this->giteaClient->repositories->update($owner, $repo, $data); + + return [ + 'success' => true, + 'repository' => $this->formatRepository($repository), + ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to update repository {$owner}/{$repo}"); + } } #[McpTool( name: 'gitea_delete_repository', - description: 'Delete a repository' + description: 'Delete a repository (DANGEROUS - permanent deletion)' )] public function deleteRepository(string $owner, string $repo): array { - $url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo"; + try { + $this->giteaClient->repositories->delete($owner, $repo); - return $this->makeRequest(HttpMethod::DELETE, $url); + return [ + 'success' => true, + 'message' => "Repository {$owner}/{$repo} deleted successfully", + ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to delete repository {$owner}/{$repo}"); + } } + // ==================== Issue Management ==================== + #[McpTool( - name: 'gitea_add_deploy_key', - description: 'Add an SSH deploy key to a repository' + name: 'gitea_create_issue', + description: 'Create a new issue in a repository' )] - public function addDeployKey( + public function createIssue( string $owner, string $repo, string $title, - string $key, - bool $readOnly = true + string $body = '', + ?array $labels = null, + ?array $assignees = null, + ?int $milestone = null ): array { - $url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys"; + try { + $data = array_filter([ + 'title' => $title, + 'body' => $body, + 'labels' => $labels, + 'assignees' => $assignees, + 'milestone' => $milestone, + ], fn($value) => $value !== null); - $data = [ - 'title' => $title, - 'key' => $key, - 'read_only' => $readOnly, - ]; - - $result = $this->makeRequest(HttpMethod::POST, $url, $data); - - if ($result['success']) { - return [ - 'success' => true, - 'deploy_key' => [ - 'id' => $result['response']['id'] ?? null, - 'title' => $result['response']['title'] ?? $title, - 'key' => $result['response']['key'] ?? $key, - 'read_only' => $result['response']['read_only'] ?? $readOnly, - 'created_at' => $result['response']['created_at'] ?? null, - ], - ]; - } - - return $result; - } - - #[McpTool( - name: 'gitea_list_deploy_keys', - description: 'List all deploy keys for a repository' - )] - public function listDeployKeys(string $owner, string $repo): array - { - $url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys"; - - $result = $this->makeRequest(HttpMethod::GET, $url); - - if ($result['success']) { - $keys = array_map(function ($key) { - return [ - 'id' => $key['id'] ?? null, - 'title' => $key['title'] ?? 'unknown', - 'key' => $key['key'] ?? '', - 'read_only' => $key['read_only'] ?? true, - 'created_at' => $key['created_at'] ?? null, - ]; - }, $result['response'] ?? []); + $issue = $this->giteaClient->issues->create($owner, $repo, $data); return [ 'success' => true, - 'deploy_keys' => $keys, - 'count' => count($keys), + 'issue' => $this->formatIssue($issue), ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to create issue in {$owner}/{$repo}"); } - - return $result; } #[McpTool( - name: 'gitea_delete_deploy_key', - description: 'Delete a deploy key from a repository' + name: 'gitea_list_issues', + description: 'List issues in a repository' )] - public function deleteDeployKey(string $owner, string $repo, int $keyId): array - { - $url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys/$keyId"; + public function listIssues( + string $owner, + string $repo, + string $state = 'open', + ?array $labels = null, + int $page = 1, + int $limit = 30 + ): array { + try { + $options = array_filter([ + 'state' => $state, + 'labels' => $labels ? implode(',', $labels) : null, + 'page' => $page, + 'limit' => $limit, + ], fn($value) => $value !== null); - return $this->makeRequest(HttpMethod::DELETE, $url); - } - - #[McpTool( - name: 'gitea_add_user_ssh_key', - description: 'Add an SSH key to the authenticated user' - )] - public function addUserSshKey(string $title, string $key, bool $readOnly = false): array - { - $url = "{$this->giteaUrl}/api/v1/user/keys"; - - $data = [ - 'title' => $title, - 'key' => $key, - 'read_only' => $readOnly, - ]; - - $result = $this->makeRequest(HttpMethod::POST, $url, $data); - - if ($result['success']) { - return [ - 'success' => true, - 'ssh_key' => [ - 'id' => $result['response']['id'] ?? null, - 'title' => $result['response']['title'] ?? $title, - 'key' => $result['response']['key'] ?? $key, - 'read_only' => $result['response']['read_only'] ?? $readOnly, - 'created_at' => $result['response']['created_at'] ?? null, - ], - ]; - } - - return $result; - } - - #[McpTool( - name: 'gitea_list_user_ssh_keys', - description: 'List all SSH keys for the authenticated user' - )] - public function listUserSshKeys(): array - { - $url = "{$this->giteaUrl}/api/v1/user/keys"; - - $result = $this->makeRequest(HttpMethod::GET, $url); - - if ($result['success']) { - $keys = array_map(function ($key) { - return [ - 'id' => $key['id'] ?? null, - 'title' => $key['title'] ?? 'unknown', - 'key' => $key['key'] ?? '', - 'fingerprint' => $key['fingerprint'] ?? '', - 'read_only' => $key['read_only'] ?? false, - 'created_at' => $key['created_at'] ?? null, - ]; - }, $result['response'] ?? []); + $issues = $this->giteaClient->issues->list($owner, $repo, $options); return [ 'success' => true, - 'ssh_keys' => $keys, - 'count' => count($keys), + 'issues' => array_map( + fn($issue) => $this->formatIssue($issue), + $issues + ), + 'count' => count($issues), + 'state' => $state, + 'page' => $page, + 'limit' => $limit, ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to list issues in {$owner}/{$repo}"); } - - return $result; } #[McpTool( - name: 'gitea_delete_user_ssh_key', - description: 'Delete an SSH key from the authenticated user' + name: 'gitea_get_issue', + description: 'Get details of a specific issue' )] - public function deleteUserSshKey(int $keyId): array + public function getIssue(string $owner, string $repo, int $index): array { - $url = "{$this->giteaUrl}/api/v1/user/keys/$keyId"; + try { + $issue = $this->giteaClient->issues->get($owner, $repo, $index); - return $this->makeRequest(HttpMethod::DELETE, $url); + return [ + 'success' => true, + 'issue' => $this->formatIssue($issue), + ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to get issue #{$index} in {$owner}/{$repo}"); + } } + #[McpTool( + name: 'gitea_update_issue', + description: 'Update an existing issue' + )] + public function updateIssue( + string $owner, + string $repo, + int $index, + ?string $title = null, + ?string $body = null, + ?string $state = null, + ?array $labels = null, + ?array $assignees = null + ): array { + try { + $data = array_filter([ + 'title' => $title, + 'body' => $body, + 'state' => $state, + 'labels' => $labels, + 'assignees' => $assignees, + ], fn($value) => $value !== null); + + $issue = $this->giteaClient->issues->update($owner, $repo, $index, $data); + + return [ + 'success' => true, + 'issue' => $this->formatIssue($issue), + ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to update issue #{$index} in {$owner}/{$repo}"); + } + } + + #[McpTool( + name: 'gitea_close_issue', + description: 'Close an issue' + )] + public function closeIssue(string $owner, string $repo, int $index): array + { + try { + $issue = $this->giteaClient->issues->close($owner, $repo, $index); + + return [ + 'success' => true, + 'issue' => $this->formatIssue($issue), + 'message' => "Issue #{$index} closed successfully", + ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to close issue #{$index} in {$owner}/{$repo}"); + } + } + + // ==================== CI/CD & Actions ==================== + + #[McpTool( + name: 'gitea_list_workflows', + description: 'List all workflows in a repository' + )] + #[ConsoleCommand( + name: 'gitea:workflows:list', + description: 'List all workflows in a Gitea repository' + )] + public function listWorkflows(string $owner, string $repo): GenericResult + { + try { + $workflows = $this->giteaClient->actions->listWorkflows($owner, $repo); + + return GenericResult::success([ + 'workflows' => $workflows['workflows'] ?? [], + 'count' => count($workflows['workflows'] ?? []), + ]); + } catch (\Exception $e) { + return GenericResult::fromException($e); + } + } + + #[McpTool( + name: 'gitea_trigger_workflow', + description: 'Manually trigger a workflow' + )] + public function triggerWorkflow( + string $owner, + string $repo, + string $workflowId, + ?string $ref = null, + ?array $inputs = null + ): array { + try { + $this->giteaClient->actions->triggerWorkflow( + $owner, + $repo, + $workflowId, + $inputs ?? [], + $ref + ); + + return [ + 'success' => true, + 'message' => "Workflow {$workflowId} triggered successfully", + 'workflow_id' => $workflowId, + 'ref' => $ref, + ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to trigger workflow {$workflowId}"); + } + } + + #[McpTool( + name: 'gitea_list_workflow_runs', + description: 'List workflow runs for a repository' + )] + #[ConsoleCommand( + name: 'gitea:workflows:runs', + description: 'List workflow runs for a Gitea repository' + )] + public function listWorkflowRuns( + string $owner, + string $repo, + ?string $status = null, + ?int $workflowId = null, + int $page = 1, + int $limit = 30 + ): GenericResult { + try { + $options = array_filter([ + 'status' => $status, + 'workflow_id' => $workflowId, + 'page' => $page, + 'limit' => $limit, + ], fn($value) => $value !== null); + + $runs = $this->giteaClient->actions->listRuns($owner, $repo, $options); + + return GenericResult::success([ + 'runs' => array_map( + fn($run) => $this->formatWorkflowRun($run), + $runs['workflow_runs'] ?? [] + ), + 'count' => count($runs['workflow_runs'] ?? []), + 'page' => $page, + 'limit' => $limit, + ]); + } catch (\Exception $e) { + return GenericResult::fromException($e); + } + } + + #[McpTool( + name: 'gitea_get_workflow_run', + description: 'Get details of a specific workflow run' + )] + #[ConsoleCommand( + name: 'gitea:workflows:run', + description: 'Get details of a specific Gitea workflow run' + )] + public function getWorkflowRun(string $owner, string $repo, int $runId): GenericResult + { + try { + $run = $this->giteaClient->actions->getRun($owner, $repo, $runId); + + return GenericResult::success([ + 'run' => $this->formatWorkflowRun($run), + ]); + } catch (\Exception $e) { + return GenericResult::fromException($e, [ + 'context' => "Failed to get workflow run #{$runId}", + ]); + } + } + + #[McpTool( + name: 'gitea_cancel_workflow_run', + description: 'Cancel a running workflow' + )] + public function cancelWorkflowRun(string $owner, string $repo, int $runId): array + { + try { + $this->giteaClient->actions->cancelRun($owner, $repo, $runId); + + return [ + 'success' => true, + 'message' => "Workflow run #{$runId} cancelled successfully", + 'run_id' => $runId, + ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to cancel workflow run #{$runId}"); + } + } + + #[McpTool( + name: 'gitea_get_workflow_logs', + description: 'Get logs of a workflow run' + )] + public function getWorkflowLogs(string $owner, string $repo, int $runId): array + { + try { + $logs = $this->giteaClient->actions->getLogs($owner, $repo, $runId); + + return [ + 'success' => true, + 'logs' => $logs, + 'run_id' => $runId, + 'size' => strlen($logs), + ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to get workflow logs for run #{$runId}"); + } + } + + // ==================== Git Remote Integration ==================== + #[McpTool( name: 'gitea_add_remote', description: 'Add Gitea repository as git remote' @@ -306,150 +469,130 @@ final readonly class GiteaTools string $repo, bool $useSsh = true ): array { - // Get repository info first - $repoInfo = $this->getRepository($owner, $repo); + try { + // Get repository info first + $repository = $this->giteaClient->repositories->get($owner, $repo); - if (! $repoInfo['success']) { - return $repoInfo; - } + $url = $useSsh + ? $repository['ssh_url'] + : $repository['clone_url']; - $url = $useSsh - ? $repoInfo['repository']['ssh_url'] - : $repoInfo['repository']['clone_url']; - - if (! $url) { - return [ - 'success' => false, - 'error' => 'Repository URL not found', - ]; - } - - // Add remote via git command - $output = []; - $exitCode = 0; - $command = sprintf( - 'git remote add %s %s 2>&1', - escapeshellarg($remoteName), - escapeshellarg($url) - ); - exec($command, $output, $exitCode); - - if ($exitCode !== 0) { - // Check if remote already exists - if (str_contains(implode("\n", $output), 'already exists')) { + if (!$url) { return [ 'success' => false, - 'error' => 'Remote already exists', - 'suggestion' => "Use 'git remote set-url $remoteName $url' to update", + 'error' => 'Repository URL not found', + ]; + } + + // Add remote via git command + $output = []; + $exitCode = 0; + $command = sprintf( + 'git remote add %s %s 2>&1', + escapeshellarg($remoteName), + escapeshellarg($url) + ); + exec($command, $output, $exitCode); + + if ($exitCode !== 0) { + // Check if remote already exists + if (str_contains(implode("\n", $output), 'already exists')) { + return [ + 'success' => false, + 'error' => 'Remote already exists', + 'suggestion' => "Use 'git remote set-url {$remoteName} {$url}' to update", + ]; + } + + return [ + 'success' => false, + 'error' => 'Failed to add remote', + 'output' => implode("\n", $output), + 'exit_code' => $exitCode, ]; } - return [ - 'success' => false, - 'error' => 'Failed to add remote', - 'output' => implode("\n", $output), - 'exit_code' => $exitCode, - ]; - } - - return [ - 'success' => true, - 'remote_name' => $remoteName, - 'url' => $url, - 'use_ssh' => $useSsh, - ]; - } - - #[McpTool( - name: 'gitea_webhook_create', - description: 'Create a webhook for a repository' - )] - public function createWebhook( - string $owner, - string $repo, - string $url, - string $contentType = 'json', - array $events = ['push'], - bool $active = true, - ?string $secret = null - ): array { - $hookUrl = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/hooks"; - - $data = [ - 'type' => 'gitea', - 'config' => [ - 'url' => $url, - 'content_type' => $contentType, - 'secret' => $secret ?? '', - ], - 'events' => $events, - 'active' => $active, - ]; - - $result = $this->makeRequest(HttpMethod::POST, $hookUrl, $data); - - if ($result['success']) { return [ 'success' => true, - 'webhook' => [ - 'id' => $result['response']['id'] ?? null, - 'url' => $result['response']['config']['url'] ?? $url, - 'events' => $result['response']['events'] ?? $events, - 'active' => $result['response']['active'] ?? $active, - 'created_at' => $result['response']['created_at'] ?? null, - ], + 'remote_name' => $remoteName, + 'url' => $url, + 'use_ssh' => $useSsh, ]; + } catch (\Exception $e) { + return $this->formatError($e, "Failed to add remote {$remoteName}"); } - - return $result; } // ==================== Private Helper Methods ==================== - private function makeRequest(HttpMethod $method, string $url, ?array $data = null): array + private function formatRepository(array $repo): array { - try { - $options = [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - 'Authorization' => 'Basic ' . base64_encode("{$this->giteaUsername}:{$this->giteaPassword}"), - ], - 'verify_ssl' => false, // For self-signed certificates - ]; + return [ + 'id' => $repo['id'] ?? null, + 'name' => $repo['name'] ?? 'unknown', + 'full_name' => $repo['full_name'] ?? 'unknown', + 'description' => $repo['description'] ?? '', + 'private' => $repo['private'] ?? false, + 'clone_url' => $repo['clone_url'] ?? null, + 'ssh_url' => $repo['ssh_url'] ?? null, + 'html_url' => $repo['html_url'] ?? null, + 'default_branch' => $repo['default_branch'] ?? 'main', + 'created_at' => $repo['created_at'] ?? null, + 'updated_at' => $repo['updated_at'] ?? null, + 'stars_count' => $repo['stars_count'] ?? 0, + 'forks_count' => $repo['forks_count'] ?? 0, + 'open_issues_count' => $repo['open_issues_count'] ?? 0, + ]; + } - if ($data !== null) { - $options['json'] = $data; - } + private function formatIssue(array $issue): array + { + return [ + 'id' => $issue['id'] ?? null, + 'number' => $issue['number'] ?? null, + 'title' => $issue['title'] ?? 'Untitled', + 'body' => $issue['body'] ?? '', + 'state' => $issue['state'] ?? 'open', + 'labels' => array_map( + fn($label) => $label['name'] ?? 'unknown', + $issue['labels'] ?? [] + ), + 'assignees' => array_map( + fn($assignee) => $assignee['username'] ?? 'unknown', + $issue['assignees'] ?? [] + ), + 'html_url' => $issue['html_url'] ?? null, + 'created_at' => $issue['created_at'] ?? null, + 'updated_at' => $issue['updated_at'] ?? null, + 'closed_at' => $issue['closed_at'] ?? null, + ]; + } - $response = $this->httpClient->request($method, $url, $options); + private function formatWorkflowRun(array $run): array + { + return [ + 'id' => $run['id'] ?? null, + 'name' => $run['name'] ?? 'Unnamed Workflow', + 'status' => $run['status'] ?? 'unknown', + 'conclusion' => $run['conclusion'] ?? null, + 'workflow_id' => $run['workflow_id'] ?? null, + 'event' => $run['event'] ?? null, + 'head_branch' => $run['head_branch'] ?? null, + 'head_sha' => $run['head_sha'] ?? null, + 'html_url' => $run['html_url'] ?? null, + 'created_at' => $run['created_at'] ?? null, + 'updated_at' => $run['updated_at'] ?? null, + 'run_started_at' => $run['run_started_at'] ?? null, + ]; + } - $statusCode = $response->getStatusCode(); - $body = $response->getBody(); - - // Decode JSON response - $decoded = json_decode($body, true); - - if ($statusCode >= 200 && $statusCode < 300) { - return [ - 'success' => true, - 'response' => $decoded, - 'http_code' => $statusCode, - ]; - } - - return [ - 'success' => false, - 'error' => $decoded['message'] ?? 'HTTP error ' . $statusCode, - 'response' => $decoded, - 'http_code' => $statusCode, - ]; - } catch (\Exception $e) { - return [ - 'success' => false, - 'error' => 'Request failed: ' . $e->getMessage(), - 'exception' => get_class($e), - ]; - } + private function formatError(\Exception $e, string $context): array + { + return [ + 'success' => false, + 'error' => $context, + 'message' => $e->getMessage(), + 'exception' => get_class($e), + ]; } } diff --git a/src/Framework/Mcp/Tools/GiteaToolsInitializer.php b/src/Framework/Mcp/Tools/GiteaToolsInitializer.php index 1bb2246a..7e840e91 100644 --- a/src/Framework/Mcp/Tools/GiteaToolsInitializer.php +++ b/src/Framework/Mcp/Tools/GiteaToolsInitializer.php @@ -4,34 +4,25 @@ declare(strict_types=1); namespace App\Framework\Mcp\Tools; -use App\Framework\Config\Environment; use App\Framework\DI\Initializer; -use App\Framework\HttpClient\HttpClient; +use App\Infrastructure\Api\Gitea\GiteaClient; /** * Initializer for Gitea MCP Tools + * + * Registers GiteaTools with the DI container using the GiteaClient service. + * The GiteaClient is initialized separately with proper configuration via GiteaClientInitializer. */ final readonly class GiteaToolsInitializer { public function __construct( - private HttpClient $httpClient, - private Environment $environment + private GiteaClient $giteaClient ) { } #[Initializer] public function __invoke(): GiteaTools { - // Get Gitea configuration from environment - $giteaUrl = $this->environment->get('GITEA_URL', 'https://localhost:9443'); - $giteaUsername = $this->environment->get('GITEA_USERNAME', 'michael'); - $giteaPassword = $this->environment->get('GITEA_PASSWORD', 'GiteaAdmin2024'); - - return new GiteaTools( - $this->httpClient, - $giteaUrl, - $giteaUsername, - $giteaPassword - ); + return new GiteaTools($this->giteaClient); } } diff --git a/src/Framework/Queue/Commands/JobChainCommands.php b/src/Framework/Queue/Commands/JobChainCommands.php index 97d4236e..0fdc302f 100644 --- a/src/Framework/Queue/Commands/JobChainCommands.php +++ b/src/Framework/Queue/Commands/JobChainCommands.php @@ -92,8 +92,9 @@ final readonly class JobChainCommands foreach ($status['job_statuses'] as $jobStatus) { $canExecute = $jobStatus['can_execute'] ? 'βœ…' : '⏳'; $depStatus = "{$jobStatus['dependencies_satisfied']}/{$jobStatus['dependencies_total']} deps"; + $position = $jobStatus['position'] + 1; - echo " {$canExecute} Job {$jobStatus['position'] + 1}: {$jobStatus['job_id']} ({$depStatus})\n"; + echo " {$canExecute} Job {$position}: {$jobStatus['job_id']} ({$depStatus})\n"; } } catch (\Exception $e) { @@ -167,7 +168,8 @@ final readonly class JobChainCommands echo "πŸ”— {$statusIcon} {$chain['name']} ({$chain['chain_id']})\n"; echo " Status: {$chain['status']}\n"; echo " Mode: {$chain['execution_mode']}\n"; - echo " Position: {$chain['job_position'] + 1}/{$chain['total_jobs']}\n"; + $position = $chain['job_position'] + 1; + echo " Position: {$position}/{$chain['total_jobs']}\n"; if ($chain['next_job_after_current']) { echo " Next job: {$chain['next_job_after_current']}\n"; diff --git a/src/Framework/Queue/Commands/JobDependencyCommands.php b/src/Framework/Queue/Commands/JobDependencyCommands.php index ce678398..d586a05d 100644 --- a/src/Framework/Queue/Commands/JobDependencyCommands.php +++ b/src/Framework/Queue/Commands/JobDependencyCommands.php @@ -192,11 +192,12 @@ final readonly class JobDependencyCommands foreach ($health['issues'] as $issue) { echo " - {$issue['type']}: "; - match($issue['type']) { - 'stalled_chain' => echo "Chain {$issue['chain_id']} running for {$issue['hours_running']} hours\n", - 'many_unsatisfied_dependencies' => echo "Job {$issue['job_id']} has {$issue['unsatisfied_count']} unsatisfied dependencies\n", - default => echo "Unknown issue\n" + $message = match($issue['type']) { + 'stalled_chain' => "Chain {$issue['chain_id']} running for {$issue['hours_running']} hours\n", + 'many_unsatisfied_dependencies' => "Job {$issue['job_id']} has {$issue['unsatisfied_count']} unsatisfied dependencies\n", + default => "Unknown issue\n" }; + echo $message; } } } diff --git a/src/Framework/Reflection/Cache/MethodCache.php b/src/Framework/Reflection/Cache/MethodCache.php index c8807f59..82cad460 100644 --- a/src/Framework/Reflection/Cache/MethodCache.php +++ b/src/Framework/Reflection/Cache/MethodCache.php @@ -99,8 +99,22 @@ final class MethodCache implements \App\Framework\Reflection\Contracts\Reflectio if (! isset($this->methodCache[$key])) { /** @var class-string $classNameString */ $classNameString = $className->getFullyQualified(); - $class = new \ReflectionClass($classNameString); - $this->methodCache[$key] = $class->getMethods($filter); + + try { + $class = new \ReflectionClass($classNameString); + $this->methodCache[$key] = $class->getMethods($filter); + } catch (\ReflectionException $e) { + // Provide context about which class failed to reflect + $errorMessage = sprintf( + 'Failed to reflect class "%s" in MethodCache::getNativeMethods(): %s', + $classNameString, + $e->getMessage() + ); + error_log($errorMessage); + + // Re-throw with more context + throw new \ReflectionException($errorMessage, $e->getCode(), $e); + } } return $this->methodCache[$key]; diff --git a/src/Framework/Router/GenericResult.php b/src/Framework/Router/GenericResult.php new file mode 100644 index 00000000..6b87e9d5 --- /dev/null +++ b/src/Framework/Router/GenericResult.php @@ -0,0 +1,180 @@ + $users]); + * } + */ +final readonly class GenericResult implements ActionResult, ConsoleResult +{ + public readonly array $data; + public readonly ExitCode $exitCode; + + /** + * Create generic result + * + * Use factory methods (success/failure/error) for convenience. + */ + public function __construct( + public mixed $payload, + public bool $success, + public ?string $error = null, + public array $metadata = [], + ?ExitCode $exitCode = null + ) { + $this->exitCode = $exitCode ?? ($success ? ExitCode::SUCCESS : ExitCode::FAILURE); + $this->data = [ + 'success' => $this->success, + 'payload' => $this->payload, + 'error' => $this->error, + 'metadata' => $this->metadata, + 'exit_code' => $this->exitCode->value, + ]; + } + + /** + * Create success result + * + * @param mixed $payload Result data + * @param array $metadata Additional metadata + */ + public static function success(mixed $payload, array $metadata = []): self + { + return new self( + payload: $payload, + success: true, + metadata: $metadata + ); + } + + /** + * Create failure result + * + * @param string $error Error message + * @param mixed $payload Optional partial data + * @param array $metadata Additional metadata + */ + public static function failure(string $error, mixed $payload = null, array $metadata = []): self + { + return new self( + payload: $payload, + success: false, + error: $error, + metadata: $metadata + ); + } + + /** + * Create failure from exception + * + * @param \Throwable $exception Exception to convert + * @param array $metadata Additional metadata + */ + public static function fromException(\Throwable $exception, array $metadata = []): self + { + return new self( + payload: null, + success: false, + error: $exception->getMessage(), + metadata: array_merge($metadata, [ + 'exception_type' => get_class($exception), + 'exception_file' => $exception->getFile(), + 'exception_line' => $exception->getLine(), + ]) + ); + } + + /** + * Convert to ToolResult for MCP context + * + * Automatic conversion when accessed via MCP tools. + */ + public function toToolResult(): ToolResult + { + if ($this->success) { + return ToolResult::success( + data: $this->payload, + metadata: $this->metadata + ); + } + + return ToolResult::failure( + error: $this->error ?? 'Unknown error', + data: $this->payload, + metadata: $this->metadata + ); + } + + /** + * Convert to ConsoleResult for console context + * + * Automatic conversion when accessed via console commands. + */ + public function toConsoleResult(): ConsoleResult + { + if ($this->success) { + $message = $this->formatSuccessMessage(); + return TextResult::success($message); + } + + return TextResult::error($this->error ?? 'Unknown error'); + } + + /** + * Render to console output (ConsoleResult interface) + * + * Delegates to TextResult for rendering. + */ + public function render(ConsoleOutputInterface $output): void + { + $this->toConsoleResult()->render($output); + } + + /** + * Format success message for console output + */ + private function formatSuccessMessage(): string + { + if (is_string($this->payload)) { + return $this->payload; + } + + if (is_array($this->payload)) { + // Check for common success message keys + if (isset($this->payload['message'])) { + return (string) $this->payload['message']; + } + + // Format array data as JSON for readability + return json_encode($this->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + } + + // Fallback: Generic success message + return 'Operation completed successfully'; + } +} diff --git a/src/Framework/Storage/Exceptions/BucketNotFoundException.php b/src/Framework/Storage/Exceptions/BucketNotFoundException.php new file mode 100644 index 00000000..0280fe5a --- /dev/null +++ b/src/Framework/Storage/Exceptions/BucketNotFoundException.php @@ -0,0 +1,41 @@ +withData(['bucket' => $bucket]); + + parent::__construct( + message: "Bucket not found: {$bucket}", + context: $context, + errorCode: ErrorCode::NOT_FOUND, + previous: $previous, + code: 404 + ); + } + + public static function for(string $bucket, ?\Throwable $previous = null): self + { + return new self($bucket, $previous); + } + + public function getBucket(): string + { + return $this->getContext()->getData()['bucket'] ?? ''; + } +} + diff --git a/src/Framework/Storage/Exceptions/ObjectNotFoundException.php b/src/Framework/Storage/Exceptions/ObjectNotFoundException.php new file mode 100644 index 00000000..af57b81e --- /dev/null +++ b/src/Framework/Storage/Exceptions/ObjectNotFoundException.php @@ -0,0 +1,50 @@ +withData([ + 'bucket' => $bucket, + 'key' => $key, + ]); + + parent::__construct( + message: "Object not found: {$bucket}/{$key}", + context: $context, + errorCode: ErrorCode::NOT_FOUND, + previous: $previous, + code: 404 + ); + } + + public static function for(string $bucket, string $key, ?\Throwable $previous = null): self + { + return new self($bucket, $key, $previous); + } + + public function getBucket(): string + { + return $this->getContext()->getData()['bucket'] ?? ''; + } + + public function getKey(): string + { + return $this->getContext()->getData()['key'] ?? ''; + } +} + diff --git a/src/Framework/Storage/Exceptions/StorageConnectionException.php b/src/Framework/Storage/Exceptions/StorageConnectionException.php new file mode 100644 index 00000000..6b9b2593 --- /dev/null +++ b/src/Framework/Storage/Exceptions/StorageConnectionException.php @@ -0,0 +1,50 @@ +withData([ + 'endpoint' => $endpoint, + 'reason' => $reason, + ]); + + $message = "Failed to connect to storage endpoint: {$endpoint}"; + if ($reason !== '') { + $message .= " ({$reason})"; + } + + parent::__construct( + message: $message, + context: $context, + errorCode: ErrorCode::CONNECTION_FAILED, + previous: $previous, + code: 503 + ); + } + + public static function for(string $endpoint, string $reason = '', ?\Throwable $previous = null): self + { + return new self($endpoint, $reason, $previous); + } + + public function getEndpoint(): string + { + return $this->getContext()->getData()['endpoint'] ?? ''; + } +} + diff --git a/src/Framework/Storage/Exceptions/StorageException.php b/src/Framework/Storage/Exceptions/StorageException.php new file mode 100644 index 00000000..26a940eb --- /dev/null +++ b/src/Framework/Storage/Exceptions/StorageException.php @@ -0,0 +1,87 @@ +withData([ + 'bucket' => $bucket, + 'key' => $key, + ]); + + return new self( + message: "Object not found: {$bucket}/{$key}", + context: $context, + errorCode: ErrorCode::NOT_FOUND, + previous: $previous, + code: 404 + ); + } + + /** + * Create exception for connection errors + */ + public static function connectionFailed(string $endpoint, ?\Throwable $previous = null): self + { + $context = ExceptionContext::forOperation('storage.connect', 'Storage') + ->withData(['endpoint' => $endpoint]); + + return new self( + message: "Failed to connect to storage endpoint: {$endpoint}", + context: $context, + errorCode: ErrorCode::CONNECTION_FAILED, + previous: $previous, + code: 503 + ); + } + + /** + * Create exception for operation failures + */ + public static function operationFailed(string $operation, string $bucket, string $key, ?\Throwable $previous = null): self + { + $context = ExceptionContext::forOperation("storage.{$operation}", 'Storage') + ->withData([ + 'bucket' => $bucket, + 'key' => $key, + ]); + + return new self( + message: "Storage operation failed: {$operation} on {$bucket}/{$key}", + context: $context, + errorCode: ErrorCode::OPERATION_FAILED, + previous: $previous, + code: 500 + ); + } +} + diff --git a/src/Framework/Storage/Exceptions/StorageOperationException.php b/src/Framework/Storage/Exceptions/StorageOperationException.php new file mode 100644 index 00000000..21b7d880 --- /dev/null +++ b/src/Framework/Storage/Exceptions/StorageOperationException.php @@ -0,0 +1,63 @@ +withData([ + 'bucket' => $bucket, + 'key' => $key, + 'reason' => $reason, + ]); + + $message = "Storage operation '{$operation}' failed for {$bucket}/{$key}"; + if ($reason !== '') { + $message .= ": {$reason}"; + } + + parent::__construct( + message: $message, + context: $context, + errorCode: ErrorCode::OPERATION_FAILED, + previous: $previous, + code: 500 + ); + } + + public static function for(string $operation, string $bucket, string $key, string $reason = '', ?\Throwable $previous = null): self + { + return new self($operation, $bucket, $key, $reason, $previous); + } + + public function getOperation(): string + { + return $this->getContext()->getOperation(); + } + + public function getBucket(): string + { + return $this->getContext()->getData()['bucket'] ?? ''; + } + + public function getKey(): string + { + return $this->getContext()->getData()['key'] ?? ''; + } +} + diff --git a/src/Framework/Storage/FilesystemObjectStorage.php b/src/Framework/Storage/FilesystemObjectStorage.php new file mode 100644 index 00000000..af93e708 --- /dev/null +++ b/src/Framework/Storage/FilesystemObjectStorage.php @@ -0,0 +1,360 @@ +basePath = rtrim($basePath, '/'); + } + + public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo + { + $bucketName = BucketName::fromString($bucket); + $objectKey = ObjectKey::fromString($key); + $path = $this->buildPath($bucket, $key); + + try { + // Ensure bucket directory exists + $bucketPath = $this->buildBucketPath($bucket); + if (! $this->storage->exists($bucketPath)) { + $this->storage->createDirectory($bucketPath); + } + + // Store content + $this->storage->put($path, $body); + + // Get file metadata + $size = FileSize::fromBytes($this->storage->size($path)); + $lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path)); + $mimeTypeString = $this->storage->getMimeType($path); + + // Generate ETag (SHA256 hash of content) + $etag = Hash::sha256($body); + + // Convert MIME type string to Value Object + $contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString); + + $metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []); + + return new ObjectInfo( + bucket: $bucketName, + key: $objectKey, + etag: $etag, + size: $size, + contentType: $contentType, + lastModified: $lastModified, + metadata: $metadata, + versionId: null + ); + } catch (\Throwable $e) { + throw StorageOperationException::for('put', $bucket, $key, $e->getMessage(), $e); + } + } + + public function get(string $bucket, string $key): string + { + $path = $this->buildPath($bucket, $key); + + try { + if (! $this->storage->exists($path)) { + throw ObjectNotFoundException::for($bucket, $key); + } + + return $this->storage->get($path); + } catch (ObjectNotFoundException $e) { + throw $e; + } catch (\Throwable $e) { + throw StorageOperationException::for('get', $bucket, $key, $e->getMessage(), $e); + } + } + + public function stream(string $bucket, string $key) + { + // Backward compatibility: returns temporary stream + return $this->openReadStream($bucket, $key); + } + + public function getToStream(string $bucket, string $key, $destination, array $opts = []): int + { + $path = $this->buildPath($bucket, $key); + + try { + if (! $this->storage->exists($path)) { + throw ObjectNotFoundException::for($bucket, $key); + } + + // Validate destination stream + if (! is_resource($destination)) { + throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream'); + } + + // Use Storage's readStream if available + if (method_exists($this->storage, 'readStream')) { + $sourceStream = $this->storage->readStream($path); + $bufferSize = $opts['bufferSize'] ?? 8192; + + try { + $bytesWritten = stream_copy_to_stream($sourceStream, $destination, null, $bufferSize); + if ($bytesWritten === false) { + throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to copy stream'); + } + + return $bytesWritten; + } finally { + fclose($sourceStream); + } + } + + // Fallback: read content and write to stream + $content = $this->storage->get($path); + $bytesWritten = fwrite($destination, $content); + if ($bytesWritten === false) { + throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to write to destination stream'); + } + + return $bytesWritten; + } catch (ObjectNotFoundException $e) { + throw $e; + } catch (StorageOperationException $e) { + throw $e; + } catch (\Throwable $e) { + throw StorageOperationException::for('getToStream', $bucket, $key, $e->getMessage(), $e); + } + } + + public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo + { + $path = $this->buildPath($bucket, $key); + + try { + // Validate source stream + if (! is_resource($source)) { + throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream'); + } + + // Ensure bucket directory exists + $bucketPath = $this->buildBucketPath($bucket); + if (! $this->storage->exists($bucketPath)) { + $this->storage->createDirectory($bucketPath); + } + + // Use Storage's putStream if available + if (method_exists($this->storage, 'putStream')) { + $this->storage->putStream($path, $source); + } else { + // Fallback: read stream content and use put() + $content = stream_get_contents($source); + if ($content === false) { + throw StorageOperationException::for('putFromStream', $bucket, $key, 'Failed to read from source stream'); + } + + $this->storage->put($path, $content); + } + + // Reuse head() logic to get ObjectInfo + return $this->head($bucket, $key); + } catch (StorageOperationException $e) { + throw $e; + } catch (\Throwable $e) { + throw StorageOperationException::for('putFromStream', $bucket, $key, $e->getMessage(), $e); + } + } + + public function openReadStream(string $bucket, string $key) + { + $path = $this->buildPath($bucket, $key); + + try { + if (! $this->storage->exists($path)) { + throw ObjectNotFoundException::for($bucket, $key); + } + + // Use Storage's readStream if available + if (method_exists($this->storage, 'readStream')) { + return $this->storage->readStream($path); + } + + // Fallback: create stream from file content + $content = $this->storage->get($path); + $stream = fopen('php://temp', 'r+'); + if ($stream === false) { + throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream'); + } + + fwrite($stream, $content); + rewind($stream); + + return $stream; + } catch (ObjectNotFoundException $e) { + throw $e; + } catch (\Throwable $e) { + throw StorageOperationException::for('openReadStream', $bucket, $key, $e->getMessage(), $e); + } + } + + public function head(string $bucket, string $key): ObjectInfo + { + $bucketName = BucketName::fromString($bucket); + $objectKey = ObjectKey::fromString($key); + $path = $this->buildPath($bucket, $key); + + try { + if (! $this->storage->exists($path)) { + throw ObjectNotFoundException::for($bucket, $key); + } + + $size = FileSize::fromBytes($this->storage->size($path)); + $lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path)); + $mimeTypeString = $this->storage->getMimeType($path); + + // Read content to generate ETag (could be optimized to read only if needed) + $content = $this->storage->get($path); + $etag = Hash::sha256($content); + + // Convert MIME type string to Value Object + $contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString); + + return new ObjectInfo( + bucket: $bucketName, + key: $objectKey, + etag: $etag, + size: $size, + contentType: $contentType, + lastModified: $lastModified, + metadata: ObjectMetadata::empty(), + versionId: null + ); + } catch (ObjectNotFoundException $e) { + throw $e; + } catch (\Throwable $e) { + throw StorageOperationException::for('head', $bucket, $key, $e->getMessage(), $e); + } + } + + public function delete(string $bucket, string $key): void + { + $path = $this->buildPath($bucket, $key); + + try { + if (! $this->storage->exists($path)) { + // Object doesn't exist, but that's OK for delete operations + return; + } + + $this->storage->delete($path); + } catch (\Throwable $e) { + throw StorageOperationException::for('delete', $bucket, $key, $e->getMessage(), $e); + } + } + + public function exists(string $bucket, string $key): bool + { + $path = $this->buildPath($bucket, $key); + + return $this->storage->exists($path); + } + + public function url(string $bucket, string $key): ?string + { + // Filesystem storage doesn't have public URLs + return null; + } + + public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string + { + // Filesystem storage doesn't support presigned URLs + throw StorageOperationException::for( + 'temporaryUrl', + $bucket, + $key, + 'Temporary URLs are not supported for filesystem storage' + ); + } + + /** + * Build filesystem path for bucket/key + */ + private function buildPath(string $bucket, string $key): string + { + // Sanitize bucket name (prevent path traversal) + $bucket = $this->sanitizeBucketName($bucket); + $key = ltrim($key, '/'); + + // Sanitize key (prevent path traversal) + $key = $this->sanitizeKey($key); + + return $this->basePath . '/' . $bucket . '/' . $key; + } + + /** + * Build filesystem path for bucket directory + */ + private function buildBucketPath(string $bucket): string + { + $bucket = $this->sanitizeBucketName($bucket); + + return $this->basePath . '/' . $bucket; + } + + /** + * Sanitize bucket name to prevent path traversal + */ + private function sanitizeBucketName(string $bucket): string + { + // Remove any path separators and dangerous characters + $bucket = str_replace(['/', '\\', '..'], '', $bucket); + $bucket = trim($bucket, '.'); + + if ($bucket === '' || $bucket === '.') { + throw StorageOperationException::for('sanitize', $bucket, '', 'Invalid bucket name'); + } + + return $bucket; + } + + /** + * Sanitize key to prevent path traversal + */ + private function sanitizeKey(string $key): string + { + // Remove leading slashes but preserve internal structure + $key = ltrim($key, '/'); + + // Prevent directory traversal + if (str_contains($key, '..')) { + throw StorageOperationException::for('sanitize', '', $key, 'Path traversal detected in key'); + } + + return $key; + } +} + diff --git a/src/Framework/Storage/InMemoryObjectStorage.php b/src/Framework/Storage/InMemoryObjectStorage.php new file mode 100644 index 00000000..28b5e7a8 --- /dev/null +++ b/src/Framework/Storage/InMemoryObjectStorage.php @@ -0,0 +1,229 @@ +> + */ +final class InMemoryObjectStorage implements ObjectStorage, StreamableObjectStorage +{ + /** + * Storage structure: [bucket => [key => [content, metadata, timestamp]]] + * + * @var array> + */ + private array $storage = []; + + public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo + { + $bucketName = BucketName::fromString($bucket); + $objectKey = ObjectKey::fromString($key); + + if (! isset($this->storage[$bucket])) { + $this->storage[$bucket] = []; + } + + $etag = Hash::sha256($body); + $size = FileSize::fromBytes(strlen($body)); + $timestamp = Timestamp::now(); + $metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []); + + $contentType = null; + if (isset($opts['contentType'])) { + $contentType = MimeType::tryFrom($opts['contentType']) ?? CustomMimeType::fromString($opts['contentType']); + } else { + $contentType = MimeType::APPLICATION_OCTET_STREAM; + } + + $this->storage[$bucket][$key] = [ + 'content' => $body, + 'metadata' => $metadata->toArray(), + 'timestamp' => $timestamp->toTimestamp(), + ]; + + return new ObjectInfo( + bucket: $bucketName, + key: $objectKey, + etag: $etag, + size: $size, + contentType: $contentType, + lastModified: $timestamp, + metadata: $metadata, + versionId: null + ); + } + + public function get(string $bucket, string $key): string + { + if (! isset($this->storage[$bucket][$key])) { + throw ObjectNotFoundException::for($bucket, $key); + } + + return $this->storage[$bucket][$key]['content']; + } + + public function stream(string $bucket, string $key) + { + // Backward compatibility: returns temporary stream + return $this->openReadStream($bucket, $key); + } + + public function getToStream(string $bucket, string $key, $destination, array $opts = []): int + { + if (! is_resource($destination)) { + throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream'); + } + + $content = $this->get($bucket, $key); + $bytesWritten = fwrite($destination, $content); + + if ($bytesWritten === false) { + throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to write to destination stream'); + } + + return $bytesWritten; + } + + public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo + { + if (! is_resource($source)) { + throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream'); + } + + $content = stream_get_contents($source); + if ($content === false) { + throw StorageOperationException::for('putFromStream', $bucket, $key, 'Failed to read from source stream'); + } + + return $this->put($bucket, $key, $content, $opts); + } + + public function openReadStream(string $bucket, string $key) + { + $content = $this->get($bucket, $key); + $stream = fopen('php://temp', 'r+'); + if ($stream === false) { + throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream'); + } + + fwrite($stream, $content); + rewind($stream); + + return $stream; + } + + public function head(string $bucket, string $key): ObjectInfo + { + $bucketName = BucketName::fromString($bucket); + $objectKey = ObjectKey::fromString($key); + + if (! isset($this->storage[$bucket][$key])) { + throw ObjectNotFoundException::for($bucket, $key); + } + + $entry = $this->storage[$bucket][$key]; + $content = $entry['content']; + $etag = Hash::sha256($content); + $size = FileSize::fromBytes(strlen($content)); + $lastModified = Timestamp::fromTimestamp($entry['timestamp']); + $metadata = ObjectMetadata::fromArray($entry['metadata']); + + return new ObjectInfo( + bucket: $bucketName, + key: $objectKey, + etag: $etag, + size: $size, + contentType: MimeType::APPLICATION_OCTET_STREAM, + lastModified: $lastModified, + metadata: $metadata, + versionId: null + ); + } + + public function delete(string $bucket, string $key): void + { + if (! isset($this->storage[$bucket][$key])) { + // Object doesn't exist, but that's OK for delete operations + return; + } + + unset($this->storage[$bucket][$key]); + + // Clean up empty bucket + if (empty($this->storage[$bucket])) { + unset($this->storage[$bucket]); + } + } + + public function exists(string $bucket, string $key): bool + { + return isset($this->storage[$bucket][$key]); + } + + public function url(string $bucket, string $key): ?string + { + // In-memory storage doesn't have URLs + return null; + } + + public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string + { + // In-memory storage doesn't support presigned URLs + throw StorageOperationException::for( + 'temporaryUrl', + $bucket, + $key, + 'Temporary URLs are not supported for in-memory storage' + ); + } + + /** + * Clear all stored objects (for testing) + */ + public function clear(): void + { + $this->storage = []; + } + + /** + * Get all buckets (for testing) + * + * @return array + */ + public function listBuckets(): array + { + return array_keys($this->storage); + } + + /** + * List all keys in a bucket (for testing) + * + * @return array + */ + public function listKeys(string $bucket): array + { + if (! isset($this->storage[$bucket])) { + return []; + } + + return array_keys($this->storage[$bucket]); + } +} + diff --git a/src/Framework/Storage/ObjectInfo.php b/src/Framework/Storage/ObjectInfo.php index 42521a55..63380096 100644 --- a/src/Framework/Storage/ObjectInfo.php +++ b/src/Framework/Storage/ObjectInfo.php @@ -1,17 +1,147 @@ $legacyMetadata + */ + public static function fromLegacy( + string $bucket, + string $key, + ?string $etag = null, + ?int $size = null, + ?string $contentType = null, + array $legacyMetadata = [], + ?string $versionId = null, + ?int $lastModified = null + ): self { + $bucketName = BucketName::fromString($bucket); + $objectKey = ObjectKey::fromString($key); + + $hash = $etag !== null ? Hash::fromString($etag, HashAlgorithm::SHA256) : null; + $fileSize = $size !== null ? FileSize::fromBytes($size) : null; + + $mimeType = null; + if ($contentType !== null) { + $mimeType = MimeType::tryFrom($contentType) ?? CustomMimeType::fromString($contentType); + } + + $timestamp = $lastModified !== null ? Timestamp::fromTimestamp($lastModified) : null; + $metadata = ObjectMetadata::fromArray($legacyMetadata); + + $versionIdObj = $versionId !== null ? VersionId::fromString($versionId) : null; + + return new self( + bucket: $bucketName, + key: $objectKey, + etag: $hash, + size: $fileSize, + contentType: $mimeType, + lastModified: $timestamp, + metadata: $metadata, + versionId: $versionIdObj + ); + } + + /** + * Get bucket name as string + */ + public function getBucketName(): string + { + return $this->bucket->toString(); + } + + /** + * Get object key as string + */ + public function getKey(): string + { + return $this->key->toString(); + } + + /** + * Get ETag as string (if available) + */ + public function getEtag(): ?string + { + return $this->etag?->toString(); + } + + /** + * Get size in bytes (if available) + */ + public function getSizeBytes(): ?int + { + return $this->size?->toBytes(); + } + + /** + * Get content type as string (if available) + */ + public function getContentType(): ?string + { + return $this->contentType?->getValue(); + } + + /** + * Get last modified timestamp (if available) + */ + public function getLastModifiedTimestamp(): ?int + { + return $this->lastModified?->toTimestamp(); + } + + /** + * Get metadata as array + * + * @return array + */ + public function getMetadataArray(): array + { + return $this->metadata->toArray(); + } + + /** + * Get version ID as string (if available) + */ + public function getVersionId(): ?string + { + return $this->versionId?->toString(); + } } diff --git a/src/Framework/Storage/StorageInitializer.php b/src/Framework/Storage/StorageInitializer.php new file mode 100644 index 00000000..9df5fa0e --- /dev/null +++ b/src/Framework/Storage/StorageInitializer.php @@ -0,0 +1,81 @@ +get(Environment::class); + $driver = $env->getString('STORAGE_DRIVER', 'filesystem'); + + // Register HmacService if not already registered + if (! $container->has(HmacService::class)) { + $container->singleton(HmacService::class, function () { + return new HmacService(); + }); + } + + // Register MinIoClient (if needed for S3 driver) + $container->singleton(MinIoClient::class, function (Container $container) use ($env) { + return new MinIoClient( + endpoint: $env->getString('MINIO_ENDPOINT', 'http://minio:9000'), + accessKey: $env->getString('MINIO_ACCESS_KEY', 'minioadmin'), + secretKey: $env->getString('MINIO_SECRET_KEY', 'minioadmin'), + region: $env->getString('MINIO_REGION', 'us-east-1'), + usePathStyle: $env->getBool('MINIO_USE_PATH_STYLE', true), + randomGenerator: $container->get(RandomGenerator::class), + hmacService: $container->get(HmacService::class), + httpClient: $container->get(CurlHttpClient::class) + ); + }); + + // Register S3ObjectStorage + $container->singleton(S3ObjectStorage::class, function (Container $container) { + return new S3ObjectStorage( + client: $container->get(MinIoClient::class) + ); + }); + + // Register FilesystemObjectStorage + $container->singleton(FilesystemObjectStorage::class, function (Container $container) use ($env) { + $storage = $container->get(Storage::class); + $basePath = $env->getString('STORAGE_LOCAL_ROOT', '/var/www/html/storage/objects'); + + return new FilesystemObjectStorage( + storage: $storage, + basePath: $basePath + ); + }); + + // Register default ObjectStorage based on driver + $container->singleton(ObjectStorage::class, function (Container $container) use ($driver) { + return match ($driver) { + 'minio', 's3' => $container->get(S3ObjectStorage::class), + 'filesystem', 'local' => $container->get(FilesystemObjectStorage::class), + 'memory', 'inmemory' => $container->get(InMemoryObjectStorage::class), + default => throw new \InvalidArgumentException("Unknown storage driver: {$driver}") + }; + }); + } +} + diff --git a/src/Framework/Storage/StreamableObjectStorage.php b/src/Framework/Storage/StreamableObjectStorage.php new file mode 100644 index 00000000..db595519 --- /dev/null +++ b/src/Framework/Storage/StreamableObjectStorage.php @@ -0,0 +1,60 @@ + $opts Optionale Parameter: + * - 'bufferSize' => int (default: 8192) Buffer-Grâße fΓΌr Chunked Transfer + * @return int Anzahl geschriebener Bytes + * @throws \App\Framework\Storage\Exceptions\ObjectNotFoundException Wenn Objekt nicht existiert + * @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern + */ + public function getToStream(string $bucket, string $key, $destination, array $opts = []): int; + + /** + * LΓ€dt Objekt-Inhalt von einem lesbaren Stream + * + * @param string $bucket Bucket-Name + * @param string $key Object-Key + * @param resource $source Lesbarer Stream (z.B. fopen('file.txt', 'r')) + * @param array $opts Optionale Parameter: + * - 'contentType' => string MIME-Type + * - 'metadata' => array Metadata + * - 'bufferSize' => int (default: 8192) Buffer-Grâße fΓΌr Chunked Transfer + * @return ObjectInfo Metadaten des hochgeladenen Objekts + * @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern + */ + public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo; + + /** + * Γ–ffnet einen Read-Stream fΓΌr das Objekt + * + * WICHTIG: Der zurΓΌckgegebene Stream muss vom Caller mit fclose() geschlossen werden! + * + * @param string $bucket Bucket-Name + * @param string $key Object-Key + * @return resource Lesbarer Stream + * @throws \App\Framework\Storage\Exceptions\ObjectNotFoundException Wenn Objekt nicht existiert + * @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern + */ + public function openReadStream(string $bucket, string $key); +} + diff --git a/src/Framework/Storage/ValueObjects/BucketName.php b/src/Framework/Storage/ValueObjects/BucketName.php new file mode 100644 index 00000000..bec7b961 --- /dev/null +++ b/src/Framework/Storage/ValueObjects/BucketName.php @@ -0,0 +1,122 @@ +validate(); + } + + /** + * Create BucketName from string + */ + public static function fromString(string $name): self + { + return new self($name); + } + + /** + * Get bucket name as string + */ + public function toString(): string + { + return $this->value; + } + + /** + * String representation + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Check if two bucket names are equal + */ + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + /** + * Validate bucket name according to S3 rules + */ + private function validate(): void + { + $length = strlen($this->value); + + // Length check + if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) { + throw new InvalidArgumentException( + "Bucket name must be between " . self::MIN_LENGTH . " and " . self::MAX_LENGTH . " characters (got {$length})" + ); + } + + // Must start and end with alphanumeric character + if (! ctype_alnum($this->value[0])) { + throw new InvalidArgumentException('Bucket name must start with a letter or number'); + } + + if (! ctype_alnum($this->value[$length - 1])) { + throw new InvalidArgumentException('Bucket name must end with a letter or number'); + } + + // Only lowercase letters, numbers, dots, and hyphens allowed + if (! preg_match('/^[a-z0-9.-]+$/', $this->value)) { + throw new InvalidArgumentException( + 'Bucket name can only contain lowercase letters, numbers, dots, and hyphens' + ); + } + + // No consecutive dots + if (str_contains($this->value, '..')) { + throw new InvalidArgumentException('Bucket name cannot contain consecutive dots'); + } + + // Cannot be formatted as an IP address (e.g., 192.168.1.1) + if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $this->value)) { + throw new InvalidArgumentException('Bucket name cannot be formatted as an IP address'); + } + + // Cannot start with "xn--" (punycode prefix) + if (str_starts_with($this->value, 'xn--')) { + throw new InvalidArgumentException('Bucket name cannot start with "xn--"'); + } + + // Cannot end with "-s3alias" (S3 alias suffix) + if (str_ends_with($this->value, '-s3alias')) { + throw new InvalidArgumentException('Bucket name cannot end with "-s3alias"'); + } + } + + /** + * Static validation method (for pre-validation without creating object) + */ + public static function isValid(string $name): bool + { + try { + new self($name); + + return true; + } catch (InvalidArgumentException) { + return false; + } + } +} + diff --git a/src/Framework/Storage/ValueObjects/ObjectKey.php b/src/Framework/Storage/ValueObjects/ObjectKey.php new file mode 100644 index 00000000..287fcf00 --- /dev/null +++ b/src/Framework/Storage/ValueObjects/ObjectKey.php @@ -0,0 +1,162 @@ +validate(); + } + + /** + * Create ObjectKey from string + */ + public static function fromString(string $key): self + { + return new self($key); + } + + /** + * Get object key as string + */ + public function toString(): string + { + return $this->value; + } + + /** + * String representation + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Check if two object keys are equal + */ + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + /** + * Get directory path (without filename) + * + * Example: "folder/subfolder/file.txt" -> "folder/subfolder" + */ + public function getDirectory(): ?string + { + $lastSlash = strrpos($this->value, '/'); + if ($lastSlash === false) { + return null; + } + + return substr($this->value, 0, $lastSlash); + } + + /** + * Get filename (last path segment) + * + * Example: "folder/subfolder/file.txt" -> "file.txt" + */ + public function getFilename(): ?string + { + $lastSlash = strrpos($this->value, '/'); + if ($lastSlash === false) { + return $this->value; + } + + return substr($this->value, $lastSlash + 1); + } + + /** + * Get file extension (without dot) + * + * Example: "file.txt" -> "txt" + */ + public function getExtension(): ?string + { + $filename = $this->getFilename(); + if ($filename === null) { + return null; + } + + $lastDot = strrpos($filename, '.'); + if ($lastDot === false) { + return null; + } + + return substr($filename, $lastDot + 1); + } + + /** + * Check if key has a directory path + */ + public function hasDirectory(): bool + { + return str_contains($this->value, '/'); + } + + /** + * Validate object key according to S3 rules + */ + private function validate(): void + { + $length = strlen($this->value); + + // Length check + if ($length > self::MAX_LENGTH) { + throw new InvalidArgumentException( + "Object key cannot exceed " . self::MAX_LENGTH . " characters (got {$length})" + ); + } + + // Empty keys are allowed (for root-level objects) + if ($length === 0) { + return; + } + + // Check for control characters (0x00-0x1F, 0x7F) + if (preg_match('/[\x00-\x1F\x7F]/', $this->value)) { + throw new InvalidArgumentException('Object key cannot contain control characters'); + } + + // Validate UTF-8 encoding + if (! mb_check_encoding($this->value, 'UTF-8')) { + throw new InvalidArgumentException('Object key must be valid UTF-8'); + } + + // S3 allows most characters, but we should avoid some problematic ones + // Note: S3 actually allows most characters, but we'll be conservative + } + + /** + * Static validation method (for pre-validation without creating object) + */ + public static function isValid(string $key): bool + { + try { + new self($key); + + return true; + } catch (InvalidArgumentException) { + return false; + } + } +} + diff --git a/src/Framework/Storage/ValueObjects/ObjectMetadata.php b/src/Framework/Storage/ValueObjects/ObjectMetadata.php new file mode 100644 index 00000000..e7a7d947 --- /dev/null +++ b/src/Framework/Storage/ValueObjects/ObjectMetadata.php @@ -0,0 +1,192 @@ + + */ + private array $data; + + public function __construct(array $data) + { + $this->validate($data); + $this->data = $data; + } + + /** + * Create ObjectMetadata from array + * + * @param array $metadata + */ + public static function fromArray(array $metadata): self + { + return new self($metadata); + } + + /** + * Create empty ObjectMetadata + */ + public static function empty(): self + { + return new self([]); + } + + /** + * Get metadata value by key + * + * @return mixed + */ + public function get(string $key, mixed $default = null): mixed + { + return $this->data[$key] ?? $default; + } + + /** + * Check if metadata key exists + */ + public function has(string $key): bool + { + return isset($this->data[$key]); + } + + /** + * Get all metadata as array + * + * @return array + */ + public function toArray(): array + { + return $this->data; + } + + /** + * Create new instance with additional/updated metadata + */ + public function with(string $key, mixed $value): self + { + $newData = $this->data; + $newData[$key] = $value; + + return new self($newData); + } + + /** + * Create new instance without specified key + */ + public function without(string $key): self + { + $newData = $this->data; + unset($newData[$key]); + + return new self($newData); + } + + /** + * Merge with another ObjectMetadata + */ + public function merge(self $other): self + { + return new self(array_merge($this->data, $other->data)); + } + + /** + * Check if metadata is empty + */ + public function isEmpty(): bool + { + return empty($this->data); + } + + /** + * Get count of metadata entries + */ + public function count(): int + { + return count($this->data); + } + + /** + * Get all keys + * + * @return array + */ + public function keys(): array + { + return array_keys($this->data); + } + + /** + * Get all values + * + * @return array + */ + public function values(): array + { + return array_values($this->data); + } + + /** + * Validate metadata structure + * + * @param array $data + */ + private function validate(array $data): void + { + foreach ($data as $key => $value) { + // Key validation + if (! is_string($key)) { + throw new InvalidArgumentException('Metadata keys must be strings'); + } + + if (empty($key)) { + throw new InvalidArgumentException('Metadata keys cannot be empty'); + } + + if (strlen($key) > 255) { + throw new InvalidArgumentException('Metadata keys cannot exceed 255 characters'); + } + + // Value validation - only allow serializable types + if (! $this->isValidValue($value)) { + throw new InvalidArgumentException( + "Metadata value for key '{$key}' must be serializable (string, int, float, bool, null, or array of these)" + ); + } + } + } + + /** + * Check if value is valid for metadata + */ + private function isValidValue(mixed $value): bool + { + if (is_scalar($value) || $value === null) { + return true; + } + + if (is_array($value)) { + foreach ($value as $item) { + if (! $this->isValidValue($item)) { + return false; + } + } + + return true; + } + + return false; + } +} + diff --git a/src/Framework/Storage/ValueObjects/VersionId.php b/src/Framework/Storage/ValueObjects/VersionId.php new file mode 100644 index 00000000..dbe0c713 --- /dev/null +++ b/src/Framework/Storage/ValueObjects/VersionId.php @@ -0,0 +1,114 @@ +validate(); + } + + /** + * Create VersionId from string + */ + public static function fromString(string $versionId): self + { + return new self($versionId); + } + + /** + * Create null version (represents current version or no versioning) + */ + public static function null(): self + { + return new self(self::NULL_VERSION); + } + + /** + * Get version ID as string + */ + public function toString(): string + { + return $this->value; + } + + /** + * String representation + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Check if two version IDs are equal + */ + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + /** + * Check if this is a null version + */ + public function isNullVersion(): bool + { + return $this->value === self::NULL_VERSION; + } + + /** + * Validate version ID + */ + private function validate(): void + { + $length = strlen($this->value); + + // Cannot be empty + if ($length === 0) { + throw new InvalidArgumentException('Version ID cannot be empty'); + } + + // Length check + if ($length > self::MAX_LENGTH) { + throw new InvalidArgumentException( + "Version ID cannot exceed " . self::MAX_LENGTH . " characters (got {$length})" + ); + } + + // S3 version IDs are typically alphanumeric with some special characters + // We'll be permissive but validate basic constraints + if (preg_match('/[\x00-\x1F\x7F]/', $this->value)) { + throw new InvalidArgumentException('Version ID cannot contain control characters'); + } + } + + /** + * Static validation method (for pre-validation without creating object) + */ + public static function isValid(string $versionId): bool + { + try { + new self($versionId); + + return true; + } catch (InvalidArgumentException) { + return false; + } + } +} + diff --git a/src/Framework/Tokenizer/Discovery/DiscoveryTokenizer.php b/src/Framework/Tokenizer/Discovery/DiscoveryTokenizer.php index e11f4527..eb58acc9 100644 --- a/src/Framework/Tokenizer/Discovery/DiscoveryTokenizer.php +++ b/src/Framework/Tokenizer/Discovery/DiscoveryTokenizer.php @@ -34,8 +34,15 @@ final readonly class DiscoveryTokenizer $name = $this->findNextIdentifier($tokens, $token); if ($name) { + // Validate: Only extract classes that are declared in the current namespace context + // The currentNamespace should match the file's namespace declaration + $currentNamespace = $context->currentNamespace ?? null; + // For class FQN, only use namespace + name (not currentClass) - $fqn = $context->currentNamespace ? $context->currentNamespace . '\\' . $name : $name; + // IMPORTANT: Only use currentNamespace if it's actually set (from namespace declaration) + // This ensures we only extract classes that are declared in this file's namespace + $fqn = $currentNamespace ? $currentNamespace . '\\' . $name : $name; + $classes[] = [ 'type' => match($token->id) { T_CLASS => 'class', @@ -45,7 +52,7 @@ final readonly class DiscoveryTokenizer default => 'unknown' }, 'name' => $name, - 'namespace' => $context->currentNamespace, + 'namespace' => $currentNamespace, 'fqn' => $fqn, 'line' => $token->line, ]; @@ -140,20 +147,58 @@ final readonly class DiscoveryTokenizer } /** - * Find next identifier after a token + * Find next identifier after a token (class/interface/trait/enum name) + * Stops at structural elements to avoid extracting method names */ private function findNextIdentifier(TokenCollection $tokens, $startToken): ?string { - $found = false; - foreach ($tokens as $token) { - if ($found && $token->id === T_STRING) { - return $token->value; - } + $tokensArray = $tokens->toArray(); + $startIndex = null; + + // Find the start token index + foreach ($tokensArray as $index => $token) { if ($token === $startToken) { - $found = true; + $startIndex = $index; + break; } } - + + if ($startIndex === null) { + return null; + } + + // Tokens that are allowed before the class name (modifiers) + $allowedModifiers = [T_FINAL, T_ABSTRACT, T_READONLY]; + + // Tokens that stop the search (structural boundaries) + $stopTokens = [ + '{', ';', // Class body start or statement end + T_EXTENDS, T_IMPLEMENTS, // Inheritance keywords + T_FUNCTION, T_FN, // Function/method declarations + T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM, // Other class-like declarations + ]; + + // Iterate through tokens after the start token + for ($i = $startIndex + 1; $i < count($tokensArray); $i++) { + $token = $tokensArray[$i]; + + // Stop at structural boundaries + if ($token->value === '{' || $token->value === ';' || + $token->is($stopTokens)) { + break; + } + + // Skip whitespace and allowed modifiers + if ($token->id === T_WHITESPACE || $token->is($allowedModifiers)) { + continue; + } + + // Found the identifier (class name) + if ($token->id === T_STRING) { + return $token->value; + } + } + return null; } diff --git a/src/Infrastructure/Api/Gitea/ActionService.php b/src/Infrastructure/Api/Gitea/ActionService.php new file mode 100644 index 00000000..2c0fde5d --- /dev/null +++ b/src/Infrastructure/Api/Gitea/ActionService.php @@ -0,0 +1,211 @@ +apiClient->request( + Method::GET, + "repos/{$owner}/{$repo}/actions/workflows" + ); + } + + /** + * Listet alle Workflow Runs eines Repositories + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param array $options Optionale Parameter (status, workflow_id, page, limit, etc.) + * @return array Liste der Runs + */ + public function listRuns(string $owner, string $repo, array $options = []): array + { + return $this->apiClient->request( + Method::GET, + "repos/{$owner}/{$repo}/actions/runs", + [], + $options + ); + } + + /** + * Ruft Details eines Workflow Runs ab + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param int $runId Run ID + * @return array Run-Details + */ + public function getRun(string $owner, string $repo, int $runId): array + { + return $this->apiClient->request( + Method::GET, + "repos/{$owner}/{$repo}/actions/runs/{$runId}" + ); + } + + /** + * Triggert einen Workflow manuell + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param string $workflowId Workflow ID oder Dateiname (z.B. "ci.yml") + * @param array $inputs Optionale Inputs fΓΌr den Workflow + * @param string|null $ref Optional: Branch/Tag/Commit SHA (Standard: default branch) + * @return array Response (normalerweise 204 No Content) + */ + public function triggerWorkflow( + string $owner, + string $repo, + string $workflowId, + array $inputs = [], + ?string $ref = null + ): array { + $data = []; + if (! empty($inputs)) { + $data['inputs'] = $inputs; + } + if ($ref !== null) { + $data['ref'] = $ref; + } + + return $this->apiClient->request( + Method::POST, + "repos/{$owner}/{$repo}/actions/workflows/{$workflowId}/dispatches", + $data + ); + } + + /** + * Bricht einen laufenden Workflow Run ab + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param int $runId Run ID + * @return array Response + */ + public function cancelRun(string $owner, string $repo, int $runId): array + { + return $this->apiClient->request( + Method::POST, + "repos/{$owner}/{$repo}/actions/runs/{$runId}/cancel" + ); + } + + /** + * Ruft die Logs eines Workflow Runs ab + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param int $runId Run ID + * @return string Logs als Text (oder Array wenn JSON) + */ + public function getLogs(string $owner, string $repo, int $runId): string + { + $response = $this->apiClient->sendRawRequest( + Method::GET, + "repos/{$owner}/{$repo}/actions/runs/{$runId}/logs" + ); + + return $response->body; + } + + /** + * Ruft den Status eines Workflow Runs ab (Helper-Methode) + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param int $runId Run ID + * @return string Status (z.B. "success", "failure", "cancelled", "running", "waiting") + */ + public function getRunStatus(string $owner, string $repo, int $runId): string + { + $run = $this->getRun($owner, $repo, $runId); + + return $run['status'] ?? 'unknown'; + } + + // ======================================================================== + // Typed Value Object Methods (Parallel Implementation) + // ======================================================================== + + /** + * Listet alle Workflows eines Repositories (typed) + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @return WorkflowList Type-safe Workflow Liste + */ + public function listWorkflowsTyped(string $owner, string $repo): WorkflowList + { + $data = $this->listWorkflows($owner, $repo); + return WorkflowList::fromApiResponse($data); + } + + /** + * Listet alle Workflow Runs eines Repositories (typed) + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param array $options Optionale Parameter (status, workflow_id, page, limit, etc.) + * @return WorkflowRunsList Type-safe Workflow Runs Liste + */ + public function listRunsTyped(string $owner, string $repo, array $options = []): WorkflowRunsList + { + $data = $this->listRuns($owner, $repo, $options); + return WorkflowRunsList::fromApiResponse($data); + } + + /** + * Ruft Details eines Workflow Runs ab (typed) + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param int $runId Run ID + * @return WorkflowRun Type-safe Workflow Run + */ + public function getRunTyped(string $owner, string $repo, int $runId): WorkflowRun + { + $data = $this->getRun($owner, $repo, $runId); + return WorkflowRun::fromApiResponse($data); + } + + /** + * Ruft Details eines Workflow Runs ab via RunId (typed) + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param RunId $runId Run ID Value Object + * @return WorkflowRun Type-safe Workflow Run + */ + public function getRunByIdTyped(string $owner, string $repo, RunId $runId): WorkflowRun + { + return $this->getRunTyped($owner, $repo, $runId->value); + } +} + diff --git a/src/Infrastructure/Api/Gitea/GiteaApiClient.php b/src/Infrastructure/Api/Gitea/GiteaApiClient.php new file mode 100644 index 00000000..a19cf4c0 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/GiteaApiClient.php @@ -0,0 +1,158 @@ +buildAuthConfig(); + $this->defaultOptions = new ClientOptions( + timeout: (int) $this->config->timeout, + auth: $authConfig + ); + } + + /** + * Sendet eine API-Anfrage und gibt JSON-Daten zurΓΌck + */ + public function request( + Method $method, + string $endpoint, + array $data = [], + array $queryParams = [] + ): array { + $response = $this->sendRawRequest($method, $endpoint, $data, $queryParams); + + return $this->handleResponse($response); + } + + /** + * Sendet eine API-Anfrage und gibt raw Response zurΓΌck + */ + public function sendRawRequest( + Method $method, + string $endpoint, + array $data = [], + array $queryParams = [] + ): ClientResponse { + $baseUrl = rtrim($this->config->baseUrl, '/'); + $url = $baseUrl . '/api/v1/' . ltrim($endpoint, '/'); + + $options = $this->defaultOptions; + if (! empty($queryParams)) { + $options = $options->with(['query' => $queryParams]); + } + + if (in_array($method, [Method::GET, Method::DELETE]) && ! empty($data)) { + $options = $options->with(['query' => array_merge($options->query, $data)]); + $data = []; + } + + $request = empty($data) + ? new ClientRequest($method, $url, options: $options) + : ClientRequest::json($method, $url, $data, $options); + + $response = $this->httpClient->send($request); + + if (! $response->isSuccessful()) { + $this->throwApiException($response); + } + + return $response; + } + + /** + * Behandelt API-Response + */ + private function handleResponse(ClientResponse $response): array + { + if (! $response->isJson()) { + throw new ApiException( + 'Expected JSON response, got: ' . $response->getContentType(), + 0, + $response + ); + } + + try { + return $response->json(); + } catch (\Exception $e) { + throw new ApiException( + 'Invalid JSON response: ' . $e->getMessage(), + 0, + $response + ); + } + } + + /** + * Wirft API-Exception + */ + private function throwApiException(ClientResponse $response): never + { + $data = []; + + if ($response->isJson()) { + try { + $data = $response->json(); + } catch (\Exception) { + // JSON parsing failed + } + } + + $message = $this->formatErrorMessage($data, $response); + + throw new ApiException($message, $response->status->value, $response); + } + + /** + * Formatiert Fehlermeldung + */ + private function formatErrorMessage(array $responseData, ClientResponse $response): string + { + if (isset($responseData['message'])) { + return 'Gitea API Error: ' . $responseData['message']; + } + + if (isset($responseData['error'])) { + return 'Gitea API Error: ' . $responseData['error']; + } + + return "Gitea API Error (HTTP {$response->status->value}): " . + substr($response->body, 0, 200); + } + + /** + * Erstellt AuthConfig basierend auf GiteaConfig + */ + private function buildAuthConfig(): AuthConfig + { + if ($this->config->token !== null) { + return AuthConfig::bearer($this->config->token); + } + + if ($this->config->username !== null && $this->config->password !== null) { + return AuthConfig::basic($this->config->username, $this->config->password); + } + + throw new \InvalidArgumentException( + 'Either token or username+password must be provided for Gitea authentication' + ); + } +} + diff --git a/src/Infrastructure/Api/Gitea/GiteaClient.php b/src/Infrastructure/Api/Gitea/GiteaClient.php new file mode 100644 index 00000000..c4b28527 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/GiteaClient.php @@ -0,0 +1,31 @@ +repositories = new RepositoryService($apiClient); + $this->users = new UserService($apiClient); + $this->issues = new IssueService($apiClient); + $this->actions = new ActionService($apiClient); + } +} + diff --git a/src/Infrastructure/Api/Gitea/GiteaClientInitializer.php b/src/Infrastructure/Api/Gitea/GiteaClientInitializer.php new file mode 100644 index 00000000..34e1bda6 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/GiteaClientInitializer.php @@ -0,0 +1,38 @@ +get(Environment::class); + $httpClient = $container->get(HttpClient::class) ?? new CurlHttpClient(); + + $baseUrl = $env->get('GITEA_URL', 'https://git.michaelschiemer.de'); + $token = $env->get('GITEA_TOKEN'); + $username = $env->get('GITEA_USERNAME'); + $password = $env->get('GITEA_PASSWORD'); + $timeout = (float) $env->get('GITEA_TIMEOUT', '30.0'); + + $config = new GiteaConfig( + baseUrl: $baseUrl, + token: $token, + username: $username, + password: $password, + timeout: $timeout + ); + + return new GiteaClient($config, $httpClient); + } +} + diff --git a/src/Infrastructure/Api/Gitea/GiteaConfig.php b/src/Infrastructure/Api/Gitea/GiteaConfig.php new file mode 100644 index 00000000..af2bbb7b --- /dev/null +++ b/src/Infrastructure/Api/Gitea/GiteaConfig.php @@ -0,0 +1,23 @@ +token === null && ($this->username === null || $this->password === null)) { + throw new \InvalidArgumentException( + 'Either token or username+password must be provided for Gitea authentication' + ); + } + } +} + diff --git a/src/Infrastructure/Api/Gitea/IssueService.php b/src/Infrastructure/Api/Gitea/IssueService.php new file mode 100644 index 00000000..f5e66b10 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/IssueService.php @@ -0,0 +1,98 @@ +apiClient->request( + Method::GET, + "repos/{$owner}/{$repo}/issues", + [], + $options + ); + } + + /** + * Ruft ein Issue ab + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param int $index Issue Index + * @return array Issue-Daten + */ + public function get(string $owner, string $repo, int $index): array + { + return $this->apiClient->request( + Method::GET, + "repos/{$owner}/{$repo}/issues/{$index}" + ); + } + + /** + * Erstellt ein neues Issue + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param array $data Issue-Daten (title, body, assignees, labels, etc.) + * @return array Erstelltes Issue + */ + public function create(string $owner, string $repo, array $data): array + { + return $this->apiClient->request( + Method::POST, + "repos/{$owner}/{$repo}/issues", + $data + ); + } + + /** + * Aktualisiert ein Issue + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param int $index Issue Index + * @param array $data Zu aktualisierende Daten + * @return array Aktualisiertes Issue + */ + public function update(string $owner, string $repo, int $index, array $data): array + { + return $this->apiClient->request( + Method::PATCH, + "repos/{$owner}/{$repo}/issues/{$index}", + $data + ); + } + + /** + * Schließt ein Issue + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param int $index Issue Index + * @return array Aktualisiertes Issue + */ + public function close(string $owner, string $repo, int $index): array + { + return $this->update($owner, $repo, $index, ['state' => 'closed']); + } +} + diff --git a/src/Infrastructure/Api/Gitea/README.md b/src/Infrastructure/Api/Gitea/README.md new file mode 100644 index 00000000..9447c97e --- /dev/null +++ b/src/Infrastructure/Api/Gitea/README.md @@ -0,0 +1,501 @@ +# Gitea API Client + +## Übersicht + +Dieser Client bietet eine strukturierte Schnittstelle fΓΌr die Kommunikation mit der Gitea API v1. Er unterstΓΌtzt Basis-Operationen fΓΌr Repositories, User und Issues. + +## Architektur + +Der Client folgt dem Service-Layer-Pattern: + +- **GiteaApiClient**: Low-level API Client fΓΌr HTTP-Kommunikation +- **RepositoryService**: Repository-Verwaltung +- **UserService**: User-Verwaltung +- **IssueService**: Issue-Verwaltung +- **ActionService**: Workflow/Action Management fΓΌr Testing und Trigger +- **GiteaClient**: Facade, die alle Services bereitstellt + +## Konfiguration + +### Environment Variables + +```env +GITEA_URL=https://git.michaelschiemer.de +GITEA_TOKEN=your_access_token +# ODER +GITEA_USERNAME=your_username +GITEA_PASSWORD=your_password +GITEA_TIMEOUT=30.0 +``` + +### Manuelle Konfiguration + +```php +use App\Infrastructure\Api\Gitea\GiteaClient; +use App\Infrastructure\Api\Gitea\GiteaConfig; +use App\Framework\HttpClient\CurlHttpClient; + +// Mit Token +$config = new GiteaConfig( + baseUrl: 'https://git.michaelschiemer.de', + token: 'your_access_token' +); + +// Mit Username/Password +$config = new GiteaConfig( + baseUrl: 'https://git.michaelschiemer.de', + username: 'your_username', + password: 'your_password' +); + +$client = new GiteaClient($config, new CurlHttpClient()); +``` + +### Dependency Injection + +```php +use App\Infrastructure\Api\Gitea\GiteaClient; + +// Der GiteaClientInitializer lΓ€dt automatisch die Konfiguration aus Environment +$client = $container->get(GiteaClient::class); +``` + +## Quick Start + +```php +use App\Infrastructure\Api\Gitea\GiteaClient; + +$client = $container->get(GiteaClient::class); + +// Repository-Operationen +$repos = $client->repositories->list(); +$repo = $client->repositories->get('owner', 'repo-name'); + +// User-Operationen +$currentUser = $client->users->getCurrent(); +$user = $client->users->get('username'); + +// Issue-Operationen +$issues = $client->issues->list('owner', 'repo-name'); +$issue = $client->issues->get('owner', 'repo-name', 1); + +// Action/Workflow-Operationen +$workflows = $client->actions->listWorkflows('owner', 'repo-name'); +$runs = $client->actions->listRuns('owner', 'repo-name'); +$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml'); +``` + +## API Reference + +### RepositoryService + +#### list() + +Listet alle Repositories des authentifizierten Users. + +```php +$repos = $client->repositories->list(); +$repos = $client->repositories->list(['page' => 1, 'limit' => 50]); +``` + +#### get(string $owner, string $repo) + +Ruft ein Repository ab. + +```php +$repo = $client->repositories->get('owner', 'repo-name'); +``` + +#### create(array $data) + +Erstellt ein neues Repository. + +```php +$repo = $client->repositories->create([ + 'name' => 'my-repo', + 'description' => 'My repository description', + 'private' => true, + 'auto_init' => false, + 'default_branch' => 'main' +]); +``` + +#### update(string $owner, string $repo, array $data) + +Aktualisiert ein Repository. + +```php +$repo = $client->repositories->update('owner', 'repo-name', [ + 'description' => 'Updated description', + 'private' => false +]); +``` + +#### delete(string $owner, string $repo) + +LΓΆscht ein Repository. + +```php +$client->repositories->delete('owner', 'repo-name'); +``` + +### UserService + +#### getCurrent() + +Ruft den aktuellen authentifizierten User ab. + +```php +$user = $client->users->getCurrent(); +``` + +#### get(string $username) + +Ruft einen User anhand des Usernames ab. + +```php +$user = $client->users->get('username'); +``` + +#### list(array $options = []) + +Sucht nach Usern. + +```php +$users = $client->users->list(['q' => 'search-term', 'page' => 1, 'limit' => 50]); +``` + +### IssueService + +#### list(string $owner, string $repo, array $options = []) + +Listet alle Issues eines Repositories. + +```php +$issues = $client->issues->list('owner', 'repo-name'); +$issues = $client->issues->list('owner', 'repo-name', [ + 'state' => 'open', + 'labels' => 'bug', + 'page' => 1, + 'limit' => 50 +]); +``` + +#### get(string $owner, string $repo, int $index) + +Ruft ein Issue ab. + +```php +$issue = $client->issues->get('owner', 'repo-name', 1); +``` + +#### create(string $owner, string $repo, array $data) + +Erstellt ein neues Issue. + +```php +$issue = $client->issues->create('owner', 'repo-name', [ + 'title' => 'Bug Report', + 'body' => 'Issue description', + 'assignees' => ['username'], + 'labels' => [1, 2, 3] +]); +``` + +#### update(string $owner, string $repo, int $index, array $data) + +Aktualisiert ein Issue. + +```php +$issue = $client->issues->update('owner', 'repo-name', 1, [ + 'title' => 'Updated title', + 'body' => 'Updated description', + 'state' => 'open' +]); +``` + +#### close(string $owner, string $repo, int $index) + +Schließt ein Issue. + +```php +$issue = $client->issues->close('owner', 'repo-name', 1); +``` + +### ActionService + +#### listWorkflows(string $owner, string $repo) + +Listet alle Workflows eines Repositories. + +```php +$workflows = $client->actions->listWorkflows('owner', 'repo-name'); +``` + +#### listRuns(string $owner, string $repo, array $options = []) + +Listet alle Workflow Runs eines Repositories. + +```php +$runs = $client->actions->listRuns('owner', 'repo-name'); +$runs = $client->actions->listRuns('owner', 'repo-name', [ + 'status' => 'success', + 'workflow_id' => 1, + 'page' => 1, + 'limit' => 50 +]); +``` + +#### getRun(string $owner, string $repo, int $runId) + +Ruft Details eines Workflow Runs ab. + +```php +$run = $client->actions->getRun('owner', 'repo-name', 123); +``` + +#### triggerWorkflow(string $owner, string $repo, string $workflowId, array $inputs = [], ?string $ref = null) + +Triggert einen Workflow manuell. + +```php +// Workflow ohne Inputs triggern +$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml'); + +// Workflow mit Inputs triggern +$client->actions->triggerWorkflow('owner', 'repo-name', 'deploy.yml', [ + 'environment' => 'production', + 'skip_tests' => false +]); + +// Workflow auf spezifischem Branch triggern +$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml', [], 'develop'); +``` + +#### cancelRun(string $owner, string $repo, int $runId) + +Bricht einen laufenden Workflow Run ab. + +```php +$client->actions->cancelRun('owner', 'repo-name', 123); +``` + +#### getLogs(string $owner, string $repo, int $runId) + +Ruft die Logs eines Workflow Runs ab. + +```php +$logs = $client->actions->getLogs('owner', 'repo-name', 123); +echo $logs; // Logs als Text +``` + +#### getRunStatus(string $owner, string $repo, int $runId) + +Ruft den Status eines Workflow Runs ab (Helper-Methode). + +```php +$status = $client->actions->getRunStatus('owner', 'repo-name', 123); +// MΓΆgliche Werte: "success", "failure", "cancelled", "running", "waiting" +``` + +## Authentifizierung + +Der Client unterstΓΌtzt zwei Authentifizierungsmethoden: + +### Token-Authentifizierung (empfohlen) + +```php +$config = new GiteaConfig( + baseUrl: 'https://git.michaelschiemer.de', + token: 'your_access_token' +); +``` + +### Basic Authentication + +```php +$config = new GiteaConfig( + baseUrl: 'https://git.michaelschiemer.de', + username: 'your_username', + password: 'your_password' +); +``` + +## Fehlerbehandlung + +Alle API-Clients werfen eine standardisierte `ApiException` bei Fehlern: + +```php +use App\Framework\Api\ApiException; + +try { + $repo = $client->repositories->get('owner', 'repo-name'); +} catch (ApiException $e) { + echo "Error: " . $e->getMessage(); + echo "Status Code: " . $e->getCode(); + // Zugriff auf Response-Daten + $responseData = $e->getResponseData(); +} +``` + +## API-Endpunkte + +Der Client nutzt die folgenden Gitea API v1 Endpunkte: + +- `GET /api/v1/user/repos` - Repositories auflisten +- `GET /api/v1/repos/{owner}/{repo}` - Repository abrufen +- `POST /api/v1/user/repos` - Repository erstellen +- `PATCH /api/v1/repos/{owner}/{repo}` - Repository aktualisieren +- `DELETE /api/v1/repos/{owner}/{repo}` - Repository lΓΆschen +- `GET /api/v1/user` - Aktueller User +- `GET /api/v1/users/{username}` - User abrufen +- `GET /api/v1/users/search` - User suchen +- `GET /api/v1/repos/{owner}/{repo}/issues` - Issues auflisten +- `GET /api/v1/repos/{owner}/{repo}/issues/{index}` - Issue abrufen +- `POST /api/v1/repos/{owner}/{repo}/issues` - Issue erstellen +- `PATCH /api/v1/repos/{owner}/{repo}/issues/{index}` - Issue aktualisieren +- `GET /api/v1/repos/{owner}/{repo}/actions/workflows` - Workflows auflisten +- `GET /api/v1/repos/{owner}/{repo}/actions/runs` - Runs auflisten +- `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}` - Run Details +- `POST /api/v1/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches` - Workflow triggern +- `POST /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` - Run abbrechen +- `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/logs` - Run Logs + +## Beispiele + +### Repository-Verwaltung + +```php +// Alle Repositories auflisten +$repos = $client->repositories->list(); + +// Neues Repository erstellen +$newRepo = $client->repositories->create([ + 'name' => 'my-new-repo', + 'description' => 'A new repository', + 'private' => true, + 'auto_init' => true +]); + +// Repository aktualisieren +$updatedRepo = $client->repositories->update('owner', 'repo-name', [ + 'description' => 'Updated description' +]); + +// Repository lΓΆschen +$client->repositories->delete('owner', 'repo-name'); +``` + +### Issue-Verwaltung + +```php +// Alle Issues auflisten +$openIssues = $client->issues->list('owner', 'repo-name', ['state' => 'open']); + +// Neues Issue erstellen +$issue = $client->issues->create('owner', 'repo-name', [ + 'title' => 'Bug: Something is broken', + 'body' => 'Detailed description of the bug', + 'labels' => [1] // Label ID +]); + +// Issue schließen +$closedIssue = $client->issues->close('owner', 'repo-name', 1); +``` + +### User-Informationen + +```php +// Aktuellen User abrufen +$currentUser = $client->users->getCurrent(); +echo "Logged in as: " . $currentUser['login']; + +// User suchen +$users = $client->users->list(['q' => 'john', 'limit' => 10]); +``` + +### Workflow/Action Management + +```php +// Alle Workflows auflisten +$workflows = $client->actions->listWorkflows('owner', 'repo-name'); + +// Alle Runs auflisten +$runs = $client->actions->listRuns('owner', 'repo-name', ['status' => 'running']); + +// Workflow manuell triggern (fΓΌr Testing) +$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml', [ + 'skip_tests' => false, + 'environment' => 'staging' +], 'main'); + +// Run Status prΓΌfen +$status = $client->actions->getRunStatus('owner', 'repo-name', $runId); +if ($status === 'running') { + echo "Workflow lΓ€uft noch..."; +} + +// Run Logs abrufen +$logs = $client->actions->getLogs('owner', 'repo-name', $runId); +file_put_contents('workflow-logs.txt', $logs); + +// Laufenden Run abbrechen +$client->actions->cancelRun('owner', 'repo-name', $runId); +``` + +### Workflow Testing Workflow + +```php +// 1. Workflow triggern +$client->actions->triggerWorkflow('owner', 'repo-name', 'test.yml', [], 'test-branch'); + +// 2. Warten und Status prΓΌfen +do { + sleep(5); + $runs = $client->actions->listRuns('owner', 'repo-name', ['limit' => 1]); + $latestRun = $runs['workflow_runs'][0] ?? null; + $status = $latestRun['status'] ?? 'unknown'; +} while ($status === 'running' || $status === 'waiting'); + +// 3. Logs abrufen wenn abgeschlossen +if ($latestRun) { + $logs = $client->actions->getLogs('owner', 'repo-name', $latestRun['id']); + echo $logs; +} +``` + +## Best Practices + +1. **Token-Authentifizierung bevorzugen**: Token sind sicherer als Username/Password +2. **Fehlerbehandlung**: Immer `ApiException` abfangen +3. **Pagination**: Bei großen Listen Pagination-Parameter verwenden +4. **Rate Limiting**: Gitea API Rate Limits beachten +5. **Dependency Injection**: Client ΓΌber DI Container verwenden + +## Troubleshooting + +### "Invalid authentication credentials" + +- ÜberprΓΌfe, ob Token oder Username/Password korrekt sind +- Stelle sicher, dass der Token die benΓΆtigten Berechtigungen hat + +### "Repository not found" + +- ÜberprΓΌfe, ob Owner und Repository-Name korrekt sind +- Stelle sicher, dass der User Zugriff auf das Repository hat + +### "API rate limit exceeded" + +- Reduziere die Anzahl der API-Aufrufe +- Implementiere Retry-Logik mit Exponential Backoff + +### "Workflow not found" + +- ÜberprΓΌfe, ob der Workflow-Dateiname korrekt ist (z.B. "ci.yml") +- Stelle sicher, dass der Workflow im `.gitea/workflows/` Verzeichnis existiert + +### "Workflow run already completed" + +- Workflow Runs kΓΆnnen nur abgebrochen werden, wenn sie noch laufen +- PrΓΌfe den Status mit `getRunStatus()` vor dem Abbrechen + diff --git a/src/Infrastructure/Api/Gitea/RepositoryService.php b/src/Infrastructure/Api/Gitea/RepositoryService.php new file mode 100644 index 00000000..eac008c3 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/RepositoryService.php @@ -0,0 +1,94 @@ +apiClient->request( + Method::GET, + 'user/repos', + [], + $options + ); + } + + /** + * Ruft ein Repository ab + * + * @param string $owner Repository Owner (Username oder Organization) + * @param string $repo Repository Name + * @return array Repository-Daten + */ + public function get(string $owner, string $repo): array + { + return $this->apiClient->request( + Method::GET, + "repos/{$owner}/{$repo}" + ); + } + + /** + * Erstellt ein neues Repository + * + * @param array $data Repository-Daten (name, description, private, auto_init, etc.) + * @return array Erstelltes Repository + */ + public function create(array $data): array + { + return $this->apiClient->request( + Method::POST, + 'user/repos', + $data + ); + } + + /** + * Aktualisiert ein Repository + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @param array $data Zu aktualisierende Daten + * @return array Aktualisiertes Repository + */ + public function update(string $owner, string $repo, array $data): array + { + return $this->apiClient->request( + Method::PATCH, + "repos/{$owner}/{$repo}", + $data + ); + } + + /** + * LΓΆscht ein Repository + * + * @param string $owner Repository Owner + * @param string $repo Repository Name + * @return void + */ + public function delete(string $owner, string $repo): void + { + $this->apiClient->sendRawRequest( + Method::DELETE, + "repos/{$owner}/{$repo}" + ); + } +} + diff --git a/src/Infrastructure/Api/Gitea/UserService.php b/src/Infrastructure/Api/Gitea/UserService.php new file mode 100644 index 00000000..42c647d0 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/UserService.php @@ -0,0 +1,59 @@ +apiClient->request( + Method::GET, + 'user' + ); + } + + /** + * Ruft einen User anhand des Usernames ab + * + * @param string $username Username + * @return array User-Daten + */ + public function get(string $username): array + { + return $this->apiClient->request( + Method::GET, + "users/{$username}" + ); + } + + /** + * Sucht nach Usern + * + * @param array $options Optionale Parameter (q, page, limit, etc.) + * @return array Liste der User + */ + public function list(array $options = []): array + { + return $this->apiClient->request( + Method::GET, + 'users/search', + [], + $options + ); + } +} + diff --git a/src/Infrastructure/Api/Gitea/ValueObjects/RunConclusion.php b/src/Infrastructure/Api/Gitea/ValueObjects/RunConclusion.php new file mode 100644 index 00000000..43adda36 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/ValueObjects/RunConclusion.php @@ -0,0 +1,42 @@ +value === $other->value; + } + + /** + * Convert to string representation + */ + public function toString(): string + { + return (string) $this->value; + } + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/src/Infrastructure/Api/Gitea/ValueObjects/RunStatus.php b/src/Infrastructure/Api/Gitea/ValueObjects/RunStatus.php new file mode 100644 index 00000000..8c3527b6 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/ValueObjects/RunStatus.php @@ -0,0 +1,37 @@ + true, + self::COMPLETED => false, + }; + } +} diff --git a/src/Infrastructure/Api/Gitea/ValueObjects/Workflow.php b/src/Infrastructure/Api/Gitea/ValueObjects/Workflow.php new file mode 100644 index 00000000..a94ab99f --- /dev/null +++ b/src/Infrastructure/Api/Gitea/ValueObjects/Workflow.php @@ -0,0 +1,70 @@ +state === 'active'; + } + + /** + * Check if the workflow is disabled + */ + public function isDisabled(): bool + { + return $this->state === 'disabled'; + } + + /** + * Get the workflow file name + */ + public function getFileName(): string + { + return basename($this->path); + } + + /** + * Convert to array representation + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'path' => $this->path, + 'state' => $this->state, + ]; + } +} diff --git a/src/Infrastructure/Api/Gitea/ValueObjects/WorkflowList.php b/src/Infrastructure/Api/Gitea/ValueObjects/WorkflowList.php new file mode 100644 index 00000000..c7592921 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/ValueObjects/WorkflowList.php @@ -0,0 +1,153 @@ +workflows = array_values($workflows); + } + + /** + * Create from Gitea API response + */ + public static function fromApiResponse(array $data): self + { + $workflows = []; + + foreach ($data['workflows'] ?? [] as $workflowData) { + $workflows[] = Workflow::fromApiResponse($workflowData); + } + + return new self($workflows); + } + + /** + * Get all workflows + * + * @return Workflow[] + */ + public function all(): array + { + return $this->workflows; + } + + /** + * Get active workflows + * + * @return Workflow[] + */ + public function active(): array + { + return array_filter( + $this->workflows, + fn(Workflow $workflow) => $workflow->isActive() + ); + } + + /** + * Get disabled workflows + * + * @return Workflow[] + */ + public function disabled(): array + { + return array_filter( + $this->workflows, + fn(Workflow $workflow) => $workflow->isDisabled() + ); + } + + /** + * Find workflow by ID + */ + public function findById(int $id): ?Workflow + { + foreach ($this->workflows as $workflow) { + if ($workflow->id === $id) { + return $workflow; + } + } + + return null; + } + + /** + * Find workflow by name + */ + public function findByName(string $name): ?Workflow + { + foreach ($this->workflows as $workflow) { + if ($workflow->name === $name) { + return $workflow; + } + } + + return null; + } + + /** + * Check if list is empty + */ + public function isEmpty(): bool + { + return empty($this->workflows); + } + + /** + * Get number of workflows + */ + public function count(): int + { + return count($this->workflows); + } + + /** + * Get iterator for foreach support + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->workflows); + } + + /** + * Convert to array representation + */ + public function toArray(): array + { + return [ + 'workflows' => array_map( + fn(Workflow $workflow) => $workflow->toArray(), + $this->workflows + ), + 'total_count' => $this->count(), + ]; + } +} diff --git a/src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRun.php b/src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRun.php new file mode 100644 index 00000000..791fd263 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRun.php @@ -0,0 +1,154 @@ +status === RunStatus::COMPLETED + && $this->conclusion?->isSuccessful() === true; + } + + /** + * Check if the workflow run failed + */ + public function isFailed(): bool + { + return $this->status === RunStatus::COMPLETED + && $this->conclusion?->isFailed() === true; + } + + /** + * Check if the workflow run is currently executing + */ + public function isRunning(): bool + { + return $this->status->isActive(); + } + + /** + * Check if the workflow run is completed (any conclusion) + */ + public function isCompleted(): bool + { + return $this->status === RunStatus::COMPLETED; + } + + /** + * Check if the workflow run was cancelled + */ + public function wasCancelled(): bool + { + return $this->conclusion?->wasCancelled() === true; + } + + /** + * Get the duration of the workflow run + * + * Returns null if the run hasn't completed yet. + */ + public function getDuration(): ?Duration + { + if ($this->completedAt === null) { + return null; + } + + return Duration::between($this->startedAt, $this->completedAt); + } + + /** + * Get the elapsed time since the run started + * + * Returns duration even for running workflows. + */ + public function getElapsedTime(): Duration + { + $endTime = $this->completedAt ?? Timestamp::now(); + return Duration::between($this->startedAt, $endTime); + } + + /** + * Get a human-readable status summary + */ + public function getStatusSummary(): string + { + return match (true) { + $this->isSuccessful() => "βœ… Successful", + $this->isFailed() => "❌ Failed", + $this->wasCancelled() => "🚫 Cancelled", + $this->isRunning() => "πŸ”„ Running", + default => "⏳ {$this->status->value}", + }; + } + + /** + * Convert to array representation (compatible with API format) + */ + public function toArray(): array + { + return [ + 'id' => $this->id->value, + 'display_title' => $this->displayTitle, + 'status' => $this->status->value, + 'conclusion' => $this->conclusion?->value, + 'started_at' => $this->startedAt->format('Y-m-d H:i:s'), + 'completed_at' => $this->completedAt?->format('Y-m-d H:i:s'), + 'head_branch' => $this->headBranch, + 'head_sha' => $this->headSha, + 'run_number' => $this->runNumber, + 'event' => $this->event, + 'name' => $this->name, + ]; + } +} diff --git a/src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRunsList.php b/src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRunsList.php new file mode 100644 index 00000000..96670b48 --- /dev/null +++ b/src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRunsList.php @@ -0,0 +1,207 @@ +runs = array_values($runs); + } + + /** + * Create from Gitea API response + */ + public static function fromApiResponse(array $data): self + { + $runs = []; + + foreach ($data['workflow_runs'] ?? [] as $runData) { + $runs[] = WorkflowRun::fromApiResponse($runData); + } + + return new self($runs); + } + + /** + * Get all runs + * + * @return WorkflowRun[] + */ + public function all(): array + { + return $this->runs; + } + + /** + * Get runs that are currently running + * + * @return WorkflowRun[] + */ + public function running(): array + { + return array_filter( + $this->runs, + fn(WorkflowRun $run) => $run->isRunning() + ); + } + + /** + * Get runs that completed successfully + * + * @return WorkflowRun[] + */ + public function successful(): array + { + return array_filter( + $this->runs, + fn(WorkflowRun $run) => $run->isSuccessful() + ); + } + + /** + * Get runs that failed + * + * @return WorkflowRun[] + */ + public function failed(): array + { + return array_filter( + $this->runs, + fn(WorkflowRun $run) => $run->isFailed() + ); + } + + /** + * Find run by ID + */ + public function findById(RunId $id): ?WorkflowRun + { + foreach ($this->runs as $run) { + if ($run->id->equals($id)) { + return $run; + } + } + + return null; + } + + /** + * Get the latest run + */ + public function latest(): ?WorkflowRun + { + if (empty($this->runs)) { + return null; + } + + return $this->runs[0]; + } + + /** + * Filter runs by branch + * + * @return WorkflowRun[] + */ + public function forBranch(string $branch): array + { + return array_filter( + $this->runs, + fn(WorkflowRun $run) => $run->headBranch === $branch + ); + } + + /** + * Get count of successful runs + */ + public function successCount(): int + { + return count($this->successful()); + } + + /** + * Get count of failed runs + */ + public function failureCount(): int + { + return count($this->failed()); + } + + /** + * Calculate success rate (0.0 to 1.0) + */ + public function successRate(): float + { + $total = $this->count(); + + if ($total === 0) { + return 0.0; + } + + return $this->successCount() / $total; + } + + /** + * Check if list is empty + */ + public function isEmpty(): bool + { + return empty($this->runs); + } + + /** + * Get number of runs + */ + public function count(): int + { + return count($this->runs); + } + + /** + * Get iterator for foreach support + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->runs); + } + + /** + * Convert to array representation + */ + public function toArray(): array + { + return [ + 'workflow_runs' => array_map( + fn(WorkflowRun $run) => $run->toArray(), + $this->runs + ), + 'total_count' => $this->count(), + ]; + } +} diff --git a/src/Infrastructure/Api/README.md b/src/Infrastructure/Api/README.md index fd20913a..26bdc267 100644 --- a/src/Infrastructure/Api/README.md +++ b/src/Infrastructure/Api/README.md @@ -47,6 +47,44 @@ $client = new GitHubClient('github_personal_access_token'); $repo = $client->getRepository('username', 'repo-name'); ``` +### GiteaClient + +Integration mit der Gitea API v1 fΓΌr Repository-, User- und Issue-Verwaltung: + +```php +use App\Infrastructure\Api\Gitea\GiteaClient; + +// Über Dependency Injection (empfohlen) +$client = $container->get(GiteaClient::class); + +// Oder manuell +use App\Infrastructure\Api\Gitea\GiteaConfig; +use App\Framework\HttpClient\CurlHttpClient; + +$config = new GiteaConfig( + baseUrl: 'https://git.michaelschiemer.de', + token: 'your_access_token' +); +$client = new GiteaClient($config, new CurlHttpClient()); + +// Repository-Operationen +$repos = $client->repositories->list(); +$repo = $client->repositories->get('owner', 'repo-name'); + +// User-Operationen +$currentUser = $client->users->getCurrent(); +$user = $client->users->get('username'); + +// Issue-Operationen +$issues = $client->issues->list('owner', 'repo-name'); +$issue = $client->issues->create('owner', 'repo-name', [ + 'title' => 'New Issue', + 'body' => 'Issue description' +]); +``` + +Siehe [Gitea/README.md](Gitea/README.md) fΓΌr detaillierte Dokumentation. + ## Implementierung eines neuen API-Clients Neue API-Clients kΓΆnnen einfach durch Verwendung des `ApiRequestTrait` erstellt werden: diff --git a/src/Infrastructure/Database/Migrations/CreateComponentStateTable.php b/src/Infrastructure/Database/Migrations/CreateComponentStateTable.php index f3cc2f48..89bf9eb6 100644 --- a/src/Infrastructure/Database/Migrations/CreateComponentStateTable.php +++ b/src/Infrastructure/Database/Migrations/CreateComponentStateTable.php @@ -56,7 +56,7 @@ final readonly class CreateComponentStateTable implements Migration public function getVersion(): MigrationVersion { - return MigrationVersion::fromString('2024_12_20_120000'); + return MigrationVersion::fromTimestamp('2024_12_20_120000'); } public function getDescription(): string diff --git a/src/Infrastructure/Storage/MinIoClient.php b/src/Infrastructure/Storage/MinIoClient.php new file mode 100644 index 00000000..435bd176 --- /dev/null +++ b/src/Infrastructure/Storage/MinIoClient.php @@ -0,0 +1,690 @@ +endpoint = rtrim($endpoint, '/'); + } + + /** + * Upload object to bucket + * + * @param array $headers Additional headers (e.g., Content-Type) + * @return array{etag: string, size: int, contentType: ?string} + */ + public function putObject(string $bucket, string $key, string $body, array $headers = []): array + { + $url = $this->buildUrl($bucket, $key); + $requestHeaders = $this->buildHeaders($headers); + $payloadHash = Hash::sha256($body)->toString(); + + $signedRequest = $this->signRequest( + method: Method::PUT, + url: $url, + headers: $requestHeaders, + payloadHash: $payloadHash, + body: $body + ); + + $response = $this->sendRequest($signedRequest); + + if (! $response->status->isSuccess()) { + throw StorageOperationException::for('put', $bucket, $key, "HTTP {$response->status->value}"); + } + + $etag = $this->extractEtag($response->headers); + $contentType = $requestHeaders->get('Content-Type')?->value() ?? null; + + return [ + 'etag' => $etag, + 'size' => strlen($body), + 'contentType' => $contentType, + ]; + } + + /** + * Download object from bucket + */ + public function getObject(string $bucket, string $key): string + { + $url = $this->buildUrl($bucket, $key); + $requestHeaders = $this->buildHeaders([]); + $payloadHash = Hash::sha256('')->toString(); // Empty body for GET + + $signedRequest = $this->signRequest( + method: Method::GET, + url: $url, + headers: $requestHeaders, + payloadHash: $payloadHash + ); + + $response = $this->sendRequest($signedRequest); + + if ($response->status->value === 404) { + throw StorageOperationException::for('get', $bucket, $key, 'Object not found'); + } + + if (! $response->status->isSuccess()) { + throw StorageOperationException::for('get', $bucket, $key, "HTTP {$response->status->value}"); + } + + return $response->body; + } + + /** + * Get object metadata (HEAD request) + * + * @return array{etag: ?string, size: ?int, contentType: ?string, lastModified: ?int} + */ + public function headObject(string $bucket, string $key): array + { + $url = $this->buildUrl($bucket, $key); + $requestHeaders = $this->buildHeaders([]); + $payloadHash = Hash::sha256('')->toString(); + + $signedRequest = $this->signRequest( + method: Method::HEAD, + url: $url, + headers: $requestHeaders, + payloadHash: $payloadHash + ); + + $response = $this->sendRequest($signedRequest); + + if ($response->status->value === 404) { + throw StorageOperationException::for('head', $bucket, $key, 'Object not found'); + } + + if (! $response->status->isSuccess()) { + throw StorageOperationException::for('head', $bucket, $key, "HTTP {$response->status->value}"); + } + + $etag = $this->extractEtag($response->headers); + $size = $this->extractSize($response->headers); + $contentType = $this->extractContentType($response->headers); + $lastModified = $this->extractLastModified($response->headers); + + return [ + 'etag' => $etag, + 'size' => $size, + 'contentType' => $contentType, + 'lastModified' => $lastModified, + ]; + } + + /** + * Delete object from bucket + */ + public function deleteObject(string $bucket, string $key): void + { + $url = $this->buildUrl($bucket, $key); + $requestHeaders = $this->buildHeaders([]); + $payloadHash = Hash::sha256('')->toString(); + + $signedRequest = $this->signRequest( + method: Method::DELETE, + url: $url, + headers: $requestHeaders, + payloadHash: $payloadHash + ); + + $response = $this->sendRequest($signedRequest); + + if ($response->status->value === 404) { + // Object doesn't exist, but that's OK for delete operations + return; + } + + if (! $response->status->isSuccess()) { + throw StorageOperationException::for('delete', $bucket, $key, "HTTP {$response->status->value}"); + } + } + + /** + * Check if object exists + */ + public function objectExists(string $bucket, string $key): bool + { + try { + $this->headObject($bucket, $key); + + return true; + } catch (StorageOperationException $e) { + if ($e->getCode() === 404) { + return false; + } + + throw $e; + } + } + + /** + * Stream object content to destination + * + * Streams HTTP response directly to a writable stream resource. + * Useful for large file downloads without loading entire response into memory. + * + * @param string $bucket Bucket name + * @param string $key Object key + * @param resource $destination Writable stream resource + * @param array $opts Optional parameters (e.g., 'bufferSize') + * @return int Number of bytes written + * @throws StorageOperationException + */ + public function getObjectToStream(string $bucket, string $key, $destination, array $opts = []): int + { + if (! is_resource($destination)) { + throw StorageOperationException::for('getObjectToStream', $bucket, $key, 'Invalid destination stream'); + } + + $url = $this->buildUrl($bucket, $key); + $requestHeaders = $this->buildHeaders([]); + $payloadHash = Hash::sha256('')->toString(); // Empty body for GET + + $signedRequest = $this->signRequest( + method: Method::GET, + url: $url, + headers: $requestHeaders, + payloadHash: $payloadHash + ); + + try { + $streamingResponse = $this->httpClient->sendStreaming($signedRequest, $destination); + + if ($streamingResponse->status->value === 404) { + throw StorageOperationException::for('getObjectToStream', $bucket, $key, 'Object not found'); + } + + if (! $streamingResponse->status->isSuccess()) { + throw StorageOperationException::for('getObjectToStream', $bucket, $key, "HTTP {$streamingResponse->status->value}"); + } + + return $streamingResponse->bytesWritten; + } catch (StorageOperationException $e) { + throw $e; + } catch (\Throwable $e) { + throw StorageConnectionException::for($this->endpoint, $e->getMessage(), $e); + } + } + + /** + * Upload object from stream + * + * Streams HTTP request body from a readable stream resource. + * Useful for large file uploads without loading entire file into memory. + * + * @param string $bucket Bucket name + * @param string $key Object key + * @param resource $source Readable stream resource + * @param array $opts Optional parameters: + * - 'headers' => array Additional headers (e.g., Content-Type) + * - 'contentLength' => int Content-Length in bytes (null for chunked transfer) + * @return array{etag: string, size: int, contentType: ?string} + * @throws StorageOperationException + */ + public function putObjectFromStream(string $bucket, string $key, $source, array $opts = []): array + { + if (! is_resource($source)) { + throw StorageOperationException::for('putObjectFromStream', $bucket, $key, 'Invalid source stream'); + } + + $url = $this->buildUrl($bucket, $key); + $additionalHeaders = $opts['headers'] ?? []; + $requestHeaders = $this->buildHeaders($additionalHeaders); + + // Try to get content length if available + $contentLength = $opts['contentLength'] ?? $this->getStreamSize($source); + + // For streaming uploads, we use "UNSIGNED-PAYLOAD" for AWS SigV4 + // This allows streaming without reading the entire stream to calculate hash + $payloadHash = 'UNSIGNED-PAYLOAD'; + + $signedRequest = $this->signRequest( + method: Method::PUT, + url: $url, + headers: $requestHeaders, + payloadHash: $payloadHash + ); + + try { + $response = $this->httpClient->sendStreamingUpload($signedRequest, $source, $contentLength); + + if (! $response->status->isSuccess()) { + throw StorageOperationException::for('putObjectFromStream', $bucket, $key, "HTTP {$response->status->value}"); + } + + $etag = $this->extractEtag($response->headers); + $contentType = $requestHeaders->get('Content-Type')?->value() ?? null; + + return [ + 'etag' => $etag, + 'size' => $contentLength ?? 0, + 'contentType' => $contentType, + ]; + } catch (StorageOperationException $e) { + throw $e; + } catch (\Throwable $e) { + throw StorageConnectionException::for($this->endpoint, $e->getMessage(), $e); + } + } + + /** + * Get stream size if available + * + * @param resource $stream + * @return int|null Size in bytes, or null if not available + */ + private function getStreamSize($stream): ?int + { + $meta = stream_get_meta_data($stream); + $uri = $meta['uri'] ?? null; + + if ($uri !== null && file_exists($uri)) { + $size = filesize($uri); + if ($size !== false) { + return $size; + } + } + + // Try to get size from fstat + $stat = @fstat($stream); + if ($stat !== false && isset($stat['size'])) { + return $stat['size']; + } + + return null; + } + + /** + * Create presigned URL for temporary access + */ + public function createPresignedUrl(string $bucket, string $key, \DateInterval $ttl): string + { + $url = $this->buildUrl($bucket, $key); + $now = time(); + $expires = $now + ($ttl->days * 86400) + ($ttl->h * 3600) + ($ttl->i * 60) + $ttl->s; + + // Parse URL to add query parameters + $parsedUrl = parse_url($url); + $queryParams = []; + if (isset($parsedUrl['query'])) { + parse_str($parsedUrl['query'], $queryParams); + } + + $amzDate = gmdate('Ymd\THis\Z', $now); + $dateStamp = gmdate('Ymd', $now); + + $queryParams['X-Amz-Algorithm'] = self::ALGORITHM; + $queryParams['X-Amz-Credential'] = $this->buildCredential($now); + $queryParams['X-Amz-Date'] = $amzDate; + $queryParams['X-Amz-Expires'] = (string) ($expires - $now); + $queryParams['X-Amz-SignedHeaders'] = 'host'; + + $queryString = http_build_query($queryParams); + $presignedUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host']; + if (isset($parsedUrl['port'])) { + $presignedUrl .= ':' . $parsedUrl['port']; + } + $presignedUrl .= $parsedUrl['path'] . '?' . $queryString; + + // Sign the request + $headers = new Headers(); + $payloadHash = Hash::sha256('')->toString(); + $signature = $this->calculateSignature( + method: Method::GET, + canonicalUri: $parsedUrl['path'], + canonicalQueryString: $queryString, + canonicalHeaders: $this->buildCanonicalHeaders($headers), + signedHeaders: 'host', + payloadHash: $payloadHash, + timestamp: $now + ); + + $presignedUrl .= '&X-Amz-Signature=' . $signature; + + return $presignedUrl; + } + + /** + * Build URL for bucket/key + */ + private function buildUrl(string $bucket, string $key): string + { + $key = ltrim($key, '/'); + $encodedKey = $this->encodeKey($key); + + if ($this->usePathStyle) { + // Path-style: http://endpoint/bucket/key + return $this->endpoint . '/' . $bucket . '/' . $encodedKey; + } + + // Virtual-host-style: http://bucket.endpoint/key + $host = parse_url($this->endpoint, PHP_URL_HOST); + $port = parse_url($this->endpoint, PHP_URL_PORT); + $scheme = parse_url($this->endpoint, PHP_URL_SCHEME) ?? 'http'; + + $url = $scheme . '://' . $bucket . '.' . $host; + if ($port !== null) { + $url .= ':' . $port; + } + $url .= '/' . $encodedKey; + + return $url; + } + + /** + * URL-encode key (S3-style encoding) + */ + private function encodeKey(string $key): string + { + // S3 encoding: preserve /, encode everything else + return str_replace('%2F', '/', rawurlencode($key)); + } + + /** + * Build headers with required S3 headers + * + * @param array $additionalHeaders + */ + private function buildHeaders(array $additionalHeaders): Headers + { + $headers = new Headers(); + + foreach ($additionalHeaders as $name => $value) { + $headers = $headers->with($name, $value); + } + + // Add host header + $host = parse_url($this->endpoint, PHP_URL_HOST); + $port = parse_url($this->endpoint, PHP_URL_PORT); + $hostHeader = $port !== null ? "{$host}:{$port}" : $host; + $headers = $headers->with('Host', $hostHeader); + + return $headers; + } + + /** + * Sign request with AWS Signature Version 4 + */ + private function signRequest( + Method $method, + string $url, + Headers $headers, + string $payloadHash, + string $body = '' + ): ClientRequest { + $parsedUrl = parse_url($url); + $canonicalUri = $parsedUrl['path'] ?? '/'; + $canonicalQueryString = $parsedUrl['query'] ?? ''; + + // Build canonical headers + $canonicalHeaders = $this->buildCanonicalHeaders($headers); + $signedHeaders = $this->buildSignedHeaders($headers); + + // Create canonical request + $canonicalRequest = $this->buildCanonicalRequest( + method: $method->value, + canonicalUri: $canonicalUri, + canonicalQueryString: $canonicalQueryString, + canonicalHeaders: $canonicalHeaders, + signedHeaders: $signedHeaders, + payloadHash: $payloadHash + ); + + // Create string to sign + $timestamp = time(); + $dateStamp = gmdate('Ymd', $timestamp); + $amzDate = gmdate('Ymd\THis\Z', $timestamp); + $credentialScope = "{$dateStamp}/{$this->region}/" . self::SERVICE_NAME . '/aws4_request'; + + $stringToSign = self::ALGORITHM . "\n" + . $amzDate . "\n" + . $credentialScope . "\n" + . Hash::sha256($canonicalRequest)->toString(); + + // Calculate signature + $signature = $this->calculateSignature( + method: $method, + canonicalUri: $canonicalUri, + canonicalQueryString: $canonicalQueryString, + canonicalHeaders: $canonicalHeaders, + signedHeaders: $signedHeaders, + payloadHash: $payloadHash, + timestamp: $timestamp + ); + + // Add Authorization header + $authorization = self::SIGNATURE_VERSION . ' ' + . 'Credential=' . $this->accessKey . '/' . $credentialScope . ', ' + . 'SignedHeaders=' . $signedHeaders . ', ' + . 'Signature=' . $signature; + + $signedHeaders = $headers->with('Authorization', $authorization); + $signedHeaders = $signedHeaders->with('X-Amz-Date', $amzDate); + $signedHeaders = $signedHeaders->with('X-Amz-Content-Sha256', $payloadHash); + + return new ClientRequest( + method: $method, + url: $url, + headers: $signedHeaders, + body: $body + ); + } + + /** + * Build canonical headers string + */ + private function buildCanonicalHeaders(Headers $headers): string + { + $canonicalHeaders = []; + $allHeaders = $headers->all(); + + foreach ($allHeaders as $name => $value) { + $lowerName = strtolower($name); + $canonicalHeaders[$lowerName] = trim($value); + } + + // Sort by header name + ksort($canonicalHeaders); + + $result = ''; + foreach ($canonicalHeaders as $name => $value) { + $result .= $name . ':' . $value . "\n"; + } + + return $result; + } + + /** + * Build signed headers string (semicolon-separated list of header names) + */ + private function buildSignedHeaders(Headers $headers): string + { + $headerNames = []; + foreach (array_keys($headers->all()) as $name) { + $headerNames[] = strtolower($name); + } + + sort($headerNames); + + return implode(';', $headerNames); + } + + /** + * Build canonical request string + */ + private function buildCanonicalRequest( + string $method, + string $canonicalUri, + string $canonicalQueryString, + string $canonicalHeaders, + string $signedHeaders, + string $payloadHash + ): string { + return $method . "\n" + . $canonicalUri . "\n" + . $canonicalQueryString . "\n" + . $canonicalHeaders . "\n" + . $signedHeaders . "\n" + . $payloadHash; + } + + /** + * Calculate AWS Signature Version 4 signature + */ + private function calculateSignature( + Method $method, + string $canonicalUri, + string $canonicalQueryString, + string $canonicalHeaders, + string $signedHeaders, + string $payloadHash, + int $timestamp + ): string { + $dateStamp = gmdate('Ymd', $timestamp); + + // Build canonical request + $canonicalRequest = $this->buildCanonicalRequest( + method: $method->value, + canonicalUri: $canonicalUri, + canonicalQueryString: $canonicalQueryString, + canonicalHeaders: $canonicalHeaders, + signedHeaders: $signedHeaders, + payloadHash: $payloadHash + ); + + // Create string to sign + $credentialScope = "{$dateStamp}/{$this->region}/" . self::SERVICE_NAME . '/aws4_request'; + $amzDate = gmdate('Ymd\THis\Z', $timestamp); + $stringToSign = self::ALGORITHM . "\n" + . $amzDate . "\n" + . $credentialScope . "\n" + . Hash::sha256($canonicalRequest)->toString(); + + // Calculate signing key + $kDate = $this->hmacService->generateHmac($dateStamp, 'AWS4' . $this->secretKey, HashAlgorithm::SHA256); + $kRegion = $this->hmacService->generateHmac($this->region, $kDate->toString(), HashAlgorithm::SHA256); + $kService = $this->hmacService->generateHmac(self::SERVICE_NAME, $kRegion->toString(), HashAlgorithm::SHA256); + $kSigning = $this->hmacService->generateHmac('aws4_request', $kService->toString(), HashAlgorithm::SHA256); + + // Calculate signature + $signature = $this->hmacService->generateHmac($stringToSign, $kSigning->toString(), HashAlgorithm::SHA256); + + return $signature->toString(); + } + + /** + * Build credential string for presigned URLs + */ + private function buildCredential(int $timestamp): string + { + $dateStamp = gmdate('Ymd', $timestamp); + $credentialScope = "{$dateStamp}/{$this->region}/" . self::SERVICE_NAME . '/aws4_request'; + + return $this->accessKey . '/' . $credentialScope; + } + + /** + * Send HTTP request and handle errors + */ + private function sendRequest(ClientRequest $request): ClientResponse + { + try { + return $this->httpClient->send($request); + } catch (\Throwable $e) { + throw StorageConnectionException::for($this->endpoint, $e->getMessage(), $e); + } + } + + /** + * Extract ETag from response headers + */ + private function extractEtag(Headers $headers): ?string + { + $etag = $headers->get('ETag')?->value(); + if ($etag === null) { + return null; + } + + // Remove quotes if present + return trim($etag, '"'); + } + + /** + * Extract content length from response headers + */ + private function extractSize(Headers $headers): ?int + { + $contentLength = $headers->get('Content-Length')?->value(); + if ($contentLength === null) { + return null; + } + + return (int) $contentLength; + } + + /** + * Extract content type from response headers + */ + private function extractContentType(Headers $headers): ?string + { + return $headers->get('Content-Type')?->value(); + } + + /** + * Extract last modified timestamp from response headers + */ + private function extractLastModified(Headers $headers): ?int + { + $lastModified = $headers->get('Last-Modified')?->value(); + if ($lastModified === null) { + return null; + } + + $timestamp = strtotime($lastModified); + + return $timestamp !== false ? $timestamp : null; + } +} + diff --git a/src/Infrastructure/Storage/S3ObjectStorage.php b/src/Infrastructure/Storage/S3ObjectStorage.php new file mode 100644 index 00000000..2ba132e1 --- /dev/null +++ b/src/Infrastructure/Storage/S3ObjectStorage.php @@ -0,0 +1,248 @@ +client->putObject($bucket, $key, $body, $headers); + + // Build Value Objects from result + $etag = $result['etag'] !== null ? Hash::fromString($result['etag'], HashAlgorithm::SHA256) : null; + $size = $result['size'] !== null ? FileSize::fromBytes($result['size']) : null; + + $contentType = null; + if ($result['contentType'] !== null) { + $contentType = MimeType::tryFrom($result['contentType']) ?? CustomMimeType::fromString($result['contentType']); + } elseif (isset($opts['contentType'])) { + $contentType = MimeType::tryFrom($opts['contentType']) ?? CustomMimeType::fromString($opts['contentType']); + } + + $metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []); + $versionId = isset($opts['versionId']) ? VersionId::fromString($opts['versionId']) : null; + $lastModified = isset($result['lastModified']) && $result['lastModified'] !== null + ? Timestamp::fromTimestamp($result['lastModified']) + : null; + + return new ObjectInfo( + bucket: $bucketName, + key: $objectKey, + etag: $etag, + size: $size, + contentType: $contentType, + lastModified: $lastModified, + metadata: $metadata, + versionId: $versionId + ); + } + + public function get(string $bucket, string $key): string + { + try { + return $this->client->getObject($bucket, $key); + } catch (StorageOperationException $e) { + if ($e->getCode() === 404) { + throw ObjectNotFoundException::for($bucket, $key, $e); + } + + throw $e; + } + } + + public function stream(string $bucket, string $key) + { + // Backward compatibility: returns temporary stream + return $this->openReadStream($bucket, $key); + } + + public function getToStream(string $bucket, string $key, $destination, array $opts = []): int + { + if (! is_resource($destination)) { + throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream'); + } + + try { + return $this->client->getObjectToStream($bucket, $key, $destination, $opts); + } catch (StorageOperationException $e) { + if ($e->getCode() === 404) { + throw ObjectNotFoundException::for($bucket, $key, $e); + } + + throw $e; + } + } + + public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo + { + if (! is_resource($source)) { + throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream'); + } + + $bucketName = BucketName::fromString($bucket); + $objectKey = ObjectKey::fromString($key); + + $headers = $opts['headers'] ?? []; + if (isset($opts['contentType'])) { + $headers['Content-Type'] = $opts['contentType']; + } + + $clientOpts = [ + 'headers' => $headers, + 'contentLength' => $opts['contentLength'] ?? null, + ]; + + $result = $this->client->putObjectFromStream($bucket, $key, $source, $clientOpts); + + // Build Value Objects from result + $etag = $result['etag'] !== null ? Hash::fromString($result['etag'], HashAlgorithm::SHA256) : null; + $size = $result['size'] !== null ? FileSize::fromBytes($result['size']) : null; + + $contentType = null; + if ($result['contentType'] !== null) { + $contentType = MimeType::tryFrom($result['contentType']) ?? CustomMimeType::fromString($result['contentType']); + } elseif (isset($opts['contentType'])) { + $contentType = MimeType::tryFrom($opts['contentType']) ?? CustomMimeType::fromString($opts['contentType']); + } + + $metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []); + $versionId = isset($opts['versionId']) ? VersionId::fromString($opts['versionId']) : null; + $lastModified = isset($result['lastModified']) && $result['lastModified'] !== null + ? Timestamp::fromTimestamp($result['lastModified']) + : null; + + return new ObjectInfo( + bucket: $bucketName, + key: $objectKey, + etag: $etag, + size: $size, + contentType: $contentType, + lastModified: $lastModified, + metadata: $metadata, + versionId: $versionId + ); + } + + public function openReadStream(string $bucket, string $key) + { + // For S3, we create a temporary stream and stream the content into it + // This is necessary because S3 doesn't provide direct stream access + // (would require a custom stream wrapper for true streaming) + $stream = fopen('php://temp', 'r+'); + if ($stream === false) { + throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream'); + } + + try { + $this->client->getObjectToStream($bucket, $key, $stream); + rewind($stream); + + return $stream; + } catch (StorageOperationException $e) { + fclose($stream); + if ($e->getCode() === 404) { + throw ObjectNotFoundException::for($bucket, $key, $e); + } + + throw $e; + } + } + + public function head(string $bucket, string $key): ObjectInfo + { + try { + $bucketName = BucketName::fromString($bucket); + $objectKey = ObjectKey::fromString($key); + $result = $this->client->headObject($bucket, $key); + + // Build Value Objects from result + $etag = $result['etag'] !== null ? Hash::fromString($result['etag'], HashAlgorithm::SHA256) : null; + $size = $result['size'] !== null ? FileSize::fromBytes($result['size']) : null; + + $contentType = null; + if ($result['contentType'] !== null) { + $contentType = MimeType::tryFrom($result['contentType']) ?? CustomMimeType::fromString($result['contentType']); + } + + $lastModified = $result['lastModified'] !== null + ? Timestamp::fromTimestamp($result['lastModified']) + : null; + + return new ObjectInfo( + bucket: $bucketName, + key: $objectKey, + etag: $etag, + size: $size, + contentType: $contentType, + lastModified: $lastModified, + metadata: ObjectMetadata::empty(), + versionId: null + ); + } catch (StorageOperationException $e) { + if ($e->getCode() === 404) { + throw ObjectNotFoundException::for($bucket, $key, $e); + } + + throw $e; + } + } + + public function delete(string $bucket, string $key): void + { + $this->client->deleteObject($bucket, $key); + } + + public function exists(string $bucket, string $key): bool + { + return $this->client->objectExists($bucket, $key); + } + + public function url(string $bucket, string $key): ?string + { + // S3 public URLs are only available if bucket is public + // For now, return null (can be extended later) + return null; + } + + public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string + { + return $this->client->createPresignedUrl($bucket, $key, $ttl); + } +} + diff --git a/tests/Framework/MultiPurposeAttributeTest.php b/tests/Framework/MultiPurposeAttributeTest.php new file mode 100644 index 00000000..c4f01b94 --- /dev/null +++ b/tests/Framework/MultiPurposeAttributeTest.php @@ -0,0 +1,92 @@ +getAttributes(); + + $attributeNames = array_map(fn($attr) => $attr->getName(), $attributes); + + expect($attributeNames)->toContain(McpTool::class); + expect($attributeNames)->toContain(ConsoleCommand::class); + // Note: Route attribute checking would need Route class import +}); + +test('parameter type validator recognizes builtin types', function () { + $validator = new ParameterTypeValidator(); + + // Use reflection from actual method to test builtin types + $reflectionMethod = new \ReflectionMethod(MultiPurposeAction::class, 'listUsers'); + $parameters = $reflectionMethod->getParameters(); + + // Test string parameter + $stringParam = $parameters[0]; // $status: string + expect($validator->isBuiltinType($stringParam->getType()))->toBeTrue(); + + // Test int parameter + $intParam = $parameters[1]; // $limit: int + expect($validator->isBuiltinType($intParam->getType()))->toBeTrue(); + + // Test bool parameter + $boolParam = $parameters[2]; // $includeDetails: bool + expect($validator->isBuiltinType($boolParam->getType()))->toBeTrue(); +}); + +test('parameter type validator rejects non-builtin types', function () { + $validator = new ParameterTypeValidator(); + + // Use reflection from a method that has non-builtin types (if available) + // For now, test with a class that doesn't exist in our test + $reflectionClass = new \ReflectionClass(MultiPurposeAction::class); + + // All methods in MultiPurposeAction should have only builtin types + // So we'll test with a known non-builtin type string + $classType = new \ReflectionClass(\stdClass::class); + $method = $classType->getMethod('__construct'); + $parameters = $method->getParameters(); + + // If there are parameters, check they're not builtin (if they're object types) + if (count($parameters) > 0 && $parameters[0]->getType() instanceof \ReflectionNamedType) { + $typeName = $parameters[0]->getType()->getName(); + if (! in_array($typeName, ['string', 'int', 'float', 'bool', 'array', 'mixed'], true)) { + expect($validator->isBuiltinType($parameters[0]->getType()))->toBeFalse(); + } + } +}); + +test('multi-purpose method returns ActionResult', function () { + $action = new MultiPurposeAction(); + + $result = $action->listUsers('active', 10, false); + + expect($result)->toBeInstanceOf(\App\Framework\Router\ActionResult::class); + expect($result)->toBeInstanceOf(\App\Framework\Router\Result\JsonResult::class); +}); + +test('ConsoleResult implements ActionResult', function () { + $textResult = new \App\Framework\Console\Result\TextResult('Test message'); + + expect($textResult)->toBeInstanceOf(\App\Framework\Router\ActionResult::class); + expect($textResult)->toBeInstanceOf(\App\Framework\Console\Result\ConsoleResult::class); +}); + +test('ToolResult implements ActionResult', function () { + $toolResult = \App\Framework\Mcp\Core\ValueObjects\ToolResult::success(['data' => 'test']); + + expect($toolResult)->toBeInstanceOf(\App\Framework\Router\ActionResult::class); + expect($toolResult)->toBeInstanceOf(\App\Framework\Mcp\Core\ValueObjects\ToolResult::class); +}); + diff --git a/tests/debug/test-gitea-actions-typed.php b/tests/debug/test-gitea-actions-typed.php new file mode 100644 index 00000000..2322605f --- /dev/null +++ b/tests/debug/test-gitea-actions-typed.php @@ -0,0 +1,199 @@ +actions->listWorkflowsTyped($owner, $repo); + + echo " Total workflows: {$workflows->count()}\n"; + echo " Active workflows: " . count($workflows->active()) . "\n"; + echo " Disabled workflows: " . count($workflows->disabled()) . "\n\n"; + + foreach ($workflows as $workflow) { + $statusIcon = $workflow->isActive() ? 'βœ…' : '❌'; + echo " {$statusIcon} Workflow: {$workflow->name}\n"; + echo " ID: {$workflow->id}\n"; + echo " Path: {$workflow->path}\n"; + echo " File: {$workflow->getFileName()}\n"; + echo " State: {$workflow->state}\n"; + echo "\n"; + } + + // ===================================================================== + // Test 2: List Workflow Runs (Typed) + // ===================================================================== + echo "πŸƒ Test 2: Listing Recent Workflow Runs (Type-Safe)\n"; + echo str_repeat('─', 60) . "\n"; + + $runs = $giteaClient->actions->listRunsTyped($owner, $repo, ['limit' => 10]); + + echo " Total runs: {$runs->count()}\n"; + echo " Running: " . count($runs->running()) . "\n"; + + $successRate = $runs->successRate() * 100; + echo " Successful: {$runs->successCount()} ({$successRate}%)\n"; + echo " Failed: {$runs->failureCount()}\n\n"; + + foreach ($runs as $run) { + echo " {$run->getStatusSummary()} Run #{$run->id}\n"; + echo " Title: {$run->displayTitle}\n"; + echo " Status: {$run->status->value}\n"; + echo " Branch: {$run->headBranch}\n"; + echo " Started: {$run->startedAt->format('Y-m-d H:i:s')}\n"; + + if ($run->isCompleted() && $run->getDuration() !== null) { + $duration = $run->getDuration(); + echo " Duration: {$duration->toSeconds()}s\n"; + } elseif ($run->isRunning()) { + $elapsed = $run->getElapsedTime(); + echo " Elapsed: {$elapsed->toSeconds()}s (still running)\n"; + } + + echo "\n"; + } + + // ===================================================================== + // Test 3: Get Specific Run Details (Typed) + // ===================================================================== + if (!$runs->isEmpty()) { + echo "πŸ” Test 3: Detailed Run Information (Type-Safe)\n"; + echo str_repeat('─', 60) . "\n"; + + $latestRun = $runs->latest(); + + echo " Run ID: {$latestRun->id}\n"; + echo " Title: {$latestRun->displayTitle}\n"; + echo " Status: {$latestRun->getStatusSummary()}\n"; + echo " Branch: {$latestRun->headBranch}\n"; + echo " Commit: " . substr($latestRun->headSha, 0, 8) . "\n"; + echo " Triggered by: {$latestRun->event}\n"; + echo " Run Number: {$latestRun->runNumber}\n"; + echo "\n"; + + echo " Business Logic Methods:\n"; + echo " - isSuccessful(): " . ($latestRun->isSuccessful() ? 'true' : 'false') . "\n"; + echo " - isFailed(): " . ($latestRun->isFailed() ? 'true' : 'false') . "\n"; + echo " - isRunning(): " . ($latestRun->isRunning() ? 'true' : 'false') . "\n"; + echo " - wasCancelled(): " . ($latestRun->wasCancelled() ? 'true' : 'false') . "\n"; + + if ($latestRun->getDuration() !== null) { + $duration = $latestRun->getDuration(); + echo " - getDuration(): {$duration->toSeconds()}s\n"; + } + + echo "\n"; + } + + // ===================================================================== + // Test 4: Collection Methods Demo + // ===================================================================== + echo "πŸ“Š Test 4: Collection Methods Demo\n"; + echo str_repeat('─', 60) . "\n"; + + // Filter by branch + $mainBranchRuns = $runs->forBranch('main'); + echo " Runs on 'main' branch: " . count($mainBranchRuns) . "\n"; + + // Find by ID + if (!$runs->isEmpty()) { + $firstRunId = $runs->latest()->id; + $foundRun = $runs->findById($firstRunId); + echo " Found run by ID: " . ($foundRun !== null ? 'Yes' : 'No') . "\n"; + } + + echo "\n"; + + // ===================================================================== + // Test 5: Backward Compatibility Check + // ===================================================================== + echo "πŸ”„ Test 5: Backward Compatibility (Raw Arrays Still Work)\n"; + echo str_repeat('─', 60) . "\n"; + + // Old API still works + $rawWorkflows = $giteaClient->actions->listWorkflows($owner, $repo); + echo " Raw array method works: Yes\n"; + echo " Raw workflows count: " . count($rawWorkflows['workflows'] ?? []) . "\n"; + + $rawRuns = $giteaClient->actions->listRuns($owner, $repo, ['limit' => 5]); + echo " Raw runs method works: Yes\n"; + echo " Raw runs count: " . count($rawRuns['workflow_runs'] ?? []) . "\n"; + + echo "\n"; + + echo "βœ… All Tests SUCCESSFUL!\n\n"; + + // ===================================================================== + // Insights + // ===================================================================== + echo "╔═══════════════════════════════════════════════════════════════════╗\n"; + echo "β•‘ β˜… Insights ─────────────────────────────────────────────────────║\n"; + echo "╠═══════════════════════════════════════════════════════════════════╣\n"; + echo "β•‘ β•‘\n"; + echo "β•‘ Value Objects bieten mehrere Vorteile: β•‘\n"; + echo "β•‘ β•‘\n"; + echo "β•‘ 1. Type Safety - IDE erkennt Fehler zur Compile-Zeit β•‘\n"; + echo "β•‘ \$run->id (RunId) statt \$run['id'] (mixed) β•‘\n"; + echo "β•‘ β•‘\n"; + echo "β•‘ 2. Business Logic - Methoden wie isSuccessful(), getDuration() β•‘\n"; + echo "β•‘ direkt am Objekt statt externe Helper-Funktionen β•‘\n"; + echo "β•‘ β•‘\n"; + echo "β•‘ 3. Collection Methods - Filtern, Suchen, Aggregieren β•‘\n"; + echo "β•‘ \$runs->successful(), \$runs->successRate() etc. β•‘\n"; + echo "β•‘ β•‘\n"; + echo "β•‘ 4. API Evolution - Γ„nderungen in Gitea API isoliert β•‘\n"; + echo "β•‘ fromApiResponse() mappt API-Γ„nderungen transparent β•‘\n"; + echo "β•‘ β•‘\n"; + echo "β•‘ 5. Backward Compatible - Raw arrays funktionieren weiterhin β•‘\n"; + echo "β•‘ listWorkflows() (array) + listWorkflowsTyped() (VOs) β•‘\n"; + echo "β•‘ β•‘\n"; + echo "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•\n\n"; + +} catch (\Exception $e) { + echo "\n❌ Error: " . $e->getMessage() . "\n"; + echo "File: " . $e->getFile() . "\n"; + echo "Line: " . $e->getLine() . "\n"; + exit(1); +} diff --git a/tests/debug/test-gitea-actions.php b/tests/debug/test-gitea-actions.php new file mode 100644 index 00000000..eb1b229b --- /dev/null +++ b/tests/debug/test-gitea-actions.php @@ -0,0 +1,116 @@ +actions->listWorkflows($owner, $repo); + + if (isset($workflows['workflows']) && !empty($workflows['workflows'])) { + foreach ($workflows['workflows'] as $workflow) { + echo " - Workflow: {$workflow['name']}\n"; + echo " ID: {$workflow['id']}\n"; + echo " Path: {$workflow['path']}\n"; + echo " State: {$workflow['state']}\n"; + echo "\n"; + } + } else { + echo " No workflows found.\n\n"; + } + + // Test 2: List Workflow Runs (last 10) + echo "2. Listing Recent Workflow Runs (last 10):\n"; + echo str_repeat('-', 60) . "\n"; + + $runs = $giteaClient->actions->listRuns($owner, $repo, [ + 'limit' => 10 + ]); + + if (isset($runs['workflow_runs']) && !empty($runs['workflow_runs'])) { + foreach ($runs['workflow_runs'] as $run) { + echo " - Run #{$run['id']}\n"; + echo " Title: {$run['display_title']}\n"; + echo " Status: {$run['status']}\n"; + echo " Conclusion: " . ($run['conclusion'] ?? 'N/A') . "\n"; + echo " Started: {$run['started_at']}\n"; + echo " Branch: {$run['head_branch']}\n"; + echo "\n"; + } + + // Test 3: Get detailed info about latest run + $latestRun = $runs['workflow_runs'][0]; + echo "3. Detailed Info for Latest Run (#{$latestRun['id']}):\n"; + echo str_repeat('-', 60) . "\n"; + + $runDetails = $giteaClient->actions->getRun($owner, $repo, $latestRun['id']); + + echo " Workflow: {$runDetails['name']}\n"; + echo " Status: {$runDetails['status']}\n"; + echo " Conclusion: " . ($runDetails['conclusion'] ?? 'N/A') . "\n"; + echo " Triggered by: {$runDetails['event']}\n"; + echo " Branch: {$runDetails['head_branch']}\n"; + echo " Commit: {$runDetails['head_sha']}\n"; + echo " Run Number: {$runDetails['run_number']}\n"; + echo " Started: {$runDetails['run_started_at']}\n"; + + if (isset($runDetails['jobs'])) { + echo "\n Jobs:\n"; + foreach ($runDetails['jobs'] as $job) { + echo " - {$job['name']}: {$job['status']}"; + if (isset($job['conclusion'])) { + echo " ({$job['conclusion']})"; + } + echo "\n"; + } + } + + } else { + echo " No workflow runs found.\n"; + } + + echo "\nβœ… Actions/Workflows API Test SUCCESSFUL!\n"; + +} catch (\Exception $e) { + echo "\n❌ Error: " . $e->getMessage() . "\n"; + echo "File: " . $e->getFile() . "\n"; + echo "Line: " . $e->getLine() . "\n"; + exit(1); +}