diff --git a/deployment/infrastructure/.gitignore b/deployment/infrastructure/.gitignore new file mode 100644 index 00000000..65e53a85 --- /dev/null +++ b/deployment/infrastructure/.gitignore @@ -0,0 +1,2 @@ +# Ignore local Ansible vault pass in infrastructure directory +.vault_pass diff --git a/deployment/infrastructure/playbooks/deploy-application.yml b/deployment/infrastructure/playbooks/deploy-application.yml new file mode 100644 index 00000000..a3ae54a0 --- /dev/null +++ b/deployment/infrastructure/playbooks/deploy-application.yml @@ -0,0 +1,226 @@ +--- +# Production Container Deployment Playbook +# Deploys pre-built container images for Custom PHP Framework + +- name: Deploy Custom PHP Framework Application + hosts: web_servers + become: true + gather_facts: true + + vars: + app_path: "/var/www/html" + backup_path: "/var/www/backups" + image_tag: "{{ IMAGE_TAG | default('latest') }}" + domain_name: "{{ DOMAIN_NAME | default('michaelschiemer.de') }}" + backup_enabled: "{{ BACKUP_ENABLED | default(true) | bool }}" + backup_retention_days: "{{ BACKUP_RETENTION_DAYS | default(30) }}" + cdn_update: "{{ CDN_UPDATE | default(false) | bool }}" + # Pfade für Templates/Compose relativ zum Playbook-Verzeichnis + compose_base_src: "{{ playbook_dir }}/../../../docker-compose.yml" + compose_overlay_src: "{{ playbook_dir }}/../../applications/docker-compose.{{ environment }}.yml" + env_template_src: "{{ playbook_dir }}/../../applications/environments/.env.{{ environment }}.template" + # Compose-Projektname: Standardmäßig Verzeichnisname von app_path (z. B. 'html') + compose_project: "{{ compose_project_name | default(app_path | basename) }}" + + pre_tasks: + - name: Verify deployment requirements + assert: + that: + - app_path is defined + - domain_name is defined + - image_tag is defined + - image_tag != 'latest' or environment != 'production' + fail_msg: "Production deployment requires specific image tag (not 'latest')" + tags: always + + - name: Create required directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: deploy + group: deploy + mode: '0755' + loop: + - "{{ app_path }}" + - "{{ backup_path }}" + - /var/log/applications + tags: always + + - name: Store current image tag for rollback + ansible.builtin.shell: | + if [ -f {{ app_path }}/.env.{{ environment }} ]; then + grep '^IMAGE_TAG=' {{ app_path }}/.env.{{ environment }} | cut -d'=' -f2 > {{ app_path }}/.last_release || echo 'none' + fi + ignore_errors: true + tags: backup + + tasks: + - name: Check for existing deployment + ansible.builtin.stat: + path: "{{ app_path }}/docker-compose.yml" + register: existing_deployment + tags: deploy + + - name: Render environment file from template + ansible.builtin.template: + src: "{{ env_template_src }}" + dest: "{{ app_path }}/.env.{{ environment }}" + owner: deploy + group: deploy + mode: '0600' + backup: true + vars: + IMAGE_TAG: "{{ image_tag }}" + DOMAIN_NAME: "{{ domain_name }}" + no_log: true + tags: deploy + + - name: Copy Docker Compose files (base + overlay) + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "{{ app_path }}/{{ item.dest }}" + owner: deploy + group: deploy + mode: '0644' + loop: + - { src: "{{ compose_base_src }}", dest: "docker-compose.yml" } + - { src: "{{ compose_overlay_src }}", dest: "docker-compose.{{ environment }}.yml" } + tags: deploy + + - name: Stop existing services gracefully if present + community.docker.docker_compose_v2: + project_src: "{{ app_path }}" + files: + - docker-compose.yml + - "docker-compose.{{ environment }}.yml" + env_files: + - ".env.{{ environment }}" + state: stopped + timeout: 60 + when: existing_deployment.stat.exists + ignore_errors: true + tags: deploy + + - name: Create storage volumes with proper permissions + ansible.builtin.file: + path: "{{ app_path }}/{{ item }}" + state: directory + owner: www-data + group: www-data + mode: '0775' + loop: + - storage + - storage/logs + - storage/cache + - var + - var/logs + tags: deploy + + - name: Deploy application with Docker Compose v2 + community.docker.docker_compose_v2: + project_src: "{{ app_path }}" + files: + - docker-compose.yml + - "docker-compose.{{ environment }}.yml" + env_files: + - ".env.{{ environment }}" + pull: true + build: false + state: present + recreate: smart + remove_orphans: true + timeout: 300 + tags: deploy + + - name: Wait for PHP container to be healthy (label-based) + community.docker.docker_container_info: + filters: + label: + - "com.docker.compose.service=php" + - "com.docker.compose.project={{ compose_project }}" + register: php_info + retries: 20 + delay: 10 + until: php_info.containers is defined and + (php_info.containers | length) > 0 and + ( + (php_info.containers[0].State.Health is defined and php_info.containers[0].State.Health.Status == "healthy") + or + php_info.containers[0].State.Status == "running" + ) + tags: deploy + + - name: Run database migrations + community.docker.docker_container_exec: + container: "{{ php_info.containers[0].Id }}" + command: php console.php db:migrate --force + chdir: /var/www/html + tags: deploy + + - name: Clear application caches + community.docker.docker_container_exec: + container: "{{ php_info.containers[0].Id }}" + command: "php console.php {{ item }}" + chdir: /var/www/html + loop: + - cache:clear + - view:clear + ignore_errors: true + tags: deploy + + - name: Wait for application to be ready + ansible.builtin.uri: + url: "https://{{ domain_name }}/health" + method: GET + status_code: 200 + timeout: 30 + headers: + User-Agent: "Mozilla/5.0 (Ansible Health Check)" + validate_certs: true + register: http_health + retries: 15 + delay: 10 + until: http_health.status == 200 + tags: deploy + + - name: Store successful deployment tag + ansible.builtin.copy: + content: "{{ image_tag }}" + dest: "{{ app_path }}/.last_successful_release" + owner: deploy + group: deploy + mode: '0644' + tags: deploy + + post_tasks: + - name: Clean up old backups + ansible.builtin.find: + paths: "{{ backup_path }}" + age: "{{ backup_retention_days }}d" + file_type: directory + register: old_backups + when: backup_enabled + tags: cleanup + + - name: Remove old backup directories + ansible.builtin.file: + path: "{{ item.path }}" + state: absent + loop: "{{ old_backups.files }}" + when: backup_enabled and old_backups.files is defined + tags: cleanup + + - name: Import CDN update playbook if enabled + import_playbook: update-cdn.yml + when: cdn_update | default(false) | bool + tags: cdn + + - name: Deployment success notification + ansible.builtin.debug: + msg: + - "Application deployment completed successfully" + - "Image Tag: {{ image_tag }}" + - "Environment: {{ environment }}" + - "Domain: {{ domain_name }}" + - "CDN Updated: {{ cdn_update }}" + tags: always \ No newline at end of file diff --git a/deployment/infrastructure/playbooks/rollback.yml b/deployment/infrastructure/playbooks/rollback.yml new file mode 100644 index 00000000..1c2cfaaf --- /dev/null +++ b/deployment/infrastructure/playbooks/rollback.yml @@ -0,0 +1,235 @@ +--- +# Application Rollback Playbook +# Rolls back to a specific image tag using container deployment +--- +# Rollback Playbook: setzt IMAGE_TAG zurück und recreatet Services + +- name: Rollback Custom PHP Framework Application + hosts: web_servers + become: true + gather_facts: false + + vars: + app_path: "/var/www/html" + domain_name: "{{ DOMAIN_NAME | default('michaelschiemer.de') }}" + rollback_tag: "{{ ROLLBACK_TAG | default('') }}" + compose_project: "{{ compose_project_name | default(app_path | basename) }}" + + pre_tasks: + - name: Validate ROLLBACK_TAG is provided + ansible.builtin.fail: + msg: "Setze ROLLBACK_TAG auf ein gültiges Image-Tag." + when: rollback_tag | length == 0 + + tasks: + - name: Ensure environment file exists + ansible.builtin.stat: + path: "{{ app_path }}/.env.{{ environment }}" + register: env_file + + - name: Fail if environment file is missing + ansible.builtin.fail: + msg: "Environment-Datei fehlt: {{ app_path }}/.env.{{ environment }}" + when: not env_file.stat.exists + + - name: Write IMAGE_TAG to env file + ansible.builtin.lineinfile: + path: "{{ app_path }}/.env.{{ environment }}" + regexp: '^IMAGE_TAG=' + line: "IMAGE_TAG={{ rollback_tag }}" + create: no + backrefs: false + mode: "0600" + + - name: Recreate services with rollback tag + community.docker.docker_compose_v2: + project_src: "{{ app_path }}" + files: + - docker-compose.yml + - "docker-compose.{{ environment }}.yml" + env_files: + - ".env.{{ environment }}" + pull: false + build: false + state: present + recreate: smart + remove_orphans: true + timeout: 300 + + - name: Wait for PHP container to be healthy (label-based) + community.docker.docker_container_info: + filters: + label: + - "com.docker.compose.service=php" + - "com.docker.compose.project={{ compose_project }}" + register: php_info + retries: 20 + delay: 10 + until: php_info.containers is defined and + (php_info.containers | length) > 0 and + ( + (php_info.containers[0].State.Health is defined and php_info.containers[0].State.Health.Status == "healthy") + or + php_info.containers[0].State.Status == "running" + ) + + - name: Verify application HTTP health + ansible.builtin.uri: + url: "https://{{ domain_name }}/health" + method: GET + status_code: 200 + timeout: 30 + validate_certs: true + register: http_health + retries: 15 + delay: 10 + until: http_health.status == 200 + + post_tasks: + - name: Rollback completed + ansible.builtin.debug: + msg: + - "Rollback erfolgreich" + - "Neues aktives Image-Tag: {{ rollback_tag }}" +- name: Rollback Custom PHP Framework Application + hosts: web_servers + become: true + gather_facts: true + + vars: + app_path: "/var/www/html" + rollback_tag: "{{ ROLLBACK_TAG | mandatory }}" + domain_name: "{{ DOMAIN_NAME | default('michaelschiemer.de') }}" + environment: "{{ ENV | default('production') }}" + + pre_tasks: + - name: Verify rollback requirements + assert: + that: + - rollback_tag is defined + - rollback_tag != '' + - rollback_tag != 'latest' + fail_msg: "Rollback requires specific ROLLBACK_TAG (not 'latest')" + tags: always + + - name: Check if target tag exists locally + community.docker.docker_image_info: + name: "{{ project_name | default('michaelschiemer') }}:{{ rollback_tag }}" + register: rollback_image_info + ignore_errors: true + tags: always + + - name: Pull rollback image if not available locally + community.docker.docker_image: + name: "{{ project_name | default('michaelschiemer') }}:{{ rollback_tag }}" + source: pull + force_source: true + when: rollback_image_info.images | length == 0 + tags: always + + - name: Store current deployment for emergency recovery + shell: | + if [ -f {{ app_path }}/.env.{{ environment }} ]; then + grep '^IMAGE_TAG=' {{ app_path }}/.env.{{ environment }} | cut -d'=' -f2 > {{ app_path }}/.emergency_recovery_tag || echo 'none' + fi + tags: backup + + tasks: + - name: Update environment with rollback tag + template: + src: "{{ environment }}.env.template" + dest: "{{ app_path }}/.env.{{ environment }}" + owner: deploy + group: deploy + mode: '0600' + backup: true + vars: + IMAGE_TAG: "{{ rollback_tag }}" + DOMAIN_NAME: "{{ domain_name }}" + no_log: true + tags: rollback + + - name: Stop current services + community.docker.docker_compose_v2: + project_src: "{{ app_path }}" + files: + - docker-compose.yml + - "docker-compose.{{ environment }}.yml" + env_files: + - ".env.{{ environment }}" + state: stopped + timeout: 120 + tags: rollback + + - name: Deploy rollback version + community.docker.docker_compose_v2: + project_src: "{{ app_path }}" + files: + - docker-compose.yml + - "docker-compose.{{ environment }}.yml" + env_files: + - ".env.{{ environment }}" + pull: "never" # Use local image + build: "never" + state: present + recreate: "always" # Force recreate for rollback + timeout: 300 + tags: rollback + + - name: Wait for containers to be healthy after rollback + community.docker.docker_container_info: + name: "{{ item }}" + register: container_info + retries: 15 + delay: 10 + until: container_info.container.State.Health.Status == "healthy" or container_info.container.State.Status == "running" + loop: + - "{{ ansible_hostname }}_php_1" + - "{{ ansible_hostname }}_web_1" + - "{{ ansible_hostname }}_db_1" + - "{{ ansible_hostname }}_redis_1" + ignore_errors: true + tags: rollback + + - name: Verify application health after rollback + uri: + url: "https://{{ domain_name }}/health" + method: GET + status_code: 200 + timeout: 30 + headers: + User-Agent: "Mozilla/5.0 (Ansible Rollback Check)" + validate_certs: true + retries: 10 + delay: 15 + tags: rollback + + - name: Update successful rollback tag + copy: + content: "{{ rollback_tag }}" + dest: "{{ app_path }}/.last_successful_release" + owner: deploy + group: deploy + mode: '0644' + tags: rollback + + post_tasks: + - name: Rollback success notification + debug: + msg: + - "Application rollback completed successfully" + - "Rolled back to: {{ rollback_tag }}" + - "Environment: {{ environment }}" + - "Domain: {{ domain_name }}" + - "Emergency recovery tag stored for further rollback if needed" + tags: always + + - name: Log rollback event + lineinfile: + path: "{{ app_path }}/rollback.log" + line: "{{ ansible_date_time.iso8601 }} - Rollback to {{ rollback_tag }} from {{ environment }} completed successfully" + create: true + owner: deploy + group: deploy + mode: '0644' + tags: always \ No newline at end of file diff --git a/src/Framework/Cache/Driver/FileCache.php b/src/Framework/Cache/Driver/FileCache.php index 3a0c1132..8f858a25 100644 --- a/src/Framework/Cache/Driver/FileCache.php +++ b/src/Framework/Cache/Driver/FileCache.php @@ -14,7 +14,7 @@ use App\Framework\Filesystem\Storage; final readonly class FileCache implements CacheDriver, Scannable { - private const string CACHE_PATH = __DIR__ . '/../storage/cache'; + private const string CACHE_PATH = '/var/www/html/storage/cache'; public function __construct( private Storage $fileSystem = new FileStorage(), diff --git a/src/Framework/Cache/FileCacheCleaner.php b/src/Framework/Cache/FileCacheCleaner.php index c0cc2b4c..e5065e74 100644 --- a/src/Framework/Cache/FileCacheCleaner.php +++ b/src/Framework/Cache/FileCacheCleaner.php @@ -11,7 +11,7 @@ final readonly class FileCacheCleaner { public function __construct( private Storage $fileSystem = new FileStorage(), - private string $cacheFolder = __DIR__.'/storage/cache/' + private string $cacheFolder = '/var/www/html/storage/cache/' ) { } diff --git a/test-totp-qr.svg b/test-totp-qr.svg deleted file mode 100644 index 857a563d..00000000 --- a/test-totp-qr.svg +++ /dev/null @@ -1,26 +0,0 @@ -{ - "error": true, - "code": 500, - "message": "App\\Framework\\Http\\HttpResponse::__construct(): Argument #1 ($status) must be of type App\\Framework\\Http\\Status, int given, called in /var/www/html/src/Application/Http/Controllers/QrCodeController.php on line 143", - "requestId": {}, - "timestamp": "2025-08-09T17:48:38+00:00", - "debug": { - "operation": null, - "component": null, - "exception_class": "TypeError", - "client_ip": "172.20.0.1", - "request_method": "GET", - "request_uri": "https://localhost/api/qrcode/totp?uri=otpauth%3A%2F%2Ftotp%2FTestApp%3Atestuser%40example.com%3Fsecret%3DMFRGGZDFMZTWQ2LK&issuer=TestApp", - "user_agent": { - "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" - }, - "memory_usage": "6 MB", - "execution_time": 0.058526039123535156, - "exception_data": { - "exception_message": "App\\Framework\\Http\\HttpResponse::__construct(): Argument #1 ($status) must be of type App\\Framework\\Http\\Status, int given, called in /var/www/html/src/Application/Http/Controllers/QrCodeController.php on line 143", - "exception_file": "/var/www/html/src/Framework/Http/HttpResponse.php", - "exception_line": 9, - "original_exception": {} - } - } -} \ No newline at end of file