diff --git a/.gitea/workflows/production-deploy.yml b/.gitea/workflows/production-deploy.yml new file mode 100644 index 00000000..7a74b734 --- /dev/null +++ b/.gitea/workflows/production-deploy.yml @@ -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 diff --git a/deployment/ansible/playbooks/deploy-update.yml b/deployment/ansible/playbooks/deploy-update.yml new file mode 100644 index 00000000..a1b89000 --- /dev/null +++ b/deployment/ansible/playbooks/deploy-update.yml @@ -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 }}" diff --git a/deployment/ansible/templates/.env.production.j2 b/deployment/ansible/templates/.env.production.j2 new file mode 100644 index 00000000..a73fcb56 --- /dev/null +++ b/deployment/ansible/templates/.env.production.j2 @@ -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) }} diff --git a/src/Framework/Core/Helper/Str.php b/src/Framework/Core/Helper/Str.php new file mode 100644 index 00000000..9f5bd7a8 --- /dev/null +++ b/src/Framework/Core/Helper/Str.php @@ -0,0 +1,9 @@ +bytes->toBytes(), $this->duration->toNanoseconds()); + } +} diff --git a/src/Framework/Core/ValueObjects/Duration.php b/src/Framework/Core/ValueObjects/Duration.php index bd68ac2d..a09dd5ea 100644 --- a/src/Framework/Core/ValueObjects/Duration.php +++ b/src/Framework/Core/ValueObjects/Duration.php @@ -8,12 +8,18 @@ use InvalidArgumentException; 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'); } + + $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 { - return new self($nanoseconds); + return new self($nanoseconds, TimeUnit::NANOSECOND); } // Factory Methods public static function fromSeconds(float $seconds): self { - if ($seconds < 0) { - throw new InvalidArgumentException('Duration cannot be negative'); - } - - return new self((int) round($seconds * 1_000_000_000)); + return new self($seconds, TimeUnit::SECOND); } public static function fromUnit(float $value, TimeUnit $unit): self { - if ($value < 0) { - throw new InvalidArgumentException('Duration cannot be negative'); - } - $seconds = $value * $unit->getMultiplierToSeconds(); - - return new self((int) round($seconds * 1_000_000_000)); + return new self($value, $unit); } public static function fromMilliseconds(float $milliseconds): self @@ -109,24 +106,24 @@ final readonly class Duration 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 @@ -192,7 +189,7 @@ final readonly class Duration public function isNotZero(): bool { - return $this->nanoseconds > 0; + return !$this->isZero(); } // Framework Integration @@ -219,7 +216,7 @@ final readonly class Duration public static function oneSecond(): self { - return new self(1_000_000_000); + return new self(1, TimeUnit::SECOND); } public static function oneMinute(): self diff --git a/src/Framework/Core/ValueObjects/FileSize.php b/src/Framework/Core/ValueObjects/FileSize.php index 3f0e538f..7acd2971 100644 --- a/src/Framework/Core/ValueObjects/FileSize.php +++ b/src/Framework/Core/ValueObjects/FileSize.php @@ -14,7 +14,7 @@ use App\Framework\Exception\FrameworkException; final readonly class FileSize { public function __construct( - public readonly Byte $bytes + public Byte $bytes ) { // File size cannot be negative (already validated by Byte) } diff --git a/src/Framework/Core/ValueObjects/Percentage.php b/src/Framework/Core/ValueObjects/Percentage.php index 39360e0c..f34baf49 100644 --- a/src/Framework/Core/ValueObjects/Percentage.php +++ b/src/Framework/Core/ValueObjects/Percentage.php @@ -4,15 +4,25 @@ declare(strict_types=1); namespace App\Framework\Core\ValueObjects; +use App\Framework\Math\Number; use InvalidArgumentException; +use Stringable; -final readonly class Percentage +final readonly class Percentage implements Stringable { + #public Number $value; + + public function __construct( - private float $value + private float $value, + public PercentageMode $mode = PercentageMode::Strict, ) { - if ($value < 0.0 || $value > 100.0) { - throw new InvalidArgumentException('Percentage must be between 0 and 100'); + + if($this->mode === PercentageMode::Strict) { + + if ($value < 0.0 || $value > 100.0) { + throw new InvalidArgumentException('Percentage must be between 0 and 100'); + } } } diff --git a/src/Framework/Core/ValueObjects/PercentageMode.php b/src/Framework/Core/ValueObjects/PercentageMode.php new file mode 100644 index 00000000..c1aa4ac9 --- /dev/null +++ b/src/Framework/Core/ValueObjects/PercentageMode.php @@ -0,0 +1,10 @@ +