From aa857bc2aed39c59d5f256b7df57692feca6f5d3 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Fri, 31 Jul 2026 23:27:01 -0500 Subject: [PATCH] fase 1 --- .gitea/workflows/build-medusa.yaml | 117 ++++ workloads/commerce-backend/.dockerignore | 10 + workloads/commerce-backend/.npmrc | 3 + workloads/commerce-backend/Dockerfile | 39 ++ workloads/commerce-backend/README.md | 20 + workloads/commerce-backend/medusa-config.ts | 66 ++ workloads/commerce-backend/package.json | 32 + workloads/commerce-backend/src/.gitkeep | 0 workloads/commerce-backend/tsconfig.json | 35 ++ workloads/ecommerce/KUSTOMIZATION-PHASE1.md | 23 + workloads/ecommerce/commerce/configmap.yaml | 21 + .../ecommerce/commerce/ingress-commerce.yaml | 30 + .../ecommerce/commerce/kustomization.yaml | 11 + workloads/ecommerce/commerce/medusa.yaml | 90 +++ .../commerce/minio-bootstrap-job.yaml | 54 ++ workloads/ecommerce/commerce/minio.yaml | 92 +++ workloads/ecommerce/commerce/postgres.yaml | 94 +++ workloads/ecommerce/commerce/redis.yaml | 91 +++ .../ecommerce/commerce/secrets.template.yaml | 32 + .../lib/headless/providers/medusa.ts | 568 +++++++++++++++++- workloads/ecommerce/next.config.ts | 20 +- .../patches/frontend-medusa-env.yaml | 21 + 22 files changed, 1450 insertions(+), 19 deletions(-) create mode 100644 .gitea/workflows/build-medusa.yaml create mode 100644 workloads/commerce-backend/.dockerignore create mode 100644 workloads/commerce-backend/.npmrc create mode 100644 workloads/commerce-backend/Dockerfile create mode 100644 workloads/commerce-backend/README.md create mode 100644 workloads/commerce-backend/medusa-config.ts create mode 100644 workloads/commerce-backend/package.json create mode 100644 workloads/commerce-backend/src/.gitkeep create mode 100644 workloads/commerce-backend/tsconfig.json create mode 100644 workloads/ecommerce/KUSTOMIZATION-PHASE1.md create mode 100644 workloads/ecommerce/commerce/configmap.yaml create mode 100644 workloads/ecommerce/commerce/ingress-commerce.yaml create mode 100644 workloads/ecommerce/commerce/kustomization.yaml create mode 100644 workloads/ecommerce/commerce/medusa.yaml create mode 100644 workloads/ecommerce/commerce/minio-bootstrap-job.yaml create mode 100644 workloads/ecommerce/commerce/minio.yaml create mode 100644 workloads/ecommerce/commerce/postgres.yaml create mode 100644 workloads/ecommerce/commerce/redis.yaml create mode 100644 workloads/ecommerce/commerce/secrets.template.yaml create mode 100644 workloads/ecommerce/patches/frontend-medusa-env.yaml diff --git a/.gitea/workflows/build-medusa.yaml b/.gitea/workflows/build-medusa.yaml new file mode 100644 index 0000000..d268761 --- /dev/null +++ b/.gitea/workflows/build-medusa.yaml @@ -0,0 +1,117 @@ +name: Build and Push Medusa + +on: + push: + branches: + - main + paths: + - 'workloads/commerce-backend/**' + - '.gitea/workflows/build-medusa.yaml' + +permissions: + contents: write + packages: write + +jobs: + build: + name: Construir y publicar Medusa + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout del código + uses: actions/checkout@v3 + with: + fetch-depth: 1 + persist-credentials: true + + - name: Definir versión + id: vars + shell: bash + run: | + set -euo pipefail + echo "VERSION=v1.0.${{ github.run_number }}" >> "$GITHUB_OUTPUT" + + - name: Validar secretos del Registry + shell: bash + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + test -n "${REGISTRY_USER}" || { + echo "ERROR: REGISTRY_USER no está configurado." + exit 1 + } + test -n "${REGISTRY_PASSWORD}" || { + echo "ERROR: REGISTRY_PASSWORD no está configurado." + exit 1 + } + + - name: Login en Gitea Registry + uses: docker/login-action@v2 + with: + registry: gitea.cruzcloud.net + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + logout: true + + - name: Construir y subir imagen + uses: docker/build-push-action@v4 + with: + context: workloads/commerce-backend/ + file: workloads/commerce-backend/Dockerfile + push: true + tags: | + gitea.cruzcloud.net/devops/ecommerce-medusa:${{ steps.vars.outputs.VERSION }} + gitea.cruzcloud.net/devops/ecommerce-medusa:latest + + - name: Verificar promoción segura + id: promotion + shell: bash + run: | + set -euo pipefail + + git fetch origin main + + CURRENT_SHA="${{ github.sha }}" + REMOTE_SHA="$(git rev-parse origin/main)" + + if [ "${CURRENT_SHA}" = "${REMOTE_SHA}" ]; then + echo "promote=true" >> "$GITHUB_OUTPUT" + else + echo "promote=false" >> "$GITHUB_OUTPUT" + echo "Hay un commit más reciente; no se actualizará el manifiesto." + fi + + - name: Actualizar manifiesto Medusa + if: steps.promotion.outputs.promote == 'true' + shell: bash + run: | + set -euo pipefail + + VERSION="${{ steps.vars.outputs.VERSION }}" + MANIFEST="workloads/ecommerce/commerce/medusa.yaml" + IMAGE="gitea.cruzcloud.net/devops/ecommerce-medusa" + + git config user.name "gitea-actions" + git config user.email "gitea-actions@cruzcloud.net" + + git fetch origin main + git checkout -B main origin/main + + sed -i -E \ + "s|(${IMAGE}:)v[0-9]+\.[0-9]+\.[0-9]+|\1${VERSION}|g" \ + "${MANIFEST}" + + git add "${MANIFEST}" + + if git diff --cached --quiet; then + echo "El manifiesto ya apunta a ${VERSION}." + exit 0 + fi + + git commit \ + -m "chore(gitops): deploy Medusa ${VERSION} [skip ci]" + + git push origin HEAD:main diff --git a/workloads/commerce-backend/.dockerignore b/workloads/commerce-backend/.dockerignore new file mode 100644 index 0000000..6a9c1b9 --- /dev/null +++ b/workloads/commerce-backend/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.medusa +.cache +.git +.env +.env.* +npm-debug.log* +README.md +data +migration diff --git a/workloads/commerce-backend/.npmrc b/workloads/commerce-backend/.npmrc new file mode 100644 index 0000000..f6bb561 --- /dev/null +++ b/workloads/commerce-backend/.npmrc @@ -0,0 +1,3 @@ +fund=false +audit=false +legacy-peer-deps=true diff --git a/workloads/commerce-backend/Dockerfile b/workloads/commerce-backend/Dockerfile new file mode 100644 index 0000000..29be0bf --- /dev/null +++ b/workloads/commerce-backend/Dockerfile @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22.18.0-bookworm-slim AS builder + +WORKDIR /app + +ENV CI=true +ENV NODE_ENV=development +ENV NPM_CONFIG_UPDATE_NOTIFIER=false + +COPY package.json .npmrc ./ +RUN --mount=type=cache,target=/root/.npm,sharing=locked \ + npm install --no-audit --no-fund + +COPY tsconfig.json medusa-config.ts ./ +COPY src ./src + +RUN npm run build + +FROM node:22.18.0-bookworm-slim AS runtime + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=9000 +ENV NPM_CONFIG_UPDATE_NOTIFIER=false + +COPY --from=builder /app/.medusa/server/package.json ./ +RUN --mount=type=cache,target=/root/.npm,sharing=locked \ + npm install --omit=dev --no-audit --no-fund + +COPY --from=builder /app/.medusa/server ./ + +RUN chown -R node:node /app +USER node + +EXPOSE 9000 + +CMD ["npx", "medusa", "start"] diff --git a/workloads/commerce-backend/README.md b/workloads/commerce-backend/README.md new file mode 100644 index 0000000..82f1c28 --- /dev/null +++ b/workloads/commerce-backend/README.md @@ -0,0 +1,20 @@ +# ARI Shopping Commerce Backend + +Backend Medusa v2.18 para la Fase 1. + +## Capacidades iniciales + +- Medusa Admin en `/app`. +- Catálogo, variantes y precios. +- Imágenes en almacenamiento S3 compatible. +- PostgreSQL y Redis externos. +- Modo `shared` para una sola réplica durante la Fase 1. +- Migraciones ejecutadas por el `initContainer` de Kubernetes. + +La imagen se construye mediante: + +```text +.gitea/workflows/build-medusa.yaml +``` + +No se deben colocar secretos en este directorio. diff --git a/workloads/commerce-backend/medusa-config.ts b/workloads/commerce-backend/medusa-config.ts new file mode 100644 index 0000000..a7b8766 --- /dev/null +++ b/workloads/commerce-backend/medusa-config.ts @@ -0,0 +1,66 @@ +import { defineConfig, loadEnv } from "@medusajs/framework/utils" + +loadEnv(process.env.NODE_ENV || "development", process.cwd()) + +const required = (name: string): string => { + const value = process.env[name] + if (!value) { + throw new Error(`La variable ${name} es obligatoria.`) + } + return value +} + +module.exports = defineConfig({ + projectConfig: { + databaseUrl: required("DATABASE_URL"), + redisUrl: required("REDIS_URL"), + workerMode: (process.env.MEDUSA_WORKER_MODE || "shared") as + | "shared" + | "worker" + | "server", + http: { + storeCors: required("STORE_CORS"), + adminCors: required("ADMIN_CORS"), + authCors: required("AUTH_CORS"), + jwtSecret: required("JWT_SECRET"), + cookieSecret: required("COOKIE_SECRET"), + }, + }, + + admin: { + disable: process.env.DISABLE_MEDUSA_ADMIN === "true", + backendUrl: + process.env.MEDUSA_BACKEND_URL || + "https://commerce.cruzcloud.net", + storefrontUrl: + process.env.STOREFRONT_URL || + "https://shop.cruzcloud.net", + }, + + modules: [ + { + resolve: "@medusajs/medusa/file", + options: { + providers: [ + { + resolve: "@medusajs/medusa/file-s3", + id: "s3", + options: { + file_url: required("S3_FILE_URL"), + access_key_id: required("S3_ACCESS_KEY_ID"), + secret_access_key: required("S3_SECRET_ACCESS_KEY"), + region: process.env.S3_REGION || "us-east-1", + bucket: required("S3_BUCKET"), + endpoint: required("S3_ENDPOINT"), + cache_control: + "public, max-age=31536000, immutable", + additional_client_config: { + forcePathStyle: true, + }, + }, + }, + ], + }, + }, + ], +}) diff --git a/workloads/commerce-backend/package.json b/workloads/commerce-backend/package.json new file mode 100644 index 0000000..d23e93a --- /dev/null +++ b/workloads/commerce-backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "ari-shopping-commerce-backend", + "version": "1.0.0", + "private": true, + "description": "Backend de catálogo dinámico ARI Shopping sobre Medusa v2.", + "license": "UNLICENSED", + "scripts": { + "build": "medusa build", + "start": "medusa start", + "dev": "medusa develop", + "predeploy": "medusa db:migrate" + }, + "dependencies": { + "@medusajs/admin-sdk": "2.18.0", + "@medusajs/cli": "2.18.0", + "@medusajs/framework": "2.18.0", + "@medusajs/medusa": "2.18.0" + }, + "devDependencies": { + "@swc/core": "^1.7.28", + "@types/node": "^20.12.11", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.2.25", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.6.2", + "vite": "^5.4.14" + }, + "engines": { + "node": ">=20" + } +} diff --git a/workloads/commerce-backend/src/.gitkeep b/workloads/commerce-backend/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workloads/commerce-backend/tsconfig.json b/workloads/commerce-backend/tsconfig.json new file mode 100644 index 0000000..27bc845 --- /dev/null +++ b/workloads/commerce-backend/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES2021", + "esModuleInterop": true, + "module": "Node16", + "moduleResolution": "Node16", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "declaration": false, + "sourceMap": false, + "inlineSourceMap": true, + "outDir": "./.medusa/server", + "rootDir": "./", + "jsx": "react-jsx", + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "checkJs": false, + "strictNullChecks": true + }, + "ts-node": { + "swc": true + }, + "include": [ + "**/*", + ".medusa/types/*" + ], + "exclude": [ + "node_modules", + ".medusa/server", + ".medusa/admin", + ".cache" + ] +} diff --git a/workloads/ecommerce/KUSTOMIZATION-PHASE1.md b/workloads/ecommerce/KUSTOMIZATION-PHASE1.md new file mode 100644 index 0000000..101d8a7 --- /dev/null +++ b/workloads/ecommerce/KUSTOMIZATION-PHASE1.md @@ -0,0 +1,23 @@ +# Cambios a `workloads/ecommerce/kustomization.yaml` + +El instalador incluido agrega solamente el backend de la Fase 1: + +```yaml +resources: + - commerce/ +``` + +No activa automáticamente Medusa en el frontend. + +Después de validar el backend, agrega este patch al bloque `patches`: + +```yaml + - path: patches/frontend-medusa-env.yaml + target: + group: apps + version: v1 + kind: Deployment + name: frontend-deploy +``` + +El catálogo `mock` seguirá funcionando hasta realizar este commit de corte. diff --git a/workloads/ecommerce/commerce/configmap.yaml b/workloads/ecommerce/commerce/configmap.yaml new file mode 100644 index 0000000..1395e41 --- /dev/null +++ b/workloads/ecommerce/commerce/configmap.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: commerce-config +data: + NODE_ENV: production + PORT: "9000" + + STORE_CORS: https://shop.cruzcloud.net + ADMIN_CORS: https://commerce.cruzcloud.net + AUTH_CORS: https://commerce.cruzcloud.net,https://shop.cruzcloud.net + + MEDUSA_BACKEND_URL: https://commerce.cruzcloud.net + STOREFRONT_URL: https://shop.cruzcloud.net + MEDUSA_WORKER_MODE: shared + DISABLE_MEDUSA_ADMIN: "false" + + S3_FILE_URL: https://media.cruzcloud.net/ari-products + S3_ENDPOINT: http://minio-svc:9000 + S3_REGION: us-east-1 + S3_BUCKET: ari-products diff --git a/workloads/ecommerce/commerce/ingress-commerce.yaml b/workloads/ecommerce/commerce/ingress-commerce.yaml new file mode 100644 index 0000000..ad28a57 --- /dev/null +++ b/workloads/ecommerce/commerce/ingress-commerce.yaml @@ -0,0 +1,30 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: commerce-ingress + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web +spec: + ingressClassName: traefik + rules: + - host: commerce.cruzcloud.net + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: medusa-svc + port: + number: 9000 + + - host: media.cruzcloud.net + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: minio-svc + port: + number: 9000 diff --git a/workloads/ecommerce/commerce/kustomization.yaml b/workloads/ecommerce/commerce/kustomization.yaml new file mode 100644 index 0000000..b607179 --- /dev/null +++ b/workloads/ecommerce/commerce/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - configmap.yaml + - postgres.yaml + - redis.yaml + - minio.yaml + - minio-bootstrap-job.yaml + - medusa.yaml + - ingress-commerce.yaml diff --git a/workloads/ecommerce/commerce/medusa.yaml b/workloads/ecommerce/commerce/medusa.yaml new file mode 100644 index 0000000..5eed1c3 --- /dev/null +++ b/workloads/ecommerce/commerce/medusa.yaml @@ -0,0 +1,90 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: medusa-deploy +spec: + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app: medusa + template: + metadata: + labels: + app: medusa + spec: + imagePullSecrets: + - name: gitea-registry-secret + + initContainers: + - name: migrations + image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.0 + imagePullPolicy: IfNotPresent + command: + - npx + - medusa + - db:migrate + envFrom: + - configMapRef: + name: commerce-config + - secretRef: + name: commerce-secrets + resources: + requests: + cpu: 50m + memory: 256Mi + limits: + cpu: 500m + memory: 768Mi + + containers: + - name: medusa + image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.0 + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: commerce-config + - secretRef: + name: commerce-secrets + ports: + - name: http + containerPort: 9000 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 18 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 60 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + requests: + cpu: 150m + memory: 384Mi + limits: + cpu: 750m + memory: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: medusa-svc +spec: + selector: + app: medusa + ports: + - name: http + port: 9000 + targetPort: 9000 diff --git a/workloads/ecommerce/commerce/minio-bootstrap-job.yaml b/workloads/ecommerce/commerce/minio-bootstrap-job.yaml new file mode 100644 index 0000000..3d2cfdd --- /dev/null +++ b/workloads/ecommerce/commerce/minio-bootstrap-job.yaml @@ -0,0 +1,54 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: commerce-minio-bootstrap +spec: + backoffLimit: 12 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app: commerce-minio-bootstrap + spec: + restartPolicy: OnFailure + containers: + - name: configure + image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z + imagePullPolicy: IfNotPresent + env: + - name: MINIO_ROOT_USER + valueFrom: + secretKeyRef: + name: commerce-secrets + key: MINIO_ROOT_USER + - name: MINIO_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: commerce-secrets + key: MINIO_ROOT_PASSWORD + command: + - sh + - -c + args: + - | + set -eu + + until mc alias set local \ + http://minio-svc:9000 \ + "$MINIO_ROOT_USER" \ + "$MINIO_ROOT_PASSWORD"; do + echo "Esperando MinIO..." + sleep 5 + done + + mc mb --ignore-existing local/ari-products + mc anonymous set download local/ari-products + + echo "Bucket ari-products creado y habilitado para lectura pública." + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 128Mi diff --git a/workloads/ecommerce/commerce/minio.yaml b/workloads/ecommerce/commerce/minio.yaml new file mode 100644 index 0000000..da2b227 --- /dev/null +++ b/workloads/ecommerce/commerce/minio.yaml @@ -0,0 +1,92 @@ +apiVersion: v1 +kind: Service +metadata: + name: minio-svc +spec: + selector: + app: commerce-minio + ports: + - name: s3 + port: 9000 + targetPort: 9000 + - name: console + port: 9001 + targetPort: 9001 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: commerce-minio +spec: + serviceName: minio-svc + replicas: 1 + selector: + matchLabels: + app: commerce-minio + template: + metadata: + labels: + app: commerce-minio + spec: + terminationGracePeriodSeconds: 60 + containers: + - name: minio + image: quay.io/minio/minio:RELEASE.2025-10-15T17-29-55Z + imagePullPolicy: IfNotPresent + args: + - server + - /data + - --console-address + - ":9001" + env: + - name: MINIO_ROOT_USER + valueFrom: + secretKeyRef: + name: commerce-secrets + key: MINIO_ROOT_USER + - name: MINIO_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: commerce-secrets + key: MINIO_ROOT_PASSWORD + ports: + - name: s3 + containerPort: 9000 + - name: console + containerPort: 9001 + readinessProbe: + httpGet: + path: /minio/health/ready + port: s3 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 12 + livenessProbe: + httpGet: + path: /minio/health/live + port: s3 + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 768Mi + volumeMounts: + - name: minio-data + mountPath: /data + volumeClaimTemplates: + - metadata: + name: minio-data + spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 20Gi diff --git a/workloads/ecommerce/commerce/postgres.yaml b/workloads/ecommerce/commerce/postgres.yaml new file mode 100644 index 0000000..63ff21b --- /dev/null +++ b/workloads/ecommerce/commerce/postgres.yaml @@ -0,0 +1,94 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgres-svc +spec: + clusterIP: None + selector: + app: commerce-postgres + ports: + - name: postgres + port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: commerce-postgres +spec: + serviceName: postgres-svc + replicas: 1 + selector: + matchLabels: + app: commerce-postgres + template: + metadata: + labels: + app: commerce-postgres + spec: + terminationGracePeriodSeconds: 60 + containers: + - name: postgres + image: postgres:17.10-alpine3.24 + imagePullPolicy: IfNotPresent + ports: + - name: postgres + containerPort: 5432 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: commerce-secrets + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: commerce-secrets + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: commerce-secrets + key: POSTGRES_DB + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + readinessProbe: + exec: + command: + - sh + - -c + - pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + exec: + command: + - sh + - -c + - pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 768Mi + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 10Gi diff --git a/workloads/ecommerce/commerce/redis.yaml b/workloads/ecommerce/commerce/redis.yaml new file mode 100644 index 0000000..8046173 --- /dev/null +++ b/workloads/ecommerce/commerce/redis.yaml @@ -0,0 +1,91 @@ +apiVersion: v1 +kind: Service +metadata: + name: redis-svc +spec: + clusterIP: None + selector: + app: commerce-redis + ports: + - name: redis + port: 6379 + targetPort: 6379 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: commerce-redis +spec: + serviceName: redis-svc + replicas: 1 + selector: + matchLabels: + app: commerce-redis + template: + metadata: + labels: + app: commerce-redis + spec: + terminationGracePeriodSeconds: 30 + containers: + - name: redis + image: redis:7.4-alpine + imagePullPolicy: IfNotPresent + command: + - sh + - -c + args: + - > + exec redis-server + --appendonly yes + --appendfsync everysec + --requirepass "$REDIS_PASSWORD" + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: commerce-secrets + key: REDIS_PASSWORD + ports: + - name: redis + containerPort: 6379 + readinessProbe: + exec: + command: + - sh + - -c + - redis-cli -a "$REDIS_PASSWORD" ping | grep PONG + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + exec: + command: + - sh + - -c + - redis-cli -a "$REDIS_PASSWORD" ping | grep PONG + initialDelaySeconds: 20 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + volumeMounts: + - name: redis-data + mountPath: /data + volumeClaimTemplates: + - metadata: + name: redis-data + spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 1Gi diff --git a/workloads/ecommerce/commerce/secrets.template.yaml b/workloads/ecommerce/commerce/secrets.template.yaml new file mode 100644 index 0000000..52a8046 --- /dev/null +++ b/workloads/ecommerce/commerce/secrets.template.yaml @@ -0,0 +1,32 @@ +# NO agregar este archivo al kustomization ni subir valores reales a Gitea. +# Utiliza scripts/create-commerce-secrets.sh o reemplázalo por Sealed Secrets. + +apiVersion: v1 +kind: Secret +metadata: + name: commerce-secrets +type: Opaque +stringData: + POSTGRES_USER: medusa + POSTGRES_PASSWORD: CAMBIAR + POSTGRES_DB: medusa + DATABASE_URL: postgresql://medusa:CAMBIAR@postgres-svc:5432/medusa + + REDIS_PASSWORD: CAMBIAR + REDIS_URL: redis://:CAMBIAR@redis-svc:6379 + + MINIO_ROOT_USER: ari-minio + MINIO_ROOT_PASSWORD: CAMBIAR + S3_ACCESS_KEY_ID: ari-minio + S3_SECRET_ACCESS_KEY: CAMBIAR + + JWT_SECRET: CAMBIAR + COOKIE_SECRET: CAMBIAR +--- +apiVersion: v1 +kind: Secret +metadata: + name: commerce-storefront +type: Opaque +stringData: + MEDUSA_PUBLISHABLE_KEY: pk_CAMBIAR_DESPUES_DE_CREARLA_EN_MEDUSA diff --git a/workloads/ecommerce/lib/headless/providers/medusa.ts b/workloads/ecommerce/lib/headless/providers/medusa.ts index a327367..c0586d8 100644 --- a/workloads/ecommerce/lib/headless/providers/medusa.ts +++ b/workloads/ecommerce/lib/headless/providers/medusa.ts @@ -1,31 +1,563 @@ -import type { CatalogProvider, Product } from "@/lib/headless/types"; +import type { + CatalogProvider, + Product, + ProductFilters, + ProductVariant, +} from "@/lib/headless/types"; -async function medusaFetch(path: string): Promise { - const baseUrl = process.env.MEDUSA_BACKEND_URL; - if (!baseUrl) throw new Error("Medusa no está configurado."); - const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { - headers: process.env.MEDUSA_PUBLISHABLE_KEY - ? { "x-publishable-api-key": process.env.MEDUSA_PUBLISHABLE_KEY } +type MedusaImage = { + url?: string | null; +}; + +type MedusaTag = { + value?: string | null; +}; + +type MedusaCategory = { + name?: string | null; +}; + +type MedusaCollection = { + title?: string | null; +}; + +type MedusaCalculatedPrice = { + calculated_amount?: number | null; + original_amount?: number | null; + currency_code?: string | null; +}; + +type MedusaVariant = { + id: string; + title?: string | null; + sku?: string | null; + manage_inventory?: boolean | null; + allow_backorder?: boolean | null; + inventory_quantity?: number | null; + calculated_price?: MedusaCalculatedPrice | null; + options?: Array<{ + value?: string | null; + option?: { + title?: string | null; + } | null; + }> | null; +}; + +type MedusaProduct = { + id: string; + handle?: string | null; + title?: string | null; + subtitle?: string | null; + description?: string | null; + thumbnail?: string | null; + external_id?: string | null; + metadata?: Record | null; + images?: MedusaImage[] | null; + tags?: MedusaTag[] | null; + categories?: MedusaCategory[] | null; + collection?: MedusaCollection | null; + variants?: MedusaVariant[] | null; +}; + +type MedusaProductsResponse = { + products: MedusaProduct[]; + count?: number; + limit?: number; + offset?: number; +}; + +type LegacyMeta = { + category: string; + collection: string; + brand: string; + ageRange: string; + featured: boolean; + isNew: boolean; + fallbackImage: string; + fallbackThumbnail: string; +}; + +const legacyProductMeta: Record = { + "toy-story-5-rex": { + "category": "Figuras", + "collection": "Toy Story", + "brand": "Disney Pixar", + "ageRange": "3+", + "featured": true, + "isNew": true, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-001-toy-story-5-rex.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-001-toy-story-5-rex.webp" + }, + "toy-story-5-buzz-lightyear": { + "category": "Figuras", + "collection": "Toy Story", + "brand": "Disney Pixar", + "ageRange": "3+", + "featured": true, + "isNew": true, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-002-toy-story-5-buzz-lightyear.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp" + }, + "toy-story-5-jessie": { + "category": "Figuras", + "collection": "Toy Story", + "brand": "Disney Pixar", + "ageRange": "3+", + "featured": true, + "isNew": true, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-003-toy-story-5-jessie.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-003-toy-story-5-jessie.webp" + }, + "toy-story-5-woody": { + "category": "Figuras", + "collection": "Toy Story", + "brand": "Disney Pixar", + "ageRange": "3+", + "featured": true, + "isNew": true, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-004-toy-story-5-woody.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-004-toy-story-5-woody.webp" + }, + "intensamente-furia": { + "category": "Peluches", + "collection": "Intensamente", + "brand": "Disney", + "ageRange": "3+", + "featured": true, + "isNew": true, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-005-intensamente-furia.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-005-intensamente-furia.webp" + }, + "intensamente-tristeza": { + "category": "Peluches", + "collection": "Intensamente", + "brand": "Disney", + "ageRange": "3+", + "featured": true, + "isNew": true, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-006-intensamente-tristeza.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-006-intensamente-tristeza.webp" + }, + "minnie-y-mickey-mouse": { + "category": "Peluches", + "collection": "Mickey & Minnie", + "brand": "Disney", + "ageRange": "3+", + "featured": true, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-007-minnie-y-mickey-mouse.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp" + }, + "angel-lilo-y-stitch": { + "category": "Peluches", + "collection": "Lilo & Stitch", + "brand": "Disney", + "ageRange": "3+", + "featured": true, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-008-angel-lilo-y-stitch.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-008-angel-lilo-y-stitch.webp" + }, + "mochilas-disney-coleccion-marvel": { + "category": "Mochilas", + "collection": "Marvel", + "brand": "Marvel", + "ageRange": "6+", + "featured": false, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-009-mochilas-disney-coleccion-marvel.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp" + }, + "mochila-ligera-y-resistente-al-agua-de-disney-frozen-mochila-escolar-y-de-viaje-con-multiples-compa": { + "category": "Mochilas", + "collection": "Frozen", + "brand": "Disney", + "ageRange": "6+", + "featured": false, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp" + }, + "mochila-escolar-de-spider-man-de-disney-moda-estudiantil-libro-de-gran-capacidad-mochila-ligera": { + "category": "Mochilas", + "collection": "Marvel", + "brand": "Marvel", + "ageRange": "6+", + "featured": false, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-011-mochila-escolar-de-spider-man-de-disney-.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp" + }, + "disney-mochila-de-poliester-ultraligera-y-transpirable-de-gran-tamano-y-multiples-colores-dimension": { + "category": "Mochilas", + "collection": "Disney", + "brand": "Disney", + "ageRange": "6+", + "featured": false, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-012-disney-mochila-de-poliester-ultraligera-.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp" + }, + "mochila-pequena-bob-esponja": { + "category": "Mochilas", + "collection": "Bob Esponja", + "brand": "Disney", + "ageRange": "6+", + "featured": false, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-013-mochila-pequena-bob-esponja.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp" + }, + "mochila-casual-de-gran-capacidad-con-estampado-de-bob-esponja-unisex-bolsa-escolar-de-dibujos-anim": { + "category": "Mochilas", + "collection": "Bob Esponja", + "brand": "Disney", + "ageRange": "6+", + "featured": false, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-014-mochila-casual-de-gran-capacidad-con-est.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp" + }, + "mochila-escolar-estilo-britanico-de-disney-con-proteccion-de-la-columna-vertebral-nueva-mochila-de": { + "category": "Mochilas", + "collection": "Disney", + "brand": "Disney", + "ageRange": "6+", + "featured": false, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-015-mochila-escolar-estilo-britanico-de-disn.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp" + }, + "disney-mochila-iron-man-de-poliester-ultraligera-y-transpirable-de-gran-tamano-dimensiones-46-cm-x": { + "category": "Mochilas", + "collection": "Marvel", + "brand": "Marvel", + "ageRange": "6+", + "featured": false, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-016-disney-mochila-iron-man-de-poliester-ult.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp" + }, + "mochila-de-disney-marvel-capitan-america-de-gran-capacidad-ligera-y-resistente-a-salpicaduras-par": { + "category": "Mochilas", + "collection": "Marvel", + "brand": "Marvel", + "ageRange": "6+", + "featured": false, + "isNew": false, + "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-017-mochila-de-disney-marvel-capitan-america.webp", + "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp" + } +}; + +function backendUrl(): string { + const value = process.env.MEDUSA_BACKEND_URL; + if (!value) { + throw new Error("MEDUSA_BACKEND_URL no está configurado."); + } + return value.replace(/\/$/, ""); +} + +async function medusaFetch( + path: string, + searchParams?: URLSearchParams, +): Promise { + const url = new URL(`${backendUrl()}${path}`); + + if (searchParams) { + searchParams.forEach((value, key) => { + url.searchParams.append(key, value); + }); + } + + const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY; + + const response = await fetch(url, { + headers: publishableKey + ? { "x-publishable-api-key": publishableKey } : undefined, - next: { revalidate: 300 }, + next: { + revalidate: 60, + tags: ["medusa-products"], + }, }); - if (!response.ok) throw new Error(`Medusa respondió HTTP ${response.status}`); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `Medusa respondió HTTP ${response.status}: ${body.slice(0, 300)}`, + ); + } + return (await response.json()) as T; } -function notImplemented(): never { - throw new Error("Completa el mapeo Medusa → Product en lib/headless/providers/medusa.ts."); +function stringMetadata( + metadata: Record | null | undefined, + key: string, +): string { + const value = metadata?.[key]; + return typeof value === "string" ? value : ""; +} + +function booleanMetadata( + metadata: Record | null | undefined, + key: string, +): boolean { + const value = metadata?.[key]; + return value === true || value === "true"; +} + +function normalizeCurrency(value?: string | null): string { + return (value || "COP").toUpperCase(); +} + +function mapVariant(variant: MedusaVariant): ProductVariant { + const option = variant.options?.[0]; + + const available = + variant.allow_backorder === true || + variant.manage_inventory === false || + (variant.inventory_quantity ?? 0) > 0; + + return { + id: variant.id, + name: option?.option?.title || "Presentación", + value: option?.value || variant.title || "Estándar", + available, + }; +} + +function mapProduct(product: MedusaProduct): Product { + const slug = product.handle || product.id; + const legacy = legacyProductMeta[slug]; + + const variant = product.variants?.[0]; + const calculated = variant?.calculated_price; + + const category = + product.categories?.[0]?.name || + stringMetadata(product.metadata, "category") || + legacy?.category || + "Variedades"; + + const collection = + product.collection?.title || + stringMetadata(product.metadata, "collection") || + legacy?.collection || + ""; + + const brand = + stringMetadata(product.metadata, "brand") || + legacy?.brand || + "ARI Shopping"; + + const ageRange = + stringMetadata(product.metadata, "ageRange") || + legacy?.ageRange || + "Todas"; + + const images = [ + ...(product.images || []) + .map((image) => image.url || "") + .filter(Boolean), + ]; + + if (images.length === 0 && legacy?.fallbackImage) { + images.push(legacy.fallbackImage); + } + + const thumbnail = + product.thumbnail || + images[0] || + legacy?.fallbackThumbnail || + "/images/brand/logo-ari.png"; + + const price = + typeof calculated?.calculated_amount === "number" + ? calculated.calculated_amount + : null; + + const original = + typeof calculated?.original_amount === "number" + ? calculated.original_amount + : null; + + const compareAtPrice = + original !== null && price !== null && original > price + ? original + : null; + + const available = + !variant || + variant.allow_backorder === true || + variant.manage_inventory === false || + (variant.inventory_quantity ?? 0) > 0; + + const tags = (product.tags || []) + .map((tag) => tag.value || "") + .filter(Boolean); + + return { + id: product.external_id || product.id, + slug, + name: product.title || "Producto ARI Shopping", + shortDescription: + product.subtitle || + product.title || + "Producto ARI Shopping", + description: + product.description || + "Producto disponible para confirmación de inventario con ARI Shopping.", + category, + collection, + brand, + ageRange, + price, + compareAtPrice, + currency: normalizeCurrency(calculated?.currency_code), + priceLabel: price === null ? "Consultar disponibilidad" : "", + available, + featured: + booleanMetadata(product.metadata, "featured") || + legacy?.featured || + false, + isNew: + booleanMetadata(product.metadata, "isNew") || + legacy?.isNew || + false, + image: images[0] || thumbnail, + thumbnail, + images: images.length > 0 ? images : [thumbnail], + tags, + variants: (product.variants || []).map(mapVariant), + }; +} + +function matchesFilters( + product: Product, + filters?: ProductFilters, +): boolean { + if (!filters) return true; + + const query = filters.query?.trim().toLocaleLowerCase("es"); + if ( + query && + ![ + product.name, + product.shortDescription, + product.description, + product.category, + product.collection, + product.brand, + ...product.tags, + ] + .join(" ") + .toLocaleLowerCase("es") + .includes(query) + ) { + return false; + } + + if ( + filters.category && + filters.category !== "Todos" && + product.category !== filters.category + ) { + return false; + } + + if ( + filters.collection && + product.collection !== filters.collection + ) { + return false; + } + + if ( + filters.brand && + filters.brand !== "Todas" && + product.brand !== filters.brand + ) { + return false; + } + + if ( + filters.ageRange && + filters.ageRange !== "Todas" && + product.ageRange !== filters.ageRange + ) { + return false; + } + + if ( + typeof filters.minPrice === "number" && + product.price !== null && + product.price < filters.minPrice + ) { + return false; + } + + if ( + typeof filters.maxPrice === "number" && + product.price !== null && + product.price > filters.maxPrice + ) { + return false; + } + + return true; +} + +async function fetchProducts( + filters?: ProductFilters, + limit = 100, +): Promise { + const params = new URLSearchParams({ + limit: String(limit), + currency_code: "cop", + fields: + "+metadata,+images,+tags,+categories,+collection,+variants.inventory_quantity,+variants.calculated_price,+variants.options", + }); + + if (filters?.query) { + params.set("q", filters.query); + } + + const response = await medusaFetch( + "/store/products", + params, + ); + + return response.products + .map(mapProduct) + .filter((product) => matchesFilters(product, filters)); } export const medusaCatalogProvider: CatalogProvider = { - async getProducts() { - await medusaFetch("/store/products?limit=24"); - return notImplemented(); + async getProducts(filters) { + return fetchProducts(filters); }, - async getProductBySlug(): Promise { - return notImplemented(); + + async getProductBySlug(slug) { + const params = new URLSearchParams({ + handle: slug, + limit: "1", + currency_code: "cop", + fields: + "+metadata,+images,+tags,+categories,+collection,+variants.inventory_quantity,+variants.calculated_price,+variants.options", + }); + + const response = await medusaFetch( + "/store/products", + params, + ); + + const product = response.products[0]; + return product ? mapProduct(product) : null; }, - async searchProducts(): Promise { - return notImplemented(); + + async searchProducts(query, limit = 8) { + return fetchProducts({ query }, limit); }, }; diff --git a/workloads/ecommerce/next.config.ts b/workloads/ecommerce/next.config.ts index 9e45d01..247d8ea 100644 --- a/workloads/ecommerce/next.config.ts +++ b/workloads/ecommerce/next.config.ts @@ -10,6 +10,24 @@ const nextConfig: NextConfig = { minimumCacheTTL: 604800, deviceSizes: [360, 640, 768, 1024, 1280, 1536], imageSizes: [64, 96, 160, 256, 384, 560], + + remotePatterns: [ + { + protocol: "https", + hostname: "media.cruzcloud.net", + pathname: "/ari-products/**", + }, + { + protocol: "https", + hostname: "shop.cruzcloud.net", + pathname: "/images/products/**", + }, + { + protocol: "https", + hostname: "shop.cruzcloud.net", + pathname: "/images/thumbnails/**", + }, + ], }, async headers() { @@ -28,4 +46,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; \ No newline at end of file +export default nextConfig; diff --git a/workloads/ecommerce/patches/frontend-medusa-env.yaml b/workloads/ecommerce/patches/frontend-medusa-env.yaml new file mode 100644 index 0000000..45c9810 --- /dev/null +++ b/workloads/ecommerce/patches/frontend-medusa-env.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend-deploy +spec: + template: + spec: + containers: + - name: web + env: + - name: HEADLESS_PROVIDER + value: medusa + + - name: MEDUSA_BACKEND_URL + value: http://medusa-svc:9000 + + - name: MEDUSA_PUBLISHABLE_KEY + valueFrom: + secretKeyRef: + name: commerce-storefront + key: MEDUSA_PUBLISHABLE_KEY