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:
208
deployment/ansible/playbooks/CLEANUP_SUMMARY.md
Normal file
208
deployment/ansible/playbooks/CLEANUP_SUMMARY.md
Normal 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
|
||||
|
||||
|
||||
@@ -1,42 +1,81 @@
|
||||
# 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
|
||||
|
||||
> **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-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
|
||||
### Setup (Initial Setup)
|
||||
|
||||
### Deployment & Updates
|
||||
- **`rollback.yml`** - Rollback zu vorheriger Version
|
||||
- **`backup.yml`** - Erstellt Backups von PostgreSQL, Application Data, Gitea, Registry
|
||||
- **`deploy-image.yml`** - Docker Image Deployment (wird von CI/CD Workflows verwendet)
|
||||
- **`setup/infrastructure.yml`** - Deployed alle Stacks (Traefik, PostgreSQL, Redis, Registry, Gitea, Monitoring, Production)
|
||||
- **`setup/gitea.yml`** - Setup Gitea Initial Configuration (Wrapper für `gitea` Role, `tasks_from: setup`)
|
||||
- **`setup/ssl.yml`** - SSL Certificate Setup (Wrapper für `traefik` Role, `tasks_from: ssl`)
|
||||
- **`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`)
|
||||
- **`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`)
|
||||
- **`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`)
|
||||
- **`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`)
|
||||
- **`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`)
|
||||
|
||||
### Application Deployment (Role-basiert)
|
||||
- **`deploy-application-code.yml`** - Deploy Application Code via Git (Wrapper für `application` Role, `tasks_from: deploy_code` mit `application_deployment_method: git`)
|
||||
#### Application Management
|
||||
- **`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`)
|
||||
- **`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-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`)
|
||||
@@ -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`)
|
||||
- **`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
|
||||
- **`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
|
||||
- **`troubleshoot.yml`** - Unified Troubleshooting mit Tags
|
||||
|
||||
- **`maintenance/backup.yml`** - Erstellt Backups von PostgreSQL, Application Data, Gitea, Registry
|
||||
- **`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
|
||||
|
||||
- **`generate-wireguard-client.yml`** - Generiert WireGuard Client-Config
|
||||
- **`wireguard-routing.yml`** - Konfiguriert WireGuard Routing
|
||||
- **`setup-wireguard-host.yml`** - WireGuard VPN Setup
|
||||
|
||||
### Initial Deployment
|
||||
|
||||
- **`build-initial-image.yml`** - Build und Push des initialen Docker Images (für erstes Deployment)
|
||||
|
||||
### CI/CD & Development
|
||||
|
||||
- **`setup-gitea-runner-ci.yml`** - Gitea Runner CI Setup
|
||||
- **`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:
|
||||
- ~~`build-and-push.yml`~~ - Wird durch CI/CD Pipeline ersetzt
|
||||
- ~~`remove-framework-production-stack.yml`~~ - Temporäres Playbook
|
||||
- ~~`remove-temporary-grafana-ip.yml`~~ - Temporäres Playbook
|
||||
Die folgenden Playbooks wurden konsolidiert oder entfernt:
|
||||
|
||||
### Konsolidiert in `diagnose/gitea.yml`:
|
||||
- ~~`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
|
||||
|
||||
@@ -78,6 +178,69 @@ cd deployment/ansible
|
||||
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
|
||||
|
||||
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**
|
||||
```bash
|
||||
# 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 "git_branch=staging" \
|
||||
--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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
Die Playbooks verwenden jetzt folgende Roles:
|
||||
@@ -143,11 +291,11 @@ Die Playbooks verwenden jetzt folgende Roles:
|
||||
- **Location**: `roles/application/tasks/`
|
||||
- **Defaults**: `roles/application/defaults/main.yml`
|
||||
|
||||
## Vorteile der Role-basierten 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
|
||||
## Vorteile der neuen Struktur
|
||||
|
||||
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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
@@ -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)
|
||||
================================================================================
|
||||
403
deployment/ansible/playbooks/diagnose/gitea.yml
Normal file
403
deployment/ansible/playbooks/diagnose/gitea.yml
Normal 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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
|
||||
229
deployment/ansible/playbooks/diagnose/traefik.yml
Normal file
229
deployment/ansible/playbooks/diagnose/traefik.yml
Normal 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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
================================================================================
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
================================================================================
|
||||
|
||||
255
deployment/ansible/playbooks/maintenance/rollback-redeploy.yml
Normal file
255
deployment/ansible/playbooks/maintenance/rollback-redeploy.yml
Normal 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
|
||||
|
||||
================================================================================
|
||||
|
||||
|
||||
294
deployment/ansible/playbooks/manage/gitea.yml
Normal file
294
deployment/ansible/playbooks/manage/gitea.yml
Normal 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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
|
||||
162
deployment/ansible/playbooks/manage/traefik.yml
Normal file
162
deployment/ansible/playbooks/manage/traefik.yml
Normal 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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
210
deployment/ansible/playbooks/setup/REDEPLOY_GUIDE.md
Normal file
210
deployment/ansible/playbooks/setup/REDEPLOY_GUIDE.md
Normal 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
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -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
|
||||
================================================================================
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -23,6 +23,10 @@ DOMAIN = {{ gitea_domain }}
|
||||
HTTP_ADDR = 0.0.0.0
|
||||
HTTP_PORT = 3000
|
||||
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
|
||||
START_SSH_SERVER = false
|
||||
SSH_DOMAIN = {{ gitea_domain }}
|
||||
@@ -68,7 +72,11 @@ HOST = redis://:{{ redis_password }}@redis:6379/0?pool_size=100&idle_timeout=180
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
[session]
|
||||
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)
|
||||
@@ -82,6 +90,8 @@ CONN_STR = redis://:{{ redis_password }}@redis:6379/0
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
# Cookie security (only if ROOT_URL is https)
|
||||
COOKIE_SECURE = true
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Service Configuration
|
||||
|
||||
@@ -218,3 +218,4 @@ ansible-playbook -i inventory/production.yml \
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,8 +37,16 @@ services:
|
||||
- "traefik.http.routers.gitea.priority=100"
|
||||
# Service configuration (Docker provider uses port, not url)
|
||||
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
|
||||
# Middleware chain (removed temporarily to test if it causes issues)
|
||||
# - "traefik.http.routers.gitea.middlewares=security-headers-global@file,gzip-compression@file"
|
||||
# ServersTransport for longer timeouts (prevents 504 for SSE/Long-Polling like /user/events)
|
||||
# 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)
|
||||
- "traefik.http.routers.gitea.service=gitea"
|
||||
healthcheck:
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Analytics;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Application\Analytics\Service\AnalyticsDashboardService;
|
||||
use App\Application\Analytics\Service\AnalyticsRealTimeService;
|
||||
use App\Application\Analytics\Service\AnalyticsReportService;
|
||||
@@ -22,10 +21,9 @@ use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
|
||||
#[AdminSection(name: 'Analytics', icon: 'chart-bar', order: 4, description: 'Analytics and reporting')]
|
||||
final class AnalyticsController
|
||||
final readonly class AnalyticsController
|
||||
{
|
||||
public function __construct(
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private AnalyticsDashboardService $dashboardService,
|
||||
private AnalyticsStorage $storage,
|
||||
private AnalyticsReportService $reportService,
|
||||
@@ -69,12 +67,10 @@ final class AnalyticsController
|
||||
'last_update' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'analytics-dashboard',
|
||||
metaData: new MetaData('Analytics Dashboard', 'Website Analytics and User Behavior'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Content;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Domain\Media\ImageRepository;
|
||||
use App\Domain\Media\ImageSlotRepository;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
@@ -21,7 +20,6 @@ final readonly class ImageManagerController
|
||||
public function __construct(
|
||||
private ImageSlotRepository $slotRepository,
|
||||
private ImageRepository $imageRepository,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -40,12 +38,10 @@ final readonly class ImageManagerController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
'image-manager',
|
||||
new MetaData('Image Manager'),
|
||||
$finalData
|
||||
$data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Content;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Domain\Media\ImageSlot;
|
||||
use App\Domain\Media\ImageSlotRepository;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
@@ -21,7 +20,6 @@ final readonly class ImageSlotsController
|
||||
{
|
||||
public function __construct(
|
||||
private ImageSlotRepository $imageSlotRepository,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -38,9 +36,7 @@ final readonly class ImageSlotsController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult('imageslots', new MetaData('Image Slots', 'Image Slots Management'), $finalData);
|
||||
return new ViewResult('imageslots', new MetaData('Image Slots', 'Image Slots Management'), $data);
|
||||
}
|
||||
|
||||
#[Route('/admin/content/image-slots/{slotName}', method: Method::POST)]
|
||||
@@ -55,9 +51,7 @@ final readonly class ImageSlotsController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult('imageslot', new MetaData('Edit Image Slot', 'Image Slot Management'), $finalData);
|
||||
return new ViewResult('imageslot', new MetaData('Edit Image Slot', 'Image Slot Management'), $data);
|
||||
}
|
||||
|
||||
#[Route('/admin/imageslots/create', method: Method::POST)]
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Application\Admin\Database;
|
||||
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\AdminPageRenderer;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Database\Browser\Registry\DatabaseRegistry;
|
||||
@@ -24,7 +23,6 @@ final readonly class DatabaseBrowserController
|
||||
private DatabaseRegistry $databaseRegistry,
|
||||
private TableRegistry $tableRegistry,
|
||||
private AdminPageRenderer $pageRenderer,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private PaginationService $paginationService,
|
||||
private DatabaseTableGenerator $tableGenerator,
|
||||
) {
|
||||
@@ -72,12 +70,10 @@ final readonly class DatabaseBrowserController
|
||||
],
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'database/browser',
|
||||
metaData: new MetaData('Database Browser', 'Admin - Database Browser'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Application\Admin\Database;
|
||||
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Database\Browser\Registry\TableRegistry;
|
||||
use App\Framework\Meta\MetaData;
|
||||
@@ -16,7 +15,6 @@ final readonly class TableBrowserController
|
||||
{
|
||||
public function __construct(
|
||||
private TableRegistry $tableRegistry,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private DatabaseMetadataTableGenerator $metadataTableGenerator,
|
||||
) {
|
||||
}
|
||||
@@ -33,12 +31,10 @@ final readonly class TableBrowserController
|
||||
'error' => "Table '{$table}' not found",
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'database/table-detail',
|
||||
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),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'database/table-detail',
|
||||
metaData: new MetaData("Table: {$tableSchema->name}", "Admin - Table: {$tableSchema->name}"),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Development;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Design\Component\ComponentCategory;
|
||||
@@ -33,7 +32,6 @@ final readonly class DesignSystemController
|
||||
private DesignSystemAnalyzer $analyzer,
|
||||
private FileScanner $fileScanner,
|
||||
private ComponentScanner $componentScanner,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -56,12 +54,10 @@ final readonly class DesignSystemController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
'design-dashboard',
|
||||
new MetaData('Design System Dashboard', 'Design System Dashboard'),
|
||||
$finalData
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Development;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Framework\Attributes\Route;
|
||||
@@ -17,7 +16,6 @@ final readonly class RoutesController
|
||||
{
|
||||
public function __construct(
|
||||
private DiscoveryRegistry $processedResults,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -110,12 +108,10 @@ final readonly class RoutesController
|
||||
'routes' => $routes,
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'routes-overview',
|
||||
metaData: new MetaData('Routes Overview', 'System Routes Overview'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Development;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Http\Method;
|
||||
@@ -29,7 +28,6 @@ use App\Framework\Router\AdminRoutes;
|
||||
final readonly class StyleguideController
|
||||
{
|
||||
public function __construct(
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -52,12 +50,10 @@ final readonly class StyleguideController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'styleguide',
|
||||
metaData: $metaData,
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Development;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Config\WafConfig as FrameworkWafConfig;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
@@ -46,7 +45,6 @@ final class WafTestController
|
||||
private readonly PerformanceService $performance,
|
||||
private readonly Logger $logger,
|
||||
private readonly Clock $clock,
|
||||
private readonly AdminLayoutProcessor $layoutProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -59,12 +57,10 @@ final class WafTestController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'waf-test',
|
||||
metaData: new MetaData('WAF Test Suite', 'Web Application Firewall Testing Interface'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Infrastructure;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Framework\Attributes\Route;
|
||||
@@ -22,7 +21,6 @@ final readonly class CacheMetricsController
|
||||
public function __construct(
|
||||
private CacheMetricsInterface $cacheMetrics,
|
||||
private Clock $clock,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -52,12 +50,10 @@ final readonly class CacheMetricsController
|
||||
$data['driver_stats'] = $driverStats;
|
||||
}
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'cache-metrics',
|
||||
metaData: new MetaData('Cache Metrics', 'Cache Performance Monitoring'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Infrastructure;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Framework\Attributes\Route;
|
||||
@@ -22,7 +21,6 @@ final readonly class DockerController
|
||||
{
|
||||
public function __construct(
|
||||
private DockerService $dockerService,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -71,12 +69,10 @@ final readonly class DockerController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'docker-dashboard',
|
||||
metaData: new MetaData('Docker Dashboard', 'Docker container management and monitoring'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Infrastructure;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Framework\Attributes\Route;
|
||||
@@ -28,7 +27,6 @@ final readonly class LogViewerController
|
||||
public function __construct(
|
||||
private LogViewer $logViewer,
|
||||
private Timer $timer,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -48,9 +46,7 @@ final readonly class LogViewerController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult('log-viewer', $metaData, $finalData);
|
||||
return new ViewResult('log-viewer', $metaData, $data);
|
||||
}
|
||||
|
||||
#[Route('/admin/infrastructure/logs/api/list', Method::GET)]
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Infrastructure;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Framework\Attributes\Route;
|
||||
@@ -20,7 +19,6 @@ final readonly class RedisController
|
||||
public function __construct(
|
||||
private RedisMonitoringService $redisMonitoring,
|
||||
private Clock $clock,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -86,12 +84,10 @@ final readonly class RedisController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'redis',
|
||||
metaData: new MetaData('Redis Dashboard', 'Redis monitoring and cache analysis'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Infrastructure;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Framework\Attributes\Route;
|
||||
@@ -20,7 +19,6 @@ final readonly class ServicesController
|
||||
public function __construct(
|
||||
private DefaultContainer $container,
|
||||
private Clock $clock,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -66,12 +64,10 @@ final readonly class ServicesController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'services',
|
||||
metaData: new MetaData('Registrierte Dienste', 'Übersicht aller registrierten Services'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\MachineLearning;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
@@ -23,7 +22,6 @@ final readonly class MLDashboardAdminController
|
||||
public function __construct(
|
||||
private ModelRegistry $registry,
|
||||
private ModelPerformanceMonitor $performanceMonitor,
|
||||
private AdminLayoutProcessor $layoutProcessor
|
||||
) {}
|
||||
|
||||
#[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',
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'ml-dashboard',
|
||||
metaData: new MetaData('ML Dashboard', 'Machine Learning Model Monitoring and Performance'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Framework\Attributes\Route;
|
||||
@@ -24,7 +23,6 @@ final readonly class MigrationStatus
|
||||
public function __construct(
|
||||
private MigrationLoader $migrationLoader,
|
||||
private MigrationRunner $migrationRunner,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -96,12 +94,10 @@ final readonly class MigrationStatus
|
||||
'has_pending' => $pendingCount > 0,
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'migrations',
|
||||
metaData: new MetaData('Database Migrations', 'Database migration status and management'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Application\LiveComponents\ImageGallery\ImageGalleryComponent;
|
||||
use App\Domain\Media\ImageRepository;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
@@ -21,7 +20,6 @@ use App\Framework\Router\Result\ViewResult;
|
||||
final readonly class ShowImageManager
|
||||
{
|
||||
public function __construct(
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private ImageRepository $imageRepository,
|
||||
private DataProviderResolver $dataProviderResolver
|
||||
) {
|
||||
@@ -56,13 +54,11 @@ final readonly class ShowImageManager
|
||||
'slots' => [], // TODO: Load actual slots for legacy support
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($viewData);
|
||||
|
||||
$metaData = MetaData::create(
|
||||
title: 'Image Management',
|
||||
description: 'Upload, manage and organize your images'
|
||||
);
|
||||
|
||||
return new ViewResult('image-manager', $metaData, $finalData);
|
||||
return new ViewResult('image-manager', $metaData, $viewData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Domain\Media\Image;
|
||||
use App\Domain\Media\ImageRepository;
|
||||
use App\Domain\Media\ImageResizer;
|
||||
@@ -28,7 +27,6 @@ final readonly class ShowImageUpload
|
||||
private PathProvider $pathProvider,
|
||||
private StringConverter $stringConverter,
|
||||
private FormIdGenerator $formIdGenerator,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -49,14 +47,12 @@ final readonly class ShowImageUpload
|
||||
'formHtml' => RawHtml::from($formHtml),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
$metaData = MetaData::create(
|
||||
title: 'Bild-Upload | Admin Panel',
|
||||
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)]
|
||||
@@ -143,12 +139,10 @@ final readonly class ShowImageUpload
|
||||
'formHtml' => $this->buildUploadForm(),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
'upload-form',
|
||||
MetaData::create('Upload Fehler | Admin Panel', $message),
|
||||
$finalData
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,12 +155,10 @@ final readonly class ShowImageUpload
|
||||
'formHtml' => $this->buildUploadForm(),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
'upload-form',
|
||||
MetaData::create($title . ' | Admin Panel', $message),
|
||||
$finalData
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
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')]
|
||||
final readonly class ShowUploadTest
|
||||
{
|
||||
public function __construct(
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
) {
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
#[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.',
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
$metaData = MetaData::create(
|
||||
title: 'JavaScript Upload Test | Admin Panel',
|
||||
description: 'Test JavaScript upload functionality'
|
||||
);
|
||||
|
||||
return new ViewResult('upload-test', $metaData, $finalData);
|
||||
return new ViewResult('upload-test', $metaData, $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\System;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Framework\Attributes\Route;
|
||||
@@ -21,7 +20,6 @@ final readonly class EnvironmentController
|
||||
public function __construct(
|
||||
private DefaultContainer $container,
|
||||
private Clock $clock,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -60,12 +58,10 @@ final readonly class EnvironmentController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'environment',
|
||||
metaData: new MetaData('Umgebungsvariablen', 'Umgebungsvariablen'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\System;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Framework\Attributes\Route;
|
||||
@@ -22,7 +21,6 @@ final readonly class HealthController
|
||||
{
|
||||
public function __construct(
|
||||
private HealthCheckManager $healthManager,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private Clock $clock,
|
||||
private HealthCheckTableGenerator $tableGenerator,
|
||||
) {
|
||||
@@ -81,14 +79,10 @@ final readonly class HealthController
|
||||
'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(
|
||||
template: 'health-dashboard',
|
||||
metaData: new MetaData('System Health', 'System Health Dashboard'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\System;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
use App\Framework\Attributes\Route;
|
||||
@@ -24,7 +23,6 @@ final readonly class PerformanceController
|
||||
public function __construct(
|
||||
private MemoryMonitor $memoryMonitor,
|
||||
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'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'performance',
|
||||
metaData: new MetaData('Performance-Daten', 'Performance-Daten'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\System;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Application\Admin\System\Service\PhpInfoService;
|
||||
use App\Framework\Admin\Attributes\AdminPage;
|
||||
use App\Framework\Admin\Attributes\AdminSection;
|
||||
@@ -19,7 +18,6 @@ final readonly class PhpInfoController
|
||||
{
|
||||
public function __construct(
|
||||
private PhpInfoService $phpInfoService,
|
||||
private AdminLayoutProcessor $layoutProcessor,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -56,12 +54,10 @@ final readonly class PhpInfoController
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'phpinfo',
|
||||
metaData: new MetaData('PHP Information', 'PHP Information and Configuration'),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Admin;
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
use App\Framework\View\FormBuilder;
|
||||
@@ -13,13 +12,13 @@ use App\Framework\View\Table\Table;
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
public function __construct(
|
||||
private AdminLayoutProcessor $layoutProcessor
|
||||
) {
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function renderIndex(
|
||||
@@ -35,12 +34,10 @@ final readonly class AdminPageRenderer
|
||||
'actions' => $actions,
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'admin-index',
|
||||
metaData: new MetaData($title, "Admin - {$title}"),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,12 +54,10 @@ final readonly class AdminPageRenderer
|
||||
'form' => $form->build(),
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'admin-form',
|
||||
metaData: new MetaData($title, "Admin - {$title}"),
|
||||
data: $finalData
|
||||
data: $data
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,12 +72,10 @@ final readonly class AdminPageRenderer
|
||||
...$data,
|
||||
];
|
||||
|
||||
$finalData = $this->layoutProcessor->processLayoutFromArray($pageData);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'admin-show',
|
||||
metaData: new MetaData($title, "Admin - {$title}"),
|
||||
data: $finalData
|
||||
data: $pageData
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ final readonly class CommandCategorizer
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const CATEGORY_INFO = [
|
||||
private const array CATEGORY_INFO = [
|
||||
'db' => 'Database operations (migrations, health checks)',
|
||||
'errors' => 'Error management and analytics',
|
||||
'backup' => 'Backup and restore operations',
|
||||
|
||||
@@ -187,12 +187,13 @@ final readonly class ConsoleTUI
|
||||
$needsRender = true;
|
||||
}
|
||||
|
||||
// Render if needed (with throttling)
|
||||
// Render if needed (with throttling to reduce flickering)
|
||||
if ($needsRender) {
|
||||
$currentTime = microtime(true);
|
||||
$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();
|
||||
$lastRenderTime = $currentTime;
|
||||
$needsRender = false;
|
||||
@@ -204,7 +205,7 @@ final readonly class ConsoleTUI
|
||||
|
||||
// Sleep if no events processed to reduce CPU usage
|
||||
if ($eventsProcessed === 0) {
|
||||
usleep(5000); // 5ms
|
||||
usleep(10000); // 10ms - increased to reduce CPU usage
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errorCount++;
|
||||
|
||||
@@ -377,6 +377,11 @@ final readonly class KeyboardEventHandler
|
||||
*/
|
||||
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 !== '') {
|
||||
return $event->code;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ final class TuiRenderer
|
||||
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
|
||||
$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
|
||||
{
|
||||
$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(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));
|
||||
|
||||
// Clear everything from cursor downwards (preserves lines 2-3: menu bar)
|
||||
@@ -151,25 +158,40 @@ final class TuiRenderer
|
||||
*/
|
||||
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;
|
||||
$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++;
|
||||
|
||||
$categories = $state->getCategories();
|
||||
$maxVisibleCategories = min(count($categories), 20); // Limit visible items to prevent overflow
|
||||
|
||||
foreach ($categories as $index => $category) {
|
||||
// Only render visible categories to prevent overflow
|
||||
if ($index >= $maxVisibleCategories) {
|
||||
break;
|
||||
}
|
||||
|
||||
$isSelected = $index === $state->getSelectedCategory();
|
||||
$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->renderCategoryItem($category, $isSelected, $isHovered);
|
||||
$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([
|
||||
"↑/↓: Navigate",
|
||||
"Enter: Select",
|
||||
@@ -195,8 +217,9 @@ final class TuiRenderer
|
||||
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ($isHovered ? ConsoleColor::BRIGHT_CYAN : ConsoleColor::WHITE);
|
||||
|
||||
// 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("\r");
|
||||
$this->output->write("\r"); // Reset to beginning of line
|
||||
|
||||
// Build the text without any tabs or extra spaces
|
||||
$text = "{$prefix}{$icon} {$name} ({$count} commands)";
|
||||
@@ -208,8 +231,8 @@ final class TuiRenderer
|
||||
$this->output->write($text);
|
||||
}
|
||||
|
||||
// Move to next line explicitly
|
||||
$this->output->write("\n");
|
||||
// Don't write newline here - the caller controls line positioning
|
||||
// The cursor will be positioned by the caller for the next item
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,17 +245,57 @@ final class TuiRenderer
|
||||
return;
|
||||
}
|
||||
|
||||
$icon = $category['icon'] ?? '📁';
|
||||
$this->output->writeLine("📂 {$icon} {$category['name']} Commands:", ConsoleColor::BRIGHT_YELLOW);
|
||||
$this->output->writeLine('');
|
||||
// Start at line 4 (after menu bar at lines 2-3)
|
||||
$currentLine = 4;
|
||||
|
||||
foreach ($category['commands'] as $index => $command) {
|
||||
$isSelected = $index === $state->getSelectedCommand();
|
||||
$isHovered = $state->isContentItemHovered('command', $index);
|
||||
$this->renderCommandItem($command, $isSelected, $isHovered);
|
||||
// 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;
|
||||
}
|
||||
|
||||
$this->output->newLine();
|
||||
$isSelected = $index === $state->getSelectedCommand();
|
||||
$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);
|
||||
$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
|
||||
}
|
||||
}
|
||||
|
||||
// Render navigation bar after commands
|
||||
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
|
||||
$this->output->writeLine('');
|
||||
$this->renderNavigationBar([
|
||||
"↑/↓: Navigate",
|
||||
"Enter: Execute",
|
||||
@@ -269,11 +332,21 @@ final class TuiRenderer
|
||||
$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)) {
|
||||
// Clear next line and write description
|
||||
$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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ use App\Framework\Discovery\DiscoveryServiceBootstrapper;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Logging\ValueObjects\LogContext;
|
||||
use App\Framework\Pcntl\PcntlService;
|
||||
use App\Framework\Pcntl\ValueObjects\Signal;
|
||||
use JetBrains\PhpStorm\NoReturn;
|
||||
use Throwable;
|
||||
|
||||
final class ConsoleApplication
|
||||
@@ -46,7 +48,7 @@ final class ConsoleApplication
|
||||
|
||||
// Setup signal handlers für graceful shutdown
|
||||
$this->signalHandler = new ConsoleSignalHandler(
|
||||
$this->container,
|
||||
$container->get(PcntlService::class),
|
||||
function (Signal $signal) {
|
||||
$this->handleShutdown($signal);
|
||||
}
|
||||
@@ -87,6 +89,7 @@ final class ConsoleApplication
|
||||
$this->errorHandler = new ConsoleErrorHandler($recoveryService, $logger);
|
||||
}
|
||||
|
||||
#[NoReturn]
|
||||
public function handleShutdown(Signal $signal): void
|
||||
{
|
||||
$this->shutdownRequested = true;
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Framework\Console;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\Pcntl\PcntlService;
|
||||
use App\Framework\Pcntl\ValueObjects\Signal;
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* Kapselt Signal-Handler-Setup für ConsoleApplication.
|
||||
@@ -16,13 +17,10 @@ use App\Framework\Pcntl\ValueObjects\Signal;
|
||||
*/
|
||||
final readonly class ConsoleSignalHandler
|
||||
{
|
||||
private ?PcntlService $pcntlService = null;
|
||||
|
||||
public function __construct(
|
||||
private Container $container,
|
||||
private callable $shutdownCallback
|
||||
) {
|
||||
}
|
||||
private PcntlService $pcntlService,
|
||||
private Closure $shutdownCallback
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Setup shutdown handlers für SIGTERM, SIGINT, SIGHUP.
|
||||
@@ -32,8 +30,6 @@ final readonly class ConsoleSignalHandler
|
||||
public function setupShutdownHandlers(): void
|
||||
{
|
||||
try {
|
||||
$this->pcntlService = $this->container->get(PcntlService::class);
|
||||
|
||||
$this->pcntlService->registerSignal(Signal::SIGTERM, function (Signal $signal) {
|
||||
($this->shutdownCallback)($signal);
|
||||
});
|
||||
@@ -58,10 +54,8 @@ final readonly class ConsoleSignalHandler
|
||||
*/
|
||||
public function dispatchSignals(): void
|
||||
{
|
||||
if ($this->pcntlService !== null) {
|
||||
$this->pcntlService->dispatchSignals();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Signal-Handling verfügbar ist.
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
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\UrlFactory;
|
||||
|
||||
@@ -105,9 +105,9 @@ final readonly class ParsedArguments
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -191,7 +191,7 @@ class ProgressBar
|
||||
*/
|
||||
private function getRemaining(float $percent): string
|
||||
{
|
||||
if ($percent === 0) {
|
||||
if ($percent === 0.0 || $percent < 0.0001) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ class Spinner
|
||||
$frame = $this->frames[$this->currentFrame];
|
||||
$this->output->write("\r\033[2K{$frame} {$this->message}");
|
||||
|
||||
$this->updateCount = $expectedUpdates;
|
||||
$this->updateCount = (int) $expectedUpdates;
|
||||
}
|
||||
|
||||
return $this;
|
||||
|
||||
@@ -23,6 +23,7 @@ use App\Framework\Router\Result\WebSocketResult;
|
||||
use App\Framework\View\RenderContext;
|
||||
use App\Framework\View\Template;
|
||||
use App\Framework\View\TemplateRenderer;
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
|
||||
final readonly class RouteResponder
|
||||
{
|
||||
@@ -36,16 +37,51 @@ final readonly class RouteResponder
|
||||
|
||||
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(
|
||||
template: $result->model ? $this->resolveTemplate($result->model) : $result->template,
|
||||
metaData: $result->metaData,
|
||||
data: $result->model ? get_object_vars($result->model) + $result->data : $result->data,
|
||||
data: $data,
|
||||
layout: '',
|
||||
slots: $result->slots,
|
||||
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
|
||||
{
|
||||
if ($result instanceof Response) {
|
||||
|
||||
@@ -38,14 +38,14 @@ final readonly class StorageInitializer
|
||||
// Register MinIoClient (if needed for S3 driver)
|
||||
$container->singleton(MinIoClient::class, function (Container $container) use ($env) {
|
||||
return new MinIoClient(
|
||||
endpoint: $env->getString('MINIO_ENDPOINT', 'http://minio:9000'),
|
||||
accessKey: $env->getString('MINIO_ACCESS_KEY', 'minioadmin'),
|
||||
secretKey: $env->getString('MINIO_SECRET_KEY', 'minioadmin'),
|
||||
region: $env->getString('MINIO_REGION', 'us-east-1'),
|
||||
usePathStyle: $env->getBool('MINIO_USE_PATH_STYLE', true),
|
||||
endpoint : $env->getString('MINIO_ENDPOINT', 'http://minio:9000'),
|
||||
accessKey : $env->getString('MINIO_ACCESS_KEY', 'minioadmin'),
|
||||
secretKey : $env->getString('MINIO_SECRET_KEY', 'minioadmin'),
|
||||
randomGenerator: $container->get(RandomGenerator::class),
|
||||
hmacService: $container->get(HmacService::class),
|
||||
httpClient: $container->get(CurlHttpClient::class)
|
||||
hmacService : $container->get(HmacService::class),
|
||||
httpClient : $container->get(CurlHttpClient::class),
|
||||
region : $env->getString('MINIO_REGION', 'us-east-1'),
|
||||
usePathStyle : $env->getBool('MINIO_USE_PATH_STYLE', true)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
196
tests/Framework/Console/ArgumentDefinitionTest.php
Normal file
196
tests/Framework/Console/ArgumentDefinitionTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
283
tests/Framework/Console/ArgumentParserTest.php
Normal file
283
tests/Framework/Console/ArgumentParserTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
57
tests/Framework/Console/ArgumentTypeTest.php
Normal file
57
tests/Framework/Console/ArgumentTypeTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
152
tests/Framework/Console/CommandCategorizerTest.php
Normal file
152
tests/Framework/Console/CommandCategorizerTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
191
tests/Framework/Console/CommandListTest.php
Normal file
191
tests/Framework/Console/CommandListTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
237
tests/Framework/Console/CommandParameterResolverTest.php
Normal file
237
tests/Framework/Console/CommandParameterResolverTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
103
tests/Framework/Console/CommandResultProcessorTest.php
Normal file
103
tests/Framework/Console/CommandResultProcessorTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
291
tests/Framework/Console/Components/ConsoleDialogTest.php
Normal file
291
tests/Framework/Console/Components/ConsoleDialogTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
169
tests/Framework/Console/Components/ConsoleTUITest.php
Normal file
169
tests/Framework/Console/Components/ConsoleTUITest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
158
tests/Framework/Console/Components/InteractiveMenuTest.php
Normal file
158
tests/Framework/Console/Components/InteractiveMenuTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
107
tests/Framework/Console/ConsoleColorTest.php
Normal file
107
tests/Framework/Console/ConsoleColorTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
199
tests/Framework/Console/ConsoleInputTest.php
Normal file
199
tests/Framework/Console/ConsoleInputTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
208
tests/Framework/Console/ConsoleOutputTest.php
Normal file
208
tests/Framework/Console/ConsoleOutputTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
105
tests/Framework/Console/ExitCodeTest.php
Normal file
105
tests/Framework/Console/ExitCodeTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
163
tests/Framework/Console/Helpers/MockKeyboard.php
Normal file
163
tests/Framework/Console/Helpers/MockKeyboard.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
128
tests/Framework/Console/Helpers/MockStdin.php
Normal file
128
tests/Framework/Console/Helpers/MockStdin.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
153
tests/Framework/Console/Helpers/MockTerminal.php
Normal file
153
tests/Framework/Console/Helpers/MockTerminal.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
149
tests/Framework/Console/Helpers/TestConsoleOutput.php
Normal file
149
tests/Framework/Console/Helpers/TestConsoleOutput.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
227
tests/Framework/Console/InteractiveFormTest.php
Normal file
227
tests/Framework/Console/InteractiveFormTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
167
tests/Framework/Console/InteractivePrompterTest.php
Normal file
167
tests/Framework/Console/InteractivePrompterTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
387
tests/Framework/Console/ParsedArgumentsTest.php
Normal file
387
tests/Framework/Console/ParsedArgumentsTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
224
tests/Framework/Console/Progress/ProgressTrackerTest.php
Normal file
224
tests/Framework/Console/Progress/ProgressTrackerTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
217
tests/Framework/Console/ProgressBarTest.php
Normal file
217
tests/Framework/Console/ProgressBarTest.php
Normal 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
Reference in New Issue
Block a user