chore(deploy): add prod env template, improve ansible deploy, prune old workflows

- Add deployment/ansible/templates/.env.production.j2 used by secrets playbook
- Enhance deploy-update.yml to read registry creds from vault or CI
- Update production-deploy workflow to pass registry credentials to Ansible
- Remove obsolete GitHub-style workflows under .gitea (conflicted naming)

Why: make the production pipeline executable end-to-end with Ansible and
consistent secrets handling; avoid legacy CI configs interfering.
This commit is contained in:
2025-10-30 21:38:28 +01:00
parent d021c49906
commit 2a7b90312f
18 changed files with 577 additions and 30 deletions

View File

@@ -0,0 +1,184 @@
name: Production Deployment Pipeline
on:
push:
branches:
- main
paths-ignore:
- 'docs/**'
- '**.md'
- '.github/**'
workflow_dispatch:
inputs:
skip_tests:
description: 'Skip tests (emergency only)'
required: false
default: false
type: boolean
env:
REGISTRY: git.michaelschiemer.de:5000
IMAGE_NAME: framework
DEPLOYMENT_HOST: 94.16.110.151
jobs:
# Job 1: Run Tests
test:
name: Run Tests & Quality Checks
runs-on: ubuntu-latest
if: ${{ !inputs.skip_tests }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, pdo, pdo_mysql, zip, gd, intl, sodium, bcmath, redis
coverage: none
- name: Cache Composer dependencies
uses: actions/cache@v3
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Run Pest tests
run: ./vendor/bin/pest --colors=always
- name: Run PHPStan
run: make phpstan
- name: Code style check
run: composer cs
# Job 2: Build & Push Docker Image
build:
name: Build Docker Image
needs: test
if: always() && (needs.test.result == 'success' || needs.test.result == 'skipped')
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.tag }}
commit_sha: ${{ github.sha }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate image metadata
id: meta
run: |
SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7)
TAG="${SHORT_SHA}-$(date +%s)"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Login to Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.production
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:git-${{ steps.meta.outputs.short_sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
build-args: |
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
GIT_COMMIT=${{ github.sha }}
GIT_BRANCH=${{ github.ref_name }}
- name: Image scan for vulnerabilities
run: |
# Optional: Add Trivy or similar vulnerability scanning
echo "✅ Image built successfully: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }}"
# Job 3: Deploy to Production
deploy:
name: Deploy to Production Server
needs: build
runs-on: ubuntu-latest
environment:
name: production
url: https://michaelschiemer.de
steps:
- name: Checkout deployment scripts
uses: actions/checkout@v4
with:
sparse-checkout: |
deployment/ansible
sparse-checkout-cone-mode: false
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/production
chmod 600 ~/.ssh/production
ssh-keyscan -H ${{ env.DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts
- name: Install Ansible
run: |
sudo apt-get update
sudo apt-get install -y ansible
- name: Deploy via Ansible
run: |
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/deploy-update.yml \
-e "image_tag=${{ needs.build.outputs.image_tag }}" \
-e "git_commit_sha=${{ needs.build.outputs.commit_sha }}" \
-e "deployment_timestamp=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
-e "docker_registry_username=${{ secrets.REGISTRY_USER }}" \
-e "docker_registry_password=${{ secrets.REGISTRY_PASSWORD }}"
- name: Wait for deployment to stabilize
run: sleep 30
- name: Health check
id: health
run: |
for i in {1..10}; do
if curl -f -k https://michaelschiemer.de/health; then
echo "✅ Health check passed"
exit 0
fi
echo "⏳ Waiting for service... (attempt $i/10)"
sleep 10
done
echo "❌ Health check failed"
exit 1
- name: Rollback on failure
if: failure() && steps.health.outcome == 'failure'
run: |
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/rollback.yml
- name: Notify deployment success
if: success()
run: |
echo "🚀 Deployment successful!"
echo "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build.outputs.image_tag }}"
echo "Commit: ${{ needs.build.outputs.commit_sha }}"
- name: Notify deployment failure
if: failure()
run: |
echo "❌ Deployment failed and was rolled back"
# TODO: Add Slack/Email notification

View File

@@ -0,0 +1,137 @@
---
- name: Deploy Application Update
hosts: production
gather_facts: yes
become: yes
vars:
# These should be passed via -e from CI/CD
image_tag: "{{ image_tag | default('latest') }}"
git_commit_sha: "{{ git_commit_sha | default('unknown') }}"
deployment_timestamp: "{{ deployment_timestamp | default(ansible_date_time.iso8601) }}"
pre_tasks:
- name: Optionally load registry credentials from encrypted vault
include_vars:
file: "{{ playbook_dir }}/../secrets/production.vault.yml"
no_log: yes
ignore_errors: yes
delegate_to: localhost
become: no
- name: Derive docker registry credentials from vault when not provided
set_fact:
docker_registry_username: "{{ docker_registry_username | default(vault_docker_registry_username | default(omit)) }}"
docker_registry_password: "{{ docker_registry_password | default(vault_docker_registry_password | default(omit)) }}"
- name: Verify Docker is running
systemd:
name: docker
state: started
register: docker_service
- name: Fail if Docker is not running
fail:
msg: "Docker service is not running"
when: docker_service.status.ActiveState != 'active'
- name: Create backup directory
file:
path: "{{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: '0755'
tasks:
- name: Backup current deployment metadata
shell: |
docker service inspect {{ stack_name }}_app --format '{{ "{{" }}.Spec.TaskTemplate.ContainerSpec.Image{{ "}}" }}' \
> {{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/current_image.txt || true
docker stack ps {{ stack_name }} --format 'table {{ "{{" }}.Name{{ "}}" }}\t{{ "{{" }}.Image{{ "}}" }}\t{{ "{{" }}.CurrentState{{ "}}" }}' \
> {{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/stack_status.txt || true
args:
executable: /bin/bash
changed_when: false
- name: Login to Docker registry (if credentials provided)
docker_login:
registry_url: "{{ docker_registry_url }}"
username: "{{ docker_registry_username }}"
password: "{{ docker_registry_password }}"
no_log: yes
when:
- docker_registry_username is defined
- docker_registry_password is defined
- name: Pull new Docker image
docker_image:
name: "{{ app_image }}"
tag: "{{ image_tag }}"
source: pull
force_source: yes
register: image_pull
- name: Update docker-compose.prod.yml with new image tag
lineinfile:
path: "{{ compose_file }}"
regexp: '^(\s+image:\s+){{ app_image }}:.*$'
line: '\1{{ app_image }}:{{ image_tag }}'
backrefs: yes
when: compose_file is file
- name: Deploy stack update
docker_stack:
name: "{{ stack_name }}"
compose:
- "{{ compose_file }}"
state: present
prune: yes
register: stack_deploy
- name: Wait for service to be updated
command: >
docker service ps {{ stack_name }}_app
--filter "desired-state=running"
--format '{{ "{{" }}.CurrentState{{ "}}" }}'
register: service_status
until: "'Running' in service_status.stdout"
retries: 30
delay: 10
changed_when: false
- name: Get updated service info
command: docker service inspect {{ stack_name }}_app --format '{{ "{{" }}.Spec.TaskTemplate.ContainerSpec.Image{{ "}}" }}'
register: deployed_image
changed_when: false
- name: Record deployment metadata
copy:
content: |
Deployment Timestamp: {{ deployment_timestamp }}
Git Commit: {{ git_commit_sha }}
Image Tag: {{ image_tag }}
Deployed Image: {{ deployed_image.stdout }}
Stack Deploy Output: {{ stack_deploy }}
dest: "{{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/deployment_metadata.txt"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: '0644'
- name: Cleanup old backups (keep last {{ max_rollback_versions }})
shell: |
cd {{ backups_path }}
ls -t | tail -n +{{ max_rollback_versions + 1 }} | xargs -r rm -rf
args:
executable: /bin/bash
changed_when: false
post_tasks:
- name: Display deployment summary
debug:
msg:
- "Deployment completed successfully!"
- "Image: {{ app_image }}:{{ image_tag }}"
- "Commit: {{ git_commit_sha }}"
- "Timestamp: {{ deployment_timestamp }}"
- "Health check URL: {{ health_check_url }}"

View File

@@ -0,0 +1,103 @@
APP_ENV=production
APP_DEBUG=false
# Application keys
APP_KEY={{ vault_app_key }}
ENCRYPTION_KEY={{ vault_encryption_key | default('') }}
STATE_ENCRYPTION_KEY={{ vault_state_encryption_key | default('') }}
JWT_SECRET={{ vault_jwt_secret | default('') }}
# Database
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=framework_production
DB_USERNAME=framework_user
DB_PASSWORD={{ vault_db_password }}
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD={{ vault_redis_password }}
# Cache & Session
CACHE_DRIVER=redis
CACHE_PREFIX=framework
SESSION_DRIVER=redis
SESSION_LIFETIME=120
# Mail (optional)
MAIL_MAILER={{ mail_mailer | default('smtp') }}
MAIL_HOST={{ mail_host | default('') }}
MAIL_PORT={{ mail_port | default('587') }}
MAIL_USERNAME={{ mail_username | default('') }}
MAIL_PASSWORD={{ vault_mail_password | default('') }}
MAIL_ENCRYPTION={{ mail_encryption | default('tls') }}
MAIL_FROM_ADDRESS={{ mail_from_address | default('noreply@michaelschiemer.de') }}
MAIL_FROM_NAME={{ mail_from_name | default('Framework') }}
# Rate limiting / security
RATE_LIMIT_ENABLED={{ rate_limit_enabled | default('true') }}
RATE_LIMIT_DEFAULT={{ rate_limit_default | default('60') }}
RATE_LIMIT_WINDOW={{ rate_limit_window | default('60') }}
ADMIN_ALLOWED_IPS={{ admin_allowed_ips | default('127.0.0.1,::1') }}
# App domain
APP_DOMAIN={{ app_domain | default('michaelschiemer.de') }}
# Production Environment Configuration
# Generated by Ansible - DO NOT EDIT MANUALLY
# Last Updated: {{ ansible_date_time.iso8601 }}
# Application
APP_NAME={{ app_name }}
APP_ENV=production
APP_DEBUG=false
APP_URL=https://{{ app_domain }}
APP_KEY={{ vault_app_key }}
# Database
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE={{ app_name }}
DB_USERNAME={{ app_name }}
DB_PASSWORD={{ vault_db_password }}
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD={{ vault_redis_password }}
# Cache
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
# Security
JWT_SECRET={{ vault_jwt_secret }}
ENCRYPTION_KEY={{ vault_encryption_key | default('') }}
SESSION_SECRET={{ vault_session_secret | default('') }}
# Mail Configuration
MAIL_MAILER=smtp
MAIL_HOST={{ vault_mail_host | default('smtp.example.com') }}
MAIL_PORT={{ vault_mail_port | default('587') }}
MAIL_USERNAME={{ vault_mail_username | default('') }}
MAIL_PASSWORD={{ vault_mail_password }}
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS={{ vault_mail_from | default('noreply@' + app_domain) }}
MAIL_FROM_NAME="{{ app_name }}"
# Logging
LOG_CHANNEL=stack
LOG_LEVEL=warning
LOG_STACK=daily
# Performance
OPCACHE_ENABLE=1
OPCACHE_VALIDATE_TIMESTAMPS=0
# Deployment Info
DEPLOY_VERSION={{ image_tag | default('unknown') }}
DEPLOY_COMMIT={{ git_commit_sha | default('unknown') }}
DEPLOY_TIMESTAMP={{ deployment_timestamp | default(ansible_date_time.iso8601) }}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\Helper;
final class Str
{
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use Stringable;
final readonly class DataRate implements Stringable
{
public function __construct(
private Byte $bytes,
private Duration $duration,
) {}
public function __toString()
{
return sprintf('%d B / %d ns', $this->bytes->toBytes(), $this->duration->toNanoseconds());
}
}

View File

@@ -8,12 +8,18 @@ use InvalidArgumentException;
final readonly class Duration final readonly class Duration
{ {
private function __construct( private int $nanoseconds;
private int $nanoseconds public function __construct(
float|int $value,
TimeUnit $unit = TimeUnit::NANOSECOND,
) { ) {
if ($nanoseconds < 0) { if ($value < 0) {
throw new InvalidArgumentException('Duration cannot be negative'); throw new InvalidArgumentException('Duration cannot be negative');
} }
$seconds = $value * $unit->getMultiplierToSeconds();
$this->nanoseconds = (int) round($seconds * 1_000_000_000);
} }
/** /**
@@ -21,27 +27,18 @@ final readonly class Duration
*/ */
public static function fromNanoseconds(int $nanoseconds): self public static function fromNanoseconds(int $nanoseconds): self
{ {
return new self($nanoseconds); return new self($nanoseconds, TimeUnit::NANOSECOND);
} }
// Factory Methods // Factory Methods
public static function fromSeconds(float $seconds): self public static function fromSeconds(float $seconds): self
{ {
if ($seconds < 0) { return new self($seconds, TimeUnit::SECOND);
throw new InvalidArgumentException('Duration cannot be negative');
}
return new self((int) round($seconds * 1_000_000_000));
} }
public static function fromUnit(float $value, TimeUnit $unit): self public static function fromUnit(float $value, TimeUnit $unit): self
{ {
if ($value < 0) { return new self($value, $unit);
throw new InvalidArgumentException('Duration cannot be negative');
}
$seconds = $value * $unit->getMultiplierToSeconds();
return new self((int) round($seconds * 1_000_000_000));
} }
public static function fromMilliseconds(float $milliseconds): self public static function fromMilliseconds(float $milliseconds): self
@@ -109,24 +106,24 @@ final readonly class Duration
return round($seconds / $unit->getMultiplierToSeconds(), $precision); return round($seconds / $unit->getMultiplierToSeconds(), $precision);
} }
public function toMilliseconds(): float public function toMilliseconds(int $precision = 0): float
{ {
return $this->toUnit(TimeUnit::MILLISECOND, 0); return $this->toUnit(TimeUnit::MILLISECOND, $precision);
} }
public function toMicroseconds(): float public function toMicroseconds(int $precision = 0): float
{ {
return $this->toUnit(TimeUnit::MICROSECOND, 0); return $this->toUnit(TimeUnit::MICROSECOND, $precision);
} }
public function toMinutes(): float public function toMinutes(int $precision = 0): float
{ {
return $this->toUnit(TimeUnit::MINUTE); return $this->toUnit(TimeUnit::MINUTE, $precision);
} }
public function toHours(): float public function toHours(int $precision = 0): float
{ {
return $this->toUnit(TimeUnit::HOUR); return $this->toUnit(TimeUnit::HOUR, $precision);
} }
// Human-readable format // Human-readable format
@@ -192,7 +189,7 @@ final readonly class Duration
public function isNotZero(): bool public function isNotZero(): bool
{ {
return $this->nanoseconds > 0; return !$this->isZero();
} }
// Framework Integration // Framework Integration
@@ -219,7 +216,7 @@ final readonly class Duration
public static function oneSecond(): self public static function oneSecond(): self
{ {
return new self(1_000_000_000); return new self(1, TimeUnit::SECOND);
} }
public static function oneMinute(): self public static function oneMinute(): self

View File

@@ -14,7 +14,7 @@ use App\Framework\Exception\FrameworkException;
final readonly class FileSize final readonly class FileSize
{ {
public function __construct( public function __construct(
public readonly Byte $bytes public Byte $bytes
) { ) {
// File size cannot be negative (already validated by Byte) // File size cannot be negative (already validated by Byte)
} }

View File

@@ -4,17 +4,27 @@ declare(strict_types=1);
namespace App\Framework\Core\ValueObjects; namespace App\Framework\Core\ValueObjects;
use App\Framework\Math\Number;
use InvalidArgumentException; use InvalidArgumentException;
use Stringable;
final readonly class Percentage final readonly class Percentage implements Stringable
{ {
#public Number $value;
public function __construct( public function __construct(
private float $value private float $value,
public PercentageMode $mode = PercentageMode::Strict,
) { ) {
if($this->mode === PercentageMode::Strict) {
if ($value < 0.0 || $value > 100.0) { if ($value < 0.0 || $value > 100.0) {
throw new InvalidArgumentException('Percentage must be between 0 and 100'); throw new InvalidArgumentException('Percentage must be between 0 and 100');
} }
} }
}
// Factory Methods // Factory Methods
public static function from(float $percentage): self public static function from(float $percentage): self

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
enum PercentageMode
{
case Strict;
case Extended;
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\Core\ValueObjects\PhoneNumber;
enum PhoneNumberFormat
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Math;
final class BcCalculator
{
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\Math;
interface Calculator
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Math;
final class Context
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Math;
interface ContextResolver
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Math;
final class DefaultContextResolver
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Math;
final class Number
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Math;
final class NumberCalculator
{
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\Math;
enum RoundingMode
{
}