fix(console): comprehensive TUI rendering fixes

- Fix Enter key detection: handle multiple Enter key formats (\n, \r, \r\n)
- Reduce flickering: lower render frequency from 60 FPS to 30 FPS
- Fix menu bar visibility: re-render menu bar after content to prevent overwriting
- Fix content positioning: explicit line positioning for categories and commands
- Fix line shifting: clear lines before writing, control newlines manually
- Limit visible items: prevent overflow with maxVisibleCategories/Commands
- Improve CPU usage: increase sleep interval when no events processed

This fixes:
- Enter key not working for selection
- Strong flickering of the application
- Menu bar not visible or being overwritten
- Top half of selection list not displayed
- Lines being shifted/misaligned
This commit is contained in:
2025-11-10 11:06:07 +01:00
parent 6bc78f5540
commit 8f3c15ddbb
106 changed files with 9082 additions and 4483 deletions

View File

@@ -0,0 +1,208 @@
# Playbook Cleanup & Server Redeploy - Summary
## Completed Tasks
### Phase 1: Playbook Cleanup ✅
#### 1.1 Redundante Diagnose-Playbooks konsolidiert
- ✅ Created `diagnose/gitea.yml` - Consolidates:
- `diagnose-gitea-timeouts.yml`
- `diagnose-gitea-timeout-deep.yml`
- `diagnose-gitea-timeout-live.yml`
- `diagnose-gitea-timeouts-complete.yml`
- `comprehensive-gitea-diagnosis.yml`
- ✅ Uses tags: `deep`, `complete` for selective execution
- ✅ Removed redundant playbooks
#### 1.2 Redundante Fix-Playbooks konsolidiert
- ✅ Created `manage/gitea.yml` - Consolidates:
- `fix-gitea-timeouts.yml`
- `fix-gitea-traefik-connection.yml`
- `fix-gitea-ssl-routing.yml`
- `fix-gitea-servers-transport.yml`
- `fix-gitea-complete.yml`
- `restart-gitea-complete.yml`
- `restart-gitea-with-cache.yml`
- ✅ Uses tags: `restart`, `fix-timeouts`, `fix-ssl`, `fix-servers-transport`, `complete`
- ✅ Removed redundant playbooks
#### 1.3 Traefik-Diagnose/Fix-Playbooks konsolidiert
- ✅ Created `diagnose/traefik.yml` - Consolidates:
- `diagnose-traefik-restarts.yml`
- `find-traefik-restart-source.yml`
- `monitor-traefik-restarts.yml`
- `monitor-traefik-continuously.yml`
- `verify-traefik-fix.yml`
- ✅ Created `manage/traefik.yml` - Consolidates:
- `stabilize-traefik.yml`
- `disable-traefik-auto-restarts.yml`
- ✅ Uses tags: `restart-source`, `monitor`, `stabilize`, `disable-auto-restart`
- ✅ Removed redundant playbooks
#### 1.4 Veraltete/Redundante Playbooks entfernt
- ✅ Removed `update-gitea-traefik-service.yml` (deprecated)
- ✅ Removed `ensure-gitea-traefik-discovery.yml` (redundant)
- ✅ Removed `test-gitea-after-fix.yml` (temporär)
- ✅ Removed `find-ansible-automation-source.yml` (temporär)
#### 1.5 Neue Verzeichnisstruktur erstellt
- ✅ Created `playbooks/diagnose/` directory
- ✅ Created `playbooks/manage/` directory
- ✅ Created `playbooks/setup/` directory
- ✅ Created `playbooks/maintenance/` directory
- ✅ Created `playbooks/deploy/` directory
#### 1.6 Playbooks verschoben
-`setup-infrastructure.yml``setup/infrastructure.yml`
-`deploy-complete.yml``deploy/complete.yml`
-`deploy-image.yml``deploy/image.yml`
-`deploy-application-code.yml``deploy/code.yml`
-`setup-ssl-certificates.yml``setup/ssl.yml`
-`setup-gitea-initial-config.yml``setup/gitea.yml`
-`cleanup-all-containers.yml``maintenance/cleanup.yml`
#### 1.7 README aktualisiert
- ✅ Updated `playbooks/README.md` with new structure
- ✅ Documented consolidated playbooks
- ✅ Added usage examples with tags
- ✅ Listed removed/consolidated playbooks
### Phase 2: Server Neustart-Vorbereitung ✅
#### 2.1 Backup-Script erstellt
- ✅ Created `maintenance/backup-before-redeploy.yml`
- ✅ Backs up:
- Gitea data (volumes)
- SSL certificates (acme.json)
- Gitea configuration (app.ini)
- Traefik configuration
- PostgreSQL data (if applicable)
- ✅ Includes backup verification
#### 2.2 Neustart-Playbook erstellt
- ✅ Created `setup/redeploy-traefik-gitea-clean.yml`
- ✅ Features:
- Automatic backup (optional)
- Stop and remove containers (preserves volumes/acme.json)
- Sync configurations
- Redeploy stacks
- Restore Gitea configuration
- Verify service discovery
- Final tests
#### 2.3 Neustart-Anleitung erstellt
- ✅ Created `setup/REDEPLOY_GUIDE.md`
- ✅ Includes:
- Step-by-step guide
- Prerequisites
- Backup verification
- Rollback procedure
- Troubleshooting
- Common issues
#### 2.4 Rollback-Playbook erstellt
- ✅ Created `maintenance/rollback-redeploy.yml`
- ✅ Features:
- Restore from backup
- Restore volumes, configurations, SSL certificates
- Restart stacks
- Verification
## New Playbook Structure
```
playbooks/
├── setup/ # Initial Setup
│ ├── infrastructure.yml
│ ├── gitea.yml
│ ├── ssl.yml
│ ├── redeploy-traefik-gitea-clean.yml
│ └── REDEPLOY_GUIDE.md
├── deploy/ # Deployment
│ ├── complete.yml
│ ├── image.yml
│ └── code.yml
├── manage/ # Management (konsolidiert)
│ ├── traefik.yml
│ └── gitea.yml
├── diagnose/ # Diagnose (konsolidiert)
│ ├── gitea.yml
│ └── traefik.yml
└── maintenance/ # Wartung
├── backup.yml
├── backup-before-redeploy.yml
├── cleanup.yml
├── rollback-redeploy.yml
└── system.yml
```
## Usage Examples
### Gitea Diagnosis
```bash
# Basic
ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml
# Deep
ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml --tags deep
# Complete
ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml --tags complete
```
### Gitea Management
```bash
# Restart
ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags restart
# Fix timeouts
ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags fix-timeouts
# Complete fix
ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags complete
```
### Redeploy
```bash
# With automatic backup
ansible-playbook -i inventory/production.yml playbooks/setup/redeploy-traefik-gitea-clean.yml \
--vault-password-file secrets/.vault_pass
# With existing backup
ansible-playbook -i inventory/production.yml playbooks/setup/redeploy-traefik-gitea-clean.yml \
--vault-password-file secrets/.vault_pass \
-e "backup_name=redeploy-backup-1234567890" \
-e "skip_backup=true"
```
### Rollback
```bash
ansible-playbook -i inventory/production.yml playbooks/maintenance/rollback-redeploy.yml \
--vault-password-file secrets/.vault_pass \
-e "backup_name=redeploy-backup-1234567890"
```
## Statistics
- **Consolidated playbooks created**: 4 (diagnose/gitea.yml, diagnose/traefik.yml, manage/gitea.yml, manage/traefik.yml)
- **Redeploy playbooks created**: 3 (redeploy-traefik-gitea-clean.yml, backup-before-redeploy.yml, rollback-redeploy.yml)
- **Redundant playbooks removed**: ~20+
- **Playbooks moved to new structure**: 7
- **Documentation created**: 2 (README.md updated, REDEPLOY_GUIDE.md)
## Next Steps
1. ✅ Test consolidated playbooks (dry-run where possible)
2. ✅ Verify redeploy playbook works correctly
3. ✅ Update CI/CD workflows to use new playbook paths if needed
4. ⏳ Perform actual server redeploy when ready
## Notes
- All consolidated playbooks use tags for selective execution
- Old wrapper playbooks (e.g., `restart-traefik.yml`) still exist and work
- Backup playbook preserves all critical data
- Redeploy playbook includes comprehensive verification
- Rollback playbook allows quick recovery if needed

View File

@@ -1,42 +1,81 @@
# Ansible Playbooks - Übersicht # Ansible Playbooks - Übersicht
## Neue Struktur
Die Playbooks wurden reorganisiert in eine klare Verzeichnisstruktur:
```
playbooks/
├── setup/ # Initial Setup
│ ├── infrastructure.yml
│ ├── gitea.yml
│ └── ssl.yml
├── deploy/ # Deployment
│ ├── complete.yml
│ ├── image.yml
│ └── code.yml
├── manage/ # Management (konsolidiert)
│ ├── traefik.yml
│ ├── gitea.yml
│ └── application.yml
├── diagnose/ # Diagnose (konsolidiert)
│ ├── gitea.yml
│ ├── traefik.yml
│ └── application.yml
└── maintenance/ # Wartung
├── backup.yml
├── backup-before-redeploy.yml
├── cleanup.yml
├── rollback-redeploy.yml
└── system.yml
```
## Verfügbare Playbooks ## Verfügbare Playbooks
> **Hinweis**: Die meisten Playbooks wurden in wiederverwendbare Roles refactored. Die Playbooks sind jetzt Wrapper, die die entsprechenden Role-Tasks aufrufen. Dies verbessert Wiederverwendbarkeit, Wartbarkeit und folgt Ansible Best Practices. > **Hinweis**: Die meisten Playbooks wurden in wiederverwendbare Roles refactored. Die Playbooks sind jetzt Wrapper, die die entsprechenden Role-Tasks aufrufen. Dies verbessert Wiederverwendbarkeit, Wartbarkeit und folgt Ansible Best Practices.
### Infrastructure Setup ### Setup (Initial Setup)
- **`setup-infrastructure.yml`** - Deployed alle Stacks (Traefik, PostgreSQL, Redis, Registry, Gitea, Monitoring, Production)
- **`setup-production-secrets.yml`** - Deployed Secrets zu Production
- **`setup-ssl-certificates.yml`** - SSL Certificate Setup (Wrapper für `traefik` Role, `tasks_from: ssl`)
- **`setup-wireguard-host.yml`** - WireGuard VPN Setup
- **`sync-stacks.yml`** - Synchronisiert Stack-Konfigurationen zum Server
### Deployment & Updates - **`setup/infrastructure.yml`** - Deployed alle Stacks (Traefik, PostgreSQL, Redis, Registry, Gitea, Monitoring, Production)
- **`rollback.yml`** - Rollback zu vorheriger Version - **`setup/gitea.yml`** - Setup Gitea Initial Configuration (Wrapper für `gitea` Role, `tasks_from: setup`)
- **`backup.yml`** - Erstellt Backups von PostgreSQL, Application Data, Gitea, Registry - **`setup/ssl.yml`** - SSL Certificate Setup (Wrapper für `traefik` Role, `tasks_from: ssl`)
- **`deploy-image.yml`** - Docker Image Deployment (wird von CI/CD Workflows verwendet) - **`setup/redeploy-traefik-gitea-clean.yml`** - Clean redeployment of Traefik and Gitea stacks
- **`setup/REDEPLOY_GUIDE.md`** - Step-by-step guide for redeployment
### Traefik Management (Role-basiert) ### Deployment
- **`deploy/complete.yml`** - Complete deployment (code + image + dependencies)
- **`deploy/image.yml`** - Docker Image Deployment (wird von CI/CD Workflows verwendet)
- **`deploy/code.yml`** - Deploy Application Code via Git (Wrapper für `application` Role, `tasks_from: deploy_code`)
### Management (Konsolidiert)
#### Traefik Management
- **`manage/traefik.yml`** - Consolidated Traefik management
- `--tags stabilize`: Fix acme.json, ensure running, monitor stability
- `--tags disable-auto-restart`: Check and document auto-restart mechanisms
- **`restart-traefik.yml`** - Restart Traefik Container (Wrapper für `traefik` Role, `tasks_from: restart`) - **`restart-traefik.yml`** - Restart Traefik Container (Wrapper für `traefik` Role, `tasks_from: restart`)
- **`recreate-traefik.yml`** - Recreate Traefik Container (Wrapper für `traefik` Role, `tasks_from: restart` mit `traefik_restart_action: recreate`) - **`recreate-traefik.yml`** - Recreate Traefik Container (Wrapper für `traefik` Role, `tasks_from: restart` mit `traefik_restart_action: recreate`)
- **`deploy-traefik-config.yml`** - Deploy Traefik Configuration Files (Wrapper für `traefik` Role, `tasks_from: config`) - **`deploy-traefik-config.yml`** - Deploy Traefik Configuration Files (Wrapper für `traefik` Role, `tasks_from: config`)
- **`check-traefik-acme-logs.yml`** - Check Traefik ACME Challenge Logs (Wrapper für `traefik` Role, `tasks_from: logs`) - **`check-traefik-acme-logs.yml`** - Check Traefik ACME Challenge Logs (Wrapper für `traefik` Role, `tasks_from: logs`)
- **`setup-ssl-certificates.yml`** - Setup Let's Encrypt SSL Certificates (Wrapper für `traefik` Role, `tasks_from: ssl`)
### Gitea Management (Role-basiert) #### Gitea Management
- **`manage/gitea.yml`** - Consolidated Gitea management
- `--tags restart`: Restart Gitea container
- `--tags fix-timeouts`: Restart Gitea and Traefik to fix timeouts
- `--tags fix-ssl`: Fix SSL/routing issues
- `--tags fix-servers-transport`: Update ServersTransport configuration
- `--tags complete`: Complete fix (stop runner, restart services, verify)
- **`check-and-restart-gitea.yml`** - Check and Restart Gitea if Unhealthy (Wrapper für `gitea` Role, `tasks_from: restart`) - **`check-and-restart-gitea.yml`** - Check and Restart Gitea if Unhealthy (Wrapper für `gitea` Role, `tasks_from: restart`)
- **`fix-gitea-runner-config.yml`** - Fix Gitea Runner Configuration (Wrapper für `gitea` Role, `tasks_from: runner` mit `gitea_runner_action: fix`) - **`fix-gitea-runner-config.yml`** - Fix Gitea Runner Configuration (Wrapper für `gitea` Role, `tasks_from: runner` mit `gitea_runner_action: fix`)
- **`register-gitea-runner.yml`** - Register Gitea Runner (Wrapper für `gitea` Role, `tasks_from: runner` mit `gitea_runner_action: register`) - **`register-gitea-runner.yml`** - Register Gitea Runner (Wrapper für `gitea` Role, `tasks_from: runner` mit `gitea_runner_action: register`)
- **`update-gitea-config.yml`** - Update Gitea Configuration (Wrapper für `gitea` Role, `tasks_from: config`) - **`update-gitea-config.yml`** - Update Gitea Configuration (Wrapper für `gitea` Role, `tasks_from: config`)
- **`setup-gitea-initial-config.yml`** - Setup Gitea Initial Configuration (Wrapper für `gitea` Role, `tasks_from: setup`)
- **`setup-gitea-repository.yml`** - Setup Gitea Repository (Wrapper für `gitea` Role, `tasks_from: repository`) - **`setup-gitea-repository.yml`** - Setup Gitea Repository (Wrapper für `gitea` Role, `tasks_from: repository`)
### Application Deployment (Role-basiert) #### Application Management
- **`deploy-application-code.yml`** - Deploy Application Code via Git (Wrapper für `application` Role, `tasks_from: deploy_code` mit `application_deployment_method: git`) - **`manage/application.yml`** - Consolidated application management (to be created)
- **`sync-application-code.yml`** - Synchronize Application Code via Rsync (Wrapper für `application` Role, `tasks_from: deploy_code` mit `application_deployment_method: rsync`) - **`sync-application-code.yml`** - Synchronize Application Code via Rsync (Wrapper für `application` Role, `tasks_from: deploy_code` mit `application_deployment_method: rsync`)
- **`install-composer-dependencies.yml`** - Install Composer Dependencies (Wrapper für `application` Role, `tasks_from: composer`) - **`install-composer-dependencies.yml`** - Install Composer Dependencies (Wrapper für `application` Role, `tasks_from: composer`)
### Application Container Management (Role-basiert)
- **`check-container-status.yml`** - Check Container Status (Wrapper für `application` Role, `tasks_from: health_check`) - **`check-container-status.yml`** - Check Container Status (Wrapper für `application` Role, `tasks_from: health_check`)
- **`check-container-logs.yml`** - Check Container Logs (Wrapper für `application` Role, `tasks_from: logs`) - **`check-container-logs.yml`** - Check Container Logs (Wrapper für `application` Role, `tasks_from: logs`)
- **`check-worker-logs.yml`** - Check Worker and Scheduler Logs (Wrapper für `application` Role, `tasks_from: logs` mit `application_logs_check_vendor: true`) - **`check-worker-logs.yml`** - Check Worker and Scheduler Logs (Wrapper für `application` Role, `tasks_from: logs` mit `application_logs_check_vendor: true`)
@@ -46,28 +85,89 @@
- **`recreate-containers-with-env.yml`** - Recreate Containers with Environment Variables (Wrapper für `application` Role, `tasks_from: containers` mit `application_container_action: recreate-with-env`) - **`recreate-containers-with-env.yml`** - Recreate Containers with Environment Variables (Wrapper für `application` Role, `tasks_from: containers` mit `application_container_action: recreate-with-env`)
- **`sync-and-recreate-containers.yml`** - Sync and Recreate Containers (Wrapper für `application` Role, `tasks_from: containers` mit `application_container_action: sync-recreate`) - **`sync-and-recreate-containers.yml`** - Sync and Recreate Containers (Wrapper für `application` Role, `tasks_from: containers` mit `application_container_action: sync-recreate`)
### Diagnose (Konsolidiert)
#### Gitea Diagnose
- **`diagnose/gitea.yml`** - Consolidated Gitea diagnosis
- Basic checks (always): Container status, health endpoints, network connectivity, service discovery
- `--tags deep`: Resource usage, multiple connection tests, log analysis
- `--tags complete`: All checks including app.ini, ServersTransport, etc.
#### Traefik Diagnose
- **`diagnose/traefik.yml`** - Consolidated Traefik diagnosis
- Basic checks (always): Container status, restart count, recent logs
- `--tags restart-source`: Find source of restart loops (cronjobs, systemd, scripts)
- `--tags monitor`: Monitor for restarts over time
### Maintenance ### Maintenance
- **`cleanup-all-containers.yml`** - Stoppt und entfernt alle Container, bereinigt Netzwerke und Volumes (für vollständigen Server-Reset)
- **`system-maintenance.yml`** - System-Updates, Unattended-Upgrades, Docker-Pruning - **`maintenance/backup.yml`** - Erstellt Backups von PostgreSQL, Application Data, Gitea, Registry
- **`troubleshoot.yml`** - Unified Troubleshooting mit Tags - **`maintenance/backup-before-redeploy.yml`** - Backup before redeploy (Gitea data, SSL certificates, configurations)
- **`maintenance/rollback-redeploy.yml`** - Rollback from redeploy backup
- **`maintenance/cleanup.yml`** - Stoppt und entfernt alle Container, bereinigt Netzwerke und Volumes (für vollständigen Server-Reset)
- **`maintenance/system.yml`** - System-Updates, Unattended-Upgrades, Docker-Pruning
- **`rollback.yml`** - Rollback zu vorheriger Version
### WireGuard ### WireGuard
- **`generate-wireguard-client.yml`** - Generiert WireGuard Client-Config - **`generate-wireguard-client.yml`** - Generiert WireGuard Client-Config
- **`wireguard-routing.yml`** - Konfiguriert WireGuard Routing - **`wireguard-routing.yml`** - Konfiguriert WireGuard Routing
- **`setup-wireguard-host.yml`** - WireGuard VPN Setup
### Initial Deployment ### Initial Deployment
- **`build-initial-image.yml`** - Build und Push des initialen Docker Images (für erstes Deployment) - **`build-initial-image.yml`** - Build und Push des initialen Docker Images (für erstes Deployment)
### CI/CD & Development ### CI/CD & Development
- **`setup-gitea-runner-ci.yml`** - Gitea Runner CI Setup - **`setup-gitea-runner-ci.yml`** - Gitea Runner CI Setup
- **`install-docker.yml`** - Docker Installation auf Server - **`install-docker.yml`** - Docker Installation auf Server
## Entfernte/Legacy Playbooks ## Entfernte/Konsolidierte Playbooks
Die folgenden Playbooks wurden entfernt, da sie nicht mehr benötigt werden: Die folgenden Playbooks wurden konsolidiert oder entfernt:
- ~~`build-and-push.yml`~~ - Wird durch CI/CD Pipeline ersetzt
- ~~`remove-framework-production-stack.yml`~~ - Temporäres Playbook ### Konsolidiert in `diagnose/gitea.yml`:
- ~~`remove-temporary-grafana-ip.yml`~~ - Temporäres Playbook - ~~`diagnose-gitea-timeouts.yml`~~
- ~~`diagnose-gitea-timeout-deep.yml`~~
- ~~`diagnose-gitea-timeout-live.yml`~~
- ~~`diagnose-gitea-timeouts-complete.yml`~~
- ~~`comprehensive-gitea-diagnosis.yml`~~
### Konsolidiert in `manage/gitea.yml`:
- ~~`fix-gitea-timeouts.yml`~~
- ~~`fix-gitea-traefik-connection.yml`~~
- ~~`fix-gitea-ssl-routing.yml`~~
- ~~`fix-gitea-servers-transport.yml`~~
- ~~`fix-gitea-complete.yml`~~
- ~~`restart-gitea-complete.yml`~~
- ~~`restart-gitea-with-cache.yml`~~
### Konsolidiert in `diagnose/traefik.yml`:
- ~~`diagnose-traefik-restarts.yml`~~
- ~~`find-traefik-restart-source.yml`~~
- ~~`monitor-traefik-restarts.yml`~~
- ~~`monitor-traefik-continuously.yml`~~
- ~~`verify-traefik-fix.yml`~~
### Konsolidiert in `manage/traefik.yml`:
- ~~`stabilize-traefik.yml`~~
- ~~`disable-traefik-auto-restarts.yml`~~
### Entfernt (veraltet/redundant):
- ~~`update-gitea-traefik-service.yml`~~ - Deprecated (wie in Code dokumentiert)
- ~~`ensure-gitea-traefik-discovery.yml`~~ - Redundant
- ~~`test-gitea-after-fix.yml`~~ - Temporär
- ~~`find-ansible-automation-source.yml`~~ - Temporär
### Verschoben:
- `setup-infrastructure.yml``setup/infrastructure.yml`
- `deploy-complete.yml``deploy/complete.yml`
- `deploy-image.yml``deploy/image.yml`
- `deploy-application-code.yml``deploy/code.yml`
- `setup-ssl-certificates.yml``setup/ssl.yml`
- `setup-gitea-initial-config.yml``setup/gitea.yml`
- `cleanup-all-containers.yml``maintenance/cleanup.yml`
## Verwendung ## Verwendung
@@ -78,6 +178,69 @@ cd deployment/ansible
ansible-playbook -i inventory/production.yml playbooks/<playbook>.yml --vault-password-file secrets/.vault_pass ansible-playbook -i inventory/production.yml playbooks/<playbook>.yml --vault-password-file secrets/.vault_pass
``` ```
### Konsolidierte Playbooks mit Tags
**Gitea Diagnose:**
```bash
# Basic diagnosis (default)
ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml --vault-password-file secrets/.vault_pass
# Deep diagnosis
ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml --tags deep --vault-password-file secrets/.vault_pass
# Complete diagnosis
ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml --tags complete --vault-password-file secrets/.vault_pass
```
**Gitea Management:**
```bash
# Restart Gitea
ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags restart --vault-password-file secrets/.vault_pass
# Fix timeouts
ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags fix-timeouts --vault-password-file secrets/.vault_pass
# Complete fix
ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags complete --vault-password-file secrets/.vault_pass
```
**Traefik Diagnose:**
```bash
# Basic diagnosis
ansible-playbook -i inventory/production.yml playbooks/diagnose/traefik.yml --vault-password-file secrets/.vault_pass
# Find restart source
ansible-playbook -i inventory/production.yml playbooks/diagnose/traefik.yml --tags restart-source --vault-password-file secrets/.vault_pass
# Monitor restarts
ansible-playbook -i inventory/production.yml playbooks/diagnose/traefik.yml --tags monitor --vault-password-file secrets/.vault_pass
```
**Traefik Management:**
```bash
# Stabilize Traefik
ansible-playbook -i inventory/production.yml playbooks/manage/traefik.yml --tags stabilize --vault-password-file secrets/.vault_pass
```
**Redeploy:**
```bash
# With automatic backup
ansible-playbook -i inventory/production.yml playbooks/setup/redeploy-traefik-gitea-clean.yml --vault-password-file secrets/.vault_pass
# With existing backup
ansible-playbook -i inventory/production.yml playbooks/setup/redeploy-traefik-gitea-clean.yml \
--vault-password-file secrets/.vault_pass \
-e "backup_name=redeploy-backup-1234567890" \
-e "skip_backup=true"
```
**Rollback:**
```bash
ansible-playbook -i inventory/production.yml playbooks/maintenance/rollback-redeploy.yml \
--vault-password-file secrets/.vault_pass \
-e "backup_name=redeploy-backup-1234567890"
```
### Role-basierte Playbooks ### Role-basierte Playbooks
Die meisten Playbooks sind jetzt Wrapper, die Roles verwenden. Die Funktionalität bleibt gleich, aber die Implementierung ist jetzt in wiederverwendbaren Roles organisiert: Die meisten Playbooks sind jetzt Wrapper, die Roles verwenden. Die Funktionalität bleibt gleich, aber die Implementierung ist jetzt in wiederverwendbaren Roles organisiert:
@@ -99,7 +262,7 @@ ansible-playbook -i inventory/production.yml playbooks/fix-gitea-runner-config.y
**Beispiel: Application Code Deployment** **Beispiel: Application Code Deployment**
```bash ```bash
# Git-basiert (Standard): # Git-basiert (Standard):
ansible-playbook -i inventory/production.yml playbooks/deploy-application-code.yml \ ansible-playbook -i inventory/production.yml playbooks/deploy/code.yml \
-e "deployment_environment=staging" \ -e "deployment_environment=staging" \
-e "git_branch=staging" \ -e "git_branch=staging" \
--vault-password-file secrets/.vault_pass --vault-password-file secrets/.vault_pass
@@ -109,21 +272,6 @@ ansible-playbook -i inventory/production.yml playbooks/sync-application-code.yml
--vault-password-file secrets/.vault_pass --vault-password-file secrets/.vault_pass
``` ```
### Tags verwenden
Viele Playbooks unterstützen Tags für selektive Ausführung:
```bash
# Nur Traefik-bezogene Tasks:
ansible-playbook -i inventory/production.yml playbooks/restart-traefik.yml --tags traefik,restart
# Nur Gitea-bezogene Tasks:
ansible-playbook -i inventory/production.yml playbooks/check-and-restart-gitea.yml --tags gitea,restart
# Nur Application-bezogene Tasks:
ansible-playbook -i inventory/production.yml playbooks/deploy-application-code.yml --tags application,deploy
```
## Role-Struktur ## Role-Struktur
Die Playbooks verwenden jetzt folgende Roles: Die Playbooks verwenden jetzt folgende Roles:
@@ -143,11 +291,11 @@ Die Playbooks verwenden jetzt folgende Roles:
- **Location**: `roles/application/tasks/` - **Location**: `roles/application/tasks/`
- **Defaults**: `roles/application/defaults/main.yml` - **Defaults**: `roles/application/defaults/main.yml`
## Vorteile der Role-basierten Struktur ## Vorteile der neuen Struktur
1. **Wiederverwendbarkeit**: Tasks können in mehreren Playbooks genutzt werden
2. **Wartbarkeit**: Änderungen zentral in Roles
3. **Testbarkeit**: Roles isoliert testbar
4. **Klarheit**: Klare Struktur nach Komponenten
5. **Best Practices**: Folgt Ansible-Empfehlungen
1. **Klarheit**: Klare Verzeichnisstruktur nach Funktion
2. **Konsolidierung**: Redundante Playbooks zusammengeführt
3. **Tags**: Selektive Ausführung mit Tags
4. **Wiederverwendbarkeit**: Tasks können in mehreren Playbooks genutzt werden
5. **Wartbarkeit**: Änderungen zentral in Roles
6. **Best Practices**: Folgt Ansible-Empfehlungen

View File

@@ -1,195 +0,0 @@
---
# Comprehensive Gitea Timeout Diagnosis
# Prüft alle Aspekte des intermittierenden Gitea-Timeout-Problems
- name: Comprehensive Gitea Timeout Diagnosis
hosts: production
gather_facts: yes
become: no
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_url: "https://{{ gitea_domain }}"
tasks:
- name: Check Traefik container uptime and restart count
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.State.Status{{ '}}' }}|{{ '{{' }}.State.StartedAt{{ '}}' }}|{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: traefik_info
changed_when: false
- name: Check Gitea container uptime and restart count
ansible.builtin.shell: |
docker inspect gitea --format '{{ '{{' }}.State.Status{{ '}}' }}|{{ '{{' }}.State.StartedAt{{ '}}' }}|{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: gitea_info
changed_when: false
- name: Check Traefik logs for recent restarts (last 2 hours)
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs traefik --since 2h 2>&1 | grep -iE "stopping server gracefully|I have to go|restart|shutdown" | tail -20 || echo "Keine Restart-Meldungen in den letzten 2 Stunden"
register: traefik_restart_logs
changed_when: false
- name: Check Gitea logs for errors/timeouts (last 2 hours)
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose logs gitea --since 2h 2>&1 | grep -iE "error|timeout|failed|panic|fatal|slow" | tail -30 || echo "Keine Fehler in den letzten 2 Stunden"
register: gitea_error_logs
changed_when: false
- name: Test Gitea direct connection (multiple attempts)
ansible.builtin.shell: |
for i in {1..5}; do
echo "=== Attempt $i ==="
cd {{ gitea_stack_path }}
timeout 5 docker compose exec -T gitea curl -f http://localhost:3000/api/healthz 2>&1 || echo "FAILED"
sleep 1
done
register: gitea_direct_tests
changed_when: false
- name: Test Gitea via Traefik (multiple attempts)
ansible.builtin.shell: |
for i in {1..5}; do
echo "=== Attempt $i ==="
timeout 10 curl -k -s -o /dev/null -w "%{http_code}" {{ gitea_url }}/api/healthz 2>&1 || echo "TIMEOUT"
sleep 2
done
register: gitea_traefik_tests
changed_when: false
- name: Check Traefik service discovery for Gitea (using CLI)
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose exec -T traefik traefik show providers docker 2>/dev/null | grep -i "gitea" || echo "Gitea service not found in Traefik providers"
register: traefik_gitea_service
changed_when: false
failed_when: false
- name: Check Traefik routers for Gitea (using CLI)
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose exec -T traefik traefik show providers docker 2>/dev/null | grep -i "gitea" || echo "Gitea router not found in Traefik providers"
register: traefik_gitea_router
changed_when: false
failed_when: false
- name: Check network connectivity Traefik -> Gitea
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
for i in {1..3}; do
echo "=== Attempt $i ==="
docker compose exec -T traefik wget -qO- --timeout=5 http://gitea:3000/api/healthz 2>&1 || echo "CONNECTION_FAILED"
sleep 1
done
register: traefik_gitea_network
changed_when: false
- name: Check Gitea container resources (CPU/Memory)
ansible.builtin.shell: |
docker stats gitea --no-stream --format 'CPU: {{ '{{' }}.CPUPerc{{ '}}' }} | Memory: {{ '{{' }}.MemUsage{{ '}}' }}' 2>/dev/null || echo "Could not get stats"
register: gitea_resources
changed_when: false
failed_when: false
- name: Check Traefik container resources (CPU/Memory)
ansible.builtin.shell: |
docker stats traefik --no-stream --format 'CPU: {{ '{{' }}.CPUPerc{{ '}}' }} | Memory: {{ '{{' }}.MemUsage{{ '}}' }}' 2>/dev/null || echo "Could not get stats"
register: traefik_resources
changed_when: false
failed_when: false
- name: Check if Gitea is in traefik-public network
ansible.builtin.shell: |
docker network inspect traefik-public --format '{{ '{{' }}range .Containers{{ '}}' }}{{ '{{' }}.Name{{ '}}' }} {{ '{{' }}end{{ '}}' }}' 2>/dev/null | grep -q gitea && echo "YES" || echo "NO"
register: gitea_in_network
changed_when: false
- name: Check Traefik access logs for Gitea requests (last 100 lines)
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
tail -100 logs/access.log 2>/dev/null | grep -i "git.michaelschiemer.de" | tail -20 || echo "Keine Access-Logs gefunden"
register: traefik_access_logs
changed_when: false
failed_when: false
- name: Check Traefik error logs for Gitea-related errors
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
tail -100 logs/traefik.log 2>/dev/null | grep -iE "gitea|git\.michaelschiemer\.de|timeout|error.*gitea" | tail -20 || echo "Keine Gitea-Fehler in Traefik-Logs"
register: traefik_error_logs
changed_when: false
failed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
UMFASSENDE GITEA TIMEOUT DIAGNOSE:
================================================================================
Container Status:
- Traefik: {{ traefik_info.stdout }}
- Gitea: {{ gitea_info.stdout }}
Traefik Restart-Logs (letzte 2h):
{{ traefik_restart_logs.stdout }}
Gitea Error-Logs (letzte 2h):
{{ gitea_error_logs.stdout }}
Direkte Gitea-Verbindung (5 Versuche):
{{ gitea_direct_tests.stdout }}
Gitea via Traefik (5 Versuche):
{{ gitea_traefik_tests.stdout }}
Traefik Service Discovery:
- Gitea Service: {{ traefik_gitea_service.stdout }}
- Gitea Router: {{ traefik_gitea_router.stdout }}
Netzwerk-Verbindung Traefik -> Gitea (3 Versuche):
{{ traefik_gitea_network.stdout }}
Container-Ressourcen:
- Gitea: {{ gitea_resources.stdout }}
- Traefik: {{ traefik_resources.stdout }}
Netzwerk:
- Gitea in traefik-public: {% if gitea_in_network.stdout == 'YES' %}✅{% else %}❌{% endif %}
Traefik Access-Logs (letzte 20 Gitea-Requests):
{{ traefik_access_logs.stdout }}
Traefik Error-Logs (Gitea-bezogen):
{{ traefik_error_logs.stdout }}
================================================================================
ANALYSE:
================================================================================
{% if 'stopping server gracefully' in traefik_restart_logs.stdout | lower or 'I have to go' in traefik_restart_logs.stdout %}
❌ PROBLEM: Traefik wird regelmäßig gestoppt!
→ Dies ist die Hauptursache für die Timeouts
→ Führe 'find-traefik-restart-source.yml' aus um die Quelle zu finden
{% endif %}
{% if 'CONNECTION_FAILED' in traefik_gitea_network.stdout %}
❌ PROBLEM: Traefik kann Gitea nicht erreichen
→ Netzwerk-Problem zwischen Traefik und Gitea
→ Prüfe ob beide Container im traefik-public Netzwerk sind
{% endif %}
{% if 'not found' in traefik_gitea_service.stdout | lower or 'not found' in traefik_gitea_router.stdout | lower %}
❌ PROBLEM: Gitea nicht in Traefik Service Discovery
→ Traefik hat Gitea nicht erkannt
→ Führe 'fix-gitea-timeouts.yml' aus um beide zu restarten
{% endif %}
{% if 'TIMEOUT' in gitea_traefik_tests.stdout %}
⚠️ PROBLEM: Intermittierende Timeouts via Traefik
→ Mögliche Ursachen: Traefik-Restarts, Gitea-Performance, Netzwerk-Probleme
{% endif %}
================================================================================

View File

@@ -1,499 +0,0 @@
---
# Diagnose Gitea Timeout - Deep Analysis während Request
# Führt alle Checks während eines tatsächlichen Requests durch, inkl. pg_stat_activity, Redis, Backpressure-Tests
- name: Diagnose Gitea Timeout Deep Analysis During Request
hosts: production
gather_facts: yes
become: no
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_url: "https://{{ gitea_domain }}"
test_duration_seconds: 60 # Wie lange wir testen
test_timestamp: "{{ ansible_date_time.epoch }}"
postgres_max_connections: 300
tasks:
- name: Display diagnostic plan
ansible.builtin.debug:
msg: |
================================================================================
GITEA TIMEOUT DEEP DIAGNOSE - LIVE WÄHREND REQUEST
================================================================================
Diese erweiterte Diagnose führt alle Checks während eines tatsächlichen Requests durch:
1. Docker Stats (CPU/RAM/IO) während Request
2. pg_stat_activity: Connection Count vs max_connections ({{ postgres_max_connections }})
3. Redis Ping Check (Session-Store-Blockaden)
4. Gitea localhost Test (Backpressure-Analyse)
5. Gitea Logs (DB-Timeouts, Panics, "context deadline exceeded", SESSION: context canceled)
6. Postgres Logs (Connection issues, authentication timeouts)
7. Traefik Logs ("backend connection error", "EOF")
8. Runner Status und git-upload-pack/git gc Jobs
Test-Dauer: {{ test_duration_seconds }} Sekunden
Timestamp: {{ test_timestamp }}
================================================================================
- name: Get initial container stats (baseline)
ansible.builtin.shell: |
docker stats --no-stream --format "table {{ '{{' }}.Name{{ '}}' }}\t{{ '{{' }}.CPUPerc{{ '}}' }}\t{{ '{{' }}.MemUsage{{ '}}' }}\t{{ '{{' }}.NetIO{{ '}}' }}\t{{ '{{' }}.BlockIO{{ '}}' }}" gitea gitea-postgres gitea-redis traefik 2>/dev/null || echo "Stats collection failed"
register: initial_stats
changed_when: false
- name: Get initial PostgreSQL connection count
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T postgres psql -U gitea -d gitea -c "SELECT count(*) as connection_count FROM pg_stat_activity;" 2>&1 | grep -E "^[[:space:]]*[0-9]+" | head -1 || echo "0"
register: initial_pg_connections
changed_when: false
failed_when: false
- name: Start collecting Docker stats in background
ansible.builtin.shell: |
timeout {{ test_duration_seconds }} docker stats --format "{{ '{{' }}.Name{{ '}}' }},{{ '{{' }}.CPUPerc{{ '}}' }},{{ '{{' }}.MemUsage{{ '}}' }},{{ '{{' }}.NetIO{{ '}}' }},{{ '{{' }}.BlockIO{{ '}}' }}" gitea gitea-postgres gitea-redis traefik 2>/dev/null | while read line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] $line"
done > /tmp/gitea_stats_{{ test_timestamp }}.log 2>&1 &
STATS_PID=$!
echo $STATS_PID
register: stats_pid
changed_when: false
- name: Start collecting Gitea logs in background
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
timeout {{ test_duration_seconds }} docker compose logs -f gitea 2>&1 | while read line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] $line"
done > /tmp/gitea_logs_{{ test_timestamp }}.log 2>&1 &
echo $!
register: gitea_logs_pid
changed_when: false
- name: Start collecting Postgres logs in background
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
timeout {{ test_duration_seconds }} docker compose logs -f postgres 2>&1 | while read line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] $line"
done > /tmp/postgres_logs_{{ test_timestamp }}.log 2>&1 &
echo $!
register: postgres_logs_pid
changed_when: false
- name: Start collecting Traefik logs in background
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
timeout {{ test_duration_seconds }} docker compose logs -f traefik 2>&1 | while read line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] $line"
done > /tmp/traefik_logs_{{ test_timestamp }}.log 2>&1 &
echo $!
register: traefik_logs_pid
changed_when: false
- name: Start monitoring pg_stat_activity in background
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
for i in $(seq 1 {{ (test_duration_seconds / 5) | int }}); do
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] $(docker compose exec -T postgres psql -U gitea -d gitea -t -c 'SELECT count(*) FROM pg_stat_activity;' 2>&1 | tr -d ' ' || echo 'ERROR')"
sleep 5
done > /tmp/pg_stat_activity_{{ test_timestamp }}.log 2>&1 &
echo $!
register: pg_stat_pid
changed_when: false
- name: Wait a moment for log collection to start
ansible.builtin.pause:
seconds: 2
- name: Trigger Gitea request via Traefik (with timeout)
ansible.builtin.shell: |
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Starting request to {{ gitea_url }}/api/healthz"
timeout 35 curl -k -v -s -o /tmp/gitea_response_{{ test_timestamp }}.log -w "\nHTTP_CODE:%{http_code}\nTIME_TOTAL:%{time_total}\nTIME_CONNECT:%{time_connect}\nTIME_STARTTRANSFER:%{time_starttransfer}\n" "{{ gitea_url }}/api/healthz" 2>&1 | tee /tmp/gitea_curl_{{ test_timestamp }}.log
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Request completed"
register: gitea_request
changed_when: false
failed_when: false
- name: Test Gitea localhost (Backpressure-Test)
ansible.builtin.shell: |
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Starting localhost test"
cd {{ gitea_stack_path }}
timeout 35 docker compose exec -T gitea curl -f -s -w "\nHTTP_CODE:%{http_code}\nTIME_TOTAL:%{time_total}\n" http://localhost:3000/api/healthz 2>&1 | tee /tmp/gitea_localhost_{{ test_timestamp }}.log || echo "LOCALHOST_TEST_FAILED" > /tmp/gitea_localhost_{{ test_timestamp }}.log
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Localhost test completed"
register: gitea_localhost_test
changed_when: false
failed_when: false
- name: Test direct connection Traefik → Gitea (parallel)
ansible.builtin.shell: |
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Starting direct test Traefik → Gitea"
cd {{ traefik_stack_path }}
timeout 35 docker compose exec -T traefik wget -qO- --timeout=30 http://gitea:3000/api/healthz 2>&1 | tee /tmp/traefik_gitea_direct_{{ test_timestamp }}.log || echo "DIRECT_TEST_FAILED" > /tmp/traefik_gitea_direct_{{ test_timestamp }}.log
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Direct test completed"
register: traefik_direct_test
changed_when: false
failed_when: false
- name: Test Redis connection during request
ansible.builtin.shell: |
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Testing Redis connection"
cd {{ gitea_stack_path }}
docker compose exec -T redis redis-cli ping 2>&1 | tee /tmp/redis_ping_{{ test_timestamp }}.log || echo "REDIS_PING_FAILED" > /tmp/redis_ping_{{ test_timestamp }}.log
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Redis ping completed"
register: redis_ping_test
changed_when: false
failed_when: false
- name: Check Gitea Runner status
ansible.builtin.shell: |
docker ps --format "{{ '{{' }}.Names{{ '}}' }}" | grep -q "gitea-runner" && echo "RUNNING" || echo "STOPPED"
register: runner_status
changed_when: false
failed_when: false
- name: Wait for log collection to complete
ansible.builtin.pause:
seconds: "{{ test_duration_seconds - 5 }}"
- name: Stop background processes
ansible.builtin.shell: |
pkill -f "docker.*stats.*gitea" || true
pkill -f "docker compose logs.*gitea" || true
pkill -f "docker compose logs.*postgres" || true
pkill -f "docker compose logs.*traefik" || true
pkill -f "pg_stat_activity" || true
sleep 2
changed_when: false
failed_when: false
- name: Get final PostgreSQL connection count
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T postgres psql -U gitea -d gitea -c "SELECT count(*) as connection_count FROM pg_stat_activity;" 2>&1 | grep -E "^[[:space:]]*[0-9]+" | head -1 || echo "0"
register: final_pg_connections
changed_when: false
failed_when: false
- name: Collect stats results
ansible.builtin.slurp:
src: "/tmp/gitea_stats_{{ test_timestamp }}.log"
register: stats_results
changed_when: false
failed_when: false
- name: Collect pg_stat_activity results
ansible.builtin.slurp:
src: "/tmp/pg_stat_activity_{{ test_timestamp }}.log"
register: pg_stat_results
changed_when: false
failed_when: false
- name: Collect Gitea logs results
ansible.builtin.slurp:
src: "/tmp/gitea_logs_{{ test_timestamp }}.log"
register: gitea_logs_results
changed_when: false
failed_when: false
- name: Collect Postgres logs results
ansible.builtin.slurp:
src: "/tmp/postgres_logs_{{ test_timestamp }}.log"
register: postgres_logs_results
changed_when: false
failed_when: false
- name: Collect Traefik logs results
ansible.builtin.slurp:
src: "/tmp/traefik_logs_{{ test_timestamp }}.log"
register: traefik_logs_results
changed_when: false
failed_when: false
- name: Get request result
ansible.builtin.slurp:
src: "/tmp/gitea_curl_{{ test_timestamp }}.log"
register: request_result
changed_when: false
failed_when: false
- name: Get localhost test result
ansible.builtin.slurp:
src: "/tmp/gitea_localhost_{{ test_timestamp }}.log"
register: localhost_result
changed_when: false
failed_when: false
- name: Get direct test result
ansible.builtin.slurp:
src: "/tmp/traefik_gitea_direct_{{ test_timestamp }}.log"
register: direct_test_result
changed_when: false
failed_when: false
- name: Get Redis ping result
ansible.builtin.slurp:
src: "/tmp/redis_ping_{{ test_timestamp }}.log"
register: redis_ping_result
changed_when: false
failed_when: false
- name: Analyze pg_stat_activity for connection count
ansible.builtin.shell: |
if [ -f /tmp/pg_stat_activity_{{ test_timestamp }}.log ]; then
echo "=== POSTGRES CONNECTION COUNT ANALYSIS ==="
echo "Initial connections: {{ initial_pg_connections.stdout }}"
echo "Final connections: {{ final_pg_connections.stdout }}"
echo "Max connections: {{ postgres_max_connections }}"
echo ""
echo "=== CONNECTION COUNT TIMELINE ==="
cat /tmp/pg_stat_activity_{{ test_timestamp }}.log | tail -20 || echo "No connection count data"
echo ""
echo "=== CONNECTION COUNT ANALYSIS ==="
MAX_COUNT=$(cat /tmp/pg_stat_activity_{{ test_timestamp }}.log | grep -E "^\[.*\] [0-9]+" | awk -F'] ' '{print $2}' | sort -n | tail -1 || echo "0")
if [ "$MAX_COUNT" != "0" ] && [ "$MAX_COUNT" != "" ]; then
echo "Maximum connections during test: $MAX_COUNT"
WARNING_THRESHOLD=$(({{ postgres_max_connections }} * 80 / 100))
if [ "$MAX_COUNT" -gt "$WARNING_THRESHOLD" ]; then
echo "⚠️ WARNING: Connection count ($MAX_COUNT) is above 80% of max_connections ({{ postgres_max_connections }})"
echo " Consider reducing MAX_OPEN_CONNS or increasing max_connections"
else
echo "✅ Connection count is within safe limits"
fi
fi
else
echo "pg_stat_activity log file not found"
fi
register: pg_stat_analysis
changed_when: false
failed_when: false
- name: Analyze stats for high CPU/Memory/IO
ansible.builtin.shell: |
if [ -f /tmp/gitea_stats_{{ test_timestamp }}.log ]; then
echo "=== STATS SUMMARY ==="
echo "Total samples: $(wc -l < /tmp/gitea_stats_{{ test_timestamp }}.log)"
echo ""
echo "=== HIGH CPU (>80%) ==="
grep -E "gitea|gitea-postgres" /tmp/gitea_stats_{{ test_timestamp }}.log | awk -F',' '{cpu=$2; gsub(/%/, "", cpu); if (cpu+0 > 80) print $0}' | head -10 || echo "No high CPU usage found"
echo ""
echo "=== MEMORY USAGE ==="
grep -E "gitea" /tmp/gitea_stats_{{ test_timestamp }}.log | tail -5 || echo "No memory stats"
else
echo "Stats file not found"
fi
register: stats_analysis
changed_when: false
failed_when: false
- name: Analyze Gitea logs for errors (including SESSION context canceled, panic, git-upload-pack)
ansible.builtin.shell: |
if [ -f /tmp/gitea_logs_{{ test_timestamp }}.log ]; then
echo "=== DB-TIMEOUTS / CONNECTION ERRORS ==="
grep -iE "timeout|deadline exceeded|connection.*failed|database.*error|postgres.*error|context.*deadline" /tmp/gitea_logs_{{ test_timestamp }}.log | tail -20 || echo "No DB-timeouts found"
echo ""
echo "=== SESSION: CONTEXT CANCELED ==="
grep -iE "SESSION.*context canceled|session.*release.*context canceled" /tmp/gitea_logs_{{ test_timestamp }}.log | tail -10 || echo "No SESSION: context canceled found"
echo ""
echo "=== PANICS / FATAL ERRORS ==="
grep -iE "panic|fatal|error.*fatal" /tmp/gitea_logs_{{ test_timestamp }}.log | tail -10 || echo "No panics found"
echo ""
echo "=== GIT-UPLOAD-PACK REQUESTS (can block) ==="
grep -iE "git-upload-pack|ServiceUploadPack" /tmp/gitea_logs_{{ test_timestamp }}.log | tail -10 || echo "No git-upload-pack requests found"
echo ""
echo "=== GIT GC JOBS (can hold connections) ==="
grep -iE "git.*gc|garbage.*collect" /tmp/gitea_logs_{{ test_timestamp }}.log | tail -10 || echo "No git gc jobs found"
echo ""
echo "=== SLOW QUERIES / PERFORMANCE ==="
grep -iE "slow|performance|took.*ms|duration" /tmp/gitea_logs_{{ test_timestamp }}.log | tail -10 || echo "No slow queries found"
else
echo "Gitea logs file not found"
fi
register: gitea_logs_analysis
changed_when: false
failed_when: false
- name: Analyze Postgres logs for errors
ansible.builtin.shell: |
if [ -f /tmp/postgres_logs_{{ test_timestamp }}.log ]; then
echo "=== POSTGRES ERRORS ==="
grep -iE "error|timeout|deadlock|connection.*refused|too many connections|authentication.*timeout" /tmp/postgres_logs_{{ test_timestamp }}.log | tail -20 || echo "No Postgres errors found"
echo ""
echo "=== SLOW QUERIES ==="
grep -iE "slow|duration|statement.*took" /tmp/postgres_logs_{{ test_timestamp }}.log | tail -10 || echo "No slow queries found"
else
echo "Postgres logs file not found"
fi
register: postgres_logs_analysis
changed_when: false
failed_when: false
- name: Analyze Traefik logs for backend errors
ansible.builtin.shell: |
if [ -f /tmp/traefik_logs_{{ test_timestamp }}.log ]; then
echo "=== BACKEND CONNECTION ERRORS ==="
grep -iE "backend.*error|connection.*error|EOF|gitea.*error|git\.michaelschiemer\.de.*error" /tmp/traefik_logs_{{ test_timestamp }}.log | tail -20 || echo "No backend errors found"
echo ""
echo "=== TIMEOUT ERRORS ==="
grep -iE "timeout|504|gateway.*timeout" /tmp/traefik_logs_{{ test_timestamp }}.log | tail -10 || echo "No timeout errors found"
else
echo "Traefik logs file not found"
fi
register: traefik_logs_analysis
changed_when: false
failed_when: false
- name: Display comprehensive diagnosis
ansible.builtin.debug:
msg: |
================================================================================
GITEA TIMEOUT DEEP DIAGNOSE - ERGEBNISSE
================================================================================
BASELINE STATS (vor Request):
{{ initial_stats.stdout }}
POSTGRES CONNECTION COUNT:
{{ pg_stat_analysis.stdout }}
REQUEST ERGEBNIS (Traefik → Gitea):
{% if request_result.content is defined and request_result.content != '' %}
{{ request_result.content | b64decode }}
{% else %}
Request-Ergebnis nicht verfügbar
{% endif %}
BACKPRESSURE TEST - GITEA LOCALHOST:
{% if localhost_result.content is defined and localhost_result.content != '' %}
{{ localhost_result.content | b64decode }}
{% else %}
Localhost-Test-Ergebnis nicht verfügbar
{% endif %}
DIREKTER TEST TRAEFIK → GITEA:
{% if direct_test_result.content is defined and direct_test_result.content != '' %}
{{ direct_test_result.content | b64decode }}
{% else %}
Direkter Test-Ergebnis nicht verfügbar
{% endif %}
REDIS PING TEST:
{% if redis_ping_result.content is defined and redis_ping_result.content != '' %}
{{ redis_ping_result.content | b64decode }}
{% else %}
Redis-Ping-Ergebnis nicht verfügbar
{% endif %}
RUNNER STATUS:
- Status: {{ runner_status.stdout }}
================================================================================
STATS-ANALYSE (während Request):
================================================================================
{{ stats_analysis.stdout }}
================================================================================
GITEA LOGS-ANALYSE:
================================================================================
{{ gitea_logs_analysis.stdout }}
================================================================================
POSTGRES LOGS-ANALYSE:
================================================================================
{{ postgres_logs_analysis.stdout }}
================================================================================
TRAEFIK LOGS-ANALYSE:
================================================================================
{{ traefik_logs_analysis.stdout }}
================================================================================
INTERPRETATION:
================================================================================
{% set request_content = request_result.content | default('') | b64decode | default('') %}
{% set localhost_content = localhost_result.content | default('') | b64decode | default('') %}
{% set direct_content = direct_test_result.content | default('') | b64decode | default('') %}
{% set redis_content = redis_ping_result.content | default('') | b64decode | default('') %}
{% set traefik_errors = traefik_logs_analysis.stdout | default('') %}
{% set gitea_errors = gitea_logs_analysis.stdout | default('') %}
{% set postgres_errors = postgres_logs_analysis.stdout | default('') %}
{% set stats_content = stats_analysis.stdout | default('') %}
{% if 'timeout' in request_content or '504' in request_content or 'HTTP_CODE:504' in request_content %}
⚠️ REQUEST HAT TIMEOUT/504:
BACKPRESSURE-ANALYSE:
{% if 'LOCALHOST_TEST_FAILED' in localhost_content or localhost_content == '' %}
→ Gitea localhost Test schlägt fehl oder blockiert
→ Problem liegt IN Gitea/DB selbst, nicht zwischen Traefik und Gitea
{% elif 'HTTP_CODE:200' in localhost_content or '200 OK' in localhost_content %}
→ Gitea localhost Test funktioniert schnell
→ Problem liegt ZWISCHEN Traefik und Gitea (Netzwerk, Firewall, Limit)
{% endif %}
{% if 'REDIS_PING_FAILED' in redis_content or redis_content == '' or 'PONG' not in redis_content %}
→ Redis ist nicht erreichbar
→ Session-Store blockiert, Gitea läuft in "context canceled"
{% else %}
→ Redis ist erreichbar
{% endif %}
{% if 'SESSION.*context canceled' in gitea_errors or 'session.*release.*context canceled' in gitea_errors %}
→ Gitea hat SESSION: context canceled Fehler
→ Session-Store (Redis) könnte blockieren oder Session-Locks hängen
{% endif %}
{% if 'git-upload-pack' in gitea_errors %}
→ git-upload-pack Requests gefunden (können blockieren)
→ Prüfe ob Runner aktiv ist und viele Git-Operationen durchführt
{% endif %}
{% if 'git.*gc' in gitea_errors %}
→ git gc Jobs gefunden (können Verbindungen halten)
→ Prüfe ob git gc Jobs hängen
{% endif %}
{% if 'EOF' in traefik_errors or 'backend' in traefik_errors | lower or 'connection.*error' in traefik_errors | lower %}
→ Traefik meldet Backend-Connection-Error
→ Gitea antwortet nicht auf Traefik's Verbindungsversuche
{% endif %}
{% if 'timeout' in gitea_errors | lower or 'deadline exceeded' in gitea_errors | lower %}
→ Gitea hat DB-Timeouts oder Context-Deadline-Exceeded
→ Postgres könnte blockieren oder zu langsam sein
{% endif %}
{% if 'too many connections' in postgres_errors | lower %}
→ Postgres hat zu viele Verbindungen
→ Connection Pool könnte überlastet sein
{% endif %}
{% if 'HIGH CPU' in stats_content or '>80' in stats_content %}
→ Gitea oder Postgres haben hohe CPU-Last
→ Performance-Problem, nicht Timeout-Konfiguration
{% endif %}
{% else %}
✅ REQUEST WAR ERFOLGREICH:
→ Problem tritt nur intermittierend auf
→ Prüfe Logs auf sporadische Fehler
{% endif %}
================================================================================
NÄCHSTE SCHRITTE:
================================================================================
1. Prüfe pg_stat_activity: Connection Count nahe max_connections?
2. Prüfe ob Redis erreichbar ist (Session-Store-Blockaden)
3. Prüfe Backpressure: localhost schnell aber Traefik langsam = Netzwerk-Problem
4. Prüfe SESSION: context canceled Fehler (Session-Locks)
5. Prüfe git-upload-pack Requests (Runner-Überlastung)
6. Prüfe git gc Jobs (hängen und halten Verbindungen)
================================================================================
- name: Cleanup temporary files
ansible.builtin.file:
path: "/tmp/gitea_{{ test_timestamp }}.log"
state: absent
failed_when: false

View File

@@ -1,343 +0,0 @@
---
# Diagnose Gitea Timeout - Live während Request
# Führt alle Checks während eines tatsächlichen Requests durch
- name: Diagnose Gitea Timeout During Request
hosts: production
gather_facts: yes
become: no
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_url: "https://{{ gitea_domain }}"
test_duration_seconds: 60 # Wie lange wir testen
test_timestamp: "{{ ansible_date_time.epoch }}"
tasks:
- name: Display diagnostic plan
ansible.builtin.debug:
msg: |
================================================================================
GITEA TIMEOUT DIAGNOSE - LIVE WÄHREND REQUEST
================================================================================
Diese Diagnose führt alle Checks während eines tatsächlichen Requests durch:
1. Docker Stats (CPU/RAM/IO) während Request
2. Gitea Logs (DB-Timeouts, Panics, "context deadline exceeded")
3. Postgres Logs (Connection issues)
4. Traefik Logs ("backend connection error", "EOF")
5. Direkter Test Traefik → Gitea
Test-Dauer: {{ test_duration_seconds }} Sekunden
Timestamp: {{ test_timestamp }}
================================================================================
- name: Get initial container stats (baseline)
ansible.builtin.shell: |
docker stats --no-stream --format "table {{ '{{' }}.Name{{ '}}' }}\t{{ '{{' }}.CPUPerc{{ '}}' }}\t{{ '{{' }}.MemUsage{{ '}}' }}\t{{ '{{' }}.NetIO{{ '}}' }}\t{{ '{{' }}.BlockIO{{ '}}' }}" gitea gitea-postgres gitea-redis traefik 2>/dev/null || echo "Stats collection failed"
register: initial_stats
changed_when: false
- name: Start collecting Docker stats in background
ansible.builtin.shell: |
timeout {{ test_duration_seconds }} docker stats --format "{{ '{{' }}.Name{{ '}}' }},{{ '{{' }}.CPUPerc{{ '}}' }},{{ '{{' }}.MemUsage{{ '}}' }},{{ '{{' }}.NetIO{{ '}}' }},{{ '{{' }}.BlockIO{{ '}}' }}" gitea gitea-postgres gitea-redis traefik 2>/dev/null | while read line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] $line"
done > /tmp/gitea_stats_{{ test_timestamp }}.log 2>&1 &
STATS_PID=$!
echo $STATS_PID
register: stats_pid
changed_when: false
- name: Start collecting Gitea logs in background
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
timeout {{ test_duration_seconds }} docker compose logs -f gitea 2>&1 | while read line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] $line"
done > /tmp/gitea_logs_{{ test_timestamp }}.log 2>&1 &
echo $!
register: gitea_logs_pid
changed_when: false
- name: Start collecting Postgres logs in background
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
timeout {{ test_duration_seconds }} docker compose logs -f gitea-postgres 2>&1 | while read line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] $line"
done > /tmp/postgres_logs_{{ test_timestamp }}.log 2>&1 &
echo $!
register: postgres_logs_pid
changed_when: false
- name: Start collecting Traefik logs in background
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
timeout {{ test_duration_seconds }} docker compose logs -f traefik 2>&1 | while read line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] $line"
done > /tmp/traefik_logs_{{ test_timestamp }}.log 2>&1 &
echo $!
register: traefik_logs_pid
changed_when: false
- name: Wait a moment for log collection to start
ansible.builtin.pause:
seconds: 2
- name: Trigger Gitea request via Traefik (with timeout)
ansible.builtin.shell: |
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Starting request to {{ gitea_url }}/api/healthz"
timeout 35 curl -k -v -s -o /tmp/gitea_response_{{ test_timestamp }}.log -w "\nHTTP_CODE:%{http_code}\nTIME_TOTAL:%{time_total}\nTIME_CONNECT:%{time_connect}\nTIME_STARTTRANSFER:%{time_starttransfer}\n" "{{ gitea_url }}/api/healthz" 2>&1 | tee /tmp/gitea_curl_{{ test_timestamp }}.log
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Request completed"
register: gitea_request
changed_when: false
failed_when: false
- name: Test direct connection Traefik → Gitea (parallel)
ansible.builtin.shell: |
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Starting direct test Traefik → Gitea"
cd {{ traefik_stack_path }}
timeout 35 docker compose exec -T traefik wget -qO- --timeout=30 http://gitea:3000/api/healthz 2>&1 | tee /tmp/traefik_gitea_direct_{{ test_timestamp }}.log || echo "DIRECT_TEST_FAILED" > /tmp/traefik_gitea_direct_{{ test_timestamp }}.log
echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] Direct test completed"
register: traefik_direct_test
changed_when: false
failed_when: false
- name: Wait for log collection to complete
ansible.builtin.pause:
seconds: "{{ test_duration_seconds - 5 }}"
- name: Stop background processes
ansible.builtin.shell: |
pkill -f "docker.*stats.*gitea" || true
pkill -f "docker compose logs.*gitea" || true
pkill -f "docker compose logs.*postgres" || true
pkill -f "docker compose logs.*traefik" || true
sleep 2
changed_when: false
failed_when: false
- name: Collect stats results
ansible.builtin.slurp:
src: "/tmp/gitea_stats_{{ test_timestamp }}.log"
register: stats_results
changed_when: false
failed_when: false
- name: Collect Gitea logs results
ansible.builtin.slurp:
src: "/tmp/gitea_logs_{{ test_timestamp }}.log"
register: gitea_logs_results
changed_when: false
failed_when: false
- name: Collect Postgres logs results
ansible.builtin.slurp:
src: "/tmp/postgres_logs_{{ test_timestamp }}.log"
register: postgres_logs_results
changed_when: false
failed_when: false
- name: Collect Traefik logs results
ansible.builtin.slurp:
src: "/tmp/traefik_logs_{{ test_timestamp }}.log"
register: traefik_logs_results
changed_when: false
failed_when: false
- name: Get request result
ansible.builtin.slurp:
src: "/tmp/gitea_curl_{{ test_timestamp }}.log"
register: request_result
changed_when: false
failed_when: false
- name: Get direct test result
ansible.builtin.slurp:
src: "/tmp/traefik_gitea_direct_{{ test_timestamp }}.log"
register: direct_test_result
changed_when: false
failed_when: false
- name: Analyze stats for high CPU/Memory/IO
ansible.builtin.shell: |
if [ -f /tmp/gitea_stats_{{ test_timestamp }}.log ]; then
echo "=== STATS SUMMARY ==="
echo "Total samples: $(wc -l < /tmp/gitea_stats_{{ test_timestamp }}.log)"
echo ""
echo "=== HIGH CPU (>80%) ==="
grep -E "gitea|gitea-postgres" /tmp/gitea_stats_{{ test_timestamp }}.log | awk -F',' '{cpu=$2; gsub(/%/, "", cpu); if (cpu+0 > 80) print $0}' | head -10 || echo "No high CPU usage found"
echo ""
echo "=== MEMORY USAGE ==="
grep -E "gitea" /tmp/gitea_stats_{{ test_timestamp }}.log | tail -5 || echo "No memory stats"
echo ""
echo "=== NETWORK IO ==="
grep -E "gitea" /tmp/gitea_stats_{{ test_timestamp }}.log | tail -5 || echo "No network activity"
else
echo "Stats file not found"
fi
register: stats_analysis
changed_when: false
failed_when: false
- name: Analyze Gitea logs for errors
ansible.builtin.shell: |
if [ -f /tmp/gitea_logs_{{ test_timestamp }}.log ]; then
echo "=== DB-TIMEOUTS / CONNECTION ERRORS ==="
grep -iE "timeout|deadline exceeded|connection.*failed|database.*error|postgres.*error|context.*deadline" /tmp/gitea_logs_{{ test_timestamp }}.log | tail -20 || echo "No DB-timeouts found"
echo ""
echo "=== PANICS / FATAL ERRORS ==="
grep -iE "panic|fatal|error.*fatal" /tmp/gitea_logs_{{ test_timestamp }}.log | tail -10 || echo "No panics found"
echo ""
echo "=== SLOW QUERIES / PERFORMANCE ==="
grep -iE "slow|performance|took.*ms|duration" /tmp/gitea_logs_{{ test_timestamp }}.log | tail -10 || echo "No slow queries found"
echo ""
echo "=== RECENT LOG ENTRIES (last 10) ==="
tail -10 /tmp/gitea_logs_{{ test_timestamp }}.log || echo "No recent logs"
else
echo "Gitea logs file not found"
fi
register: gitea_logs_analysis
changed_when: false
failed_when: false
- name: Analyze Postgres logs for errors
ansible.builtin.shell: |
if [ -f /tmp/postgres_logs_{{ test_timestamp }}.log ]; then
echo "=== POSTGRES ERRORS ==="
grep -iE "error|timeout|deadlock|connection.*refused|too many connections" /tmp/postgres_logs_{{ test_timestamp }}.log | tail -20 || echo "No Postgres errors found"
echo ""
echo "=== SLOW QUERIES ==="
grep -iE "slow|duration|statement.*took" /tmp/postgres_logs_{{ test_timestamp }}.log | tail -10 || echo "No slow queries found"
echo ""
echo "=== RECENT LOG ENTRIES (last 10) ==="
tail -10 /tmp/postgres_logs_{{ test_timestamp }}.log || echo "No recent logs"
else
echo "Postgres logs file not found"
fi
register: postgres_logs_analysis
changed_when: false
failed_when: false
- name: Analyze Traefik logs for backend errors
ansible.builtin.shell: |
if [ -f /tmp/traefik_logs_{{ test_timestamp }}.log ]; then
echo "=== BACKEND CONNECTION ERRORS ==="
grep -iE "backend.*error|connection.*error|EOF|gitea.*error|git\.michaelschiemer\.de.*error" /tmp/traefik_logs_{{ test_timestamp }}.log | tail -20 || echo "No backend errors found"
echo ""
echo "=== TIMEOUT ERRORS ==="
grep -iE "timeout|504|gateway.*timeout" /tmp/traefik_logs_{{ test_timestamp }}.log | tail -10 || echo "No timeout errors found"
echo ""
echo "=== RECENT LOG ENTRIES (last 10) ==="
tail -10 /tmp/traefik_logs_{{ test_timestamp }}.log || echo "No recent logs"
else
echo "Traefik logs file not found"
fi
register: traefik_logs_analysis
changed_when: false
failed_when: false
- name: Display comprehensive diagnosis
ansible.builtin.debug:
msg: |
================================================================================
GITEA TIMEOUT DIAGNOSE - ERGEBNISSE
================================================================================
BASELINE STATS (vor Request):
{{ initial_stats.stdout }}
REQUEST ERGEBNIS:
{% if request_result.content is defined and request_result.content != '' %}
{{ request_result.content | b64decode }}
{% else %}
Request-Ergebnis nicht verfügbar
{% endif %}
DIREKTER TEST TRAEFIK → GITEA:
{% if direct_test_result.content is defined and direct_test_result.content != '' %}
{{ direct_test_result.content | b64decode }}
{% else %}
Direkter Test-Ergebnis nicht verfügbar
{% endif %}
================================================================================
STATS-ANALYSE (während Request):
================================================================================
{{ stats_analysis.stdout }}
================================================================================
GITEA LOGS-ANALYSE:
================================================================================
{{ gitea_logs_analysis.stdout }}
================================================================================
POSTGRES LOGS-ANALYSE:
================================================================================
{{ postgres_logs_analysis.stdout }}
================================================================================
TRAEFIK LOGS-ANALYSE:
================================================================================
{{ traefik_logs_analysis.stdout }}
================================================================================
INTERPRETATION:
================================================================================
{% set request_content = request_result.content | default('') | b64decode | default('') %}
{% set direct_content = direct_test_result.content | default('') | b64decode | default('') %}
{% set traefik_errors = traefik_logs_analysis.stdout | default('') %}
{% set gitea_errors = gitea_logs_analysis.stdout | default('') %}
{% set postgres_errors = postgres_logs_analysis.stdout | default('') %}
{% set stats_content = stats_analysis.stdout | default('') %}
{% if 'timeout' in request_content or '504' in request_content or 'HTTP_CODE:504' in request_content %}
⚠️ REQUEST HAT TIMEOUT/504:
{% if 'EOF' in traefik_errors or 'backend' in traefik_errors | lower or 'connection.*error' in traefik_errors | lower %}
→ Traefik meldet Backend-Connection-Error
→ Gitea antwortet nicht auf Traefik's Verbindungsversuche
{% endif %}
{% if 'timeout' in gitea_errors | lower or 'deadline exceeded' in gitea_errors | lower %}
→ Gitea hat DB-Timeouts oder Context-Deadline-Exceeded
→ Postgres könnte blockieren oder zu langsam sein
{% endif %}
{% if 'too many connections' in postgres_errors | lower %}
→ Postgres hat zu viele Verbindungen
→ Connection Pool könnte überlastet sein
{% endif %}
{% if 'HIGH CPU' in stats_content or '>80' in stats_content %}
→ Gitea oder Postgres haben hohe CPU-Last
→ Performance-Problem, nicht Timeout-Konfiguration
{% endif %}
{% if 'DIRECT_TEST_FAILED' in direct_content or direct_content == '' %}
→ Direkter Test Traefik → Gitea schlägt fehl
→ Problem liegt bei Gitea selbst, nicht bei Traefik-Routing
{% endif %}
{% else %}
✅ REQUEST WAR ERFOLGREICH:
→ Problem tritt nur intermittierend auf
→ Prüfe Logs auf sporadische Fehler
{% endif %}
================================================================================
NÄCHSTE SCHRITTE:
================================================================================
1. Prüfe ob hohe CPU/Memory bei Gitea oder Postgres
2. Prüfe ob DB-Timeouts in Gitea-Logs
3. Prüfe ob Postgres "too many connections" meldet
4. Prüfe ob Traefik "backend connection error" oder "EOF" meldet
5. Prüfe ob direkter Test Traefik → Gitea funktioniert
================================================================================
- name: Cleanup temporary files
ansible.builtin.file:
path: "/tmp/gitea_{{ test_timestamp }}.log"
state: absent
failed_when: false

View File

@@ -1,325 +0,0 @@
---
# Diagnose Gitea Timeouts
# Prüft Gitea-Status, Traefik-Routing, Netzwerk-Verbindungen und behebt Probleme
- name: Diagnose Gitea Timeouts
hosts: production
gather_facts: yes
become: no
tasks:
- name: Check Gitea container status
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/gitea
docker compose ps gitea
register: gitea_status
changed_when: false
- name: Display Gitea container status
ansible.builtin.debug:
msg: |
================================================================================
Gitea Container Status:
================================================================================
{{ gitea_status.stdout }}
================================================================================
- name: Check Gitea health endpoint (direct from container)
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/gitea
docker compose exec -T gitea curl -f http://localhost:3000/api/healthz 2>&1 || echo "HEALTH_CHECK_FAILED"
register: gitea_health_direct
changed_when: false
failed_when: false
- name: Display Gitea health (direct)
ansible.builtin.debug:
msg: |
================================================================================
Gitea Health Check (direct from container):
================================================================================
{% if 'HEALTH_CHECK_FAILED' not in gitea_health_direct.stdout %}
✅ Gitea is healthy (direct check)
Response: {{ gitea_health_direct.stdout }}
{% else %}
❌ Gitea health check failed (direct)
Error: {{ gitea_health_direct.stdout }}
{% endif %}
================================================================================
- name: Check Gitea health endpoint (via Traefik)
ansible.builtin.uri:
url: "https://git.michaelschiemer.de/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_health_traefik
failed_when: false
changed_when: false
- name: Display Gitea health (via Traefik)
ansible.builtin.debug:
msg: |
================================================================================
Gitea Health Check (via Traefik):
================================================================================
{% if gitea_health_traefik.status == 200 %}
✅ Gitea is reachable via Traefik
Status: {{ gitea_health_traefik.status }}
{% else %}
❌ Gitea is NOT reachable via Traefik
Status: {{ gitea_health_traefik.status | default('TIMEOUT/ERROR') }}
Message: {{ gitea_health_traefik.msg | default('No response') }}
{% endif %}
================================================================================
- name: Check Traefik container status
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik
docker compose ps traefik
register: traefik_status
changed_when: false
- name: Display Traefik container status
ansible.builtin.debug:
msg: |
================================================================================
Traefik Container Status:
================================================================================
{{ traefik_status.stdout }}
================================================================================
- name: Check Redis container status
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/gitea
docker compose ps redis
register: redis_status
changed_when: false
- name: Display Redis container status
ansible.builtin.debug:
msg: |
================================================================================
Redis Container Status:
================================================================================
{{ redis_status.stdout }}
================================================================================
- name: Check PostgreSQL container status
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/gitea
docker compose ps postgres
register: postgres_status
changed_when: false
- name: Display PostgreSQL container status
ansible.builtin.debug:
msg: |
================================================================================
PostgreSQL Container Status:
================================================================================
{{ postgres_status.stdout }}
================================================================================
- name: Check Gitea container IP in traefik-public network
ansible.builtin.shell: |
docker inspect gitea --format '{{ '{{' }}range .NetworkSettings.Networks{{ '}}' }}{{ '{{' }}if eq .NetworkID (docker network inspect traefik-public --format "{{ '{{' }}.Id{{ '}}' }}"){{ '}}' }}{{ '{{' }}.IPAddress{{ '}}' }}{{ '{{' }}end{{ '}}' }}{{ '{{' }}end{{ '}}' }}' 2>/dev/null || echo "NOT_FOUND"
register: gitea_ip
changed_when: false
failed_when: false
- name: Display Gitea IP in traefik-public network
ansible.builtin.debug:
msg: |
================================================================================
Gitea IP in traefik-public Network:
================================================================================
{% if gitea_ip.stdout and gitea_ip.stdout != 'NOT_FOUND' %}
✅ Gitea IP: {{ gitea_ip.stdout }}
{% else %}
❌ Gitea IP not found in traefik-public network
{% endif %}
================================================================================
- name: Test connection from Traefik to Gitea
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik
docker compose exec -T traefik wget -qO- --timeout=5 http://gitea:3000/api/healthz 2>&1 || echo "CONNECTION_FAILED"
register: traefik_gitea_connection
changed_when: false
failed_when: false
- name: Display Traefik-Gitea connection test
ansible.builtin.debug:
msg: |
================================================================================
Traefik → Gitea Connection Test:
================================================================================
{% if 'CONNECTION_FAILED' in traefik_gitea_connection.stdout %}
❌ Traefik cannot reach Gitea
Error: {{ traefik_gitea_connection.stdout }}
{% else %}
✅ Traefik can reach Gitea
Response: {{ traefik_gitea_connection.stdout }}
{% endif %}
================================================================================
- name: Check Traefik routing configuration for Gitea
ansible.builtin.shell: |
docker inspect gitea --format '{{ '{{' }}json .Config.Labels{{ '}}' }}' 2>/dev/null | grep -i "traefik" || echo "NO_TRAEFIK_LABELS"
register: traefik_labels
changed_when: false
failed_when: false
- name: Display Traefik labels for Gitea
ansible.builtin.debug:
msg: |
================================================================================
Traefik Labels for Gitea:
================================================================================
{{ traefik_labels.stdout }}
================================================================================
- name: Check Gitea logs for errors
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/gitea
docker compose logs gitea --tail=50 2>&1 | grep -iE "error|timeout|failed|panic|fatal" | tail -20 || echo "No errors in recent logs"
register: gitea_errors
changed_when: false
failed_when: false
- name: Display Gitea errors
ansible.builtin.debug:
msg: |
================================================================================
Gitea Error Logs (last 50 lines):
================================================================================
{{ gitea_errors.stdout }}
================================================================================
- name: Check Traefik logs for Gitea-related errors
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik
docker compose logs traefik --tail=50 2>&1 | grep -iE "gitea|git\.michaelschiemer\.de|timeout|error" | tail -20 || echo "No Gitea-related errors in Traefik logs"
register: traefik_gitea_errors
changed_when: false
failed_when: false
- name: Display Traefik Gitea errors
ansible.builtin.debug:
msg: |
================================================================================
Traefik Gitea-Related Error Logs (last 50 lines):
================================================================================
{{ traefik_gitea_errors.stdout }}
================================================================================
- name: Check if Gitea is in traefik-public network
ansible.builtin.shell: |
docker network inspect traefik-public --format '{{ '{{' }}range .Containers{{ '}}' }}{{ '{{' }}.Name{{ '}}' }} {{ '{{' }}end{{ '}}' }}' 2>/dev/null | grep -q gitea && echo "YES" || echo "NO"
register: gitea_in_traefik_network
changed_when: false
failed_when: false
- name: Display Gitea network membership
ansible.builtin.debug:
msg: |
================================================================================
Gitea in traefik-public Network:
================================================================================
{% if gitea_in_traefik_network.stdout == 'YES' %}
✅ Gitea is in traefik-public network
{% else %}
❌ Gitea is NOT in traefik-public network
{% endif %}
================================================================================
- name: Check Redis connection from Gitea
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/gitea
docker compose exec -T gitea sh -c "redis-cli -h redis -p 6379 -a gitea_redis_password ping 2>&1" || echo "REDIS_CONNECTION_FAILED"
register: gitea_redis_connection
changed_when: false
failed_when: false
- name: Display Gitea-Redis connection
ansible.builtin.debug:
msg: |
================================================================================
Gitea → Redis Connection:
================================================================================
{% if 'REDIS_CONNECTION_FAILED' in gitea_redis_connection.stdout %}
❌ Gitea cannot connect to Redis
Error: {{ gitea_redis_connection.stdout }}
{% else %}
✅ Gitea can connect to Redis
Response: {{ gitea_redis_connection.stdout }}
{% endif %}
================================================================================
- name: Check PostgreSQL connection from Gitea
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/gitea
docker compose exec -T gitea sh -c "pg_isready -h postgres -p 5432 -U gitea 2>&1" || echo "POSTGRES_CONNECTION_FAILED"
register: gitea_postgres_connection
changed_when: false
failed_when: false
- name: Display Gitea-PostgreSQL connection
ansible.builtin.debug:
msg: |
================================================================================
Gitea → PostgreSQL Connection:
================================================================================
{% if 'POSTGRES_CONNECTION_FAILED' in gitea_postgres_connection.stdout %}
❌ Gitea cannot connect to PostgreSQL
Error: {{ gitea_postgres_connection.stdout }}
{% else %}
✅ Gitea can connect to PostgreSQL
Response: {{ gitea_postgres_connection.stdout }}
{% endif %}
================================================================================
- name: Summary and recommendations
ansible.builtin.debug:
msg: |
================================================================================
ZUSAMMENFASSUNG - Gitea Timeout Diagnose:
================================================================================
Gitea Status: {{ gitea_status.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
Gitea Health (direct): {% if 'HEALTH_CHECK_FAILED' not in gitea_health_direct.stdout %}✅{% else %}❌{% endif %}
Gitea Health (via Traefik): {% if gitea_health_traefik.status == 200 %}✅{% else %}❌{% endif %}
Traefik Status: {{ traefik_status.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
Redis Status: {{ redis_status.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
PostgreSQL Status: {{ postgres_status.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
Netzwerk:
- Gitea in traefik-public: {% if gitea_in_traefik_network.stdout == 'YES' %}✅{% else %}❌{% endif %}
- Traefik → Gitea: {% if 'CONNECTION_FAILED' not in traefik_gitea_connection.stdout %}✅{% else %}❌{% endif %}
- Gitea → Redis: {% if 'REDIS_CONNECTION_FAILED' not in gitea_redis_connection.stdout %}✅{% else %}❌{% endif %}
- Gitea → PostgreSQL: {% if 'POSTGRES_CONNECTION_FAILED' not in gitea_postgres_connection.stdout %}✅{% else %}❌{% endif %}
Empfohlene Aktionen:
{% if gitea_health_traefik.status != 200 %}
1. ❌ Gitea ist nicht über Traefik erreichbar
→ Führe 'fix-gitea-timeouts.yml' aus um Gitea und Traefik zu restarten
{% endif %}
{% if gitea_in_traefik_network.stdout != 'YES' %}
2. ❌ Gitea ist nicht im traefik-public Netzwerk
→ Gitea Container neu starten um Netzwerk-Verbindung zu aktualisieren
{% endif %}
{% if 'CONNECTION_FAILED' in traefik_gitea_connection.stdout %}
3. ❌ Traefik kann Gitea nicht erreichen
→ Beide Container neu starten
{% endif %}
{% if 'REDIS_CONNECTION_FAILED' in gitea_redis_connection.stdout %}
4. ❌ Gitea kann Redis nicht erreichen
→ Redis Container prüfen und neu starten
{% endif %}
{% if 'POSTGRES_CONNECTION_FAILED' in gitea_postgres_connection.stdout %}
5. ❌ Gitea kann PostgreSQL nicht erreichen
→ PostgreSQL Container prüfen und neu starten
{% endif %}
================================================================================

View File

@@ -1,477 +0,0 @@
---
# Diagnose: Finde Ursache für Traefik Restart-Loop
# Prüft alle möglichen Ursachen für regelmäßige Traefik-Restarts
- name: Diagnose Traefik Restart Loop
hosts: production
gather_facts: yes
become: yes
tasks:
- name: Check systemd timers
ansible.builtin.shell: |
systemctl list-timers --all --no-pager
register: systemd_timers
changed_when: false
- name: Display systemd timers
ansible.builtin.debug:
msg: |
================================================================================
Systemd Timers (können Container stoppen):
================================================================================
{{ systemd_timers.stdout }}
================================================================================
- name: Check root crontab
ansible.builtin.shell: |
crontab -l 2>/dev/null || echo "No root crontab"
register: root_crontab
changed_when: false
- name: Display root crontab
ansible.builtin.debug:
msg: |
================================================================================
Root Crontab:
================================================================================
{{ root_crontab.stdout }}
================================================================================
- name: Check deploy user crontab
ansible.builtin.shell: |
crontab -l -u deploy 2>/dev/null || echo "No deploy user crontab"
register: deploy_crontab
changed_when: false
- name: Display deploy user crontab
ansible.builtin.debug:
msg: |
================================================================================
Deploy User Crontab:
================================================================================
{{ deploy_crontab.stdout }}
================================================================================
- name: Check system-wide cron jobs
ansible.builtin.shell: |
echo "=== /etc/cron.d ==="
ls -la /etc/cron.d 2>/dev/null || echo "Directory not found"
grep -r "traefik\|docker.*compose.*traefik\|docker.*stop\|docker.*restart" /etc/cron.d 2>/dev/null || echo "No matches"
echo ""
echo "=== /etc/cron.daily ==="
ls -la /etc/cron.daily 2>/dev/null || echo "Directory not found"
grep -r "traefik\|docker.*compose.*traefik\|docker.*stop\|docker.*restart" /etc/cron.daily 2>/dev/null || echo "No matches"
echo ""
echo "=== /etc/cron.hourly ==="
ls -la /etc/cron.hourly 2>/dev/null || echo "Directory not found"
grep -r "traefik\|docker.*compose.*traefik\|docker.*stop\|docker.*restart" /etc/cron.hourly 2>/dev/null || echo "No matches"
echo ""
echo "=== /etc/cron.weekly ==="
ls -la /etc/cron.weekly 2>/dev/null || echo "Directory not found"
grep -r "traefik\|docker.*compose.*traefik\|docker.*stop\|docker.*restart" /etc/cron.weekly 2>/dev/null || echo "No matches"
echo ""
echo "=== /etc/cron.monthly ==="
ls -la /etc/cron.monthly 2>/dev/null || echo "Directory not found"
grep -r "traefik\|docker.*compose.*traefik\|docker.*stop\|docker.*restart" /etc/cron.monthly 2>/dev/null || echo "No matches"
register: system_cron
changed_when: false
- name: Display system cron jobs
ansible.builtin.debug:
msg: |
================================================================================
System-Wide Cron Jobs:
================================================================================
{{ system_cron.stdout }}
================================================================================
- name: Check for scripts that might restart Traefik
ansible.builtin.shell: |
find /home/deploy -type f -name "*.sh" -exec grep -l "traefik\|docker.*compose.*restart\|docker.*stop.*traefik\|docker.*down.*traefik" {} \; 2>/dev/null | head -20
register: traefik_scripts
changed_when: false
- name: Display scripts that might restart Traefik
ansible.builtin.debug:
msg: |
================================================================================
Scripts die Traefik stoppen/restarten könnten:
================================================================================
{% if traefik_scripts.stdout %}
{{ traefik_scripts.stdout }}
{% else %}
Keine Skripte gefunden
{% endif %}
================================================================================
- name: Check Docker events for Traefik container (last 24h)
ansible.builtin.shell: |
timeout 5 docker events --since 24h --filter container=traefik --format "{{ '{{' }}.Time{{ '}}' }} {{ '{{' }}.Action{{ '}}' }} {{ '{{' }}.Actor.Attributes.name{{ '}}' }}" 2>/dev/null | tail -50 || echo "No recent events or docker events not available"
register: docker_events
changed_when: false
- name: Display Docker events
ansible.builtin.debug:
msg: |
================================================================================
Docker Events für Traefik (letzte 24h):
================================================================================
{{ docker_events.stdout }}
================================================================================
- name: Check Traefik container exit history
ansible.builtin.shell: |
docker ps -a --filter "name=traefik" --format "{{ '{{' }}.ID{{ '}}' }} | {{ '{{' }}.Status{{ '}}' }} | {{ '{{' }}.CreatedAt{{ '}}' }}" | head -10
register: traefik_exits
changed_when: false
- name: Display Traefik container exit history
ansible.builtin.debug:
msg: |
================================================================================
Traefik Container Exit-Historie:
================================================================================
{{ traefik_exits.stdout }}
================================================================================
- name: Check Docker daemon logs for Traefik stops
ansible.builtin.shell: |
journalctl -u docker.service --since "24h ago" --no-pager | grep -i "traefik\|stop\|kill" | tail -50 || echo "No relevant logs in journalctl"
register: docker_daemon_logs
changed_when: false
- name: Display Docker daemon logs
ansible.builtin.debug:
msg: |
================================================================================
Docker Daemon Logs (Traefik/Stop/Kill):
================================================================================
{{ docker_daemon_logs.stdout }}
================================================================================
- name: Check if there's a health check script running
ansible.builtin.shell: |
ps aux | grep -E "traefik|health.*check|monitor.*docker|auto.*heal|watchdog" | grep -v grep || echo "No health check processes found"
register: health_check_processes
changed_when: false
- name: Display health check processes
ansible.builtin.debug:
msg: |
================================================================================
Laufende Health-Check/Monitoring-Prozesse:
================================================================================
{{ health_check_processes.stdout }}
================================================================================
- name: Check for monitoring/auto-heal scripts
ansible.builtin.shell: |
find /home/deploy -type f \( -name "*monitor*" -o -name "*health*" -o -name "*auto*heal*" -o -name "*watchdog*" \) 2>/dev/null | head -20
register: monitoring_scripts
changed_when: false
- name: Display monitoring scripts
ansible.builtin.debug:
msg: |
================================================================================
Monitoring/Auto-Heal-Skripte:
================================================================================
{% if monitoring_scripts.stdout %}
{{ monitoring_scripts.stdout }}
{% else %}
Keine Monitoring-Skripte gefunden
{% endif %}
================================================================================
- name: Check Docker Compose file for restart policies
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik && grep -A 5 "restart:" docker-compose.yml || echo "No restart policy found"
register: restart_policy
changed_when: false
- name: Display restart policy
ansible.builtin.debug:
msg: |
================================================================================
Docker Compose Restart Policy:
================================================================================
{{ restart_policy.stdout }}
================================================================================
- name: Check if Traefik is managed by systemd
ansible.builtin.shell: |
systemctl list-units --type=service --all | grep -i traefik || echo "No Traefik systemd service found"
register: traefik_systemd
changed_when: false
- name: Display Traefik systemd service
ansible.builtin.debug:
msg: |
================================================================================
Traefik Systemd Service:
================================================================================
{{ traefik_systemd.stdout }}
================================================================================
- name: Check recent Traefik container logs for stop messages
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik && docker compose logs traefik --since 24h 2>&1 | grep -E "I have to go|Stopping server gracefully|SIGTERM|SIGINT|received signal" | tail -20 || echo "No stop messages in logs"
register: traefik_stop_logs
changed_when: false
- name: Display Traefik stop messages
ansible.builtin.debug:
msg: |
================================================================================
Traefik Stop-Meldungen (letzte 24h):
================================================================================
{{ traefik_stop_logs.stdout }}
================================================================================
- name: Check Traefik container uptime and restart count
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.State.StartedAt{{ '}}' }} | {{ '{{' }}.State.FinishedAt{{ '}}' }} | Restarts: {{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "Container not found"
register: traefik_uptime
changed_when: false
- name: Display Traefik uptime and restart count
ansible.builtin.debug:
msg: |
================================================================================
Traefik Container Uptime & Restart Count:
================================================================================
{{ traefik_uptime.stdout }}
================================================================================
- name: Check for unattended-upgrades activity
ansible.builtin.shell: |
journalctl -u unattended-upgrades --since "24h ago" --no-pager | tail -20 || echo "No unattended-upgrades logs"
register: unattended_upgrades
changed_when: false
- name: Display unattended-upgrades activity
ansible.builtin.debug:
msg: |
================================================================================
Unattended-Upgrades Aktivität (kann zu Reboots führen):
================================================================================
{{ unattended_upgrades.stdout }}
================================================================================
- name: Check system reboot history
ansible.builtin.shell: |
last reboot | head -10 || echo "No reboot history available"
register: reboot_history
changed_when: false
- name: Display reboot history
ansible.builtin.debug:
msg: |
================================================================================
System Reboot-Historie:
================================================================================
{{ reboot_history.stdout }}
================================================================================
- name: Check Docker Compose processes that might affect Traefik
ansible.builtin.shell: |
ps aux | grep -E "docker.*compose.*traefik|docker-compose.*traefik" | grep -v grep || echo "No docker compose processes for Traefik found"
register: docker_compose_processes
changed_when: false
- name: Display Docker Compose processes
ansible.builtin.debug:
msg: |
================================================================================
Docker Compose Prozesse für Traefik:
================================================================================
{{ docker_compose_processes.stdout }}
================================================================================
- name: Check all user crontabs (not just root/deploy)
ansible.builtin.shell: |
for user in $(cut -f1 -d: /etc/passwd); do
crontab -u "$user" -l 2>/dev/null | grep -q "traefik\|docker.*compose.*traefik\|docker.*restart.*traefik" && echo "=== User: $user ===" && crontab -u "$user" -l 2>/dev/null | grep -E "traefik|docker.*compose.*traefik|docker.*restart.*traefik" || true
done || echo "No user crontabs with Traefik commands found"
register: all_user_crontabs
changed_when: false
- name: Display all user crontabs with Traefik commands
ansible.builtin.debug:
msg: |
================================================================================
Alle User-Crontabs mit Traefik-Befehlen:
================================================================================
{{ all_user_crontabs.stdout }}
================================================================================
- name: Check for Gitea Workflows that might restart Traefik
ansible.builtin.shell: |
find /home/deploy -type f -path "*/.gitea/workflows/*.yml" -o -path "*/.github/workflows/*.yml" 2>/dev/null | xargs grep -l "traefik\|restart.*traefik\|docker.*compose.*traefik" 2>/dev/null | head -10 || echo "No Gitea/GitHub workflows found that restart Traefik"
register: gitea_workflows
changed_when: false
- name: Display Gitea Workflows that might restart Traefik
ansible.builtin.debug:
msg: |
================================================================================
Gitea/GitHub Workflows die Traefik restarten könnten:
================================================================================
{{ gitea_workflows.stdout }}
================================================================================
- name: Check for custom systemd services in /etc/systemd/system/
ansible.builtin.shell: |
find /etc/systemd/system -type f -name "*.service" -o -name "*.timer" 2>/dev/null | xargs grep -l "traefik\|docker.*compose.*traefik\|docker.*restart.*traefik" 2>/dev/null | head -10 || echo "No custom systemd services/timers found for Traefik"
register: custom_systemd_services
changed_when: false
- name: Display custom systemd services
ansible.builtin.debug:
msg: |
================================================================================
Custom Systemd Services/Timers für Traefik:
================================================================================
{{ custom_systemd_services.stdout }}
================================================================================
- name: Check for at jobs (scheduled tasks)
ansible.builtin.shell: |
atq 2>/dev/null | while read line; do
job_id=$(echo "$line" | awk '{print $1}')
at -c "$job_id" 2>/dev/null | grep -q "traefik\|docker.*compose.*traefik\|docker.*restart.*traefik" && echo "=== Job ID: $job_id ===" && at -c "$job_id" 2>/dev/null | grep -E "traefik|docker.*compose.*traefik|docker.*restart.*traefik" || true
done || echo "No at jobs found or atq not available"
register: at_jobs
changed_when: false
- name: Display at jobs
ansible.builtin.debug:
msg: |
================================================================================
At Jobs (geplante Tasks) die Traefik betreffen:
================================================================================
{{ at_jobs.stdout }}
================================================================================
- name: Check for Docker Compose watch mode
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik && docker compose ps --format json 2>/dev/null | jq -r '.[] | select(.Service=="traefik") | .State' || echo "Could not check Docker Compose watch mode"
register: docker_compose_watch
changed_when: false
- name: Check if Docker Compose watch is enabled
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik && docker compose config 2>/dev/null | grep -i "watch\|x-develop" || echo "No watch mode configured"
register: docker_compose_watch_config
changed_when: false
- name: Display Docker Compose watch mode
ansible.builtin.debug:
msg: |
================================================================================
Docker Compose Watch Mode:
================================================================================
Watch Config: {{ docker_compose_watch_config.stdout }}
================================================================================
- name: Check Ansible traefik_auto_restart setting
ansible.builtin.shell: |
grep -r "traefik_auto_restart" /home/deploy/deployment/ansible/roles/traefik/defaults/ /home/deploy/deployment/ansible/inventory/ 2>/dev/null | head -10 || echo "traefik_auto_restart not found in Ansible config"
register: ansible_auto_restart
changed_when: false
- name: Display Ansible traefik_auto_restart setting
ansible.builtin.debug:
msg: |
================================================================================
Ansible traefik_auto_restart Einstellung:
================================================================================
{{ ansible_auto_restart.stdout }}
================================================================================
- name: Check Port 80/443 configuration
ansible.builtin.shell: |
echo "=== Port 80 ==="
netstat -tlnp 2>/dev/null | grep ":80 " || ss -tlnp 2>/dev/null | grep ":80 " || echo "Could not check port 80"
echo ""
echo "=== Port 443 ==="
netstat -tlnp 2>/dev/null | grep ":443 " || ss -tlnp 2>/dev/null | grep ":443 " || echo "Could not check port 443"
echo ""
echo "=== Docker Port Mappings for Traefik ==="
docker inspect traefik --format '{{ '{{' }}json .HostConfig.PortBindings{{ '}}' }}' 2>/dev/null | jq '.' || echo "Could not get Docker port mappings"
register: port_config
changed_when: false
- name: Display Port configuration
ansible.builtin.debug:
msg: |
================================================================================
Port-Konfiguration (80/443):
================================================================================
{{ port_config.stdout }}
================================================================================
- name: Check if other services are blocking ports 80/443
ansible.builtin.shell: |
echo "=== Services listening on port 80 ==="
lsof -i :80 2>/dev/null || fuser 80/tcp 2>/dev/null || echo "Could not check port 80"
echo ""
echo "=== Services listening on port 443 ==="
lsof -i :443 2>/dev/null || fuser 443/tcp 2>/dev/null || echo "Could not check port 443"
register: port_blockers
changed_when: false
- name: Display port blockers
ansible.builtin.debug:
msg: |
================================================================================
Services die Ports 80/443 blockieren könnten:
================================================================================
{{ port_blockers.stdout }}
================================================================================
- name: Check Traefik network configuration
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}json .NetworkSettings{{ '}}' }}' 2>/dev/null | jq '.Networks' || echo "Could not get Traefik network configuration"
register: traefik_network
changed_when: false
- name: Display Traefik network configuration
ansible.builtin.debug:
msg: |
================================================================================
Traefik Netzwerk-Konfiguration:
================================================================================
{{ traefik_network.stdout }}
================================================================================
- name: Summary - Most likely causes
ansible.builtin.debug:
msg: |
================================================================================
ZUSAMMENFASSUNG - Mögliche Ursachen für Traefik-Restarts:
================================================================================
Prüfe die obigen Ausgaben auf:
1. Systemd-Timer: Können Container stoppen (z.B. unattended-upgrades)
2. Cronjobs: Regelmäßige Skripte die Traefik stoppen (alle User-Crontabs geprüft)
3. Docker-Events: Zeigen wer/was den Container stoppt
4. Monitoring-Skripte: Auto-Heal-Skripte die bei Fehlern restarten
5. Unattended-Upgrades: Können zu Reboots führen
6. Reboot-Historie: System-Reboots stoppen alle Container
7. Gitea Workflows: Können Traefik via Ansible restarten
8. Custom Systemd Services: Eigene Services die Traefik verwalten
9. At Jobs: Geplante Tasks die Traefik stoppen
10. Docker Compose Watch Mode: Automatische Restarts bei Dateiänderungen
11. Ansible traefik_auto_restart: Automatische Restarts nach Config-Deployment
12. Port-Konfiguration: Ports 80/443 müssen auf Traefik zeigen
Nächste Schritte:
- Prüfe die Docker-Events für wiederkehrende Muster
- Prüfe alle User-Crontabs auf regelmäßige Traefik-Befehle
- Prüfe ob Monitoring-Skripte zu aggressiv sind
- Prüfe ob unattended-upgrades zu Reboots führt
- Prüfe ob traefik_auto_restart zu häufigen Restarts führt
- Verifiziere Port-Konfiguration (80/443)
================================================================================

View File

@@ -0,0 +1,403 @@
---
# Consolidated Gitea Diagnosis Playbook
# Consolidates: diagnose-gitea-timeouts.yml, diagnose-gitea-timeout-deep.yml,
# diagnose-gitea-timeout-live.yml, diagnose-gitea-timeouts-complete.yml,
# comprehensive-gitea-diagnosis.yml
#
# Usage:
# # Basic diagnosis (default)
# ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml
#
# # Deep diagnosis (includes resource checks, multiple connection tests)
# ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml --tags deep
#
# # Live diagnosis (monitors during request)
# ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml --tags live
#
# # Complete diagnosis (all checks)
# ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml --tags complete
- name: Diagnose Gitea Issues
hosts: production
gather_facts: yes
become: no
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_url: "https://{{ gitea_domain }}"
gitea_container_name: "gitea"
traefik_container_name: "traefik"
tasks:
# ========================================
# BASIC DIAGNOSIS (always runs)
# ========================================
- name: Display diagnostic plan
ansible.builtin.debug:
msg: |
================================================================================
GITEA DIAGNOSIS
================================================================================
Running diagnosis with tags: {{ ansible_run_tags | default(['all']) }}
Basic checks (always):
- Container status
- Health endpoints
- Network connectivity
- Service discovery
Deep checks (--tags deep):
- Resource usage
- Multiple connection tests
- Log analysis
Live checks (--tags live):
- Real-time monitoring during request
Complete checks (--tags complete):
- All checks including app.ini, ServersTransport, etc.
================================================================================
- name: Check Gitea container status
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose ps {{ gitea_container_name }}
register: gitea_status
changed_when: false
- name: Check Traefik container status
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose ps {{ traefik_container_name }}
register: traefik_status
changed_when: false
- name: Check Gitea health endpoint (direct from container)
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T {{ gitea_container_name }} curl -f http://localhost:3000/api/healthz 2>&1 || echo "HEALTH_CHECK_FAILED"
register: gitea_health_direct
changed_when: false
failed_when: false
- name: Check Gitea health endpoint (via Traefik)
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_health_traefik
failed_when: false
changed_when: false
- name: Check if Gitea is in traefik-public network
ansible.builtin.shell: |
docker network inspect traefik-public --format '{{ '{{' }}range .Containers{{ '}}' }}{{ '{{' }}.Name{{ '}}' }} {{ '{{' }}end{{ '}}' }}' 2>/dev/null | grep -q {{ gitea_container_name }} && echo "YES" || echo "NO"
register: gitea_in_traefik_network
changed_when: false
failed_when: false
- name: Test connection from Traefik to Gitea
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose exec -T {{ traefik_container_name }} wget -qO- --timeout=5 http://{{ gitea_container_name }}:3000/api/healthz 2>&1 || echo "CONNECTION_FAILED"
register: traefik_gitea_connection
changed_when: false
failed_when: false
- name: Check Traefik service discovery for Gitea
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose exec -T {{ traefik_container_name }} traefik show providers docker 2>/dev/null | grep -i "gitea" || echo "NOT_FOUND"
register: traefik_gitea_service
changed_when: false
failed_when: false
# ========================================
# DEEP DIAGNOSIS (--tags deep)
# ========================================
- name: Check Gitea container resources (CPU/Memory)
ansible.builtin.shell: |
docker stats {{ gitea_container_name }} --no-stream --format 'CPU: {{ '{{' }}.CPUPerc{{ '}}' }} | Memory: {{ '{{' }}.MemUsage{{ '}}' }}' 2>/dev/null || echo "Could not get stats"
register: gitea_resources
changed_when: false
failed_when: false
tags:
- deep
- complete
- name: Check Traefik container resources (CPU/Memory)
ansible.builtin.shell: |
docker stats {{ traefik_container_name }} --no-stream --format 'CPU: {{ '{{' }}.CPUPerc{{ '}}' }} | Memory: {{ '{{' }}.MemUsage{{ '}}' }}' 2>/dev/null || echo "Could not get stats"
register: traefik_resources
changed_when: false
failed_when: false
tags:
- deep
- complete
- name: Test Gitea direct connection (multiple attempts)
ansible.builtin.shell: |
for i in {1..5}; do
echo "=== Attempt $i ==="
cd {{ gitea_stack_path }}
timeout 5 docker compose exec -T {{ gitea_container_name }} curl -f http://localhost:3000/api/healthz 2>&1 || echo "FAILED"
sleep 1
done
register: gitea_direct_tests
changed_when: false
tags:
- deep
- complete
- name: Test Gitea via Traefik (multiple attempts)
ansible.builtin.shell: |
for i in {1..5}; do
echo "=== Attempt $i ==="
timeout 10 curl -k -s -o /dev/null -w "%{http_code}" {{ gitea_url }}/api/healthz 2>&1 || echo "TIMEOUT"
sleep 2
done
register: gitea_traefik_tests
changed_when: false
tags:
- deep
- complete
- name: Check Gitea logs for errors/timeouts
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose logs {{ gitea_container_name }} --tail=50 2>&1 | grep -iE "error|timeout|failed|panic|fatal" | tail -20 || echo "No errors in recent logs"
register: gitea_errors
changed_when: false
failed_when: false
tags:
- deep
- complete
- name: Check Traefik logs for Gitea-related errors
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs {{ traefik_container_name }} --tail=50 2>&1 | grep -iE "gitea|git\.michaelschiemer\.de|timeout|error" | tail -20 || echo "No Gitea-related errors in Traefik logs"
register: traefik_gitea_errors
changed_when: false
failed_when: false
tags:
- deep
- complete
# ========================================
# COMPLETE DIAGNOSIS (--tags complete)
# ========================================
- name: Test Gitea internal port (127.0.0.1:3000)
ansible.builtin.shell: |
docker exec {{ gitea_container_name }} curl -sS -I http://127.0.0.1:3000/ 2>&1 | head -5
register: gitea_internal_test
changed_when: false
failed_when: false
tags:
- complete
- name: Test Traefik to Gitea via Docker DNS (gitea:3000)
ansible.builtin.shell: |
docker exec {{ traefik_container_name }} sh -lc 'apk add --no-cache curl >/dev/null 2>&1 || true; curl -sS -I http://gitea:3000/ 2>&1' | head -10
register: traefik_gitea_dns_test
changed_when: false
failed_when: false
tags:
- complete
- name: Check Traefik logs for 504 errors
ansible.builtin.shell: |
docker logs {{ traefik_container_name }} --tail=100 2>&1 | grep -i "504\|timeout" | tail -20 || echo "No 504/timeout errors found"
register: traefik_504_logs
changed_when: false
failed_when: false
tags:
- complete
- name: Check Gitea Traefik labels
ansible.builtin.shell: |
docker inspect {{ gitea_container_name }} --format '{{ '{{' }}json .Config.Labels{{ '}}' }}' 2>/dev/null | python3 -m json.tool | grep -E "traefik" || echo "No Traefik labels found"
register: gitea_labels
changed_when: false
failed_when: false
tags:
- complete
- name: Verify service port is 3000
ansible.builtin.shell: |
docker inspect {{ gitea_container_name }} --format '{{ '{{' }}json .Config.Labels{{ '}}' }}' 2>/dev/null | python3 -c "import sys, json; labels = json.load(sys.stdin); print('server.port:', labels.get('traefik.http.services.gitea.loadbalancer.server.port', 'NOT SET'))"
register: gitea_service_port
changed_when: false
failed_when: false
tags:
- complete
- name: Check ServersTransport configuration
ansible.builtin.shell: |
docker inspect {{ gitea_container_name }} --format '{{ '{{' }}json .Config.Labels{{ '}}' }}' 2>/dev/null | python3 -c "
import sys, json
labels = json.load(sys.stdin)
transport = labels.get('traefik.http.services.gitea.loadbalancer.serversTransport', '')
if transport:
print('ServersTransport:', transport)
print('dialtimeout:', labels.get('traefik.http.serverstransports.gitea-transport.forwardingtimeouts.dialtimeout', 'NOT SET'))
print('responseheadertimeout:', labels.get('traefik.http.serverstransports.gitea-transport.forwardingtimeouts.responseheadertimeout', 'NOT SET'))
print('idleconntimeout:', labels.get('traefik.http.serverstransports.gitea-transport.forwardingtimeouts.idleconntimeout', 'NOT SET'))
print('maxidleconnsperhost:', labels.get('traefik.http.serverstransports.gitea-transport.maxidleconnsperhost', 'NOT SET'))
else:
print('ServersTransport: NOT CONFIGURED')
"
register: gitea_timeout_config
changed_when: false
failed_when: false
tags:
- complete
- name: Check Gitea app.ini proxy settings
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T {{ gitea_container_name }} cat /data/gitea/conf/app.ini 2>/dev/null | grep -E "PROXY_TRUSTED_PROXIES|LOCAL_ROOT_URL|COOKIE_SECURE|SAME_SITE" || echo "Proxy settings not found in app.ini"
register: gitea_proxy_settings
changed_when: false
failed_when: false
tags:
- complete
- name: Check if Traefik can resolve Gitea hostname
ansible.builtin.shell: |
docker exec {{ traefik_container_name }} getent hosts {{ gitea_container_name }} || echo "DNS resolution failed"
register: traefik_dns_resolution
changed_when: false
failed_when: false
tags:
- complete
- name: Check Docker networks for Gitea and Traefik
ansible.builtin.shell: |
docker inspect {{ gitea_container_name }} --format '{{ '{{' }}json .NetworkSettings.Networks{{ '}}' }}' | python3 -c "import sys, json; data=json.load(sys.stdin); print('Gitea networks:', list(data.keys()))"
docker inspect {{ traefik_container_name }} --format '{{ '{{' }}json .NetworkSettings.Networks{{ '}}' }}' | python3 -c "import sys, json; data=json.load(sys.stdin); print('Traefik networks:', list(data.keys()))"
register: docker_networks_check
changed_when: false
failed_when: false
tags:
- complete
- name: Test long-running endpoint from external
ansible.builtin.uri:
url: "{{ gitea_url }}/user/events"
method: GET
status_code: [200, 504]
validate_certs: false
timeout: 60
register: long_running_endpoint_test
changed_when: false
failed_when: false
tags:
- complete
- name: Check Redis connection from Gitea
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T {{ gitea_container_name }} sh -c "redis-cli -h redis -a {{ vault_gitea_redis_password | default('gitea_redis_password') }} ping 2>&1" || echo "REDIS_CONNECTION_FAILED"
register: gitea_redis_connection
changed_when: false
failed_when: false
tags:
- complete
- name: Check PostgreSQL connection from Gitea
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T {{ gitea_container_name }} sh -c "pg_isready -h postgres -p 5432 -U gitea 2>&1" || echo "POSTGRES_CONNECTION_FAILED"
register: gitea_postgres_connection
changed_when: false
failed_when: false
tags:
- complete
# ========================================
# SUMMARY
# ========================================
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
GITEA DIAGNOSIS SUMMARY
================================================================================
Container Status:
- Gitea: {{ gitea_status.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
- Traefik: {{ traefik_status.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
Health Checks:
- Gitea (direct): {% if 'HEALTH_CHECK_FAILED' not in gitea_health_direct.stdout %}✅{% else %}❌{% endif %}
- Gitea (via Traefik): {% if gitea_health_traefik.status == 200 %}✅{% else %}❌ (Status: {{ gitea_health_traefik.status | default('TIMEOUT') }}){% endif %}
Network:
- Gitea in traefik-public: {% if gitea_in_traefik_network.stdout == 'YES' %}✅{% else %}❌{% endif %}
- Traefik → Gitea: {% if 'CONNECTION_FAILED' not in traefik_gitea_connection.stdout %}✅{% else %}❌{% endif %}
Service Discovery:
- Traefik finds Gitea: {% if 'NOT_FOUND' not in traefik_gitea_service.stdout %}✅{% else %}❌{% endif %}
{% if 'deep' in ansible_run_tags or 'complete' in ansible_run_tags %}
Resources:
- Gitea: {{ gitea_resources.stdout | default('N/A') }}
- Traefik: {{ traefik_resources.stdout | default('N/A') }}
Connection Tests:
- Direct (5 attempts): {{ gitea_direct_tests.stdout | default('N/A') }}
- Via Traefik (5 attempts): {{ gitea_traefik_tests.stdout | default('N/A') }}
Error Logs:
- Gitea: {{ gitea_errors.stdout | default('No errors') }}
- Traefik: {{ traefik_gitea_errors.stdout | default('No errors') }}
{% endif %}
{% if 'complete' in ansible_run_tags %}
Configuration:
- Service Port: {{ gitea_service_port.stdout | default('N/A') }}
- ServersTransport: {{ gitea_timeout_config.stdout | default('N/A') }}
- Proxy Settings: {{ gitea_proxy_settings.stdout | default('N/A') }}
- DNS Resolution: {{ traefik_dns_resolution.stdout | default('N/A') }}
- Networks: {{ docker_networks_check.stdout | default('N/A') }}
Long-Running Endpoint:
- Status: {{ long_running_endpoint_test.status | default('N/A') }}
Dependencies:
- Redis: {% if 'REDIS_CONNECTION_FAILED' not in gitea_redis_connection.stdout %}✅{% else %}❌{% endif %}
- PostgreSQL: {% if 'POSTGRES_CONNECTION_FAILED' not in gitea_postgres_connection.stdout %}✅{% else %}❌{% endif %}
{% endif %}
================================================================================
RECOMMENDATIONS
================================================================================
{% if gitea_health_traefik.status != 200 %}
❌ Gitea is not reachable via Traefik
→ Run: ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags restart
{% endif %}
{% if gitea_in_traefik_network.stdout != 'YES' %}
❌ Gitea is not in traefik-public network
→ Restart Gitea container to update network membership
{% endif %}
{% if 'CONNECTION_FAILED' in traefik_gitea_connection.stdout %}
❌ Traefik cannot reach Gitea
→ Restart both containers
{% endif %}
{% if 'NOT_FOUND' in traefik_gitea_service.stdout %}
❌ Gitea not found in Traefik service discovery
→ Restart Traefik to refresh service discovery
{% endif %}
================================================================================

View File

@@ -0,0 +1,229 @@
---
# Consolidated Traefik Diagnosis Playbook
# Consolidates: diagnose-traefik-restarts.yml, find-traefik-restart-source.yml,
# monitor-traefik-restarts.yml, monitor-traefik-continuously.yml,
# verify-traefik-fix.yml
#
# Usage:
# # Basic diagnosis (default)
# ansible-playbook -i inventory/production.yml playbooks/diagnose/traefik.yml
#
# # Find restart source
# ansible-playbook -i inventory/production.yml playbooks/diagnose/traefik.yml --tags restart-source
#
# # Monitor restarts
# ansible-playbook -i inventory/production.yml playbooks/diagnose/traefik.yml --tags monitor
- name: Diagnose Traefik Issues
hosts: production
gather_facts: yes
become: yes
vars:
traefik_stack_path: "{{ stacks_base_path }}/traefik"
traefik_container_name: "traefik"
monitor_duration_seconds: "{{ monitor_duration_seconds | default(120) }}"
monitor_lookback_hours: "{{ monitor_lookback_hours | default(24) }}"
tasks:
- name: Display diagnostic plan
ansible.builtin.debug:
msg: |
================================================================================
TRAEFIK DIAGNOSIS
================================================================================
Running diagnosis with tags: {{ ansible_run_tags | default(['all']) }}
Basic checks (always):
- Container status
- Restart count
- Recent logs
Restart source (--tags restart-source):
- Find source of restart loops
- Check cronjobs, systemd, scripts
Monitor (--tags monitor):
- Monitor for restarts over time
================================================================================
# ========================================
# BASIC DIAGNOSIS (always runs)
# ========================================
- name: Check Traefik container status
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose ps {{ traefik_container_name }}
register: traefik_status
changed_when: false
- name: Check Traefik container restart count
ansible.builtin.shell: |
docker inspect {{ traefik_container_name }} --format '{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "0"
register: traefik_restart_count
changed_when: false
- name: Check Traefik container start time
ansible.builtin.shell: |
docker inspect {{ traefik_container_name }} --format '{{ '{{' }}.State.StartedAt{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: traefik_started_at
changed_when: false
- name: Check Traefik logs for recent restarts
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs {{ traefik_container_name }} --since 2h 2>&1 | grep -iE "stopping server gracefully|I have to go|restart|shutdown" | tail -20 || echo "No restart messages in last 2 hours"
register: traefik_restart_logs
changed_when: false
failed_when: false
- name: Check Traefik logs for errors
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs {{ traefik_container_name }} --tail=100 2>&1 | grep -iE "error|warn|fail" | tail -20 || echo "No errors in recent logs"
register: traefik_error_logs
changed_when: false
failed_when: false
# ========================================
# RESTART SOURCE DIAGNOSIS (--tags restart-source)
# ========================================
- name: Check all user crontabs for Traefik/Docker commands
ansible.builtin.shell: |
for user in $(cut -f1 -d: /etc/passwd); do
crontab -u "$user" -l 2>/dev/null | grep -qE "traefik|docker.*compose.*traefik|docker.*stop.*traefik|docker.*restart.*traefik|docker.*down.*traefik" && echo "=== User: $user ===" && crontab -u "$user" -l 2>/dev/null | grep -E "traefik|docker.*compose.*traefik|docker.*stop.*traefik|docker.*restart.*traefik|docker.*down.*traefik" || true
done || echo "No user crontabs with Traefik commands found"
register: all_user_crontabs
changed_when: false
tags:
- restart-source
- name: Check system-wide cron directories
ansible.builtin.shell: |
for dir in /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly; do
if [ -d "$dir" ]; then
echo "=== $dir ==="
grep -rE "traefik|docker.*compose.*traefik|docker.*stop.*traefik|docker.*restart.*traefik|docker.*down.*traefik" "$dir" 2>/dev/null || echo "No matches"
fi
done
register: system_cron_dirs
changed_when: false
tags:
- restart-source
- name: Check systemd timers and services
ansible.builtin.shell: |
echo "=== Active Timers ==="
systemctl list-timers --all --no-pager | grep -E "traefik|docker.*compose" || echo "No Traefik-related timers"
echo ""
echo "=== Custom Services ==="
systemctl list-units --type=service --all | grep -E "traefik|docker.*compose" || echo "No Traefik-related services"
register: systemd_services
changed_when: false
tags:
- restart-source
- name: Check for scripts in deployment directory that restart Traefik
ansible.builtin.shell: |
find /home/deploy/deployment -type f \( -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) -exec grep -lE "traefik.*restart|docker.*compose.*traefik.*restart|docker.*compose.*traefik.*down|docker.*compose.*traefik.*stop" {} \; 2>/dev/null | head -30
register: deployment_scripts
changed_when: false
tags:
- restart-source
- name: Check Ansible roles for traefik_auto_restart or restart tasks
ansible.builtin.shell: |
grep -rE "traefik_auto_restart|traefik.*restart|docker.*compose.*traefik.*restart" /home/deploy/deployment/ansible/roles/ 2>/dev/null | grep -v ".git" | head -20 || echo "No auto-restart settings found"
register: ansible_auto_restart
changed_when: false
tags:
- restart-source
- name: Check Docker events for Traefik (last 24 hours)
ansible.builtin.shell: |
timeout 5 docker events --since 24h --filter container={{ traefik_container_name }} --filter event=die --format "{{ '{{' }}.Time{{ '}}' }} {{ '{{' }}.Action{{ '}}' }}" 2>/dev/null | tail -20 || echo "No Traefik die events found"
register: docker_events_traefik
changed_when: false
failed_when: false
tags:
- restart-source
# ========================================
# MONITOR (--tags monitor)
# ========================================
- name: Check Traefik logs for stop messages (lookback period)
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs {{ traefik_container_name }} --since {{ monitor_lookback_hours }}h 2>&1 | grep -E "I have to go|Stopping server gracefully" | tail -20 || echo "No stop messages found"
register: traefik_stop_messages
changed_when: false
tags:
- monitor
- name: Count stop messages
ansible.builtin.set_fact:
stop_count: "{{ traefik_stop_messages.stdout | regex_findall('I have to go|Stopping server gracefully') | length }}"
tags:
- monitor
- name: Check system reboot history
ansible.builtin.shell: |
last reboot | head -5 || echo "No reboots found"
register: reboots
changed_when: false
tags:
- monitor
# ========================================
# SUMMARY
# ========================================
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
TRAEFIK DIAGNOSIS SUMMARY
================================================================================
Container Status:
- Status: {{ traefik_status.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
- Restart Count: {{ traefik_restart_count.stdout }}
- Started At: {{ traefik_started_at.stdout }}
Recent Logs:
- Restart Messages (last 2h): {{ traefik_restart_logs.stdout | default('None') }}
- Errors (last 100 lines): {{ traefik_error_logs.stdout | default('None') }}
{% if 'restart-source' in ansible_run_tags %}
Restart Source Analysis:
- User Crontabs: {{ all_user_crontabs.stdout | default('None found') }}
- System Cron: {{ system_cron_dirs.stdout | default('None found') }}
- Systemd Services/Timers: {{ systemd_services.stdout | default('None found') }}
- Deployment Scripts: {{ deployment_scripts.stdout | default('None found') }}
- Ansible Auto-Restart: {{ ansible_auto_restart.stdout | default('None found') }}
- Docker Events: {{ docker_events_traefik.stdout | default('None found') }}
{% endif %}
{% if 'monitor' in ansible_run_tags %}
Monitoring (last {{ monitor_lookback_hours }} hours):
- Stop Messages: {{ stop_count | default(0) }}
- System Reboots: {{ reboots.stdout | default('None') }}
{% endif %}
================================================================================
RECOMMENDATIONS
================================================================================
{% if 'stopping server gracefully' in traefik_restart_logs.stdout | lower or 'I have to go' in traefik_restart_logs.stdout %}
❌ PROBLEM: Traefik is being stopped regularly!
→ Run with --tags restart-source to find the source
{% endif %}
{% if (traefik_restart_count.stdout | int) > 5 %}
⚠️ WARNING: High restart count ({{ traefik_restart_count.stdout }})
→ Check restart source: ansible-playbook -i inventory/production.yml playbooks/diagnose/traefik.yml --tags restart-source
{% endif %}
================================================================================

View File

@@ -1,136 +0,0 @@
---
# Disable Traefik Auto-Restarts
# Deaktiviert automatische Restarts nach Config-Deployment und entfernt Cronjobs/Systemd-Timer
- name: Disable Traefik Auto-Restarts
hosts: production
gather_facts: yes
become: yes
tasks:
- name: Check current traefik_auto_restart setting in Ansible defaults
ansible.builtin.shell: |
grep -r "traefik_auto_restart" /home/deploy/deployment/ansible/roles/traefik/defaults/main.yml 2>/dev/null || echo "Setting not found"
register: current_auto_restart_setting
changed_when: false
- name: Display current traefik_auto_restart setting
ansible.builtin.debug:
msg: |
================================================================================
Aktuelle traefik_auto_restart Einstellung:
================================================================================
{{ current_auto_restart_setting.stdout }}
================================================================================
- name: Check for cronjobs that restart Traefik
ansible.builtin.shell: |
for user in $(cut -f1 -d: /etc/passwd); do
crontab -u "$user" -l 2>/dev/null | grep -q "traefik\|docker.*compose.*traefik.*restart" && echo "=== User: $user ===" && crontab -u "$user" -l 2>/dev/null | grep -E "traefik|docker.*compose.*traefik.*restart" || true
done || echo "No cronjobs found that restart Traefik"
register: traefik_cronjobs
changed_when: false
- name: Display Traefik cronjobs
ansible.builtin.debug:
msg: |
================================================================================
Cronjobs die Traefik restarten:
================================================================================
{{ traefik_cronjobs.stdout }}
================================================================================
- name: Check for systemd timers that restart Traefik
ansible.builtin.shell: |
find /etc/systemd/system -type f -name "*.timer" 2>/dev/null | xargs grep -l "traefik\|docker.*compose.*traefik.*restart" 2>/dev/null | head -10 || echo "No systemd timers found for Traefik"
register: traefik_timers
changed_when: false
- name: Display Traefik systemd timers
ansible.builtin.debug:
msg: |
================================================================================
Systemd Timers die Traefik restarten:
================================================================================
{{ traefik_timers.stdout }}
================================================================================
- name: Check for systemd services that restart Traefik
ansible.builtin.shell: |
find /etc/systemd/system -type f -name "*.service" 2>/dev/null | xargs grep -l "traefik\|docker.*compose.*traefik.*restart" 2>/dev/null | head -10 || echo "No systemd services found for Traefik"
register: traefik_services
changed_when: false
- name: Display Traefik systemd services
ansible.builtin.debug:
msg: |
================================================================================
Systemd Services die Traefik restarten:
================================================================================
{{ traefik_services.stdout }}
================================================================================
- name: Summary - Found auto-restart mechanisms
ansible.builtin.debug:
msg: |
================================================================================
ZUSAMMENFASSUNG - Gefundene Auto-Restart-Mechanismen:
================================================================================
Ansible traefik_auto_restart: {{ current_auto_restart_setting.stdout }}
{% if traefik_cronjobs.stdout and 'No cronjobs' not in traefik_cronjobs.stdout %}
⚠️ Gefundene Cronjobs:
{{ traefik_cronjobs.stdout }}
Manuelle Deaktivierung erforderlich:
- Entferne die Cronjob-Einträge manuell
- Oder verwende: crontab -e
{% endif %}
{% if traefik_timers.stdout and 'No systemd timers' not in traefik_timers.stdout %}
⚠️ Gefundene Systemd Timers:
{{ traefik_timers.stdout }}
Manuelle Deaktivierung erforderlich:
- systemctl stop <timer-name>
- systemctl disable <timer-name>
{% endif %}
{% if traefik_services.stdout and 'No systemd services' not in traefik_services.stdout %}
⚠️ Gefundene Systemd Services:
{{ traefik_services.stdout }}
Manuelle Deaktivierung erforderlich:
- systemctl stop <service-name>
- systemctl disable <service-name>
{% endif %}
{% if 'No cronjobs' in traefik_cronjobs.stdout and 'No systemd timers' in traefik_timers.stdout and 'No systemd services' in traefik_services.stdout %}
✅ Keine automatischen Restart-Mechanismen gefunden (außer Ansible traefik_auto_restart)
{% endif %}
Empfehlung:
- Setze traefik_auto_restart: false in group_vars oder inventory
- Oder überschreibe bei Config-Deployment: -e "traefik_auto_restart=false"
================================================================================
- name: Note - Manual steps required
ansible.builtin.debug:
msg: |
================================================================================
HINWEIS - Manuelle Schritte erforderlich:
================================================================================
Dieses Playbook zeigt nur gefundene Auto-Restart-Mechanismen an.
Um traefik_auto_restart zu deaktivieren:
1. In group_vars/production/vars.yml oder inventory hinzufügen:
traefik_auto_restart: false
2. Oder bei jedem Config-Deployment überschreiben:
ansible-playbook ... -e "traefik_auto_restart=false"
3. Für Cronjobs/Systemd: Siehe oben für manuelle Deaktivierung
================================================================================

View File

@@ -1,90 +0,0 @@
---
# Ensure Gitea is Discovered by Traefik
# This playbook ensures that Traefik properly discovers Gitea after restarts
- name: Ensure Gitea is Discovered by Traefik
hosts: production
gather_facts: no
become: no
vars:
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_stack_path: "{{ stacks_base_path }}/gitea"
max_wait_seconds: 60
check_interval: 5
tasks:
- name: Check if Gitea container is running
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose ps gitea | grep -q "Up" && echo "RUNNING" || echo "NOT_RUNNING"
register: gitea_status
changed_when: false
- name: Start Gitea if not running
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose up -d gitea
when: gitea_status.stdout == "NOT_RUNNING"
register: gitea_start
- name: Wait for Gitea to be ready
ansible.builtin.wait_for:
timeout: 30
delay: 2
when: gitea_start.changed | default(false) | bool
- name: Check if Traefik can see Gitea container
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose exec -T traefik sh -c 'wget -qO- http://localhost:8080/api/http/routers 2>&1 | python3 -m json.tool 2>&1 | grep -qi gitea && echo "FOUND" || echo "NOT_FOUND"'
register: traefik_gitea_check
changed_when: false
failed_when: false
retries: "{{ (max_wait_seconds | int) // (check_interval | int) }}"
delay: "{{ check_interval }}"
until: traefik_gitea_check.stdout == "FOUND"
- name: Restart Traefik if Gitea not found
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose restart traefik
when: traefik_gitea_check.stdout == "NOT_FOUND"
register: traefik_restart
- name: Wait for Traefik to be ready after restart
ansible.builtin.wait_for:
timeout: 30
delay: 2
when: traefik_restart.changed | default(false) | bool
- name: Verify Gitea is reachable via Traefik
ansible.builtin.uri:
url: "https://{{ gitea_domain }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_health_check
retries: 5
delay: 2
until: gitea_health_check.status == 200
failed_when: false
- name: Display result
ansible.builtin.debug:
msg: |
================================================================================
GITEA TRAEFIK DISCOVERY - RESULT
================================================================================
Gitea Status: {{ gitea_status.stdout }}
Traefik Discovery: {{ traefik_gitea_check.stdout }}
Gitea Health Check: {{ 'OK' if (gitea_health_check.status | default(0) == 200) else 'FAILED' }}
{% if gitea_health_check.status | default(0) == 200 %}
✅ Gitea is reachable via Traefik
{% else %}
❌ Gitea is not reachable via Traefik
{% endif %}
================================================================================

View File

@@ -1,246 +0,0 @@
---
# Find Ansible Automation Source
# Findet die Quelle der externen Ansible-Automatisierung, die Traefik regelmäßig neu startet
- name: Find Ansible Automation Source
hosts: production
gather_facts: yes
become: yes
tasks:
- name: Check for running Ansible processes
ansible.builtin.shell: |
ps aux | grep -E "ansible|ansible-playbook|ansible-pull" | grep -v grep || echo "No Ansible processes found"
register: ansible_processes
changed_when: false
- name: Check for ansible-pull processes
ansible.builtin.shell: |
ps aux | grep ansible-pull | grep -v grep || echo "No ansible-pull processes found"
register: ansible_pull_processes
changed_when: false
- name: Check systemd timers for ansible-pull
ansible.builtin.shell: |
systemctl list-timers --all --no-pager | grep -i ansible || echo "No ansible timers found"
register: ansible_timers
changed_when: false
- name: Check for ansible-pull cronjobs
ansible.builtin.shell: |
for user in $(cut -f1 -d: /etc/passwd); do
crontab -u "$user" -l 2>/dev/null | grep -q "ansible-pull\|ansible.*playbook" && echo "=== User: $user ===" && crontab -u "$user" -l 2>/dev/null | grep -E "ansible-pull|ansible.*playbook" || true
done || echo "No ansible-pull cronjobs found"
register: ansible_cronjobs
changed_when: false
- name: Check system-wide cron for ansible
ansible.builtin.shell: |
for dir in /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly; do
if [ -d "$dir" ]; then
grep -rE "ansible-pull|ansible.*playbook" "$dir" 2>/dev/null && echo "=== Found in $dir ===" || true
fi
done || echo "No ansible in system cron"
register: ansible_system_cron
changed_when: false
- name: Check journalctl for ansible-ansible processes
ansible.builtin.shell: |
journalctl --since "24 hours ago" --no-pager | grep -iE "ansible-ansible|ansible-playbook|ansible-pull" | tail -50 || echo "No ansible processes in journalctl"
register: ansible_journal
changed_when: false
- name: Check for ansible-pull configuration files
ansible.builtin.shell: |
find /home -name "*ansible-pull*" -o -name "*ansible*.yml" -path "*/ansible-pull/*" 2>/dev/null | head -20 || echo "No ansible-pull config files found"
register: ansible_pull_configs
changed_when: false
- name: Check for running docker compose commands related to Traefik
ansible.builtin.shell: |
ps aux | grep -E "docker.*compose.*traefik|docker.*restart.*traefik" | grep -v grep || echo "No docker compose traefik commands running"
register: docker_traefik_commands
changed_when: false
- name: Check Docker events for Traefik kill events (last hour)
ansible.builtin.shell: |
docker events --since 1h --until now --filter container=traefik --filter event=die --format "{{ '{{' }}.Time{{ '}}' }} {{ '{{' }}.Action{{ '}}' }} {{ '{{' }}.Actor.Attributes.signal{{ '}}' }}" 2>/dev/null | tail -20 || echo "No Traefik die events in last hour"
register: traefik_kill_events
changed_when: false
failed_when: false
- name: Check journalctl for docker compose traefik commands
ansible.builtin.shell: |
journalctl --since "24 hours ago" --no-pager | grep -iE "docker.*compose.*traefik|docker.*restart.*traefik" | tail -30 || echo "No docker compose traefik commands in journalctl"
register: docker_traefik_journal
changed_when: false
- name: Check for CI/CD scripts that might run Ansible
ansible.builtin.shell: |
find /home/deploy -type f \( -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) -exec grep -lE "ansible.*playbook.*traefik|docker.*compose.*traefik.*restart" {} \; 2>/dev/null | head -20 || echo "No CI/CD scripts found"
register: cicd_scripts
changed_when: false
- name: Check for Gitea Workflows that run Ansible
ansible.builtin.shell: |
find /home/deploy -type f -path "*/.gitea/workflows/*.yml" -o -path "*/.github/workflows/*.yml" 2>/dev/null | xargs grep -lE "ansible.*playbook.*traefik|docker.*compose.*traefik" 2>/dev/null | head -10 || echo "No Gitea workflows found"
register: gitea_workflows
changed_when: false
- name: Check for monitoring/healthcheck scripts
ansible.builtin.shell: |
find /home/deploy -type f -name "*monitor*" -o -name "*health*" 2>/dev/null | xargs grep -lE "traefik.*restart|docker.*compose.*traefik" 2>/dev/null | head -10 || echo "No monitoring scripts found"
register: monitoring_scripts
changed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
ANSIBLE AUTOMATION SOURCE DIAGNOSE:
================================================================================
Laufende Ansible-Prozesse:
{{ ansible_processes.stdout }}
Ansible-Pull Prozesse:
{{ ansible_pull_processes.stdout }}
Systemd Timers für Ansible:
{{ ansible_timers.stdout }}
Cronjobs für Ansible:
{{ ansible_cronjobs.stdout }}
System-Cron für Ansible:
{{ ansible_system_cron.stdout }}
Ansible-Prozesse in Journalctl (letzte 24h):
{{ ansible_journal.stdout }}
Ansible-Pull Konfigurationsdateien:
{{ ansible_pull_configs.stdout }}
Laufende Docker Compose Traefik-Befehle:
{{ docker_traefik_commands.stdout }}
Traefik Kill-Events (letzte Stunde):
{{ traefik_kill_events.stdout }}
Docker Compose Traefik-Befehle in Journalctl:
{{ docker_traefik_journal.stdout }}
CI/CD Scripts die Traefik restarten:
{{ cicd_scripts.stdout }}
Gitea Workflows die Traefik restarten:
{{ gitea_workflows.stdout }}
Monitoring-Scripts die Traefik restarten:
{{ monitoring_scripts.stdout }}
================================================================================
ANALYSE:
================================================================================
{% if 'No Ansible processes found' not in ansible_processes.stdout %}
⚠️ AKTIVE ANSIBLE-PROZESSE GEFUNDEN:
{{ ansible_processes.stdout }}
→ Diese Prozesse könnten Traefik regelmäßig neu starten
→ Prüfe die Kommandozeile dieser Prozesse um das Playbook zu identifizieren
{% endif %}
{% if 'No ansible-pull processes found' not in ansible_pull_processes.stdout %}
❌ ANSIBLE-PULL LÄUFT:
{{ ansible_pull_processes.stdout }}
→ ansible-pull führt regelmäßig Playbooks aus
→ Dies ist wahrscheinlich die Quelle der Traefik-Restarts
{% endif %}
{% if 'No ansible timers found' not in ansible_timers.stdout %}
❌ ANSIBLE TIMER GEFUNDEN:
{{ ansible_timers.stdout }}
→ Ein Systemd-Timer führt regelmäßig Ansible aus
→ Deaktiviere mit: systemctl disable <timer-name>
{% endif %}
{% if 'No ansible-pull cronjobs found' not in ansible_cronjobs.stdout %}
❌ ANSIBLE CRONJOB GEFUNDEN:
{{ ansible_cronjobs.stdout }}
→ Ein Cronjob führt regelmäßig Ansible aus
→ Entferne oder kommentiere den Cronjob-Eintrag
{% endif %}
{% if cicd_scripts.stdout and 'No CI/CD scripts found' not in cicd_scripts.stdout %}
⚠️ CI/CD SCRIPTS GEFUNDEN:
{{ cicd_scripts.stdout }}
→ Diese Scripts könnten Traefik regelmäßig neu starten
→ Prüfe diese Dateien und entferne/kommentiere Traefik-Restart-Befehle
{% endif %}
{% if gitea_workflows.stdout and 'No Gitea workflows found' not in gitea_workflows.stdout %}
⚠️ GITEA WORKFLOWS GEFUNDEN:
{{ gitea_workflows.stdout }}
→ Diese Workflows könnten Traefik regelmäßig neu starten
→ Prüfe diese Workflows und entferne/kommentiere Traefik-Restart-Schritte
{% endif %}
{% if monitoring_scripts.stdout and 'No monitoring scripts found' not in monitoring_scripts.stdout %}
⚠️ MONITORING SCRIPTS GEFUNDEN:
{{ monitoring_scripts.stdout }}
→ Diese Scripts könnten Traefik regelmäßig neu starten
→ Prüfe diese Scripts und entferne/kommentiere Traefik-Restart-Befehle
{% endif %}
================================================================================
LÖSUNG:
================================================================================
{% if 'No Ansible processes found' in ansible_processes.stdout and 'No ansible-pull processes found' in ansible_pull_processes.stdout and 'No ansible timers found' in ansible_timers.stdout and 'No ansible-pull cronjobs found' in ansible_cronjobs.stdout %}
Keine aktiven Ansible-Automatisierungen gefunden
Mögliche Ursachen:
1. Ansible-Prozesse laufen nur zeitweise (intermittierend)
2. Externe CI/CD-Pipeline führt Ansible aus
3. Manuelle Ansible-Aufrufe von außen
Nächste Schritte:
1. Beobachte Docker Events in Echtzeit: docker events --filter container=traefik
2. Beobachte Ansible-Prozesse: watch -n 1 'ps aux | grep ansible'
3. Prüfe ob externe CI/CD-Pipelines Ansible ausführen
{% else %}
SOFORTMASSNAHME:
{% if 'No ansible-pull processes found' not in ansible_pull_processes.stdout %}
1. ❌ Stoppe ansible-pull:
pkill -f ansible-pull
{% endif %}
{% if 'No ansible timers found' not in ansible_timers.stdout %}
2. ❌ Deaktiviere Ansible-Timer:
systemctl stop <timer-name>
systemctl disable <timer-name>
{% endif %}
{% if 'No ansible-pull cronjobs found' not in ansible_cronjobs.stdout %}
3. ❌ Entferne Ansible-Cronjobs:
crontab -u <user> -e
(Kommentiere oder entferne die Ansible-Zeilen)
{% endif %}
LANGZEITLÖSUNG:
1. Prüfe gefundene Scripts/Workflows und entferne Traefik-Restart-Befehle
2. Falls Healthchecks nötig sind, setze größere Intervalle (z.B. 5 Minuten statt 30 Sekunden)
3. Restarte Traefik nur bei echten Fehlern, nicht präventiv
{% endif %}
================================================================================

View File

@@ -1,328 +0,0 @@
---
# Find Source of Traefik Restarts
# Umfassende Diagnose um die Quelle der regelmäßigen Traefik-Restarts zu finden
- name: Find Source of Traefik Restarts
hosts: production
gather_facts: yes
become: yes
vars:
traefik_stack_path: "{{ stacks_base_path }}/traefik"
monitor_duration_seconds: 120 # 2 Minuten Monitoring (kann erhöht werden)
tasks:
- name: Check Traefik container restart count
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "0"
register: traefik_restart_count
changed_when: false
- name: Check Traefik container start time
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.State.StartedAt{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: traefik_started_at
changed_when: false
- name: Analyze Traefik logs for "Stopping server gracefully" messages
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs traefik 2>&1 | grep -i "stopping server gracefully\|I have to go" | tail -20
register: traefik_stop_messages
changed_when: false
failed_when: false
- name: Extract timestamps from stop messages
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs traefik 2>&1 | grep -i "stopping server gracefully\|I have to go" | tail -20 | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}' | sort | uniq
register: stop_timestamps
changed_when: false
failed_when: false
- name: Check Docker daemon logs for Traefik stop events
ansible.builtin.shell: |
journalctl -u docker.service --since "24 hours ago" --no-pager | grep -iE "traefik.*stop|traefik.*kill|traefik.*die|container.*traefik.*stopped" | tail -30 || echo "No Traefik stop events in Docker daemon logs"
register: docker_daemon_logs
changed_when: false
failed_when: false
- name: Check Docker events for Traefik (last 24 hours)
ansible.builtin.shell: |
docker events --since 24h --until now --filter container=traefik --filter event=die --format "{{ '{{' }}.Time{{ '}}' }} {{ '{{' }}.Action{{ '}}' }} {{ '{{' }}.Actor.Attributes.name{{ '}}' }}" 2>/dev/null | tail -20 || echo "No Traefik die events found"
register: docker_events_traefik
changed_when: false
failed_when: false
- name: Check all user crontabs for Traefik/Docker commands
ansible.builtin.shell: |
for user in $(cut -f1 -d: /etc/passwd); do
crontab -u "$user" -l 2>/dev/null | grep -qE "traefik|docker.*compose.*traefik|docker.*stop.*traefik|docker.*restart.*traefik|docker.*down.*traefik" && echo "=== User: $user ===" && crontab -u "$user" -l 2>/dev/null | grep -E "traefik|docker.*compose.*traefik|docker.*stop.*traefik|docker.*restart.*traefik|docker.*down.*traefik" || true
done || echo "No user crontabs with Traefik commands found"
register: all_user_crontabs
changed_when: false
- name: Check system-wide cron directories
ansible.builtin.shell: |
for dir in /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly; do
if [ -d "$dir" ]; then
echo "=== $dir ==="
grep -rE "traefik|docker.*compose.*traefik|docker.*stop.*traefik|docker.*restart.*traefik|docker.*down.*traefik" "$dir" 2>/dev/null || echo "No matches"
fi
done
register: system_cron_dirs
changed_when: false
- name: Check systemd timers and services
ansible.builtin.shell: |
echo "=== Active Timers ==="
systemctl list-timers --all --no-pager | grep -E "traefik|docker.*compose" || echo "No Traefik-related timers"
echo ""
echo "=== Custom Services ==="
systemctl list-units --type=service --all | grep -E "traefik|docker.*compose" || echo "No Traefik-related services"
register: systemd_services
changed_when: false
- name: Check for scripts in deployment directory that restart Traefik
ansible.builtin.shell: |
find /home/deploy/deployment -type f \( -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) -exec grep -lE "traefik.*restart|docker.*compose.*traefik.*restart|docker.*compose.*traefik.*down|docker.*compose.*traefik.*stop" {} \; 2>/dev/null | head -30
register: deployment_scripts
changed_when: false
- name: Check Ansible roles for traefik_auto_restart or restart tasks
ansible.builtin.shell: |
grep -rE "traefik_auto_restart|traefik.*restart|docker.*compose.*traefik.*restart" /home/deploy/deployment/ansible/roles/ 2>/dev/null | grep -v ".git" | head -20 || echo "No auto-restart settings found"
register: ansible_auto_restart
changed_when: false
- name: Check Docker Compose watch mode
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose ps traefik 2>/dev/null | grep -q "traefik" && echo "running" || echo "not_running"
register: docker_compose_watch
changed_when: false
failed_when: false
- name: Check if Docker Compose is running in watch mode
ansible.builtin.shell: |
ps aux | grep -E "docker.*compose.*watch|docker.*compose.*--watch" | grep -v grep || echo "No Docker Compose watch mode detected"
register: watch_mode_process
changed_when: false
- name: Check for monitoring/watchdog scripts
ansible.builtin.shell: |
find /home/deploy -type f -name "*monitor*" -o -name "*watchdog*" -o -name "*health*" 2>/dev/null | xargs grep -lE "traefik|docker.*compose.*traefik" 2>/dev/null | head -10 || echo "No monitoring scripts found"
register: monitoring_scripts
changed_when: false
- name: Check Gitea Workflows for Traefik restarts
ansible.builtin.shell: |
find /home/deploy -type f -path "*/.gitea/workflows/*.yml" -o -path "*/.github/workflows/*.yml" 2>/dev/null | xargs grep -lE "traefik.*restart|docker.*compose.*traefik.*restart" 2>/dev/null | head -10 || echo "No Gitea workflows found that restart Traefik"
register: gitea_workflows
changed_when: false
- name: Monitor Docker events in real-time (5 minutes)
ansible.builtin.shell: |
timeout {{ monitor_duration_seconds }} docker events --filter container=traefik --format "{{ '{{' }}.Time{{ '}}' }} {{ '{{' }}.Action{{ '}}' }} {{ '{{' }}.Actor.Attributes.name{{ '}}' }}" 2>&1 || echo "Monitoring completed or timeout"
register: docker_events_realtime
changed_when: false
failed_when: false
async: "{{ monitor_duration_seconds + 10 }}"
poll: 0
- name: Wait for monitoring to complete
ansible.builtin.async_status:
jid: "{{ docker_events_realtime.ansible_job_id }}"
register: monitoring_result
until: monitoring_result.finished
retries: "{{ (monitor_duration_seconds / 10) | int + 5 }}"
delay: 10
failed_when: false
- name: Check system reboot history
ansible.builtin.shell: |
last reboot --since "24 hours ago" 2>/dev/null | head -10 || echo "No reboots in last 24 hours"
register: reboot_history
changed_when: false
failed_when: false
- name: Check for at jobs
ansible.builtin.shell: |
atq 2>/dev/null | while read line; do
job_id=$(echo "$line" | awk '{print $1}')
at -c "$job_id" 2>/dev/null | grep -qE "traefik|docker.*compose.*traefik" && echo "=== Job ID: $job_id ===" && at -c "$job_id" 2>/dev/null | grep -E "traefik|docker.*compose.*traefik" || true
done || echo "No at jobs found or atq not available"
register: at_jobs
changed_when: false
- name: Check Docker daemon configuration for auto-restart
ansible.builtin.shell: |
cat /etc/docker/daemon.json 2>/dev/null | grep -iE "restart|live-restore" || echo "No restart settings in daemon.json"
register: docker_daemon_config
changed_when: false
failed_when: false
- name: Check if Traefik has restart policy
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose config | grep -A 5 "traefik:" | grep -E "restart|restart_policy" || echo "No explicit restart policy found"
register: traefik_restart_policy
changed_when: false
failed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
TRAEFIK RESTART SOURCE DIAGNOSE - ZUSAMMENFASSUNG:
================================================================================
Traefik Status:
- Restart Count: {{ traefik_restart_count.stdout }}
- Started At: {{ traefik_started_at.stdout }}
- Stop Messages gefunden: {{ traefik_stop_messages.stdout_lines | length }} (letzte 20)
Stop-Zeitstempel (letzte 20):
{% if stop_timestamps.stdout %}
{{ stop_timestamps.stdout }}
{% else %}
Keine Stop-Zeitstempel gefunden
{% endif %}
Docker Events (letzte 24h):
{% if docker_events_traefik.stdout and 'No Traefik die events' not in docker_events_traefik.stdout %}
{{ docker_events_traefik.stdout }}
{% else %}
Keine Traefik die-Events in den letzten 24 Stunden
{% endif %}
Docker Daemon Logs:
{% if docker_daemon_logs.stdout and 'No Traefik stop events' not in docker_daemon_logs.stdout %}
{{ docker_daemon_logs.stdout }}
{% else %}
Keine Traefik-Stop-Events in Docker-Daemon-Logs
{% endif %}
Gefundene Quellen:
{% if all_user_crontabs.stdout and 'No user crontabs' not in all_user_crontabs.stdout %}
1. ❌ CRONJOBS (User):
{{ all_user_crontabs.stdout }}
{% endif %}
{% if system_cron_dirs.stdout and 'No matches' not in system_cron_dirs.stdout %}
2. ❌ SYSTEM CRON:
{{ system_cron_dirs.stdout }}
{% endif %}
{% if systemd_services.stdout and 'No Traefik-related' not in systemd_services.stdout %}
3. ❌ SYSTEMD TIMERS/SERVICES:
{{ systemd_services.stdout }}
{% endif %}
{% if deployment_scripts.stdout and 'No' not in deployment_scripts.stdout %}
4. ⚠️ DEPLOYMENT SCRIPTS:
{{ deployment_scripts.stdout }}
{% endif %}
{% if ansible_auto_restart.stdout and 'No auto-restart' not in ansible_auto_restart.stdout %}
5. ⚠️ ANSIBLE AUTO-RESTART:
{{ ansible_auto_restart.stdout }}
{% endif %}
{% if gitea_workflows.stdout and 'No Gitea workflows' not in gitea_workflows.stdout %}
6. ⚠️ GITEA WORKFLOWS:
{{ gitea_workflows.stdout }}
{% endif %}
{% if monitoring_scripts.stdout and 'No monitoring scripts' not in monitoring_scripts.stdout %}
7. ⚠️ MONITORING SCRIPTS:
{{ monitoring_scripts.stdout }}
{% endif %}
{% if at_jobs.stdout and 'No at jobs' not in at_jobs.stdout %}
8. ❌ AT JOBS:
{{ at_jobs.stdout }}
{% endif %}
{% if docker_compose_watch.stdout and 'Could not check' not in docker_compose_watch.stdout %}
9. ⚠️ DOCKER COMPOSE WATCH:
{{ docker_compose_watch.stdout }}
{% endif %}
{% if watch_mode_process.stdout and 'No Docker Compose watch' not in watch_mode_process.stdout %}
10. ❌ DOCKER COMPOSE WATCH MODE (PROZESS):
{{ watch_mode_process.stdout }}
{% endif %}
{% if reboot_history.stdout and 'No reboots' not in reboot_history.stdout %}
11. ⚠️ SYSTEM REBOOTS:
{{ reboot_history.stdout }}
{% endif %}
Real-Time Monitoring ({{ monitor_duration_seconds }} Sekunden):
{% if monitoring_result.finished and monitoring_result.ansible_job_id %}
{{ monitoring_result.stdout | default('Keine Events während Monitoring') }}
{% else %}
Monitoring läuft noch oder wurde unterbrochen
{% endif %}
================================================================================
NÄCHSTE SCHRITTE:
================================================================================
{% if all_user_crontabs.stdout and 'No user crontabs' not in all_user_crontabs.stdout %}
1. ❌ CRONJOBS DEAKTIVIEREN:
- Prüfe gefundene Cronjobs: {{ all_user_crontabs.stdout }}
- Entferne oder kommentiere die entsprechenden Einträge
{% endif %}
{% if system_cron_dirs.stdout and 'No matches' not in system_cron_dirs.stdout %}
2. ❌ SYSTEM CRON DEAKTIVIEREN:
- Prüfe gefundene System-Cronjobs: {{ system_cron_dirs.stdout }}
- Entferne oder benenne die Dateien um
{% endif %}
{% if systemd_services.stdout and 'No Traefik-related' not in systemd_services.stdout %}
3. ❌ SYSTEMD TIMERS/SERVICES DEAKTIVIEREN:
- Prüfe gefundene Services/Timers: {{ systemd_services.stdout }}
- Deaktiviere mit: systemctl disable <service>
{% endif %}
{% if deployment_scripts.stdout and 'No' not in deployment_scripts.stdout %}
4. ⚠️ DEPLOYMENT SCRIPTS PRÜFEN:
- Prüfe gefundene Scripts: {{ deployment_scripts.stdout }}
- Entferne oder kommentiere Traefik-Restart-Befehle
{% endif %}
{% if ansible_auto_restart.stdout and 'No auto-restart' not in ansible_auto_restart.stdout %}
5. ⚠️ ANSIBLE AUTO-RESTART PRÜFEN:
- Prüfe gefundene Einstellungen: {{ ansible_auto_restart.stdout }}
- Setze traefik_auto_restart: false in group_vars
{% endif %}
{% if not all_user_crontabs.stdout or 'No user crontabs' in all_user_crontabs.stdout %}
{% if not system_cron_dirs.stdout or 'No matches' in system_cron_dirs.stdout %}
{% if not systemd_services.stdout or 'No Traefik-related' in systemd_services.stdout %}
{% if not deployment_scripts.stdout or 'No' in deployment_scripts.stdout %}
{% if not ansible_auto_restart.stdout or 'No auto-restart' in ansible_auto_restart.stdout %}
⚠️ KEINE AUTOMATISCHEN RESTART-MECHANISMEN GEFUNDEN!
Mögliche Ursachen:
1. Externer Prozess (nicht über Cron/Systemd)
2. Docker-Service-Restarts (systemctl restart docker)
3. Host-Reboots
4. Manuelle Restarts (von außen)
5. Monitoring-Service (Portainer, Watchtower, etc.)
Nächste Schritte:
1. Führe 'docker events --filter container=traefik' manuell aus und beobachte
2. Prüfe journalctl -u docker.service für Docker-Service-Restarts
3. Prüfe ob Portainer oder andere Monitoring-Tools laufen
4. Prüfe ob Watchtower oder andere Auto-Update-Tools installiert sind
{% endif %}
{% endif %}
{% endif %}
{% endif %}
{% endif %}
================================================================================

View File

@@ -1,175 +0,0 @@
---
# Fix Gitea Complete - Deaktiviert Runner, repariert Service Discovery
# Behebt Gitea-Timeouts durch: 1) Runner deaktivieren, 2) Service Discovery reparieren
- name: Fix Gitea Complete
hosts: production
gather_facts: yes
become: no
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_runner_path: "{{ stacks_base_path }}/../gitea-runner"
gitea_url: "https://{{ gitea_domain }}"
tasks:
- name: Check Gitea Runner status
ansible.builtin.shell: |
cd {{ gitea_runner_path }}
docker compose ps gitea-runner 2>/dev/null || echo "Runner not found"
register: runner_status
changed_when: false
failed_when: false
- name: Display Gitea Runner status
ansible.builtin.debug:
msg: |
================================================================================
Gitea Runner Status (Before):
================================================================================
{{ runner_status.stdout }}
================================================================================
- name: Stop Gitea Runner to reduce load
ansible.builtin.shell: |
cd {{ gitea_runner_path }}
docker compose stop gitea-runner
register: runner_stop
changed_when: runner_stop.rc == 0
failed_when: false
when: runner_status.rc == 0
- name: Check Gitea container status before restart
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose ps gitea
register: gitea_status_before
changed_when: false
- name: Check Traefik container status before restart
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose ps traefik
register: traefik_status_before
changed_when: false
- name: Restart Gitea container
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose restart gitea
register: gitea_restart
changed_when: gitea_restart.rc == 0
- name: Wait for Gitea to be ready (direct check)
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
for i in {1..30}; do
if docker compose exec -T gitea curl -f http://localhost:3000/api/healthz >/dev/null 2>&1; then
echo "Gitea is ready"
exit 0
fi
sleep 2
done
echo "Gitea not ready after 60 seconds"
exit 1
register: gitea_ready
changed_when: false
failed_when: false
- name: Restart Traefik to refresh service discovery
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose restart traefik
register: traefik_restart
changed_when: traefik_restart.rc == 0
when: traefik_auto_restart | default(false) | bool
- name: Wait for Traefik to be ready
ansible.builtin.wait_for:
timeout: 30
delay: 2
changed_when: false
when: traefik_restart.changed | default(false) | bool
- name: Wait for Gitea to be reachable via Traefik (with retries)
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_health_via_traefik
until: gitea_health_via_traefik.status == 200
retries: 15
delay: 2
changed_when: false
failed_when: false
when: (traefik_restart.changed | default(false) | bool) or (gitea_restart.changed | default(false) | bool)
- name: Check if Gitea is in Traefik service discovery
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose exec -T traefik traefik show providers docker 2>/dev/null | grep -i "gitea" || echo "NOT_FOUND"
register: traefik_gitea_service_check
changed_when: false
failed_when: false
when: (traefik_restart.changed | default(false) | bool) or (gitea_restart.changed | default(false) | bool)
- name: Final status check
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: final_status
changed_when: false
failed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
ZUSAMMENFASSUNG - Gitea Complete Fix:
================================================================================
Aktionen:
- Gitea Runner: {% if runner_stop.changed | default(false) %}✅ Gestoppt{% else %} War nicht aktiv oder nicht gefunden{% endif %}
- Gitea Restart: {% if gitea_restart.changed %}✅ Durchgeführt{% else %} Nicht nötig{% endif %}
- Traefik Restart: {% if traefik_restart.changed %}✅ Durchgeführt{% else %} Nicht nötig{% endif %}
Gitea Ready Check:
- Direkt: {% if gitea_ready.rc == 0 %}✅ Bereit{% else %}❌ Nicht bereit{% endif %}
Final Status:
- Gitea via Traefik: {% if final_status.status == 200 %}✅ Erreichbar (Status: 200){% else %}❌ Nicht erreichbar (Status: {{ final_status.status | default('TIMEOUT') }}){% endif %}
- Traefik Service Discovery: {% if 'NOT_FOUND' not in traefik_gitea_service_check.stdout %}✅ Gitea gefunden{% else %}❌ Gitea nicht gefunden{% endif %}
{% if final_status.status == 200 and 'NOT_FOUND' not in traefik_gitea_service_check.stdout %}
✅ ERFOLG: Gitea ist jetzt über Traefik erreichbar!
URL: {{ gitea_url }}
Nächste Schritte:
1. Teste Gitea im Browser: {{ gitea_url }}
2. Wenn alles stabil läuft, kannst du den Runner wieder aktivieren:
cd {{ gitea_runner_path }} && docker compose up -d gitea-runner
3. Beobachte ob der Runner Gitea wieder überlastet
{% else %}
⚠️ PROBLEM: Gitea ist noch nicht vollständig erreichbar
Mögliche Ursachen:
{% if final_status.status != 200 %}
- Gitea antwortet nicht via Traefik (Status: {{ final_status.status | default('TIMEOUT') }})
{% endif %}
{% if 'NOT_FOUND' in traefik_gitea_service_check.stdout %}
- Traefik Service Discovery hat Gitea noch nicht erkannt
{% endif %}
Nächste Schritte:
1. Warte 1-2 Minuten und teste erneut: curl -k {{ gitea_url }}/api/healthz
2. Prüfe Traefik-Logs: cd {{ traefik_stack_path }} && docker compose logs traefik --tail=50
3. Prüfe Gitea-Logs: cd {{ gitea_stack_path }} && docker compose logs gitea --tail=50
4. Prüfe Service Discovery: cd {{ traefik_stack_path }} && docker compose exec -T traefik traefik show providers docker
{% endif %}
================================================================================

View File

@@ -1,195 +0,0 @@
---
# Fix Gitea SSL and Routing Issues
# Prüft SSL-Zertifikat, Service Discovery und behebt Routing-Probleme
- name: Fix Gitea SSL and Routing
hosts: production
gather_facts: yes
become: no
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_url: "https://{{ gitea_domain }}"
gitea_url_http: "http://{{ gitea_domain }}"
tasks:
- name: Check Gitea container status
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose ps gitea
register: gitea_status
changed_when: false
- name: Check Traefik container status
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose ps traefik
register: traefik_status
changed_when: false
- name: Check if Gitea is in traefik-public network
ansible.builtin.shell: |
docker network inspect traefik-public --format '{{ '{{' }}range .Containers{{ '}}' }}{{ '{{' }}.Name{{ '}}' }} {{ '{{' }}end{{ '}}' }}' 2>/dev/null | grep -q gitea && echo "YES" || echo "NO"
register: gitea_in_network
changed_when: false
- name: Test direct connection from Traefik to Gitea (by service name)
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose exec -T traefik wget -qO- --timeout=5 http://gitea:3000/api/healthz 2>&1 || echo "CONNECTION_FAILED"
register: traefik_gitea_direct
changed_when: false
failed_when: false
- name: Check Traefik logs for SSL/ACME errors
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs traefik --tail=100 2>&1 | grep -iE "acme|certificate|git\.michaelschiemer\.de|ssl|tls" | tail -20 || echo "No SSL/ACME errors found"
register: traefik_ssl_errors
changed_when: false
failed_when: false
- name: Check if SSL certificate exists for git.michaelschiemer.de
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose exec -T traefik cat /acme.json 2>/dev/null | grep -q "git.michaelschiemer.de" && echo "YES" || echo "NO"
register: ssl_cert_exists
changed_when: false
failed_when: false
- name: Test Gitea via HTTP (port 80, should redirect or show error)
ansible.builtin.uri:
url: "{{ gitea_url_http }}/api/healthz"
method: GET
status_code: [200, 301, 302, 404, 502, 503, 504]
validate_certs: false
timeout: 10
register: gitea_http_test
changed_when: false
failed_when: false
- name: Test Gitea via HTTPS
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200, 301, 302, 404, 502, 503, 504]
validate_certs: false
timeout: 10
register: gitea_https_test
changed_when: false
failed_when: false
- name: Display diagnostic information
ansible.builtin.debug:
msg: |
================================================================================
GITEA SSL/ROUTING DIAGNOSE:
================================================================================
Container Status:
- Gitea: {{ gitea_status.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
- Traefik: {{ traefik_status.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
Network:
- Gitea in traefik-public: {% if gitea_in_network.stdout == 'YES' %}✅{% else %}❌{% endif %}
- Traefik → Gitea (direct): {% if 'CONNECTION_FAILED' not in traefik_gitea_direct.stdout %}✅{% else %}❌{% endif %}
SSL/Certificate:
- Certificate in acme.json: {% if ssl_cert_exists.stdout == 'YES' %}✅{% else %}❌{% endif %}
Connectivity:
- HTTP (port 80): Status {{ gitea_http_test.status | default('TIMEOUT') }}
- HTTPS (port 443): Status {{ gitea_https_test.status | default('TIMEOUT') }}
Traefik SSL/ACME Errors:
{{ traefik_ssl_errors.stdout }}
================================================================================
- name: Restart Gitea if not in network or connection failed
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose restart gitea
register: gitea_restart
changed_when: gitea_restart.rc == 0
when: gitea_in_network.stdout != 'YES' or 'CONNECTION_FAILED' in traefik_gitea_direct.stdout
- name: Wait for Gitea to be ready after restart
ansible.builtin.pause:
seconds: 30
when: gitea_restart.changed | default(false)
- name: Restart Traefik to refresh service discovery and SSL
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose restart traefik
register: traefik_restart
changed_when: traefik_restart.rc == 0
when: >
(traefik_auto_restart | default(false) | bool)
and (gitea_restart.changed | default(false) or gitea_https_test.status | default(0) != 200)
- name: Wait for Traefik to be ready after restart
ansible.builtin.pause:
seconds: 15
when: traefik_restart.changed | default(false)
- name: Wait for Gitea to be reachable via HTTPS (with retries)
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: final_gitea_test
until: final_gitea_test.status == 200
retries: 20
delay: 3
changed_when: false
failed_when: false
when: traefik_restart.changed | default(false) or gitea_restart.changed | default(false)
- name: Final status check
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: final_status
changed_when: false
failed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
ZUSAMMENFASSUNG - Gitea SSL/Routing Fix:
================================================================================
Aktionen:
- Gitea Restart: {% if gitea_restart.changed | default(false) %}✅ Durchgeführt{% else %} Nicht nötig{% endif %}
- Traefik Restart: {% if traefik_restart.changed | default(false) %}✅ Durchgeführt{% else %} Nicht nötig{% endif %}
Final Status:
- Gitea via HTTPS: {% if final_status.status == 200 %}✅ Erreichbar{% else %}❌ Nicht erreichbar (Status: {{ final_status.status | default('TIMEOUT') }}){% endif %}
{% if final_status.status == 200 %}
✅ Gitea ist jetzt über Traefik erreichbar!
URL: {{ gitea_url }}
{% else %}
⚠️ Gitea ist noch nicht erreichbar
Mögliche Ursachen:
1. SSL-Zertifikat wird noch generiert (ACME Challenge läuft)
2. Traefik Service Discovery braucht mehr Zeit
3. Netzwerk-Problem zwischen Traefik und Gitea
Nächste Schritte:
1. Warte 2-5 Minuten und teste erneut: curl -k {{ gitea_url }}/api/healthz
2. Prüfe Traefik-Logs: cd {{ traefik_stack_path }} && docker compose logs traefik --tail=50
3. Prüfe Gitea-Logs: cd {{ gitea_stack_path }} && docker compose logs gitea --tail=50
4. Prüfe Netzwerk: docker network inspect traefik-public | grep -A 5 gitea
{% endif %}
================================================================================

View File

@@ -1,159 +0,0 @@
---
# Fix Gitea Timeouts
# Startet Gitea und Traefik neu, um Timeout-Probleme zu beheben
- name: Fix Gitea Timeouts
hosts: production
gather_facts: yes
become: no
tasks:
- name: Check Gitea container status before restart
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/gitea
docker compose ps gitea
register: gitea_status_before
changed_when: false
- name: Display Gitea status before restart
ansible.builtin.debug:
msg: |
================================================================================
Gitea Status (Before Restart):
================================================================================
{{ gitea_status_before.stdout }}
================================================================================
- name: Check Traefik container status before restart
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik
docker compose ps traefik
register: traefik_status_before
changed_when: false
- name: Display Traefik status before restart
ansible.builtin.debug:
msg: |
================================================================================
Traefik Status (Before Restart):
================================================================================
{{ traefik_status_before.stdout }}
================================================================================
- name: Restart Gitea container
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/gitea
docker compose restart gitea
register: gitea_restart
changed_when: gitea_restart.rc == 0
- name: Wait for Gitea to be ready
ansible.builtin.uri:
url: "https://git.michaelschiemer.de/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_health_after_restart
until: gitea_health_after_restart.status == 200
retries: 30
delay: 2
changed_when: false
failed_when: false
- name: Display Gitea health after restart
ansible.builtin.debug:
msg: |
================================================================================
Gitea Health After Restart:
================================================================================
{% if gitea_health_after_restart.status == 200 %}
✅ Gitea is healthy after restart
{% else %}
⚠️ Gitea health check failed after restart (Status: {{ gitea_health_after_restart.status | default('TIMEOUT') }})
{% endif %}
================================================================================
- name: Restart Traefik to refresh service discovery
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik
docker compose restart traefik
register: traefik_restart
changed_when: traefik_restart.rc == 0
when: traefik_auto_restart | default(false) | bool
- name: Wait for Traefik to be ready
ansible.builtin.wait_for:
timeout: 30
delay: 2
changed_when: false
when: traefik_restart.changed | default(false) | bool
- name: Wait for Gitea to be reachable via Traefik
ansible.builtin.uri:
url: "https://git.michaelschiemer.de/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_health_via_traefik
until: gitea_health_via_traefik.status == 200
retries: 30
delay: 2
changed_when: false
failed_when: false
when: (traefik_restart.changed | default(false) | bool) or (gitea_restart.changed | default(false) | bool)
- name: Check final Gitea container status
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/gitea
docker compose ps gitea
register: gitea_status_after
changed_when: false
- name: Check final Traefik container status
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik
docker compose ps traefik
register: traefik_status_after
changed_when: false
- name: Test Gitea access via Traefik
ansible.builtin.uri:
url: "https://git.michaelschiemer.de/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: final_gitea_test
changed_when: false
failed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
ZUSAMMENFASSUNG - Gitea Timeout Fix:
================================================================================
Gitea Restart: {% if gitea_restart.changed %}✅ Durchgeführt{% else %} Nicht nötig{% endif %}
Traefik Restart: {% if traefik_restart.changed %}✅ Durchgeführt{% else %} Nicht nötig{% endif %}
Final Status:
- Gitea: {{ gitea_status_after.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
- Traefik: {{ traefik_status_after.stdout | regex_replace('.*(Up|Down|Restarting).*', '\\1') | default('UNKNOWN') }}
- Gitea via Traefik: {% if final_gitea_test.status == 200 %}✅ Erreichbar{% else %}❌ Nicht erreichbar (Status: {{ final_gitea_test.status | default('TIMEOUT') }}){% endif %}
{% if final_gitea_test.status == 200 %}
✅ Gitea ist jetzt über Traefik erreichbar!
URL: https://git.michaelschiemer.de
{% else %}
⚠️ Gitea ist noch nicht über Traefik erreichbar
Nächste Schritte:
1. Prüfe Gitea-Logs: cd /home/deploy/deployment/stacks/gitea && docker compose logs gitea --tail=50
2. Prüfe Traefik-Logs: cd /home/deploy/deployment/stacks/traefik && docker compose logs traefik --tail=50
3. Prüfe Netzwerk: docker network inspect traefik-public | grep -A 5 gitea
4. Führe diagnose-gitea-timeouts.yml aus für detaillierte Diagnose
{% endif %}
================================================================================

View File

@@ -1,94 +0,0 @@
---
# Ansible Playbook: Fix Gitea-Traefik Connection Issues
# Purpose: Ensure Traefik can reliably reach Gitea by restarting both services
# Usage:
# ansible-playbook -i inventory/production.yml playbooks/fix-gitea-traefik-connection.yml \
# --vault-password-file secrets/.vault_pass
- name: Fix Gitea-Traefik Connection
hosts: production
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_url: "https://{{ gitea_domain }}"
tasks:
- name: Get current Gitea container IP
shell: |
docker inspect gitea | grep -A 10 'traefik-public' | grep IPAddress | head -1 | awk '{print $2}' | tr -d '",'
register: gitea_ip
changed_when: false
failed_when: false
- name: Display Gitea IP
debug:
msg: "Gitea container IP in traefik-public network: {{ gitea_ip.stdout }}"
- name: Test direct connection to Gitea from Traefik container
shell: |
docker compose -f {{ traefik_stack_path }}/docker-compose.yml exec -T traefik wget -qO- http://{{ gitea_ip.stdout }}:3000/api/healthz 2>&1 | head -3
register: traefik_gitea_test
changed_when: false
failed_when: false
- name: Display Traefik-Gitea connection test result
debug:
msg: "{{ traefik_gitea_test.stdout }}"
- name: Restart Gitea container to refresh IP
shell: |
docker compose -f {{ gitea_stack_path }}/docker-compose.yml restart gitea
when: traefik_gitea_test.rc != 0
- name: Wait for Gitea to be ready
uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_health
until: gitea_health.status == 200
retries: 30
delay: 2
changed_when: false
when: traefik_gitea_test.rc != 0
- name: Restart Traefik to refresh service discovery
shell: |
docker compose -f {{ traefik_stack_path }}/docker-compose.yml restart traefik
when: >
traefik_gitea_test.rc != 0
and (traefik_auto_restart | default(false) | bool)
register: traefik_restart
changed_when: traefik_restart.rc == 0
- name: Wait for Traefik to be ready
pause:
seconds: 10
when: traefik_restart.changed | default(false) | bool
- name: Test Gitea via Traefik
uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: final_test
changed_when: false
when: traefik_restart.changed | default(false) | bool
- name: Display result
debug:
msg: |
Gitea-Traefik connection test:
- Direct connection: {{ 'OK' if traefik_gitea_test.rc == 0 else 'FAILED' }}
- Via Traefik: {{ 'OK' if (final_test.status | default(0) == 200) else 'FAILED' if (traefik_restart.changed | default(false) | bool) else 'SKIPPED (no restart)' }}
{% if traefik_restart.changed | default(false) | bool %}
Traefik has been restarted to refresh service discovery.
{% elif traefik_gitea_test.rc != 0 %}
Note: Traefik restart was skipped (traefik_auto_restart=false). Direct connection test failed.
{% endif %}

View File

@@ -0,0 +1,198 @@
---
# Backup Before Redeploy
# Creates comprehensive backup of Gitea data, SSL certificates, and configurations
# before redeploying Traefik and Gitea stacks
- name: Backup Before Redeploy
hosts: production
gather_facts: yes
become: no
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
traefik_stack_path: "{{ stacks_base_path }}/traefik"
backup_base_path: "{{ backups_path | default('/home/deploy/backups') }}"
backup_name: "redeploy-backup-{{ ansible_date_time.epoch }}"
tasks:
- name: Display backup plan
ansible.builtin.debug:
msg: |
================================================================================
BACKUP BEFORE REDEPLOY
================================================================================
This playbook will backup:
1. Gitea data (volumes)
2. SSL certificates (acme.json)
3. Gitea configuration (app.ini)
4. Traefik configuration
5. PostgreSQL data (if applicable)
Backup location: {{ backup_base_path }}/{{ backup_name }}
================================================================================
- name: Ensure backup directory exists
ansible.builtin.file:
path: "{{ backup_base_path }}/{{ backup_name }}"
state: directory
mode: '0755'
become: yes
- name: Create backup timestamp file
ansible.builtin.copy:
content: |
Backup created: {{ ansible_date_time.iso8601 }}
Backup name: {{ backup_name }}
Purpose: Before Traefik/Gitea redeploy
dest: "{{ backup_base_path }}/{{ backup_name }}/backup-info.txt"
mode: '0644'
become: yes
# ========================================
# Backup Gitea Data
# ========================================
- name: Check Gitea volumes
ansible.builtin.shell: |
docker volume ls --filter name=gitea --format "{{ '{{' }}.Name{{ '}}' }}"
register: gitea_volumes
changed_when: false
failed_when: false
- name: Backup Gitea volumes
ansible.builtin.shell: |
for volume in {{ gitea_volumes.stdout_lines | join(' ') }}; do
if [ -n "$volume" ]; then
echo "Backing up volume: $volume"
docker run --rm \
-v "$volume:/source:ro" \
-v "{{ backup_base_path }}/{{ backup_name }}:/backup" \
alpine tar czf "/backup/gitea-volume-${volume}.tar.gz" -C /source .
fi
done
when: gitea_volumes.stdout_lines | length > 0
register: gitea_volumes_backup
changed_when: gitea_volumes_backup.rc == 0
# ========================================
# Backup SSL Certificates
# ========================================
- name: Check if acme.json exists
ansible.builtin.stat:
path: "{{ traefik_stack_path }}/acme.json"
register: acme_json_stat
- name: Backup acme.json
ansible.builtin.copy:
src: "{{ traefik_stack_path }}/acme.json"
dest: "{{ backup_base_path }}/{{ backup_name }}/acme.json"
remote_src: yes
mode: '0600'
when: acme_json_stat.stat.exists
register: acme_backup
changed_when: acme_backup.changed | default(false)
# ========================================
# Backup Gitea Configuration
# ========================================
- name: Backup Gitea app.ini
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T gitea cat /data/gitea/conf/app.ini > "{{ backup_base_path }}/{{ backup_name }}/gitea-app.ini" 2>/dev/null || echo "Could not read app.ini"
register: gitea_app_ini_backup
changed_when: false
failed_when: false
- name: Backup Gitea docker-compose.yml
ansible.builtin.copy:
src: "{{ gitea_stack_path }}/docker-compose.yml"
dest: "{{ backup_base_path }}/{{ backup_name }}/gitea-docker-compose.yml"
remote_src: yes
mode: '0644'
register: gitea_compose_backup
changed_when: gitea_compose_backup.changed | default(false)
# ========================================
# Backup Traefik Configuration
# ========================================
- name: Backup Traefik configuration files
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
tar czf "{{ backup_base_path }}/{{ backup_name }}/traefik-config.tar.gz" \
traefik.yml \
docker-compose.yml \
dynamic/ 2>/dev/null || echo "Some files may be missing"
register: traefik_config_backup
changed_when: traefik_config_backup.rc == 0
failed_when: false
# ========================================
# Backup PostgreSQL Data (if applicable)
# ========================================
- name: Check if PostgreSQL stack exists
ansible.builtin.stat:
path: "{{ stacks_base_path }}/postgresql/docker-compose.yml"
register: postgres_compose_exists
- name: Backup PostgreSQL database (if running)
ansible.builtin.shell: |
cd {{ stacks_base_path }}/postgresql
if docker compose ps postgres | grep -q "Up"; then
docker compose exec -T postgres pg_dumpall -U postgres | gzip > "{{ backup_base_path }}/{{ backup_name }}/postgresql-all-{{ ansible_date_time.epoch }}.sql.gz"
echo "PostgreSQL backup created"
else
echo "PostgreSQL not running, skipping backup"
fi
when: postgres_compose_exists.stat.exists
register: postgres_backup
changed_when: false
failed_when: false
# ========================================
# Verify Backup
# ========================================
- name: List backup contents
ansible.builtin.shell: |
ls -lh "{{ backup_base_path }}/{{ backup_name }}/"
register: backup_contents
changed_when: false
- name: Calculate backup size
ansible.builtin.shell: |
du -sh "{{ backup_base_path }}/{{ backup_name }}" | awk '{print $1}'
register: backup_size
changed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
BACKUP SUMMARY
================================================================================
Backup location: {{ backup_base_path }}/{{ backup_name }}
Backup size: {{ backup_size.stdout }}
Backed up:
- Gitea volumes: {% if gitea_volumes_backup.changed %}✅{% else %} No volumes found{% endif %}
- SSL certificates (acme.json): {% if acme_backup.changed | default(false) %}✅{% else %} Not found{% endif %}
- Gitea app.ini: {% if gitea_app_ini_backup.rc == 0 %}✅{% else %}⚠️ Could not read{% endif %}
- Gitea docker-compose.yml: {% if gitea_compose_backup.changed | default(false) %}✅{% else %} Not found{% endif %}
- Traefik configuration: {% if traefik_config_backup.rc == 0 %}✅{% else %}⚠️ Some files may be missing{% endif %}
- PostgreSQL data: {% if postgres_backup.rc == 0 and 'created' in postgres_backup.stdout %}✅{% else %} Not running or not found{% endif %}
Backup contents:
{{ backup_contents.stdout }}
================================================================================
NEXT STEPS
================================================================================
Backup completed successfully. You can now proceed with redeploy:
ansible-playbook -i inventory/production.yml playbooks/setup/redeploy-traefik-gitea-clean.yml \
--vault-password-file secrets/.vault_pass \
-e "backup_name={{ backup_name }}"
================================================================================

View File

@@ -0,0 +1,255 @@
---
# Rollback Redeploy
# Restores Traefik and Gitea from backup created before redeploy
#
# Usage:
# ansible-playbook -i inventory/production.yml playbooks/maintenance/rollback-redeploy.yml \
# --vault-password-file secrets/.vault_pass \
# -e "backup_name=redeploy-backup-1234567890"
- name: Rollback Redeploy
hosts: production
gather_facts: yes
become: no
vars:
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_stack_path: "{{ stacks_base_path }}/gitea"
backup_base_path: "{{ backups_path | default('/home/deploy/backups') }}"
backup_name: "{{ backup_name | default('') }}"
tasks:
- name: Validate backup name
ansible.builtin.fail:
msg: "backup_name is required. Use: -e 'backup_name=redeploy-backup-1234567890'"
when: backup_name == ""
- name: Check if backup directory exists
ansible.builtin.stat:
path: "{{ backup_base_path }}/{{ backup_name }}"
register: backup_dir_stat
- name: Fail if backup not found
ansible.builtin.fail:
msg: "Backup directory not found: {{ backup_base_path }}/{{ backup_name }}"
when: not backup_dir_stat.stat.exists
- name: Display rollback plan
ansible.builtin.debug:
msg: |
================================================================================
ROLLBACK REDEPLOY
================================================================================
This playbook will restore from backup: {{ backup_base_path }}/{{ backup_name }}
Steps:
1. Stop Traefik and Gitea stacks
2. Restore Gitea volumes
3. Restore SSL certificates (acme.json)
4. Restore Gitea configuration (app.ini)
5. Restore Traefik configuration
6. Restore PostgreSQL data (if applicable)
7. Restart stacks
8. Verify
⚠️ WARNING: This will overwrite current state!
================================================================================
# ========================================
# 1. STOP STACKS
# ========================================
- name: Stop Traefik stack
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose down
register: traefik_stop
changed_when: traefik_stop.rc == 0
failed_when: false
- name: Stop Gitea stack
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose down
register: gitea_stop
changed_when: gitea_stop.rc == 0
failed_when: false
# ========================================
# 2. RESTORE GITEA VOLUMES
# ========================================
- name: List Gitea volume backups
ansible.builtin.shell: |
ls -1 "{{ backup_base_path }}/{{ backup_name }}/gitea-volume-"*.tar.gz 2>/dev/null || echo ""
register: gitea_volume_backups
changed_when: false
- name: Restore Gitea volumes
ansible.builtin.shell: |
for backup_file in {{ backup_base_path }}/{{ backup_name }}/gitea-volume-*.tar.gz; do
if [ -f "$backup_file" ]; then
volume_name=$(basename "$backup_file" .tar.gz | sed 's/gitea-volume-//')
echo "Restoring volume: $volume_name"
docker volume create "$volume_name" 2>/dev/null || true
docker run --rm \
-v "$volume_name:/target" \
-v "{{ backup_base_path }}/{{ backup_name }}:/backup:ro" \
alpine sh -c "cd /target && tar xzf /backup/$(basename $backup_file)"
fi
done
when: gitea_volume_backups.stdout != ""
register: gitea_volumes_restore
changed_when: gitea_volumes_restore.rc == 0
# ========================================
# 3. RESTORE SSL CERTIFICATES
# ========================================
- name: Restore acme.json
ansible.builtin.copy:
src: "{{ backup_base_path }}/{{ backup_name }}/acme.json"
dest: "{{ traefik_stack_path }}/acme.json"
remote_src: yes
mode: '0600'
register: acme_restore
changed_when: acme_restore.rc == 0
# ========================================
# 4. RESTORE CONFIGURATIONS
# ========================================
- name: Restore Gitea docker-compose.yml
ansible.builtin.copy:
src: "{{ backup_base_path }}/{{ backup_name }}/gitea-docker-compose.yml"
dest: "{{ gitea_stack_path }}/docker-compose.yml"
remote_src: yes
mode: '0644'
register: gitea_compose_restore
changed_when: gitea_compose_restore.rc == 0
failed_when: false
- name: Restore Traefik configuration
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
tar xzf "{{ backup_base_path }}/{{ backup_name }}/traefik-config.tar.gz" 2>/dev/null || echo "Some files may be missing"
register: traefik_config_restore
changed_when: traefik_config_restore.rc == 0
failed_when: false
# ========================================
# 5. RESTORE POSTGRESQL DATA
# ========================================
- name: Find PostgreSQL backup
ansible.builtin.shell: |
ls -1 "{{ backup_base_path }}/{{ backup_name }}/postgresql-all-"*.sql.gz 2>/dev/null | head -1 || echo ""
register: postgres_backup_file
changed_when: false
- name: Restore PostgreSQL database
ansible.builtin.shell: |
cd {{ stacks_base_path }}/postgresql
if docker compose ps postgres | grep -q "Up"; then
gunzip -c "{{ postgres_backup_file.stdout }}" | docker compose exec -T postgres psql -U postgres
echo "PostgreSQL restored"
else
echo "PostgreSQL not running, skipping restore"
fi
when: postgres_backup_file.stdout != ""
register: postgres_restore
changed_when: false
failed_when: false
# ========================================
# 6. RESTART STACKS
# ========================================
- name: Deploy Traefik stack
community.docker.docker_compose_v2:
project_src: "{{ traefik_stack_path }}"
state: present
pull: always
register: traefik_deploy
- name: Wait for Traefik to be ready
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose ps traefik | grep -Eiq "Up|running"
register: traefik_ready
changed_when: false
until: traefik_ready.rc == 0
retries: 12
delay: 5
failed_when: traefik_ready.rc != 0
- name: Deploy Gitea stack
community.docker.docker_compose_v2:
project_src: "{{ gitea_stack_path }}"
state: present
pull: always
register: gitea_deploy
- name: Restore Gitea app.ini
ansible.builtin.shell: |
if [ -f "{{ backup_base_path }}/{{ backup_name }}/gitea-app.ini" ]; then
cd {{ gitea_stack_path }}
docker compose exec -T gitea sh -c "cat > /data/gitea/conf/app.ini" < "{{ backup_base_path }}/{{ backup_name }}/gitea-app.ini"
docker compose restart gitea
echo "app.ini restored and Gitea restarted"
else
echo "No app.ini backup found"
fi
register: gitea_app_ini_restore
changed_when: false
failed_when: false
- name: Wait for Gitea to be ready
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose ps gitea | grep -Eiq "Up|running"
register: gitea_ready
changed_when: false
until: gitea_ready.rc == 0
retries: 12
delay: 5
failed_when: gitea_ready.rc != 0
# ========================================
# 7. VERIFY
# ========================================
- name: Wait for Gitea to be healthy
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T gitea curl -f http://localhost:3000/api/healthz 2>&1 | grep -q "status.*pass" && echo "HEALTHY" || echo "NOT_HEALTHY"
register: gitea_health
changed_when: false
until: gitea_health.stdout == "HEALTHY"
retries: 30
delay: 2
failed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
ROLLBACK SUMMARY
================================================================================
Restored from backup: {{ backup_base_path }}/{{ backup_name }}
Restored:
- Gitea volumes: {% if gitea_volumes_restore.changed %}✅{% else %} No volumes to restore{% endif %}
- SSL certificates: {% if acme_restore.changed %}✅{% else %} Not found{% endif %}
- Gitea docker-compose.yml: {% if gitea_compose_restore.changed %}✅{% else %} Not found{% endif %}
- Traefik configuration: {% if traefik_config_restore.rc == 0 %}✅{% else %}⚠️ Some files may be missing{% endif %}
- PostgreSQL data: {% if postgres_restore.rc == 0 and 'restored' in postgres_restore.stdout %}✅{% else %} Not restored{% endif %}
- Gitea app.ini: {% if gitea_app_ini_restore.rc == 0 and 'restored' in gitea_app_ini_restore.stdout %}✅{% else %} Not found{% endif %}
Status:
- Traefik: {% if traefik_ready.rc == 0 %}✅ Running{% else %}❌ Not running{% endif %}
- Gitea: {% if gitea_ready.rc == 0 %}✅ Running{% else %}❌ Not running{% endif %}
- Gitea Health: {% if gitea_health.stdout == 'HEALTHY' %}✅ Healthy{% else %}❌ Not healthy{% endif %}
Next steps:
1. Test Gitea: curl -k https://{{ gitea_domain }}/api/healthz
2. Check logs if issues: cd {{ gitea_stack_path }} && docker compose logs gitea --tail=50
================================================================================

View File

@@ -0,0 +1,294 @@
---
# Consolidated Gitea Management Playbook
# Consolidates: fix-gitea-timeouts.yml, fix-gitea-traefik-connection.yml,
# fix-gitea-ssl-routing.yml, fix-gitea-servers-transport.yml,
# fix-gitea-complete.yml, restart-gitea-complete.yml,
# restart-gitea-with-cache.yml
#
# Usage:
# # Restart Gitea
# ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags restart
#
# # Fix timeouts (restart Gitea and Traefik)
# ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags fix-timeouts
#
# # Fix SSL/routing issues
# ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags fix-ssl
#
# # Complete fix (runner stop + restart + service discovery)
# ansible-playbook -i inventory/production.yml playbooks/manage/gitea.yml --tags complete
- name: Manage Gitea
hosts: production
gather_facts: yes
become: no
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_runner_path: "{{ stacks_base_path }}/../gitea-runner"
gitea_url: "https://{{ gitea_domain }}"
gitea_container_name: "gitea"
traefik_container_name: "traefik"
tasks:
- name: Display management plan
ansible.builtin.debug:
msg: |
================================================================================
GITEA MANAGEMENT
================================================================================
Running management tasks with tags: {{ ansible_run_tags | default(['all']) }}
Available actions:
- restart: Restart Gitea container
- fix-timeouts: Restart Gitea and Traefik to fix timeouts
- fix-ssl: Fix SSL/routing issues
- fix-servers-transport: Update ServersTransport configuration
- complete: Complete fix (stop runner, restart services, verify)
================================================================================
# ========================================
# COMPLETE FIX (--tags complete)
# ========================================
- name: Check Gitea Runner status
ansible.builtin.shell: |
cd {{ gitea_runner_path }}
docker compose ps gitea-runner 2>/dev/null || echo "Runner not found"
register: runner_status
changed_when: false
failed_when: false
tags:
- complete
- name: Stop Gitea Runner to reduce load
ansible.builtin.shell: |
cd {{ gitea_runner_path }}
docker compose stop gitea-runner
register: runner_stop
changed_when: runner_stop.rc == 0
failed_when: false
when: runner_status.rc == 0
tags:
- complete
# ========================================
# RESTART GITEA (--tags restart, fix-timeouts, complete)
# ========================================
- name: Check Gitea container status before restart
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose ps {{ gitea_container_name }}
register: gitea_status_before
changed_when: false
tags:
- restart
- fix-timeouts
- complete
- name: Restart Gitea container
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose restart {{ gitea_container_name }}
register: gitea_restart
changed_when: gitea_restart.rc == 0
tags:
- restart
- fix-timeouts
- complete
- name: Wait for Gitea to be ready (direct check)
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
for i in {1..30}; do
if docker compose exec -T {{ gitea_container_name }} curl -f http://localhost:3000/api/healthz >/dev/null 2>&1; then
echo "Gitea is ready"
exit 0
fi
sleep 2
done
echo "Gitea not ready after 60 seconds"
exit 1
register: gitea_ready
changed_when: false
failed_when: false
tags:
- restart
- fix-timeouts
- complete
# ========================================
# RESTART TRAEFIK (--tags fix-timeouts, complete)
# ========================================
- name: Check Traefik container status before restart
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose ps {{ traefik_container_name }}
register: traefik_status_before
changed_when: false
tags:
- fix-timeouts
- complete
- name: Restart Traefik to refresh service discovery
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose restart {{ traefik_container_name }}
register: traefik_restart
changed_when: traefik_restart.rc == 0
when: traefik_auto_restart | default(false) | bool
tags:
- fix-timeouts
- complete
- name: Wait for Traefik to be ready
ansible.builtin.wait_for:
timeout: 30
delay: 2
changed_when: false
when: traefik_restart.changed | default(false) | bool
tags:
- fix-timeouts
- complete
# ========================================
# FIX SERVERS TRANSPORT (--tags fix-servers-transport)
# ========================================
- name: Sync Gitea stack configuration
ansible.builtin.synchronize:
src: "{{ playbook_dir }}/../../stacks/gitea/"
dest: "{{ gitea_stack_path }}/"
delete: no
recursive: yes
rsync_opts:
- "--chmod=D755,F644"
- "--exclude=.git"
- "--exclude=*.log"
- "--exclude=data/"
- "--exclude=volumes/"
tags:
- fix-servers-transport
- name: Restart Gitea container to apply new labels
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose up -d --force-recreate {{ gitea_container_name }}
register: gitea_restart_transport
changed_when: gitea_restart_transport.rc == 0
tags:
- fix-servers-transport
# ========================================
# VERIFICATION (--tags fix-timeouts, fix-ssl, complete)
# ========================================
- name: Wait for Gitea to be reachable via Traefik (with retries)
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_health_via_traefik
until: gitea_health_via_traefik.status == 200
retries: 15
delay: 2
changed_when: false
failed_when: false
when: (traefik_restart.changed | default(false) | bool) or (gitea_restart.changed | default(false) | bool)
tags:
- fix-timeouts
- fix-ssl
- complete
- name: Check if Gitea is in Traefik service discovery
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose exec -T {{ traefik_container_name }} traefik show providers docker 2>/dev/null | grep -i "gitea" || echo "NOT_FOUND"
register: traefik_gitea_service_check
changed_when: false
failed_when: false
when: (traefik_restart.changed | default(false) | bool) or (gitea_restart.changed | default(false) | bool)
tags:
- fix-timeouts
- fix-ssl
- complete
- name: Final status check
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: final_status
changed_when: false
failed_when: false
tags:
- fix-timeouts
- fix-ssl
- complete
# ========================================
# SUMMARY
# ========================================
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
GITEA MANAGEMENT SUMMARY
================================================================================
Actions performed:
{% if 'complete' in ansible_run_tags %}
- Gitea Runner: {% if runner_stop.changed | default(false) %}✅ Stopped{% else %} Not active or not found{% endif %}
{% endif %}
{% if 'restart' in ansible_run_tags or 'fix-timeouts' in ansible_run_tags or 'complete' in ansible_run_tags %}
- Gitea Restart: {% if gitea_restart.changed %}✅ Performed{% else %} Not needed{% endif %}
- Gitea Ready: {% if gitea_ready.rc == 0 %}✅ Ready{% else %}❌ Not ready{% endif %}
{% endif %}
{% if 'fix-timeouts' in ansible_run_tags or 'complete' in ansible_run_tags %}
- Traefik Restart: {% if traefik_restart.changed %}✅ Performed{% else %} Not needed (traefik_auto_restart=false){% endif %}
{% endif %}
{% if 'fix-servers-transport' in ansible_run_tags %}
- ServersTransport Update: {% if gitea_restart_transport.changed %}✅ Applied{% else %} Not needed{% endif %}
{% endif %}
Final Status:
{% if 'fix-timeouts' in ansible_run_tags or 'fix-ssl' in ansible_run_tags or 'complete' in ansible_run_tags %}
- Gitea via Traefik: {% if final_status.status == 200 %}✅ Reachable (Status: 200){% else %}❌ Not reachable (Status: {{ final_status.status | default('TIMEOUT') }}){% endif %}
- Traefik Service Discovery: {% if 'NOT_FOUND' not in traefik_gitea_service_check.stdout %}✅ Gitea found{% else %}❌ Gitea not found{% endif %}
{% endif %}
{% if final_status.status == 200 and 'NOT_FOUND' not in traefik_gitea_service_check.stdout %}
✅ SUCCESS: Gitea is now reachable via Traefik!
URL: {{ gitea_url }}
Next steps:
1. Test Gitea in browser: {{ gitea_url }}
{% if 'complete' in ansible_run_tags %}
2. If everything is stable, you can reactivate the runner:
cd {{ gitea_runner_path }} && docker compose up -d gitea-runner
3. Monitor if the runner overloads Gitea again
{% endif %}
{% else %}
⚠️ PROBLEM: Gitea is not fully reachable
Possible causes:
{% if final_status.status != 200 %}
- Gitea does not respond via Traefik (Status: {{ final_status.status | default('TIMEOUT') }})
{% endif %}
{% if 'NOT_FOUND' in traefik_gitea_service_check.stdout %}
- Traefik Service Discovery has not recognized Gitea yet
{% endif %}
Next steps:
1. Wait 1-2 minutes and test again: curl -k {{ gitea_url }}/api/healthz
2. Check Traefik logs: cd {{ traefik_stack_path }} && docker compose logs {{ traefik_container_name }} --tail=50
3. Check Gitea logs: cd {{ gitea_stack_path }} && docker compose logs {{ gitea_container_name }} --tail=50
4. Run diagnosis: ansible-playbook -i inventory/production.yml playbooks/diagnose/gitea.yml
{% endif %}
================================================================================

View File

@@ -0,0 +1,162 @@
---
# Consolidated Traefik Management Playbook
# Consolidates: stabilize-traefik.yml, disable-traefik-auto-restarts.yml
#
# Usage:
# # Stabilize Traefik (fix acme.json, ensure running, monitor)
# ansible-playbook -i inventory/production.yml playbooks/manage/traefik.yml --tags stabilize
#
# # Disable auto-restarts
# ansible-playbook -i inventory/production.yml playbooks/manage/traefik.yml --tags disable-auto-restart
- name: Manage Traefik
hosts: production
gather_facts: yes
become: no
vars:
traefik_stack_path: "{{ stacks_base_path }}/traefik"
traefik_container_name: "traefik"
traefik_stabilize_wait_minutes: "{{ traefik_stabilize_wait_minutes | default(10) }}"
traefik_stabilize_check_interval: 60
tasks:
- name: Display management plan
ansible.builtin.debug:
msg: |
================================================================================
TRAEFIK MANAGEMENT
================================================================================
Running management tasks with tags: {{ ansible_run_tags | default(['all']) }}
Available actions:
- stabilize: Fix acme.json, ensure running, monitor stability
- disable-auto-restart: Check and document auto-restart mechanisms
================================================================================
# ========================================
# STABILIZE (--tags stabilize)
# ========================================
- name: Fix acme.json permissions
ansible.builtin.file:
path: "{{ traefik_stack_path }}/acme.json"
state: file
mode: '0600'
owner: "{{ ansible_user | default('deploy') }}"
group: "{{ ansible_user | default('deploy') }}"
register: acme_permissions_fixed
tags:
- stabilize
- name: Ensure Traefik container is running
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose up -d {{ traefik_container_name }}
register: traefik_start
changed_when: traefik_start.rc == 0
tags:
- stabilize
- name: Wait for Traefik to be ready
ansible.builtin.wait_for:
timeout: 30
delay: 2
changed_when: false
tags:
- stabilize
- name: Monitor Traefik stability
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose ps {{ traefik_container_name }} --format "{{ '{{' }}.State{{ '}}' }}" | head -1 || echo "UNKNOWN"
register: traefik_state_check
changed_when: false
until: traefik_state_check.stdout == "running"
retries: "{{ (traefik_stabilize_wait_minutes | int * 60 / traefik_stabilize_check_interval) | int }}"
delay: "{{ traefik_stabilize_check_interval }}"
tags:
- stabilize
- name: Check Traefik logs for restarts during monitoring
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs {{ traefik_container_name }} --since "{{ traefik_stabilize_wait_minutes }}m" 2>&1 | grep -iE "stopping server gracefully|I have to go" | wc -l
register: restarts_during_monitoring
changed_when: false
tags:
- stabilize
# ========================================
# DISABLE AUTO-RESTART (--tags disable-auto-restart)
# ========================================
- name: Check Ansible traefik_auto_restart setting
ansible.builtin.shell: |
grep -r "traefik_auto_restart" /home/deploy/deployment/ansible/inventory/group_vars/ 2>/dev/null | head -5 || echo "No traefik_auto_restart setting found"
register: ansible_auto_restart_setting
changed_when: false
tags:
- disable-auto-restart
- name: Check for cronjobs that restart Traefik
ansible.builtin.shell: |
(crontab -l 2>/dev/null || true) | grep -E "traefik|docker.*compose.*restart.*traefik|docker.*stop.*traefik" || echo "No cronjobs found"
register: traefik_cronjobs
changed_when: false
tags:
- disable-auto-restart
- name: Check systemd timers for Traefik
ansible.builtin.shell: |
systemctl list-timers --all --no-pager | grep -E "traefik|docker.*compose.*traefik" || echo "No Traefik-related timers"
register: traefik_timers
changed_when: false
tags:
- disable-auto-restart
# ========================================
# SUMMARY
# ========================================
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
TRAEFIK MANAGEMENT SUMMARY
================================================================================
{% if 'stabilize' in ansible_run_tags %}
Stabilization:
- acme.json permissions: {% if acme_permissions_fixed.changed %}✅ Fixed{% else %} Already correct{% endif %}
- Traefik started: {% if traefik_start.changed %}✅ Started{% else %} Already running{% endif %}
- Stability monitoring: {{ traefik_stabilize_wait_minutes }} minutes
- Restarts during monitoring: {{ restarts_during_monitoring.stdout | default('0') }}
{% if (restarts_during_monitoring.stdout | default('0') | int) == 0 %}
✅ Traefik ran stable during monitoring period!
{% else %}
⚠️ {{ restarts_during_monitoring.stdout }} restarts detected during monitoring
→ Run diagnosis: ansible-playbook -i inventory/production.yml playbooks/diagnose/traefik.yml --tags restart-source
{% endif %}
{% endif %}
{% if 'disable-auto-restart' in ansible_run_tags %}
Auto-Restart Analysis:
- Ansible setting: {{ ansible_auto_restart_setting.stdout | default('Not found') }}
- Cronjobs: {{ traefik_cronjobs.stdout | default('None found') }}
- Systemd timers: {{ traefik_timers.stdout | default('None found') }}
Recommendations:
{% if 'traefik_auto_restart.*true' in ansible_auto_restart_setting.stdout %}
- Set traefik_auto_restart: false in group_vars
{% endif %}
{% if 'No cronjobs' not in traefik_cronjobs.stdout %}
- Remove or disable cronjobs that restart Traefik
{% endif %}
{% if 'No Traefik-related timers' not in traefik_timers.stdout %}
- Disable systemd timers that restart Traefik
{% endif %}
{% endif %}
================================================================================

View File

@@ -1,141 +0,0 @@
---
# Monitor Traefik Continuously
# Überwacht Traefik-Logs und Docker Events in Echtzeit um Restart-Quelle zu finden
- name: Monitor Traefik Continuously
hosts: production
gather_facts: yes
become: no
vars:
traefik_stack_path: "{{ stacks_base_path }}/traefik"
monitor_duration_minutes: 30 # Standard: 30 Minuten, kann überschrieben werden
tasks:
- name: Display monitoring information
ansible.builtin.debug:
msg: |
================================================================================
TRAEFIK CONTINUOUS MONITORING
================================================================================
Überwachungsdauer: {{ monitor_duration_minutes }} Minuten
Überwacht:
1. Traefik-Logs auf "Stopping server gracefully" / "I have to go"
2. Docker Events für Traefik-Container
3. Docker Daemon Logs für Container-Stops
Starte Monitoring...
================================================================================
- name: Get initial Traefik status
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.State.Status{{ '}}' }} {{ '{{' }}.State.StartedAt{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: initial_status
changed_when: false
- name: Start monitoring Traefik logs in background
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
timeout {{ monitor_duration_minutes * 60 }} docker compose logs -f traefik 2>&1 | grep --line-buffered -iE "stopping server gracefully|I have to go" | while read line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $line"
done > /tmp/traefik_monitor_$$.log 2>&1 &
echo $!
register: log_monitor_pid
changed_when: false
async: "{{ monitor_duration_minutes * 60 + 60 }}"
poll: 0
- name: Start monitoring Docker events in background
ansible.builtin.shell: |
timeout {{ monitor_duration_minutes * 60 }} docker events --filter container=traefik --filter event=die --format "[{{ '{{' }}.Time{{ '}}' }}] {{ '{{' }}.Action{{ '}}' }} {{ '{{' }}.Actor.Attributes.name{{ '}}' }}" 2>&1 | tee /tmp/traefik_docker_events_$$.log &
echo $!
register: docker_events_pid
changed_when: false
async: "{{ monitor_duration_minutes * 60 + 60 }}"
poll: 0
- name: Wait for monitoring period
ansible.builtin.pause:
minutes: "{{ monitor_duration_minutes }}"
- name: Stop log monitoring
ansible.builtin.shell: |
pkill -f "docker compose logs.*traefik" || true
sleep 2
changed_when: false
failed_when: false
- name: Stop Docker events monitoring
ansible.builtin.shell: |
pkill -f "docker events.*traefik" || true
sleep 2
changed_when: false
failed_when: false
- name: Read Traefik log monitoring results
ansible.builtin.slurp:
src: "{{ item }}"
register: log_results
changed_when: false
failed_when: false
loop: "{{ log_monitor_pid.stdout_lines | map('regex_replace', '^.*', '/tmp/traefik_monitor_' + ansible_date_time.epoch + '.log') | list }}"
- name: Read Docker events monitoring results
ansible.builtin.slurp:
src: "{{ item }}"
register: docker_events_results
changed_when: false
failed_when: false
loop: "{{ docker_events_pid.stdout_lines | map('regex_replace', '^.*', '/tmp/traefik_docker_events_' + ansible_date_time.epoch + '.log') | list }}"
- name: Get final Traefik status
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.State.Status{{ '}}' }} {{ '{{' }}.State.StartedAt{{ '}}' }} {{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: final_status
changed_when: false
- name: Check Traefik logs for stop messages during monitoring
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs traefik --since {{ monitor_duration_minutes }}m 2>&1 | grep -iE "stopping server gracefully|I have to go" || echo "Keine Stop-Meldungen gefunden"
register: traefik_stop_messages
changed_when: false
failed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
MONITORING ZUSAMMENFASSUNG ({{ monitor_duration_minutes }} Minuten):
================================================================================
Initial Status: {{ initial_status.stdout }}
Final Status: {{ final_status.stdout }}
Traefik Stop-Meldungen während Monitoring:
{% if traefik_stop_messages.stdout and 'Keine Stop-Meldungen' not in traefik_stop_messages.stdout %}
❌ STOP-MELDUNGEN GEFUNDEN:
{{ traefik_stop_messages.stdout }}
⚠️ PROBLEM BESTÄTIGT: Traefik wurde während des Monitorings gestoppt!
Nächste Schritte:
1. Prüfe Docker Events Log: /tmp/traefik_docker_events_*.log
2. Prüfe Traefik Log Monitor: /tmp/traefik_monitor_*.log
3. Prüfe wer den Stop-Befehl ausgeführt hat:
- journalctl -u docker.service --since "{{ monitor_duration_minutes }} minutes ago"
- docker events --since "{{ monitor_duration_minutes }} minutes ago" --filter container=traefik
{% else %}
✅ KEINE STOP-MELDUNGEN GEFUNDEN
Traefik lief stabil während des {{ monitor_duration_minutes }}-minütigen Monitorings.
{% if initial_status.stdout != final_status.stdout %}
⚠️ Status hat sich geändert:
- Vorher: {{ initial_status.stdout }}
- Nachher: {{ final_status.stdout }}
{% endif %}
{% endif %}
================================================================================

View File

@@ -1,150 +0,0 @@
---
# Monitor Traefik for Unexpected Restarts
# Überwacht Traefik-Logs auf "I have to go..." Meldungen und identifiziert die Ursache
- name: Monitor Traefik Restarts
hosts: production
gather_facts: yes
become: no
vars:
monitor_lookback_hours: "{{ monitor_lookback_hours | default(24) }}"
tasks:
- name: Check Traefik logs for "I have to go..." messages
ansible.builtin.shell: |
cd /home/deploy/deployment/stacks/traefik
docker compose logs traefik --since {{ monitor_lookback_hours }}h 2>&1 | grep -E "I have to go|Stopping server gracefully" | tail -20 || echo "No stop messages found"
register: traefik_stop_messages
changed_when: false
- name: Display Traefik stop messages
ansible.builtin.debug:
msg: |
================================================================================
Traefik Stop-Meldungen (letzte {{ monitor_lookback_hours }} Stunden):
================================================================================
{{ traefik_stop_messages.stdout }}
================================================================================
- name: Check Traefik container restart count
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "0"
register: traefik_restart_count
changed_when: false
- name: Check Traefik container start time
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.State.StartedAt{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: traefik_started_at
changed_when: false
- name: Check Docker events for Traefik stops
ansible.builtin.shell: |
timeout 5 docker events --since {{ monitor_lookback_hours }}h --filter container=traefik --filter event=die --format "{{ '{{' }}.Time{{ '}}' }} {{ '{{' }}.Action{{ '}}' }} {{ '{{' }}.Actor.Attributes.name{{ '}}' }}" 2>/dev/null | tail -20 || echo "No stop events found or docker events not available"
register: traefik_stop_events
changed_when: false
- name: Display Traefik stop events
ansible.builtin.debug:
msg: |
================================================================================
Docker Stop-Events für Traefik (letzte {{ monitor_lookback_hours }} Stunden):
================================================================================
{{ traefik_stop_events.stdout }}
================================================================================
- name: Check for manual docker compose commands in history
ansible.builtin.shell: |
history | grep -E "docker.*compose.*traefik.*(restart|stop|down|up)" | tail -10 || echo "No manual docker compose commands found in history"
register: manual_commands
changed_when: false
failed_when: false
- name: Display manual docker compose commands
ansible.builtin.debug:
msg: |
================================================================================
Manuelle Docker Compose Befehle (aus History):
================================================================================
{{ manual_commands.stdout }}
================================================================================
- name: Check systemd docker service status
ansible.builtin.shell: |
systemctl status docker.service --no-pager -l | head -20 || echo "Could not check docker service status"
register: docker_service_status
changed_when: false
failed_when: false
- name: Display Docker service status
ansible.builtin.debug:
msg: |
================================================================================
Docker Service Status:
================================================================================
{{ docker_service_status.stdout }}
================================================================================
- name: Check for system reboots
ansible.builtin.shell: |
last reboot --since "{{ monitor_lookback_hours }} hours ago" 2>/dev/null | head -5 || echo "No reboots in the last {{ monitor_lookback_hours }} hours"
register: reboots
changed_when: false
failed_when: false
- name: Display reboot history
ansible.builtin.debug:
msg: |
================================================================================
System Reboots (letzte {{ monitor_lookback_hours }} Stunden):
================================================================================
{{ reboots.stdout }}
================================================================================
- name: Analyze stop message timestamps
ansible.builtin.set_fact:
stop_timestamps: "{{ traefik_stop_messages.stdout | regex_findall('\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}') }}"
- name: Count stop messages
ansible.builtin.set_fact:
stop_count: "{{ stop_timestamps | length | int }}"
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
ZUSAMMENFASSUNG - Traefik Restart Monitoring:
================================================================================
Überwachungszeitraum: Letzte {{ monitor_lookback_hours }} Stunden
Traefik Status:
- Restart Count: {{ traefik_restart_count.stdout }}
- Gestartet um: {{ traefik_started_at.stdout }}
- Stop-Meldungen gefunden: {{ stop_count | default(0) }}
{% if (stop_count | default(0) | int) > 0 %}
⚠️ {{ stop_count }} Stop-Meldungen gefunden:
{{ traefik_stop_messages.stdout }}
Mögliche Ursachen:
{% if reboots.stdout and 'No reboots' not in reboots.stdout %}
1. System-Reboots: {{ reboots.stdout }}
{% endif %}
{% if traefik_stop_events.stdout and 'No stop events' not in traefik_stop_events.stdout %}
2. Docker Stop-Events: {{ traefik_stop_events.stdout }}
{% endif %}
{% if manual_commands.stdout and 'No manual' not in manual_commands.stdout %}
3. Manuelle Befehle: {{ manual_commands.stdout }}
{% endif %}
Nächste Schritte:
- Prüfe ob die Stop-Meldungen mit unseren manuellen Restarts übereinstimmen
- Prüfe ob System-Reboots die Ursache sind
- Prüfe Docker-Service-Logs für automatische Stops
{% else %}
✅ Keine Stop-Meldungen in den letzten {{ monitor_lookback_hours }} Stunden
Traefik läuft stabil!
{% endif %}
================================================================================

View File

@@ -1,95 +0,0 @@
---
# Restart Gitea Complete - Stoppt und startet Gitea neu um alle Konfigurationsänderungen zu übernehmen
- name: Restart Gitea Complete
hosts: production
gather_facts: no
become: no
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
gitea_url: "https://{{ gitea_domain }}"
tasks:
- name: Check current Gitea environment variables
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T gitea env | grep -E 'GITEA__database__' | sort || echo "Could not read environment variables"
register: gitea_env_before
changed_when: false
failed_when: false
- name: Display current environment variables
ansible.builtin.debug:
msg: |
Current Gitea Database Environment Variables:
{{ gitea_env_before.stdout }}
- name: Stop Gitea container completely
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose stop gitea
register: gitea_stop
changed_when: gitea_stop.rc == 0
- name: Wait for Gitea to stop
ansible.builtin.pause:
seconds: 5
- name: Start Gitea container
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose up -d gitea
register: gitea_start
changed_when: gitea_start.rc == 0
- name: Wait for Gitea to be ready
ansible.builtin.wait_for:
timeout: 60
delay: 5
- name: Check Gitea health after restart
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
validate_certs: false
timeout: 10
register: gitea_health_after
changed_when: false
failed_when: false
retries: 5
delay: 5
- name: Check environment variables after restart
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T gitea env | grep -E 'GITEA__database__' | sort || echo "Could not read environment variables"
register: gitea_env_after
changed_when: false
failed_when: false
- name: Display restart results
ansible.builtin.debug:
msg: |
================================================================================
GITEA COMPLETE RESTART - RESULTS
================================================================================
Gitea Health After Restart:
- Status: {{ gitea_health_after.status | default('TIMEOUT') }}
{% if gitea_health_after.status | default(0) == 200 %}
✅ Gitea is healthy after restart
{% else %}
❌ Gitea health check failed (Status: {{ gitea_health_after.status | default('TIMEOUT') }})
{% endif %}
Environment Variables After Restart:
{{ gitea_env_after.stdout }}
{% if 'MAX_OPEN_CONNS' in gitea_env_after.stdout %}
✅ Connection pool settings are present
{% else %}
⚠️ Connection pool settings NOT found in environment variables
→ Check docker-compose.yml configuration
{% endif %}
================================================================================

View File

@@ -1,57 +0,0 @@
---
# Ansible Playbook: Restart Gitea with Redis Cache Enabled
# Purpose: Restart Gitea container to apply new cache configuration from docker-compose.yml
# Usage:
# ansible-playbook -i inventory/production.yml playbooks/restart-gitea-with-cache.yml
- name: Restart Gitea with Redis Cache Enabled
hosts: production
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
gitea_url: "https://{{ gitea_domain }}"
tasks:
- name: Verify Gitea container exists
shell: |
docker compose -f {{ gitea_stack_path }}/docker-compose.yml ps gitea | grep -q "gitea"
register: gitea_exists
changed_when: false
failed_when: false
- name: Fail if Gitea container does not exist
fail:
msg: "Gitea container does not exist. Please deploy Gitea stack first."
when: gitea_exists.rc != 0
- name: Recreate Gitea container with new cache configuration
shell: |
cd {{ gitea_stack_path }} && \
docker compose up -d --force-recreate gitea
register: gitea_recreated
- name: Wait for Gitea to be ready after restart
uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_health_after_restart
until: gitea_health_after_restart.status == 200
retries: 30
delay: 5
changed_when: false
- name: Display success message
debug:
msg: |
Gitea has been restarted successfully with Redis cache enabled!
Cache configuration:
- ENABLED: true
- ADAPTER: redis
- HOST: redis:6379
- DB: 0
Gitea should now use Redis for caching, improving performance.

View File

@@ -0,0 +1,210 @@
# Traefik/Gitea Redeploy Guide
This guide explains how to perform a clean redeployment of Traefik and Gitea stacks.
## Overview
A clean redeploy:
- Stops and removes containers (preserves volumes and SSL certificates)
- Syncs latest configurations
- Redeploys stacks with fresh containers
- Restores configurations
- Verifies service discovery
**Expected downtime**: ~2-5 minutes
## Prerequisites
- Ansible installed locally
- SSH access to production server
- Vault password file: `deployment/ansible/secrets/.vault_pass`
## Step-by-Step Guide
### Step 1: Backup
**Automatic backup (recommended):**
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/maintenance/backup-before-redeploy.yml \
--vault-password-file secrets/.vault_pass
```
**Manual backup:**
```bash
# On server
cd /home/deploy/deployment/stacks
docker compose -f gitea/docker-compose.yml exec gitea cat /data/gitea/conf/app.ini > /tmp/gitea-app.ini.backup
cp traefik/acme.json /tmp/acme.json.backup
```
### Step 2: Verify Backup
Check backup contents:
```bash
# Backup location will be shown in output
ls -lh /home/deploy/backups/redeploy-backup-*/
```
Verify:
- `acme.json` exists
- `gitea-app.ini` exists
- `gitea-volume-*.tar.gz` exists (if volumes were backed up)
### Step 3: Redeploy
**With automatic backup:**
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/setup/redeploy-traefik-gitea-clean.yml \
--vault-password-file secrets/.vault_pass
```
**With existing backup:**
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/setup/redeploy-traefik-gitea-clean.yml \
--vault-password-file secrets/.vault_pass \
-e "backup_name=redeploy-backup-1234567890" \
-e "skip_backup=true"
```
### Step 4: Verify Deployment
**Check Gitea accessibility:**
```bash
curl -k https://git.michaelschiemer.de/api/healthz
```
**Check Traefik service discovery:**
```bash
# On server
cd /home/deploy/deployment/stacks/traefik
docker compose exec traefik traefik show providers docker | grep -i gitea
```
**Check container status:**
```bash
# On server
docker ps | grep -E "traefik|gitea"
```
### Step 5: Troubleshooting
**If Gitea is not reachable:**
1. Check Gitea logs:
```bash
cd /home/deploy/deployment/stacks/gitea
docker compose logs gitea --tail=50
```
2. Check Traefik logs:
```bash
cd /home/deploy/deployment/stacks/traefik
docker compose logs traefik --tail=50
```
3. Check service discovery:
```bash
cd /home/deploy/deployment/stacks/traefik
docker compose exec traefik traefik show providers docker
```
4. Run diagnosis:
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/diagnose/gitea.yml \
--vault-password-file secrets/.vault_pass
```
**If SSL certificate issues:**
1. Check acme.json permissions:
```bash
ls -l /home/deploy/deployment/stacks/traefik/acme.json
# Should be: -rw------- (600)
```
2. Check Traefik ACME logs:
```bash
cd /home/deploy/deployment/stacks/traefik
docker compose logs traefik | grep -i acme
```
## Rollback Procedure
If something goes wrong, rollback to the backup:
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/maintenance/rollback-redeploy.yml \
--vault-password-file secrets/.vault_pass \
-e "backup_name=redeploy-backup-1234567890"
```
Replace `redeploy-backup-1234567890` with the actual backup name from Step 1.
## What Gets Preserved
- ✅ Gitea data (volumes)
- ✅ SSL certificates (acme.json)
- ✅ Gitea configuration (app.ini)
- ✅ Traefik configuration
- ✅ PostgreSQL data (if applicable)
## What Gets Recreated
- 🔄 Traefik container
- 🔄 Gitea container
- 🔄 Service discovery
## Common Issues
### Issue: Gitea returns 404 after redeploy
**Solution:**
1. Wait 1-2 minutes for service discovery
2. Restart Traefik: `cd /home/deploy/deployment/stacks/traefik && docker compose restart traefik`
3. Check if Gitea is in traefik-public network: `docker network inspect traefik-public | grep gitea`
### Issue: SSL certificate errors
**Solution:**
1. Verify acme.json permissions: `chmod 600 /home/deploy/deployment/stacks/traefik/acme.json`
2. Check Traefik logs for ACME errors
3. Wait 5-10 minutes for certificate renewal
### Issue: Gitea configuration lost
**Solution:**
1. Restore from backup: `playbooks/maintenance/rollback-redeploy.yml`
2. Or manually restore app.ini:
```bash
cd /home/deploy/deployment/stacks/gitea
docker compose exec gitea sh -c "cat > /data/gitea/conf/app.ini" < /path/to/backup/gitea-app.ini
docker compose restart gitea
```
## Best Practices
1. **Always backup before redeploy** - Use automatic backup
2. **Test in staging first** - If available
3. **Monitor during deployment** - Watch logs in separate terminal
4. **Have rollback ready** - Know backup name before starting
5. **Verify after deployment** - Check all services are accessible
## Related Playbooks
- `playbooks/maintenance/backup-before-redeploy.yml` - Create backup
- `playbooks/setup/redeploy-traefik-gitea-clean.yml` - Perform redeploy
- `playbooks/maintenance/rollback-redeploy.yml` - Rollback from backup
- `playbooks/diagnose/gitea.yml` - Diagnose Gitea issues
- `playbooks/diagnose/traefik.yml` - Diagnose Traefik issues

View File

@@ -0,0 +1,321 @@
---
# Clean Redeploy Traefik and Gitea Stacks
# Complete redeployment with backup, container recreation, and verification
#
# Usage:
# # With automatic backup
# ansible-playbook -i inventory/production.yml playbooks/setup/redeploy-traefik-gitea-clean.yml \
# --vault-password-file secrets/.vault_pass
#
# # With existing backup
# ansible-playbook -i inventory/production.yml playbooks/setup/redeploy-traefik-gitea-clean.yml \
# --vault-password-file secrets/.vault_pass \
# -e "backup_name=redeploy-backup-1234567890" \
# -e "skip_backup=true"
- name: Clean Redeploy Traefik and Gitea
hosts: production
gather_facts: yes
become: no
vars:
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_stack_path: "{{ stacks_base_path }}/gitea"
gitea_url: "https://{{ gitea_domain }}"
traefik_container_name: "traefik"
gitea_container_name: "gitea"
backup_base_path: "{{ backups_path | default('/home/deploy/backups') }}"
skip_backup: "{{ skip_backup | default(false) | bool }}"
backup_name: "{{ backup_name | default('') }}"
tasks:
# ========================================
# 1. BACKUP (unless skipped)
# ========================================
- name: Set backup name fact
ansible.builtin.set_fact:
actual_backup_name: "{{ backup_name | default('redeploy-backup-' + ansible_date_time.epoch) }}"
when: not skip_backup
- name: Display backup note
ansible.builtin.debug:
msg: |
⚠️ NOTE: Backup should be run separately before redeploy:
ansible-playbook -i inventory/production.yml playbooks/maintenance/backup-before-redeploy.yml \
--vault-password-file secrets/.vault_pass \
-e "backup_name={{ actual_backup_name }}"
Or use existing backup with: -e "backup_name=redeploy-backup-XXXXX" -e "skip_backup=true"
when: not skip_backup
- name: Display redeployment plan
ansible.builtin.debug:
msg: |
================================================================================
CLEAN REDEPLOY TRAEFIK AND GITEA
================================================================================
This playbook will:
1. ✅ Backup ({% if skip_backup %}SKIPPED{% else %}Performed{% endif %})
2. ✅ Stop and remove Traefik containers (keeps acme.json)
3. ✅ Stop and remove Gitea containers (keeps volumes/data)
4. ✅ Sync latest stack configurations
5. ✅ Redeploy Traefik stack
6. ✅ Redeploy Gitea stack
7. ✅ Restore Gitea configuration (app.ini)
8. ✅ Verify service discovery
9. ✅ Test Gitea accessibility
⚠️ IMPORTANT:
- SSL certificates (acme.json) will be preserved
- Gitea data (volumes) will be preserved
- Only containers will be recreated
- Expected downtime: ~2-5 minutes
{% if not skip_backup %}
- Backup location: {{ backup_base_path }}/{{ actual_backup_name }}
{% endif %}
================================================================================
# ========================================
# 2. STOP AND REMOVE CONTAINERS
# ========================================
- name: Stop Traefik stack
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose down
register: traefik_stop
changed_when: traefik_stop.rc == 0
failed_when: false
- name: Remove Traefik containers (if any remain)
ansible.builtin.shell: |
docker ps -a --filter "name={{ traefik_container_name }}" --format "{{ '{{' }}.ID{{ '}}' }}" | xargs -r docker rm -f 2>/dev/null || true
register: traefik_remove
changed_when: traefik_remove.rc == 0
failed_when: false
- name: Stop Gitea stack (preserves volumes)
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose down
register: gitea_stop
changed_when: gitea_stop.rc == 0
failed_when: false
- name: Remove Gitea containers (if any remain, volumes are preserved)
ansible.builtin.shell: |
docker ps -a --filter "name={{ gitea_container_name }}" --format "{{ '{{' }}.ID{{ '}}' }}" | xargs -r docker rm -f 2>/dev/null || true
register: gitea_remove
changed_when: gitea_remove.rc == 0
failed_when: false
# ========================================
# 3. SYNC CONFIGURATIONS
# ========================================
- name: Get stacks directory path
ansible.builtin.set_fact:
stacks_source_path: "{{ playbook_dir | dirname | dirname | dirname }}/stacks"
delegate_to: localhost
run_once: true
- name: Sync stacks directory to production server
ansible.builtin.synchronize:
src: "{{ stacks_source_path }}/"
dest: "{{ stacks_base_path }}/"
delete: no
recursive: yes
rsync_opts:
- "--chmod=D755,F644"
- "--exclude=.git"
- "--exclude=*.log"
- "--exclude=data/"
- "--exclude=volumes/"
- "--exclude=acme.json" # Preserve SSL certificates
- "--exclude=*.key"
- "--exclude=*.pem"
# ========================================
# 4. ENSURE ACME.JSON EXISTS
# ========================================
- name: Check if acme.json exists
ansible.builtin.stat:
path: "{{ traefik_stack_path }}/acme.json"
register: acme_json_stat
- name: Ensure acme.json exists and has correct permissions
ansible.builtin.file:
path: "{{ traefik_stack_path }}/acme.json"
state: touch
mode: '0600'
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
become: yes
register: acme_json_ensure
# ========================================
# 5. REDEPLOY TRAEFIK
# ========================================
- name: Deploy Traefik stack
community.docker.docker_compose_v2:
project_src: "{{ traefik_stack_path }}"
state: present
pull: always
register: traefik_deploy
- name: Wait for Traefik to be ready
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose ps {{ traefik_container_name }} | grep -Eiq "Up|running"
register: traefik_ready
changed_when: false
until: traefik_ready.rc == 0
retries: 12
delay: 5
failed_when: traefik_ready.rc != 0
# ========================================
# 6. REDEPLOY GITEA
# ========================================
- name: Deploy Gitea stack
community.docker.docker_compose_v2:
project_src: "{{ gitea_stack_path }}"
state: present
pull: always
register: gitea_deploy
- name: Wait for Gitea to be ready
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose ps {{ gitea_container_name }} | grep -Eiq "Up|running"
register: gitea_ready
changed_when: false
until: gitea_ready.rc == 0
retries: 12
delay: 5
failed_when: gitea_ready.rc != 0
- name: Wait for Gitea to be healthy
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose exec -T {{ gitea_container_name }} curl -f http://localhost:3000/api/healthz 2>&1 | grep -q "status.*pass" && echo "HEALTHY" || echo "NOT_HEALTHY"
register: gitea_health
changed_when: false
until: gitea_health.stdout == "HEALTHY"
retries: 30
delay: 2
failed_when: false
# ========================================
# 7. RESTORE GITEA CONFIGURATION
# ========================================
- name: Restore Gitea app.ini from backup
ansible.builtin.shell: |
if [ -f "{{ backup_base_path }}/{{ actual_backup_name }}/gitea-app.ini" ]; then
cd {{ gitea_stack_path }}
docker compose exec -T {{ gitea_container_name }} sh -c "cat > /data/gitea/conf/app.ini" < "{{ backup_base_path }}/{{ actual_backup_name }}/gitea-app.ini"
docker compose restart {{ gitea_container_name }}
echo "app.ini restored and Gitea restarted"
else
echo "No app.ini backup found, using default configuration"
fi
when: not skip_backup
register: gitea_app_ini_restore
changed_when: false
failed_when: false
# ========================================
# 8. VERIFY SERVICE DISCOVERY
# ========================================
- name: Wait for service discovery (Traefik needs time to discover Gitea)
ansible.builtin.pause:
seconds: 15
- name: Check if Gitea is in traefik-public network
ansible.builtin.shell: |
docker network inspect traefik-public --format '{{ '{{' }}range .Containers{{ '}}' }}{{ '{{' }}.Name{{ '}}' }} {{ '{{' }}end{{ '}}' }}' 2>/dev/null | grep -q {{ gitea_container_name }} && echo "YES" || echo "NO"
register: gitea_in_network
changed_when: false
- name: Test direct connection from Traefik to Gitea
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose exec -T {{ traefik_container_name }} wget -qO- --timeout=5 http://{{ gitea_container_name }}:3000/api/healthz 2>&1 | head -5 || echo "CONNECTION_FAILED"
register: traefik_gitea_direct
changed_when: false
failed_when: false
# ========================================
# 9. FINAL VERIFICATION
# ========================================
- name: Test Gitea via HTTPS (with retries)
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_https_test
until: gitea_https_test.status == 200
retries: 20
delay: 3
changed_when: false
failed_when: false
- name: Check SSL certificate status
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
if [ -f acme.json ] && [ -s acme.json ]; then
echo "SSL certificates: PRESENT"
else
echo "SSL certificates: MISSING or EMPTY"
fi
register: ssl_status
changed_when: false
- name: Final status summary
ansible.builtin.debug:
msg: |
================================================================================
REDEPLOYMENT SUMMARY
================================================================================
Traefik:
- Status: {{ traefik_ready.rc | ternary('Up', 'Down') }}
- SSL Certificates: {{ ssl_status.stdout }}
Gitea:
- Status: {{ gitea_ready.rc | ternary('Up', 'Down') }}
- Health: {% if gitea_health.stdout == 'HEALTHY' %}✅ Healthy{% else %}❌ Not Healthy{% endif %}
- Configuration: {% if gitea_app_ini_restore.changed %}✅ Restored{% else %} Using default{% endif %}
Service Discovery:
- Gitea in network: {% if gitea_in_network.stdout == 'YES' %}✅{% else %}❌{% endif %}
- Direct connection: {% if 'CONNECTION_FAILED' not in traefik_gitea_direct.stdout %}✅{% else %}❌{% endif %}
Gitea Accessibility:
{% if gitea_https_test.status == 200 %}
✅ Gitea is reachable via HTTPS (Status: 200)
URL: {{ gitea_url }}
{% else %}
❌ Gitea is NOT reachable via HTTPS (Status: {{ gitea_https_test.status | default('TIMEOUT') }})
Possible causes:
1. SSL certificate is still being generated (wait 2-5 minutes)
2. Service discovery needs more time (wait 1-2 minutes)
3. Network configuration issue
Next steps:
- Wait 2-5 minutes and test again: curl -k {{ gitea_url }}/api/healthz
- Check Traefik logs: cd {{ traefik_stack_path }} && docker compose logs {{ traefik_container_name }} --tail=50
- Check Gitea logs: cd {{ gitea_stack_path }} && docker compose logs {{ gitea_container_name }} --tail=50
{% endif %}
{% if not skip_backup %}
Backup location: {{ backup_base_path }}/{{ actual_backup_name }}
To rollback: ansible-playbook -i inventory/production.yml playbooks/maintenance/rollback-redeploy.yml \
--vault-password-file secrets/.vault_pass \
-e "backup_name={{ actual_backup_name }}"
{% endif %}
================================================================================

View File

@@ -1,236 +0,0 @@
---
# Stabilize Traefik
# Stellt sicher, dass Traefik stabil läuft, acme.json korrekt ist und ACME-Challenges durchlaufen
- name: Stabilize Traefik
hosts: production
gather_facts: yes
become: no
vars:
traefik_stabilize_wait_minutes: "{{ traefik_stabilize_wait_minutes | default(10) }}"
traefik_stabilize_check_interval: 60 # Check every 60 seconds
tasks:
- name: Check if Traefik stack directory exists
ansible.builtin.stat:
path: "{{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}"
register: traefik_stack_exists
- name: Fail if Traefik stack directory does not exist
ansible.builtin.fail:
msg: "Traefik stack directory not found at {{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}"
when: not traefik_stack_exists.stat.exists
- name: Fix acme.json permissions first
ansible.builtin.file:
path: "{{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}/acme.json"
state: file
mode: '0600'
owner: "{{ ansible_user | default('deploy') }}"
group: "{{ ansible_user | default('deploy') }}"
ignore_errors: yes
- name: Ensure Traefik container is running
ansible.builtin.shell: |
cd {{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}
docker compose up -d traefik
register: traefik_start
changed_when: traefik_start.rc == 0
- name: Wait for Traefik to be ready
ansible.builtin.wait_for:
timeout: 30
delay: 2
changed_when: false
- name: Check Traefik container status
ansible.builtin.shell: |
cd {{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}
docker compose ps traefik
register: traefik_status
changed_when: false
- name: Display Traefik status
ansible.builtin.debug:
msg: |
================================================================================
Traefik Container Status:
================================================================================
{{ traefik_status.stdout }}
================================================================================
- name: Check Traefik health
ansible.builtin.shell: |
cd {{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}
docker compose exec -T traefik traefik healthcheck --ping 2>&1 || echo "HEALTH_CHECK_FAILED"
register: traefik_health
changed_when: false
failed_when: false
- name: Display Traefik health check
ansible.builtin.debug:
msg: |
================================================================================
Traefik Health Check:
================================================================================
{% if 'HEALTH_CHECK_FAILED' not in traefik_health.stdout %}
✅ Traefik is healthy
{% else %}
⚠️ Traefik health check failed: {{ traefik_health.stdout }}
{% endif %}
================================================================================
- name: Verify acme.json permissions
ansible.builtin.stat:
path: "{{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}/acme.json"
register: acme_json_stat
- name: Fix acme.json permissions if needed
ansible.builtin.file:
path: "{{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}/acme.json"
mode: '0600'
owner: "{{ ansible_user | default('deploy') }}"
group: "{{ ansible_user | default('deploy') }}"
when: acme_json_stat.stat.mode | string | regex_replace('^0o?', '') != '0600'
- name: Display acme.json status
ansible.builtin.debug:
msg: |
================================================================================
acme.json Status:
================================================================================
Path: {{ acme_json_stat.stat.path }}
Mode: {{ acme_json_stat.stat.mode | string | regex_replace('^0o?', '') }}
{% if acme_json_stat.stat.mode | string | regex_replace('^0o?', '') == '0600' %}
✅ acme.json has correct permissions (600)
{% else %}
⚠️ acme.json permissions need to be fixed
{% endif %}
================================================================================
- name: Check Port 80/443 configuration
ansible.builtin.shell: |
echo "=== Port 80 ==="
ss -tlnp 2>/dev/null | grep ":80 " || netstat -tlnp 2>/dev/null | grep ":80 " || echo "Could not check port 80"
echo ""
echo "=== Port 443 ==="
ss -tlnp 2>/dev/null | grep ":443 " || netstat -tlnp 2>/dev/null | grep ":443 " || echo "Could not check port 443"
register: port_config_check
changed_when: false
- name: Display Port configuration
ansible.builtin.debug:
msg: |
================================================================================
Port-Konfiguration (80/443):
================================================================================
{{ port_config_check.stdout }}
================================================================================
- name: Get initial Traefik restart count
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "0"
register: initial_restart_count
changed_when: false
- name: Display initial restart count
ansible.builtin.debug:
msg: |
================================================================================
Initial Traefik Restart Count: {{ initial_restart_count.stdout }}
================================================================================
- name: Wait for ACME challenges to complete
ansible.builtin.debug:
msg: |
================================================================================
Warte auf ACME-Challenge-Abschluss...
================================================================================
Warte {{ traefik_stabilize_wait_minutes }} Minuten und prüfe alle {{ traefik_stabilize_check_interval }} Sekunden
ob Traefik stabil läuft und keine Restarts auftreten.
================================================================================
- name: Monitor Traefik stability
ansible.builtin.shell: |
cd {{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}
docker compose ps traefik --format "{{ '{{' }}.State{{ '}}' }}" | head -1 || echo "UNKNOWN"
register: traefik_state_check
changed_when: false
until: traefik_state_check.stdout == "running"
retries: "{{ (traefik_stabilize_wait_minutes | int * 60 / traefik_stabilize_check_interval) | int }}"
delay: "{{ traefik_stabilize_check_interval }}"
- name: Get final Traefik restart count
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "0"
register: final_restart_count
changed_when: false
- name: Check for Traefik restarts during monitoring
ansible.builtin.set_fact:
traefik_restarted: "{{ (final_restart_count.stdout | int) > (initial_restart_count.stdout | int) }}"
- name: Check Traefik logs for ACME errors
ansible.builtin.shell: |
cd {{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}
docker compose logs traefik --since {{ traefik_stabilize_wait_minutes }}m 2>&1 | grep -i "acme\|challenge\|certificate" | tail -20 || echo "No ACME-related messages in logs"
register: traefik_acme_logs
changed_when: false
- name: Display Traefik ACME logs
ansible.builtin.debug:
msg: |
================================================================================
Traefik ACME Logs (letzte {{ traefik_stabilize_wait_minutes }} Minuten):
================================================================================
{{ traefik_acme_logs.stdout }}
================================================================================
- name: Final status check
ansible.builtin.shell: |
cd {{ traefik_stack_path | default('/home/deploy/deployment/stacks/traefik') }}
docker compose ps traefik || echo "Could not get final status"
register: final_status
changed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
ZUSAMMENFASSUNG - Traefik Stabilisierung:
================================================================================
Initial Restart Count: {{ initial_restart_count.stdout }}
Final Restart Count: {{ final_restart_count.stdout }}
{% if traefik_restarted %}
⚠️ WARNUNG: Traefik wurde während der Überwachung neu gestartet!
Restart Count erhöht sich von {{ initial_restart_count.stdout }} auf {{ final_restart_count.stdout }}
Nächste Schritte:
- Führe diagnose-traefik-restarts.yml aus um die Ursache zu finden
- Prüfe Docker-Events und Logs für Restart-Gründe
{% else %}
✅ Traefik lief stabil während der Überwachung ({{ traefik_stabilize_wait_minutes }} Minuten)
Keine Restarts aufgetreten.
{% endif %}
Final Status: {{ final_status.stdout }}
{% if acme_json_stat.stat.mode | string | regex_replace('^0o?', '') == '0600' %}
✅ acme.json hat korrekte Berechtigungen
{% else %}
⚠️ acme.json Berechtigungen müssen korrigiert werden
{% endif %}
Wichtig:
- Traefik muss stabil laufen (keine häufigen Restarts)
- Port 80/443 müssen auf Traefik zeigen
- acme.json muss beschreibbar sein
- ACME-Challenges benötigen 5-10 Minuten um abzuschließen
Nächste Schritte:
- Prüfe Traefik-Logs regelmäßig auf ACME-Fehler
- Stelle sicher, dass keine Auto-Restart-Mechanismen aktiv sind
- Überwache Traefik für weitere {{ traefik_stabilize_wait_minutes }} Minuten
================================================================================

View File

@@ -1,73 +0,0 @@
---
# Test Gitea After Connection Pool Fix
- name: Test Gitea After Connection Pool Fix
hosts: production
gather_facts: no
become: no
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
gitea_url: "https://{{ gitea_domain }}"
tasks:
- name: Test Gitea health endpoint
ansible.builtin.uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
validate_certs: false
timeout: 35
register: gitea_test
changed_when: false
- name: Check Gitea logs for connection pool messages
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose logs gitea --tail 100 | grep -iE "timeout.*authentication|connection.*pool|MAX_OPEN_CONNS|database.*pool" | tail -20 || echo "No connection pool messages found"
register: gitea_logs_check
changed_when: false
failed_when: false
- name: Check Postgres logs for authentication timeouts
ansible.builtin.shell: |
cd {{ gitea_stack_path }}
docker compose logs postgres --tail 50 | grep -iE "timeout.*authentication|authentication.*timeout" | tail -10 || echo "No authentication timeout messages found"
register: postgres_logs_check
changed_when: false
failed_when: false
- name: Display test results
ansible.builtin.debug:
msg: |
================================================================================
GITEA CONNECTION POOL FIX - TEST RESULTS
================================================================================
Health Check Result:
- Status: {{ gitea_test.status | default('TIMEOUT') }}
- Response Time: {{ gitea_test.elapsed | default('N/A') }}s
{% if gitea_test.status | default(0) == 200 %}
✅ Gitea is reachable
{% else %}
❌ Gitea returned status {{ gitea_test.status | default('TIMEOUT') }}
{% endif %}
Gitea Logs (Connection Pool):
{{ gitea_logs_check.stdout }}
Postgres Logs (Authentication Timeouts):
{{ postgres_logs_check.stdout }}
================================================================================
INTERPRETATION:
================================================================================
{% if 'timeout.*authentication' in gitea_logs_check.stdout | lower or 'timeout.*authentication' in postgres_logs_check.stdout | lower %}
⚠️ Authentication timeout messages still present
→ Connection pool settings may need further tuning
→ Consider increasing MAX_OPEN_CONNS or authentication_timeout
{% else %}
✅ No authentication timeout messages found
→ Connection pool fix appears to be working
{% endif %}
================================================================================

View File

@@ -1,82 +0,0 @@
---
# Ansible Playbook: Update Gitea Traefik Service with Current IP
#
# ⚠️ DEPRECATED: This playbook is no longer needed since Traefik runs in bridge network mode.
# Service discovery via Docker labels works reliably in bridge mode, so manual IP updates
# are not required. This playbook is kept for reference only.
#
# Purpose: Update Traefik dynamic config with current Gitea container IP
# Usage:
# ansible-playbook -i inventory/production.yml playbooks/update-gitea-traefik-service.yml \
# --vault-password-file secrets/.vault_pass
- name: Update Gitea Traefik Service with Current IP
hosts: production
vars:
traefik_stack_path: "{{ stacks_base_path }}/traefik"
gitea_url: "https://{{ gitea_domain }}"
tasks:
- name: Warn that this playbook is deprecated
ansible.builtin.fail:
msg: |
⚠️ This playbook is DEPRECATED and should not be used.
Traefik service discovery via Docker labels works reliably in bridge mode.
If you really need to run this, set traefik_auto_restart=true explicitly.
when: traefik_auto_restart | default(false) | bool == false
- name: Get current Gitea container IP in traefik-public network
shell: |
docker inspect gitea | grep -A 10 'traefik-public' | grep IPAddress | head -1 | awk '{print $2}' | tr -d '",'
register: gitea_ip
changed_when: false
- name: Display Gitea IP
debug:
msg: "Gitea container IP: {{ gitea_ip.stdout }}"
- name: Create Gitea service configuration with current IP
copy:
dest: "{{ traefik_stack_path }}/dynamic/gitea-service.yml"
content: |
http:
services:
gitea:
loadBalancer:
servers:
- url: http://{{ gitea_ip.stdout }}:3000
mode: '0644'
- name: Restart Traefik to load new configuration
shell: |
docker compose -f {{ traefik_stack_path }}/docker-compose.yml restart traefik
when: traefik_auto_restart | default(false) | bool
register: traefik_restart
changed_when: traefik_restart.rc == 0
- name: Wait for Traefik to be ready
pause:
seconds: 10
when: traefik_restart.changed | default(false) | bool
- name: Test Gitea via Traefik
uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: final_test
retries: 5
delay: 2
changed_when: false
- name: Display result
debug:
msg: |
Gitea-Traefik connection:
- Gitea IP: {{ gitea_ip.stdout }}
- Via Traefik: {{ 'OK' if final_test.status == 200 else 'FAILED' }}
Note: This is a temporary fix. The IP will need to be updated if the container restarts.

View File

@@ -1,143 +0,0 @@
---
# Verify Traefik Restart Loop Fix
# Prüft ob die Änderungen (traefik_auto_restart: false) die Restart-Loops beheben
- name: Verify Traefik Restart Loop Fix
hosts: production
gather_facts: yes
become: no
vars:
traefik_stack_path: "{{ stacks_base_path }}/traefik"
monitor_duration_minutes: 10 # 10 Minuten Monitoring
tasks:
- name: Display current configuration
ansible.builtin.debug:
msg: |
================================================================================
TRAEFIK RESTART LOOP FIX - VERIFICATION:
================================================================================
Aktuelle Konfiguration:
- traefik_auto_restart: {{ traefik_auto_restart | default('NOT SET') }}
- traefik_ssl_restart: {{ traefik_ssl_restart | default('NOT SET') }}
- gitea_auto_restart: {{ gitea_auto_restart | default('NOT SET') }}
Erwartetes Verhalten:
- Traefik sollte NICHT automatisch nach Config-Deployment neu starten
- Traefik sollte NICHT automatisch während SSL-Setup neu starten
- Gitea sollte NICHT automatisch bei Healthcheck-Fehlern neu starten
Monitoring: {{ monitor_duration_minutes }} Minuten
================================================================================
- name: Get initial Traefik status
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.State.Status{{ '}}' }}|{{ '{{' }}.State.StartedAt{{ '}}' }}|{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: initial_traefik_status
changed_when: false
- name: Get initial Gitea status
ansible.builtin.shell: |
docker inspect gitea --format '{{ '{{' }}.State.Status{{ '}}' }}|{{ '{{' }}.State.StartedAt{{ '}}' }}|{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: initial_gitea_status
changed_when: false
- name: Check Traefik logs for recent restarts
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs traefik --since 1h 2>&1 | grep -iE "stopping server gracefully|I have to go" | wc -l
register: recent_restarts
changed_when: false
- name: Wait for monitoring period
ansible.builtin.pause:
minutes: "{{ monitor_duration_minutes }}"
- name: Get final Traefik status
ansible.builtin.shell: |
docker inspect traefik --format '{{ '{{' }}.State.Status{{ '}}' }}|{{ '{{' }}.State.StartedAt{{ '}}' }}|{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: final_traefik_status
changed_when: false
- name: Get final Gitea status
ansible.builtin.shell: |
docker inspect gitea --format '{{ '{{' }}.State.Status{{ '}}' }}|{{ '{{' }}.State.StartedAt{{ '}}' }}|{{ '{{' }}.RestartCount{{ '}}' }}' 2>/dev/null || echo "UNKNOWN"
register: final_gitea_status
changed_when: false
- name: Check Traefik logs for restarts during monitoring
ansible.builtin.shell: |
cd {{ traefik_stack_path }}
docker compose logs traefik --since {{ monitor_duration_minutes }}m 2>&1 | grep -iE "stopping server gracefully|I have to go" || echo "Keine Restarts gefunden"
register: restarts_during_monitoring
changed_when: false
failed_when: false
- name: Test Gitea accessibility (multiple attempts)
ansible.builtin.uri:
url: "https://git.michaelschiemer.de/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_test
until: gitea_test.status == 200
retries: 5
delay: 2
changed_when: false
failed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
================================================================================
VERIFICATION SUMMARY:
================================================================================
Initial Status:
- Traefik: {{ initial_traefik_status.stdout }}
- Gitea: {{ initial_gitea_status.stdout }}
Final Status:
- Traefik: {{ final_traefik_status.stdout }}
- Gitea: {{ final_gitea_status.stdout }}
Restarts während Monitoring ({{ monitor_duration_minutes }} Minuten):
{% if restarts_during_monitoring.stdout and 'Keine Restarts' not in restarts_during_monitoring.stdout %}
❌ RESTARTS GEFUNDEN:
{{ restarts_during_monitoring.stdout }}
⚠️ PROBLEM: Traefik wurde während des Monitorings gestoppt!
→ Die Änderungen haben das Problem noch nicht vollständig behoben
→ Prüfe ob externe Ansible-Playbooks noch laufen
→ Prüfe ob andere Automatisierungen Traefik stoppen
{% else %}
✅ KEINE RESTARTS GEFUNDEN
Traefik lief stabil während des {{ monitor_duration_minutes }}-minütigen Monitorings!
→ Die Änderungen scheinen zu funktionieren
{% endif %}
Gitea Accessibility:
{% if gitea_test.status == 200 %}
✅ Gitea ist erreichbar (Status: 200)
{% else %}
❌ Gitea ist nicht erreichbar (Status: {{ gitea_test.status | default('TIMEOUT') }})
{% endif %}
================================================================================
NÄCHSTE SCHRITTE:
================================================================================
{% if restarts_during_monitoring.stdout and 'Keine Restarts' not in restarts_during_monitoring.stdout %}
1. ❌ Prüfe externe Ansible-Playbooks die noch laufen könnten
2. ❌ Prüfe CI/CD-Pipelines die Traefik restarten könnten
3. ❌ Führe 'find-ansible-automation-source.yml' erneut aus
{% else %}
1. ✅ Traefik läuft stabil - keine automatischen Restarts mehr
2. ✅ Überwache Traefik weiterhin für 1-2 Stunden um sicherzugehen
3. ✅ Teste Gitea im Browser: https://git.michaelschiemer.de
{% endif %}
================================================================================

View File

@@ -23,6 +23,10 @@ DOMAIN = {{ gitea_domain }}
HTTP_ADDR = 0.0.0.0 HTTP_ADDR = 0.0.0.0
HTTP_PORT = 3000 HTTP_PORT = 3000
ROOT_URL = https://{{ gitea_domain }}/ ROOT_URL = https://{{ gitea_domain }}/
# LOCAL_ROOT_URL for internal access (Runner/Webhooks)
LOCAL_ROOT_URL = http://gitea:3000/
# Trust Traefik proxy (Docker network: 172.18.0.0/16)
PROXY_TRUSTED_PROXIES = 172.18.0.0/16,::1,127.0.0.1
DISABLE_SSH = false DISABLE_SSH = false
START_SSH_SERVER = false START_SSH_SERVER = false
SSH_DOMAIN = {{ gitea_domain }} SSH_DOMAIN = {{ gitea_domain }}
@@ -68,7 +72,11 @@ HOST = redis://:{{ redis_password }}@redis:6379/0?pool_size=100&idle_timeout=180
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[session] [session]
PROVIDER = redis PROVIDER = redis
PROVIDER_CONFIG = network=tcp,addr=redis:6379,password={{ redis_password }},db=0,pool_size=100,idle_timeout=180 # PROVIDER_CONFIG must be a Redis connection string (as per Gitea documentation)
# Format: redis://:password@host:port/db?pool_size=100&idle_timeout=180s
# Using same format as cache HOST and queue CONN_STR for consistency
PROVIDER_CONFIG = redis://:{{ redis_password }}@redis:6379/0?pool_size=100&idle_timeout=180s
SAME_SITE = lax
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Queue Configuration (Redis) ;; Queue Configuration (Redis)
@@ -82,6 +90,8 @@ CONN_STR = redis://:{{ redis_password }}@redis:6379/0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[security] [security]
INSTALL_LOCK = true INSTALL_LOCK = true
# Cookie security (only if ROOT_URL is https)
COOKIE_SECURE = true
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Service Configuration ;; Service Configuration

View File

@@ -218,3 +218,4 @@ ansible-playbook -i inventory/production.yml \

View File

@@ -37,8 +37,16 @@ services:
- "traefik.http.routers.gitea.priority=100" - "traefik.http.routers.gitea.priority=100"
# Service configuration (Docker provider uses port, not url) # Service configuration (Docker provider uses port, not url)
- "traefik.http.services.gitea.loadbalancer.server.port=3000" - "traefik.http.services.gitea.loadbalancer.server.port=3000"
# Middleware chain (removed temporarily to test if it causes issues) # ServersTransport for longer timeouts (prevents 504 for SSE/Long-Polling like /user/events)
# - "traefik.http.routers.gitea.middlewares=security-headers-global@file,gzip-compression@file" # Temporarily removed to test if this is causing the service discovery issue
# - "traefik.http.services.gitea.loadbalancer.serversTransport=gitea-transport@docker"
# - "traefik.http.serverstransports.gitea-transport.forwardingtimeouts.dialtimeout=10s"
# - "traefik.http.serverstransports.gitea-transport.forwardingtimeouts.responseheadertimeout=120s"
# - "traefik.http.serverstransports.gitea-transport.forwardingtimeouts.idleconntimeout=180s"
# - "traefik.http.serverstransports.gitea-transport.maxidleconnsperhost=100"
# X-Forwarded-Proto header (helps with redirects/cookies)
- "traefik.http.middlewares.gitea-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.routers.gitea.middlewares=gitea-headers@docker"
# Explicitly reference the service (like MinIO does) # Explicitly reference the service (like MinIO does)
- "traefik.http.routers.gitea.service=gitea" - "traefik.http.routers.gitea.service=gitea"
healthcheck: healthcheck:

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Analytics; namespace App\Application\Admin\Analytics;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Application\Analytics\Service\AnalyticsDashboardService; use App\Application\Analytics\Service\AnalyticsDashboardService;
use App\Application\Analytics\Service\AnalyticsRealTimeService; use App\Application\Analytics\Service\AnalyticsRealTimeService;
use App\Application\Analytics\Service\AnalyticsReportService; use App\Application\Analytics\Service\AnalyticsReportService;
@@ -22,10 +21,9 @@ use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
#[AdminSection(name: 'Analytics', icon: 'chart-bar', order: 4, description: 'Analytics and reporting')] #[AdminSection(name: 'Analytics', icon: 'chart-bar', order: 4, description: 'Analytics and reporting')]
final class AnalyticsController final readonly class AnalyticsController
{ {
public function __construct( public function __construct(
private AdminLayoutProcessor $layoutProcessor,
private AnalyticsDashboardService $dashboardService, private AnalyticsDashboardService $dashboardService,
private AnalyticsStorage $storage, private AnalyticsStorage $storage,
private AnalyticsReportService $reportService, private AnalyticsReportService $reportService,
@@ -69,12 +67,10 @@ final class AnalyticsController
'last_update' => date('Y-m-d H:i:s'), 'last_update' => date('Y-m-d H:i:s'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'analytics-dashboard', template: 'analytics-dashboard',
metaData: new MetaData('Analytics Dashboard', 'Website Analytics and User Behavior'), metaData: new MetaData('Analytics Dashboard', 'Website Analytics and User Behavior'),
data: $finalData data: $data
); );
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Content; namespace App\Application\Admin\Content;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Domain\Media\ImageRepository; use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageSlotRepository; use App\Domain\Media\ImageSlotRepository;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
@@ -21,7 +20,6 @@ final readonly class ImageManagerController
public function __construct( public function __construct(
private ImageSlotRepository $slotRepository, private ImageSlotRepository $slotRepository,
private ImageRepository $imageRepository, private ImageRepository $imageRepository,
private AdminLayoutProcessor $layoutProcessor,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -40,12 +38,10 @@ final readonly class ImageManagerController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
'image-manager', 'image-manager',
new MetaData('Image Manager'), new MetaData('Image Manager'),
$finalData $data
); );
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Content; namespace App\Application\Admin\Content;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Domain\Media\ImageSlot; use App\Domain\Media\ImageSlot;
use App\Domain\Media\ImageSlotRepository; use App\Domain\Media\ImageSlotRepository;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
@@ -21,7 +20,6 @@ final readonly class ImageSlotsController
{ {
public function __construct( public function __construct(
private ImageSlotRepository $imageSlotRepository, private ImageSlotRepository $imageSlotRepository,
private AdminLayoutProcessor $layoutProcessor,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -38,9 +36,7 @@ final readonly class ImageSlotsController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data); return new ViewResult('imageslots', new MetaData('Image Slots', 'Image Slots Management'), $data);
return new ViewResult('imageslots', new MetaData('Image Slots', 'Image Slots Management'), $finalData);
} }
#[Route('/admin/content/image-slots/{slotName}', method: Method::POST)] #[Route('/admin/content/image-slots/{slotName}', method: Method::POST)]
@@ -55,9 +51,7 @@ final readonly class ImageSlotsController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data); return new ViewResult('imageslot', new MetaData('Edit Image Slot', 'Image Slot Management'), $data);
return new ViewResult('imageslot', new MetaData('Edit Image Slot', 'Image Slot Management'), $finalData);
} }
#[Route('/admin/imageslots/create', method: Method::POST)] #[Route('/admin/imageslots/create', method: Method::POST)]

View File

@@ -6,7 +6,6 @@ namespace App\Application\Admin\Database;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\AdminPageRenderer; use App\Framework\Admin\AdminPageRenderer;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
use App\Framework\Database\Browser\Registry\DatabaseRegistry; use App\Framework\Database\Browser\Registry\DatabaseRegistry;
@@ -24,7 +23,6 @@ final readonly class DatabaseBrowserController
private DatabaseRegistry $databaseRegistry, private DatabaseRegistry $databaseRegistry,
private TableRegistry $tableRegistry, private TableRegistry $tableRegistry,
private AdminPageRenderer $pageRenderer, private AdminPageRenderer $pageRenderer,
private AdminLayoutProcessor $layoutProcessor,
private PaginationService $paginationService, private PaginationService $paginationService,
private DatabaseTableGenerator $tableGenerator, private DatabaseTableGenerator $tableGenerator,
) { ) {
@@ -72,12 +70,10 @@ final readonly class DatabaseBrowserController
], ],
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'database/browser', template: 'database/browser',
metaData: new MetaData('Database Browser', 'Admin - Database Browser'), metaData: new MetaData('Database Browser', 'Admin - Database Browser'),
data: $finalData data: $data
); );
} }
} }

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Database; namespace App\Application\Admin\Database;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
use App\Framework\Database\Browser\Registry\TableRegistry; use App\Framework\Database\Browser\Registry\TableRegistry;
use App\Framework\Meta\MetaData; use App\Framework\Meta\MetaData;
@@ -16,7 +15,6 @@ final readonly class TableBrowserController
{ {
public function __construct( public function __construct(
private TableRegistry $tableRegistry, private TableRegistry $tableRegistry,
private AdminLayoutProcessor $layoutProcessor,
private DatabaseMetadataTableGenerator $metadataTableGenerator, private DatabaseMetadataTableGenerator $metadataTableGenerator,
) { ) {
} }
@@ -33,12 +31,10 @@ final readonly class TableBrowserController
'error' => "Table '{$table}' not found", 'error' => "Table '{$table}' not found",
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'database/table-detail', template: 'database/table-detail',
metaData: new MetaData('Table Not Found', 'Admin - Table Not Found'), metaData: new MetaData('Table Not Found', 'Admin - Table Not Found'),
data: $finalData data: $data
); );
} }
@@ -57,12 +53,10 @@ final readonly class TableBrowserController
'has_foreign_keys' => !empty($tableSchema->foreignKeys), 'has_foreign_keys' => !empty($tableSchema->foreignKeys),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'database/table-detail', template: 'database/table-detail',
metaData: new MetaData("Table: {$tableSchema->name}", "Admin - Table: {$tableSchema->name}"), metaData: new MetaData("Table: {$tableSchema->name}", "Admin - Table: {$tableSchema->name}"),
data: $finalData data: $data
); );
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Development; namespace App\Application\Admin\Development;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
use App\Framework\DateTime\Clock; use App\Framework\DateTime\Clock;
use App\Framework\Design\Component\ComponentCategory; use App\Framework\Design\Component\ComponentCategory;
@@ -33,7 +32,6 @@ final readonly class DesignSystemController
private DesignSystemAnalyzer $analyzer, private DesignSystemAnalyzer $analyzer,
private FileScanner $fileScanner, private FileScanner $fileScanner,
private ComponentScanner $componentScanner, private ComponentScanner $componentScanner,
private AdminLayoutProcessor $layoutProcessor,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -56,12 +54,10 @@ final readonly class DesignSystemController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
'design-dashboard', 'design-dashboard',
new MetaData('Design System Dashboard', 'Design System Dashboard'), new MetaData('Design System Dashboard', 'Design System Dashboard'),
$finalData $data
); );
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Development; namespace App\Application\Admin\Development;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -17,7 +16,6 @@ final readonly class RoutesController
{ {
public function __construct( public function __construct(
private DiscoveryRegistry $processedResults, private DiscoveryRegistry $processedResults,
private AdminLayoutProcessor $layoutProcessor,
) { ) {
} }
@@ -110,12 +108,10 @@ final readonly class RoutesController
'routes' => $routes, 'routes' => $routes,
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'routes-overview', template: 'routes-overview',
metaData: new MetaData('Routes Overview', 'System Routes Overview'), metaData: new MetaData('Routes Overview', 'System Routes Overview'),
data: $finalData data: $data
); );
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Development; namespace App\Application\Admin\Development;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
use App\Framework\DateTime\Clock; use App\Framework\DateTime\Clock;
use App\Framework\Http\Method; use App\Framework\Http\Method;
@@ -29,7 +28,6 @@ use App\Framework\Router\AdminRoutes;
final readonly class StyleguideController final readonly class StyleguideController
{ {
public function __construct( public function __construct(
private AdminLayoutProcessor $layoutProcessor,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -52,12 +50,10 @@ final readonly class StyleguideController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'styleguide', template: 'styleguide',
metaData: $metaData, metaData: $metaData,
data: $finalData data: $data
); );
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Development; namespace App\Application\Admin\Development;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
use App\Framework\Config\WafConfig as FrameworkWafConfig; use App\Framework\Config\WafConfig as FrameworkWafConfig;
use App\Framework\Core\ValueObjects\Duration; use App\Framework\Core\ValueObjects\Duration;
@@ -46,7 +45,6 @@ final class WafTestController
private readonly PerformanceService $performance, private readonly PerformanceService $performance,
private readonly Logger $logger, private readonly Logger $logger,
private readonly Clock $clock, private readonly Clock $clock,
private readonly AdminLayoutProcessor $layoutProcessor,
) { ) {
} }
@@ -59,12 +57,10 @@ final class WafTestController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'waf-test', template: 'waf-test',
metaData: new MetaData('WAF Test Suite', 'Web Application Firewall Testing Interface'), metaData: new MetaData('WAF Test Suite', 'Web Application Firewall Testing Interface'),
data: $finalData data: $data
); );
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Infrastructure; namespace App\Application\Admin\Infrastructure;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -22,7 +21,6 @@ final readonly class CacheMetricsController
public function __construct( public function __construct(
private CacheMetricsInterface $cacheMetrics, private CacheMetricsInterface $cacheMetrics,
private Clock $clock, private Clock $clock,
private AdminLayoutProcessor $layoutProcessor,
) { ) {
} }
@@ -52,12 +50,10 @@ final readonly class CacheMetricsController
$data['driver_stats'] = $driverStats; $data['driver_stats'] = $driverStats;
} }
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'cache-metrics', template: 'cache-metrics',
metaData: new MetaData('Cache Metrics', 'Cache Performance Monitoring'), metaData: new MetaData('Cache Metrics', 'Cache Performance Monitoring'),
data: $finalData data: $data
); );
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Infrastructure; namespace App\Application\Admin\Infrastructure;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -22,7 +21,6 @@ final readonly class DockerController
{ {
public function __construct( public function __construct(
private DockerService $dockerService, private DockerService $dockerService,
private AdminLayoutProcessor $layoutProcessor,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -71,12 +69,10 @@ final readonly class DockerController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'docker-dashboard', template: 'docker-dashboard',
metaData: new MetaData('Docker Dashboard', 'Docker container management and monitoring'), metaData: new MetaData('Docker Dashboard', 'Docker container management and monitoring'),
data: $finalData data: $data
); );
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Infrastructure; namespace App\Application\Admin\Infrastructure;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -28,7 +27,6 @@ final readonly class LogViewerController
public function __construct( public function __construct(
private LogViewer $logViewer, private LogViewer $logViewer,
private Timer $timer, private Timer $timer,
private AdminLayoutProcessor $layoutProcessor,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -48,9 +46,7 @@ final readonly class LogViewerController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data); return new ViewResult('log-viewer', $metaData, $data);
return new ViewResult('log-viewer', $metaData, $finalData);
} }
#[Route('/admin/infrastructure/logs/api/list', Method::GET)] #[Route('/admin/infrastructure/logs/api/list', Method::GET)]

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Infrastructure; namespace App\Application\Admin\Infrastructure;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -20,7 +19,6 @@ final readonly class RedisController
public function __construct( public function __construct(
private RedisMonitoringService $redisMonitoring, private RedisMonitoringService $redisMonitoring,
private Clock $clock, private Clock $clock,
private AdminLayoutProcessor $layoutProcessor,
) { ) {
} }
@@ -86,12 +84,10 @@ final readonly class RedisController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'redis', template: 'redis',
metaData: new MetaData('Redis Dashboard', 'Redis monitoring and cache analysis'), metaData: new MetaData('Redis Dashboard', 'Redis monitoring and cache analysis'),
data: $finalData data: $data
); );
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\Infrastructure; namespace App\Application\Admin\Infrastructure;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -20,7 +19,6 @@ final readonly class ServicesController
public function __construct( public function __construct(
private DefaultContainer $container, private DefaultContainer $container,
private Clock $clock, private Clock $clock,
private AdminLayoutProcessor $layoutProcessor,
) { ) {
} }
@@ -66,12 +64,10 @@ final readonly class ServicesController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'services', template: 'services',
metaData: new MetaData('Registrierte Dienste', 'Übersicht aller registrierten Services'), metaData: new MetaData('Registrierte Dienste', 'Übersicht aller registrierten Services'),
data: $finalData data: $data
); );
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\MachineLearning; namespace App\Application\Admin\MachineLearning;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Duration; use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\HttpRequest; use App\Framework\Http\HttpRequest;
@@ -23,7 +22,6 @@ final readonly class MLDashboardAdminController
public function __construct( public function __construct(
private ModelRegistry $registry, private ModelRegistry $registry,
private ModelPerformanceMonitor $performanceMonitor, private ModelPerformanceMonitor $performanceMonitor,
private AdminLayoutProcessor $layoutProcessor
) {} ) {}
#[AdminPage(title: 'ML Dashboard', icon: 'brain', section: 'Machine Learning', order: 10)] #[AdminPage(title: 'ML Dashboard', icon: 'brain', section: 'Machine Learning', order: 10)]
@@ -167,12 +165,10 @@ final readonly class MLDashboardAdminController
'api_health_url' => '/api/ml/dashboard/health', 'api_health_url' => '/api/ml/dashboard/health',
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'ml-dashboard', template: 'ml-dashboard',
metaData: new MetaData('ML Dashboard', 'Machine Learning Model Monitoring and Performance'), metaData: new MetaData('ML Dashboard', 'Machine Learning Model Monitoring and Performance'),
data: $finalData data: $data
); );
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin; namespace App\Application\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -24,7 +23,6 @@ final readonly class MigrationStatus
public function __construct( public function __construct(
private MigrationLoader $migrationLoader, private MigrationLoader $migrationLoader,
private MigrationRunner $migrationRunner, private MigrationRunner $migrationRunner,
private AdminLayoutProcessor $layoutProcessor,
) { ) {
} }
@@ -96,12 +94,10 @@ final readonly class MigrationStatus
'has_pending' => $pendingCount > 0, 'has_pending' => $pendingCount > 0,
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'migrations', template: 'migrations',
metaData: new MetaData('Database Migrations', 'Database migration status and management'), metaData: new MetaData('Database Migrations', 'Database migration status and management'),
data: $finalData data: $data
); );
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin; namespace App\Application\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Application\LiveComponents\ImageGallery\ImageGalleryComponent; use App\Application\LiveComponents\ImageGallery\ImageGalleryComponent;
use App\Domain\Media\ImageRepository; use App\Domain\Media\ImageRepository;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
@@ -21,7 +20,6 @@ use App\Framework\Router\Result\ViewResult;
final readonly class ShowImageManager final readonly class ShowImageManager
{ {
public function __construct( public function __construct(
private AdminLayoutProcessor $layoutProcessor,
private ImageRepository $imageRepository, private ImageRepository $imageRepository,
private DataProviderResolver $dataProviderResolver private DataProviderResolver $dataProviderResolver
) { ) {
@@ -56,13 +54,11 @@ final readonly class ShowImageManager
'slots' => [], // TODO: Load actual slots for legacy support 'slots' => [], // TODO: Load actual slots for legacy support
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($viewData);
$metaData = MetaData::create( $metaData = MetaData::create(
title: 'Image Management', title: 'Image Management',
description: 'Upload, manage and organize your images' description: 'Upload, manage and organize your images'
); );
return new ViewResult('image-manager', $metaData, $finalData); return new ViewResult('image-manager', $metaData, $viewData);
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin; namespace App\Application\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Domain\Media\Image; use App\Domain\Media\Image;
use App\Domain\Media\ImageRepository; use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageResizer; use App\Domain\Media\ImageResizer;
@@ -28,7 +27,6 @@ final readonly class ShowImageUpload
private PathProvider $pathProvider, private PathProvider $pathProvider,
private StringConverter $stringConverter, private StringConverter $stringConverter,
private FormIdGenerator $formIdGenerator, private FormIdGenerator $formIdGenerator,
private AdminLayoutProcessor $layoutProcessor,
) { ) {
} }
@@ -49,14 +47,12 @@ final readonly class ShowImageUpload
'formHtml' => RawHtml::from($formHtml), 'formHtml' => RawHtml::from($formHtml),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
$metaData = MetaData::create( $metaData = MetaData::create(
title: 'Bild-Upload | Admin Panel', title: 'Bild-Upload | Admin Panel',
description: 'Upload new images to the system' description: 'Upload new images to the system'
); );
return new ViewResult('upload-form', $metaData, $finalData); return new ViewResult('upload-form', $metaData, $data);
} }
#[Route('/upload', Method::POST)] #[Route('/upload', Method::POST)]
@@ -143,12 +139,10 @@ final readonly class ShowImageUpload
'formHtml' => $this->buildUploadForm(), 'formHtml' => $this->buildUploadForm(),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
'upload-form', 'upload-form',
MetaData::create('Upload Fehler | Admin Panel', $message), MetaData::create('Upload Fehler | Admin Panel', $message),
$finalData $data
); );
} }
@@ -161,12 +155,10 @@ final readonly class ShowImageUpload
'formHtml' => $this->buildUploadForm(), 'formHtml' => $this->buildUploadForm(),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
'upload-form', 'upload-form',
MetaData::create($title . ' | Admin Panel', $message), MetaData::create($title . ' | Admin Panel', $message),
$finalData $data
); );
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin; namespace App\Application\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -14,9 +13,8 @@ use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Development', icon: 'code', order: 5, description: 'Development tools and utilities')] #[AdminSection(name: 'Development', icon: 'code', order: 5, description: 'Development tools and utilities')]
final readonly class ShowUploadTest final readonly class ShowUploadTest
{ {
public function __construct( public function __construct()
private AdminLayoutProcessor $layoutProcessor, {
) {
} }
#[AdminPage(title: 'Upload Test', icon: 'file-upload', section: 'Development', order: 60, hidden: true)] #[AdminPage(title: 'Upload Test', icon: 'file-upload', section: 'Development', order: 60, hidden: true)]
@@ -28,13 +26,11 @@ final readonly class ShowUploadTest
'description' => 'Test page for JavaScript file upload functionality with CSRF protection.', 'description' => 'Test page for JavaScript file upload functionality with CSRF protection.',
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
$metaData = MetaData::create( $metaData = MetaData::create(
title: 'JavaScript Upload Test | Admin Panel', title: 'JavaScript Upload Test | Admin Panel',
description: 'Test JavaScript upload functionality' description: 'Test JavaScript upload functionality'
); );
return new ViewResult('upload-test', $metaData, $finalData); return new ViewResult('upload-test', $metaData, $data);
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\System; namespace App\Application\Admin\System;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -21,7 +20,6 @@ final readonly class EnvironmentController
public function __construct( public function __construct(
private DefaultContainer $container, private DefaultContainer $container,
private Clock $clock, private Clock $clock,
private AdminLayoutProcessor $layoutProcessor,
) { ) {
} }
@@ -60,12 +58,10 @@ final readonly class EnvironmentController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'environment', template: 'environment',
metaData: new MetaData('Umgebungsvariablen', 'Umgebungsvariablen'), metaData: new MetaData('Umgebungsvariablen', 'Umgebungsvariablen'),
data: $finalData data: $data
); );
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\System; namespace App\Application\Admin\System;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -22,7 +21,6 @@ final readonly class HealthController
{ {
public function __construct( public function __construct(
private HealthCheckManager $healthManager, private HealthCheckManager $healthManager,
private AdminLayoutProcessor $layoutProcessor,
private Clock $clock, private Clock $clock,
private HealthCheckTableGenerator $tableGenerator, private HealthCheckTableGenerator $tableGenerator,
) { ) {
@@ -81,14 +79,10 @@ final readonly class HealthController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
error_log("HealthController: Final data keys: " . implode(', ', array_keys($finalData)));
return new ViewResult( return new ViewResult(
template: 'health-dashboard', template: 'health-dashboard',
metaData: new MetaData('System Health', 'System Health Dashboard'), metaData: new MetaData('System Health', 'System Health Dashboard'),
data: $finalData data: $data
); );
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\System; namespace App\Application\Admin\System;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
@@ -24,7 +23,6 @@ final readonly class PerformanceController
public function __construct( public function __construct(
private MemoryMonitor $memoryMonitor, private MemoryMonitor $memoryMonitor,
private Clock $clock, private Clock $clock,
private AdminLayoutProcessor $layoutProcessor,
) { ) {
} }
@@ -73,12 +71,10 @@ final readonly class PerformanceController
'timestamp' => $this->clock->now()->format('Y-m-d H:i:s'), 'timestamp' => $this->clock->now()->format('Y-m-d H:i:s'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'performance', template: 'performance',
metaData: new MetaData('Performance-Daten', 'Performance-Daten'), metaData: new MetaData('Performance-Daten', 'Performance-Daten'),
data: $finalData data: $data
); );
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin\System; namespace App\Application\Admin\System;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Application\Admin\System\Service\PhpInfoService; use App\Application\Admin\System\Service\PhpInfoService;
use App\Framework\Admin\Attributes\AdminPage; use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection; use App\Framework\Admin\Attributes\AdminSection;
@@ -19,7 +18,6 @@ final readonly class PhpInfoController
{ {
public function __construct( public function __construct(
private PhpInfoService $phpInfoService, private PhpInfoService $phpInfoService,
private AdminLayoutProcessor $layoutProcessor,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -56,12 +54,10 @@ final readonly class PhpInfoController
'current_year' => $this->clock->now()->format('Y'), 'current_year' => $this->clock->now()->format('Y'),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'phpinfo', template: 'phpinfo',
metaData: new MetaData('PHP Information', 'PHP Information and Configuration'), metaData: new MetaData('PHP Information', 'PHP Information and Configuration'),
data: $finalData data: $data
); );
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\Admin; namespace App\Framework\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Meta\MetaData; use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult; use App\Framework\Router\Result\ViewResult;
use App\Framework\View\FormBuilder; use App\Framework\View\FormBuilder;
@@ -13,13 +12,13 @@ use App\Framework\View\Table\Table;
/** /**
* Admin Page Renderer * Admin Page Renderer
* *
* Provides consistent rendering for admin pages * Provides consistent rendering for admin pages.
* Layout data (navigation, breadcrumbs) is automatically added by RouteResponder.
*/ */
final readonly class AdminPageRenderer final readonly class AdminPageRenderer
{ {
public function __construct( public function __construct()
private AdminLayoutProcessor $layoutProcessor {
) {
} }
public function renderIndex( public function renderIndex(
@@ -35,12 +34,10 @@ final readonly class AdminPageRenderer
'actions' => $actions, 'actions' => $actions,
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'admin-index', template: 'admin-index',
metaData: new MetaData($title, "Admin - {$title}"), metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData data: $data
); );
} }
@@ -57,12 +54,10 @@ final readonly class AdminPageRenderer
'form' => $form->build(), 'form' => $form->build(),
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult( return new ViewResult(
template: 'admin-form', template: 'admin-form',
metaData: new MetaData($title, "Admin - {$title}"), metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData data: $data
); );
} }
@@ -77,12 +72,10 @@ final readonly class AdminPageRenderer
...$data, ...$data,
]; ];
$finalData = $this->layoutProcessor->processLayoutFromArray($pageData);
return new ViewResult( return new ViewResult(
template: 'admin-show', template: 'admin-show',
metaData: new MetaData($title, "Admin - {$title}"), metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData data: $pageData
); );
} }
} }

View File

@@ -16,7 +16,7 @@ final readonly class CommandCategorizer
* *
* @var array<string, string> * @var array<string, string>
*/ */
private const CATEGORY_INFO = [ private const array CATEGORY_INFO = [
'db' => 'Database operations (migrations, health checks)', 'db' => 'Database operations (migrations, health checks)',
'errors' => 'Error management and analytics', 'errors' => 'Error management and analytics',
'backup' => 'Backup and restore operations', 'backup' => 'Backup and restore operations',

View File

@@ -187,12 +187,13 @@ final readonly class ConsoleTUI
$needsRender = true; $needsRender = true;
} }
// Render if needed (with throttling) // Render if needed (with throttling to reduce flickering)
if ($needsRender) { if ($needsRender) {
$currentTime = microtime(true); $currentTime = microtime(true);
$timeSinceLastRender = $currentTime - $lastRenderTime; $timeSinceLastRender = $currentTime - $lastRenderTime;
$minRenderInterval = 0.033; // ~30 FPS instead of 60 to reduce flickering
if ($timeSinceLastRender >= 0.016) { // ~60 FPS max if ($timeSinceLastRender >= $minRenderInterval) {
$this->renderCurrentView(); $this->renderCurrentView();
$lastRenderTime = $currentTime; $lastRenderTime = $currentTime;
$needsRender = false; $needsRender = false;
@@ -204,7 +205,7 @@ final readonly class ConsoleTUI
// Sleep if no events processed to reduce CPU usage // Sleep if no events processed to reduce CPU usage
if ($eventsProcessed === 0) { if ($eventsProcessed === 0) {
usleep(5000); // 5ms usleep(10000); // 10ms - increased to reduce CPU usage
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$errorCount++; $errorCount++;

View File

@@ -377,6 +377,11 @@ final readonly class KeyboardEventHandler
*/ */
private function keyEventToString(KeyEvent $event): string private function keyEventToString(KeyEvent $event): string
{ {
// Check for Enter key explicitly (multiple formats)
if ($event->key === 'Enter' || $event->key === "\n" || $event->key === "\r" || $event->key === "\r\n") {
return TuiKeyCode::ENTER->value;
}
if ($event->code !== '') { if ($event->code !== '') {
return $event->code; return $event->code;
} }

View File

@@ -86,6 +86,10 @@ final class TuiRenderer
TuiView::HELP => $this->renderHelp($state), TuiView::HELP => $this->renderHelp($state),
}; };
// Re-render menu bar AFTER content to ensure it's always visible
// This prevents content from overwriting the menu bar
$this->renderMenuBar($state, $terminalSize->width);
// Render status line at bottom // Render status line at bottom
$this->renderStatusLine($state, $terminalSize->width); $this->renderStatusLine($state, $terminalSize->width);
@@ -114,17 +118,20 @@ final class TuiRenderer
} }
/** /**
* Clear only the content area (from line 4 downwards), preserving the menu bar (lines 1-3) * Clear only the content area (from line 4 downwards), preserving the menu bar (lines 2-3)
* Line 1: empty spacing (can be cleared)
* Lines 2-3: menu bar (MUST be preserved)
* Line 4+: content area (will be cleared)
*/ */
private function clearContentArea(): void private function clearContentArea(): void
{ {
$terminalSize = TerminalSize::detect(); $terminalSize = TerminalSize::detect();
// Clear line 1 (spacing line) explicitly // Clear line 1 (spacing line) explicitly - this is safe to clear
$this->output->write(CursorControlCode::POSITION->format(1, 1)); $this->output->write(CursorControlCode::POSITION->format(1, 1));
$this->output->write(ScreenControlCode::CLEAR_LINE->format()); $this->output->write(ScreenControlCode::CLEAR_LINE->format());
// Position cursor at line 4 (start of content area) // Position cursor at line 4 (start of content area, after menu bar at 2-3)
$this->output->write(CursorControlCode::POSITION->format(4, 1)); $this->output->write(CursorControlCode::POSITION->format(4, 1));
// Clear everything from cursor downwards (preserves lines 2-3: menu bar) // Clear everything from cursor downwards (preserves lines 2-3: menu bar)
@@ -151,25 +158,40 @@ final class TuiRenderer
*/ */
private function renderCategories(TuiState $state): void private function renderCategories(TuiState $state): void
{ {
// Ensure we're at the correct starting line (4) for content // Start at line 4 (after menu bar at lines 2-3)
$currentLine = 4; $currentLine = 4;
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->writeLine('📂 Select Category:', ConsoleColor::BRIGHT_YELLOW); // Clear and write title - ensure we're at the right position
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\r");
$this->output->write('📂 Select Category:', ConsoleColor::BRIGHT_YELLOW);
// Don't use writeLine here - we control the line manually
$this->output->write("\n");
$currentLine++; $currentLine++;
$categories = $state->getCategories(); $categories = $state->getCategories();
$maxVisibleCategories = min(count($categories), 20); // Limit visible items to prevent overflow
foreach ($categories as $index => $category) { foreach ($categories as $index => $category) {
// Only render visible categories to prevent overflow
if ($index >= $maxVisibleCategories) {
break;
}
$isSelected = $index === $state->getSelectedCategory(); $isSelected = $index === $state->getSelectedCategory();
$isHovered = $state->isContentItemHovered('category', $index); $isHovered = $state->isContentItemHovered('category', $index);
// Position cursor at correct line before rendering // Position cursor at correct line before rendering - ensure we're at column 1
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1)); $this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->renderCategoryItem($category, $isSelected, $isHovered); $this->renderCategoryItem($category, $isSelected, $isHovered);
$currentLine++; $currentLine++;
} }
$this->output->newLine(); // Render navigation bar after categories
// Position cursor explicitly before rendering navigation
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->writeLine(''); // Empty line before navigation
$this->renderNavigationBar([ $this->renderNavigationBar([
"↑/↓: Navigate", "↑/↓: Navigate",
"Enter: Select", "Enter: Select",
@@ -195,8 +217,9 @@ final class TuiRenderer
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ($isHovered ? ConsoleColor::BRIGHT_CYAN : ConsoleColor::WHITE); $color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ($isHovered ? ConsoleColor::BRIGHT_CYAN : ConsoleColor::WHITE);
// Clear the entire line first to remove any leftover characters // Clear the entire line first to remove any leftover characters
// Use CLEAR_LINE and reset to column 1
$this->output->write(ScreenControlCode::CLEAR_LINE->format()); $this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\r"); $this->output->write("\r"); // Reset to beginning of line
// Build the text without any tabs or extra spaces // Build the text without any tabs or extra spaces
$text = "{$prefix}{$icon} {$name} ({$count} commands)"; $text = "{$prefix}{$icon} {$name} ({$count} commands)";
@@ -208,8 +231,8 @@ final class TuiRenderer
$this->output->write($text); $this->output->write($text);
} }
// Move to next line explicitly // Don't write newline here - the caller controls line positioning
$this->output->write("\n"); // The cursor will be positioned by the caller for the next item
} }
/** /**
@@ -222,17 +245,57 @@ final class TuiRenderer
return; return;
} }
$icon = $category['icon'] ?? '📁'; // Start at line 4 (after menu bar at lines 2-3)
$this->output->writeLine("📂 {$icon} {$category['name']} Commands:", ConsoleColor::BRIGHT_YELLOW); $currentLine = 4;
$this->output->writeLine('');
// Clear and write title
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\r");
$icon = $category['icon'] ?? '📁';
$this->output->write("📂 {$icon} {$category['name']} Commands:", ConsoleColor::BRIGHT_YELLOW);
$this->output->write("\n");
$currentLine++;
// Empty line
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\n");
$currentLine++;
$commands = $category['commands'];
$maxVisibleCommands = min(count($commands), 15); // Limit visible items
foreach ($commands as $index => $command) {
if ($index >= $maxVisibleCommands) {
break;
}
foreach ($category['commands'] as $index => $command) {
$isSelected = $index === $state->getSelectedCommand(); $isSelected = $index === $state->getSelectedCommand();
$isHovered = $state->isContentItemHovered('command', $index); $isHovered = $state->isContentItemHovered('command', $index);
// Position cursor at correct line before rendering
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->renderCommandItem($command, $isSelected, $isHovered); $this->renderCommandItem($command, $isSelected, $isHovered);
$currentLine++; // Command name line
// Check if description exists and increment line counter
$hasDescription = false;
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
$hasDescription = $attribute !== null && !empty($attribute->description ?? '');
} else {
$hasDescription = !empty($command->description ?? '');
}
if ($hasDescription) {
$currentLine++; // Description line
}
} }
$this->output->newLine(); // Render navigation bar after commands
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->writeLine('');
$this->renderNavigationBar([ $this->renderNavigationBar([
"↑/↓: Navigate", "↑/↓: Navigate",
"Enter: Execute", "Enter: Execute",
@@ -269,11 +332,21 @@ final class TuiRenderer
$commandDescription = $command->description ?? ''; $commandDescription = $command->description ?? '';
} }
$this->output->writeLine("{$prefix}{$commandName}", $color); // Clear line first
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\r");
// Write command name
$this->output->write("{$prefix}{$commandName}", $color);
$this->output->write("\n");
if (! empty($commandDescription)) { if (! empty($commandDescription)) {
// Clear next line and write description
$descColor = ConsoleColor::GRAY; $descColor = ConsoleColor::GRAY;
$this->output->writeLine(" {$commandDescription}", $descColor); $this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\r");
$this->output->write(" {$commandDescription}", $descColor);
$this->output->write("\n");
} }
} }

View File

@@ -21,7 +21,9 @@ use App\Framework\Discovery\DiscoveryServiceBootstrapper;
use App\Framework\Discovery\Results\DiscoveryRegistry; use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Logging\Logger; use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext; use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Pcntl\PcntlService;
use App\Framework\Pcntl\ValueObjects\Signal; use App\Framework\Pcntl\ValueObjects\Signal;
use JetBrains\PhpStorm\NoReturn;
use Throwable; use Throwable;
final class ConsoleApplication final class ConsoleApplication
@@ -46,7 +48,7 @@ final class ConsoleApplication
// Setup signal handlers für graceful shutdown // Setup signal handlers für graceful shutdown
$this->signalHandler = new ConsoleSignalHandler( $this->signalHandler = new ConsoleSignalHandler(
$this->container, $container->get(PcntlService::class),
function (Signal $signal) { function (Signal $signal) {
$this->handleShutdown($signal); $this->handleShutdown($signal);
} }
@@ -87,6 +89,7 @@ final class ConsoleApplication
$this->errorHandler = new ConsoleErrorHandler($recoveryService, $logger); $this->errorHandler = new ConsoleErrorHandler($recoveryService, $logger);
} }
#[NoReturn]
public function handleShutdown(Signal $signal): void public function handleShutdown(Signal $signal): void
{ {
$this->shutdownRequested = true; $this->shutdownRequested = true;

View File

@@ -7,6 +7,7 @@ namespace App\Framework\Console;
use App\Framework\DI\Container; use App\Framework\DI\Container;
use App\Framework\Pcntl\PcntlService; use App\Framework\Pcntl\PcntlService;
use App\Framework\Pcntl\ValueObjects\Signal; use App\Framework\Pcntl\ValueObjects\Signal;
use Closure;
/** /**
* Kapselt Signal-Handler-Setup für ConsoleApplication. * Kapselt Signal-Handler-Setup für ConsoleApplication.
@@ -16,13 +17,10 @@ use App\Framework\Pcntl\ValueObjects\Signal;
*/ */
final readonly class ConsoleSignalHandler final readonly class ConsoleSignalHandler
{ {
private ?PcntlService $pcntlService = null;
public function __construct( public function __construct(
private Container $container, private PcntlService $pcntlService,
private callable $shutdownCallback private Closure $shutdownCallback
) { ) {}
}
/** /**
* Setup shutdown handlers für SIGTERM, SIGINT, SIGHUP. * Setup shutdown handlers für SIGTERM, SIGINT, SIGHUP.
@@ -32,8 +30,6 @@ final readonly class ConsoleSignalHandler
public function setupShutdownHandlers(): void public function setupShutdownHandlers(): void
{ {
try { try {
$this->pcntlService = $this->container->get(PcntlService::class);
$this->pcntlService->registerSignal(Signal::SIGTERM, function (Signal $signal) { $this->pcntlService->registerSignal(Signal::SIGTERM, function (Signal $signal) {
($this->shutdownCallback)($signal); ($this->shutdownCallback)($signal);
}); });
@@ -58,9 +54,7 @@ final readonly class ConsoleSignalHandler
*/ */
public function dispatchSignals(): void public function dispatchSignals(): void
{ {
if ($this->pcntlService !== null) { $this->pcntlService->dispatchSignals();
$this->pcntlService->dispatchSignals();
}
} }
/** /**

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Console; namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\Email; use App\Framework\Core\ValueObjects\EmailAddress;
use App\Framework\Http\Url\Url; use App\Framework\Http\Url\Url;
use App\Framework\Http\Url\UrlFactory; use App\Framework\Http\Url\UrlFactory;
@@ -105,9 +105,9 @@ final readonly class ParsedArguments
/** /**
* Get Email value object * Get Email value object
*/ */
public function getEmail(string $name): Email public function getEmail(string $name): EmailAddress
{ {
return new Email($this->getString($name)); return new EmailAddress($this->getString($name));
} }
/** /**

View File

@@ -191,7 +191,7 @@ class ProgressBar
*/ */
private function getRemaining(float $percent): string private function getRemaining(float $percent): string
{ {
if ($percent === 0) { if ($percent === 0.0 || $percent < 0.0001) {
return '--'; return '--';
} }

View File

@@ -114,7 +114,7 @@ class Spinner
$frame = $this->frames[$this->currentFrame]; $frame = $this->frames[$this->currentFrame];
$this->output->write("\r\033[2K{$frame} {$this->message}"); $this->output->write("\r\033[2K{$frame} {$this->message}");
$this->updateCount = $expectedUpdates; $this->updateCount = (int) $expectedUpdates;
} }
return $this; return $this;

View File

@@ -23,6 +23,7 @@ use App\Framework\Router\Result\WebSocketResult;
use App\Framework\View\RenderContext; use App\Framework\View\RenderContext;
use App\Framework\View\Template; use App\Framework\View\Template;
use App\Framework\View\TemplateRenderer; use App\Framework\View\TemplateRenderer;
use App\Application\Admin\Service\AdminLayoutProcessor;
final readonly class RouteResponder final readonly class RouteResponder
{ {
@@ -36,16 +37,51 @@ final readonly class RouteResponder
public function getContext(ViewResult $result): RenderContext public function getContext(ViewResult $result): RenderContext
{ {
$data = $result->model ? get_object_vars($result->model) + $result->data : $result->data;
// Automatically enrich with admin layout data for admin routes
if ($this->isAdminRoute($this->request->path)) {
$data = $this->enrichWithAdminLayout($data);
}
return new RenderContext( return new RenderContext(
template: $result->model ? $this->resolveTemplate($result->model) : $result->template, template: $result->model ? $this->resolveTemplate($result->model) : $result->template,
metaData: $result->metaData, metaData: $result->metaData,
data: $result->model ? get_object_vars($result->model) + $result->data : $result->data, data: $data,
layout: '', layout: '',
slots: $result->slots, slots: $result->slots,
isPartial: $this->isSpaRequest(), isPartial: $this->isSpaRequest(),
); );
} }
/**
* Check if the current path is an admin route
*/
private function isAdminRoute(string $path): bool
{
return str_starts_with($path, '/admin/') || $path === '/admin';
}
/**
* Enrich data with admin layout information (navigation, breadcrumbs, etc.)
*/
private function enrichWithAdminLayout(array $data): array
{
try {
// Get AdminLayoutProcessor from container
if (!$this->container->has(AdminLayoutProcessor::class)) {
return $data;
}
$layoutProcessor = $this->container->get(AdminLayoutProcessor::class);
return $layoutProcessor->processLayoutFromArray($data);
} catch (\Throwable $e) {
// Log error but don't break the request
error_log("RouteResponder: Failed to enrich admin layout: " . $e->getMessage());
return $data;
}
}
public function respond(Response|ActionResult $result): Response public function respond(Response|ActionResult $result): Response
{ {
if ($result instanceof Response) { if ($result instanceof Response) {

View File

@@ -38,14 +38,14 @@ final readonly class StorageInitializer
// Register MinIoClient (if needed for S3 driver) // Register MinIoClient (if needed for S3 driver)
$container->singleton(MinIoClient::class, function (Container $container) use ($env) { $container->singleton(MinIoClient::class, function (Container $container) use ($env) {
return new MinIoClient( return new MinIoClient(
endpoint: $env->getString('MINIO_ENDPOINT', 'http://minio:9000'), endpoint : $env->getString('MINIO_ENDPOINT', 'http://minio:9000'),
accessKey: $env->getString('MINIO_ACCESS_KEY', 'minioadmin'), accessKey : $env->getString('MINIO_ACCESS_KEY', 'minioadmin'),
secretKey: $env->getString('MINIO_SECRET_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), randomGenerator: $container->get(RandomGenerator::class),
hmacService: $container->get(HmacService::class), hmacService : $container->get(HmacService::class),
httpClient: $container->get(CurlHttpClient::class) httpClient : $container->get(CurlHttpClient::class),
region : $env->getString('MINIO_REGION', 'us-east-1'),
usePathStyle : $env->getBool('MINIO_USE_PATH_STYLE', true)
); );
}); });

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\ArgumentDefinition;
use App\Framework\Console\ArgumentType;
describe('ArgumentDefinition', function () {
it('creates required string argument', function () {
$def = ArgumentDefinition::required('name', 'User name');
expect($def->name)->toBe('name');
expect($def->type)->toBe(ArgumentType::STRING);
expect($def->required)->toBeTrue();
expect($def->default)->toBeNull();
expect($def->description)->toBe('User name');
});
it('creates optional string argument with default', function () {
$def = ArgumentDefinition::optional('name', 'Guest', 'User name');
expect($def->name)->toBe('name');
expect($def->type)->toBe(ArgumentType::STRING);
expect($def->required)->toBeFalse();
expect($def->default)->toBe('Guest');
expect($def->description)->toBe('User name');
});
it('creates boolean flag', function () {
$def = ArgumentDefinition::flag('verbose', 'v', 'Enable verbose output');
expect($def->name)->toBe('verbose');
expect($def->type)->toBe(ArgumentType::BOOLEAN);
expect($def->shortName)->toBe('v');
expect($def->description)->toBe('Enable verbose output');
});
it('creates email argument', function () {
$def = ArgumentDefinition::email('email', required: true, description: 'User email');
expect($def->name)->toBe('email');
expect($def->type)->toBe(ArgumentType::EMAIL);
expect($def->required)->toBeTrue();
expect($def->description)->toBe('User email');
});
it('creates integer argument', function () {
$def = ArgumentDefinition::integer('count', required: false, default: 10, description: 'Item count');
expect($def->name)->toBe('count');
expect($def->type)->toBe(ArgumentType::INTEGER);
expect($def->required)->toBeFalse();
expect($def->default)->toBe(10);
expect($def->description)->toBe('Item count');
});
it('creates choice argument', function () {
$def = ArgumentDefinition::choice('mode', ['dev', 'prod', 'test'], required: false, default: 'dev');
expect($def->name)->toBe('mode');
expect($def->type)->toBe(ArgumentType::STRING);
expect($def->allowedValues)->toBe(['dev', 'prod', 'test']);
expect($def->default)->toBe('dev');
});
it('throws exception for empty name', function () {
expect(fn () => new ArgumentDefinition('', ArgumentType::STRING))
->toThrow(\InvalidArgumentException::class, 'Argument name cannot be empty');
});
it('throws exception for invalid short name length', function () {
expect(fn () => new ArgumentDefinition('name', ArgumentType::STRING, shortName: 'ab'))
->toThrow(\InvalidArgumentException::class, 'Short name must be exactly one character');
});
it('throws exception when required argument has default', function () {
expect(fn () => new ArgumentDefinition('name', ArgumentType::STRING, required: true, default: 'Guest'))
->toThrow(\InvalidArgumentException::class, 'Required arguments cannot have default values');
});
it('throws exception when boolean argument has allowed values', function () {
expect(fn () => new ArgumentDefinition('flag', ArgumentType::BOOLEAN, allowedValues: ['true', 'false']))
->toThrow(\InvalidArgumentException::class, 'Boolean arguments cannot have allowed values');
});
it('gets display name with short name', function () {
$def = ArgumentDefinition::flag('verbose', 'v');
expect($def->getDisplayName())->toBe('v, verbose');
});
it('gets display name without short name', function () {
$def = ArgumentDefinition::required('name');
expect($def->getDisplayName())->toBe('name');
});
it('gets usage text for boolean flag', function () {
$def = ArgumentDefinition::flag('verbose', 'v');
expect($def->getUsageText())->toBe('[--verbose]');
});
it('gets usage text for required boolean flag', function () {
$def = new ArgumentDefinition('verbose', ArgumentType::BOOLEAN, required: true);
expect($def->getUsageText())->toBe('--verbose');
});
it('gets usage text for optional boolean flag', function () {
$def = ArgumentDefinition::flag('verbose', 'v');
expect($def->getUsageText())->toBe('[--verbose]');
});
it('gets usage text for required string argument', function () {
$def = ArgumentDefinition::required('name');
expect($def->getUsageText())->toContain('--name');
expect($def->getUsageText())->toContain('<');
});
it('gets usage text for optional string argument', function () {
$def = ArgumentDefinition::optional('name', 'Guest');
expect($def->getUsageText())->toContain('--name');
expect($def->getUsageText())->toContain('[');
});
it('gets usage text with allowed values', function () {
$def = ArgumentDefinition::choice('mode', ['dev', 'prod']);
expect($def->getUsageText())->toContain('dev|prod');
});
it('validates required value', function () {
$def = ArgumentDefinition::required('name');
expect(fn () => $def->validateValue(null))
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
});
it('validates allowed values', function () {
$def = ArgumentDefinition::choice('mode', ['dev', 'prod', 'test']);
// Should not throw for valid value
$def->validateValue('dev');
expect(true)->toBeTrue();
});
it('throws exception for invalid allowed value', function () {
$def = ArgumentDefinition::choice('mode', ['dev', 'prod', 'test']);
expect(fn () => $def->validateValue('invalid'))
->toThrow(\InvalidArgumentException::class, "Invalid value 'invalid'");
});
it('allows empty value for optional argument', function () {
$def = ArgumentDefinition::optional('name', 'Guest');
// Should not throw
$def->validateValue(null);
$def->validateValue('');
expect(true)->toBeTrue();
});
it('validates value with default', function () {
$def = ArgumentDefinition::optional('name', 'Guest');
// Should not throw
$def->validateValue('John');
expect(true)->toBeTrue();
});
it('creates argument with all properties', function () {
$def = new ArgumentDefinition(
name: 'test',
type: ArgumentType::INTEGER,
required: false,
default: 42,
description: 'Test description',
shortName: 't',
allowedValues: []
);
expect($def->name)->toBe('test');
expect($def->type)->toBe(ArgumentType::INTEGER);
expect($def->required)->toBeFalse();
expect($def->default)->toBe(42);
expect($def->description)->toBe('Test description');
expect($def->shortName)->toBe('t');
});
});

View File

@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\ArgumentDefinition;
use App\Framework\Console\ArgumentParser;
use App\Framework\Console\ArgumentParserBuilder;
use App\Framework\Console\ArgumentType;
describe('ArgumentParser', function () {
it('parses simple positional arguments', function () {
$parser = new ArgumentParser();
$parsed = $parser->parse(['arg1', 'arg2']);
expect($parsed->getAllArguments())->toBeArray();
});
it('parses long options with equals sign', function () {
$parser = ArgumentParser::create()
->optionalString('name')
->build();
$parsed = $parser->parse(['--name=John']);
expect($parsed->get('name'))->toBe('John');
});
it('parses long options with space', function () {
$parser = ArgumentParser::create()
->optionalString('name')
->build();
$parsed = $parser->parse(['--name', 'John']);
expect($parsed->get('name'))->toBe('John');
});
it('parses boolean flags', function () {
$parser = ArgumentParser::create()
->flag('verbose', 'v')
->build();
$parsed = $parser->parse(['--verbose']);
expect($parsed->getBool('verbose'))->toBeTrue();
});
it('parses short options', function () {
$parser = ArgumentParser::create()
->addArgument(new ArgumentDefinition('name', ArgumentType::STRING, shortName: 'n'))
->build();
$parsed = $parser->parse(['-n', 'John']);
expect($parsed->get('name'))->toBe('John');
});
it('parses combined short flags', function () {
$parser = ArgumentParser::create()
->flag('verbose', 'v')
->flag('force', 'f')
->build();
$parsed = $parser->parse(['-vf']);
expect($parsed->getBool('verbose'))->toBeTrue();
expect($parsed->getBool('force'))->toBeTrue();
});
it('converts kebab-case to camelCase', function () {
$parser = ArgumentParser::create()
->addArgument(new ArgumentDefinition('dryRun', ArgumentType::BOOLEAN))
->optionalString('outputFile')
->build();
$parsed = $parser->parse(['--dry-run', '--output-file=test.txt']);
expect($parsed->getBool('dryRun'))->toBeTrue();
expect($parsed->get('outputFile'))->toBe('test.txt');
});
it('validates required arguments', function () {
$parser = ArgumentParser::create()
->requiredString('name')
->build();
expect(fn () => $parser->parse([]))
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
});
it('applies default values for optional arguments', function () {
$parser = ArgumentParser::create()
->optionalString('name', 'Guest')
->build();
$parsed = $parser->parse([]);
expect($parsed->get('name'))->toBe('Guest');
});
it('parses integer values', function () {
$parser = ArgumentParser::create()
->integer('count')
->build();
$parsed = $parser->parse(['--count=42']);
expect($parsed->getInt('count'))->toBe(42);
});
it('validates integer values', function () {
$parser = ArgumentParser::create()
->integer('count')
->build();
expect(fn () => $parser->parse(['--count=not-a-number']))
->toThrow(\InvalidArgumentException::class, 'not a valid integer');
});
it('parses float values', function () {
$parser = ArgumentParser::create()
->addArgument(new ArgumentDefinition('price', ArgumentType::FLOAT))
->build();
$parsed = $parser->parse(['--price=12.34']);
expect($parsed->getFloat('price'))->toBe(12.34);
});
it('parses boolean values', function () {
$parser = ArgumentParser::create()
->addArgument(new ArgumentDefinition('active', ArgumentType::BOOLEAN))
->build();
$parsed = $parser->parse(['--active=true']);
expect($parsed->getBool('active'))->toBeTrue();
});
it('parses array values from comma-separated string', function () {
$parser = ArgumentParser::create()
->addArgument(new ArgumentDefinition('items', ArgumentType::ARRAY))
->build();
$parsed = $parser->parse(['--items=item1,item2,item3']);
expect($parsed->getArray('items'))->toBe(['item1', 'item2', 'item3']);
});
it('validates email addresses', function () {
$parser = ArgumentParser::create()
->email('email')
->build();
$parsed = $parser->parse(['--email=user@example.com']);
expect($parsed->get('email'))->toBe('user@example.com');
});
it('throws exception for invalid email', function () {
$parser = ArgumentParser::create()
->email('email')
->build();
expect(fn () => $parser->parse(['--email=invalid-email']))
->toThrow(\InvalidArgumentException::class, 'not a valid email address');
});
it('validates URL addresses', function () {
$parser = ArgumentParser::create()
->addArgument(new ArgumentDefinition('url', ArgumentType::URL))
->build();
$parsed = $parser->parse(['--url=https://example.com']);
expect($parsed->get('url'))->toBe('https://example.com');
});
it('throws exception for invalid URL', function () {
$parser = ArgumentParser::create()
->addArgument(new ArgumentDefinition('url', ArgumentType::URL))
->build();
expect(fn () => $parser->parse(['--url=not-a-url']))
->toThrow(\InvalidArgumentException::class, 'not a valid URL');
});
it('validates allowed values', function () {
$parser = ArgumentParser::create()
->choice('mode', ['dev', 'prod', 'test'])
->build();
$parsed = $parser->parse(['--mode=dev']);
expect($parsed->get('mode'))->toBe('dev');
});
it('throws exception for invalid choice value', function () {
$parser = ArgumentParser::create()
->choice('mode', ['dev', 'prod', 'test'])
->build();
expect(fn () => $parser->parse(['--mode=invalid']))
->toThrow(\InvalidArgumentException::class, "Invalid value 'invalid'");
});
it('handles positional arguments', function () {
$parser = ArgumentParser::create()
->addArgument(new ArgumentDefinition('name', ArgumentType::STRING))
->addArgument(new ArgumentDefinition('age', ArgumentType::INTEGER))
->build();
$parsed = $parser->parse(['John', '25']);
expect($parsed->get('name'))->toBe('John');
expect($parsed->getInt('age'))->toBe(25);
});
it('throws exception when required option value is missing', function () {
$parser = ArgumentParser::create()
->requiredString('name')
->build();
expect(fn () => $parser->parse(['--name']))
->toThrow(\InvalidArgumentException::class, "Option '--name' requires a value");
});
it('handles optional boolean flags without value', function () {
$parser = ArgumentParser::create()
->flag('verbose', 'v')
->build();
$parsed = $parser->parse(['--verbose']);
expect($parsed->getBool('verbose'))->toBeTrue();
});
it('prevents combining short options that require values', function () {
$parser = ArgumentParser::create()
->addArgument(new ArgumentDefinition('name', ArgumentType::STRING, shortName: 'n'))
->flag('verbose', 'v')
->build();
expect(fn () => $parser->parse(['-nv']))
->toThrow(\InvalidArgumentException::class, "requires a value and cannot be combined");
});
it('returns all definitions', function () {
$parser = ArgumentParser::create()
->optionalString('name')
->flag('verbose', 'v')
->build();
$definitions = $parser->getDefinitions();
expect($definitions)->toHaveKey('name');
expect($definitions)->toHaveKey('verbose');
});
});
describe('ArgumentParserBuilder', function () {
it('creates parser with fluent interface', function () {
$parser = ArgumentParser::create()
->requiredString('name')
->build();
expect($parser)->toBeInstanceOf(ArgumentParser::class);
});
it('supports all argument definition factory methods', function () {
$parser = ArgumentParser::create()
->requiredString('arg1')
->optionalString('opt1')
->flag('flag1', 'f')
->choice('choice1', ['a', 'b', 'c'])
->build();
expect($parser)->toBeInstanceOf(ArgumentParser::class);
});
});

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\ArgumentType;
describe('ArgumentType', function () {
it('has all expected enum values', function () {
expect(ArgumentType::STRING->value)->toBe('string');
expect(ArgumentType::INTEGER->value)->toBe('int');
expect(ArgumentType::FLOAT->value)->toBe('float');
expect(ArgumentType::BOOLEAN->value)->toBe('bool');
expect(ArgumentType::ARRAY->value)->toBe('array');
expect(ArgumentType::EMAIL->value)->toBe('email');
expect(ArgumentType::URL->value)->toBe('url');
});
it('provides descriptions for all types', function () {
expect(ArgumentType::STRING->getDescription())->toBe('Text string');
expect(ArgumentType::INTEGER->getDescription())->toBe('Integer number');
expect(ArgumentType::FLOAT->getDescription())->toBe('Decimal number');
expect(ArgumentType::BOOLEAN->getDescription())->toBe('True/false flag');
expect(ArgumentType::ARRAY->getDescription())->toBe('Comma-separated values');
expect(ArgumentType::EMAIL->getDescription())->toBe('Valid email address');
expect(ArgumentType::URL->getDescription())->toBe('Valid URL');
});
it('checks if type requires value', function () {
expect(ArgumentType::STRING->requiresValue())->toBeTrue();
expect(ArgumentType::INTEGER->requiresValue())->toBeTrue();
expect(ArgumentType::FLOAT->requiresValue())->toBeTrue();
expect(ArgumentType::BOOLEAN->requiresValue())->toBeFalse();
expect(ArgumentType::ARRAY->requiresValue())->toBeTrue();
expect(ArgumentType::EMAIL->requiresValue())->toBeTrue();
expect(ArgumentType::URL->requiresValue())->toBeTrue();
});
it('provides example values for all types', function () {
expect(ArgumentType::STRING->getExample())->toBe('text');
expect(ArgumentType::INTEGER->getExample())->toBe('123');
expect(ArgumentType::FLOAT->getExample())->toBe('12.34');
expect(ArgumentType::BOOLEAN->getExample())->toBe('true|false');
expect(ArgumentType::ARRAY->getExample())->toBe('item1,item2,item3');
expect(ArgumentType::EMAIL->getExample())->toBe('user@example.com');
expect(ArgumentType::URL->getExample())->toBe('https://example.com');
});
it('can be used as string value', function () {
$type = ArgumentType::STRING;
expect($type->value)->toBeString();
expect($type->value)->toBe('string');
});
});

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\CommandCategorizer;
use App\Framework\Console\CommandList;
use App\Framework\Console\ConsoleCommand;
describe('CommandCategorizer', function () {
it('categorizes commands by namespace prefix', function () {
$commands = [
new ConsoleCommand('demo:hello', 'Hello command'),
new ConsoleCommand('demo:colors', 'Colors command'),
new ConsoleCommand('db:migrate', 'Migrate command'),
new ConsoleCommand('db:rollback', 'Rollback command'),
];
$commandList = new CommandList(...$commands);
$categorizer = new CommandCategorizer();
$categories = $categorizer->categorize($commandList);
expect($categories)->toHaveKey('demo');
expect($categories)->toHaveKey('db');
expect($categories['demo'])->toHaveCount(2);
expect($categories['db'])->toHaveCount(2);
});
it('sorts categories alphabetically', function () {
$commands = [
new ConsoleCommand('zebra:command', 'Zebra command'),
new ConsoleCommand('alpha:command', 'Alpha command'),
new ConsoleCommand('beta:command', 'Beta command'),
];
$commandList = new CommandList(...$commands);
$categorizer = new CommandCategorizer();
$categories = $categorizer->categorize($commandList);
$keys = array_keys($categories);
expect($keys)->toBe(['alpha', 'beta', 'zebra']);
});
it('handles commands without namespace', function () {
$commands = [
new ConsoleCommand('standalone', 'Standalone command'),
new ConsoleCommand('demo:hello', 'Hello command'),
];
$commandList = new CommandList(...$commands);
$categorizer = new CommandCategorizer();
$categories = $categorizer->categorize($commandList);
expect($categories)->toHaveKey('standalone');
expect($categories)->toHaveKey('demo');
expect($categories['standalone'])->toHaveCount(1);
});
it('handles empty command list', function () {
$commandList = CommandList::empty();
$categorizer = new CommandCategorizer();
$categories = $categorizer->categorize($commandList);
expect($categories)->toBeEmpty();
});
it('groups multiple commands in same category', function () {
$commands = [
new ConsoleCommand('demo:hello', 'Hello'),
new ConsoleCommand('demo:colors', 'Colors'),
new ConsoleCommand('demo:interactive', 'Interactive'),
new ConsoleCommand('demo:menu', 'Menu'),
];
$commandList = new CommandList(...$commands);
$categorizer = new CommandCategorizer();
$categories = $categorizer->categorize($commandList);
expect($categories['demo'])->toHaveCount(4);
});
it('handles commands with multiple colons', function () {
$commands = [
new ConsoleCommand('db:migrate:up', 'Migrate up'),
new ConsoleCommand('db:migrate:down', 'Migrate down'),
];
$commandList = new CommandList(...$commands);
$categorizer = new CommandCategorizer();
$categories = $categorizer->categorize($commandList);
// Should use first part before first colon
expect($categories)->toHaveKey('db');
expect($categories['db'])->toHaveCount(2);
});
it('gets category description for known category', function () {
$categorizer = new CommandCategorizer();
expect($categorizer->getCategoryDescription('db'))->toBe('Database operations (migrations, health checks)');
expect($categorizer->getCategoryDescription('demo'))->toBe('Demo and example commands');
expect($categorizer->getCategoryDescription('cache'))->toBe('Cache management operations');
});
it('returns default description for unknown category', function () {
$categorizer = new CommandCategorizer();
expect($categorizer->getCategoryDescription('unknown'))->toBe('Various commands');
});
it('gets all category info', function () {
$categorizer = new CommandCategorizer();
$info = $categorizer->getCategoryInfo();
expect($info)->toBeArray();
expect($info)->toHaveKey('db');
expect($info)->toHaveKey('demo');
expect($info)->toHaveKey('cache');
});
it('handles single command in category', function () {
$commands = [
new ConsoleCommand('unique:command', 'Unique command'),
];
$commandList = new CommandList(...$commands);
$categorizer = new CommandCategorizer();
$categories = $categorizer->categorize($commandList);
expect($categories)->toHaveKey('unique');
expect($categories['unique'])->toHaveCount(1);
});
it('preserves command order within category', function () {
$commands = [
new ConsoleCommand('demo:first', 'First'),
new ConsoleCommand('demo:second', 'Second'),
new ConsoleCommand('demo:third', 'Third'),
];
$commandList = new CommandList(...$commands);
$categorizer = new CommandCategorizer();
$categories = $categorizer->categorize($commandList);
$demoCommands = $categories['demo'];
expect($demoCommands[0]->name)->toBe('demo:first');
expect($demoCommands[1]->name)->toBe('demo:second');
expect($demoCommands[2]->name)->toBe('demo:third');
});
});

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\CommandList;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\Exceptions\CommandNotFoundException;
use App\Framework\Console\Exceptions\DuplicateCommandException;
describe('CommandList', function () {
it('creates empty command list', function () {
$list = CommandList::empty();
expect($list->count())->toBe(0);
expect($list->isEmpty())->toBeTrue();
});
it('creates command list with commands', function () {
$command1 = new ConsoleCommand('test:command1', 'Description 1');
$command2 = new ConsoleCommand('test:command2', 'Description 2');
$list = new CommandList($command1, $command2);
expect($list->count())->toBe(2);
expect($list->has('test:command1'))->toBeTrue();
expect($list->has('test:command2'))->toBeTrue();
});
it('throws exception for duplicate command names', function () {
$command1 = new ConsoleCommand('test:command', 'Description 1');
$command2 = new ConsoleCommand('test:command', 'Description 2');
expect(fn () => new CommandList($command1, $command2))
->toThrow(DuplicateCommandException::class);
});
it('adds command to list', function () {
$command1 = new ConsoleCommand('test:command1', 'Description 1');
$command2 = new ConsoleCommand('test:command2', 'Description 2');
$list = CommandList::empty();
$list = $list->add($command1);
$list = $list->add($command2);
expect($list->count())->toBe(2);
expect($list->has('test:command1'))->toBeTrue();
expect($list->has('test:command2'))->toBeTrue();
});
it('throws exception when adding duplicate command', function () {
$command1 = new ConsoleCommand('test:command', 'Description 1');
$command2 = new ConsoleCommand('test:command', 'Description 2');
$list = new CommandList($command1);
expect(fn () => $list->add($command2))
->toThrow(DuplicateCommandException::class);
});
it('gets command by name', function () {
$command = new ConsoleCommand('test:command', 'Description');
$list = new CommandList($command);
$retrieved = $list->get('test:command');
expect($retrieved)->toBe($command);
expect($retrieved->name)->toBe('test:command');
expect($retrieved->description)->toBe('Description');
});
it('throws exception when getting non-existent command', function () {
$list = CommandList::empty();
expect(fn () => $list->get('nonexistent:command'))
->toThrow(CommandNotFoundException::class);
});
it('returns all command names', function () {
$command1 = new ConsoleCommand('test:command1', 'Description 1');
$command2 = new ConsoleCommand('test:command2', 'Description 2');
$command3 = new ConsoleCommand('test:command3', 'Description 3');
$list = new CommandList($command1, $command2, $command3);
$names = $list->getNames();
expect($names)->toHaveCount(3);
expect($names)->toContain('test:command1');
expect($names)->toContain('test:command2');
expect($names)->toContain('test:command3');
});
it('finds similar commands using Levenshtein distance', function () {
$command1 = new ConsoleCommand('test:hello', 'Description');
$command2 = new ConsoleCommand('test:help', 'Description');
$command3 = new ConsoleCommand('test:world', 'Description');
$command4 = new ConsoleCommand('test:hell', 'Description');
$list = new CommandList($command1, $command2, $command3, $command4);
// 'test:helo' is similar to 'test:hello' (distance 1)
$similar = $list->findSimilar('test:helo', 2);
expect($similar)->toContain('test:hello');
expect($similar)->toContain('test:help');
expect($similar)->toContain('test:hell');
});
it('returns empty array when no similar commands found', function () {
$command = new ConsoleCommand('test:command', 'Description');
$list = new CommandList($command);
$similar = $list->findSimilar('completely:different', 3);
expect($similar)->toBeEmpty();
});
it('respects max distance parameter', function () {
$command1 = new ConsoleCommand('test:hello', 'Description');
$command2 = new ConsoleCommand('test:world', 'Description');
$list = new CommandList($command1, $command2);
// 'test:helo' has distance 1 from 'test:hello', but distance 4 from 'test:world'
$similar = $list->findSimilar('test:helo', 1);
expect($similar)->toContain('test:hello');
expect($similar)->not->toContain('test:world');
});
it('does not include exact match in similar results', function () {
$command = new ConsoleCommand('test:command', 'Description');
$list = new CommandList($command);
$similar = $list->findSimilar('test:command', 3);
// Exact match should not be included (distance 0)
expect($similar)->toBeEmpty();
});
it('implements IteratorAggregate', function () {
$command1 = new ConsoleCommand('test:command1', 'Description 1');
$command2 = new ConsoleCommand('test:command2', 'Description 2');
$list = new CommandList($command1, $command2);
$commands = [];
foreach ($list as $name => $command) {
$commands[$name] = $command;
}
expect($commands)->toHaveCount(2);
expect($commands['test:command1'])->toBe($command1);
expect($commands['test:command2'])->toBe($command2);
});
it('implements Countable', function () {
$command1 = new ConsoleCommand('test:command1', 'Description 1');
$command2 = new ConsoleCommand('test:command2', 'Description 2');
$list = new CommandList($command1, $command2);
expect(count($list))->toBe(2);
});
it('converts to array', function () {
$command1 = new ConsoleCommand('test:command1', 'Description 1');
$command2 = new ConsoleCommand('test:command2', 'Description 2');
$list = new CommandList($command1, $command2);
$array = $list->toArray();
expect($array)->toHaveCount(2);
expect($array)->toHaveKey('test:command1');
expect($array)->toHaveKey('test:command2');
});
it('returns all commands as array', function () {
$command1 = new ConsoleCommand('test:command1', 'Description 1');
$command2 = new ConsoleCommand('test:command2', 'Description 2');
$list = new CommandList($command1, $command2);
$allCommands = $list->getAllCommands();
expect($allCommands)->toHaveCount(2);
expect($allCommands)->toContain($command1);
expect($allCommands)->toContain($command2);
});
});

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\CommandParameterResolver;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\MethodSignatureAnalyzer;
use ReflectionMethod;
class TestCommandClass
{
public function simpleMethod(): void
{
}
public function methodWithString(string $name): void
{
}
public function methodWithInt(int $count): void
{
}
public function methodWithBool(bool $active): void
{
}
public function methodWithArray(array $items): void
{
}
public function methodWithDefault(string $name = 'Guest'): void
{
}
public function methodWithNullable(?string $name): void
{
}
public function methodWithConsoleInput(ConsoleInput $input): void
{
}
public function methodWithConsoleOutput(ConsoleOutput $output): void
{
}
public function methodWithBoth(ConsoleInput $input, ConsoleOutput $output, string $name): void
{
}
public function methodWithOptionalFrameworkParams(string $name, ?ConsoleInput $input = null): void
{
}
}
describe('CommandParameterResolver', function () {
beforeEach(function () {
$this->analyzer = new MethodSignatureAnalyzer();
$this->resolver = new CommandParameterResolver($this->analyzer);
});
it('resolves simple string parameter', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithString');
$resolved = $this->resolver->resolveParameters($method, ['--name=John']);
expect($resolved)->toHaveCount(1);
expect($resolved[0])->toBe('John');
});
it('resolves integer parameter', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithInt');
$resolved = $this->resolver->resolveParameters($method, ['--count=42']);
expect($resolved)->toHaveCount(1);
expect($resolved[0])->toBe(42);
expect($resolved[0])->toBeInt();
});
it('resolves boolean parameter', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithBool');
$resolved = $this->resolver->resolveParameters($method, ['--active=true']);
expect($resolved)->toHaveCount(1);
expect($resolved[0])->toBeTrue();
});
it('resolves array parameter', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithArray');
$resolved = $this->resolver->resolveParameters($method, ['--items=item1,item2,item3']);
expect($resolved)->toHaveCount(1);
expect($resolved[0])->toBe(['item1', 'item2', 'item3']);
});
it('uses default values for optional parameters', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithDefault');
$resolved = $this->resolver->resolveParameters($method, []);
expect($resolved)->toHaveCount(1);
expect($resolved[0])->toBe('Guest');
});
it('resolves nullable parameters as null when missing', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithNullable');
$resolved = $this->resolver->resolveParameters($method, []);
expect($resolved)->toHaveCount(1);
expect($resolved[0])->toBeNull();
});
it('resolves ConsoleInput parameter', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithConsoleInput');
$input = new ConsoleInput([]);
$resolved = $this->resolver->resolveParameters($method, [], $input);
expect($resolved)->toHaveCount(1);
expect($resolved[0])->toBe($input);
});
it('resolves ConsoleOutput parameter', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithConsoleOutput');
$output = new ConsoleOutput();
$resolved = $this->resolver->resolveParameters($method, [], null, $output);
expect($resolved)->toHaveCount(1);
expect($resolved[0])->toBe($output);
});
it('resolves both ConsoleInput and ConsoleOutput', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithBoth');
$input = new ConsoleInput([]);
$output = new ConsoleOutput();
$resolved = $this->resolver->resolveParameters($method, ['--name=John'], $input, $output);
expect($resolved)->toHaveCount(3);
expect($resolved[0])->toBe($input);
expect($resolved[1])->toBe($output);
expect($resolved[2])->toBe('John');
});
it('handles optional framework parameters', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithOptionalFrameworkParams');
$resolved = $this->resolver->resolveParameters($method, ['--name=John']);
expect($resolved)->toHaveCount(2);
expect($resolved[0])->toBe('John');
expect($resolved[1])->toBeNull();
});
it('throws exception for missing required parameter', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithString');
expect(fn () => $this->resolver->resolveParameters($method, []))
->toThrow(\InvalidArgumentException::class, "Required parameter 'name' is missing");
});
it('throws exception when ConsoleInput is required but not provided', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithConsoleInput');
expect(fn () => $this->resolver->resolveParameters($method, []))
->toThrow(\InvalidArgumentException::class, "ConsoleInput is required but not provided");
});
it('validates method signature compatibility', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'simpleMethod');
// Should not throw for valid method
$this->resolver->validateMethodSignature($method);
expect(true)->toBeTrue();
});
it('creates parser for method', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithString');
$parser = $this->resolver->createParserForMethod($method);
expect($parser)->toBeInstanceOf(\App\Framework\Console\ArgumentParser::class);
});
});
describe('CommandParameterResolver Type Conversion', function () {
beforeEach(function () {
$this->analyzer = new MethodSignatureAnalyzer();
$this->resolver = new CommandParameterResolver($this->analyzer);
});
it('converts string values correctly', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithString');
$resolved = $this->resolver->resolveParameters($method, ['--name=123']);
expect($resolved[0])->toBe('123');
expect($resolved[0])->toBeString();
});
it('converts integer values correctly', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithInt');
$resolved = $this->resolver->resolveParameters($method, ['--count=42']);
expect($resolved[0])->toBe(42);
expect($resolved[0])->toBeInt();
});
it('converts boolean values correctly', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithBool');
$resolved = $this->resolver->resolveParameters($method, ['--active=true']);
expect($resolved[0])->toBeTrue();
$resolved = $this->resolver->resolveParameters($method, ['--active=false']);
expect($resolved[0])->toBeFalse();
$resolved = $this->resolver->resolveParameters($method, ['--active=1']);
expect($resolved[0])->toBeTrue();
$resolved = $this->resolver->resolveParameters($method, ['--active=0']);
expect($resolved[0])->toBeFalse();
});
it('converts array values correctly', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithArray');
$resolved = $this->resolver->resolveParameters($method, ['--items=item1,item2,item3']);
expect($resolved[0])->toBeArray();
expect($resolved[0])->toBe(['item1', 'item2', 'item3']);
});
it('throws exception for invalid type conversion', function () {
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithInt');
expect(fn () => $this->resolver->resolveParameters($method, ['--count=not-a-number']))
->toThrow(\InvalidArgumentException::class);
});
});

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\CommandResultProcessor;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Result\TextResult;
use App\Framework\DI\DefaultContainer;
use Tests\Framework\Console\Helpers\TestConsoleOutput;
describe('CommandResultProcessor', function () {
beforeEach(function () {
$this->container = new DefaultContainer();
$this->output = new TestConsoleOutput();
$this->processor = new CommandResultProcessor($this->container, $this->output);
});
it('processes ConsoleResult', function () {
$result = TextResult::success('Test output');
$exitCode = $this->processor->process($result);
expect($exitCode)->toBe(ExitCode::SUCCESS);
expect($this->output->getOutput())->toContain('Test output');
});
it('renders ConsoleResult to output', function () {
$result = TextResult::success('Rendered message');
$this->processor->process($result);
expect($this->output->getOutput())->toContain('Rendered message');
});
it('processes ExitCode directly', function () {
$exitCode = $this->processor->process(ExitCode::SUCCESS);
expect($exitCode)->toBe(ExitCode::SUCCESS);
});
it('processes different ExitCode values', function () {
expect($this->processor->process(ExitCode::GENERAL_ERROR))->toBe(ExitCode::GENERAL_ERROR);
expect($this->processor->process(ExitCode::COMMAND_NOT_FOUND))->toBe(ExitCode::COMMAND_NOT_FOUND);
expect($this->processor->process(ExitCode::INVALID_INPUT))->toBe(ExitCode::INVALID_INPUT);
});
it('processes legacy int return values', function () {
$exitCode = $this->processor->process(0);
expect($exitCode)->toBe(ExitCode::SUCCESS);
expect($exitCode->value)->toBe(0);
});
it('converts int to ExitCode', function () {
expect($this->processor->process(1))->toBe(ExitCode::GENERAL_ERROR);
expect($this->processor->process(64))->toBe(ExitCode::COMMAND_NOT_FOUND);
});
it('handles invalid return types', function () {
$exitCode = $this->processor->process('invalid');
expect($exitCode)->toBe(ExitCode::GENERAL_ERROR);
});
it('handles null return type', function () {
$exitCode = $this->processor->process(null);
expect($exitCode)->toBe(ExitCode::GENERAL_ERROR);
});
it('handles array return type', function () {
$exitCode = $this->processor->process([]);
expect($exitCode)->toBe(ExitCode::GENERAL_ERROR);
});
it('handles object return type', function () {
$exitCode = $this->processor->process(new \stdClass());
expect($exitCode)->toBe(ExitCode::GENERAL_ERROR);
});
it('processes ConsoleResult with different exit codes', function () {
$successResult = TextResult::success('Success');
expect($this->processor->process($successResult))->toBe(ExitCode::SUCCESS);
$errorResult = TextResult::error('Error');
expect($this->processor->process($errorResult))->toBe(ExitCode::FAILURE);
});
it('renders multiple ConsoleResults correctly', function () {
$result1 = TextResult::success('First message');
$result2 = TextResult::success('Second message');
$this->processor->process($result1);
$this->output->clear();
$this->processor->process($result2);
expect($this->output->getOutput())->toContain('Second message');
expect($this->output->getOutput())->not->toContain('First message');
});
});

View File

@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console\Components;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\CommandList;
use App\Framework\Console\CommandGroupRegistry;
use App\Framework\Console\Components\ConsoleDialog;
use App\Framework\Console\Components\DialogCommandExecutor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleOutput;
use App\Framework\DI\DefaultContainer;
use App\Framework\Discovery\Results\AttributeRegistry;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\Results\InterfaceRegistry;
use App\Framework\Discovery\Results\TemplateRegistry;
use Tests\Framework\Console\Helpers\TestConsoleOutput;
describe('ConsoleDialog', function () {
beforeEach(function () {
$this->container = new DefaultContainer();
$this->output = new TestConsoleOutput();
$this->discoveryRegistry = new DiscoveryRegistry(
attributes: new AttributeRegistry(),
interfaces: new InterfaceRegistry([]),
templates: new TemplateRegistry([])
);
$this->commandHistory = new CommandHistory();
$this->groupRegistry = new CommandGroupRegistry($this->discoveryRegistry);
$this->commandList = CommandList::empty();
$this->commandExecutor = new DialogCommandExecutor(
$this->output,
new \App\Framework\Console\CommandRegistry($this->container, $this->discoveryRegistry),
$this->commandHistory,
'test-console'
);
});
it('can be instantiated', function () {
$dialog = new ConsoleDialog(
$this->output,
$this->discoveryRegistry,
$this->commandHistory,
$this->groupRegistry,
$this->commandExecutor,
$this->commandList,
$this->container
);
expect($dialog)->toBeInstanceOf(ConsoleDialog::class);
});
it('can be instantiated with custom prompt', function () {
$dialog = new ConsoleDialog(
$this->output,
$this->discoveryRegistry,
$this->commandHistory,
$this->groupRegistry,
$this->commandExecutor,
$this->commandList,
$this->container,
'custom> '
);
expect($dialog)->toBeInstanceOf(ConsoleDialog::class);
});
it('has run method', function () {
$dialog = new ConsoleDialog(
$this->output,
$this->discoveryRegistry,
$this->commandHistory,
$this->groupRegistry,
$this->commandExecutor,
$this->commandList,
$this->container
);
expect(method_exists($dialog, 'run'))->toBeTrue();
});
it('has completeCommand method for readline', function () {
$dialog = new ConsoleDialog(
$this->output,
$this->discoveryRegistry,
$this->commandHistory,
$this->groupRegistry,
$this->commandExecutor,
$this->commandList,
$this->container
);
expect(method_exists($dialog, 'completeCommand'))->toBeTrue();
});
it('detects readline availability', function () {
$dialog = new ConsoleDialog(
$this->output,
$this->discoveryRegistry,
$this->commandHistory,
$this->groupRegistry,
$this->commandExecutor,
$this->commandList,
$this->container
);
// Should detect readline if available
expect($dialog)->toBeInstanceOf(ConsoleDialog::class);
});
});
describe('ConsoleDialog Input Parsing', function () {
beforeEach(function () {
$this->container = new DefaultContainer();
$this->output = new TestConsoleOutput();
$this->discoveryRegistry = new DiscoveryRegistry(
attributes: new AttributeRegistry(),
interfaces: new InterfaceRegistry([]),
templates: new TemplateRegistry([])
);
$this->commandHistory = new CommandHistory();
$this->groupRegistry = new CommandGroupRegistry($this->discoveryRegistry);
$this->commandList = CommandList::empty();
$this->commandExecutor = new DialogCommandExecutor(
$this->output,
new \App\Framework\Console\CommandRegistry($this->container, $this->discoveryRegistry),
$this->commandHistory,
'test-console'
);
$this->dialog = new ConsoleDialog(
$this->output,
$this->discoveryRegistry,
$this->commandHistory,
$this->groupRegistry,
$this->commandExecutor,
$this->commandList,
$this->container
);
});
it('parses simple command', function () {
// Use reflection to access private parseInput method
$reflection = new \ReflectionClass($this->dialog);
$method = $reflection->getMethod('parseInput');
$method->setAccessible(true);
$result = $method->invoke($this->dialog, 'demo:hello');
expect($result)->toHaveKey('command');
expect($result)->toHaveKey('arguments');
expect($result['command'])->toBe('demo:hello');
expect($result['arguments'])->toBeEmpty();
});
it('parses command with arguments', function () {
$reflection = new \ReflectionClass($this->dialog);
$method = $reflection->getMethod('parseInput');
$method->setAccessible(true);
$result = $method->invoke($this->dialog, 'demo:hello arg1 arg2 arg3');
expect($result['command'])->toBe('demo:hello');
expect($result['arguments'])->toBe(['arg1', 'arg2', 'arg3']);
});
it('parses command with quoted arguments', function () {
$reflection = new \ReflectionClass($this->dialog);
$method = $reflection->getMethod('parseInput');
$method->setAccessible(true);
$result = $method->invoke($this->dialog, 'command "arg with spaces"');
expect($result['command'])->toBe('command');
expect($result['arguments'])->toHaveCount(1);
expect($result['arguments'][0])->toBe('arg with spaces');
});
it('parses command with single-quoted arguments', function () {
$reflection = new \ReflectionClass($this->dialog);
$method = $reflection->getMethod('parseInput');
$method->setAccessible(true);
$result = $method->invoke($this->dialog, "command 'arg with spaces'");
expect($result['command'])->toBe('command');
expect($result['arguments'])->toHaveCount(1);
expect($result['arguments'][0])->toBe('arg with spaces');
});
it('parses empty input', function () {
$reflection = new \ReflectionClass($this->dialog);
$method = $reflection->getMethod('parseInput');
$method->setAccessible(true);
$result = $method->invoke($this->dialog, '');
expect($result['command'])->toBe('');
expect($result['arguments'])->toBeEmpty();
});
it('handles multiple spaces between arguments', function () {
$reflection = new \ReflectionClass($this->dialog);
$method = $reflection->getMethod('parseInput');
$method->setAccessible(true);
$result = $method->invoke($this->dialog, 'command arg1 arg2');
expect($result['command'])->toBe('command');
expect($result['arguments'])->toBe(['arg1', 'arg2']);
});
it('handles escaped quotes in quoted strings', function () {
$reflection = new \ReflectionClass($this->dialog);
$method = $reflection->getMethod('parseInput');
$method->setAccessible(true);
$result = $method->invoke($this->dialog, 'command "arg with \\"quotes\\""');
expect($result['command'])->toBe('command');
expect($result['arguments'])->toHaveCount(1);
});
});
describe('ConsoleDialog Command Suggestions', function () {
beforeEach(function () {
$this->container = new DefaultContainer();
$this->output = new TestConsoleOutput();
$this->discoveryRegistry = new DiscoveryRegistry(
attributes: new AttributeRegistry(),
interfaces: new InterfaceRegistry([]),
templates: new TemplateRegistry([])
);
$this->commandHistory = new CommandHistory();
$this->groupRegistry = new CommandGroupRegistry($this->discoveryRegistry);
$this->commandList = new CommandList(
new ConsoleCommand('demo:hello', 'Hello command'),
new ConsoleCommand('demo:colors', 'Colors command'),
new ConsoleCommand('db:migrate', 'Migrate command')
);
$this->commandExecutor = new DialogCommandExecutor(
$this->output,
new \App\Framework\Console\CommandRegistry($this->container, $this->discoveryRegistry),
$this->commandHistory,
'test-console'
);
$this->dialog = new ConsoleDialog(
$this->output,
$this->discoveryRegistry,
$this->commandHistory,
$this->groupRegistry,
$this->commandExecutor,
$this->commandList,
$this->container
);
});
it('gets command suggestions for partial match', function () {
$reflection = new \ReflectionClass($this->dialog);
$method = $reflection->getMethod('getCommandSuggestions');
$method->setAccessible(true);
$suggestions = $method->invoke($this->dialog, 'demo');
expect($suggestions)->toBeArray();
expect($suggestions)->toContain('demo:hello');
expect($suggestions)->toContain('demo:colors');
});
it('gets empty suggestions for no match', function () {
$reflection = new \ReflectionClass($this->dialog);
$method = $reflection->getMethod('getCommandSuggestions');
$method->setAccessible(true);
$suggestions = $method->invoke($this->dialog, 'nonexistent');
expect($suggestions)->toBeArray();
});
it('gets case-insensitive suggestions', function () {
$reflection = new \ReflectionClass($this->dialog);
$method = $reflection->getMethod('getCommandSuggestions');
$method->setAccessible(true);
$suggestions = $method->invoke($this->dialog, 'DEMO');
expect($suggestions)->toBeArray();
});
});

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console\Components;
use App\Framework\Console\CommandGroupRegistry;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\Components\ConsoleTUI;
use App\Framework\Console\Components\TuiCommandExecutor;
use App\Framework\Console\Components\TuiRenderer;
use App\Framework\Console\Components\TuiState;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\SimpleWorkflowExecutor;
use App\Framework\DI\DefaultContainer;
use App\Framework\Discovery\Results\AttributeRegistry;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\Results\InterfaceRegistry;
use App\Framework\Discovery\Results\TemplateRegistry;
describe('ConsoleTUI', function () {
beforeEach(function () {
$this->container = new DefaultContainer();
$this->output = new ConsoleOutput();
$this->discoveryRegistry = new DiscoveryRegistry(
attributes: new AttributeRegistry(),
interfaces: new InterfaceRegistry([]),
templates: new TemplateRegistry([])
);
$this->state = new TuiState();
$this->renderer = new TuiRenderer($this->output, $this->state);
$this->commandExecutor = new TuiCommandExecutor(
new \App\Framework\Console\CommandRegistry($this->container, $this->discoveryRegistry),
$this->container,
$this->discoveryRegistry,
new CommandHistory(),
new \App\Framework\Console\CommandValidator(),
new \App\Framework\Console\CommandHelpGenerator(new \App\Framework\Console\ParameterInspector()),
'test-console'
);
$this->commandHistory = new CommandHistory();
$this->groupRegistry = new CommandGroupRegistry($this->discoveryRegistry);
$this->workflowExecutor = new SimpleWorkflowExecutor($this->container, $this->discoveryRegistry);
});
it('can be instantiated', function () {
$tui = new ConsoleTUI(
$this->output,
$this->container,
$this->discoveryRegistry,
$this->state,
$this->renderer,
$this->commandExecutor,
$this->commandHistory,
$this->groupRegistry,
$this->workflowExecutor
);
expect($tui)->toBeInstanceOf(ConsoleTUI::class);
});
it('has run method', function () {
$tui = new ConsoleTUI(
$this->output,
$this->container,
$this->discoveryRegistry,
$this->state,
$this->renderer,
$this->commandExecutor,
$this->commandHistory,
$this->groupRegistry,
$this->workflowExecutor
);
expect(method_exists($tui, 'run'))->toBeTrue();
});
it('has handleShutdownSignal method', function () {
$tui = new ConsoleTUI(
$this->output,
$this->container,
$this->discoveryRegistry,
$this->state,
$this->renderer,
$this->commandExecutor,
$this->commandHistory,
$this->groupRegistry,
$this->workflowExecutor
);
expect(method_exists($tui, 'handleShutdownSignal'))->toBeTrue();
});
it('has handleShutdownSignalLegacy method', function () {
$tui = new ConsoleTUI(
$this->output,
$this->container,
$this->discoveryRegistry,
$this->state,
$this->renderer,
$this->commandExecutor,
$this->commandHistory,
$this->groupRegistry,
$this->workflowExecutor
);
expect(method_exists($tui, 'handleShutdownSignalLegacy'))->toBeTrue();
});
it('initializes with all required dependencies', function () {
$tui = new ConsoleTUI(
$this->output,
$this->container,
$this->discoveryRegistry,
$this->state,
$this->renderer,
$this->commandExecutor,
$this->commandHistory,
$this->groupRegistry,
$this->workflowExecutor
);
expect($tui)->toBeInstanceOf(ConsoleTUI::class);
});
});
describe('ConsoleTUI Terminal Compatibility', function () {
beforeEach(function () {
$this->container = new DefaultContainer();
$this->output = new ConsoleOutput();
$this->discoveryRegistry = new DiscoveryRegistry(
attributes: new AttributeRegistry(),
interfaces: new InterfaceRegistry([]),
templates: new TemplateRegistry([])
);
$this->state = new TuiState();
$this->renderer = new TuiRenderer($this->output, $this->state);
$this->commandExecutor = new TuiCommandExecutor(
new \App\Framework\Console\CommandRegistry($this->container, $this->discoveryRegistry),
$this->container,
$this->discoveryRegistry,
new CommandHistory(),
new \App\Framework\Console\CommandValidator(),
new \App\Framework\Console\CommandHelpGenerator(new \App\Framework\Console\ParameterInspector()),
'test-console'
);
$this->commandHistory = new CommandHistory();
$this->groupRegistry = new CommandGroupRegistry($this->discoveryRegistry);
$this->workflowExecutor = new SimpleWorkflowExecutor($this->container, $this->discoveryRegistry);
});
it('handles terminal compatibility check', function () {
$tui = new ConsoleTUI(
$this->output,
$this->container,
$this->discoveryRegistry,
$this->state,
$this->renderer,
$this->commandExecutor,
$this->commandHistory,
$this->groupRegistry,
$this->workflowExecutor
);
// TUI should handle terminal compatibility internally
expect($tui)->toBeInstanceOf(ConsoleTUI::class);
});
});

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console\Components;
use App\Framework\Console\Components\InteractiveMenu;
use App\Framework\Console\ConsoleOutput;
use Tests\Framework\Console\Helpers\TestConsoleOutput;
describe('InteractiveMenu', function () {
beforeEach(function () {
$this->output = new ConsoleOutput();
$this->menu = new InteractiveMenu($this->output);
});
it('can be instantiated', function () {
expect($this->menu)->toBeInstanceOf(InteractiveMenu::class);
});
it('sets title', function () {
$menu = $this->menu->setTitle('Test Menu');
expect($menu)->toBe($this->menu); // Fluent interface
});
it('adds menu items', function () {
$menu = $this->menu
->addItem('Option 1', null, 'value1')
->addItem('Option 2', null, 'value2');
expect($menu)->toBe($this->menu);
});
it('adds menu items with callable actions', function () {
$action = fn () => 'action result';
$menu = $this->menu->addItem('Option 1', $action);
expect($menu)->toBe($this->menu);
});
it('adds separators', function () {
$menu = $this->menu
->addItem('Option 1')
->addSeparator()
->addItem('Option 2');
expect($menu)->toBe($this->menu);
});
it('shows numbers by default', function () {
$menu = $this->menu->showNumbers(true);
expect($menu)->toBe($this->menu);
});
it('hides numbers', function () {
$menu = $this->menu->showNumbers(false);
expect($menu)->toBe($this->menu);
});
it('has showSimple method', function () {
expect(method_exists($this->menu, 'showSimple'))->toBeTrue();
});
it('has showInteractive method', function () {
expect(method_exists($this->menu, 'showInteractive'))->toBeTrue();
});
it('builds menu with fluent interface', function () {
$menu = $this->menu
->setTitle('Test Menu')
->addItem('Option 1', null, 'val1')
->addSeparator()
->addItem('Option 2', null, 'val2')
->showNumbers(true);
expect($menu)->toBe($this->menu);
});
it('handles empty menu', function () {
// Should not throw
expect($this->menu)->toBeInstanceOf(InteractiveMenu::class);
});
it('handles menu with only separators', function () {
$menu = $this->menu
->addSeparator()
->addSeparator();
expect($menu)->toBe($this->menu);
});
it('handles very long menu item labels', function () {
$longLabel = str_repeat('A', 200);
$menu = $this->menu->addItem($longLabel);
expect($menu)->toBe($this->menu);
});
it('handles menu with many items', function () {
$menu = $this->menu;
for ($i = 1; $i <= 100; $i++) {
$menu = $menu->addItem("Option {$i}", null, "value{$i}");
}
expect($menu)->toBe($this->menu);
});
});
describe('InteractiveMenu Rendering', function () {
beforeEach(function () {
$this->output = new TestConsoleOutput();
$this->menu = new InteractiveMenu($this->output);
});
it('renders menu title in showSimple', function () {
$this->menu
->setTitle('Test Menu')
->addItem('Option 1');
// showSimple requires STDIN, so we can't fully test it
// But we can verify the structure
expect(method_exists($this->menu, 'showSimple'))->toBeTrue();
});
it('renders menu items in showSimple', function () {
$this->menu
->addItem('Option 1')
->addItem('Option 2')
->addItem('Option 3');
// showSimple requires STDIN
expect(method_exists($this->menu, 'showSimple'))->toBeTrue();
});
it('renders separators in showSimple', function () {
$this->menu
->addItem('Option 1')
->addSeparator()
->addItem('Option 2');
// showSimple requires STDIN
expect(method_exists($this->menu, 'showSimple'))->toBeTrue();
});
it('renders interactive menu', function () {
$this->menu
->setTitle('Interactive Menu')
->addItem('Option 1')
->addItem('Option 2');
// showInteractive requires STDIN and terminal capabilities
expect(method_exists($this->menu, 'showInteractive'))->toBeTrue();
});
});

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\ConsoleColor;
describe('ConsoleColor', function () {
it('has all basic text colors', function () {
expect(ConsoleColor::BLACK->value)->toBe('30');
expect(ConsoleColor::RED->value)->toBe('31');
expect(ConsoleColor::GREEN->value)->toBe('32');
expect(ConsoleColor::YELLOW->value)->toBe('33');
expect(ConsoleColor::BLUE->value)->toBe('34');
expect(ConsoleColor::MAGENTA->value)->toBe('35');
expect(ConsoleColor::CYAN->value)->toBe('36');
expect(ConsoleColor::WHITE->value)->toBe('37');
expect(ConsoleColor::GRAY->value)->toBe('90');
});
it('has all bright text colors', function () {
expect(ConsoleColor::BRIGHT_RED->value)->toBe('91');
expect(ConsoleColor::BRIGHT_GREEN->value)->toBe('92');
expect(ConsoleColor::BRIGHT_YELLOW->value)->toBe('93');
expect(ConsoleColor::BRIGHT_BLUE->value)->toBe('94');
expect(ConsoleColor::BRIGHT_MAGENTA->value)->toBe('95');
expect(ConsoleColor::BRIGHT_CYAN->value)->toBe('96');
expect(ConsoleColor::BRIGHT_WHITE->value)->toBe('97');
});
it('has all background colors', function () {
expect(ConsoleColor::BG_BLACK->value)->toBe('40');
expect(ConsoleColor::BG_RED->value)->toBe('41');
expect(ConsoleColor::BG_GREEN->value)->toBe('42');
expect(ConsoleColor::BG_YELLOW->value)->toBe('43');
expect(ConsoleColor::BG_BLUE->value)->toBe('44');
expect(ConsoleColor::BG_MAGENTA->value)->toBe('45');
expect(ConsoleColor::BG_CYAN->value)->toBe('46');
expect(ConsoleColor::BG_WHITE->value)->toBe('47');
});
it('has combined colors', function () {
expect(ConsoleColor::WHITE_ON_RED->value)->toBe('97;41');
expect(ConsoleColor::BLACK_ON_YELLOW->value)->toBe('30;43');
});
it('has reset color', function () {
expect(ConsoleColor::RESET->value)->toBe('0');
});
it('converts to ANSI escape sequence', function () {
$ansi = ConsoleColor::RED->toAnsi();
expect($ansi)->toBe("\033[31m");
});
it('generates correct ANSI codes for all colors', function () {
expect(ConsoleColor::BLACK->toAnsi())->toBe("\033[30m");
expect(ConsoleColor::RED->toAnsi())->toBe("\033[31m");
expect(ConsoleColor::GREEN->toAnsi())->toBe("\033[32m");
expect(ConsoleColor::YELLOW->toAnsi())->toBe("\033[33m");
expect(ConsoleColor::BLUE->toAnsi())->toBe("\033[34m");
expect(ConsoleColor::MAGENTA->toAnsi())->toBe("\033[35m");
expect(ConsoleColor::CYAN->toAnsi())->toBe("\033[36m");
expect(ConsoleColor::WHITE->toAnsi())->toBe("\033[37m");
expect(ConsoleColor::GRAY->toAnsi())->toBe("\033[90m");
});
it('generates correct ANSI codes for bright colors', function () {
expect(ConsoleColor::BRIGHT_RED->toAnsi())->toBe("\033[91m");
expect(ConsoleColor::BRIGHT_GREEN->toAnsi())->toBe("\033[92m");
expect(ConsoleColor::BRIGHT_YELLOW->toAnsi())->toBe("\033[93m");
expect(ConsoleColor::BRIGHT_BLUE->toAnsi())->toBe("\033[94m");
expect(ConsoleColor::BRIGHT_MAGENTA->toAnsi())->toBe("\033[95m");
expect(ConsoleColor::BRIGHT_CYAN->toAnsi())->toBe("\033[96m");
expect(ConsoleColor::BRIGHT_WHITE->toAnsi())->toBe("\033[97m");
});
it('generates correct ANSI codes for background colors', function () {
expect(ConsoleColor::BG_BLACK->toAnsi())->toBe("\033[40m");
expect(ConsoleColor::BG_RED->toAnsi())->toBe("\033[41m");
expect(ConsoleColor::BG_GREEN->toAnsi())->toBe("\033[42m");
expect(ConsoleColor::BG_YELLOW->toAnsi())->toBe("\033[43m");
expect(ConsoleColor::BG_BLUE->toAnsi())->toBe("\033[44m");
expect(ConsoleColor::BG_MAGENTA->toAnsi())->toBe("\033[45m");
expect(ConsoleColor::BG_CYAN->toAnsi())->toBe("\033[46m");
expect(ConsoleColor::BG_WHITE->toAnsi())->toBe("\033[47m");
});
it('generates correct ANSI codes for combined colors', function () {
expect(ConsoleColor::WHITE_ON_RED->toAnsi())->toBe("\033[97;41m");
expect(ConsoleColor::BLACK_ON_YELLOW->toAnsi())->toBe("\033[30;43m");
});
it('generates reset ANSI code', function () {
expect(ConsoleColor::RESET->toAnsi())->toBe("\033[0m");
});
it('can be used as string value', function () {
$color = ConsoleColor::RED;
expect($color->value)->toBeString();
expect($color->value)->toBe('31');
});
});

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\ArgumentParser;
use App\Framework\Console\ArgumentParserBuilder;
use App\Framework\Console\ArgumentType;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
describe('ConsoleInput', function () {
beforeEach(function () {
$this->output = new ConsoleOutput();
});
it('parses simple arguments', function () {
$input = new ConsoleInput(['arg1', 'arg2', 'arg3'], $this->output);
expect($input->getArgument(0))->toBe('arg1');
expect($input->getArgument(1))->toBe('arg2');
expect($input->getArgument(2))->toBe('arg3');
});
it('returns default value for missing arguments', function () {
$input = new ConsoleInput(['arg1'], $this->output);
expect($input->getArgument(0))->toBe('arg1');
expect($input->getArgument(1, 'default'))->toBe('default');
expect($input->getArgument(2))->toBeNull();
});
it('parses long options with equals sign', function () {
$input = new ConsoleInput(['--option=value', '--flag'], $this->output);
expect($input->getOption('option'))->toBe('value');
expect($input->hasOption('flag'))->toBeTrue();
expect($input->getOption('flag'))->toBeTrue();
});
it('parses long options with space', function () {
// Note: Simple parser doesn't support --option value, only --option=value
// This test verifies the current behavior
$input = new ConsoleInput(['--option', 'value'], $this->output);
// Simple parser treats 'value' as a separate argument, not as option value
expect($input->hasOption('option'))->toBeTrue();
expect($input->getArgument(0))->toBe('value');
});
it('parses short options', function () {
$input = new ConsoleInput(['-f', '-o', 'value'], $this->output);
expect($input->hasOption('f'))->toBeTrue();
// Simple parser treats 'value' as argument, not as option value
expect($input->getArgument(0))->toBe('value');
});
it('parses mixed arguments and options', function () {
$input = new ConsoleInput(['arg1', '--option=value', 'arg2', '-f'], $this->output);
expect($input->getArgument(0))->toBe('arg1');
expect($input->getArgument(1))->toBe('arg2');
expect($input->getOption('option'))->toBe('value');
expect($input->hasOption('f'))->toBeTrue();
});
it('returns all arguments', function () {
$input = new ConsoleInput(['arg1', 'arg2', 'arg3'], $this->output);
$args = $input->getArguments();
expect($args)->toBe(['arg1', 'arg2', 'arg3']);
});
it('returns all options', function () {
$input = new ConsoleInput(['--opt1=val1', '--opt2', '-f'], $this->output);
$options = $input->getOptions();
expect($options)->toHaveKey('opt1');
expect($options)->toHaveKey('opt2');
expect($options)->toHaveKey('f');
});
it('returns default value for missing options', function () {
$input = new ConsoleInput([], $this->output);
expect($input->getOption('missing', 'default'))->toBe('default');
expect($input->getOption('missing'))->toBeNull();
});
it('supports enhanced parsing with ArgumentParser', function () {
$parser = ArgumentParser::create()
->requiredString('name')
->integer('age', required: false, default: 18)
->build();
$input = new ConsoleInput(['--name=John', '--age=25'], $this->output, $parser);
expect($input->hasEnhancedParsing())->toBeTrue();
expect($input->getString('name'))->toBe('John');
expect($input->getInt('age'))->toBe(25);
});
it('throws exception when accessing enhanced parsing without parser', function () {
$input = new ConsoleInput(['--option=value'], $this->output);
expect($input->hasEnhancedParsing())->toBeFalse();
expect(fn () => $input->getParsedArguments())
->toThrow(\RuntimeException::class, 'Enhanced parsing not available');
});
it('validates required arguments with enhanced parsing', function () {
$parser = ArgumentParser::create()
->requiredString('name')
->build();
$input = new ConsoleInput(['--name=John'], $this->output, $parser);
expect($input->require('name'))->toBe('John');
});
it('throws exception for missing required arguments', function () {
$parser = ArgumentParser::create()
->requiredString('name')
->build();
// Exception is thrown during parsing, not when calling require()
expect(fn () => new ConsoleInput([], $this->output, $parser))
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
});
it('converts types correctly with enhanced parsing', function () {
$parser = ArgumentParser::create()
->integer('count')
->addArgument(new \App\Framework\Console\ArgumentDefinition('active', ArgumentType::BOOLEAN))
->addArgument(new \App\Framework\Console\ArgumentDefinition('items', ArgumentType::ARRAY))
->build();
$input = new ConsoleInput([
'--count=42',
'--active=true',
'--items=item1,item2,item3'
], $this->output, $parser);
expect($input->getInt('count'))->toBe(42);
expect($input->getBool('active'))->toBeTrue();
expect($input->getArray('items'))->toBe(['item1', 'item2', 'item3']);
});
it('handles kebab-case option names', function () {
$input = new ConsoleInput(['--dry-run', '--output-file=test.txt'], $this->output);
expect($input->hasOption('dry-run'))->toBeTrue();
expect($input->getOption('output-file'))->toBe('test.txt');
});
it('can set argument parser after construction', function () {
$input = new ConsoleInput(['--name=John'], $this->output);
$parser = ArgumentParser::create()
->optionalString('name')
->build();
$input->setArgumentParser($parser);
expect($input->hasEnhancedParsing())->toBeTrue();
// Note: setArgumentParser re-parses, but needs the raw arguments
// The simple parser already parsed --name=John, so we can verify it's available
expect($input->getOption('name'))->toBe('John');
});
});
describe('ConsoleInput Interactive Methods', function () {
it('has ask method that delegates to InteractivePrompter', function () {
$output = new ConsoleOutput();
$input = new ConsoleInput([], $output);
// Verify method exists - actual interactive behavior requires STDIN
expect(method_exists($input, 'ask'))->toBeTrue();
});
it('has confirm method that delegates to InteractivePrompter', function () {
$output = new ConsoleOutput();
$input = new ConsoleInput([], $output);
// Verify method exists - actual interactive behavior requires STDIN
expect(method_exists($input, 'confirm'))->toBeTrue();
});
it('has askPassword method that delegates to InteractivePrompter', function () {
$output = new ConsoleOutput();
$input = new ConsoleInput([], $output);
// Verify method exists - actual interactive behavior requires STDIN
expect(method_exists($input, 'askPassword'))->toBeTrue();
});
});

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ConsoleStyle;
describe('ConsoleOutput', function () {
it('can be instantiated', function () {
$output = new ConsoleOutput();
expect($output)->toBeInstanceOf(ConsoleOutput::class);
});
it('provides cursor, display, and screen managers', function () {
$output = new ConsoleOutput();
expect($output->cursor)->toBeInstanceOf(\App\Framework\Console\Screen\Cursor::class);
expect($output->display)->toBeInstanceOf(\App\Framework\Console\Screen\Display::class);
expect($output->screen)->toBeInstanceOf(\App\Framework\Console\Screen\ScreenManager::class);
});
it('provides terminal capabilities', function () {
$output = new ConsoleOutput();
expect($output->getCapabilities())->toBeInstanceOf(\App\Framework\Console\Terminal\TerminalCapabilities::class);
expect($output->isTerminal())->toBeBool();
});
it('provides ANSI generator and link formatter', function () {
$output = new ConsoleOutput();
expect($output->getAnsiGenerator())->toBeInstanceOf(\App\Framework\Console\Ansi\AnsiSequenceGenerator::class);
expect($output->getLinkFormatter())->toBeInstanceOf(\App\Framework\Console\Ansi\LinkFormatter::class);
});
it('provides window title manager', function () {
$output = new ConsoleOutput();
expect($output->getTitleManager())->toBeInstanceOf(\App\Framework\Console\Terminal\WindowTitleManager::class);
});
it('provides animation manager', function () {
$output = new ConsoleOutput();
expect($output->getAnimationManager())->toBeInstanceOf(\App\Framework\Console\Animation\AnimationManager::class);
});
});
describe('ConsoleOutput Write Methods', function () {
it('writes text without style', function () {
$output = new ConsoleOutput();
ob_start();
$output->write('Hello World');
$content = ob_get_clean();
expect($content)->toContain('Hello World');
});
it('writes text with ConsoleColor', function () {
$output = new ConsoleOutput();
ob_start();
$output->write('Error', ConsoleColor::RED);
$content = ob_get_clean();
expect($content)->toContain('Error');
});
it('writes line with newline', function () {
$output = new ConsoleOutput();
ob_start();
$output->writeLine('Test Line');
$content = ob_get_clean();
expect($content)->toContain('Test Line');
});
it('writes success message', function () {
$output = new ConsoleOutput();
ob_start();
$output->writeSuccess('Operation successful');
$content = ob_get_clean();
expect($content)->toContain('Operation successful');
});
it('writes error message', function () {
$output = new ConsoleOutput();
ob_start();
$output->writeError('Operation failed');
$content = ob_get_clean();
expect($content)->toContain('Operation failed');
});
it('writes warning message', function () {
$output = new ConsoleOutput();
ob_start();
$output->writeWarning('Warning message');
$content = ob_get_clean();
expect($content)->toContain('Warning message');
});
it('writes info message', function () {
$output = new ConsoleOutput();
ob_start();
$output->writeInfo('Info message');
$content = ob_get_clean();
expect($content)->toContain('Info message');
});
it('writes error line to stderr', function () {
$output = new ConsoleOutput();
ob_start();
$output->writeErrorLine('Error to stderr');
$content = ob_get_clean();
expect($content)->toContain('Error to stderr');
});
it('adds newlines', function () {
$output = new ConsoleOutput();
ob_start();
$output->newLine(3);
$content = ob_get_clean();
// Should contain newlines
expect($content)->toContain("\n");
});
it('sets window title', function () {
$output = new ConsoleOutput();
// Should not throw
$output->writeWindowTitle('Test Title');
expect(true)->toBeTrue(); // Just verify it doesn't throw
});
it('sets window title with different modes', function () {
$output = new ConsoleOutput();
// Test different modes
$output->writeWindowTitle('Title 1', 0);
$output->writeWindowTitle('Title 2', 1);
$output->writeWindowTitle('Title 3', 2);
expect(true)->toBeTrue(); // Just verify it doesn't throw
});
});
describe('ConsoleOutput Interactive Methods', function () {
it('delegates askQuestion to InteractivePrompter', function () {
$output = new ConsoleOutput();
// In a real test, we'd mock the prompter, but for now we just verify the method exists
expect(method_exists($output, 'askQuestion'))->toBeTrue();
});
it('delegates confirm to InteractivePrompter', function () {
$output = new ConsoleOutput();
expect(method_exists($output, 'confirm'))->toBeTrue();
});
});
describe('ConsoleOutput Animation Methods', function () {
it('has animateFadeIn method', function () {
$output = new ConsoleOutput();
expect(method_exists($output, 'animateFadeIn'))->toBeTrue();
});
it('has animateFadeOut method', function () {
$output = new ConsoleOutput();
expect(method_exists($output, 'animateFadeOut'))->toBeTrue();
});
it('has animateTypewriter method', function () {
$output = new ConsoleOutput();
expect(method_exists($output, 'animateTypewriter'))->toBeTrue();
});
it('has animateMarquee method', function () {
$output = new ConsoleOutput();
expect(method_exists($output, 'animateMarquee'))->toBeTrue();
});
it('has animateText method for custom animations', function () {
$output = new ConsoleOutput();
expect(method_exists($output, 'animateText'))->toBeTrue();
});
it('has updateAnimations method', function () {
$output = new ConsoleOutput();
expect(method_exists($output, 'updateAnimations'))->toBeTrue();
});
});

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\ExitCode;
describe('ExitCode', function () {
it('has all standard exit codes', function () {
expect(ExitCode::SUCCESS->value)->toBe(0);
expect(ExitCode::GENERAL_ERROR->value)->toBe(1);
expect(ExitCode::USAGE_ERROR->value)->toBe(2);
expect(ExitCode::COMMAND_NOT_FOUND->value)->toBe(64);
expect(ExitCode::INVALID_INPUT->value)->toBe(65);
expect(ExitCode::NO_INPUT->value)->toBe(66);
expect(ExitCode::UNAVAILABLE->value)->toBe(69);
expect(ExitCode::SOFTWARE_ERROR->value)->toBe(70);
expect(ExitCode::OS_ERROR->value)->toBe(71);
expect(ExitCode::OS_FILE_ERROR->value)->toBe(72);
expect(ExitCode::CANT_CREATE->value)->toBe(73);
expect(ExitCode::IO_ERROR->value)->toBe(74);
expect(ExitCode::TEMP_FAIL->value)->toBe(75);
expect(ExitCode::PROTOCOL_ERROR->value)->toBe(76);
expect(ExitCode::NO_PERMISSION->value)->toBe(77);
expect(ExitCode::CONFIG_ERROR->value)->toBe(78);
expect(ExitCode::DATABASE_ERROR->value)->toBe(79);
expect(ExitCode::RATE_LIMITED->value)->toBe(80);
expect(ExitCode::PERMISSION_DENIED->value)->toBe(126);
expect(ExitCode::INTERRUPTED->value)->toBe(130);
});
it('provides descriptions for all exit codes', function () {
expect(ExitCode::SUCCESS->getDescription())->toBe('Erfolgreich abgeschlossen');
expect(ExitCode::GENERAL_ERROR->getDescription())->toBe('Allgemeiner Fehler');
expect(ExitCode::USAGE_ERROR->getDescription())->toBe('Falsche Verwendung oder ungültige Argumente');
expect(ExitCode::COMMAND_NOT_FOUND->getDescription())->toBe('Kommando nicht gefunden');
expect(ExitCode::INVALID_INPUT->getDescription())->toBe('Ungültige Eingabedaten');
expect(ExitCode::NO_INPUT->getDescription())->toBe('Keine Eingabe vorhanden');
expect(ExitCode::UNAVAILABLE->getDescription())->toBe('Service nicht verfügbar');
expect(ExitCode::SOFTWARE_ERROR->getDescription())->toBe('Interner Software-Fehler');
expect(ExitCode::OS_ERROR->getDescription())->toBe('Betriebssystem-Fehler');
expect(ExitCode::OS_FILE_ERROR->getDescription())->toBe('Datei-/Verzeichnis-Fehler');
expect(ExitCode::CANT_CREATE->getDescription())->toBe('Kann Datei/Verzeichnis nicht erstellen');
expect(ExitCode::IO_ERROR->getDescription())->toBe('Ein-/Ausgabe-Fehler');
expect(ExitCode::TEMP_FAIL->getDescription())->toBe('Temporärer Fehler');
expect(ExitCode::PROTOCOL_ERROR->getDescription())->toBe('Protokoll-Fehler');
expect(ExitCode::NO_PERMISSION->getDescription())->toBe('Keine Berechtigung');
expect(ExitCode::CONFIG_ERROR->getDescription())->toBe('Konfigurationsfehler');
expect(ExitCode::DATABASE_ERROR->getDescription())->toBe('Datenbankfehler');
expect(ExitCode::RATE_LIMITED->getDescription())->toBe('Rate-Limit erreicht');
expect(ExitCode::PERMISSION_DENIED->getDescription())->toBe('Zugriff verweigert');
expect(ExitCode::INTERRUPTED->getDescription())->toBe('Unterbrochen durch Signal (SIGINT/SIGTERM)');
});
it('identifies success correctly', function () {
expect(ExitCode::SUCCESS->isSuccess())->toBeTrue();
expect(ExitCode::SUCCESS->isError())->toBeFalse();
});
it('identifies errors correctly', function () {
expect(ExitCode::GENERAL_ERROR->isSuccess())->toBeFalse();
expect(ExitCode::GENERAL_ERROR->isError())->toBeTrue();
expect(ExitCode::COMMAND_NOT_FOUND->isSuccess())->toBeFalse();
expect(ExitCode::COMMAND_NOT_FOUND->isError())->toBeTrue();
expect(ExitCode::INVALID_INPUT->isSuccess())->toBeFalse();
expect(ExitCode::INVALID_INPUT->isError())->toBeTrue();
});
it('can be used as integer value', function () {
$exitCode = ExitCode::SUCCESS;
expect($exitCode->value)->toBeInt();
expect($exitCode->value)->toBe(0);
});
it('follows POSIX exit code standards', function () {
// 0 = success
expect(ExitCode::SUCCESS->value)->toBe(0);
// 1 = general error
expect(ExitCode::GENERAL_ERROR->value)->toBe(1);
// 2 = usage error
expect(ExitCode::USAGE_ERROR->value)->toBe(2);
// 64-78 = sysexits.h standard codes
expect(ExitCode::COMMAND_NOT_FOUND->value)->toBe(64);
expect(ExitCode::INVALID_INPUT->value)->toBe(65);
expect(ExitCode::NO_INPUT->value)->toBe(66);
expect(ExitCode::UNAVAILABLE->value)->toBe(69);
expect(ExitCode::SOFTWARE_ERROR->value)->toBe(70);
expect(ExitCode::OS_ERROR->value)->toBe(71);
expect(ExitCode::OS_FILE_ERROR->value)->toBe(72);
expect(ExitCode::CANT_CREATE->value)->toBe(73);
expect(ExitCode::IO_ERROR->value)->toBe(74);
expect(ExitCode::TEMP_FAIL->value)->toBe(75);
expect(ExitCode::PROTOCOL_ERROR->value)->toBe(76);
expect(ExitCode::NO_PERMISSION->value)->toBe(77);
expect(ExitCode::CONFIG_ERROR->value)->toBe(78);
});
});

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console\Helpers;
/**
* Mock keyboard input for testing interactive menus and TUI components
*/
final class MockKeyboard
{
private array $keySequence = [];
private int $currentIndex = 0;
/**
* ANSI escape sequences for common keys
*/
public const KEY_UP = "\033[A";
public const KEY_DOWN = "\033[B";
public const KEY_RIGHT = "\033[C";
public const KEY_LEFT = "\033[D";
public const KEY_ENTER = "\n";
public const KEY_ESCAPE = "\033";
public const KEY_TAB = "\t";
public const KEY_BACKSPACE = "\x08";
public const KEY_DELETE = "\033[3~";
public const KEY_HOME = "\033[H";
public const KEY_END = "\033[F";
public const KEY_PAGE_UP = "\033[5~";
public const KEY_PAGE_DOWN = "\033[6~";
public function __construct(array $keySequence = [])
{
$this->keySequence = $keySequence;
}
/**
* Add a key press to the sequence
*/
public function pressKey(string $key): self
{
$this->keySequence[] = $key;
return $this;
}
/**
* Add multiple key presses
*/
public function pressKeys(array $keys): self
{
foreach ($keys as $key) {
$this->pressKey($key);
}
return $this;
}
/**
* Add arrow up key
*/
public function arrowUp(): self
{
return $this->pressKey(self::KEY_UP);
}
/**
* Add arrow down key
*/
public function arrowDown(): self
{
return $this->pressKey(self::KEY_DOWN);
}
/**
* Add arrow left key
*/
public function arrowLeft(): self
{
return $this->pressKey(self::KEY_LEFT);
}
/**
* Add arrow right key
*/
public function arrowRight(): self
{
return $this->pressKey(self::KEY_RIGHT);
}
/**
* Add enter key
*/
public function enter(): self
{
return $this->pressKey(self::KEY_ENTER);
}
/**
* Add escape key
*/
public function escape(): self
{
return $this->pressKey(self::KEY_ESCAPE);
}
/**
* Add tab key
*/
public function tab(): self
{
return $this->pressKey(self::KEY_TAB);
}
/**
* Get next key in sequence
*/
public function getNextKey(): ?string
{
if ($this->currentIndex >= count($this->keySequence)) {
return null;
}
$key = $this->keySequence[$this->currentIndex];
$this->currentIndex++;
return $key;
}
/**
* Reset to beginning
*/
public function reset(): void
{
$this->currentIndex = 0;
}
/**
* Check if there are more keys
*/
public function hasMoreKeys(): bool
{
return $this->currentIndex < count($this->keySequence);
}
/**
* Get all remaining keys
*/
public function getRemainingKeys(): array
{
return array_slice($this->keySequence, $this->currentIndex);
}
/**
* Get the full sequence
*/
public function getSequence(): array
{
return $this->keySequence;
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console\Helpers;
/**
* Mock STDIN for testing interactive console components
*/
final class MockStdin
{
private array $inputs = [];
private int $currentIndex = 0;
private ?resource $originalStdin = null;
private bool $isActive = false;
public function __construct(array $inputs = [])
{
$this->inputs = $inputs;
}
/**
* Add input to the queue
*/
public function addInput(string $input): self
{
$this->inputs[] = $input;
return $this;
}
/**
* Add multiple inputs
*/
public function addInputs(array $inputs): self
{
foreach ($inputs as $input) {
$this->addInput($input);
}
return $this;
}
/**
* Activate the mock (replace STDIN)
*/
public function activate(): void
{
if ($this->isActive) {
return;
}
// Create a temporary file for input
$tempFile = tmpfile();
if ($tempFile === false) {
throw new \RuntimeException('Could not create temporary file for STDIN mock');
}
// Write all inputs to the temp file
$content = implode("\n", $this->inputs) . "\n";
fwrite($tempFile, $content);
rewind($tempFile);
// Store original STDIN if we can
if (defined('STDIN') && is_resource(STDIN)) {
$this->originalStdin = STDIN;
}
// Replace STDIN constant (not possible in PHP, so we use a workaround)
// We'll need to use a different approach - create a stream wrapper
$this->isActive = true;
}
/**
* Deactivate the mock (restore original STDIN)
*/
public function deactivate(): void
{
if (!$this->isActive) {
return;
}
$this->isActive = false;
}
/**
* Get next input (simulates fgets)
*/
public function getNextInput(): ?string
{
if ($this->currentIndex >= count($this->inputs)) {
return null;
}
$input = $this->inputs[$this->currentIndex];
$this->currentIndex++;
return $input . "\n";
}
/**
* Reset to beginning
*/
public function reset(): void
{
$this->currentIndex = 0;
}
/**
* Check if there are more inputs
*/
public function hasMoreInputs(): bool
{
return $this->currentIndex < count($this->inputs);
}
/**
* Get all remaining inputs
*/
public function getRemainingInputs(): array
{
return array_slice($this->inputs, $this->currentIndex);
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console\Helpers;
/**
* Mock terminal capabilities for testing
*/
final class MockTerminal
{
private bool $isTerminal = true;
private bool $supportsAnsi = true;
private bool $supportsColors = true;
private bool $supportsTrueColor = false;
private int $width = 80;
private int $height = 24;
private bool $supportsMouse = false;
private string $termType = 'xterm-256color';
public function __construct()
{
}
/**
* Create a mock terminal with specific capabilities
*/
public static function create(
bool $isTerminal = true,
bool $supportsAnsi = true,
int $width = 80,
int $height = 24
): self {
$mock = new self();
$mock->isTerminal = $isTerminal;
$mock->supportsAnsi = $supportsAnsi;
$mock->width = $width;
$mock->height = $height;
return $mock;
}
/**
* Create a non-terminal mock (e.g., for CI environments)
*/
public static function nonTerminal(): self
{
return self::create(isTerminal: false, supportsAnsi: false);
}
/**
* Create a minimal terminal mock
*/
public static function minimal(): self
{
return self::create(isTerminal: true, supportsAnsi: true, width: 40, height: 10);
}
/**
* Create a full-featured terminal mock
*/
public static function fullFeatured(): self
{
$mock = self::create(isTerminal: true, supportsAnsi: true, width: 120, height: 40);
$mock->supportsTrueColor = true;
$mock->supportsMouse = true;
return $mock;
}
public function isTerminal(): bool
{
return $this->isTerminal;
}
public function supportsAnsi(): bool
{
return $this->supportsAnsi;
}
public function supportsColors(): bool
{
return $this->supportsColors;
}
public function supportsTrueColor(): bool
{
return $this->supportsTrueColor;
}
public function getWidth(): int
{
return $this->width;
}
public function getHeight(): int
{
return $this->height;
}
public function supportsMouse(): bool
{
return $this->supportsMouse;
}
public function getTermType(): string
{
return $this->termType;
}
public function setWidth(int $width): self
{
$this->width = $width;
return $this;
}
public function setHeight(int $height): self
{
$this->height = $height;
return $this;
}
public function setTermType(string $termType): self
{
$this->termType = $termType;
return $this;
}
public function setSupportsMouse(bool $supports): self
{
$this->supportsMouse = $supports;
return $this;
}
public function setSupportsTrueColor(bool $supports): self
{
$this->supportsTrueColor = $supports;
return $this;
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console\Helpers;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ConsoleStyle;
/**
* Enhanced test console output that captures all output for assertions
*/
final class TestConsoleOutput implements ConsoleOutputInterface
{
public array $capturedLines = [];
public array $capturedWrites = [];
public array $capturedErrors = [];
public array $capturedSuccesses = [];
public array $capturedWarnings = [];
public array $capturedInfos = [];
public int $newLineCount = 0;
public array $windowTitles = [];
public function write(string $message, ConsoleStyle|ConsoleColor|null $style = null): void
{
$this->capturedWrites[] = ['message' => $message, 'style' => $style];
}
public function writeLine(string $message = '', ?ConsoleColor $color = null): void
{
$this->capturedLines[] = ['message' => $message, 'color' => $color];
}
public function writeError(string $message): void
{
$this->capturedErrors[] = $message;
$this->capturedLines[] = ['message' => $message, 'type' => 'error'];
}
public function writeSuccess(string $message): void
{
$this->capturedSuccesses[] = $message;
$this->capturedLines[] = ['message' => $message, 'type' => 'success'];
}
public function writeWarning(string $message): void
{
$this->capturedWarnings[] = $message;
$this->capturedLines[] = ['message' => $message, 'type' => 'warning'];
}
public function writeInfo(string $message): void
{
$this->capturedInfos[] = $message;
$this->capturedLines[] = ['message' => $message, 'type' => 'info'];
}
public function newLine(int $count = 1): void
{
$this->newLineCount += $count;
for ($i = 0; $i < $count; $i++) {
$this->capturedLines[] = ['message' => '', 'type' => 'newline'];
}
}
public function askQuestion(string $question, ?string $default = null): string
{
$this->capturedLines[] = ['message' => $question, 'type' => 'question', 'default' => $default];
return $default ?? '';
}
public function confirm(string $question, bool $default = false): bool
{
$this->capturedLines[] = ['message' => $question, 'type' => 'confirm', 'default' => $default];
return $default;
}
public function writeWindowTitle(string $title, int $mode = 0): void
{
$this->windowTitles[] = ['title' => $title, 'mode' => $mode];
}
/**
* Get all captured output as plain text
*/
public function getOutput(): string
{
$output = [];
foreach ($this->capturedLines as $line) {
$output[] = $line['message'] ?? '';
}
return implode("\n", $output);
}
/**
* Get all captured writes
*/
public function getWrites(): array
{
return $this->capturedWrites;
}
/**
* Clear all captured output
*/
public function clear(): void
{
$this->capturedLines = [];
$this->capturedWrites = [];
$this->capturedErrors = [];
$this->capturedSuccesses = [];
$this->capturedWarnings = [];
$this->capturedInfos = [];
$this->newLineCount = 0;
$this->windowTitles = [];
}
/**
* Check if output contains a specific string
*/
public function contains(string $search): bool
{
$output = $this->getOutput();
return str_contains($output, $search);
}
/**
* Get last captured line
*/
public function getLastLine(): ?string
{
if (empty($this->capturedLines)) {
return null;
}
$last = end($this->capturedLines);
return $last['message'] ?? null;
}
}

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\InteractiveForm;
use App\Framework\Console\ParameterInspector;
use Tests\Framework\Console\Helpers\TestConsoleOutput;
class TestCommand
{
public function testMethod(string $name, int $age = 18): void
{
}
}
describe('InteractiveForm', function () {
beforeEach(function () {
$this->output = new TestConsoleOutput();
$this->inspector = new ParameterInspector();
$this->form = new InteractiveForm($this->output, $this->inspector);
});
it('can be instantiated', function () {
expect($this->form)->toBeInstanceOf(InteractiveForm::class);
});
it('creates form from command', function () {
$command = new TestCommand();
$form = InteractiveForm::forCommand($command, $this->output, 'testMethod');
expect($form)->toBeInstanceOf(InteractiveForm::class);
});
it('adds field to form', function () {
$form = $this->form->addField([
'name' => 'test',
'description' => 'Test field',
'type' => ['name' => 'string'],
'required' => true,
]);
expect($form)->toBe($this->form);
});
it('adds multiple fields', function () {
$form = $this->form
->addField(['name' => 'field1', 'description' => 'Field 1'])
->addField(['name' => 'field2', 'description' => 'Field 2']);
expect($form)->toBe($this->form);
});
it('merges default field configuration', function () {
$form = $this->form->addField([
'name' => 'test',
'description' => 'Test',
]);
// Should have default values merged
expect($form)->toBe($this->form);
});
it('gets values', function () {
// Initially empty
expect($this->form->getValues())->toBeEmpty();
});
it('checks if form is completed', function () {
expect($this->form->isCompleted())->toBeFalse();
});
it('returns empty array when run with no fields', function () {
$values = $this->form->run();
expect($values)->toBeEmpty();
});
it('has run method', function () {
expect(method_exists($this->form, 'run'))->toBeTrue();
});
it('handles required fields', function () {
$form = $this->form->addField([
'name' => 'required_field',
'description' => 'Required field',
'required' => true,
]);
expect($form)->toBe($this->form);
});
it('handles optional fields with defaults', function () {
$form = $this->form->addField([
'name' => 'optional_field',
'description' => 'Optional field',
'required' => false,
'default' => 'default value',
]);
expect($form)->toBe($this->form);
});
it('handles different field types', function () {
$form = $this->form
->addField(['name' => 'text', 'input_type' => 'text'])
->addField(['name' => 'boolean', 'input_type' => 'boolean'])
->addField(['name' => 'number', 'input_type' => 'number'])
->addField(['name' => 'password', 'input_type' => 'password'])
->addField(['name' => 'file', 'input_type' => 'file'])
->addField(['name' => 'list', 'input_type' => 'list']);
expect($form)->toBe($this->form);
});
it('handles validation rules', function () {
$form = $this->form->addField([
'name' => 'email',
'description' => 'Email address',
'validation_rules' => [
'type' => 'string',
'format' => 'email',
'required' => true,
],
]);
expect($form)->toBe($this->form);
});
it('handles min/max validation for numbers', function () {
$form = $this->form->addField([
'name' => 'age',
'description' => 'Age',
'input_type' => 'number',
'validation_rules' => [
'type' => 'integer',
'min' => 0,
'max' => 120,
'required' => true,
],
]);
expect($form)->toBe($this->form);
});
it('handles many fields', function () {
$form = $this->form;
for ($i = 1; $i <= 50; $i++) {
$form = $form->addField([
'name' => "field{$i}",
'description' => "Field {$i}",
]);
}
expect($form)->toBe($this->form);
});
});
describe('InteractiveForm Field Configuration', function () {
beforeEach(function () {
$this->output = new TestConsoleOutput();
$this->inspector = new ParameterInspector();
$this->form = new InteractiveForm($this->output, $this->inspector);
});
it('creates text field', function () {
$form = $this->form->addField([
'name' => 'name',
'description' => 'Name',
'input_type' => 'text',
]);
expect($form)->toBe($this->form);
});
it('creates boolean field', function () {
$form = $this->form->addField([
'name' => 'active',
'description' => 'Active',
'input_type' => 'boolean',
]);
expect($form)->toBe($this->form);
});
it('creates number field', function () {
$form = $this->form->addField([
'name' => 'count',
'description' => 'Count',
'input_type' => 'number',
]);
expect($form)->toBe($this->form);
});
it('creates password field', function () {
$form = $this->form->addField([
'name' => 'password',
'description' => 'Password',
'input_type' => 'password',
]);
expect($form)->toBe($this->form);
});
it('creates file field', function () {
$form = $this->form->addField([
'name' => 'file',
'description' => 'File path',
'input_type' => 'file',
]);
expect($form)->toBe($this->form);
});
it('creates list field', function () {
$form = $this->form->addField([
'name' => 'items',
'description' => 'Items',
'input_type' => 'list',
]);
expect($form)->toBe($this->form);
});
});

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\InteractivePrompter;
use App\Framework\Console\TextWriter;
use App\Framework\Console\ValueObjects\ChoiceOptions;
use App\Framework\Console\ValueObjects\MenuOptions;
use Tests\Framework\Console\Helpers\TestConsoleOutput;
describe('InteractivePrompter', function () {
beforeEach(function () {
$this->output = new TestConsoleOutput();
$textWriter = new TextWriter(
new \App\Framework\Console\Ansi\AnsiSequenceGenerator(),
new \App\Framework\Console\Terminal\TerminalCapabilities()
);
$this->prompter = new InteractivePrompter($this->output, $textWriter);
});
it('can be instantiated', function () {
expect($this->prompter)->toBeInstanceOf(InteractivePrompter::class);
});
it('has askQuestion method', function () {
expect(method_exists($this->prompter, 'askQuestion'))->toBeTrue();
});
it('has askPassword method', function () {
expect(method_exists($this->prompter, 'askPassword'))->toBeTrue();
});
it('has confirm method', function () {
expect(method_exists($this->prompter, 'confirm'))->toBeTrue();
});
it('has choice method', function () {
expect(method_exists($this->prompter, 'choice'))->toBeTrue();
});
it('has choiceFromOptions method', function () {
expect(method_exists($this->prompter, 'choiceFromOptions'))->toBeTrue();
});
it('has menu method', function () {
expect(method_exists($this->prompter, 'menu'))->toBeTrue();
});
it('has menuFromOptions method', function () {
expect(method_exists($this->prompter, 'menuFromOptions'))->toBeTrue();
});
it('has multiSelect method', function () {
expect(method_exists($this->prompter, 'multiSelect'))->toBeTrue();
});
it('has multiSelectFromOptions method', function () {
expect(method_exists($this->prompter, 'multiSelectFromOptions'))->toBeTrue();
});
});
describe('InteractivePrompter Choice Methods', function () {
beforeEach(function () {
$this->output = new TestConsoleOutput();
$textWriter = new TextWriter(
new \App\Framework\Console\Ansi\AnsiSequenceGenerator(),
new \App\Framework\Console\Terminal\TerminalCapabilities()
);
$this->prompter = new InteractivePrompter($this->output, $textWriter);
});
it('delegates choice to choiceFromOptions', function () {
$choices = ['option1' => 'Option 1', 'option2' => 'Option 2'];
// This will call InteractiveMenu which requires STDIN, so we just verify the method exists
expect(method_exists($this->prompter, 'choice'))->toBeTrue();
});
it('delegates choiceFromOptions to InteractiveMenu', function () {
$options = MenuOptions::create()
->addOption('opt1', 'Option 1')
->addOption('opt2', 'Option 2');
// This will call InteractiveMenu which requires STDIN
// We verify the method exists and can be called
expect(method_exists($this->prompter, 'choiceFromOptions'))->toBeTrue();
});
it('handles MenuOptions with separators', function () {
$options = MenuOptions::create()
->addOption('opt1', 'Option 1')
->addSeparator()
->addOption('opt2', 'Option 2');
// Verify structure
expect($options->count())->toBe(3);
});
});
describe('InteractivePrompter Menu Methods', function () {
beforeEach(function () {
$this->output = new TestConsoleOutput();
$textWriter = new TextWriter(
new \App\Framework\Console\Ansi\AnsiSequenceGenerator(),
new \App\Framework\Console\Terminal\TerminalCapabilities()
);
$this->prompter = new InteractivePrompter($this->output, $textWriter);
});
it('delegates menu to menuFromOptions', function () {
$items = ['item1' => 'Item 1', 'item2' => 'Item 2'];
// This will call InteractiveMenu which requires STDIN
expect(method_exists($this->prompter, 'menu'))->toBeTrue();
});
it('delegates menuFromOptions to InteractiveMenu', function () {
$options = MenuOptions::create()
->addOption('item1', 'Item 1')
->addOption('item2', 'Item 2');
// This will call InteractiveMenu which requires STDIN
expect(method_exists($this->prompter, 'menuFromOptions'))->toBeTrue();
});
});
describe('InteractivePrompter MultiSelect Methods', function () {
beforeEach(function () {
$this->output = new TestConsoleOutput();
$textWriter = new TextWriter(
new \App\Framework\Console\Ansi\AnsiSequenceGenerator(),
new \App\Framework\Console\Terminal\TerminalCapabilities()
);
$this->prompter = new InteractivePrompter($this->output, $textWriter);
});
it('delegates multiSelect to multiSelectFromOptions', function () {
$options = ['opt1' => 'Option 1', 'opt2' => 'Option 2'];
// This will require STDIN input
expect(method_exists($this->prompter, 'multiSelect'))->toBeTrue();
});
it('delegates multiSelectFromOptions and returns ChoiceOptions', function () {
$options = ChoiceOptions::create()
->addChoice('val1', 'Value 1')
->addChoice('val2', 'Value 2');
// This will require STDIN input
expect(method_exists($this->prompter, 'multiSelectFromOptions'))->toBeTrue();
});
it('displays multiSelect options correctly', function () {
$options = ChoiceOptions::create()
->addChoice('val1', 'Value 1')
->addChoice('val2', 'Value 2')
->addChoice('val3', 'Value 3');
// Verify structure
expect($options->count())->toBe(3);
expect($options->toArray())->toHaveCount(3);
});
});

View File

@@ -0,0 +1,387 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\ArgumentDefinition;
use App\Framework\Console\ArgumentType;
use App\Framework\Console\ParsedArguments;
describe('ParsedArguments', function () {
it('gets argument values', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
];
$parsed = new ParsedArguments(
['name' => 'John'],
[],
$definitions
);
expect($parsed->get('name'))->toBe('John');
});
it('gets option values', function () {
$definitions = [
'verbose' => new ArgumentDefinition('verbose', ArgumentType::BOOLEAN),
];
$parsed = new ParsedArguments(
[],
['verbose' => true],
$definitions
);
expect($parsed->get('verbose'))->toBeTrue();
});
it('returns default value when argument is missing', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING, default: 'Guest'),
];
$parsed = new ParsedArguments([], [], $definitions);
expect($parsed->get('name'))->toBe('Guest');
});
it('returns null when argument is missing and no default', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
];
$parsed = new ParsedArguments([], [], $definitions);
expect($parsed->get('name'))->toBeNull();
});
it('requires argument and throws exception when missing', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING, required: true),
];
$parsed = new ParsedArguments([], [], $definitions);
expect(fn () => $parsed->require('name'))
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
});
it('requires argument and returns value when present', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING, required: true),
];
$parsed = new ParsedArguments(['name' => 'John'], [], $definitions);
expect($parsed->require('name'))->toBe('John');
});
it('gets typed values', function () {
$definitions = [
'count' => new ArgumentDefinition('count', ArgumentType::INTEGER),
'active' => new ArgumentDefinition('active', ArgumentType::BOOLEAN),
];
$parsed = new ParsedArguments(
['count' => '42', 'active' => 'true'],
[],
$definitions
);
expect($parsed->getTyped('count'))->toBe(42);
expect($parsed->getTyped('active'))->toBeTrue();
});
it('gets string value', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
];
$parsed = new ParsedArguments(['name' => 'John'], [], $definitions);
expect($parsed->getString('name'))->toBe('John');
expect($parsed->getString('name'))->toBeString();
});
it('gets integer value', function () {
$definitions = [
'count' => new ArgumentDefinition('count', ArgumentType::INTEGER),
];
$parsed = new ParsedArguments(['count' => '42'], [], $definitions);
expect($parsed->getInt('count'))->toBe(42);
expect($parsed->getInt('count'))->toBeInt();
});
it('throws exception for invalid integer', function () {
$definitions = [
'count' => new ArgumentDefinition('count', ArgumentType::INTEGER),
];
$parsed = new ParsedArguments(['count' => 'not-a-number'], [], $definitions);
expect(fn () => $parsed->getInt('count'))
->toThrow(\InvalidArgumentException::class, 'not a valid integer');
});
it('gets float value', function () {
$definitions = [
'price' => new ArgumentDefinition('price', ArgumentType::FLOAT),
];
$parsed = new ParsedArguments(['price' => '12.34'], [], $definitions);
expect($parsed->getFloat('price'))->toBe(12.34);
expect($parsed->getFloat('price'))->toBeFloat();
});
it('throws exception for invalid float', function () {
$definitions = [
'price' => new ArgumentDefinition('price', ArgumentType::FLOAT),
];
$parsed = new ParsedArguments(['price' => 'not-a-number'], [], $definitions);
expect(fn () => $parsed->getFloat('price'))
->toThrow(\InvalidArgumentException::class, 'not a valid number');
});
it('gets boolean value', function () {
$definitions = [
'active' => new ArgumentDefinition('active', ArgumentType::BOOLEAN),
];
$parsed = new ParsedArguments(['active' => 'true'], [], $definitions);
expect($parsed->getBool('active'))->toBeTrue();
});
it('parses various boolean string values', function () {
$definitions = [
'flag' => new ArgumentDefinition('flag', ArgumentType::BOOLEAN),
];
$trueValues = ['true', '1', 'yes', 'on'];
foreach ($trueValues as $value) {
$parsed = new ParsedArguments(['flag' => $value], [], $definitions);
expect($parsed->getBool('flag'))->toBeTrue();
}
});
it('gets array value', function () {
$definitions = [
'items' => new ArgumentDefinition('items', ArgumentType::ARRAY),
];
$parsed = new ParsedArguments(['items' => 'item1,item2,item3'], [], $definitions);
expect($parsed->getArray('items'))->toBe(['item1', 'item2', 'item3']);
});
it('handles array value that is already an array', function () {
$definitions = [
'items' => new ArgumentDefinition('items', ArgumentType::ARRAY),
];
$parsed = new ParsedArguments(['items' => ['item1', 'item2']], [], $definitions);
expect($parsed->getArray('items'))->toBe(['item1', 'item2']);
});
it('gets email value object', function () {
$definitions = [
'email' => new ArgumentDefinition('email', ArgumentType::EMAIL),
];
$parsed = new ParsedArguments(['email' => 'user@example.com'], [], $definitions);
$email = $parsed->getEmail('email');
expect($email)->toBeInstanceOf(\App\Framework\Core\ValueObjects\EmailAddress::class);
expect($email->toString())->toBe('user@example.com');
});
it('throws exception for invalid email', function () {
$definitions = [
'email' => new ArgumentDefinition('email', ArgumentType::EMAIL),
];
$parsed = new ParsedArguments(['email' => 'invalid-email'], [], $definitions);
expect(fn () => $parsed->getEmail('email'))
->toThrow(\InvalidArgumentException::class, 'not a valid email address');
});
it('gets URL value object', function () {
$definitions = [
'url' => new ArgumentDefinition('url', ArgumentType::URL),
];
$parsed = new ParsedArguments(['url' => 'https://example.com'], [], $definitions);
$url = $parsed->getUrl('url');
expect($url)->toBeInstanceOf(\App\Framework\Http\Url\Url::class);
});
it('throws exception for invalid URL', function () {
$definitions = [
'url' => new ArgumentDefinition('url', ArgumentType::URL),
];
$parsed = new ParsedArguments(['url' => 'not-a-url'], [], $definitions);
expect(fn () => $parsed->getUrl('url'))
->toThrow(\InvalidArgumentException::class, 'not a valid URL');
});
it('checks if argument exists', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
];
$parsed = new ParsedArguments(['name' => 'John'], [], $definitions);
expect($parsed->has('name'))->toBeTrue();
expect($parsed->has('missing'))->toBeFalse();
});
it('checks if argument has value', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
'empty' => new ArgumentDefinition('empty', ArgumentType::STRING),
];
$parsed = new ParsedArguments(
['name' => 'John', 'empty' => ''],
[],
$definitions
);
expect($parsed->hasValue('name'))->toBeTrue();
expect($parsed->hasValue('empty'))->toBeFalse();
expect($parsed->hasValue('missing'))->toBeFalse();
});
it('validates required arguments', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING, required: true),
'optional' => new ArgumentDefinition('optional', ArgumentType::STRING),
];
$parsed = new ParsedArguments(['name' => 'John'], [], $definitions);
// Should not throw
$parsed->validate();
expect(true)->toBeTrue();
});
it('throws exception when required argument is missing', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING, required: true),
];
$parsed = new ParsedArguments([], [], $definitions);
expect(fn () => $parsed->validate())
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
});
it('validates allowed values', function () {
$definitions = [
'mode' => new ArgumentDefinition('mode', ArgumentType::STRING, allowedValues: ['dev', 'prod', 'test']),
];
$parsed = new ParsedArguments(['mode' => 'dev'], [], $definitions);
// Should not throw
$parsed->validate();
expect(true)->toBeTrue();
});
it('throws exception for invalid allowed value', function () {
$definitions = [
'mode' => new ArgumentDefinition('mode', ArgumentType::STRING, allowedValues: ['dev', 'prod', 'test']),
];
$parsed = new ParsedArguments(['mode' => 'invalid'], [], $definitions);
expect(fn () => $parsed->validate())
->toThrow(\InvalidArgumentException::class, "Invalid value 'invalid'");
});
it('returns all arguments', function () {
$parsed = new ParsedArguments(
['arg1' => 'value1', 'arg2' => 'value2'],
[],
[]
);
$all = $parsed->getAllArguments();
expect($all)->toHaveKey('arg1');
expect($all)->toHaveKey('arg2');
expect($all['arg1'])->toBe('value1');
});
it('returns all options', function () {
$parsed = new ParsedArguments(
[],
['opt1' => 'value1', 'opt2' => 'value2'],
[]
);
$all = $parsed->getAllOptions();
expect($all)->toHaveKey('opt1');
expect($all)->toHaveKey('opt2');
expect($all['opt1'])->toBe('value1');
});
it('returns all definitions', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
'count' => new ArgumentDefinition('count', ArgumentType::INTEGER),
];
$parsed = new ParsedArguments([], [], $definitions);
$all = $parsed->getDefinitions();
expect($all)->toHaveKey('name');
expect($all)->toHaveKey('count');
});
it('handles empty string as missing value', function () {
$definitions = [
'name' => new ArgumentDefinition('name', ArgumentType::STRING, required: true),
];
$parsed = new ParsedArguments(['name' => ''], [], $definitions);
expect(fn () => $parsed->validate())
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
});
it('handles boolean false correctly', function () {
$definitions = [
'active' => new ArgumentDefinition('active', ArgumentType::BOOLEAN),
];
$parsed = new ParsedArguments(['active' => false], [], $definitions);
expect($parsed->getBool('active'))->toBeFalse();
expect($parsed->hasValue('active'))->toBeTrue();
});
it('handles empty array correctly', function () {
$definitions = [
'items' => new ArgumentDefinition('items', ArgumentType::ARRAY),
];
$parsed = new ParsedArguments(['items' => []], [], $definitions);
expect($parsed->getArray('items'))->toBe([]);
// Empty array is considered a value (it's not null or empty string)
expect($parsed->hasValue('items'))->toBeTrue();
});
});

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console\Progress;
use App\Framework\Console\Progress\ProgressTracker;
use Tests\Framework\Console\Helpers\TestConsoleOutput;
describe('ProgressTracker', function () {
beforeEach(function () {
$this->output = new TestConsoleOutput();
});
it('creates progress tracker', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test Progress');
expect($tracker)->toBeInstanceOf(ProgressTracker::class);
});
it('creates progress tracker with default title', function () {
$tracker = new ProgressTracker($this->output, 100);
expect($tracker)->toBeInstanceOf(ProgressTracker::class);
});
it('ensures total is at least 1', function () {
$tracker = new ProgressTracker($this->output, 0);
// Should not throw, total should be adjusted to 1
expect($tracker)->toBeInstanceOf(ProgressTracker::class);
});
it('renders initial progress', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
expect($this->output->capturedWrites)->not->toBeEmpty();
});
it('advances progress', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->advance(10);
expect($tracker->getProgress())->toBeGreaterThan(0);
});
it('advances by custom step', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->advance(25);
expect($tracker->getProgress())->toBe(0.25);
});
it('advances with task description', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->advance(10, 'Processing item 1');
expect($tracker->getProgress())->toBeGreaterThan(0);
});
it('sets progress directly', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(50);
expect($tracker->getProgress())->toBe(0.5);
});
it('sets progress with task', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(50, 'Halfway done');
expect($tracker->getProgress())->toBe(0.5);
});
it('does not exceed total', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(150);
expect($tracker->getProgress())->toBe(1.0);
});
it('does not go below zero', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(-10);
expect($tracker->getProgress())->toBe(0.0);
});
it('sets task separately', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setTask('Current task');
expect($tracker)->toBeInstanceOf(ProgressTracker::class);
});
it('finishes progress', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->finish();
expect($tracker->isFinished())->toBeTrue();
expect($tracker->getProgress())->toBe(1.0);
});
it('finishes with message', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->finish('Completed!');
expect($tracker->isFinished())->toBeTrue();
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('does not advance when finished', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->finish();
$tracker->advance(10);
expect($tracker->isFinished())->toBeTrue();
});
it('does not set progress when finished', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->finish();
$tracker->setProgress(50);
expect($tracker->isFinished())->toBeTrue();
});
it('gets progress as float', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(50);
expect($tracker->getProgress())->toBe(0.5);
expect($tracker->getProgress())->toBeFloat();
});
it('gets elapsed time', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
usleep(100000); // 100ms
$elapsed = $tracker->getElapsedTime();
expect($elapsed)->toBeGreaterThan(0);
expect($elapsed)->toBeFloat();
});
it('gets estimated time remaining', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(50);
usleep(100000); // 100ms
$eta = $tracker->getEstimatedTimeRemaining();
expect($eta)->not->toBeNull();
expect($eta)->toBeFloat();
expect($eta)->toBeGreaterThan(0);
});
it('returns null for estimated time when at start', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
expect($tracker->getEstimatedTimeRemaining())->toBeNull();
});
it('sets total dynamically', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setTotal(200);
expect($tracker->getProgress())->toBeLessThan(0.1); // Progress should be recalculated
});
it('ensures total is at least 1 when setting', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setTotal(0);
// Should not throw, total should be adjusted to 1
expect($tracker)->toBeInstanceOf(ProgressTracker::class);
});
it('automatically finishes when progress reaches total', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(100);
expect($tracker->isFinished())->toBeTrue();
});
it('automatically finishes when advance reaches total', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->advance(100);
expect($tracker->isFinished())->toBeTrue();
});
it('handles very large total', function () {
$tracker = new ProgressTracker($this->output, 1000000, 'Test');
$tracker->advance(100000);
expect($tracker->getProgress())->toBe(0.1);
});
it('handles edge case with total of 1', function () {
$tracker = new ProgressTracker($this->output, 1, 'Test');
$tracker->advance(1);
expect($tracker->isFinished())->toBeTrue();
expect($tracker->getProgress())->toBe(1.0);
});
});
describe('ProgressType', function () {
it('has all expected enum values', function () {
expect(\App\Framework\Console\Progress\ProgressType::AUTO->value)->toBe('auto');
expect(\App\Framework\Console\Progress\ProgressType::TRACKER->value)->toBe('tracker');
expect(\App\Framework\Console\Progress\ProgressType::SPINNER->value)->toBe('spinner');
expect(\App\Framework\Console\Progress\ProgressType::BAR->value)->toBe('bar');
expect(\App\Framework\Console\Progress\ProgressType::NONE->value)->toBe('none');
});
it('provides descriptions for all types', function () {
expect(\App\Framework\Console\Progress\ProgressType::AUTO->getDescription())->toBe('Automatically select based on operation type');
expect(\App\Framework\Console\Progress\ProgressType::TRACKER->getDescription())->toBe('Detailed progress tracker with time estimates');
expect(\App\Framework\Console\Progress\ProgressType::SPINNER->getDescription())->toBe('Spinner for indeterminate operations');
expect(\App\Framework\Console\Progress\ProgressType::BAR->getDescription())->toBe('Simple progress bar');
expect(\App\Framework\Console\Progress\ProgressType::NONE->getDescription())->toBe('No progress indication');
});
});

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console;
use App\Framework\Console\ProgressBar;
use Tests\Framework\Console\Helpers\TestConsoleOutput;
describe('ProgressBar', function () {
beforeEach(function () {
$this->output = new TestConsoleOutput();
});
it('creates progress bar with default values', function () {
$bar = new ProgressBar($this->output);
expect($bar)->toBeInstanceOf(ProgressBar::class);
});
it('creates progress bar with custom total and width', function () {
$bar = new ProgressBar($this->output, total: 200, width: 80);
expect($bar)->toBeInstanceOf(ProgressBar::class);
});
it('ensures total is at least 1', function () {
$bar = new ProgressBar($this->output, total: 0);
// Should not throw, total should be adjusted to 1
expect($bar)->toBeInstanceOf(ProgressBar::class);
});
it('sets format', function () {
$bar = new ProgressBar($this->output);
$bar->setFormat('%bar% %current%/%total%');
// Format should be set and placeholders should be replaced
$bar->start();
$bar->advance(10);
$bar->finish();
$output = $this->output->getOutput();
// Placeholders should be replaced with actual values
expect($output)->toContain('10');
expect($output)->toContain('100');
});
it('sets bar characters', function () {
$bar = new ProgressBar($this->output);
$bar->setBarCharacters('#', '.', '>');
$bar->start();
$bar->advance(10);
$bar->finish();
// Should use custom characters
expect($this->output->getOutput())->toContain('#');
});
it('sets redraw frequency', function () {
$bar = new ProgressBar($this->output);
$bar->setRedrawFrequency(5);
// Should not throw
expect($bar)->toBeInstanceOf(ProgressBar::class);
});
it('ensures redraw frequency is at least 1', function () {
$bar = new ProgressBar($this->output);
$bar->setRedrawFrequency(0);
// Should not throw, frequency should be adjusted to 1
expect($bar)->toBeInstanceOf(ProgressBar::class);
});
it('advances progress', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->start();
$bar->advance(10);
$bar->finish();
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('advances by custom step', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->start();
$bar->advance(25);
$bar->finish();
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('sets progress directly', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->start();
$bar->setCurrent(50);
$bar->finish();
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('does not exceed total', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->start();
$bar->setCurrent(150);
$bar->finish();
// Should cap at 100
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('does not go below zero', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->start();
$bar->setCurrent(-10);
$bar->finish();
// Should cap at 0
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('starts progress bar', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->start();
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('finishes progress bar', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->start();
$bar->advance(50);
$bar->finish();
expect($this->output->capturedLines)->not->toBeEmpty();
expect($this->output->newLineCount)->toBeGreaterThan(0);
});
it('completes progress when finishing', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->start();
$bar->advance(50);
$bar->finish();
// Should complete to 100%
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('formats progress with all placeholders', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->setFormat('%bar% %current%/%total% %percent%% %elapsed%s %remaining%s');
$bar->start();
$bar->advance(50);
$bar->finish();
$output = $this->output->getOutput();
// Placeholders should be replaced with actual values
expect($output)->toContain('50');
expect($output)->toContain('100');
expect($output)->toContain('%'); // Percent sign should be present
});
it('handles edge case with total of 1', function () {
$bar = new ProgressBar($this->output, total: 1);
$bar->start();
$bar->advance(1);
$bar->finish();
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('handles very large total', function () {
$bar = new ProgressBar($this->output, total: 1000000);
$bar->start();
$bar->advance(100000);
$bar->finish();
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('respects redraw frequency', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->setRedrawFrequency(10);
$bar->start();
// Advance multiple times - should only redraw every 10th time
for ($i = 0; $i < 25; $i++) {
$bar->advance(1);
}
$bar->finish();
// Should have output
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('always redraws on finish', function () {
$bar = new ProgressBar($this->output, total: 100);
$bar->setRedrawFrequency(100);
$bar->start();
$bar->advance(50);
$bar->finish();
// Should redraw on finish even if frequency not reached
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('handles negative total by adjusting to 1', function () {
$bar = new ProgressBar($this->output, total: -10);
// Should not throw, total should be adjusted to 1
expect($bar)->toBeInstanceOf(ProgressBar::class);
});
});

Some files were not shown because too many files have changed in this diff Show More