Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

RouteForge is a read-only routing preflight and explainability tool for BGP, RPKI, Registry/IRR and Routing Visibility checks.

Current user-facing version: **v0.5.2-beta**.
Current user-facing version: **v0.5.3-beta**.

<!-- Screenshot gallery placeholder:
- docs/screenshots/dashboard.png
Expand Down Expand Up @@ -32,7 +32,7 @@ Routing changes often require fast but traceable checks across multiple external

## Current Alpha Status

RouteForge is a **functional alpha** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.5.2-beta**.
RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.5.3-beta**.

## Quickstart with Docker Compose

Expand Down Expand Up @@ -182,6 +182,8 @@ docker compose up --build
```bash
cp .env.example .env
# edit .env (especially POSTGRES_PASSWORD, DATABASE_URL, CORS_ORIGINS)
docker compose -f docker-compose.prod.yml up -d postgres
docker compose -f docker-compose.prod.yml run --rm backend alembic upgrade head
docker compose -f docker-compose.prod.yml up -d --build
```

Expand All @@ -198,9 +200,9 @@ In the standard setup, RouteForge does **not** require a hardcoded host IP in th

### Database
- Recommended production path: PostgreSQL via `docker-compose.prod.yml`.
- Backend initializes tables on startup using SQLAlchemy `create_all`.
- Alembic exists, but migration workflows are still beta-grade.
- Database migrations are currently simple/alpha-grade and will be hardened before v1.0.
- Production/PostgreSQL lifecycle is managed with Alembic migrations.
- SQLite/dev mode keeps lightweight startup initialization (`create_all`) for local/demo compatibility.
- Run migrations manually before production upgrades (`alembic upgrade head`).

### Operations docs
- Backup/Restore: `docs/operations/backup-restore.md`
Expand Down
21 changes: 21 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Release Notes

## v0.5.3-beta

**Database Lifecycle & Migrations**

### Highlights

- Alembic migration baseline for backend schema lifecycle
- Initial schema migration `0001_initial_schema`
- Migration status visibility in `/api/system/status`
- Database schema version visibility in GUI System view
- Upgrade process updated with explicit migration steps
- Backup/restore documentation updated for migration-safe operations

### Known limitations

- Existing alpha databases may need manual baseline handling (`alembic stamp 0001_initial_schema`)
- No advanced rollback automation yet
- Authentication is not implemented yet

---

## v0.5.2-beta

**System Status & Operational Checks**
Expand Down
2 changes: 2 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml ./
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./alembic.ini
RUN pip install --no-cache-dir .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
39 changes: 39 additions & 0 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from __future__ import annotations

from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

from app.config import settings
from app.database import Base
from app import models # noqa: F401

config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)

if config.config_file_name is not None:
fileConfig(config.config_file_name)

target_metadata = Base.metadata


def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()


def run_migrations_online() -> None:
connectable = engine_from_config(config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
23 changes: 23 additions & 0 deletions backend/alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade() -> None:
${upgrades if upgrades else "pass"}


def downgrade() -> None:
${downgrades if downgrades else "pass"}
65 changes: 65 additions & 0 deletions backend/alembic/versions/0001_initial_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Initial schema baseline

Revision ID: 0001_initial_schema
Revises:
Create Date: 2026-05-20
"""

from alembic import op
import sqlalchemy as sa


revision = "0001_initial_schema"
down_revision = None
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"api_cache",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("cache_key", sa.String(length=255), nullable=False),
sa.Column("response_json", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_api_cache_cache_key"), "api_cache", ["cache_key"], unique=True)
op.create_index(op.f("ix_api_cache_id"), "api_cache", ["id"], unique=False)

op.create_table(
"checks",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("check_type", sa.String(length=20), nullable=False),
sa.Column("input_resource", sa.String(length=120), nullable=False),
sa.Column("origin_as", sa.String(length=20), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_checks_id"), "checks", ["id"], unique=False)

op.create_table(
"reports",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("check_id", sa.Integer(), nullable=False),
sa.Column("json_data", sa.JSON(), nullable=False),
sa.Column("markdown", sa.Text(), nullable=False),
sa.Column("html", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["check_id"], ["checks.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_reports_id"), "reports", ["id"], unique=False)


def downgrade() -> None:
op.drop_index(op.f("ix_reports_id"), table_name="reports")
op.drop_table("reports")
op.drop_index(op.f("ix_checks_id"), table_name="checks")
op.drop_table("checks")
op.drop_index(op.f("ix_api_cache_id"), table_name="api_cache")
op.drop_index(op.f("ix_api_cache_cache_key"), table_name="api_cache")
op.drop_table("api_cache")
2 changes: 1 addition & 1 deletion backend/app/api/routes_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@

@router.get('/health')
def health() -> dict:
return {"status": "ok", "version": "v0.5.2-beta", "database": get_database_status(engine).get("status", "unknown")}
return {"status": "ok", "version": "v0.5.3-beta", "database": get_database_status(engine).get("status", "unknown")}
2 changes: 1 addition & 1 deletion backend/app/api/routes_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
def system_info():
return {
'name': 'RouteForge',
'version': 'v0.5.2-beta',
'version': 'v0.5.3-beta',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
Expand Down
38 changes: 37 additions & 1 deletion backend/app/core/system_status.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from __future__ import annotations

from pathlib import Path
from urllib.parse import urlsplit, urlunsplit

from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import text
from sqlalchemy.engine import Engine

Expand Down Expand Up @@ -47,21 +51,53 @@ def _safe_error_message(exc: Exception) -> str:
return message[:300]


def _migration_snapshot(engine: Engine) -> dict:
payload = {"schema_version": "unknown", "migration_head": "unknown", "migration_status": "unknown"}
try:
alembic_ini = Path(__file__).resolve().parents[2] / "alembic.ini"
cfg = Config(str(alembic_ini))
cfg.set_main_option("script_location", str(Path(__file__).resolve().parents[2] / "alembic"))
cfg.set_main_option("sqlalchemy.url", settings.database_url)
script = ScriptDirectory.from_config(cfg)
head = script.get_current_head()
payload["migration_head"] = head or "unknown"

with engine.connect() as connection:
context = MigrationContext.configure(connection)
current = context.get_current_revision()
payload["schema_version"] = current or "unknown"

if payload["schema_version"] == "unknown" or payload["migration_head"] == "unknown":
payload["migration_status"] = "unknown"
elif payload["schema_version"] == payload["migration_head"]:
payload["migration_status"] = "up_to_date"
else:
payload["migration_status"] = "behind"
except Exception:
payload["migration_status"] = "unknown"
return payload


def get_database_status(engine: Engine | None) -> dict:
db_url = settings.database_url
payload = {
"status": "unknown",
"type": database_type_from_url(db_url),
"url_safe": safe_database_url(db_url),
"schema_version": "unknown",
"migration_status": "unknown",
"migration_head": "unknown",
}
if engine is None:
return payload
try:
with engine.connect() as connection:
connection.execute(text("SELECT 1"))
payload["status"] = "ok"
payload.update(_migration_snapshot(engine))
except Exception as exc:
payload["status"] = "error"
payload["migration_status"] = "error"
payload["error_message"] = _safe_error_message(exc)
return payload

Expand All @@ -70,7 +106,7 @@ def build_system_status(engine: Engine | None) -> dict:
return {
"status": "ok",
"name": settings.app_name,
"version": "v0.5.2-beta",
"version": "v0.5.3-beta",
"read_only": True,
"mode": "demo" if settings.demo_mode else "live",
"demo_mode": settings.demo_mode,
Expand Down
5 changes: 3 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from app.config import settings
from app.database import Base, engine

app = FastAPI(title="RouteForge", version="0.5.2")
app = FastAPI(title="RouteForge", version="0.5.3")

app.add_middleware(
CORSMiddleware,
Expand All @@ -21,7 +21,8 @@

@app.on_event("startup")
def startup() -> None:
Base.metadata.create_all(bind=engine)
if settings.database_url.startswith("sqlite"):
Base.metadata.create_all(bind=engine)


app.include_router(health_router)
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "routeforge-backend"
version = "0.5.2"
version = "0.5.3"
description = "RouteForge backend"
requires-python = ">=3.12"
dependencies = [
Expand Down
24 changes: 23 additions & 1 deletion backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def test_system_status_endpoint() -> None:
response = client.get('/api/system/status')
assert response.status_code == 200
payload = response.json()
assert payload.get('version') == 'v0.5.2-beta'
assert payload.get('version') == 'v0.5.3-beta'
assert payload.get('read_only') is True
assert payload.get('database', {}).get('status')
assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None
Expand All @@ -168,3 +168,25 @@ def test_safe_database_url() -> None:
assert safe_database_url('postgresql+psycopg://routeforge:secret@postgres:5432/routeforge') == 'postgresql://routeforge@postgres:5432/routeforge'
assert safe_database_url('sqlite:////app/data/routeforge.db') == 'sqlite:////app/data/routeforge.db'
assert safe_database_url('not a url') == 'configured'


def test_system_status_includes_migration_fields() -> None:
client = _client()
response = client.get('/api/system/status')
assert response.status_code == 200
database = response.json().get('database', {})
assert 'schema_version' in database
assert 'migration_status' in database
assert 'migration_head' in database


def test_migration_status_unknown_does_not_crash() -> None:
from app.core import system_status as ss

class FakeBrokenEngine:
def connect(self):
raise RuntimeError('db down')

payload = ss.get_database_status(FakeBrokenEngine())
assert payload.get('status') == 'error'
assert payload.get('migration_status') == 'error'
12 changes: 12 additions & 0 deletions docs/operations/backup-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,15 @@ curl http://localhost:8000/health
```
- Open UI and verify report history is present.
- If using demo/SQLite setups, back up the SQLite file separately.


## Migration safety
- Always create a backup before running `alembic upgrade head`.
- After restore, verify migration state before opening to users:

```bash
docker compose -f docker-compose.prod.yml run --rm backend alembic current
curl http://localhost:3000/api/system/status
```

- Test restore in non-production first, then apply in production.
Loading
Loading