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:
184
.gitea/workflows/production-deploy.yml
Normal file
184
.gitea/workflows/production-deploy.yml
Normal 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
|
||||||
137
deployment/ansible/playbooks/deploy-update.yml
Normal file
137
deployment/ansible/playbooks/deploy-update.yml
Normal 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 }}"
|
||||||
103
deployment/ansible/templates/.env.production.j2
Normal file
103
deployment/ansible/templates/.env.production.j2
Normal 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) }}
|
||||||
9
src/Framework/Core/Helper/Str.php
Normal file
9
src/Framework/Core/Helper/Str.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Core\Helper;
|
||||||
|
|
||||||
|
final class Str
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
19
src/Framework/Core/ValueObjects/DataRate.php
Normal file
19
src/Framework/Core/ValueObjects/DataRate.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
src/Framework/Core/ValueObjects/PercentageMode.php
Normal file
10
src/Framework/Core/ValueObjects/PercentageMode.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Core\ValueObjects;
|
||||||
|
|
||||||
|
enum PercentageMode
|
||||||
|
{
|
||||||
|
case Strict;
|
||||||
|
case Extended;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Framework\Core\ValueObjects\PhoneNumber;
|
||||||
|
|
||||||
|
enum PhoneNumberFormat
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
9
src/Framework/Math/BcCalculator.php
Normal file
9
src/Framework/Math/BcCalculator.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Math;
|
||||||
|
|
||||||
|
final class BcCalculator
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
8
src/Framework/Math/Calculator.php
Normal file
8
src/Framework/Math/Calculator.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Framework\Math;
|
||||||
|
|
||||||
|
interface Calculator
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
9
src/Framework/Math/Context.php
Normal file
9
src/Framework/Math/Context.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Math;
|
||||||
|
|
||||||
|
final class Context
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
9
src/Framework/Math/ContextResolver.php
Normal file
9
src/Framework/Math/ContextResolver.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Math;
|
||||||
|
|
||||||
|
interface ContextResolver
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
9
src/Framework/Math/DefaultContextResolver.php
Normal file
9
src/Framework/Math/DefaultContextResolver.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Math;
|
||||||
|
|
||||||
|
final class DefaultContextResolver
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
9
src/Framework/Math/Number.php
Normal file
9
src/Framework/Math/Number.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Math;
|
||||||
|
|
||||||
|
final class Number
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
9
src/Framework/Math/NumberCalculator.php
Normal file
9
src/Framework/Math/NumberCalculator.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Math;
|
||||||
|
|
||||||
|
final class NumberCalculator
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
8
src/Framework/Math/RoundingMode.php
Normal file
8
src/Framework/Math/RoundingMode.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Framework\Math;
|
||||||
|
|
||||||
|
enum RoundingMode
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user