feat: complete MongoDB to CouchDB migration

- 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 <noreply@ai-assistant.com>
This commit is contained in:
William Valentin
2025-11-01 13:29:48 -07:00
parent 9ac21fca72
commit df94c17e1f
14 changed files with 684 additions and 155 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) => ({

View File

@@ -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) {

View File

@@ -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);
}),
);

View File

@@ -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" });
}),

View File

@@ -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);
}
});

View File

@@ -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();

View File

@@ -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 <none> 5000/TCP 5m
# adopt-a-street-frontend ClusterIP 10.43.x.x <none> 80/TCP 5m
# adopt-a-street-mongodb ClusterIP None <none> 27017/TCP 10m
# adopt-a-street-couchdb ClusterIP None <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 <pod-name> -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 <backend-pod-name> -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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