diff --git a/docs/docs.json b/docs/docs.json index 8a4c958654..9b4cbb318d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -302,7 +302,8 @@ "group": "Migration", "icon": "arrow-right", "pages": [ - "migration/oss-v2-to-v3" + "migration/oss-v2-to-v3", + "migration/server-pgvector-upgrade" ] }, { diff --git a/docs/llms.txt b/docs/llms.txt index 947d76f709..cd94cbf2d3 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -228,6 +228,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f - [OSS v2 to v3 Migration](https://docs.mem0.ai/migration/oss-v2-to-v3) [OSS]: Use when upgrading a self-hosted deployment across major versions. - [Platform v2 to v3 Migration](https://docs.mem0.ai/migration/platform-v2-to-v3) [Platform]: Use when upgrading a Platform integration across major versions. - [API Changes](https://docs.mem0.ai/migration/api-changes) [Both]: Use when the upgrade involves API surface changes. +- [Server pgvector Image Upgrade](https://docs.mem0.ai/migration/server-pgvector-upgrade) [OSS]: Use when upgrading the self-hosted server Docker image from ankane/pgvector to pgvector/pgvector. - [Changelog](https://docs.mem0.ai/changelog/highlights) [Both]: Use when the user asks what shipped recently. ## Open Source diff --git a/docs/migration/server-pgvector-upgrade.mdx b/docs/migration/server-pgvector-upgrade.mdx new file mode 100644 index 0000000000..629c67485f --- /dev/null +++ b/docs/migration/server-pgvector-upgrade.mdx @@ -0,0 +1,158 @@ +--- +title: "Server: Upgrading the pgvector Docker Image" +description: "Migrate your self-hosted Mem0 server from the archived ankane/pgvector image to the official pgvector/pgvector image." +icon: "arrow-right" +iconType: "solid" +--- + +## Overview + +The self-hosted Mem0 server has upgraded its PostgreSQL Docker image: + +| | Before | After | +| --- | --- | --- | +| Docker image | `ankane/pgvector:v0.5.1` | `pgvector/pgvector:pg17` | +| PostgreSQL | 15 | 17 | +| pgvector | 0.5.1 | 0.8.0 | +| Credentials | Hardcoded `postgres` / `postgres` | Set via `POSTGRES_USER` / `POSTGRES_PASSWORD` env vars | + + +The `ankane/pgvector` image is **archived and no longer maintained**. The new `pgvector/pgvector` image is the official distribution maintained by the pgvector project. + + + +**Should you migrate?** +- You are running the Mem0 server via `docker-compose.yaml` in the `server/` directory. +- You want to stay on a maintained, actively-patched PostgreSQL + pgvector image. +- You want pgvector 0.8.0 features (improved HNSW performance, parallel index builds). + + +## Fresh Installs + +No migration is needed. Copy the example env file, set your password, and start the stack: + +```bash +cd server +cp .env.example .env +# Edit .env — set POSTGRES_PASSWORD (required) and OPENAI_API_KEY at minimum +make up +``` + +## Migrating an Existing Install + +PostgreSQL 17 cannot read data files created by PostgreSQL 15 directly. You need to export your data from the old container and import it into the new one. + +### 1. Back Up Your Data + +With the **old** stack still running: + +```bash +cd server +docker compose exec -T postgres pg_dumpall -U postgres > mem0_backup.sql +``` + +Verify the dump is non-empty: + +```bash +ls -lh mem0_backup.sql +``` + + +Do not skip this step. The next step permanently deletes your Postgres data volume. + + +### 2. Stop the Old Stack and Remove the Volume + +```bash +docker compose down +docker compose down -v +``` + +### 3. Update Your `.env` + +Postgres credentials are no longer hardcoded in `docker-compose.yaml`. Add them to your `.env`: + +```bash +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=postgres +POSTGRES_USER=postgres +POSTGRES_PASSWORD= # required — compose will refuse to start without it +POSTGRES_COLLECTION_NAME=memories +``` + + +`POSTGRES_PASSWORD` is **required** — `docker compose up` will refuse to start without it. If you previously relied on the hardcoded default, set `POSTGRES_PASSWORD=postgres`. + + +### 4. Start Only Postgres + +Start **only** the Postgres container first — do **not** start the mem0 API yet. +The API runs `alembic upgrade head` on startup, which creates empty tables that +would conflict with the restore. + +```bash +docker compose up -d postgres +``` + +Wait for Postgres to become healthy: + +```bash +docker compose exec -T postgres pg_isready -q && echo "ready" || echo "not ready" +``` + +### 5. Restore Your Data + +```bash +docker compose exec -T postgres psql -U postgres < mem0_backup.sql +``` + +You may see notices like `role "postgres" already exists` — these are safe to ignore. + + +You must restore **before** starting the mem0 API container. The API runs +database migrations on startup which create empty tables — restoring after +that would fail with duplicate-key errors and lose your API keys and settings. + + +### 6. Start the API + +Now start the mem0 API container. Alembic will detect the existing tables and +only apply any new migrations: + +```bash +docker compose up -d mem0 +``` + +### 7. Verify + +```bash +# Check service health +cd server && make health + +# Confirm memories are accessible +curl -s http://localhost:8888/memories?user_id= \ + -H "X-API-Key: " +``` + +## Rollback + +If something goes wrong, revert the image tag in `docker-compose.yaml`: + +```yaml +postgres: + image: ankane/pgvector:v0.5.1 +``` + +Then destroy the new volume, start the old image, and restore from your backup: + +```bash +docker compose down -v +docker compose up -d --build +docker compose exec -T postgres psql -U postgres < mem0_backup.sql +``` + +## Need Help? + +- Join our [Discord community](https://mem0.ai/discord) for real-time support +- Open an issue on [GitHub](https://github.com/mem0ai/mem0/issues) diff --git a/server/.env.example b/server/.env.example index 114db1239b..a338355115 100644 --- a/server/.env.example +++ b/server/.env.example @@ -4,12 +4,13 @@ OPENAI_API_KEY= # ANTHROPIC_API_KEY= # GOOGLE_API_KEY= -POSTGRES_HOST= -POSTGRES_PORT= -POSTGRES_DB= -POSTGRES_USER= +# Postgres — POSTGRES_PASSWORD is required; docker-compose will not start without it. +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=postgres +POSTGRES_USER=postgres POSTGRES_PASSWORD= -POSTGRES_COLLECTION_NAME= +POSTGRES_COLLECTION_NAME=memories ADMIN_API_KEY= JWT_SECRET= diff --git a/server/README.md b/server/README.md index 37d1535569..941d2601ad 100644 --- a/server/README.md +++ b/server/README.md @@ -2,8 +2,24 @@ Mem0 ships a self-hosted FastAPI server plus a local dashboard. It is secure by default, supports dashboard login and API keys, and exposes OpenAPI docs at `/docs`. +> **Upgrading?** The Postgres image changed from the archived `ankane/pgvector:v0.5.1` +> to the official `pgvector/pgvector:pg17`, and `POSTGRES_PASSWORD` is now a required +> env var. If you have an existing install, see +> [Migrating from ankane/pgvector to pgvector/pgvector](#migrating-from-ankanepgvector-to-pgvectorpgvector) +> before running `docker compose up`. + ## Quick Start +### Prerequisites + +Copy the example env file and set a Postgres password (required): + +```bash +cd server +cp .env.example .env +# Edit .env — at minimum set POSTGRES_PASSWORD and OPENAI_API_KEY +``` + ### Agent-first Run one command; the terminal prints the admin email, password, and first API key. @@ -121,6 +137,140 @@ The dashboard sets the following response headers on every path (see `server/das Together these prevent iframe embedding, sniffing of mislabelled MIME types, and cross-origin referrer leaks. Harden further behind your own reverse proxy if needed. +## Migrating from `ankane/pgvector` to `pgvector/pgvector` + +The `ankane/pgvector` Docker image is archived and no longer maintained. This release +replaces it with the official `pgvector/pgvector:pg17` image (PostgreSQL 17, pgvector 0.8.0). + +**What changed:** + +| | Before | After | +|---|---|---| +| Docker image | `ankane/pgvector:v0.5.1` | `pgvector/pgvector:pg17` | +| PostgreSQL version | 15 | 17 | +| pgvector version | 0.5.1 | 0.8.0 | +| Credentials | Hardcoded `postgres`/`postgres` | Driven by `POSTGRES_USER` / `POSTGRES_PASSWORD` env vars | + +### Fresh installs (no existing data) + +No migration needed. Copy `.env.example` to `.env`, set `POSTGRES_PASSWORD`, and run: + +```bash +cd server +make up +``` + +### Existing installs (preserving data) + +PostgreSQL 17 cannot read data files written by PostgreSQL 15 directly. +You must export your data first, then import it into the new container. + +**1. Export your data from the old container** + +With the old stack still running: + +```bash +cd server + +# Dump all databases (mem0 memories + mem0_app auth/config data) +docker compose exec -T postgres pg_dumpall -U postgres > mem0_backup.sql +``` + +Verify the dump file is non-empty: + +```bash +ls -lh mem0_backup.sql +``` + +**2. Stop the old stack and remove the old volume** + +```bash +# Stop containers +docker compose down + +# Remove the old Postgres data volume +docker compose down -v +``` + +> **Warning:** `docker compose down -v` deletes the `postgres_db` volume permanently. +> Only run this after you have verified your backup. + +**3. Update your `.env`** + +The Postgres credentials are no longer hardcoded in `docker-compose.yaml`. +Add them to your `.env` file (or verify they match your old setup): + +```bash +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=postgres +POSTGRES_USER=postgres +POSTGRES_PASSWORD= # required — compose will refuse to start without it +POSTGRES_COLLECTION_NAME=memories +``` + +If you previously relied on the hardcoded defaults (`postgres`/`postgres`), set +`POSTGRES_PASSWORD=postgres` to keep the same credentials. + +**4. Start only Postgres** + +Start **only** the Postgres container first — do not start the mem0 API yet. +The API runs `alembic upgrade head` on startup, which creates empty tables that +would conflict with the restore. + +```bash +docker compose up -d postgres +``` + +Wait for the Postgres healthcheck to pass: + +```bash +docker compose exec -T postgres pg_isready -q && echo "ready" || echo "not ready" +``` + +**5. Restore your data** + +```bash +docker compose exec -T postgres psql -U postgres < mem0_backup.sql +``` + +You may see notices like `role "postgres" already exists` — these are harmless. + +> **Important:** You must restore before starting the mem0 API container. The API +> runs database migrations on startup which create empty tables — restoring after +> that would fail with duplicate-key errors and lose your API keys and settings. + +**6. Start the API** + +Now start the mem0 API container. Alembic will detect the existing tables and +only apply any new migrations: + +```bash +docker compose up -d mem0 +``` + +**7. Verify** + +```bash +# Check the API is healthy +make health + +# Confirm your memories are present +curl -s http://localhost:8888/memories?user_id= -H "X-API-Key: " +``` + +### Rollback + +If you need to revert, restore the old image tag in `docker-compose.yaml`: + +```yaml +postgres: + image: ankane/pgvector:v0.5.1 +``` + +Then `docker compose down -v`, `docker compose up -d --build`, and restore from +`mem0_backup.sql` into the old container the same way. + ## Reference Additional product and API documentation lives at [docs.mem0.ai](https://docs.mem0.ai/open-source/overview). diff --git a/server/auth.py b/server/auth.py index 47a5cfb341..f1f9f8513b 100644 --- a/server/auth.py +++ b/server/auth.py @@ -3,16 +3,15 @@ import uuid from datetime import datetime, timedelta, timezone +from db import get_db from fastapi import Depends, HTTPException, Request from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt +from models import APIKey, RefreshTokenJti, User from passlib.context import CryptContext from sqlalchemy import select, update from sqlalchemy.orm import Session -from db import get_db -from models import APIKey, RefreshTokenJti, User - JWT_SECRET = os.environ.get("JWT_SECRET", "") JWT_ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 @@ -184,3 +183,33 @@ async def require_auth( return default_user raise HTTPException(status_code=401, detail="Authentication required.") return user + + +_BOOTSTRAP_ADMIN = User( + id=uuid.UUID(int=0), name="admin_api_key", email="", password_hash="", role="admin", created_at=datetime.min.replace(tzinfo=timezone.utc), +) + + +async def require_admin( + request: Request, + user: User | None = Depends(verify_auth), + db: Session = Depends(get_db), +) -> User: + """Like require_auth but also enforces admin role. + + ADMIN_API_KEY and AUTH_DISABLED callers are treated as admin even when + the users table is empty (fresh-deploy bootstrap). + """ + auth_type = getattr(request.state, "auth_type", "none") + if user is None: + if auth_type in {"admin_api_key", "disabled"}: + default_user = _get_default_user(db) + if default_user is not None: + if default_user.role != "admin": + raise HTTPException(status_code=403, detail="Admin role required.") + return default_user + return _BOOTSTRAP_ADMIN + raise HTTPException(status_code=401, detail="Authentication required.") + if user.role != "admin": + raise HTTPException(status_code=403, detail="Admin role required.") + return user diff --git a/server/docker-compose.yaml b/server/docker-compose.yaml index 766a26d7f0..edb543d42f 100644 --- a/server/docker-compose.yaml +++ b/server/docker-compose.yaml @@ -30,16 +30,16 @@ services: - MEM0_TELEMETRY=${MEM0_TELEMETRY:-true} postgres: - image: ankane/pgvector:v0.5.1 + image: pgvector/pgvector:pg17 restart: on-failure shm_size: "128mb" networks: - mem0_network environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} healthcheck: - test: ["CMD", "pg_isready", "-q", "-d", "postgres", "-U", "postgres"] + test: ["CMD-SHELL", "pg_isready -q -d postgres -U ${POSTGRES_USER:-postgres}"] interval: 5s timeout: 5s retries: 5 diff --git a/server/main.py b/server/main.py index 098712bf11..cdaf12e724 100644 --- a/server/main.py +++ b/server/main.py @@ -5,7 +5,7 @@ from typing import Any, Dict, List, Optional import telemetry -from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, verify_auth +from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, require_admin, verify_auth from db import SessionLocal from dotenv import load_dotenv from errors import ( @@ -316,8 +316,8 @@ def list_bundled_providers(_auth=Depends(verify_auth)): @app.post("/configure", summary="Configure Mem0") -def set_config(config: Dict[str, Any], _auth=Depends(verify_auth)): - """Set memory configuration.""" +def set_config(config: Dict[str, Any], _auth=Depends(require_admin)): + """Set memory configuration. Requires admin role.""" _validate_bundled_providers(config) update_config(config) return {"message": "Configuration set successfully"} @@ -391,19 +391,25 @@ def _list_all_memories(limit: int = ALL_MEMORIES_LIMIT) -> Dict[str, Any]: @app.get("/memories", summary="Get memories") def get_all_memories( + request: Request, user_id: Optional[str] = None, run_id: Optional[str] = None, agent_id: Optional[str] = None, _auth=Depends(verify_auth), ): - """Retrieve stored memories. Lists all memories when no identifier is provided.""" + """Retrieve stored memories. Lists all memories when no identifier is provided (admin only).""" try: if not any([user_id, run_id, agent_id]): + auth_type = getattr(request.state, "auth_type", "none") + if _auth is not None and _auth.role != "admin" and auth_type not in {"admin_api_key", "disabled"}: + raise HTTPException(status_code=403, detail="Admin role required to list all memories.") return _list_all_memories() filters = { k: v for k, v in {"user_id": user_id, "run_id": run_id, "agent_id": agent_id}.items() if v is not None } return get_memory_instance().get_all(filters=filters) + except HTTPException: + raise except Exception: raise upstream_error() @@ -479,9 +485,9 @@ def delete_all_memories( user_id: Optional[str] = None, run_id: Optional[str] = None, agent_id: Optional[str] = None, - _auth=Depends(verify_auth), + _auth=Depends(require_admin), ): - """Delete all memories for a given identifier.""" + """Delete all memories for a given identifier. Requires admin role.""" if not any([user_id, run_id, agent_id]): raise HTTPException(status_code=400, detail="At least one identifier is required.") try: @@ -495,8 +501,8 @@ def delete_all_memories( @app.post("/reset", summary="Reset all memories") -def reset_memory(_auth=Depends(verify_auth)): - """Completely reset stored memories.""" +def reset_memory(_auth=Depends(require_admin)): + """Completely reset stored memories. Requires admin role.""" try: get_memory_instance().reset() return {"message": "All memories reset"} diff --git a/server/routers/entities.py b/server/routers/entities.py index 5a5a0a42b5..621b67d4e2 100644 --- a/server/routers/entities.py +++ b/server/routers/entities.py @@ -2,11 +2,10 @@ from datetime import datetime from typing import Any, Literal, Optional +from auth import require_admin, verify_auth +from errors import upstream_error from fastapi import APIRouter, Depends from pydantic import BaseModel - -from auth import verify_auth -from errors import upstream_error from schemas import MessageResponse from server_state import get_memory_instance @@ -69,7 +68,7 @@ def list_entities(_auth=Depends(verify_auth)): @router.delete("/{entity_type}/{entity_id}", response_model=MessageResponse) -def delete_entity(entity_type: EntityType, entity_id: str, _auth=Depends(verify_auth)): +def delete_entity(entity_type: EntityType, entity_id: str, _auth=Depends(require_admin)): try: get_memory_instance().delete_all(**{TYPE_TO_FIELD[entity_type]: entity_id}) except Exception: diff --git a/server/routers/requests.py b/server/routers/requests.py index b25ef7c4aa..3e226e30b0 100644 --- a/server/routers/requests.py +++ b/server/routers/requests.py @@ -1,15 +1,14 @@ -from datetime import datetime import uuid +from datetime import datetime +from auth import require_admin +from db import get_db from fastapi import APIRouter, Depends, Query +from models import RequestLog from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session -from auth import require_auth -from db import get_db -from models import RequestLog, User - router = APIRouter(prefix="/requests", tags=["requests"]) @@ -30,7 +29,7 @@ class RequestLogItem(BaseModel): @router.get("", response_model=list[RequestLogItem]) def list_requests( - user: User = Depends(require_auth), + _auth=Depends(require_admin), db: Session = Depends(get_db), limit: int = Query(default=50, ge=1, le=200), ):