diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml index e4c09e7b..71a0966d 100644 --- a/.gitea/workflows/build-image.yml +++ b/.gitea/workflows/build-image.yml @@ -820,7 +820,7 @@ jobs: needs: [changes, build, runtime-base] if: | (github.ref_name == 'staging' || github.head_ref == 'staging' || (github.ref_name == '' && contains(github.ref, 'staging'))) && - (needs.build.result == 'success' || needs.build.result == 'skipped') + (needs.build.result != 'failure') runs-on: ubuntu-latest environment: name: staging diff --git a/deployment/ansible/playbooks/deploy-update.yml b/deployment/ansible/playbooks/deploy-update.yml index c5872582..c215fdeb 100644 --- a/deployment/ansible/playbooks/deploy-update.yml +++ b/deployment/ansible/playbooks/deploy-update.yml @@ -50,16 +50,21 @@ group: "{{ ansible_user }}" mode: '0755' + - name: Determine application environment + set_fact: + application_environment: "{{ APP_ENV | default('production') }}" + application_compose_suffix: "{{ 'staging.yml' if application_environment == 'staging' else 'production.yml' }}" + - name: Check if docker-compose.base.yml exists in application stack stat: path: "{{ app_stack_path }}/docker-compose.base.yml" register: compose_base_exists when: not (application_sync_files | default(false) | bool) - - name: Check if docker-compose.production.yml exists in application stack + - name: Check if docker-compose override file exists in application stack (production or staging) stat: - path: "{{ app_stack_path }}/docker-compose.production.yml" - register: compose_prod_exists + path: "{{ app_stack_path }}/docker-compose.{{ application_compose_suffix }}" + register: compose_override_exists when: not (application_sync_files | default(false) | bool) - name: Fail if docker-compose files don't exist @@ -69,7 +74,7 @@ Required files: - docker-compose.base.yml - - docker-compose.production.yml + - docker-compose.{{ application_compose_suffix }} The Application Stack must be deployed first via: ansible-playbook -i inventory/production.yml playbooks/setup-infrastructure.yml @@ -77,7 +82,7 @@ This will create the application stack with docker-compose files and .env file. when: - not (application_sync_files | default(false) | bool) - - (not compose_base_exists.stat.exists or not compose_prod_exists.stat.exists) + - (not compose_base_exists.stat.exists or not compose_override_exists.stat.exists) - name: Create backup directory file: @@ -94,10 +99,10 @@ register: compose_base_check when: not (application_sync_files | default(false) | bool) - - name: Verify docker-compose.production.yml exists + - name: Verify docker-compose override file exists (production or staging) stat: - path: "{{ app_stack_path }}/docker-compose.production.yml" - register: compose_prod_check + path: "{{ app_stack_path }}/docker-compose.{{ application_compose_suffix }}" + register: compose_override_check when: not (application_sync_files | default(false) | bool) - name: Fail if docker-compose files don't exist @@ -107,7 +112,7 @@ Required files: - docker-compose.base.yml - - docker-compose.production.yml + - docker-compose.{{ application_compose_suffix }} The Application Stack must be deployed first via: ansible-playbook -i inventory/production.yml playbooks/setup-infrastructure.yml @@ -115,12 +120,12 @@ This will create the application stack with docker-compose files and .env file. when: - not (application_sync_files | default(false) | bool) - - (not compose_base_check.stat.exists or not compose_prod_check.stat.exists) + - (not compose_base_check.stat.exists or not compose_override_check.stat.exists) - name: Backup current deployment metadata shell: | - docker compose -f {{ app_stack_path }}/docker-compose.base.yml -f {{ app_stack_path }}/docker-compose.production.yml ps --format json 2>/dev/null > {{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/current_containers.json || true - docker compose -f {{ app_stack_path }}/docker-compose.base.yml -f {{ app_stack_path }}/docker-compose.production.yml config 2>/dev/null > {{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/docker-compose-config.yml || true + docker compose -f {{ app_stack_path }}/docker-compose.base.yml -f {{ app_stack_path }}/docker-compose.{{ application_compose_suffix }} ps --format json 2>/dev/null > {{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/current_containers.json || true + docker compose -f {{ app_stack_path }}/docker-compose.base.yml -f {{ app_stack_path }}/docker-compose.{{ application_compose_suffix }} config 2>/dev/null > {{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/docker-compose-config.yml || true args: executable: /bin/bash changed_when: false @@ -128,7 +133,7 @@ when: - not (application_sync_files | default(false) | bool) - compose_base_exists.stat.exists | default(false) - - compose_prod_exists.stat.exists | default(false) + - compose_override_exists.stat.exists | default(false) - name: Login to Docker registry (if credentials provided) community.docker.docker_login: @@ -167,9 +172,9 @@ application_remove_orphans: false when: application_sync_files | default(false) | bool - - name: Update docker-compose.production.yml with new image tag (all services) + - name: Update docker-compose override file with new image tag (all services) replace: - path: "{{ app_stack_path }}/docker-compose.production.yml" + path: "{{ app_stack_path }}/docker-compose.{{ application_compose_suffix }}" # Match both localhost:5000 and registry.michaelschiemer.de (or any registry URL) regexp: '^(\s+image:\s+)(localhost:5000|registry\.michaelschiemer\.de|{{ docker_registry }})/{{ app_name }}:.*$' replace: '\1{{ app_image }}:{{ image_tag }}' diff --git a/deployment/ansible/roles/application/defaults/main.yml b/deployment/ansible/roles/application/defaults/main.yml index fac3cc20..aae75db9 100644 --- a/deployment/ansible/roles/application/defaults/main.yml +++ b/deployment/ansible/roles/application/defaults/main.yml @@ -34,3 +34,13 @@ application_wait_interval: 5 # Command executed inside the app container to run migrations application_migration_command: "php console.php db:migrate" + +# Environment (production, staging, local) +# Determines which compose files to use and service names +application_environment: "{{ APP_ENV | default('production') }}" + +# Compose file suffix based on environment +application_compose_suffix: "{{ 'staging.yml' if application_environment == 'staging' else 'production.yml' }}" + +# Service names based on environment +application_service_name: "{{ 'staging-app' if application_environment == 'staging' else 'php' }}" diff --git a/deployment/ansible/roles/application/tasks/deploy.yml b/deployment/ansible/roles/application/tasks/deploy.yml index adb35283..dfe314b6 100644 --- a/deployment/ansible/roles/application/tasks/deploy.yml +++ b/deployment/ansible/roles/application/tasks/deploy.yml @@ -10,7 +10,7 @@ - name: Wait for application container to report Up shell: | - docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.production.yml ps php | grep -Eiq "Up|running" + docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }} ps {{ application_service_name }} | grep -Eiq "Up|running" register: application_app_running changed_when: false until: application_app_running.rc == 0 @@ -20,7 +20,7 @@ - name: Ensure app container is running before migrations shell: | - docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.production.yml ps php | grep -Eiq "Up|running" + docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }} ps {{ application_service_name }} | grep -Eiq "Up|running" args: executable: /bin/bash register: application_app_container_running @@ -30,7 +30,7 @@ - name: Run database migrations shell: | - docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.production.yml exec -T php {{ application_migration_command }} + docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }} exec -T {{ application_service_name }} {{ application_migration_command }} args: executable: /bin/bash register: application_migration_result @@ -43,7 +43,7 @@ - application_app_container_running.rc == 0 - name: Collect application container status - shell: docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.production.yml ps + shell: docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }} ps register: application_ps changed_when: false ignore_errors: yes diff --git a/deployment/ansible/roles/application/tasks/sync.yml b/deployment/ansible/roles/application/tasks/sync.yml index 31003498..78aeefdb 100644 --- a/deployment/ansible/roles/application/tasks/sync.yml +++ b/deployment/ansible/roles/application/tasks/sync.yml @@ -80,11 +80,11 @@ register: application_compose_base_src become: no -- name: Check if application docker-compose.production.yml source exists locally +- name: Check if application docker-compose override file exists locally (production or staging) stat: - path: "{{ application_stack_src }}/../../../docker-compose.production.yml" + path: "{{ application_stack_src }}/../../../docker-compose.{{ application_compose_suffix }}" delegate_to: localhost - register: application_compose_prod_src + register: application_compose_override_src become: no - name: Copy application docker-compose.base.yml to target host @@ -96,14 +96,14 @@ mode: '0644' when: application_compose_base_src.stat.exists -- name: Copy application docker-compose.production.yml to target host +- name: Copy application docker-compose override file to target host (production or staging) copy: - src: "{{ application_stack_src }}/../../../docker-compose.production.yml" - dest: "{{ application_stack_dest }}/docker-compose.production.yml" + src: "{{ application_stack_src }}/../../../docker-compose.{{ application_compose_suffix }}" + dest: "{{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }}" owner: "{{ ansible_user }}" group: "{{ ansible_user }}" mode: '0644' - when: application_compose_prod_src.stat.exists + when: application_compose_override_src.stat.exists - name: Check if legacy docker-compose.yml exists (fallback) stat: diff --git a/deployment/stacks/semaphore/docker-compose.yml b/deployment/stacks/semaphore/docker-compose.yml index 9b794a4c..1fcbafaf 100644 --- a/deployment/stacks/semaphore/docker-compose.yml +++ b/deployment/stacks/semaphore/docker-compose.yml @@ -41,6 +41,22 @@ services: # Only bind to localhost, not external interfaces # Default port 3001 to avoid conflict with Gitea (port 3000) - "127.0.0.1:${SEMAPHORE_PORT:-3001}:3000" + labels: + # Traefik configuration + - "traefik.enable=true" + # HTTP Router (redirects to HTTPS) + - "traefik.http.routers.semaphore.rule=Host(`semaphore.michaelschiemer.de`)" + - "traefik.http.routers.semaphore.entrypoints=web" + - "traefik.http.routers.semaphore.middlewares=redirect-to-https" + # HTTPS Router + - "traefik.http.routers.semaphore-secure.rule=Host(`semaphore.michaelschiemer.de`)" + - "traefik.http.routers.semaphore-secure.entrypoints=websecure" + - "traefik.http.routers.semaphore-secure.tls=true" + - "traefik.http.routers.semaphore-secure.tls.certresolver=letsencrypt" + - "traefik.http.routers.semaphore-secure.service=semaphore" + # Service definition (use container IP in host network mode) + - "traefik.http.services.semaphore.loadbalancer.server.scheme=http" + - "traefik.http.services.semaphore.loadbalancer.server.port=3000" environment: - TZ=Europe/Berlin # Database Configuration @@ -68,7 +84,7 @@ services: - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"] interval: 30s timeout: 10s retries: 3 diff --git a/deployment/stacks/traefik/dynamic/semaphore.yml b/deployment/stacks/traefik/dynamic/semaphore.yml new file mode 100644 index 00000000..2343e2a9 --- /dev/null +++ b/deployment/stacks/traefik/dynamic/semaphore.yml @@ -0,0 +1,18 @@ +http: + routers: + semaphore: + rule: Host(`semaphore.michaelschiemer.de`) + entrypoints: + - websecure + service: semaphore + tls: + certResolver: letsencrypt + priority: 100 + services: + semaphore: + loadBalancer: + # Use localhost port binding since Semaphore binds to 127.0.0.1 + # Check actual port with: docker ps | grep semaphore + # Default is 3001, but may be 9300 if SEMAPHORE_PORT env var is set differently + servers: + - url: http://127.0.0.1:3001 \ No newline at end of file diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index d47eb90f..9fd59982 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -248,19 +248,38 @@ services: - REDIS_PASSWORD_FILE=/run/secrets/redis_password secrets: - redis_password - command: > - sh -c " - REDIS_PASSWORD=$$(cat /run/secrets/redis_password 2>/dev/null || echo ${REDIS_PASSWORD}) - redis-server - --requirepass $$REDIS_PASSWORD - --maxmemory 256mb - --maxmemory-policy allkeys-lru - --save 900 1 - --save 300 10 - --save 60 10000 - --appendonly yes - --appendfsync everysec - " + # Use entrypoint script to inject password from Docker Secret into config + # Note: Script runs as root to read Docker Secrets, then starts Redis + entrypoint: ["/bin/sh", "-c"] + command: + - | + # Read password from Docker Secret (as root) + REDIS_PASSWORD=$$(cat /run/secrets/redis_password 2>/dev/null || echo '') + # Start Redis with all settings as command line arguments (no config file to avoid conflicts) + if [ -n "$$REDIS_PASSWORD" ]; then + exec redis-server \ + --bind 0.0.0.0 \ + --dir /data \ + --maxmemory 256mb \ + --maxmemory-policy allkeys-lru \ + --save 900 1 \ + --save 300 10 \ + --save 60 10000 \ + --appendonly yes \ + --appendfsync everysec \ + --requirepass "$$REDIS_PASSWORD" + else + exec redis-server \ + --bind 0.0.0.0 \ + --dir /data \ + --maxmemory 256mb \ + --maxmemory-policy allkeys-lru \ + --save 900 1 \ + --save 300 10 \ + --save 60 10000 \ + --appendonly yes \ + --appendfsync everysec + fi volumes: - staging-redis-data:/data - /etc/timezone:/etc/timezone:ro