From df94c17e1f757b9b19012221352fb66915fffb46 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sat, 1 Nov 2025 13:29:48 -0700 Subject: [PATCH] feat: complete MongoDB to CouchDB migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate Report model to CouchDB with embedded street/user data - Migrate UserBadge model to CouchDB with badge population - Update all remaining routes (reports, users, badges, payments) to use CouchDB - Add CouchDB health check and graceful shutdown to server.js - Add missing methods to couchdbService (checkConnection, findWithPagination, etc.) - Update Kubernetes deployment manifests for CouchDB support - Add comprehensive CouchDB setup documentation All core functionality now uses CouchDB as primary database while maintaining MongoDB for backward compatibility during transition period. 🤖 Generated with [AI Assistant] Co-Authored-By: AI Assistant --- backend/models/Report.js | 144 ++++++++++++++++++++------- backend/models/UserBadge.js | 147 +++++++++++++++++++++------- backend/routes/badges.js | 16 ++- backend/routes/payments.js | 3 +- backend/routes/reports.js | 81 ++++++++------- backend/routes/users.js | 44 +++++++-- backend/server.js | 72 ++++++++++++-- backend/services/couchdbService.js | 72 ++++++++++++++ deploy/README.md | 66 ++++++++----- deploy/k8s/backend-deployment.yaml | 11 +++ deploy/k8s/configmap.yaml | 5 +- deploy/k8s/couchdb-configmap.yaml | 23 +++++ deploy/k8s/couchdb-statefulset.yaml | 146 +++++++++++++++++++++++++++ deploy/k8s/secrets.yaml.example | 9 ++ 14 files changed, 684 insertions(+), 155 deletions(-) create mode 100644 deploy/k8s/couchdb-configmap.yaml create mode 100644 deploy/k8s/couchdb-statefulset.yaml diff --git a/backend/models/Report.js b/backend/models/Report.js index cf36875..c2d3e08 100644 --- a/backend/models/Report.js +++ b/backend/models/Report.js @@ -1,41 +1,109 @@ -const mongoose = require("mongoose"); +const couchdbService = require("../services/couchdbService"); -const ReportSchema = new mongoose.Schema( - { - street: { - type: mongoose.Schema.Types.ObjectId, - ref: "Street", - required: true, - }, - user: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - }, - issue: { - type: String, - required: true, - }, - imageUrl: { - type: String, - }, - cloudinaryPublicId: { - type: String, - }, - status: { - type: String, - enum: ["open", "resolved"], - default: "open", - }, - }, - { - timestamps: true, - }, -); +class Report { + static async create(reportData) { + const doc = { + type: "report", + ...reportData, + status: reportData.status || "open", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; -// Indexes for performance -ReportSchema.index({ street: 1, status: 1 }); -ReportSchema.index({ user: 1 }); -ReportSchema.index({ createdAt: -1 }); + return await couchdbService.createDocument(doc); + } -module.exports = mongoose.model("Report", ReportSchema); + static async findById(id) { + const doc = await couchdbService.getDocument(id); + if (doc && doc.type === "report") { + return doc; + } + return null; + } + + static async find(filter = {}) { + const selector = { + type: "report", + ...filter, + }; + + return await couchdbService.findDocuments(selector); + } + + static async findWithPagination(options = {}) { + const { page = 1, limit = 10, sort = { createdAt: -1 } } = options; + const selector = { type: "report" }; + + return await couchdbService.findWithPagination(selector, { + page, + limit, + sort, + }); + } + + static async update(id, updateData) { + const doc = await couchdbService.getDocument(id); + if (!doc || doc.type !== "report") { + throw new Error("Report not found"); + } + + const updatedDoc = { + ...doc, + ...updateData, + updatedAt: new Date().toISOString(), + }; + + return await couchdbService.updateDocument(id, updatedDoc); + } + + static async delete(id) { + const doc = await couchdbService.getDocument(id); + if (!doc || doc.type !== "report") { + throw new Error("Report not found"); + } + + return await couchdbService.deleteDocument(id, doc._rev); + } + + static async countDocuments(filter = {}) { + const selector = { + type: "report", + ...filter, + }; + + return await couchdbService.countDocuments(selector); + } + + static async findByStreet(streetId) { + const selector = { + type: "report", + "street._id": streetId, + }; + + return await couchdbService.findDocuments(selector); + } + + static async findByUser(userId) { + const selector = { + type: "report", + "user._id": userId, + }; + + return await couchdbService.findDocuments(selector); + } + + static async findByStatus(status) { + const selector = { + type: "report", + status, + }; + + return await couchdbService.findDocuments(selector); + } + + static async update(id, updateData) { + return await couchdbService.update(id, updateData); + } +} + +module.exports = Report; diff --git a/backend/models/UserBadge.js b/backend/models/UserBadge.js index adb5568..66cd43b 100644 --- a/backend/models/UserBadge.js +++ b/backend/models/UserBadge.js @@ -1,37 +1,118 @@ -const mongoose = require("mongoose"); +const couchdbService = require("../services/couchdbService"); -const UserBadgeSchema = new mongoose.Schema( - { - user: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - index: true, - }, - badge: { - type: mongoose.Schema.Types.ObjectId, - ref: "Badge", - required: true, - index: true, - }, - earnedAt: { - type: Date, - default: Date.now, - }, - progress: { - type: Number, - default: 0, - }, - }, - { - timestamps: true, - }, -); +class UserBadge { + static async create(userBadgeData) { + const doc = { + type: "user_badge", + ...userBadgeData, + earnedAt: userBadgeData.earnedAt || new Date().toISOString(), + progress: userBadgeData.progress || 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; -// Compound unique index to prevent duplicate badge awards -UserBadgeSchema.index({ user: 1, badge: 1 }, { unique: true }); + return await couchdbService.createDocument(doc); + } -// Index for user badge queries -UserBadgeSchema.index({ user: 1, earnedAt: -1 }); + static async findById(id) { + const doc = await couchdbService.getDocument(id); + if (doc && doc.type === "user_badge") { + return doc; + } + return null; + } -module.exports = mongoose.model("UserBadge", UserBadgeSchema); + static async find(filter = {}) { + const selector = { + type: "user_badge", + ...filter, + }; + + return await couchdbService.findDocuments(selector); + } + + static async findByUser(userId) { + const selector = { + type: "user_badge", + userId: userId, + }; + + const userBadges = await couchdbService.findDocuments(selector); + + // Populate badge data for each user badge + const populatedBadges = await Promise.all( + userBadges.map(async (userBadge) => { + if (userBadge.badgeId) { + const badge = await couchdbService.getDocument(userBadge.badgeId); + return { + ...userBadge, + badge: badge, + }; + } + return userBadge; + }) + ); + + return populatedBadges; + } + + static async findByBadge(badgeId) { + const selector = { + type: "user_badge", + badgeId: badgeId, + }; + + return await couchdbService.findDocuments(selector); + } + + static async update(id, updateData) { + const doc = await couchdbService.getDocument(id); + if (!doc || doc.type !== "user_badge") { + throw new Error("UserBadge not found"); + } + + const updatedDoc = { + ...doc, + ...updateData, + updatedAt: new Date().toISOString(), + }; + + return await couchdbService.updateDocument(id, updatedDoc); + } + + static async delete(id) { + const doc = await couchdbService.getDocument(id); + if (!doc || doc.type !== "user_badge") { + throw new Error("UserBadge not found"); + } + + return await couchdbService.deleteDocument(id, doc._rev); + } + + static async findByUserAndBadge(userId, badgeId) { + const selector = { + type: "user_badge", + userId: userId, + badgeId: badgeId, + }; + + const results = await couchdbService.findDocuments(selector); + return results[0] || null; + } + + static async updateProgress(userId, badgeId, progress) { + const userBadge = await this.findByUserAndBadge(userId, badgeId); + + if (userBadge) { + return await this.update(userBadge._id, { progress }); + } else { + return await this.create({ + userId, + badgeId, + progress, + }); + } + } +} + +module.exports = UserBadge; diff --git a/backend/routes/badges.js b/backend/routes/badges.js index 47778b6..5f6cc50 100644 --- a/backend/routes/badges.js +++ b/backend/routes/badges.js @@ -14,7 +14,12 @@ const router = express.Router(); router.get( "/", asyncHandler(async (req, res) => { - const badges = await Badge.find().sort({ order: 1, rarity: 1 }); + const badges = await Badge.find({ type: "badge" }); + // Sort by order and rarity in JavaScript since CouchDB doesn't support complex sorting + badges.sort((a, b) => { + if (a.order !== b.order) return a.order - b.order; + return a.rarity.localeCompare(b.rarity); + }); res.json(badges); }) ); @@ -33,7 +38,7 @@ router.get( ); /** - * GET /api/users/:userId/badges + * GET /api/badges/users/:userId * Get badges earned by a specific user */ router.get( @@ -41,9 +46,10 @@ router.get( asyncHandler(async (req, res) => { const { userId } = req.params; - const userBadges = await UserBadge.find({ user: userId }) - .populate("badge") - .sort({ earnedAt: -1 }); + const userBadges = await UserBadge.findByUser(userId); + + // Sort by earnedAt in JavaScript + userBadges.sort((a, b) => new Date(b.earnedAt) - new Date(a.earnedAt)); res.json( userBadges.map((ub) => ({ diff --git a/backend/routes/payments.js b/backend/routes/payments.js index aed6860..6807614 100644 --- a/backend/routes/payments.js +++ b/backend/routes/payments.js @@ -14,8 +14,7 @@ router.post("/subscribe", auth, async (req, res) => { return res.status(404).json({ msg: "User not found" }); } - user.isPremium = true; - await user.save(); + await User.update(req.user.id, { isPremium: true }); res.json({ msg: "Subscription successful" }); } catch (err) { diff --git a/backend/routes/reports.js b/backend/routes/reports.js index 0b8ffdf..33cacd9 100644 --- a/backend/routes/reports.js +++ b/backend/routes/reports.js @@ -1,5 +1,7 @@ const express = require("express"); const Report = require("../models/Report"); +const User = require("../models/User"); +const Street = require("../models/Street"); const auth = require("../middleware/auth"); const { asyncHandler } = require("../middleware/errorHandler"); const { @@ -15,23 +17,23 @@ const router = express.Router(); router.get( "/", asyncHandler(async (req, res) => { - const { paginate, buildPaginatedResponse } = require("../middleware/pagination"); - - // Parse pagination params const page = parseInt(req.query.page) || 1; const limit = Math.min(parseInt(req.query.limit) || 10, 100); - const skip = (page - 1) * limit; - const reports = await Report.find() - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - .populate("street", ["name"]) - .populate("user", ["name", "profilePicture"]); + const result = await Report.findWithPagination({ + page, + limit, + sort: { createdAt: -1 }, + }); - const totalCount = await Report.countDocuments(); - - res.json(buildPaginatedResponse(reports, totalCount, page, limit)); + res.json({ + reports: result.docs, + totalCount: result.totalDocs, + currentPage: result.page, + totalPages: result.totalPages, + hasNext: result.hasNextPage, + hasPrev: result.hasPrevPage, + }); }), ); @@ -43,11 +45,29 @@ router.post( handleUploadError, createReportValidation, asyncHandler(async (req, res) => { - const { street, issue } = req.body; + const { street: streetId, issue } = req.body; + + // Get street and user data for embedding + const street = await Street.findById(streetId); + if (!street) { + return res.status(404).json({ msg: "Street not found" }); + } + + const user = await User.findById(req.user.id); + if (!user) { + return res.status(404).json({ msg: "User not found" }); + } const reportData = { - street, - user: req.user.id, + street: { + _id: street._id, + name: street.name, + }, + user: { + _id: user._id, + name: user.name, + profilePicture: user.profilePicture, + }, issue, }; @@ -61,15 +81,7 @@ router.post( reportData.cloudinaryPublicId = result.publicId; } - const newReport = new Report(reportData); - const report = await newReport.save(); - - // Populate user and street data - await report.populate([ - { path: "user", select: "name profilePicture" }, - { path: "street", select: "name" }, - ]); - + const report = await Report.create(reportData); res.json(report); }), ); @@ -88,7 +100,7 @@ router.post( } // Verify user owns the report - if (report.user.toString() !== req.user.id) { + if (report.user._id !== req.user.id) { return res.status(403).json({ msg: "Not authorized" }); } @@ -107,11 +119,12 @@ router.post( "adopt-a-street/reports", ); - report.imageUrl = result.url; - report.cloudinaryPublicId = result.publicId; - await report.save(); + const updatedReport = await Report.update(req.params.id, { + imageUrl: result.url, + cloudinaryPublicId: result.publicId, + }); - res.json(report); + res.json(updatedReport); }), ); @@ -126,11 +139,11 @@ router.put( return res.status(404).json({ msg: "Report not found" }); } - report.status = "resolved"; + const updatedReport = await Report.update(req.params.id, { + status: "resolved", + }); - await report.save(); - - res.json(report); + res.json(updatedReport); }), ); diff --git a/backend/routes/users.js b/backend/routes/users.js index a922086..0aada3d 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,5 +1,6 @@ const express = require("express"); const User = require("../models/User"); +const Street = require("../models/Street"); const auth = require("../middleware/auth"); const { asyncHandler } = require("../middleware/errorHandler"); const { userIdValidation } = require("../middleware/validators/userValidator"); @@ -14,11 +15,34 @@ router.get( auth, userIdValidation, asyncHandler(async (req, res) => { - const user = await User.findById(req.params.id).populate("adoptedStreets"); + const user = await User.findById(req.params.id); if (!user) { return res.status(404).json({ msg: "User not found" }); } - res.json(user); + + // Get adopted streets data + let adoptedStreets = []; + if (user.adoptedStreets && user.adoptedStreets.length > 0) { + adoptedStreets = await Promise.all( + user.adoptedStreets.map(async (streetId) => { + const street = await Street.findById(streetId); + return street ? { + _id: street._id, + name: street.name, + location: street.location, + status: street.status, + } : null; + }) + ); + adoptedStreets = adoptedStreets.filter(Boolean); + } + + const userWithStreets = { + ...user, + adoptedStreets, + }; + + res.json(userWithStreets); }), ); @@ -50,13 +74,14 @@ router.post( ); // Update user with new profile picture - user.profilePicture = result.url; - user.cloudinaryPublicId = result.publicId; - await user.save(); + const updatedUser = await User.update(req.user.id, { + profilePicture: result.url, + cloudinaryPublicId: result.publicId, + }); res.json({ msg: "Profile picture updated successfully", - profilePicture: user.profilePicture, + profilePicture: updatedUser.profilePicture, }); }), ); @@ -79,9 +104,10 @@ router.delete( await deleteImage(user.cloudinaryPublicId); // Remove from user - user.profilePicture = undefined; - user.cloudinaryPublicId = undefined; - await user.save(); + await User.update(req.user.id, { + profilePicture: undefined, + cloudinaryPublicId: undefined, + }); res.json({ msg: "Profile picture deleted successfully" }); }), diff --git a/backend/server.js b/backend/server.js index 5de0dc9..4eedd6f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -72,7 +72,10 @@ mongoose // CouchDB (primary database) couchdbService.initialize() .then(() => console.log("CouchDB initialized")) - .catch((err) => console.log("CouchDB initialization error:", err)); + .catch((err) => { + console.log("CouchDB initialization error:", err); + process.exit(1); // Exit if CouchDB fails to initialize since it's the primary database + }); // Socket.IO Authentication Middleware io.use(socketAuth); @@ -123,13 +126,27 @@ app.use("/api/auth/login", authLimiter); app.use("/api", apiLimiter); // Health check endpoint (for Kubernetes liveness/readiness probes) -app.get("/api/health", (req, res) => { - res.status(200).json({ - status: "healthy", - timestamp: new Date().toISOString(), - uptime: process.uptime(), - mongodb: mongoose.connection.readyState === 1 ? "connected" : "disconnected", - }); +app.get("/api/health", async (req, res) => { + try { + const couchdbStatus = await couchdbService.checkConnection(); + + res.status(200).json({ + status: "healthy", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + mongodb: mongoose.connection.readyState === 1 ? "connected" : "disconnected", + couchdb: couchdbStatus ? "connected" : "disconnected", + }); + } catch (error) { + res.status(503).json({ + status: "unhealthy", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + mongodb: mongoose.connection.readyState === 1 ? "connected" : "disconnected", + couchdb: "disconnected", + error: error.message, + }); + } }); // Routes @@ -156,3 +173,42 @@ app.use(errorHandler); server.listen(port, () => { console.log(`Server running on port ${port}`); }); + +// Graceful shutdown +process.on("SIGTERM", async () => { + console.log("SIGTERM received, shutting down gracefully"); + + try { + // Close MongoDB connection + await mongoose.connection.close(); + console.log("MongoDB connection closed"); + + // Close server + server.close(() => { + console.log("Server closed"); + process.exit(0); + }); + } catch (error) { + console.error("Error during shutdown:", error); + process.exit(1); + } +}); + +process.on("SIGINT", async () => { + console.log("SIGINT received, shutting down gracefully"); + + try { + // Close MongoDB connection + await mongoose.connection.close(); + console.log("MongoDB connection closed"); + + // Close server + server.close(() => { + console.log("Server closed"); + process.exit(0); + }); + } catch (error) { + console.error("Error during shutdown:", error); + process.exit(1); + } +}); diff --git a/backend/services/couchdbService.js b/backend/services/couchdbService.js index 19e287e..02664fa 100644 --- a/backend/services/couchdbService.js +++ b/backend/services/couchdbService.js @@ -412,6 +412,22 @@ class CouchDBService { return this.isConnected; } + /** + * Check connection health + */ + async checkConnection() { + try { + if (!this.connection) { + return false; + } + await this.connection.info(); + return true; + } catch (error) { + console.error("CouchDB connection check failed:", error.message); + return false; + } + } + // Generic CRUD operations async createDocument(doc) { if (!this.isConnected) await this.initialize(); @@ -498,6 +514,62 @@ class CouchDBService { return await this.find(query); } + async findDocuments(selector = {}, options = {}) { + const query = { + selector, + ...options + }; + return await this.find(query); + } + + async countDocuments(selector = {}) { + const query = { + selector, + limit: 0, // We don't need documents, just count + }; + + try { + const response = await this.db.find(query); + return response.total_rows || 0; + } catch (error) { + console.error("Error counting documents:", error.message); + throw error; + } + } + + async findWithPagination(selector = {}, options = {}) { + const { page = 1, limit = 10, sort = {} } = options; + const skip = (page - 1) * limit; + + const query = { + selector, + limit, + skip, + sort: Object.entries(sort).map(([field, order]) => ({ + [field]: order === -1 ? "desc" : "asc" + })) + }; + + try { + const response = await this.db.find(query); + const totalCount = response.total_rows || 0; + const totalPages = Math.ceil(totalCount / limit); + + return { + docs: response.docs, + totalDocs: totalCount, + page, + limit, + totalPages, + hasNextPage: page < totalPages, + hasPrevPage: page > 1, + }; + } catch (error) { + console.error("Error finding documents with pagination:", error.message); + throw error; + } + } + // View query helper async view(designDoc, viewName, params = {}) { if (!this.isConnected) await this.initialize(); diff --git a/deploy/README.md b/deploy/README.md index 26379f7..2fc06aa 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -10,7 +10,8 @@ deploy/ │ ├── namespace.yaml # Namespace definition │ ├── configmap.yaml # Environment configuration │ ├── secrets.yaml.example # Secret template (COPY TO secrets.yaml) -│ ├── mongodb-statefulset.yaml # MongoDB StatefulSet with PVC +│ ├── couchdb-statefulset.yaml # CouchDB StatefulSet with PVC +│ ├── couchdb-configmap.yaml # CouchDB configuration │ ├── backend-deployment.yaml # Backend Deployment + Service │ ├── frontend-deployment.yaml # Frontend Deployment + Service │ └── ingress.yaml # Ingress for routing @@ -94,6 +95,19 @@ nano deploy/k8s/frontend-deployment.yaml # Change: image: your-registry/adopt-a-street-frontend:latest ``` +### 4. Configure CouchDB + +```bash +# Apply CouchDB configuration +kubectl apply -f deploy/k8s/couchdb-configmap.yaml + +# Deploy CouchDB +kubectl apply -f deploy/k8s/couchdb-statefulset.yaml + +# Wait for CouchDB to be ready +kubectl wait --for=condition=ready pod -l app=couchdb -n adopt-a-street --timeout=120s +``` + ### 4. Update Domain Name Update the ingress host: @@ -116,11 +130,9 @@ kubectl apply -f deploy/k8s/secrets.yaml # Create ConfigMap kubectl apply -f deploy/k8s/configmap.yaml -# Deploy MongoDB -kubectl apply -f deploy/k8s/mongodb-statefulset.yaml - -# Wait for MongoDB to be ready (this may take 1-2 minutes) -kubectl wait --for=condition=ready pod -l app=mongodb -n adopt-a-street --timeout=120s +# Deploy CouchDB (already done in step 4) +# Wait for CouchDB to be ready (this may take 1-2 minutes) +kubectl wait --for=condition=ready pod -l app=couchdb -n adopt-a-street --timeout=120s # Deploy backend kubectl apply -f deploy/k8s/backend-deployment.yaml @@ -155,7 +167,7 @@ kubectl get pods -n adopt-a-street # adopt-a-street-backend-xxxxxxxxxx-xxxxx 1/1 Running 0 5m # adopt-a-street-frontend-xxxxxxxxx-xxxxx 1/1 Running 0 5m # adopt-a-street-frontend-xxxxxxxxx-xxxxx 1/1 Running 0 5m -# adopt-a-street-mongodb-0 1/1 Running 0 10m +# adopt-a-street-couchdb-0 1/1 Running 0 10m ``` ### Check Logs @@ -167,8 +179,8 @@ kubectl logs -f deployment/adopt-a-street-backend -n adopt-a-street # Frontend logs kubectl logs -f deployment/adopt-a-street-frontend -n adopt-a-street -# MongoDB logs -kubectl logs -f adopt-a-street-mongodb-0 -n adopt-a-street +# CouchDB logs +kubectl logs -f adopt-a-street-couchdb-0 -n adopt-a-street ``` ### Check Services @@ -180,7 +192,7 @@ kubectl get svc -n adopt-a-street # NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE # adopt-a-street-backend ClusterIP 10.43.x.x 5000/TCP 5m # adopt-a-street-frontend ClusterIP 10.43.x.x 80/TCP 5m -# adopt-a-street-mongodb ClusterIP None 27017/TCP 10m +# adopt-a-street-couchdb ClusterIP None 5984/TCP 10m ``` ### Check Ingress @@ -205,10 +217,11 @@ kubectl port-forward svc/adopt-a-street-frontend 3000:80 -n adopt-a-street The deployment is optimized for Raspberry Pi hardware: -### MongoDB (Pi 5 nodes only) +### CouchDB (Pi 5 nodes only) - **Requests:** 512Mi RAM, 250m CPU - **Limits:** 2Gi RAM, 1000m CPU - **Storage:** 10Gi persistent volume +- **Additional:** 64Mi RAM, 50m CPU for metrics exporter ### Backend (prefers Pi 5 nodes) - **Requests:** 256Mi RAM, 100m CPU @@ -221,7 +234,7 @@ The deployment is optimized for Raspberry Pi hardware: - **Replicas:** 2 pods ### Total Cluster Requirements -- **Minimum RAM:** ~3.5 GB (1.5GB MongoDB + 1GB backend + 200MB frontend + 800MB system) +- **Minimum RAM:** ~3.6 GB (1.5GB CouchDB + 1GB backend + 200MB frontend + 800MB system) - **Recommended:** 2x Pi 5 (8GB each) handles this comfortably ## Scaling @@ -236,7 +249,7 @@ kubectl scale deployment adopt-a-street-backend --replicas=3 -n adopt-a-street kubectl scale deployment adopt-a-street-frontend --replicas=3 -n adopt-a-street ``` -**Note:** MongoDB is a StatefulSet with 1 replica. Scaling MongoDB requires configuring replication. +**Note:** CouchDB is a StatefulSet with 1 replica. Scaling CouchDB requires configuring clustering. ## Updating @@ -318,14 +331,17 @@ kubectl logs -n adopt-a-street --previous - Verify cluster can access registry - Check if imagePullSecrets are needed -### MongoDB Connection Issues +### CouchDB Connection Issues ```bash # Shell into backend pod kubectl exec -it -n adopt-a-street -- sh -# Test MongoDB connection -wget -qO- http://adopt-a-street-mongodb:27017 +# Test CouchDB connection +curl -f http://adopt-a-street-couchdb:5984/_up + +# Test authentication +curl -u $COUCHDB_USER:$COUCHDB_PASSWORD http://adopt-a-street-couchdb:5984/_session ``` ### Persistent Volume Issues @@ -353,7 +369,8 @@ kubectl delete namespace adopt-a-street kubectl delete -f deploy/k8s/ingress.yaml kubectl delete -f deploy/k8s/frontend-deployment.yaml kubectl delete -f deploy/k8s/backend-deployment.yaml -kubectl delete -f deploy/k8s/mongodb-statefulset.yaml +kubectl delete -f deploy/k8s/couchdb-statefulset.yaml +kubectl delete -f deploy/k8s/couchdb-configmap.yaml kubectl delete -f deploy/k8s/configmap.yaml kubectl delete -f deploy/k8s/secrets.yaml kubectl delete -f deploy/k8s/namespace.yaml @@ -365,20 +382,21 @@ kubectl delete -f deploy/k8s/namespace.yaml 1. **Never commit secrets.yaml** - Always use secrets.yaml.example 2. **Use strong JWT_SECRET** - Generate with: `openssl rand -base64 32` -3. **Enable TLS/HTTPS** - Uncomment TLS section in ingress.yaml and use cert-manager -4. **Restrict ingress** - Use network policies to limit pod communication -5. **Use image digests** - Pin images to specific SHA256 digests for production -6. **Enable RBAC** - Create service accounts with minimal permissions -7. **Scan images** - Use tools like Trivy to scan for vulnerabilities +3. **Use strong CouchDB passwords** - Generate with: `openssl rand -base64 32` +4. **Enable TLS/HTTPS** - Uncomment TLS section in ingress.yaml and use cert-manager +5. **Restrict ingress** - Use network policies to limit pod communication +6. **Use image digests** - Pin images to specific SHA256 digests for production +7. **Enable RBAC** - Create service accounts with minimal permissions +8. **Scan images** - Use tools like Trivy to scan for vulnerabilities ## Performance Optimization 1. **Use imagePullPolicy: IfNotPresent** - After initial deployment to save bandwidth 2. **Implement HPA** - Horizontal Pod Autoscaler for dynamic scaling -3. **Add Redis** - For caching to reduce MongoDB load +3. **Add Redis** - For caching to reduce CouchDB load 4. **Use CDN** - For frontend static assets 5. **Enable compression** - Nginx already configured with gzip -6. **Monitor resources** - Use Prometheus + Grafana for metrics +6. **Monitor resources** - Use Prometheus + Grafana for metrics (CouchDB exporter included) ## Additional Resources diff --git a/deploy/k8s/backend-deployment.yaml b/deploy/k8s/backend-deployment.yaml index 59ec829..c6ed43c 100644 --- a/deploy/k8s/backend-deployment.yaml +++ b/deploy/k8s/backend-deployment.yaml @@ -54,6 +54,17 @@ spec: name: adopt-a-street-config - secretRef: name: adopt-a-street-secrets + env: + - name: COUCHDB_USER + valueFrom: + secretKeyRef: + name: adopt-a-street-secrets + key: COUCHDB_USER + - name: COUCHDB_PASSWORD + valueFrom: + secretKeyRef: + name: adopt-a-street-secrets + key: COUCHDB_PASSWORD resources: requests: memory: "256Mi" diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml index ceabd29..b6c021f 100644 --- a/deploy/k8s/configmap.yaml +++ b/deploy/k8s/configmap.yaml @@ -4,8 +4,9 @@ metadata: name: adopt-a-street-config namespace: adopt-a-street data: - # MongoDB Connection - MONGO_URI: "mongodb://adopt-a-street-mongodb:27017/adopt-a-street" + # CouchDB Connection + COUCHDB_URL: "http://adopt-a-street-couchdb:5984" + COUCHDB_DB_NAME: "adopt-a-street" # Backend Configuration PORT: "5000" diff --git a/deploy/k8s/couchdb-configmap.yaml b/deploy/k8s/couchdb-configmap.yaml new file mode 100644 index 0000000..d5ad93b --- /dev/null +++ b/deploy/k8s/couchdb-configmap.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: couchdb-config + namespace: adopt-a-street +data: + 10-cluster.ini: | + [cluster] + n = 1 + q = 8 + ; Enable cluster features + [chttpd] + bind_address = 0.0.0.0 + port = 5984 + [couchdb] + single_node = false + enable_cors = true + [cors] + origins = * + credentials = true + headers = accept, authorization, content-type, origin, referer, x-csrf-token + methods = GET, PUT, POST, HEAD, DELETE + max_age = 3600 \ No newline at end of file diff --git a/deploy/k8s/couchdb-statefulset.yaml b/deploy/k8s/couchdb-statefulset.yaml new file mode 100644 index 0000000..115c0d3 --- /dev/null +++ b/deploy/k8s/couchdb-statefulset.yaml @@ -0,0 +1,146 @@ +apiVersion: v1 +kind: Service +metadata: + name: adopt-a-street-couchdb + namespace: adopt-a-street + labels: + app: couchdb +spec: + clusterIP: None # Headless service for StatefulSet + selector: + app: couchdb + ports: + - port: 5984 + targetPort: 5984 + name: couchdb + - port: 4369 + targetPort: 4369 + name: epmd + - port: 9100 + targetPort: 9100 + name: couchdb-exporter + +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: adopt-a-street-couchdb + namespace: adopt-a-street +spec: + serviceName: adopt-a-street-couchdb + replicas: 1 + selector: + matchLabels: + app: couchdb + template: + metadata: + labels: + app: couchdb + spec: + # Place CouchDB on Pi 5 nodes (more RAM) + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: + - arm64 # Pi 5 architecture + containers: + - name: couchdb + image: couchdb:3.3 + ports: + - containerPort: 5984 + name: couchdb + - containerPort: 4369 + name: epmd + - containerPort: 9100 + name: couchdb-exporter + env: + - name: COUCHDB_USER + valueFrom: + secretKeyRef: + name: adopt-a-street-secrets + key: COUCHDB_USER + - name: COUCHDB_PASSWORD + valueFrom: + secretKeyRef: + name: adopt-a-street-secrets + key: COUCHDB_PASSWORD + - name: COUCHDB_SECRET + valueFrom: + secretKeyRef: + name: adopt-a-street-secrets + key: COUCHDB_SECRET + - name: NODENAME + value: couchdb@0.adopt-a-street-couchdb.adopt-a-street + - name: ERL_FLAGS + value: "+K true +A 4" + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "2Gi" + cpu: "1000m" + volumeMounts: + - name: couchdb-data + mountPath: /opt/couchdb/data + - name: couchdb-config + mountPath: /opt/couchdb/etc/local.d + livenessProbe: + httpGet: + path: /_up + port: 5984 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /_up + port: 5984 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + - name: couchdb-exporter + image: gesellix/couchdb-exporter:latest + ports: + - containerPort: 9100 + name: metrics + env: + - name: COUCHDB_URL + value: "http://localhost:5984" + - name: COUCHDB_USER + valueFrom: + secretKeyRef: + name: adopt-a-street-secrets + key: COUCHDB_USER + - name: COUCHDB_PASSWORD + valueFrom: + secretKeyRef: + name: adopt-a-street-secrets + key: COUCHDB_PASSWORD + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "100m" + volumes: + - name: couchdb-config + configMap: + name: couchdb-config + volumeClaimTemplates: + - metadata: + name: couchdb-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 10Gi + # Uncomment and set your storage class if needed + # storageClassName: local-path \ No newline at end of file diff --git a/deploy/k8s/secrets.yaml.example b/deploy/k8s/secrets.yaml.example index 49bddc8..7f67deb 100644 --- a/deploy/k8s/secrets.yaml.example +++ b/deploy/k8s/secrets.yaml.example @@ -8,6 +8,11 @@ stringData: # JWT Secret - CHANGE THIS IN PRODUCTION! JWT_SECRET: "your-super-secret-jwt-key-change-in-production" + # CouchDB Configuration + COUCHDB_USER: "admin" # Change this in production + COUCHDB_PASSWORD: "admin" # Change this in production + COUCHDB_SECRET: "some-random-secret-string" # Change this in production + # Cloudinary Configuration CLOUDINARY_CLOUD_NAME: "your-cloudinary-cloud-name" CLOUDINARY_API_KEY: "your-cloudinary-api-key" @@ -16,9 +21,13 @@ stringData: # Stripe Configuration (optional - currently mocked) # STRIPE_SECRET_KEY: "your-stripe-secret-key" + # OpenAI Configuration (optional - for AI features) + # OPENAI_API_KEY: "your-openai-api-key" + --- # IMPORTANT: # 1. Copy this file to secrets.yaml # 2. Replace all placeholder values with real secrets # 3. DO NOT commit secrets.yaml to version control # 4. Add secrets.yaml to .gitignore +# 5. Generate strong passwords for CouchDB using: openssl rand -base64 32