feat: add comprehensive framework features and deployment improvements
Major additions: - Storage abstraction layer with filesystem and in-memory implementations - Gitea API integration with MCP tools for repository management - Console dialog mode with interactive command execution - WireGuard VPN DNS fix implementation and documentation - HTTP client streaming response support - Router generic result type - Parameter type validator for framework core Framework enhancements: - Console command registry improvements - Console dialog components - Method signature analyzer updates - Route mapper refinements - MCP server and tool mapper updates - Queue job chain and dependency commands - Discovery tokenizer improvements Infrastructure: - Deployment architecture documentation - Ansible playbook updates for WireGuard client regeneration - Production environment configuration updates - Docker Compose local configuration updates - Remove obsolete docker-compose.yml (replaced by environment-specific configs) Documentation: - PERMISSIONS.md for access control guidelines - WireGuard DNS fix implementation details - Console dialog mode usage guide - Deployment architecture overview Testing: - Multi-purpose attribute tests - Gitea Actions integration tests (typed and untyped)
This commit is contained in:
@@ -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:**
|
||||
|
||||
12
bin/console
Executable file
12
bin/console
Executable file
@@ -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
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
226
docs/PERMISSIONS.md
Normal file
226
docs/PERMISSIONS.md
Normal file
@@ -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 <branch>`
|
||||
- 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`)
|
||||
777
docs/claude/deployment-architecture.md
Normal file
777
docs/claude/deployment-architecture.md
Normal file
@@ -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 <RUNNER_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=<IMAGE_TAG>"
|
||||
|
||||
# 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
|
||||
472
docs/console-dialog-mode.md
Normal file
472
docs/console-dialog-mode.md
Normal file
@@ -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 <command>` - 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 <email> [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<Tab> # Vervollständigt zu db:migrate
|
||||
console> user:cr<Tab> # 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 <command>`**: 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`
|
||||
|
||||
283
docs/deployment/WIREGUARD-DNS-FIX-IMPLEMENTED.md
Normal file
283
docs/deployment/WIREGUARD-DNS-FIX-IMPLEMENTED.md
Normal file
@@ -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 = <client_private_key>
|
||||
Address = 10.8.0.7/24
|
||||
DNS = 10.8.0.1 # ← JETZT ENTHALTEN!
|
||||
|
||||
[Peer]
|
||||
PublicKey = <server_public_key>
|
||||
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-<timestamp>`
|
||||
- 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-<timestamp> 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
|
||||
1175
docs/deployment/WIREGUARD-IMPLEMENTATION-PLAN.md
Normal file
1175
docs/deployment/WIREGUARD-IMPLEMENTATION-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
*/
|
||||
final readonly class ConsoleDialog
|
||||
{
|
||||
private bool $readlineAvailable = false;
|
||||
private bool $readlineAvailable;
|
||||
|
||||
private CommandSuggestionEngine $suggestionEngine;
|
||||
|
||||
|
||||
@@ -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<string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
101
src/Framework/Core/ParameterTypeValidator.php
Normal file
101
src/Framework/Core/ParameterTypeValidator.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Core;
|
||||
|
||||
use ReflectionIntersectionType;
|
||||
use ReflectionNamedType;
|
||||
use ReflectionParameter;
|
||||
use ReflectionType;
|
||||
use ReflectionUnionType;
|
||||
|
||||
/**
|
||||
* Validates parameter types to check if they are builtin types
|
||||
*/
|
||||
final readonly class ParameterTypeValidator
|
||||
{
|
||||
/**
|
||||
* Check if a parameter type is a builtin type
|
||||
*/
|
||||
public function isBuiltinType(?\ReflectionType $type): bool
|
||||
{
|
||||
if ($type === null) {
|
||||
return false; // No type = mixed, not allowed
|
||||
}
|
||||
|
||||
if ($type instanceof ReflectionNamedType) {
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <name>" or "interface <name>" 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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
if ($this->shouldIgnoreAttribute($attributeClass)) {
|
||||
continue;
|
||||
$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;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
164
src/Framework/Examples/MultiPurposeAction.php
Normal file
164
src/Framework/Examples/MultiPurposeAction.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Examples;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Mcp\McpTool;
|
||||
use App\Framework\Router\ActionResult;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
|
||||
/**
|
||||
* Example implementation of a multi-purpose action
|
||||
*
|
||||
* This class demonstrates how a single method can be used as:
|
||||
* - MCP Tool (for AI integration)
|
||||
* - Console Command (for CLI usage)
|
||||
* - HTTP Route (for API access)
|
||||
*
|
||||
* Requirements:
|
||||
* - Only builtin parameter types (string, int, bool, float, array)
|
||||
* - Returns ActionResult (unified return type)
|
||||
*/
|
||||
final readonly class MultiPurposeAction
|
||||
{
|
||||
/**
|
||||
* List users with filtering and pagination
|
||||
*
|
||||
* This method can be called via:
|
||||
* - MCP: {"name": "list_users", "arguments": {"status": "active", "limit": 10}}
|
||||
* - Console: users:list --status=active --limit=10
|
||||
* - HTTP: GET /api/users?status=active&limit=10
|
||||
*/
|
||||
#[McpTool(
|
||||
name: 'list_users',
|
||||
description: 'List users with optional filtering and pagination'
|
||||
)]
|
||||
#[ConsoleCommand(
|
||||
name: 'users:list',
|
||||
description: 'List users with optional filtering and pagination'
|
||||
)]
|
||||
#[Route(
|
||||
path: '/api/users',
|
||||
method: Method::GET
|
||||
)]
|
||||
public function listUsers(
|
||||
string $status = 'active',
|
||||
int $limit = 10,
|
||||
bool $includeDetails = false
|
||||
): ActionResult {
|
||||
// Simulate user data
|
||||
$users = [
|
||||
['id' => 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
);*/
|
||||
);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
30
src/Framework/HttpClient/StreamingResponse.php
Normal file
30
src/Framework/HttpClient/StreamingResponse.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\HttpClient;
|
||||
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
/**
|
||||
* Response from streaming HTTP request
|
||||
*
|
||||
* Contains status and headers, but no body (body was streamed to destination).
|
||||
* Includes bytesWritten count for verification.
|
||||
*/
|
||||
final readonly class StreamingResponse
|
||||
{
|
||||
public function __construct(
|
||||
public Status $status,
|
||||
public Headers $headers,
|
||||
public int $bytesWritten = 0
|
||||
) {
|
||||
}
|
||||
|
||||
public function isSuccessful(): bool
|
||||
{
|
||||
return $this->status->isSuccess();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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<string>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
180
src/Framework/Router/GenericResult.php
Normal file
180
src/Framework/Router/GenericResult.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Router;
|
||||
|
||||
use App\Framework\Console\ConsoleOutputInterface;
|
||||
use App\Framework\Console\ExitCode;
|
||||
use App\Framework\Console\Result\ConsoleResult;
|
||||
use App\Framework\Console\Result\TextResult;
|
||||
use App\Framework\Mcp\Core\ValueObjects\ToolResult;
|
||||
|
||||
/**
|
||||
* Generic Action Result
|
||||
*
|
||||
* Universal result object that can be converted to both ToolResult (MCP)
|
||||
* and ConsoleResult (console commands). This enables methods to have dual
|
||||
* attributes (#[McpTool] and #[ConsoleCommand]) with a single return type.
|
||||
*
|
||||
* Architecture:
|
||||
* - Methods return GenericResult
|
||||
* - MCP context: Automatically converts to ToolResult
|
||||
* - Console context: Automatically converts to ConsoleResult (TextResult)
|
||||
*
|
||||
* Example:
|
||||
* #[McpTool(name: 'list_users')]
|
||||
* #[ConsoleCommand(name: 'users:list')]
|
||||
* public function listUsers(): GenericResult
|
||||
* {
|
||||
* return GenericResult::success(['users' => $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';
|
||||
}
|
||||
}
|
||||
41
src/Framework/Storage/Exceptions/BucketNotFoundException.php
Normal file
41
src/Framework/Storage/Exceptions/BucketNotFoundException.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
|
||||
/**
|
||||
* Exception thrown when a bucket is not found
|
||||
*/
|
||||
final class BucketNotFoundException extends StorageException
|
||||
{
|
||||
public function __construct(
|
||||
string $bucket,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$context = ExceptionContext::forOperation('storage.bucket', 'ObjectStorage')
|
||||
->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'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
50
src/Framework/Storage/Exceptions/ObjectNotFoundException.php
Normal file
50
src/Framework/Storage/Exceptions/ObjectNotFoundException.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
|
||||
/**
|
||||
* Exception thrown when an object is not found in storage
|
||||
*/
|
||||
final class ObjectNotFoundException extends StorageException
|
||||
{
|
||||
public function __construct(
|
||||
string $bucket,
|
||||
string $key,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$context = ExceptionContext::forOperation('storage.get', 'ObjectStorage')
|
||||
->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'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
|
||||
/**
|
||||
* Exception thrown when storage connection fails
|
||||
*/
|
||||
final class StorageConnectionException extends StorageException
|
||||
{
|
||||
public function __construct(
|
||||
string $endpoint,
|
||||
string $reason = '',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$context = ExceptionContext::forOperation('storage.connect', 'Storage')
|
||||
->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'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
87
src/Framework/Storage/Exceptions/StorageException.php
Normal file
87
src/Framework/Storage/Exceptions/StorageException.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\ExceptionMetadata;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Base exception for Storage operations
|
||||
*
|
||||
* All storage-related exceptions should extend this class.
|
||||
*/
|
||||
class StorageException extends FrameworkException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
ExceptionContext $context,
|
||||
?ErrorCode $errorCode = null,
|
||||
?\Throwable $previous = null,
|
||||
int $code = 0,
|
||||
?ExceptionMetadata $metadata = null
|
||||
) {
|
||||
parent::__construct($message, $context, $code, $previous, $errorCode, $metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for bucket/key not found
|
||||
*/
|
||||
public static function notFound(string $bucket, string $key, ?\Throwable $previous = null): self
|
||||
{
|
||||
$context = ExceptionContext::forOperation('storage.get', 'Storage')
|
||||
->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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
|
||||
/**
|
||||
* Exception thrown when a storage operation fails
|
||||
*/
|
||||
final class StorageOperationException extends StorageException
|
||||
{
|
||||
public function __construct(
|
||||
string $operation,
|
||||
string $bucket,
|
||||
string $key,
|
||||
string $reason = '',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$context = ExceptionContext::forOperation("storage.{$operation}", 'Storage')
|
||||
->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'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
360
src/Framework/Storage/FilesystemObjectStorage.php
Normal file
360
src/Framework/Storage/FilesystemObjectStorage.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage;
|
||||
|
||||
use App\Framework\Core\ValueObjects\FileSize;
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\HashAlgorithm;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Filesystem\Storage;
|
||||
use App\Framework\Http\CustomMimeType;
|
||||
use App\Framework\Http\MimeType;
|
||||
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
|
||||
use App\Framework\Storage\Exceptions\StorageOperationException;
|
||||
use App\Framework\Storage\ValueObjects\BucketName;
|
||||
use App\Framework\Storage\ValueObjects\ObjectKey;
|
||||
use App\Framework\Storage\ValueObjects\ObjectMetadata;
|
||||
use DateInterval;
|
||||
|
||||
/**
|
||||
* Filesystem-based Object Storage implementation
|
||||
*
|
||||
* Maps S3-style buckets/keys to filesystem directories/files:
|
||||
* - Buckets = directories
|
||||
* - Keys = files within bucket directories
|
||||
*/
|
||||
final readonly class FilesystemObjectStorage implements ObjectStorage, StreamableObjectStorage
|
||||
{
|
||||
private string $basePath;
|
||||
public function __construct(
|
||||
private Storage $storage,
|
||||
string $basePath = '/'
|
||||
) {
|
||||
// Normalize base path
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
||||
229
src/Framework/Storage/InMemoryObjectStorage.php
Normal file
229
src/Framework/Storage/InMemoryObjectStorage.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage;
|
||||
|
||||
use App\Framework\Core\ValueObjects\FileSize;
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\CustomMimeType;
|
||||
use App\Framework\Http\MimeType;
|
||||
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
|
||||
use App\Framework\Storage\Exceptions\StorageOperationException;
|
||||
use App\Framework\Storage\ValueObjects\BucketName;
|
||||
use App\Framework\Storage\ValueObjects\ObjectKey;
|
||||
use App\Framework\Storage\ValueObjects\ObjectMetadata;
|
||||
use DateInterval;
|
||||
|
||||
/**
|
||||
* In-Memory Object Storage implementation for testing
|
||||
*
|
||||
* Stores objects in memory without any I/O operations.
|
||||
* Structure: array<string, array<string, array{content: string, metadata: array}>>
|
||||
*/
|
||||
final class InMemoryObjectStorage implements ObjectStorage, StreamableObjectStorage
|
||||
{
|
||||
/**
|
||||
* Storage structure: [bucket => [key => [content, metadata, timestamp]]]
|
||||
*
|
||||
* @var array<string, array<string, array{content: string, metadata: array, timestamp: int}>>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
public function listBuckets(): array
|
||||
{
|
||||
return array_keys($this->storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all keys in a bucket (for testing)
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function listKeys(string $bucket): array
|
||||
{
|
||||
if (! isset($this->storage[$bucket])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_keys($this->storage[$bucket]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage;
|
||||
|
||||
use App\Framework\Core\ValueObjects\FileSize;
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\HashAlgorithm;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\CustomMimeType;
|
||||
use App\Framework\Http\MimeType;
|
||||
use App\Framework\Http\MimeTypeInterface;
|
||||
use App\Framework\Storage\ValueObjects\BucketName;
|
||||
use App\Framework\Storage\ValueObjects\ObjectKey;
|
||||
use App\Framework\Storage\ValueObjects\ObjectMetadata;
|
||||
use App\Framework\Storage\ValueObjects\VersionId;
|
||||
|
||||
/**
|
||||
* Object Information Value Object
|
||||
*
|
||||
* Contains metadata about a stored object (bucket, key, size, content type, etc.)
|
||||
* Uses Value Objects for type safety and validation.
|
||||
*/
|
||||
final readonly class ObjectInfo
|
||||
{
|
||||
public function __construct(
|
||||
public string $bucket,
|
||||
public string $key,
|
||||
public ?string $etag = null, // z.B. sha256
|
||||
public ?int $size = null,
|
||||
public ?string $contentType = null,
|
||||
public array $metadata = [], // frei (owner, width, height, …)
|
||||
public ?string $versionId = null // S3: echt, FS: emuliert/optional
|
||||
) {}
|
||||
public BucketName $bucket,
|
||||
public ObjectKey $key,
|
||||
public ?Hash $etag = null,
|
||||
public ?FileSize $size = null,
|
||||
public ?MimeTypeInterface $contentType = null,
|
||||
public ?Timestamp $lastModified = null,
|
||||
public ObjectMetadata $metadata = new ObjectMetadata(),
|
||||
public ?VersionId $versionId = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ObjectInfo from legacy format (for backward compatibility)
|
||||
*
|
||||
* @param array<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
public function getMetadataArray(): array
|
||||
{
|
||||
return $this->metadata->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version ID as string (if available)
|
||||
*/
|
||||
public function getVersionId(): ?string
|
||||
{
|
||||
return $this->versionId?->toString();
|
||||
}
|
||||
}
|
||||
|
||||
81
src/Framework/Storage/StorageInitializer.php
Normal file
81
src/Framework/Storage/StorageInitializer.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage;
|
||||
|
||||
use App\Framework\Config\Environment;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\Encryption\HmacService;
|
||||
use App\Framework\Filesystem\Storage;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use App\Infrastructure\Storage\MinIoClient;
|
||||
use App\Infrastructure\Storage\S3ObjectStorage;
|
||||
|
||||
/**
|
||||
* Storage System Initializer
|
||||
*
|
||||
* Registriert alle Object Storage Komponenten im DI-Container
|
||||
* entsprechend der Framework-Konventionen.
|
||||
*/
|
||||
final readonly class StorageInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function initializeStorage(Container $container): void
|
||||
{
|
||||
$env = $container->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}")
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
60
src/Framework/Storage/StreamableObjectStorage.php
Normal file
60
src/Framework/Storage/StreamableObjectStorage.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage;
|
||||
|
||||
/**
|
||||
* Interface für Stream-basierte Object Storage Operationen
|
||||
*
|
||||
* Ermöglicht speichereffizientes Arbeiten mit großen Objekten ohne komplettes Laden in den Speicher.
|
||||
* Ideal für große Dateien, Media-Files und Analytics-Daten.
|
||||
*
|
||||
* Implementiert das Interface Segregation Principle - nur Adapter die Streaming unterstützen
|
||||
* implementieren dieses Interface.
|
||||
*/
|
||||
interface StreamableObjectStorage
|
||||
{
|
||||
/**
|
||||
* Streamt Objekt-Inhalt direkt in einen schreibbaren Stream
|
||||
*
|
||||
* @param string $bucket Bucket-Name
|
||||
* @param string $key Object-Key
|
||||
* @param resource $destination Schreibbarer Stream (z.B. fopen('file.txt', 'w'))
|
||||
* @param array<string, mixed> $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<string, mixed> $opts Optionale Parameter:
|
||||
* - 'contentType' => string MIME-Type
|
||||
* - 'metadata' => array<string, mixed> 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);
|
||||
}
|
||||
|
||||
122
src/Framework/Storage/ValueObjects/BucketName.php
Normal file
122
src/Framework/Storage/ValueObjects/BucketName.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Bucket Name Value Object
|
||||
*
|
||||
* Type-safe S3 bucket identifier with validation according to AWS S3 bucket naming rules.
|
||||
*/
|
||||
final readonly class BucketName
|
||||
{
|
||||
private const MIN_LENGTH = 3;
|
||||
private const MAX_LENGTH = 63;
|
||||
|
||||
private function __construct(
|
||||
private string $value
|
||||
) {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
162
src/Framework/Storage/ValueObjects/ObjectKey.php
Normal file
162
src/Framework/Storage/ValueObjects/ObjectKey.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Object Key Value Object
|
||||
*
|
||||
* Type-safe S3 object key identifier with validation according to AWS S3 key naming rules.
|
||||
* Supports path-like structures (e.g., "folder/subfolder/file.txt").
|
||||
*/
|
||||
final readonly class ObjectKey
|
||||
{
|
||||
private const MAX_LENGTH = 1024;
|
||||
|
||||
private function __construct(
|
||||
private string $value
|
||||
) {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
192
src/Framework/Storage/ValueObjects/ObjectMetadata.php
Normal file
192
src/Framework/Storage/ValueObjects/ObjectMetadata.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Object Metadata Value Object
|
||||
*
|
||||
* Type-safe metadata container for storage objects.
|
||||
* Provides immutable access to key-value metadata pairs.
|
||||
*/
|
||||
final readonly class ObjectMetadata
|
||||
{
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $data;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->validate($data);
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ObjectMetadata from array
|
||||
*
|
||||
* @param array<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
public function keys(): array
|
||||
{
|
||||
return array_keys($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public function values(): array
|
||||
{
|
||||
return array_values($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate metadata structure
|
||||
*
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
|
||||
114
src/Framework/Storage/ValueObjects/VersionId.php
Normal file
114
src/Framework/Storage/ValueObjects/VersionId.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Version ID Value Object
|
||||
*
|
||||
* Type-safe S3 object version identifier.
|
||||
* Used for S3 object versioning support.
|
||||
*/
|
||||
final readonly class VersionId
|
||||
{
|
||||
private const MAX_LENGTH = 255;
|
||||
private const NULL_VERSION = 'null';
|
||||
|
||||
private function __construct(
|
||||
private string $value
|
||||
) {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 +147,55 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
211
src/Infrastructure/Api/Gitea/ActionService.php
Normal file
211
src/Infrastructure/Api/Gitea/ActionService.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
use App\Infrastructure\Api\Gitea\ValueObjects\{
|
||||
WorkflowList,
|
||||
WorkflowRunsList,
|
||||
WorkflowRun,
|
||||
Workflow,
|
||||
RunId
|
||||
};
|
||||
|
||||
final readonly class ActionService
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiClient $apiClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Listet alle Workflows eines Repositories
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @return array Liste der Workflows
|
||||
*/
|
||||
public function listWorkflows(string $owner, string $repo): array
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
|
||||
158
src/Infrastructure/Api/Gitea/GiteaApiClient.php
Normal file
158
src/Infrastructure/Api/Gitea/GiteaApiClient.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Api\ApiException;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\HttpClient\AuthConfig;
|
||||
use App\Framework\HttpClient\ClientOptions;
|
||||
use App\Framework\HttpClient\ClientRequest;
|
||||
use App\Framework\HttpClient\ClientResponse;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final readonly class GiteaApiClient
|
||||
{
|
||||
private ClientOptions $defaultOptions;
|
||||
|
||||
public function __construct(
|
||||
private GiteaConfig $config,
|
||||
private HttpClient $httpClient
|
||||
) {
|
||||
$authConfig = $this->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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
31
src/Infrastructure/Api/Gitea/GiteaClient.php
Normal file
31
src/Infrastructure/Api/Gitea/GiteaClient.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final readonly class GiteaClient
|
||||
{
|
||||
public RepositoryService $repositories;
|
||||
|
||||
public UserService $users;
|
||||
|
||||
public IssueService $issues;
|
||||
|
||||
public ActionService $actions;
|
||||
|
||||
public function __construct(
|
||||
GiteaConfig $config,
|
||||
HttpClient $httpClient
|
||||
) {
|
||||
$apiClient = new GiteaApiClient($config, $httpClient);
|
||||
|
||||
$this->repositories = new RepositoryService($apiClient);
|
||||
$this->users = new UserService($apiClient);
|
||||
$this->issues = new IssueService($apiClient);
|
||||
$this->actions = new ActionService($apiClient);
|
||||
}
|
||||
}
|
||||
|
||||
38
src/Infrastructure/Api/Gitea/GiteaClientInitializer.php
Normal file
38
src/Infrastructure/Api/Gitea/GiteaClientInitializer.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Config\Environment;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final readonly class GiteaClientInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function __invoke(Container $container): GiteaClient
|
||||
{
|
||||
$env = $container->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);
|
||||
}
|
||||
}
|
||||
|
||||
23
src/Infrastructure/Api/Gitea/GiteaConfig.php
Normal file
23
src/Infrastructure/Api/Gitea/GiteaConfig.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
final readonly class GiteaConfig
|
||||
{
|
||||
public function __construct(
|
||||
public string $baseUrl,
|
||||
public ?string $token = null,
|
||||
public ?string $username = null,
|
||||
public ?string $password = null,
|
||||
public float $timeout = 30.0
|
||||
) {
|
||||
if ($this->token === null && ($this->username === null || $this->password === null)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Either token or username+password must be provided for Gitea authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
98
src/Infrastructure/Api/Gitea/IssueService.php
Normal file
98
src/Infrastructure/Api/Gitea/IssueService.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
|
||||
final readonly class IssueService
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiClient $apiClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Listet alle Issues eines Repositories
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param array $options Optionale Parameter (state, labels, page, limit, etc.)
|
||||
* @return array Liste der Issues
|
||||
*/
|
||||
public function list(string $owner, string $repo, array $options = []): array
|
||||
{
|
||||
return $this->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']);
|
||||
}
|
||||
}
|
||||
|
||||
501
src/Infrastructure/Api/Gitea/README.md
Normal file
501
src/Infrastructure/Api/Gitea/README.md
Normal file
@@ -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
|
||||
|
||||
94
src/Infrastructure/Api/Gitea/RepositoryService.php
Normal file
94
src/Infrastructure/Api/Gitea/RepositoryService.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
|
||||
final readonly class RepositoryService
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiClient $apiClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Listet alle Repositories des authentifizierten Users
|
||||
*
|
||||
* @param array $options Optionale Parameter (page, limit, etc.)
|
||||
* @return array Liste der Repositories
|
||||
*/
|
||||
public function list(array $options = []): array
|
||||
{
|
||||
return $this->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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
59
src/Infrastructure/Api/Gitea/UserService.php
Normal file
59
src/Infrastructure/Api/Gitea/UserService.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
|
||||
final readonly class UserService
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiClient $apiClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft den aktuellen authentifizierten User ab
|
||||
*
|
||||
* @return array User-Daten
|
||||
*/
|
||||
public function getCurrent(): array
|
||||
{
|
||||
return $this->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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
42
src/Infrastructure/Api/Gitea/ValueObjects/RunConclusion.php
Normal file
42
src/Infrastructure/Api/Gitea/ValueObjects/RunConclusion.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
/**
|
||||
* Workflow Run Conclusion
|
||||
*
|
||||
* Represents the final outcome of a completed workflow run.
|
||||
*/
|
||||
enum RunConclusion: string
|
||||
{
|
||||
case SUCCESS = 'success';
|
||||
case FAILURE = 'failure';
|
||||
case CANCELLED = 'cancelled';
|
||||
case SKIPPED = 'skipped';
|
||||
|
||||
/**
|
||||
* Check if the conclusion indicates success
|
||||
*/
|
||||
public function isSuccessful(): bool
|
||||
{
|
||||
return $this === self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the conclusion indicates failure
|
||||
*/
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this === self::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the run was manually cancelled
|
||||
*/
|
||||
public function wasCancelled(): bool
|
||||
{
|
||||
return $this === self::CANCELLED;
|
||||
}
|
||||
}
|
||||
66
src/Infrastructure/Api/Gitea/ValueObjects/RunId.php
Normal file
66
src/Infrastructure/Api/Gitea/ValueObjects/RunId.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
/**
|
||||
* Workflow Run ID
|
||||
*
|
||||
* Type-safe wrapper for workflow run identifiers.
|
||||
*/
|
||||
final readonly class RunId implements \Stringable
|
||||
{
|
||||
public function __construct(
|
||||
public int $value
|
||||
) {
|
||||
if ($value <= 0) {
|
||||
throw new \InvalidArgumentException('Run ID must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RunId from string representation
|
||||
*/
|
||||
public static function fromString(string $id): self
|
||||
{
|
||||
if (!is_numeric($id)) {
|
||||
throw new \InvalidArgumentException('Run ID must be numeric');
|
||||
}
|
||||
|
||||
return new self((int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RunId from API response
|
||||
*/
|
||||
public static function fromApiResponse(int|string $id): self
|
||||
{
|
||||
if (is_string($id)) {
|
||||
return self::fromString($id);
|
||||
}
|
||||
|
||||
return new self($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another RunId
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to string representation
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return (string) $this->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toString();
|
||||
}
|
||||
}
|
||||
37
src/Infrastructure/Api/Gitea/ValueObjects/RunStatus.php
Normal file
37
src/Infrastructure/Api/Gitea/ValueObjects/RunStatus.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
/**
|
||||
* Workflow Run Status
|
||||
*
|
||||
* Represents the current execution state of a workflow run.
|
||||
*/
|
||||
enum RunStatus: string
|
||||
{
|
||||
case COMPLETED = 'completed';
|
||||
case IN_PROGRESS = 'in_progress';
|
||||
case QUEUED = 'queued';
|
||||
case WAITING = 'waiting';
|
||||
|
||||
/**
|
||||
* Check if the run is in a terminal state
|
||||
*/
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return $this === self::COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the run is actively executing
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::IN_PROGRESS, self::QUEUED, self::WAITING => true,
|
||||
self::COMPLETED => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
70
src/Infrastructure/Api/Gitea/ValueObjects/Workflow.php
Normal file
70
src/Infrastructure/Api/Gitea/ValueObjects/Workflow.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
/**
|
||||
* Workflow
|
||||
*
|
||||
* Represents a workflow definition in Gitea Actions.
|
||||
*/
|
||||
final readonly class Workflow
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $name,
|
||||
public string $path,
|
||||
public string $state,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create Workflow from Gitea API response
|
||||
*/
|
||||
public static function fromApiResponse(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: (int) $data['id'],
|
||||
name: $data['name'],
|
||||
path: $data['path'],
|
||||
state: $data['state'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workflow is active
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
153
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowList.php
Normal file
153
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowList.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
use IteratorAggregate;
|
||||
use Countable;
|
||||
use ArrayIterator;
|
||||
|
||||
/**
|
||||
* Workflow List
|
||||
*
|
||||
* Type-safe collection of Workflow objects.
|
||||
*/
|
||||
final readonly class WorkflowList implements IteratorAggregate, Countable
|
||||
{
|
||||
/** @var Workflow[] */
|
||||
private array $workflows;
|
||||
|
||||
/**
|
||||
* @param Workflow[] $workflows
|
||||
*/
|
||||
public function __construct(array $workflows)
|
||||
{
|
||||
// Validate all items are Workflow instances
|
||||
foreach ($workflows as $workflow) {
|
||||
if (!$workflow instanceof Workflow) {
|
||||
throw new \InvalidArgumentException(
|
||||
'All items must be Workflow instances'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
154
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRun.php
Normal file
154
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRun.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\{Timestamp, Duration};
|
||||
|
||||
/**
|
||||
* Workflow Run
|
||||
*
|
||||
* Represents a complete workflow run with type-safe properties and business logic.
|
||||
*/
|
||||
final readonly class WorkflowRun
|
||||
{
|
||||
public function __construct(
|
||||
public RunId $id,
|
||||
public string $displayTitle,
|
||||
public RunStatus $status,
|
||||
public ?RunConclusion $conclusion,
|
||||
public Timestamp $startedAt,
|
||||
public ?Timestamp $completedAt,
|
||||
public string $headBranch,
|
||||
public string $headSha,
|
||||
public int $runNumber,
|
||||
public string $event,
|
||||
public ?string $name = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create WorkflowRun from Gitea API response
|
||||
*/
|
||||
public static function fromApiResponse(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: RunId::fromApiResponse($data['id']),
|
||||
displayTitle: $data['display_title'] ?? $data['name'] ?? 'Unknown',
|
||||
status: RunStatus::from($data['status']),
|
||||
conclusion: isset($data['conclusion']) && $data['conclusion'] !== null
|
||||
? RunConclusion::from($data['conclusion'])
|
||||
: null,
|
||||
startedAt: Timestamp::fromDateTime(new \DateTimeImmutable($data['started_at'] ?? $data['run_started_at'])),
|
||||
completedAt: isset($data['completed_at']) && $data['completed_at'] !== null
|
||||
? Timestamp::fromDateTime(new \DateTimeImmutable($data['completed_at']))
|
||||
: null,
|
||||
headBranch: $data['head_branch'] ?? 'unknown',
|
||||
headSha: $data['head_sha'] ?? '',
|
||||
runNumber: (int) $data['run_number'],
|
||||
event: $data['event'],
|
||||
name: $data['name'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workflow run completed successfully
|
||||
*/
|
||||
public function isSuccessful(): bool
|
||||
{
|
||||
return $this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
207
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRunsList.php
Normal file
207
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRunsList.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
use IteratorAggregate;
|
||||
use Countable;
|
||||
use ArrayIterator;
|
||||
|
||||
/**
|
||||
* Workflow Runs List
|
||||
*
|
||||
* Type-safe collection of WorkflowRun objects.
|
||||
*/
|
||||
final readonly class WorkflowRunsList implements IteratorAggregate, Countable
|
||||
{
|
||||
/** @var WorkflowRun[] */
|
||||
private array $runs;
|
||||
|
||||
/**
|
||||
* @param WorkflowRun[] $runs
|
||||
*/
|
||||
public function __construct(array $runs)
|
||||
{
|
||||
// Validate all items are WorkflowRun instances
|
||||
foreach ($runs as $run) {
|
||||
if (!$run instanceof WorkflowRun) {
|
||||
throw new \InvalidArgumentException(
|
||||
'All items must be WorkflowRun instances'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
690
src/Infrastructure/Storage/MinIoClient.php
Normal file
690
src/Infrastructure/Storage/MinIoClient.php
Normal file
@@ -0,0 +1,690 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Storage;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\HashAlgorithm;
|
||||
use App\Framework\Encryption\HmacService;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\HttpClient\ClientRequest;
|
||||
use App\Framework\HttpClient\ClientResponse;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use App\Framework\Storage\Exceptions\StorageConnectionException;
|
||||
use App\Framework\Storage\Exceptions\StorageOperationException;
|
||||
|
||||
/**
|
||||
* MinIO/S3-compatible client with AWS Signature Version 4
|
||||
*
|
||||
* Dependency-free implementation using framework modules:
|
||||
* - RandomGenerator for cryptographic random bytes
|
||||
* - HmacService for HMAC-SHA256 signatures
|
||||
* - Hash Value Objects for payload hashing
|
||||
* - CurlHttpClient for HTTP requests
|
||||
*/
|
||||
final readonly class MinIoClient
|
||||
{
|
||||
private const string SERVICE_NAME = 's3';
|
||||
private const string SIGNATURE_VERSION = 'AWS4-HMAC-SHA256';
|
||||
private const string ALGORITHM = 'AWS4-HMAC-SHA256';
|
||||
|
||||
private string $endpoint;
|
||||
|
||||
public function __construct(
|
||||
string $endpoint,
|
||||
private string $accessKey,
|
||||
private string $secretKey,
|
||||
private string $region = 'us-east-1',
|
||||
private bool $usePathStyle = true,
|
||||
private RandomGenerator $randomGenerator,
|
||||
private HmacService $hmacService,
|
||||
private CurlHttpClient $httpClient
|
||||
) {
|
||||
// Normalize endpoint (remove trailing slash)
|
||||
$this->endpoint = rtrim($endpoint, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload object to bucket
|
||||
*
|
||||
* @param array<string, string> $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<string, mixed> $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<string, mixed> $opts Optional parameters:
|
||||
* - 'headers' => array<string, string> 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<string, string> $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;
|
||||
}
|
||||
}
|
||||
|
||||
248
src/Infrastructure/Storage/S3ObjectStorage.php
Normal file
248
src/Infrastructure/Storage/S3ObjectStorage.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Storage;
|
||||
|
||||
use App\Framework\Core\ValueObjects\FileSize;
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\HashAlgorithm;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\CustomMimeType;
|
||||
use App\Framework\Http\MimeType;
|
||||
use App\Framework\Http\MimeTypeInterface;
|
||||
use App\Framework\Storage\ObjectInfo;
|
||||
use App\Framework\Storage\ObjectStorage;
|
||||
use App\Framework\Storage\StreamableObjectStorage;
|
||||
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
|
||||
use App\Framework\Storage\Exceptions\StorageOperationException;
|
||||
use App\Framework\Storage\ValueObjects\BucketName;
|
||||
use App\Framework\Storage\ValueObjects\ObjectKey;
|
||||
use App\Framework\Storage\ValueObjects\ObjectMetadata;
|
||||
use App\Framework\Storage\ValueObjects\VersionId;
|
||||
use DateInterval;
|
||||
|
||||
/**
|
||||
* S3-compatible Object Storage implementation using MinIoClient
|
||||
*/
|
||||
final readonly class S3ObjectStorage implements ObjectStorage, StreamableObjectStorage
|
||||
{
|
||||
public function __construct(
|
||||
private MinIoClient $client
|
||||
) {
|
||||
}
|
||||
|
||||
public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo
|
||||
{
|
||||
$bucketName = BucketName::fromString($bucket);
|
||||
$objectKey = ObjectKey::fromString($key);
|
||||
|
||||
$headers = $opts['headers'] ?? [];
|
||||
if (isset($opts['contentType'])) {
|
||||
$headers['Content-Type'] = $opts['contentType'];
|
||||
}
|
||||
|
||||
$result = $this->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);
|
||||
}
|
||||
}
|
||||
|
||||
92
tests/Framework/MultiPurposeAttributeTest.php
Normal file
92
tests/Framework/MultiPurposeAttributeTest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Core\ParameterTypeValidator;
|
||||
use App\Framework\Examples\MultiPurposeAction;
|
||||
use App\Framework\Mcp\McpTool;
|
||||
use App\Framework\Reflection\WrappedReflectionMethod;
|
||||
use App\Framework\Reflection\Cache\MethodCache;
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
|
||||
test('multi-purpose method has multiple attributes', function () {
|
||||
$className = ClassName::create(MultiPurposeAction::class);
|
||||
$methodName = 'listUsers';
|
||||
|
||||
// Create a mock method cache (simplified for testing)
|
||||
$reflectionMethod = new \ReflectionMethod(MultiPurposeAction::class, $methodName);
|
||||
$attributes = $reflectionMethod->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);
|
||||
});
|
||||
|
||||
199
tests/debug/test-gitea-actions-typed.php
Normal file
199
tests/debug/test-gitea-actions-typed.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
use App\Infrastructure\Api\Gitea\GiteaClient;
|
||||
use App\Infrastructure\Api\Gitea\GiteaConfig;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
|
||||
// Load environment variables manually
|
||||
$envFile = __DIR__ . '/../../.env';
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos(trim($line), '#') === 0) {
|
||||
continue;
|
||||
}
|
||||
putenv($line);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize GiteaClient
|
||||
$config = new GiteaConfig(
|
||||
baseUrl: getenv('GITEA_URL') ?: '',
|
||||
token: getenv('GITEA_TOKEN') ?: null,
|
||||
username: getenv('GITEA_USERNAME') ?: null,
|
||||
password: getenv('GITEA_PASSWORD') ?: null,
|
||||
timeout: (float) (getenv('GITEA_TIMEOUT') ?: 30.0)
|
||||
);
|
||||
|
||||
$httpClient = new CurlHttpClient();
|
||||
$giteaClient = new GiteaClient($config, $httpClient);
|
||||
|
||||
echo "\n╔═══════════════════════════════════════════════════════════════════╗\n";
|
||||
echo "║ Gitea Actions API - Value Objects Demo ║\n";
|
||||
echo "╚═══════════════════════════════════════════════════════════════════╝\n\n";
|
||||
|
||||
$owner = 'michael';
|
||||
$repo = 'michaelschiemer';
|
||||
|
||||
try {
|
||||
// =====================================================================
|
||||
// Test 1: List Workflows (Typed)
|
||||
// =====================================================================
|
||||
echo "📋 Test 1: Listing Workflows (Type-Safe)\n";
|
||||
echo str_repeat('─', 60) . "\n";
|
||||
|
||||
$workflows = $giteaClient->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);
|
||||
}
|
||||
116
tests/debug/test-gitea-actions.php
Normal file
116
tests/debug/test-gitea-actions.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
use App\Infrastructure\Api\Gitea\GiteaClient;
|
||||
use App\Infrastructure\Api\Gitea\GiteaConfig;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
|
||||
// Load environment variables manually
|
||||
$envFile = __DIR__ . '/../../.env';
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos(trim($line), '#') === 0) {
|
||||
continue;
|
||||
}
|
||||
putenv($line);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize GiteaClient
|
||||
$config = new GiteaConfig(
|
||||
baseUrl: getenv('GITEA_URL') ?: '',
|
||||
token: getenv('GITEA_TOKEN') ?: null,
|
||||
username: getenv('GITEA_USERNAME') ?: null,
|
||||
password: getenv('GITEA_PASSWORD') ?: null,
|
||||
timeout: (float) (getenv('GITEA_TIMEOUT') ?: 30.0)
|
||||
);
|
||||
|
||||
$httpClient = new CurlHttpClient();
|
||||
$giteaClient = new GiteaClient($config, $httpClient);
|
||||
|
||||
echo "\n=== Testing Gitea Actions/Workflows API ===\n\n";
|
||||
|
||||
$owner = 'michael';
|
||||
$repo = 'michaelschiemer';
|
||||
|
||||
try {
|
||||
// Test 1: List Workflows
|
||||
echo "1. Listing Workflows for {$owner}/{$repo}:\n";
|
||||
echo str_repeat('-', 60) . "\n";
|
||||
|
||||
$workflows = $giteaClient->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);
|
||||
}
|
||||
Reference in New Issue
Block a user