diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c75f400 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI Build and Publish + +on: + push: {} + +permissions: + contents: read + packages: write + +jobs: + lint-frontend: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: | + cd frontend + npm ci + + - name: Run Linting + run: | + cd frontend + npm run lint + + - name: Check Type Build (Dry Run) + # Runs the build to verify no type/compilation errors, but doesn't keep artifacts + run: | + cd frontend + npm run build + + build-and-push: + needs: [lint-frontend] + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Create short SHA output + id: sha + run: | + echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push backend image + uses: docker/build-push-action@v4 + with: + context: ./backend + file: ./backend/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/finance-manager-backend:latest + ghcr.io/${{ github.repository_owner }}/finance-manager-backend:${{ steps.sha.outputs.short_sha }} + + - name: Build and push frontend image + uses: docker/build-push-action@v4 + with: + context: ./frontend + file: ./frontend/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/finance-manager-frontend:latest + ghcr.io/${{ github.repository_owner }}/finance-manager-frontend:${{ steps.sha.outputs.short_sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..192b921 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Release Build and Publish + +on: + push: + branches: + - main + +permissions: + contents: read + packages: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Read backend pyproject version + id: backend_ver + run: | + ver=$(python - <<'PY' + import re,sys + txt=open('backend/pyproject.toml').read() + m=re.search(r"version\s*=\s*[\"']([^\"']+)[\"']", txt) + if not m: + sys.exit(1) + print(m.group(1)) + PY + ) + echo "version=$ver" >> $GITHUB_OUTPUT + + - name: Build and push backend image + uses: docker/build-push-action@v4 + with: + context: ./backend + file: ./backend/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/finance-manager-backend:${{ steps.backend_ver.outputs.version }} + ghcr.io/${{ github.repository_owner }}/finance-manager-backend:latest + + - name: Build and push frontend image + uses: docker/build-push-action@v4 + with: + context: ./frontend + file: ./frontend/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/finance-manager-frontend:${{ steps.backend_ver.outputs.version }} + ghcr.io/${{ github.repository_owner }}/finance-manager-frontend:latest diff --git a/.gitignore b/.gitignore index 0da3447..2d3affa 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +# lib/ +# lib64/ parts/ sdist/ var/ diff --git a/backend/app/api/v1/endpoints/login.py b/backend/app/api/v1/endpoints/login.py index b728086..12ff6c4 100644 --- a/backend/app/api/v1/endpoints/login.py +++ b/backend/app/api/v1/endpoints/login.py @@ -38,4 +38,6 @@ async def login_access_token( except HTTPException: raise except Exception as e: + import traceback + traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) diff --git a/frontend/src/app/(dashboard)/accounts/[id]/page.tsx b/frontend/src/app/(dashboard)/accounts/[id]/page.tsx index 2dd9dd8..2e23f6b 100644 --- a/frontend/src/app/(dashboard)/accounts/[id]/page.tsx +++ b/frontend/src/app/(dashboard)/accounts/[id]/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useState, useCallback } from "react" import { useParams } from "next/navigation" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -12,7 +12,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table" -import { Loader2, Plus } from "lucide-react" +import { Loader2 } from "lucide-react" import { accountService, Account } from "@/services/accounts" import { transactionService, Transaction } from "@/services/transactions" import { categoryService } from "@/services/categories" @@ -95,7 +95,7 @@ export default function AccountPage() { } } - const fetchAccount = async () => { + const fetchAccount = useCallback(async () => { if (accountId && authService.isAuthenticated()) { try { const endDate = date?.to ? format(date.to, "yyyy-MM-dd") : undefined @@ -105,9 +105,9 @@ export default function AccountPage() { console.error("Failed to fetch account", error) } } - } + }, [accountId, date]) - const fetchTransactions = async () => { + const fetchTransactions = useCallback(async () => { if (accountId && authService.isAuthenticated() && date?.from && date?.to) { try { const data = await transactionService.getTransactions({ @@ -122,9 +122,9 @@ export default function AccountPage() { console.error("Failed to fetch transactions", error) } } - } + }, [accountId, date, page, pageSize]) - const fetchTargetAccountExpenses = async () => { + const fetchTargetAccountExpenses = useCallback(async () => { if (!accountId || !authService.isAuthenticated() || !date?.from || !date?.to) { return } @@ -188,9 +188,9 @@ export default function AccountPage() { } finally { setLoadingTargetAccounts(false) } - } + }, [accountId, date, accountsMap]) - const fetchCategoryBreakdown = async () => { + const fetchCategoryBreakdown = useCallback(async () => { if (!accountId || !authService.isAuthenticated() || !date?.from || !date?.to) { return } @@ -263,9 +263,9 @@ export default function AccountPage() { } finally { setLoadingCategoryChart(false) } - } + }, [accountId, date, categoriesMap]) - const fetchIncomeExpenseSplit = async () => { + const fetchIncomeExpenseSplit = useCallback(async () => { if (!accountId || !authService.isAuthenticated() || !date?.from || !date?.to) { return } @@ -309,11 +309,11 @@ export default function AccountPage() { } finally { setLoadingIncomeExpense(false) } - } + }, [accountId, date]) useEffect(() => { fetchAccount() - }, [accountId, date]) + }, [fetchAccount]) // Reset page when date range changes useEffect(() => { @@ -322,19 +322,19 @@ export default function AccountPage() { useEffect(() => { fetchTransactions() - }, [accountId, date, page]) + }, [fetchTransactions]) useEffect(() => { fetchTargetAccountExpenses() - }, [accountId, date, accountsMap]) + }, [fetchTargetAccountExpenses]) useEffect(() => { fetchCategoryBreakdown() - }, [accountId, date, categoriesMap]) + }, [fetchCategoryBreakdown]) useEffect(() => { fetchIncomeExpenseSplit() - }, [accountId, date]) + }, [fetchIncomeExpenseSplit]) if (!account) { return
Loading...
diff --git a/frontend/src/app/(dashboard)/categories/page.tsx b/frontend/src/app/(dashboard)/categories/page.tsx index 1d30262..2b7643d 100644 --- a/frontend/src/app/(dashboard)/categories/page.tsx +++ b/frontend/src/app/(dashboard)/categories/page.tsx @@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button" import { categoryService, Category } from "@/services/categories" import { CategoryDialog } from "@/components/categories/CategoryDialog" import { ImportCategoriesDialog } from "@/components/categories/ImportCategoriesDialog" -import { Loader2, Trash2 } from "lucide-react" +import { Trash2 } from "lucide-react" import { AlertDialog, AlertDialogAction, diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx index b4dda3e..83752fc 100644 --- a/frontend/src/app/(dashboard)/settings/page.tsx +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -91,9 +91,10 @@ export default function SettingsPage() { setPasswordMessage({ type: 'success', text: 'Password updated successfully.' }) setPassword("") setConfirmPassword("") - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to update password", error) - const msg = error.response?.data?.detail || "Failed to update password."; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const msg = (error as any).response?.data?.detail || "Failed to update password."; setPasswordMessage({ type: 'error', text: msg }) } finally { setUpdatingPassword(false) @@ -130,9 +131,10 @@ export default function SettingsPage() { text: `Restore successful! Processed ${result.counts.categories} categories, ${result.counts.accounts} accounts, and ${result.counts.transactions} transactions.` }) if (fileInputRef.current) fileInputRef.current.value = '' - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to restore backup", error) - const msg = error.response?.data?.detail || "Failed to restore backup. Please check the file format."; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const msg = (error as any).response?.data?.detail || "Failed to restore backup. Please check the file format."; setRestoreMessage({ type: 'error', text: msg }) } finally { setRestoring(false) @@ -287,7 +289,7 @@ export default function SettingsPage() { Restore Data Restore your data from a backup ZIP file. - Warning: This will add missing records and update existing ones. It does NOT delete existing data that isn't in the backup. + Warning: This will add missing records and update existing ones. It does NOT delete existing data that isn't in the backup. diff --git a/frontend/src/components/accounts/AccountSettingsDialog.tsx b/frontend/src/components/accounts/AccountSettingsDialog.tsx index da8d169..f284933 100644 --- a/frontend/src/components/accounts/AccountSettingsDialog.tsx +++ b/frontend/src/components/accounts/AccountSettingsDialog.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import * as z from "zod" -import { Loader2, Settings, Pencil } from "lucide-react" +import { Loader2, Pencil } from "lucide-react" import { Button } from "@/components/ui/button" import { diff --git a/frontend/src/components/accounts/CreateAccountDialog.tsx b/frontend/src/components/accounts/CreateAccountDialog.tsx index 795f9ab..411840f 100644 --- a/frontend/src/components/accounts/CreateAccountDialog.tsx +++ b/frontend/src/components/accounts/CreateAccountDialog.tsx @@ -91,8 +91,9 @@ export function CreateAccountDialog({ onSuccess }: CreateAccountDialogProps) { setOpen(false) form.reset() onSuccess() - } catch (error) { + } catch (error: unknown) { console.error("Failed to create account", error) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const message = (error as any)?.response?.data?.detail || "Failed to create account. Please try again." setSubmitError(message) } finally { diff --git a/frontend/src/components/accounts/DeleteDestinationAccountDialog.tsx b/frontend/src/components/accounts/DeleteDestinationAccountDialog.tsx index f963390..42e22d2 100644 --- a/frontend/src/components/accounts/DeleteDestinationAccountDialog.tsx +++ b/frontend/src/components/accounts/DeleteDestinationAccountDialog.tsx @@ -37,9 +37,11 @@ export function DeleteDestinationAccountDialog({ await accountService.deleteDestinationAccount(account.id) setOpen(false) onSuccess() - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to delete account", error) - setError(error.response?.data?.detail || "Failed to delete account") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const msg = (error as any).response?.data?.detail || "Failed to delete account" + setError(msg) } finally { setLoading(false) } @@ -60,7 +62,7 @@ export function DeleteDestinationAccountDialog({ Are you sure? This action cannot be undone. This will permanently delete the account - "{account.name}" and all its associated data. + "{account.name}" and all its associated data. {error && (
diff --git a/frontend/src/components/accounts/DestinationAccountDialog.tsx b/frontend/src/components/accounts/DestinationAccountDialog.tsx index b058022..9672803 100644 --- a/frontend/src/components/accounts/DestinationAccountDialog.tsx +++ b/frontend/src/components/accounts/DestinationAccountDialog.tsx @@ -124,9 +124,10 @@ export function DestinationAccountDialog({ } setOpen(false) onSuccess() - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to save account", error) - const errorMessage = error.response?.data?.detail || "Failed to save account. Please try again." + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const errorMessage = (error as any).response?.data?.detail || "Failed to save account. Please try again." setError(errorMessage) } finally { setLoading(false) diff --git a/frontend/src/components/accounts/ImportDestinationAccountsDialog.tsx b/frontend/src/components/accounts/ImportDestinationAccountsDialog.tsx index 5137e5d..766039f 100644 --- a/frontend/src/components/accounts/ImportDestinationAccountsDialog.tsx +++ b/frontend/src/components/accounts/ImportDestinationAccountsDialog.tsx @@ -69,8 +69,8 @@ export function ImportDestinationAccountsDialog({ setSuccess(null) }, 1500) } - } catch (err: any) { - setError(err.message || "Failed to import destination accounts") + } catch (err: unknown) { + setError((err as Error).message || "Failed to import destination accounts") } finally { setLoading(false) } @@ -100,7 +100,8 @@ export function ImportDestinationAccountsDialog({ ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + render={({ field: { value: _value, onChange, ...fieldProps } }) => ( CSV File diff --git a/frontend/src/components/auth/AuthGuard.tsx b/frontend/src/components/auth/AuthGuard.tsx index 6803fcc..dcd5952 100644 --- a/frontend/src/components/auth/AuthGuard.tsx +++ b/frontend/src/components/auth/AuthGuard.tsx @@ -12,7 +12,8 @@ export function AuthGuard({ children }: { children: React.ReactNode }) { if (!authService.isAuthenticated()) { router.push("/login") } else { - setAuthorized(true) + // Avoid calling setState synchronously in effect + setTimeout(() => setAuthorized(true), 0) } }, [router]) diff --git a/frontend/src/components/categories/ImportCategoriesDialog.tsx b/frontend/src/components/categories/ImportCategoriesDialog.tsx index 39910cf..ae17d9c 100644 --- a/frontend/src/components/categories/ImportCategoriesDialog.tsx +++ b/frontend/src/components/categories/ImportCategoriesDialog.tsx @@ -69,8 +69,8 @@ export function ImportCategoriesDialog({ setSuccess(null) }, 1500) } - } catch (err: any) { - setError(err.message || "Failed to import categories") + } catch (err: unknown) { + setError((err as Error).message || "Failed to import categories") } finally { setLoading(false) } @@ -100,7 +100,8 @@ export function ImportCategoriesDialog({ ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + render={({ field: { value: _value, onChange, ...fieldProps } }) => ( CSV File diff --git a/frontend/src/components/dashboard/DateRangePicker.tsx b/frontend/src/components/dashboard/DateRangePicker.tsx index c176c26..d0de8d0 100644 --- a/frontend/src/components/dashboard/DateRangePicker.tsx +++ b/frontend/src/components/dashboard/DateRangePicker.tsx @@ -1,9 +1,8 @@ "use client" import * as React from "react" -import { addDays, format, subDays } from "date-fns" +import { format } from "date-fns" import { Calendar as CalendarIcon } from "lucide-react" -import { DateRange } from "react-day-picker" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" diff --git a/frontend/src/components/dashboard/Sidebar.tsx b/frontend/src/components/dashboard/Sidebar.tsx index 9f4897b..90693b5 100644 --- a/frontend/src/components/dashboard/Sidebar.tsx +++ b/frontend/src/components/dashboard/Sidebar.tsx @@ -15,7 +15,6 @@ import { BarChart3 } from "lucide-react" -import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { DropdownMenu, diff --git a/frontend/src/components/reports/ReportView.tsx b/frontend/src/components/reports/ReportView.tsx index f12d2ea..b8d94ae 100644 --- a/frontend/src/components/reports/ReportView.tsx +++ b/frontend/src/components/reports/ReportView.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect } from "react" +import { useState, useEffect, useCallback } from "react" import { format } from "date-fns" import { Loader2, ChevronDown } from "lucide-react" @@ -71,9 +71,10 @@ export function ReportView({ type }: ReportViewProps) { fetchData() }, [type]) - const fetchReport = async () => { + const fetchReport = useCallback(async () => { setLoading(true) try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const params: any = { start_date: date?.from ? format(date.from, 'yyyy-MM-dd') : undefined, end_date: date?.to ? format(date.to, 'yyyy-MM-dd') : undefined, @@ -98,11 +99,11 @@ export function ReportView({ type }: ReportViewProps) { } finally { setLoading(false) } - } + }, [date, selectedAccount, selectedItems, type]) useEffect(() => { fetchReport() - }, [date, selectedAccount, selectedItems, type]) + }, [fetchReport]) const toggleItem = (id: string) => { setSelectedItems(prev => diff --git a/frontend/src/components/transactions/EditTransactionDialog.tsx b/frontend/src/components/transactions/EditTransactionDialog.tsx index de9f7d0..76ccdbd 100644 --- a/frontend/src/components/transactions/EditTransactionDialog.tsx +++ b/frontend/src/components/transactions/EditTransactionDialog.tsx @@ -74,7 +74,7 @@ export function EditTransactionDialog({ resolver: zodResolver(formSchema), defaultValues: { name: transaction.name, - type: transaction.type as any, + type: transaction.type as "income" | "expense", amount: Math.abs(transaction.amount).toString(), target_account_id: transaction.target_account_id || "", account_id: transaction.account_id, @@ -106,7 +106,7 @@ export function EditTransactionDialog({ useEffect(() => { form.reset({ name: transaction.name, - type: transaction.type as any, + type: transaction.type as "income" | "expense", amount: Math.abs(transaction.amount).toString(), target_account_id: transaction.target_account_id || "", account_id: transaction.account_id, diff --git a/frontend/src/components/transactions/ImportTransactionsDialog.tsx b/frontend/src/components/transactions/ImportTransactionsDialog.tsx index 0d977e9..78e4c0c 100644 --- a/frontend/src/components/transactions/ImportTransactionsDialog.tsx +++ b/frontend/src/components/transactions/ImportTransactionsDialog.tsx @@ -4,7 +4,7 @@ import { useState } from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import * as z from "zod" -import { Loader2, Upload, FileUp } from "lucide-react" +import { Loader2, Upload } from "lucide-react" import { Button } from "@/components/ui/button" import { @@ -73,8 +73,8 @@ export function ImportTransactionsDialog({ setSuccess(null) }, 1500) } - } catch (err: any) { - setError(err.message || "Failed to import transactions") + } catch (err: unknown) { + setError((err as Error).message || "Failed to import transactions") } finally { setLoading(false) } @@ -101,7 +101,8 @@ export function ImportTransactionsDialog({ ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + render={({ field: { onChange, value: _value, ...field } }) => ( CSV File diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..32653d4 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,39 @@ +import axios from 'axios'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || '/api/v1'; + +export const api = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Add a request interceptor to include the auth token +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Add a response interceptor to handle 401 errors +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && error.response.status === 401) { + // Redirect to login if unauthorized + if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) { + localStorage.removeItem('token'); + window.location.href = '/login'; + } + } + return Promise.reject(error); + } +); diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/services/transactions.ts b/frontend/src/services/transactions.ts index 3e91e2b..0951b42 100644 --- a/frontend/src/services/transactions.ts +++ b/frontend/src/services/transactions.ts @@ -9,7 +9,7 @@ export interface Transaction { account_id: string; date: string; category_id?: string; - recurrency?: any; + recurrency?: Record; } export const transactionService = { @@ -39,12 +39,12 @@ export const transactionService = { return response.data; }, - bulkDeleteTransactions: async (ids: string[]): Promise => { + bulkDeleteTransactions: async (ids: string[]): Promise => { const response = await api.post('/transactions/bulk-delete', ids); return response.data; }, - importTransactions: async (file: File, accountId?: string): Promise => { + importTransactions: async (file: File, accountId?: string): Promise<{ status: string; message: string; errors?: string[] }> => { const formData = new FormData(); formData.append('file', file); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index cf9c65d..4622417 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -18,8 +18,9 @@ "name": "next" } ], + "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["src/*"] } }, "include": [ diff --git a/k8s/finance-manager/.helmignore b/k8s/finance-manager/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/k8s/finance-manager/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/k8s/finance-manager/Chart.yaml b/k8s/finance-manager/Chart.yaml new file mode 100644 index 0000000..b00a6d9 --- /dev/null +++ b/k8s/finance-manager/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: finance-manager +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.2.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/k8s/finance-manager/README.md b/k8s/finance-manager/README.md new file mode 100644 index 0000000..a99fe3e --- /dev/null +++ b/k8s/finance-manager/README.md @@ -0,0 +1,81 @@ +# Finance Manager Helm Chart + +A Helm chart for deploying the Finance Manager application (Frontend & Backend) on Kubernetes. + +## Prerequisites + +- Kubernetes 1.19+ +- Helm 3.2.0+ +- A running PostgreSQL database + +## Installation + +### 1. Database Prerequisite + +This chart expects a PostgreSQL database to be available. By default, it looks for a service named `postgres` in the same namespace. + +If you are using an external database or a cloud-managed PostgreSQL (like RDS or Cloud SQL), you need to update the `backend.env` values in `values.yaml`: + +- `POSTGRES_SERVER`: The hostname of your database +- `POSTGRES_USER`: The database user +- `POSTGRES_DB`: The database name + +### 2. Create the Database Secret + +Security best practices require sensitive information like passwords to be managed via Kubernetes Secrets. Before installing the chart, verify the namespace you are deploying to and create the secret: + +```bash +# Replace 'your-password' with your actual database password +kubectl create secret generic finance-db-secret \ + --from-literal=postgres-password='your-password' +``` + +If you wish to use a different secret name or key, update the `backend.secret` section in `values.yaml`. + +### 3. Install the Chart + +Install the chart using Helm: + +```bash +# Install from the local directory +helm install finance-manager . +``` + +To install into a specific namespace: + +```bash +helm install finance-manager . --namespace finance-ns --create-namespace +``` + +## Configuration + +The following table lists the configurable parameters and their default values. + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `backend.replicaCount` | Number of backend replicas | `1` | +| `backend.image.repository` | Backend image repository | `ghcr.io/teomarcdhio/finance-manager-backend` | +| `backend.image.tag` | Backend image tag | `latest` | +| `backend.service.port` | Backend service port | `8000` | +| `backend.env.POSTGRES_SERVER` | DB Hostname | `postgres` | +| `backend.env.POSTGRES_USER` | DB Username | `admin` | +| `backend.env.POSTGRES_DB` | DB Name | `finance_manager` | +| `frontend.replicaCount` | Number of frontend replicas | `1` | +| `frontend.image.repository` | Frontend image repository | `ghcr.io/teomarcdhio/finance-manager-frontend` | +| `frontend.image.tag` | Frontend image tag | `latest` | +| `frontend.service.port` | Frontend service port | `3000` | +| `frontend.ingress.enabled` | Enable Ingress for frontend | `true` | +| `frontend.ingress.hosts` | Frontend Ingress hosts | `finance.local` | + +Refer to `values.yaml` for the complete list of variables. + +## Accessing the Application + +If Ingress is enabled and configured, you can access the application via the configured host (e.g., `http://finance.local`). Ensure your DNS or `/etc/hosts` file resolves to the Ingress Controller IP. + +If Ingress is disabled, you can port-forward the frontend service: + +```bash +kubectl port-forward svc/finance-manager-frontend 3000:3000 +``` +Then visit `http://localhost:3000`. diff --git a/k8s/finance-manager/templates/_helpers.tpl b/k8s/finance-manager/templates/_helpers.tpl new file mode 100644 index 0000000..4ab6b6a --- /dev/null +++ b/k8s/finance-manager/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "finance-manager.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "finance-manager.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "finance-manager.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "finance-manager.labels" -}} +helm.sh/chart: {{ include "finance-manager.chart" . }} +{{ include "finance-manager.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "finance-manager.selectorLabels" -}} +app.kubernetes.io/name: {{ include "finance-manager.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "finance-manager.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "finance-manager.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/k8s/finance-manager/templates/backend-deployment.yaml b/k8s/finance-manager/templates/backend-deployment.yaml new file mode 100644 index 0000000..4c8a6dc --- /dev/null +++ b/k8s/finance-manager/templates/backend-deployment.yaml @@ -0,0 +1,83 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "finance-manager.fullname" . }}-backend + labels: + {{- include "finance-manager.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.backend.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "finance-manager.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "finance-manager.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + app.kubernetes.io/component: backend + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "finance-manager.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }}-backend + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.backend.service.port }} + protocol: TCP + env: + {{- range $key, $val := .Values.backend.env }} + - name: {{ $key }} + value: {{ $val | quote }} + {{- end }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.backend.secret.name }} + key: {{ .Values.backend.secret.key }} + {{- with .Values.backend.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/k8s/finance-manager/templates/backend-service.yaml b/k8s/finance-manager/templates/backend-service.yaml new file mode 100644 index 0000000..c0bdeba --- /dev/null +++ b/k8s/finance-manager/templates/backend-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "finance-manager.fullname" . }}-backend + labels: + {{- include "finance-manager.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: {{ .Values.backend.service.type }} + ports: + - port: {{ .Values.backend.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "finance-manager.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: backend diff --git a/k8s/finance-manager/templates/frontend-deployment.yaml b/k8s/finance-manager/templates/frontend-deployment.yaml new file mode 100644 index 0000000..a12ea40 --- /dev/null +++ b/k8s/finance-manager/templates/frontend-deployment.yaml @@ -0,0 +1,73 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "finance-manager.fullname" . }}-frontend + labels: + {{- include "finance-manager.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.frontend.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "finance-manager.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "finance-manager.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + app.kubernetes.io/component: frontend + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "finance-manager.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }}-frontend + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.frontend.service.port }} + protocol: TCP + {{- with .Values.frontend.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.frontend.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.frontend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/k8s/finance-manager/templates/frontend-ingress.yaml b/k8s/finance-manager/templates/frontend-ingress.yaml new file mode 100644 index 0000000..931d1a5 --- /dev/null +++ b/k8s/finance-manager/templates/frontend-ingress.yaml @@ -0,0 +1,44 @@ +{{- if .Values.frontend.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "finance-manager.fullname" . }}-frontend + labels: + {{- include "finance-manager.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend + {{- with .Values.frontend.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.frontend.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.frontend.ingress.tls }} + tls: + {{- range .Values.frontend.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.frontend.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "finance-manager.fullname" $ }}-frontend + port: + number: {{ $.Values.frontend.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/k8s/finance-manager/templates/frontend-service.yaml b/k8s/finance-manager/templates/frontend-service.yaml new file mode 100644 index 0000000..03fa1d0 --- /dev/null +++ b/k8s/finance-manager/templates/frontend-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "finance-manager.fullname" . }}-frontend + labels: + {{- include "finance-manager.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: {{ .Values.frontend.service.type }} + ports: + - port: {{ .Values.frontend.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "finance-manager.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: frontend diff --git a/k8s/finance-manager/templates/gateways.yaml b/k8s/finance-manager/templates/gateways.yaml new file mode 100644 index 0000000..976a6f8 --- /dev/null +++ b/k8s/finance-manager/templates/gateways.yaml @@ -0,0 +1,19 @@ +{{- if .Values.istio.enabled -}} +apiVersion: networking.istio.io/v1 +kind: Gateway +metadata: + name: {{ .Values.istio.gateway.name }} + namespace: {{ .Release.Namespace }} +spec: + selector: + {{- toYaml .Values.istio.gateway.selector | nindent 4 }} + servers: + - port: + number: 80 + name: http + protocol: HTTP + hosts: + {{- range .Values.istio.gateway.hosts }} + - {{ . | quote }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/k8s/finance-manager/templates/namespace.yaml b/k8s/finance-manager/templates/namespace.yaml new file mode 100644 index 0000000..1971910 --- /dev/null +++ b/k8s/finance-manager/templates/namespace.yaml @@ -0,0 +1,9 @@ +{{- if .Values.istio.enabled -}} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Release.Namespace }} + labels: + istio-injection: {{ .Values.istio.injection | default "enabled" }} + {{- include "finance-manager.labels" . | nindent 4 }} +{{- end }} diff --git a/k8s/finance-manager/templates/serviceaccount.yaml b/k8s/finance-manager/templates/serviceaccount.yaml new file mode 100644 index 0000000..992eedd --- /dev/null +++ b/k8s/finance-manager/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "finance-manager.serviceAccountName" . }} + labels: + {{- include "finance-manager.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/k8s/finance-manager/templates/virtual-services.yaml b/k8s/finance-manager/templates/virtual-services.yaml new file mode 100644 index 0000000..47bd3b2 --- /dev/null +++ b/k8s/finance-manager/templates/virtual-services.yaml @@ -0,0 +1,31 @@ +{{- if .Values.istio.enabled -}} +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: + name: {{ .Values.istio.virtualService.name }} + namespace: {{ .Release.Namespace }} +spec: + hosts: + {{- range .Values.istio.virtualService.hosts }} + - {{ . | quote }} + {{- end }} + gateways: + - {{ .Values.istio.gateway.name }} + http: + - match: + - uri: + prefix: /api/v1 + route: + - destination: + host: {{ include "finance-manager.fullname" . }}-backend + port: + number: {{ .Values.backend.service.port }} + - match: + - uri: + prefix: / + route: + - destination: + host: {{ include "finance-manager.fullname" . }}-frontend + port: + number: {{ .Values.frontend.service.port }} +{{- end }} \ No newline at end of file diff --git a/k8s/finance-manager/values.yaml b/k8s/finance-manager/values.yaml new file mode 100644 index 0000000..00d75f8 --- /dev/null +++ b/k8s/finance-manager/values.yaml @@ -0,0 +1,102 @@ +# Default values for finance-manager. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} +podSecurityContext: {} +securityContext: {} + +backend: + replicaCount: 1 + image: + repository: ghcr.io/teomarcdhio/finance-manager-backend + pullPolicy: Always + tag: "latest" + service: + type: ClusterIP + port: 8000 + targetPort: 8000 + ingress: + enabled: false + resources: {} + env: + POSTGRES_SERVER: "postgres-rw.postgres.svc.cluster.local" + POSTGRES_USER: "finance-manager" + POSTGRES_DB: "finance-manager" + secret: + name: "finance-db-secret" + key: "postgres-password" + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 60 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 60 + periodSeconds: 10 + +frontend: + replicaCount: 1 + image: + repository: ghcr.io/teomarcdhio/finance-manager-frontend + pullPolicy: Always + tag: "latest" + service: + type: ClusterIP + port: 3000 + targetPort: 3000 + ingress: + enabled: true + className: "" + annotations: {} + hosts: + - host: finance.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + resources: {} + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 30 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 30 + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + +istio: + enabled: true + injection: enabled + gateway: + name: finance-manager-gateway + selector: + istio: ingress-gateway + hosts: + - "finance-manager.internal.nivetek.com" + virtualService: + name: finance-manager + hosts: + - "finance-manager.internal.nivetek.com"