From 543903c2ab5d75a2b66a4a31f27787d5a9af54b3 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Mon, 2 Mar 2026 16:36:57 +0100 Subject: [PATCH 1/7] make fixtures a non-dev dependency --- backend/composer.json | 2 +- backend/composer.lock | 340 +++++++++++++++++++++--------------------- 2 files changed, 171 insertions(+), 171 deletions(-) diff --git a/backend/composer.json b/backend/composer.json index c83d2ab..7d1c393 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -8,6 +8,7 @@ "ext-ctype": "*", "ext-iconv": "*", "doctrine/doctrine-bundle": "^3.2.2", + "doctrine/doctrine-fixtures-bundle": "^4.3", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6.2", "dompdf/dompdf": "^3.1", @@ -104,7 +105,6 @@ }, "require-dev": { "deptrac/deptrac": "^4.5", - "doctrine/doctrine-fixtures-bundle": "^4.3.1", "friendsofphp/php-cs-fixer": "^3.93.1", "phpstan/phpstan": "^2.1.38", "phpunit/phpunit": "^12.5.8", diff --git a/backend/composer.lock b/backend/composer.lock index b18745c..10bd5e9 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e3183f3ee6ded4f46d8380294176679b", + "content-hash": "d68dee5a5cf2d58e0d80cb04c6e1e53c", "packages": [ { "name": "doctrine/collections", @@ -92,6 +92,89 @@ ], "time": "2026-01-15T10:01:58+00:00" }, + { + "name": "doctrine/data-fixtures", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/data-fixtures.git", + "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97", + "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^3.1 || ^4.0", + "php": "^8.1", + "psr/log": "^1.1 || ^2 || ^3" + }, + "conflict": { + "doctrine/dbal": "<3.5 || >=5", + "doctrine/orm": "<2.14 || >=4", + "doctrine/phpcr-odm": "<1.3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "doctrine/dbal": "^3.5 || ^4", + "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", + "doctrine/orm": "^2.14 || ^3", + "ext-sqlite3": "*", + "fig/log-test": "^1", + "phpstan/phpstan": "2.1.31", + "phpunit/phpunit": "10.5.45 || 12.4.0", + "symfony/cache": "^6.4 || ^7", + "symfony/var-exporter": "^6.4 || ^7" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)", + "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures", + "doctrine/orm": "For loading ORM fixtures", + "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\DataFixtures\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Data Fixtures for all Doctrine Object Managers", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "database" + ], + "support": { + "issues": "https://github.com/doctrine/data-fixtures/issues", + "source": "https://github.com/doctrine/data-fixtures/tree/2.2.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures", + "type": "tidelift" + } + ], + "time": "2025-10-17T20:06:20+00:00" + }, { "name": "doctrine/dbal", "version": "4.4.1", @@ -361,6 +444,92 @@ ], "time": "2025-12-24T12:24:29+00:00" }, + { + "name": "doctrine/doctrine-fixtures-bundle", + "version": "4.3.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d", + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d", + "shasum": "" + }, + "require": { + "doctrine/data-fixtures": "^2.2", + "doctrine/doctrine-bundle": "^2.2 || ^3.0", + "doctrine/orm": "^2.14.0 || ^3.0", + "doctrine/persistence": "^2.4 || ^3.0 || ^4.0", + "php": "^8.1", + "psr/log": "^2 || ^3", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0" + }, + "conflict": { + "doctrine/dbal": "< 3" + }, + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "phpstan/phpstan": "2.1.11", + "phpunit/phpunit": "^10.5.38 || 11.4.14" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\FixturesBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DoctrineFixturesBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "Fixture", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", + "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle", + "type": "tidelift" + } + ], + "time": "2025-12-03T16:05:42+00:00" + }, { "name": "doctrine/doctrine-migrations-bundle", "version": "4.0.0", @@ -7714,175 +7883,6 @@ }, "time": "2026-01-22T09:57:17+00:00" }, - { - "name": "doctrine/data-fixtures", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/data-fixtures.git", - "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97", - "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97", - "shasum": "" - }, - "require": { - "doctrine/persistence": "^3.1 || ^4.0", - "php": "^8.1", - "psr/log": "^1.1 || ^2 || ^3" - }, - "conflict": { - "doctrine/dbal": "<3.5 || >=5", - "doctrine/orm": "<2.14 || >=4", - "doctrine/phpcr-odm": "<1.3.0" - }, - "require-dev": { - "doctrine/coding-standard": "^14", - "doctrine/dbal": "^3.5 || ^4", - "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", - "doctrine/orm": "^2.14 || ^3", - "ext-sqlite3": "*", - "fig/log-test": "^1", - "phpstan/phpstan": "2.1.31", - "phpunit/phpunit": "10.5.45 || 12.4.0", - "symfony/cache": "^6.4 || ^7", - "symfony/var-exporter": "^6.4 || ^7" - }, - "suggest": { - "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)", - "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures", - "doctrine/orm": "For loading ORM fixtures", - "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\DataFixtures\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - } - ], - "description": "Data Fixtures for all Doctrine Object Managers", - "homepage": "https://www.doctrine-project.org", - "keywords": [ - "database" - ], - "support": { - "issues": "https://github.com/doctrine/data-fixtures/issues", - "source": "https://github.com/doctrine/data-fixtures/tree/2.2.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures", - "type": "tidelift" - } - ], - "time": "2025-10-17T20:06:20+00:00" - }, - { - "name": "doctrine/doctrine-fixtures-bundle", - "version": "4.3.1", - "source": { - "type": "git", - "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", - "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d", - "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d", - "shasum": "" - }, - "require": { - "doctrine/data-fixtures": "^2.2", - "doctrine/doctrine-bundle": "^2.2 || ^3.0", - "doctrine/orm": "^2.14.0 || ^3.0", - "doctrine/persistence": "^2.4 || ^3.0 || ^4.0", - "php": "^8.1", - "psr/log": "^2 || ^3", - "symfony/config": "^6.4 || ^7.0 || ^8.0", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", - "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0", - "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0" - }, - "conflict": { - "doctrine/dbal": "< 3" - }, - "require-dev": { - "doctrine/coding-standard": "14.0.0", - "phpstan/phpstan": "2.1.11", - "phpunit/phpunit": "^10.5.38 || 11.4.14" - }, - "type": "symfony-bundle", - "autoload": { - "psr-4": { - "Doctrine\\Bundle\\FixturesBundle\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Doctrine Project", - "homepage": "https://www.doctrine-project.org" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony DoctrineFixturesBundle", - "homepage": "https://www.doctrine-project.org", - "keywords": [ - "Fixture", - "persistence" - ], - "support": { - "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", - "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle", - "type": "tidelift" - } - ], - "time": "2025-12-03T16:05:42+00:00" - }, { "name": "evenement/evenement", "version": "v3.0.2", From e8a6bb7c6e1f5d5bca307072f8ef4e88606eeff9 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Mon, 2 Mar 2026 16:37:42 +0100 Subject: [PATCH 2/7] improve Makefile targets --- Makefile | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index ed2a18f..314f123 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,9 @@ VERSION = 0.1.1 help: @echo "📋 Available targets:\n" @echo "🏗️ Build & Setup:" - @echo " make build Build development image, boot stack, and open browser" - @echo " make image Build development Docker image" + @echo " make start Build development image, boot stack, initialize db, and open browser" + @echo " make dev Build development Docker image" + @echo " make prod Build production Docker images (app + web)" @echo " make up Boot the Docker stack" @echo " make down Shut down the Docker stack" @echo " make init Initialize app (composer, database, fixtures)" @@ -30,12 +31,17 @@ help: @echo " make clear Clear all caches" @echo " make open Open application in browser\n" -build: image up open +start: build up init open + +build: + @echo "🏗️ Building development image(s)..." + docker build . -f ./build/php/Dockerfile --target dev --no-cache -t ${APP_NAME}-dev:${VERSION} + +prod: + @echo "🛳️ Building procution image(s)..." + docker build . -f ./build/php/Dockerfile --target prod --no-cache -t ${APP_NAME}:${VERSION} + docker build . -f ./build/nginx/Dockerfile --no-cache -t ${APP_NAME}-web:${VERSION} -image: - @echo "🏗️ Building development image..." - docker build . -f ./build/php/Dockerfile --target dev -t ${APP_NAME}-dev:${VERSION} - up: @echo "🚀 Booting Docker stack..." docker compose up -d --remove-orphans From ac126bf5489444076ec02b5d54405019f3d8ed74 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Mon, 2 Mar 2026 16:38:02 +0100 Subject: [PATCH 3/7] fix .env --- backend/.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/.env b/backend/.env index d5985f1..0d461e2 100644 --- a/backend/.env +++ b/backend/.env @@ -15,7 +15,7 @@ # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration ###> symfony/framework-bundle ### -APP_ENV=dev +APP_ENV=prod APP_SECRET= APP_SHARE_DIR=var/share APP_VERSION=0.0.1 From 1477e9e4e6996dad4357597053abf710b8e87a96 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Mon, 2 Mar 2026 16:38:33 +0100 Subject: [PATCH 4/7] fixup! make fixtures a non-dev dependency --- backend/config/bundles.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/config/bundles.php b/backend/config/bundles.php index 61cc5f3..2a67cb2 100644 --- a/backend/config/bundles.php +++ b/backend/config/bundles.php @@ -7,7 +7,7 @@ Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], - Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], From bedbde63590e5b6e11788a79f66f44fbe854bc42 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Mon, 2 Mar 2026 16:41:09 +0100 Subject: [PATCH 5/7] determine API url dynamically --- frontend/src/api/config.ts | 31 ++++++++++++++++++++++ frontend/src/features/auth/AuthContext.tsx | 22 +++------------ frontend/src/features/auth/api.ts | 22 +++------------ frontend/src/features/calculation/api.ts | 6 +++-- frontend/src/features/expense/api.ts | 4 ++- 5 files changed, 45 insertions(+), 40 deletions(-) create mode 100644 frontend/src/api/config.ts diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 0000000..c35f361 --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,31 @@ +/** + * Get the API base URL based on the current environment. + * + * In development (Vite on localhost:5173): + * - Returns http://localhost:8080 (backend on different port) + * + * In production (deployed app): + * - Returns the same origin as the frontend + * - Works when frontend and backend are served from same domain/port + */ +export function getApiBaseUrl(): string { + const currentOrigin = window.location.origin + + // Development: Frontend on localhost:5173, backend on localhost:8080 + if (currentOrigin.includes('5173')) { + return 'http://localhost:8080' + } + + // Production/Docker: Frontend and backend share origin + return currentOrigin +} + +/** + * Get the full API endpoint URL + */ +export function getApiUrl(path: string): string { + const baseUrl = getApiBaseUrl() + // Ensure path starts with / + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${baseUrl}${normalizedPath}` +} diff --git a/frontend/src/features/auth/AuthContext.tsx b/frontend/src/features/auth/AuthContext.tsx index aa099db..2b333a2 100644 --- a/frontend/src/features/auth/AuthContext.tsx +++ b/frontend/src/features/auth/AuthContext.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react' import { logout as apiLogout } from './api' +import { getApiBaseUrl, getApiUrl } from '../../api/config' interface User { email: string @@ -13,17 +14,6 @@ interface AuthContextType { const AuthContext = createContext(undefined) -// Get the API base URL - use backend URL if frontend is on different origin -const getApiBaseUrl = () => { - const currentOrigin = window.location.origin - // If we're on localhost:5173 (Vite dev), point to backend at 8080 - if (currentOrigin.includes('5173')) { - return 'http://localhost:8080' - } - // Otherwise use current origin - return currentOrigin -} - export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) @@ -32,8 +22,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Check server session on app load const checkAuth = async () => { try { - const apiUrl = getApiBaseUrl() - const response = await fetch(`${apiUrl}/api/me`, { + const response = await fetch(getApiUrl('/api/me'), { method: 'GET', credentials: 'include', // Include cookies for session auth headers: { @@ -47,7 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } else { setUser(null) // Redirect to login if not authenticated - window.location.href = `${apiUrl}/login` + window.location.href = `${getApiBaseUrl()}/login` } } catch (error) { console.error('Failed to check authentication:', error) @@ -68,8 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { console.error('Error during logout:', error) } finally { // Redirect to login page - const apiUrl = getApiBaseUrl() - window.location.href = `${apiUrl}/login` + window.location.href = `${getApiBaseUrl()}/login` } } @@ -93,5 +81,3 @@ export function useAuth() { } return context } - - diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts index b21984c..ccde369 100644 --- a/frontend/src/features/auth/api.ts +++ b/frontend/src/features/auth/api.ts @@ -1,10 +1,9 @@ +import { getApiUrl } from '../../api/config' + // Logout API function - handles destroying session on server export async function logout(): Promise { - // Get the API base URL - use backend URL if frontend is on different origin - const apiUrl = getApiBaseUrl() - try { - const response = await fetch(`${apiUrl}/api/logout`, { + const response = await fetch(getApiUrl('/api/logout'), { method: 'POST', credentials: 'include', }) @@ -16,18 +15,3 @@ export async function logout(): Promise { console.error('Failed to logout:', error) } } - -// Get the API base URL - use backend URL if frontend is on different origin -function getApiBaseUrl(): string { - const currentOrigin = window.location.origin - // If we're on localhost:5173 (Vite dev), point to backend at 8080 - if (currentOrigin.includes('5173')) { - return 'http://localhost:8080' - } - // Otherwise use current origin - return currentOrigin -} - - - - diff --git a/frontend/src/features/calculation/api.ts b/frontend/src/features/calculation/api.ts index 6aaf0ab..e8d1abb 100644 --- a/frontend/src/features/calculation/api.ts +++ b/frontend/src/features/calculation/api.ts @@ -1,3 +1,5 @@ +import { getApiUrl } from '../../api/config' + interface Price { value: number currency: string @@ -25,7 +27,7 @@ export interface CalculationResponse { } export async function fetchCalculation(): Promise { - const response = await fetch('http://localhost:8080/api/calculate', { + const response = await fetch(getApiUrl('/api/calculate'), { credentials: 'include', }) @@ -38,7 +40,7 @@ export async function fetchCalculation(): Promise { } export async function downloadCalculationReport(): Promise { - return fetch('http://localhost:8080/api/report/calculation', { + return fetch(getApiUrl('/api/report/calculation'), { credentials: 'include', }) } diff --git a/frontend/src/features/expense/api.ts b/frontend/src/features/expense/api.ts index 80e32e9..b4a3fe1 100644 --- a/frontend/src/features/expense/api.ts +++ b/frontend/src/features/expense/api.ts @@ -1,3 +1,5 @@ +import { getApiUrl } from '../../api/config' + interface Price { value: number currency: string @@ -11,7 +13,7 @@ interface ExpenseData { } export async function trackExpense(expense: ExpenseData): Promise { - const response = await fetch('http://localhost:8080/api/track', { + const response = await fetch(getApiUrl('/api/track'), { method: 'POST', headers: { 'Content-Type': 'application/json', From 96352ac642748050104b823cf3309232469d29a0 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Mon, 2 Mar 2026 16:41:48 +0100 Subject: [PATCH 6/7] improve docker images --- build/nginx/Dockerfile | 29 +++++++++++++++++++++++++++++ build/php/Dockerfile | 22 +++++++++++++++++++--- 2 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 build/nginx/Dockerfile diff --git a/build/nginx/Dockerfile b/build/nginx/Dockerfile new file mode 100644 index 0000000..1b720b5 --- /dev/null +++ b/build/nginx/Dockerfile @@ -0,0 +1,29 @@ +FROM node:22-alpine AS frontend + +WORKDIR /frontend + +COPY frontend . + +RUN npm install && npm run build + +# The vite config outputs to ../backend/public/build from /frontend context +# That path resolves to /backend/public/build, copy it to /dist +RUN cp -r /backend/public/build /dist + +# ----------------------------------- +# nginx-Stage (serve frontend + proxy API) +# ----------------------------------- +FROM nginx:stable-alpine + +# Create public directory structure +RUN mkdir -p /var/www/project/public/build && \ + chmod -R 755 /var/www/project/public + +# Copy built frontend assets +COPY --from=frontend /dist /var/www/project/public/build + +# Copy EasyAdmin bundles from backend source +COPY backend/public/bundles /var/www/project/public/bundles + +# Copy nginx configuration +COPY build/nginx/default.conf /etc/nginx/conf.d/default.conf diff --git a/build/php/Dockerfile b/build/php/Dockerfile index 187e3be..6c6f7c2 100644 --- a/build/php/Dockerfile +++ b/build/php/Dockerfile @@ -59,6 +59,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* +# ----------------------------------- +# Frontend-Stage (build React app) +# ----------------------------------- +FROM node:22-alpine AS frontend + +WORKDIR /frontend + +COPY frontend . + +RUN npm install && npm run build + +# The vite config builds to ../backend/public/build which is /backend/public/build in this context +# Copy it to /dist for the prod stage to access it +RUN cp -r /backend/public/build /dist + # ----------------------------------- # Production-Stage (no Composer, no XDebug) # Note: from vendor! @@ -66,15 +81,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ FROM base AS prod COPY --from=vendor /var/www/project/vendor ./vendor +COPY --from=frontend /dist ./public/build COPY backend/.env . COPY backend/bin ./bin +COPY backend/composer.json . COPY backend/config ./config COPY backend/migrations ./migrations COPY backend/public ./public COPY backend/src ./src COPY backend/templates ./templates -#RUN mkdir -p var/cache var/log && chmod -R 777 var -# The following might not be required once we have the shared cache - check it! -#RUN mkdir var && chmod -R 777 var +RUN mkdir -p var/cache var/log && chmod -R 777 var && \ + sed -i 's/^APP_ENV=.*/APP_ENV=prod/' .env From edb93d8d9bd4bc095bc242827ca01ba197117bf4 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Mon, 2 Mar 2026 16:42:25 +0100 Subject: [PATCH 7/7] provide HELM chart for convenient installation --- README.md | 125 +++++++++++++++++++++++++- helm/Chart.yaml | 14 +++ helm/templates/NOTES.txt | 88 ++++++++++++++++++ helm/templates/_helpers.tpl | 3 + helm/templates/configmap-nginx.yaml | 7 ++ helm/templates/deployment-app.yaml | 72 +++++++++++++++ helm/templates/deployment-web.yaml | 32 +++++++ helm/templates/deployment-worker.yaml | 53 +++++++++++ helm/templates/job-db-init.yaml | 75 ++++++++++++++++ helm/templates/role.yaml | 28 ++++++ helm/templates/service-app.yaml | 13 +++ helm/templates/service-db.yaml | 13 +++ helm/templates/service-web.yaml | 14 +++ helm/templates/serviceaccount.yaml | 7 ++ helm/templates/statefulset-db.yaml | 57 ++++++++++++ helm/values.yaml | 112 +++++++++++++++++++++++ 16 files changed, 711 insertions(+), 2 deletions(-) create mode 100644 helm/Chart.yaml create mode 100644 helm/templates/NOTES.txt create mode 100644 helm/templates/_helpers.tpl create mode 100644 helm/templates/configmap-nginx.yaml create mode 100644 helm/templates/deployment-app.yaml create mode 100644 helm/templates/deployment-web.yaml create mode 100644 helm/templates/deployment-worker.yaml create mode 100644 helm/templates/job-db-init.yaml create mode 100644 helm/templates/role.yaml create mode 100644 helm/templates/service-app.yaml create mode 100644 helm/templates/service-db.yaml create mode 100644 helm/templates/service-web.yaml create mode 100644 helm/templates/serviceaccount.yaml create mode 100644 helm/templates/statefulset-db.yaml create mode 100644 helm/values.yaml diff --git a/README.md b/README.md index a6bbb47..5672968 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,108 @@ Track shared costs, calculate who owes whom, and manage group finances effortles Follows DDD principles for separting the application into generic, core, and supporting domains - enforced by [deptrac](https://github.com/deptrac/deptrac). +## Kubernetes Deployment + +This application is fully containerized and deployable to Kubernetes. Use the Helm chart to deploy: + +```bash +# Deploy to Kubernetes (requires kind, minikube, or Docker Desktop with K8s enabled) +helm install app ./helm + +# Access the application +kubectl port-forward svc/app-split-fairly-web 8080:80 +# → http://localhost:8080 + +# Or via NodePort (if enabled) +# → http://localhost:30190 +``` + +### Kubernetes Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Kubernetes Cluster │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ nginx (web) │ │ app (PHP-FPM) │ │ +│ │ Split-Fairly │ │ Deployment │ │ +│ │ NodePort:30190 │─→│ Pods × 1 │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ │ │ +│ │ Serves SPA │ Processes requests │ +│ │ EasyAdmin │ Event sourcing │ +│ │ │ Session management │ +│ │ ┌─────▼──────┐ │ +│ │ │ worker │ │ +│ │ │ Pod × 1 │ │ +│ │ │ Async jobs │ │ +│ │ └────────────┘ │ +│ │ │ │ +│ └───────────┬───────────┘ │ +│ │ │ +│ ┌──────▼──────┐ │ +│ │ MySQL │ │ +│ │ StatefulSet │ │ +│ │ PVC Storage │ │ +│ └─────────────┘ │ +│ △ │ +│ │ init Job │ +│ ┌──────┴──────┐ │ +│ │ db-init │ │ +│ │ (one-time) │ │ +│ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Components:** +- **nginx (web)**: Serves React SPA frontend, EasyAdmin assets, proxies API to PHP +- **PHP-FPM (app)**: Symfony backend, handles business logic & API endpoints +- **Worker**: Processes async jobs via Messenger (background tasks) +- **MySQL**: Persistent data storage with PVC +- **db-init Job**: One-time database initialization (schema + fixtures) + +**Access:** `http://localhost:30190` (direct) or `http://localhost:8080` (port-forward) + +### Getting Started with Kubernetes + +```bash +# Prerequisites: Docker Desktop (or kind/minikube) with Kubernetes enabled + +# Build production images +make prod + +# Deploy to cluster +helm upgrade --install app ./helm + +# Watch pods come up +kubectl get pods -w + +# View application logs +kubectl logs deployment/app-split-fairly-app -f +kubectl logs deployment/app-split-fairly-worker -f + +# Access the application +# - Direct: http://localhost:30190 +# - Port-forward: kubectl port-forward svc/app-split-fairly-web 8080:80 + +# Login credentials (auto-loaded from fixtures) +# Email: admin@example.com +# Password: secret +``` + +## Local Development (Docker Compose) + +For development without Kubernetes: + +```bash +make start # Build images, start services, and open in browser +make help # Show all available targets +``` + +Visit `http://localhost:8000` in your browser. + ## Architecture diagram ``` @@ -53,19 +155,38 @@ Follows DDD principles for separting the application into generic, core, and sup ## Prerequisites +### For Docker Compose (Local Development) - [Make](https://www.gnu.org/software/make/) - [Docker](https://www.docker.com/) - [Docker Compose](https://docs.docker.com/compose/) +### For Kubernetes Deployment +- [Make](https://www.gnu.org/software/make/) +- [Docker](https://www.docker.com/) +- [Kubernetes](https://kubernetes.io/) (kind, minikube, or Docker Desktop) +- [Helm 3](https://helm.sh/) + ## Getting Started +### Quick Start (Docker Compose) + ```bash -make build # Build image, start services, and open in browser -make init # Initialize database and load fixtures +make start # Build image, start services, and open in browser +make help # to show all targets ``` Visit `http://localhost:8000` in your browser. +### Kubernetes Deployment + +```bash +make prod # Build production images +helm install app ./helm # Deploy to Kubernetes +kubectl port-forward svc/app-split-fairly-web 8080:80 # Access app +``` + +Visit `http://localhost:8080` (or `http://localhost:30190` directly if NodePort is enabled). + ## Screenshots diff --git a/helm/Chart.yaml b/helm/Chart.yaml new file mode 100644 index 0000000..07763e1 --- /dev/null +++ b/helm/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v2 +name: split-fairly +description: Helm chart for the split-fairly application +type: application +version: 0.1.0 +appVersion: "0.1.1" +keywords: + - split-fairly + - web + - php + - mysql +maintainers: + - name: Martin Komischke + email: "martin@example.com" diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt new file mode 100644 index 0000000..0257be5 --- /dev/null +++ b/helm/templates/NOTES.txt @@ -0,0 +1,88 @@ +================================================================================ +{{ include "split-fairly.fullname" . | upper }} - Deployment Complete +================================================================================ +{{ if eq .Values.service.web.type "NodePort" }} +🚀 Direct Access (NodePort): + The application is available at: + → http://localhost:{{ .Values.service.web.nodePort }} + → http://localhost:{{ .Values.service.web.nodePort }}/admin + + This works on Docker Desktop and Minikube without port-forwarding. +{{ else if eq .Values.service.web.type "LoadBalancer" }} +🚀 LoadBalancer Access: + Wait for an external IP to be assigned (may take a moment): + $ kubectl get svc {{ include "split-fairly.fullname" . }}-web + + Once assigned, visit: + → http://:{{ .Values.service.web.port }} + → http://:{{ .Values.service.web.port }}/admin +{{ end }} +================================================================================ +📌 Alternative Access Methods: + +1. Using kubectl port-forward (any service type): + $ kubectl port-forward svc/{{ include "split-fairly.fullname" . }}-web 8080:80 + → http://localhost:8080 + +2. Using NodePort directly (if service type is NodePort): + $ kubectl get svc {{ include "split-fairly.fullname" . }}-web + → Access via the listed NodePort (default: {{ .Values.service.web.nodePort }}) + +3. Inside the cluster: + → http://{{ include "split-fairly.fullname" . }}-web:{{ .Values.service.web.port }} + +================================================================================ +🔐 Initial Login: + Email: {{ .Values.env.ADMIN_EMAIL }} + Password: {{ .Values.env.ADMIN_PASSWORD }} + +================================================================================ +📊 Service Information: + Release: {{ .Release.Name }} + Chart: {{ .Chart.Name }}-{{ .Chart.Version }} + Namespace: {{ .Release.Namespace }} + Backend URL: http://{{ include "split-fairly.fullname" . }}-app:{{ .Values.service.app.port }} + Database: {{ include "split-fairly.fullname" . }}-db:{{ .Values.service.mysql.port }} + +================================================================================ +🗄️ Database Initialization: + The database schema and initial fixtures (admin user) are managed by a + separate Kubernetes Job ({{ include "split-fairly.fullname" . }}-db-init). + + ✓ The Job runs once automatically after deployment + ✓ App and worker pods wait for the Job to complete before starting + ✓ The Job is idempotent - it safely handles already-existing data + + Check job status: + $ kubectl get job {{ include "split-fairly.fullname" . }}-db-init + + View job logs: + $ kubectl logs job/{{ include "split-fairly.fullname" . }}-db-init + + 🔄 To re-initialize the database: + 1. Delete the existing database: + $ kubectl delete statefulset {{ include "split-fairly.fullname" . }}-db + $ kubectl delete pvc data-{{ include "split-fairly.fullname" . }}-db-0 + + 2. Wait for the MySQL pod to restart and become ready + + 3. Delete the completed Job to trigger re-initialization: + $ kubectl delete job {{ include "split-fairly.fullname" . }}-db-init + + 4. Redeploy the app (triggers Job recreation): + $ helm upgrade {{ .Release.Name }} ./helm + +================================================================================ +📋 Useful Commands: + + Check all resources: + $ kubectl get all + + View deployment logs: + $ kubectl logs deployment/{{ include "split-fairly.fullname" . }}-app + $ kubectl logs deployment/{{ include "split-fairly.fullname" . }}-worker + + Describe pods (for troubleshooting): + $ kubectl describe pod -l app={{ include "split-fairly.fullname" . }}-app + +================================================================================ diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl new file mode 100644 index 0000000..aefa871 --- /dev/null +++ b/helm/templates/_helpers.tpl @@ -0,0 +1,3 @@ +{{- define "split-fairly.fullname" -}} +{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/helm/templates/configmap-nginx.yaml b/helm/templates/configmap-nginx.yaml new file mode 100644 index 0000000..439271b --- /dev/null +++ b/helm/templates/configmap-nginx.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "split-fairly.fullname" . }}-nginx +data: + default.conf: |- +{{ tpl .Values.webConfig . | indent 4 }} diff --git a/helm/templates/deployment-app.yaml b/helm/templates/deployment-app.yaml new file mode 100644 index 0000000..5f94c5d --- /dev/null +++ b/helm/templates/deployment-app.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "split-fairly.fullname" . }}-app + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + replicas: {{ .Values.replicaCount.app }} + selector: + matchLabels: + app: {{ include "split-fairly.fullname" . }}-app + template: + metadata: + labels: + app: {{ include "split-fairly.fullname" . }}-app + spec: + serviceAccountName: {{ include "split-fairly.fullname" . }} + initContainers: + - name: wait-for-db-init + image: bitnami/kubectl:latest + command: + - sh + - -c + - | + echo "Waiting for database initialization job to complete..." + until kubectl get job {{ include "split-fairly.fullname" . }}-db-init -o jsonpath='{.status.succeeded}' 2>/dev/null | grep -q '1'; do + STATUS=$(kubectl get job {{ include "split-fairly.fullname" . }}-db-init -o jsonpath='{.status.conditions[?(@.type=="Complete")].status}' 2>/dev/null || echo "NotFound") + if [ "$STATUS" = "True" ]; then + echo "Database initialization completed successfully!" + exit 0 + fi + echo "Database initialization job not yet completed. Status: $STATUS" + sleep 2 + done + echo "Database initialization completed successfully!" + containers: + - name: app + image: "{{ .Values.image.app.repository }}:{{ .Values.image.app.tag }}" + imagePullPolicy: {{ .Values.image.app.pullPolicy }} + ports: + - containerPort: {{ .Values.service.app.port }} + env: + - name: APP_SECRET + value: "{{ .Values.env.APP_SECRET }}" + - name: APP_ENV + value: "{{ .Values.env.APP_ENV }}" + - name: APP_VERSION + value: "{{ .Values.env.APP_VERSION }}" + - name: DATABASE_URL + value: "{{ tpl .Values.env.DATABASE_URL . }}" + - name: ADMIN_EMAIL + value: "{{ .Values.env.ADMIN_EMAIL }}" + - name: ADMIN_PASSWORD + value: "{{ .Values.env.ADMIN_PASSWORD }}" + volumeMounts: + - name: app-var + mountPath: /var/www/project/var + livenessProbe: + tcpSocket: + port: {{ .Values.service.app.port }} + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + tcpSocket: + port: {{ .Values.service.app.port }} + initialDelaySeconds: 10 + periodSeconds: 5 + resources: {{ toYaml .Values.resources.app | nindent 12 }} + volumes: + - name: app-var + emptyDir: {} diff --git a/helm/templates/deployment-web.yaml b/helm/templates/deployment-web.yaml new file mode 100644 index 0000000..47211fb --- /dev/null +++ b/helm/templates/deployment-web.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "split-fairly.fullname" . }}-web + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + replicas: {{ .Values.replicaCount.web }} + selector: + matchLabels: + app: {{ include "split-fairly.fullname" . }}-web + template: + metadata: + labels: + app: {{ include "split-fairly.fullname" . }}-web + spec: + containers: + - name: nginx + image: "{{ .Values.image.web.repository }}:{{ .Values.image.web.tag }}" + imagePullPolicy: {{ .Values.image.web.pullPolicy }} + ports: + - containerPort: 80 + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/conf.d/default.conf + subPath: default.conf + resources: {{ toYaml .Values.resources.web | nindent 12 }} + volumes: + - name: nginx-config + configMap: + name: {{ include "split-fairly.fullname" . }}-nginx diff --git a/helm/templates/deployment-worker.yaml b/helm/templates/deployment-worker.yaml new file mode 100644 index 0000000..505de2d --- /dev/null +++ b/helm/templates/deployment-worker.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "split-fairly.fullname" . }}-worker + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + replicas: {{ .Values.replicaCount.worker }} + selector: + matchLabels: + app: {{ include "split-fairly.fullname" . }}-worker + template: + metadata: + labels: + app: {{ include "split-fairly.fullname" . }}-worker + spec: + serviceAccountName: {{ include "split-fairly.fullname" . }} + initContainers: + - name: wait-for-db-init + image: bitnami/kubectl:latest + command: + - sh + - -c + - | + echo "Waiting for database initialization job to complete..." + until kubectl get job {{ include "split-fairly.fullname" . }}-db-init -o jsonpath='{.status.succeeded}' 2>/dev/null | grep -q '1'; do + STATUS=$(kubectl get job {{ include "split-fairly.fullname" . }}-db-init -o jsonpath='{.status.conditions[?(@.type=="Complete")].status}' 2>/dev/null || echo "NotFound") + if [ "$STATUS" = "True" ]; then + echo "Database initialization completed successfully!" + exit 0 + fi + echo "Database initialization job not yet completed. Status: $STATUS" + sleep 2 + done + echo "Database initialization completed successfully!" + containers: + - name: worker + image: "{{ .Values.image.app.repository }}:{{ .Values.image.app.tag }}" + imagePullPolicy: {{ .Values.image.app.pullPolicy }} + command: ["bin/console", "messenger:consume", "--all", "-vv"] + env: + - name: APP_ENV + value: "{{ .Values.env.APP_ENV }}" + - name: APP_VERSION + value: "{{ .Values.env.APP_VERSION }}" + - name: DATABASE_URL + value: "{{ tpl .Values.env.DATABASE_URL . }}" + - name: ADMIN_EMAIL + value: "{{ .Values.env.ADMIN_EMAIL }}" + - name: ADMIN_PASSWORD + value: "{{ .Values.env.ADMIN_PASSWORD }}" + resources: {{ toYaml .Values.resources.worker | nindent 12 }} diff --git a/helm/templates/job-db-init.yaml b/helm/templates/job-db-init.yaml new file mode 100644 index 0000000..f4c06f3 --- /dev/null +++ b/helm/templates/job-db-init.yaml @@ -0,0 +1,75 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "split-fairly.fullname" . }}-db-init + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + # Run only once - must be manually deleted and recreated to run again + backoffLimit: 3 + ttlSecondsAfterFinished: null + template: + metadata: + labels: + app: {{ include "split-fairly.fullname" . }}-db-init + spec: + serviceAccountName: default + restartPolicy: Never + # Wait for MySQL to be ready + initContainers: + - name: wait-for-db + image: busybox:1.35 + command: + - sh + - -c + - | + echo "Waiting for MySQL to be ready..." + until nc -z {{ include "split-fairly.fullname" . }}-db {{ .Values.service.mysql.port }}; do + echo "MySQL not ready, waiting..." + sleep 2 + done + echo "MySQL is ready!" + containers: + - name: db-init + image: "{{ .Values.image.app.repository }}:{{ .Values.image.app.tag }}" + imagePullPolicy: {{ .Values.image.app.pullPolicy }} + env: + - name: APP_ENV + value: "{{ .Values.env.APP_ENV }}" + - name: APP_SECRET + value: "{{ .Values.env.APP_SECRET }}" + - name: DATABASE_URL + value: "{{ tpl .Values.env.DATABASE_URL . }}" + - name: ADMIN_EMAIL + value: "{{ .Values.env.ADMIN_EMAIL }}" + - name: ADMIN_PASSWORD + value: "{{ .Values.env.ADMIN_PASSWORD }}" + command: + - sh + - -c + - | + set -e + echo "================================================" + echo "Starting Database Initialization" + echo "================================================" + + echo "1. Creating database if not exists..." + php bin/console doctrine:database:create --if-not-exists + + echo "2. Creating database schema (if not exists)..." + php bin/console doctrine:schema:create 2>/dev/null || echo "Schema already exists or error - continuing..." + + echo "3. Loading fixtures (admin user)..." + # Only load fixtures if admin user doesn't exist + ADMIN_EXISTS=$(php bin/console doctrine:query:dql "SELECT COUNT(u) FROM App\\Entity\\User u WHERE u.email = '{{ .Values.env.ADMIN_EMAIL }}'" 2>/dev/null | grep -oP '\d+' | head -1 || echo "0") + if [ "$ADMIN_EXISTS" = "0" ]; then + echo " Admin user does not exist - loading fixtures..." + php bin/console doctrine:fixtures:load --no-interaction --append + else + echo " Admin user already exists - skipping fixture load" + fi + + echo "================================================" + echo "Database initialization completed successfully!" + echo "================================================" diff --git a/helm/templates/role.yaml b/helm/templates/role.yaml new file mode 100644 index 0000000..966c5ab --- /dev/null +++ b/helm/templates/role.yaml @@ -0,0 +1,28 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "split-fairly.fullname" . }}-read-jobs + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +rules: +- apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get", "list"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "split-fairly.fullname" . }}-read-jobs-binding + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "split-fairly.fullname" . }}-read-jobs +subjects: +- kind: ServiceAccount + name: {{ include "split-fairly.fullname" . }} + namespace: {{ .Release.Namespace }} diff --git a/helm/templates/service-app.yaml b/helm/templates/service-app.yaml new file mode 100644 index 0000000..49161fd --- /dev/null +++ b/helm/templates/service-app.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "split-fairly.fullname" . }}-app +spec: + type: ClusterIP + selector: + app: {{ include "split-fairly.fullname" . }}-app + ports: + - port: {{ .Values.service.app.port }} + targetPort: {{ .Values.service.app.port }} + protocol: TCP + name: http diff --git a/helm/templates/service-db.yaml b/helm/templates/service-db.yaml new file mode 100644 index 0000000..0938a6c --- /dev/null +++ b/helm/templates/service-db.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "split-fairly.fullname" . }}-db +spec: + type: ClusterIP + selector: + app: {{ include "split-fairly.fullname" . }}-db + ports: + - port: {{ .Values.service.mysql.port }} + targetPort: 3306 + protocol: TCP + name: mysql diff --git a/helm/templates/service-web.yaml b/helm/templates/service-web.yaml new file mode 100644 index 0000000..0034886 --- /dev/null +++ b/helm/templates/service-web.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "split-fairly.fullname" . }}-web +spec: + type: {{ .Values.service.web.type }} + selector: + app: {{ include "split-fairly.fullname" . }}-web + ports: + - port: {{ .Values.service.web.port }} + targetPort: 80 + protocol: TCP + name: http + nodePort: {{ .Values.service.web.nodePort }} diff --git a/helm/templates/serviceaccount.yaml b/helm/templates/serviceaccount.yaml new file mode 100644 index 0000000..e5412e4 --- /dev/null +++ b/helm/templates/serviceaccount.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "split-fairly.fullname" . }} + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/helm/templates/statefulset-db.yaml b/helm/templates/statefulset-db.yaml new file mode 100644 index 0000000..81f609a --- /dev/null +++ b/helm/templates/statefulset-db.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "split-fairly.fullname" . }}-db +spec: + selector: + matchLabels: + app: {{ include "split-fairly.fullname" . }}-db + serviceName: {{ include "split-fairly.fullname" . }}-db + replicas: 1 + template: + metadata: + labels: + app: {{ include "split-fairly.fullname" . }}-db + spec: + containers: + - name: mysql + image: "{{ .Values.image.mysql.repository }}:{{ .Values.image.mysql.tag }}" + imagePullPolicy: {{ .Values.image.mysql.pullPolicy }} + env: + - name: MYSQL_ROOT_PASSWORD + value: "{{ .Values.mysql.rootPassword }}" + - name: MYSQL_PASSWORD + value: "{{ .Values.mysql.password }}" + ports: + - containerPort: 3306 + name: mysql + readinessProbe: + exec: + command: + - sh + - -c + - mysqladmin ping -h 127.0.0.1 --silent + initialDelaySeconds: 10 + periodSeconds: 10 + resources: {{ toYaml .Values.resources.mysql | nindent 12 }} + volumeMounts: + - name: mysql-data + mountPath: /var/lib/mysql +{{- if not .Values.mysql.persistence.enabled }} + volumes: + - name: mysql-data + emptyDir: {} +{{- end }} +{{- if .Values.mysql.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: mysql-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: {{ .Values.mysql.persistence.size }} +{{- if .Values.mysql.persistence.storageClass }} + storageClassName: "{{ .Values.mysql.persistence.storageClass }}" +{{- end }} +{{- end }} diff --git a/helm/values.yaml b/helm/values.yaml new file mode 100644 index 0000000..3585fc1 --- /dev/null +++ b/helm/values.yaml @@ -0,0 +1,112 @@ +replicaCount: + app: 1 + worker: 1 + web: 1 + +image: + app: + repository: "split-fairly" + tag: "0.1.1" + pullPolicy: IfNotPresent + web: + repository: "split-fairly-web" + tag: "0.1.1" + pullPolicy: IfNotPresent + mysql: + repository: "mysql" + tag: "8" + pullPolicy: IfNotPresent + +service: + web: + type: NodePort + port: 80 + nodePort: 30190 + app: + port: 9000 + mysql: + port: 3306 + +resources: + app: {} + worker: {} + web: {} + mysql: {} + +env: + APP_SECRET: "F3E46580-5F9A-4524-B218-90490B033192" + APP_ENV: "prod" + APP_VERSION: "0.1.1" + DATABASE_URL: "mysql://{{ .Values.mysql.user }}:{{ .Values.mysql.password }}@{{ include \"split-fairly.fullname\" . }}-db:{{ .Values.service.mysql.port }}/{{ default \"app\" .Values.mysql.database }}?serverVersion=8.0.31&charset=utf8mb4" + ADMIN_EMAIL: "admin@example.com" + ADMIN_PASSWORD: "secret" + +mysql: + rootPassword: "secret" + password: "secret" + user: "root" + persistence: + enabled: true + size: 8Gi + storageClass: "" + +webConfig: | + server { + listen 80; + server_name localhost; + + root /var/www/project/public; + index index.php index.html; + + # Serve built frontend assets with long cache headers + location /build/ { + alias /var/www/project/public/build/; + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Static assets + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + try_files $uri =404; + access_log off; + add_header Cache-Control "public, max-age=86400"; + } + + # SPA and app entrypoint - serve built index.html for SPA + location = / { + rewrite ^ /build/index.html break; + } + + # API endpoints - directly proxy to PHP + location ^~ /api/ { + fastcgi_pass {{ include "split-fairly.fullname" . }}-app:9000; + fastcgi_split_path_info ^(.+\.php)(/.*)$; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root/index.php; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param PATH_INFO $uri; + fastcgi_param DOCUMENT_ROOT $document_root; + fastcgi_param HTTPS off; + } + + location / { + try_files $uri $uri/ /index.php$is_args$args; + } + + # PHP-FPM handling for internal redirect to index.php + location ~ ^/index\.php(/|$) { + fastcgi_pass {{ include "split-fairly.fullname" . }}-app:9000; + fastcgi_split_path_info ^(.+\.php)(/.*)$; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $document_root; + fastcgi_param HTTPS off; + internal; + } + + # Deny direct .php access + location ~ \.php$ { + return 404; + } + }