diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index ac9e5fd..a3735eb 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/src/cartsnitch_api/auth/dependencies.py @@ -5,8 +5,6 @@ Sessions are verified by querying the shared sessions table directly. """ from datetime import UTC, datetime -from hashlib import sha256 -from uuid import UUID from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -28,17 +26,19 @@ SESSION_COOKIE_NAMES = [ ] -async def _validate_session_token(token: str, db: AsyncSession) -> UUID: +async def _validate_session_token(token: str, db: AsyncSession) -> str: """Validate a Better-Auth session token against the sessions table. - Returns the user_id (as UUID) if the session is valid and not expired. - Better-Auth v1.5.6+ stores tokens as SHA-256 hashes, so we hash the - incoming raw token before querying. + Returns the user_id (as str) if the session is valid and not expired. + Better-Auth v1.5.6 stores raw tokens in the DB. The session cookie + is signed: ``rawToken.base64HMACSignature``. Strip the signature + before querying. """ - hashed_token = sha256(token.encode("utf-8")).hexdigest() + # Signed cookie format: rawToken.hmacSignature — split and use only the token part + raw_token = token.split(".")[0] if "." in token else token result = await db.execute( text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), - {"token": hashed_token}, + {"token": raw_token}, ) row = result.first() @@ -59,14 +59,14 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID: detail="Session expired", ) - return UUID(str(user_id)) + return str(user_id) async def get_current_user( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), db: AsyncSession = Depends(get_db), -) -> UUID: +) -> str: """Extract and validate the session token from cookie or Authorization header. Checks in order: diff --git a/api/src/cartsnitch_api/auth/jwt.py b/api/src/cartsnitch_api/auth/jwt.py index 100c77b..4e127bc 100644 --- a/api/src/cartsnitch_api/auth/jwt.py +++ b/api/src/cartsnitch_api/auth/jwt.py @@ -2,22 +2,21 @@ from datetime import UTC, datetime, timedelta from typing import Any, cast -from uuid import UUID from jose import JWTError, jwt from cartsnitch_api.config import settings -def create_access_token(user_id: UUID) -> str: +def create_access_token(user_id: str) -> str: expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes) - payload = {"sub": str(user_id), "exp": expire, "type": "access"} + payload = {"sub": user_id, "exp": expire, "type": "access"} return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)) -def create_refresh_token(user_id: UUID) -> str: +def create_refresh_token(user_id: str) -> str: expire = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days) - payload = {"sub": str(user_id), "exp": expire, "type": "refresh"} + payload = {"sub": user_id, "exp": expire, "type": "refresh"} return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)) diff --git a/api/src/cartsnitch_api/auth/routes.py b/api/src/cartsnitch_api/auth/routes.py index 81cae2f..2c547a4 100644 --- a/api/src/cartsnitch_api/auth/routes.py +++ b/api/src/cartsnitch_api/auth/routes.py @@ -5,8 +5,6 @@ the Better-Auth service (auth/). This router provides user profile endpoints that query our own user data from the shared database. """ -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -23,7 +21,7 @@ router = APIRouter(prefix="/auth", tags=["auth"]) @router.get("/me", response_model=UserResponse) async def get_me( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AuthService(db) @@ -38,7 +36,7 @@ async def get_me( @router.patch("/me", response_model=UserResponse) async def update_me( body: UpdateUserRequest, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AuthService(db) @@ -54,7 +52,7 @@ async def update_me( @router.delete("/me", status_code=status.HTTP_204_NO_CONTENT) async def delete_me( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AuthService(db) diff --git a/api/src/cartsnitch_api/routes/alerts.py b/api/src/cartsnitch_api/routes/alerts.py index 45ab33f..9b3fe8f 100644 --- a/api/src/cartsnitch_api/routes/alerts.py +++ b/api/src/cartsnitch_api/routes/alerts.py @@ -1,7 +1,5 @@ """Alert routes: list alerts, manage settings.""" -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +13,7 @@ router = APIRouter(prefix="/alerts", tags=["alerts"]) @router.get("", response_model=list[AlertResponse]) async def list_alerts( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AlertService(db) @@ -24,7 +22,7 @@ async def list_alerts( @router.get("/settings", response_model=AlertSettingsResponse) async def get_alert_settings( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AlertService(db) @@ -34,7 +32,7 @@ async def get_alert_settings( @router.put("/settings") async def update_alert_settings( body: AlertSettingsRequest, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): raise HTTPException( diff --git a/api/src/cartsnitch_api/routes/coupons.py b/api/src/cartsnitch_api/routes/coupons.py index d33d98a..9e43fbc 100644 --- a/api/src/cartsnitch_api/routes/coupons.py +++ b/api/src/cartsnitch_api/routes/coupons.py @@ -16,7 +16,7 @@ router = APIRouter(prefix="/coupons", tags=["coupons"]) @router.get("", response_model=list[CouponResponse]) async def list_coupons( store_id: UUID | None = Query(None), - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = CouponService(db) @@ -25,7 +25,7 @@ async def list_coupons( @router.get("/relevant", response_model=list[CouponResponse]) async def relevant_coupons( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = CouponService(db) diff --git a/api/src/cartsnitch_api/routes/prices.py b/api/src/cartsnitch_api/routes/prices.py index 487dd92..c39a1ce 100644 --- a/api/src/cartsnitch_api/routes/prices.py +++ b/api/src/cartsnitch_api/routes/prices.py @@ -20,7 +20,7 @@ router = APIRouter(prefix="/prices", tags=["prices"]) @router.get("/trends", response_model=list[PriceTrendResponse]) async def price_trends( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), category: str | None = Query(None), db: AsyncSession = Depends(get_db), ): @@ -30,7 +30,7 @@ async def price_trends( @router.get("/increases", response_model=list[PriceIncreaseResponse]) async def price_increases( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PriceService(db) @@ -40,7 +40,7 @@ async def price_increases( @router.get("/comparison", response_model=list[PriceComparisonResponse]) async def price_comparison( product_ids: Annotated[list[UUID], Query()], - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PriceService(db) diff --git a/api/src/cartsnitch_api/routes/products.py b/api/src/cartsnitch_api/routes/products.py index 473cefe..84205e8 100644 --- a/api/src/cartsnitch_api/routes/products.py +++ b/api/src/cartsnitch_api/routes/products.py @@ -15,7 +15,7 @@ router = APIRouter(prefix="/products", tags=["products"]) @router.get("", response_model=list[ProductResponse]) async def list_products( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), q: str | None = Query(None), category: str | None = Query(None), page: int = Query(1, ge=1), @@ -29,7 +29,7 @@ async def list_products( @router.get("/{product_id}", response_model=ProductDetailResponse) async def get_product( product_id: UUID, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = ProductService(db) @@ -44,7 +44,7 @@ async def get_product( @router.get("/{product_id}/prices", response_model=PriceTrendResponse) async def get_product_prices( product_id: UUID, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = ProductService(db) diff --git a/api/src/cartsnitch_api/routes/purchases.py b/api/src/cartsnitch_api/routes/purchases.py index eba86ac..a337c8e 100644 --- a/api/src/cartsnitch_api/routes/purchases.py +++ b/api/src/cartsnitch_api/routes/purchases.py @@ -15,7 +15,7 @@ router = APIRouter(prefix="/purchases", tags=["purchases"]) @router.get("", response_model=list[PurchaseResponse]) async def list_purchases( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), store_id: UUID | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), @@ -27,7 +27,7 @@ async def list_purchases( @router.get("/stats", response_model=PurchaseStatsResponse) async def purchase_stats( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PurchaseService(db) @@ -37,7 +37,7 @@ async def purchase_stats( @router.get("/{purchase_id}", response_model=PurchaseDetailResponse) async def get_purchase( purchase_id: UUID, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PurchaseService(db) diff --git a/api/src/cartsnitch_api/routes/scraping.py b/api/src/cartsnitch_api/routes/scraping.py index d8bbd5f..2804212 100644 --- a/api/src/cartsnitch_api/routes/scraping.py +++ b/api/src/cartsnitch_api/routes/scraping.py @@ -1,7 +1,5 @@ """Scraping routes: trigger sync, check status (proxy to ReceiptWitness).""" -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status from httpx import HTTPStatusError, RequestError @@ -13,7 +11,7 @@ router = APIRouter(prefix="/scraping", tags=["scraping"]) @router.post("/{store_slug}/sync", response_model=SyncTriggerResponse) -async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user)): +async def trigger_sync(store_slug: str, user_id: str = Depends(get_current_user)): client = ReceiptWitnessClient() try: result = await client.trigger_sync(str(user_id), store_slug) @@ -31,7 +29,7 @@ async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user @router.get("/status", response_model=list[SyncStatusResponse]) -async def sync_status(user_id: UUID = Depends(get_current_user)): +async def sync_status(user_id: str = Depends(get_current_user)): client = ReceiptWitnessClient() try: return await client.get_sync_status(str(user_id)) diff --git a/api/src/cartsnitch_api/routes/shopping.py b/api/src/cartsnitch_api/routes/shopping.py index c64d5fd..f7c3d0e 100644 --- a/api/src/cartsnitch_api/routes/shopping.py +++ b/api/src/cartsnitch_api/routes/shopping.py @@ -1,7 +1,5 @@ """Shopping routes: optimize list, saved lists.""" -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status from httpx import HTTPStatusError, RequestError @@ -13,7 +11,7 @@ router = APIRouter(prefix="/shopping", tags=["shopping"]) @router.post("/optimize", response_model=OptimizeResponse) -async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_current_user)): +async def optimize_shopping(body: OptimizeRequest, user_id: str = Depends(get_current_user)): client = ClipArtistClient() try: result = await client.optimize( @@ -37,7 +35,7 @@ async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_c @router.get("/lists", response_model=list[ShoppingListResponse]) -async def list_shopping_lists(user_id: UUID = Depends(get_current_user)): +async def list_shopping_lists(user_id: str = Depends(get_current_user)): client = ClipArtistClient() try: return await client.get_shopping_lists(str(user_id)) diff --git a/api/src/cartsnitch_api/routes/stores.py b/api/src/cartsnitch_api/routes/stores.py index 1ab7947..1525933 100644 --- a/api/src/cartsnitch_api/routes/stores.py +++ b/api/src/cartsnitch_api/routes/stores.py @@ -1,7 +1,5 @@ """Store routes: list stores, manage user store connections.""" -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -21,7 +19,7 @@ async def list_stores(db: AsyncSession = Depends(get_db)): @router.get("/me/stores", response_model=list[StoreAccountResponse]) async def list_user_stores( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = StoreService(db) @@ -36,7 +34,7 @@ async def list_user_stores( async def connect_store( store_slug: str, body: ConnectStoreRequest, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = StoreService(db) @@ -51,7 +49,7 @@ async def connect_store( @router.delete("/me/stores/{store_slug}", status_code=status.HTTP_204_NO_CONTENT) async def disconnect_store( store_slug: str, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = StoreService(db) diff --git a/api/src/cartsnitch_api/services/alerts.py b/api/src/cartsnitch_api/services/alerts.py index fc3ddd4..cc03d60 100644 --- a/api/src/cartsnitch_api/services/alerts.py +++ b/api/src/cartsnitch_api/services/alerts.py @@ -4,8 +4,6 @@ Alerts are generated by StickerShock and ShrinkRay services and written to the D This service reads them for the API gateway. """ -from uuid import UUID - from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -15,7 +13,7 @@ class AlertService: def __init__(self, db: AsyncSession) -> None: self.db = db - async def list_alerts(self, user_id: UUID) -> list[dict]: + async def list_alerts(self, user_id: str) -> list[dict]: """List shrinkflation events for products the user has purchased.""" from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent @@ -57,7 +55,7 @@ class AlertService: for e in events ] - async def get_settings(self, user_id: UUID) -> dict: + async def get_settings(self, user_id: str) -> dict: # Alert settings would be stored in a user_settings table. # For now, return defaults since the table doesn't exist yet in common lib. return { @@ -66,7 +64,7 @@ class AlertService: "email_notifications": False, } - async def update_settings(self, user_id: UUID, **fields) -> dict: + async def update_settings(self, user_id: str, **fields) -> dict: # Would update user_settings table. Return merged defaults for now. current = await self.get_settings(user_id) for k, v in fields.items(): diff --git a/api/src/cartsnitch_api/services/auth.py b/api/src/cartsnitch_api/services/auth.py index 91724af..4894150 100644 --- a/api/src/cartsnitch_api/services/auth.py +++ b/api/src/cartsnitch_api/services/auth.py @@ -5,8 +5,6 @@ handled by the Better-Auth service (auth/). This service provides user lookup and profile update operations for the API gateway. """ -from uuid import UUID - from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +13,7 @@ class AuthService: def __init__(self, db: AsyncSession) -> None: self.db = db - async def get_user(self, user_id: UUID) -> dict: + async def get_user(self, user_id: str) -> dict: from cartsnitch_api.models import User result = await self.db.execute(select(User).where(User.id == user_id)) @@ -30,7 +28,7 @@ class AuthService: "created_at": user.created_at, } - async def update_user(self, user_id: UUID, **fields) -> dict: + async def update_user(self, user_id: str, **fields) -> dict: from cartsnitch_api.models import User result = await self.db.execute(select(User).where(User.id == user_id)) @@ -58,7 +56,7 @@ class AuthService: "created_at": user.created_at, } - async def delete_user(self, user_id: UUID) -> None: + async def delete_user(self, user_id: str) -> None: from cartsnitch_api.models import User result = await self.db.execute(select(User).where(User.id == user_id)) diff --git a/api/src/cartsnitch_api/services/coupons.py b/api/src/cartsnitch_api/services/coupons.py index 9b1543e..a5b8a2c 100644 --- a/api/src/cartsnitch_api/services/coupons.py +++ b/api/src/cartsnitch_api/services/coupons.py @@ -29,7 +29,7 @@ class CouponService: coupons = result.scalars().all() return [self._to_dict(c) for c in coupons] - async def relevant_coupons(self, user_id: UUID) -> list[dict]: + async def relevant_coupons(self, user_id: str) -> list[dict]: """Coupons for products the user has purchased.""" from cartsnitch_api.models import Coupon, PurchaseItem diff --git a/api/src/cartsnitch_api/services/purchases.py b/api/src/cartsnitch_api/services/purchases.py index 41776f4..10ca0a4 100644 --- a/api/src/cartsnitch_api/services/purchases.py +++ b/api/src/cartsnitch_api/services/purchases.py @@ -13,7 +13,7 @@ class PurchaseService: async def list_purchases( self, - user_id: UUID, + user_id: str, store_id: UUID | None = None, page: int = 1, page_size: int = 20, @@ -56,7 +56,7 @@ class PurchaseService: for p, item_count, store_name in result.all() ] - async def get_purchase(self, purchase_id: UUID, user_id: UUID) -> dict: + async def get_purchase(self, purchase_id: UUID, user_id: str) -> dict: from cartsnitch_api.models import Purchase result = await self.db.execute( @@ -88,7 +88,7 @@ class PurchaseService: ], } - async def get_stats(self, user_id: UUID) -> dict: + async def get_stats(self, user_id: str) -> dict: from cartsnitch_api.models import Purchase result = await self.db.execute( diff --git a/api/src/cartsnitch_api/services/stores.py b/api/src/cartsnitch_api/services/stores.py index 610f47e..c7d43ec 100644 --- a/api/src/cartsnitch_api/services/stores.py +++ b/api/src/cartsnitch_api/services/stores.py @@ -1,7 +1,6 @@ """Store service — list stores, manage user store account connections.""" import json -from uuid import UUID from cryptography.fernet import Fernet from sqlalchemy import select @@ -35,7 +34,7 @@ class StoreService: for s in stores ] - async def list_user_stores(self, user_id: UUID) -> list[dict]: + async def list_user_stores(self, user_id: str) -> list[dict]: from cartsnitch_api.models import UserStoreAccount result = await self.db.execute( @@ -60,7 +59,7 @@ class StoreService: for a in accounts ] - async def connect_store(self, user_id: UUID, store_slug: str, credentials: dict | None) -> dict: + async def connect_store(self, user_id: str, store_slug: str, credentials: dict | None) -> dict: from cartsnitch_api.models import Store, UserStoreAccount result = await self.db.execute(select(Store).where(Store.slug == store_slug)) @@ -107,7 +106,7 @@ class StoreService: "sync_status": "active", } - async def disconnect_store(self, user_id: UUID, store_slug: str) -> None: + async def disconnect_store(self, user_id: str, store_slug: str) -> None: from cartsnitch_api.models import Store, UserStoreAccount result = await self.db.execute(select(Store).where(Store.slug == store_slug)) diff --git a/scripts/seed-dev-job.yaml b/scripts/seed-dev-job.yaml new file mode 100644 index 0000000..2d5cc86 --- /dev/null +++ b/scripts/seed-dev-job.yaml @@ -0,0 +1,61 @@ +# seed-dev-job.yaml +# K8s Job to run the CartSnitch seed runner against the dev database. +# +# Usage: +# kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev +# +# To view logs: +# kubectl logs -n cartsnitch-dev job/seed-dev -f +# +# To re-run after fixing issues: +# kubectl delete -f seed-dev-job.yaml -n cartsnitch-dev && kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev +# +apiVersion: batch/v1 +kind: Job +metadata: + name: seed-dev + namespace: cartsnitch-dev + labels: + app: cartsnitch + component: seed + environment: dev + annotations: + description: "Runs cartsnitch-common seed runner to populate dev database with realistic test data." +spec: + # Prevent retries — a failed seed run should be investigated, not auto-repeated. + backoffLimit: 0 + # Do not run concurrently; sequential runs are safer for truncate+reseed. + concurrencyPolicy: Forbid + template: + metadata: + labels: + app: cartsnitch + component: seed + environment: dev + spec: + restartPolicy: Never + containers: + - name: seed + # Use slim Python image with the cartsnitch-common package installed from git. + # The common repo is public; no additional secret is needed for the pip install. + image: python:3.12-slim + command: + - sh + - -c + - | + pip install --no-cache-dir "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@main" && \ + python -m cartsnitch_common.seed --database-url "$${DATABASE_URL}" + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: cartsnitch-secrets + key: database-url-pg + optional: false + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/scripts/seed-dev.sh b/scripts/seed-dev.sh new file mode 100755 index 0000000..a478015 --- /dev/null +++ b/scripts/seed-dev.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# ============================================================================= +# seed-dev.sh — Run the CartSnitch seed runner against the dev database. +# +# Usage: +# ./seed-dev.sh Run full seed against dev +# ./seed-dev.sh --dry-run Show planned record counts without writing +# ./seed-dev.sh --help Show this help +# +# Prerequisites: +# - kubectl configured for the cartsnitch-dev cluster +# - Namespace cartsnitch-dev exists (CNPG Postgres must be running) +# +# What it does: +# 1. Starts a background port-forward to cartsnitch-pg-rw:5432 +# 2. Waits for the tunnel to be ready +# 3. Runs python -m cartsnitch_common.seed with --database-url pointing +# to localhost:/cartsnitch +# 4. Cleans up the port-forward on exit (normal, interrupt, or error) +# ============================================================================= + +set -euo pipefail + +# --- Config ------------------------------------------------------------------- +readonly NAMESPACE="cartsnitch-dev" +readonly SVC_NAME="cartsnitch-pg-rw" +readonly LOCAL_PORT="5433" # use a non-privileged port to avoid conflicts +readonly DB_NAME="cartsnitch" +readonly PG_USER="cartsnitch" +# Retrieve password from the CNPG credentials secret +readonly PG_PASSWORD="$( + kubectl get secret cartsnitch-pg-credentials \ + -n "$NAMESPACE" \ + -o jsonpath='{.data.password}' \ + | base64 -d +)" +readonly DB_URL="postgresql://${PG_USER}:${PG_PASSWORD}@localhost:${LOCAL_PORT}/${DB_NAME}" + +# --- Helpers ------------------------------------------------------------------ +log() { echo "[seed-dev] $*"; } +fail() { log "ERROR: $*" >&2; exit 1; } + +# Cleanup port-forward and exit. +cleanup() { + if [[ -n "${PF_PID:-}" ]]; then + log "Stopping port-forward (PID $PF_PID)..." + kill "$PF_PID" 2>/dev/null || true + wait "$PF_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# --- Args --------------------------------------------------------------------- +DRY_RUN="" +HELP_FLAG="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN="--dry-run"; shift ;; + --help) HELP_FLAG="1"; shift ;; + *) fail "Unknown argument: $1";; + esac +done + +if [[ -n "$HELP_FLAG" ]]; then + sed -n '3,/^# ---/p' "$0" | head -n -1 | sed 's/^# //' + echo "" + echo "Additional arguments are passed through to the seed runner." + echo "Common seed-runner options:" + echo " --dry-run Show planned record counts without writing" + echo " --seed N Set random seed (default: 42)" + exit 0 +fi + +# --- Prerequisites ------------------------------------------------------------ +if ! command -v kubectl &>/dev/null; then + fail "kubectl not found — must be installed and configured." +fi + +# --- Port-forward ------------------------------------------------------------- +log "Starting port-forward ${SVC_NAME}:5432 -> localhost:${LOCAL_PORT} ..." +kubectl port-forward \ + -n "$NAMESPACE" \ + svc/"$SVC_NAME" \ + "${LOCAL_PORT}:5432" \ + &>/dev/null & +PF_PID=$! + +# Give the tunnel a moment to establish +sleep 2 + +# Verify the tunnel is up +if ! kill -0 "$PF_PID" 2>/dev/null; then + fail "Port-forward failed to start." +fi +log "Port-forward active (PID $PF_PID) on localhost:${LOCAL_PORT}" + +# --- Seed -------------------------------------------------------------------- +log "Running seed against dev database..." +set -x +python -m cartsnitch_common.seed --database-url "$DB_URL" $DRY_RUN +set +x + +log "Done."