chore: initial project setup with backend, frontend, CI/CD, and documentation
Add complete NexaFabric project structure including: - FastAPI backend with SQLAlchemy models, JWT auth, RBAC, audit logging, and provider interfaces - React + TypeScript frontend with Vite, Tailwind CSS, TanStack Query, and Zustand - Docker Compose configuration for PostgreSQL, Redis, API, worker, frontend, and nginx - GitHub Actions and GitLab CI workflows for testing, linting, building, and security scanning - Environment
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir ".[dev]"
|
||||
COPY app ./app
|
||||
COPY tests ./tests
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = sqlite:///./nexafabric.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import Base
|
||||
from app.models import domain # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", get_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:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.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()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""${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 = ${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"}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""NexaFabric backend package."""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""API package."""
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.db.session import get_db
|
||||
from app.models.domain import User
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
def current_user(db: DbSession, token: Annotated[str, Depends(oauth2_scheme)]) -> User:
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||
if payload.get("typ") != "access":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||
user = db.scalar(select(User).where(User.id == payload["sub"], User.is_active.is_(True)))
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user")
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(current_user)]
|
||||
|
||||
|
||||
def require_permission(permission: str):
|
||||
def dependency(user: CurrentUser) -> User:
|
||||
permissions = {permission for role in user.roles for permission in role.permissions}
|
||||
if "*" not in permissions and permission not in permissions:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing permission")
|
||||
return user
|
||||
|
||||
return dependency
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""API v1 package."""
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUser
|
||||
from app.core.security import create_access_token, create_refresh_token, verify_password
|
||||
from app.db.session import get_db
|
||||
from app.models.domain import User
|
||||
from app.schemas.domain import LoginRequest, TokenPair, UserRead
|
||||
from app.services.audit import write_audit
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenPair)
|
||||
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenPair:
|
||||
user = db.scalar(select(User).where(User.email == payload.email))
|
||||
if not user or not verify_password(payload.password, user.password_hash):
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
permissions = sorted({permission for role in user.roles for permission in role.permissions})
|
||||
user.failed_login_attempts = 0
|
||||
db.commit()
|
||||
write_audit(db, action="login", object_type="user", object_id=user.id, user_id=user.id)
|
||||
return TokenPair(
|
||||
access_token=create_access_token(user.id, permissions),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
def me(user: CurrentUser) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
|
||||
write_audit(db, action="logout", object_type="user", object_id=user.id, user_id=user.id)
|
||||
return {"status": "ok", "at": datetime.utcnow().isoformat()}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUser
|
||||
from app.api.v1 import auth
|
||||
from app.db.session import get_db
|
||||
from app.models.domain import (
|
||||
AuditLog,
|
||||
Cluster,
|
||||
Job,
|
||||
Network,
|
||||
Node,
|
||||
Policy,
|
||||
Project,
|
||||
SecurityGroup,
|
||||
Subnet,
|
||||
Tenant,
|
||||
User,
|
||||
Workload,
|
||||
)
|
||||
from app.schemas.domain import (
|
||||
AuditLogRead,
|
||||
ClusterCreate,
|
||||
ClusterRead,
|
||||
FirewallPreview,
|
||||
IpAddressRead,
|
||||
IpReservationCreate,
|
||||
JobRead,
|
||||
NetworkRead,
|
||||
NodeRead,
|
||||
PolicyCreate,
|
||||
PolicyRead,
|
||||
ProjectRead,
|
||||
SecurityGroupCreate,
|
||||
SecurityGroupRead,
|
||||
SubnetRead,
|
||||
TenantRead,
|
||||
UserRead,
|
||||
WorkloadRead,
|
||||
)
|
||||
from app.services.audit import write_audit
|
||||
from app.services.firewall_orchestrator import FirewallOrchestrator
|
||||
from app.services.providers.base import ProviderConnection
|
||||
from app.services.providers.registry import get_provider
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router)
|
||||
|
||||
|
||||
@api_router.get("/dashboard")
|
||||
def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict:
|
||||
return {
|
||||
"clusters": db.scalar(select(func.count()).select_from(Cluster)),
|
||||
"nodes": db.scalar(select(func.count()).select_from(Node)),
|
||||
"workloads": db.scalar(select(func.count()).select_from(Workload)),
|
||||
"networks": db.scalar(select(func.count()).select_from(Network)),
|
||||
"open_policy_violations": 1,
|
||||
"last_syncs": db.scalars(select(Cluster).order_by(Cluster.updated_at.desc()).limit(5)).all(),
|
||||
"faulty_nodes": db.scalars(select(Node).where(Node.status != "online")).all(),
|
||||
"top_talkers": [
|
||||
{"name": "finance-app-2", "bytes": 942000000},
|
||||
{"name": "core-services-1", "bytes": 512000000},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@api_router.get("/users", response_model=list[UserRead])
|
||||
def users(_: CurrentUser, db: Session = Depends(get_db)) -> list[User]:
|
||||
return db.scalars(select(User).order_by(User.email)).all()
|
||||
|
||||
|
||||
@api_router.get("/clusters", response_model=list[ClusterRead])
|
||||
def clusters(_: CurrentUser, db: Session = Depends(get_db)) -> list[Cluster]:
|
||||
return db.scalars(select(Cluster).order_by(Cluster.name)).all()
|
||||
|
||||
|
||||
@api_router.post("/clusters", response_model=ClusterRead)
|
||||
def create_cluster(payload: ClusterCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Cluster:
|
||||
cluster = Cluster(
|
||||
name=payload.name,
|
||||
api_url=payload.api_url,
|
||||
token_ref=payload.api_token,
|
||||
mode=payload.mode,
|
||||
verify_tls=payload.verify_tls,
|
||||
)
|
||||
db.add(cluster)
|
||||
db.commit()
|
||||
db.refresh(cluster)
|
||||
write_audit(db, action="cluster.created", object_type="cluster", object_id=cluster.id, user_id=user.id)
|
||||
return cluster
|
||||
|
||||
|
||||
@api_router.post("/clusters/{cluster_id}/test")
|
||||
def test_cluster(cluster_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> dict:
|
||||
cluster = db.get(Cluster, cluster_id)
|
||||
if not cluster:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
return {"cluster_id": cluster.id, "status": "configured", "message": "Provider connection is ready for live token validation."}
|
||||
|
||||
|
||||
@api_router.post("/clusters/{cluster_id}/sync")
|
||||
async def sync_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict:
|
||||
cluster = db.get(Cluster, cluster_id)
|
||||
if not cluster:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
provider = get_provider(cluster.provider)
|
||||
inventory = await provider.sync_inventory(
|
||||
ProviderConnection(
|
||||
api_url=cluster.api_url,
|
||||
token=cluster.token_ref or "",
|
||||
verify_tls=cluster.verify_tls,
|
||||
read_only=cluster.mode == "read_only",
|
||||
)
|
||||
)
|
||||
cluster.last_sync_status = "success"
|
||||
cluster.last_sync_error = None
|
||||
db.add(Job(kind="proxmox.sync", status="success", progress=100, logs=[f"Synced {cluster.name}"]))
|
||||
db.commit()
|
||||
write_audit(db, action="cluster.sync", object_type="cluster", object_id=cluster.id, user_id=user.id)
|
||||
return {"cluster_id": cluster.id, "status": "success", "inventory_counts": {key: len(value) for key, value in inventory.items()}}
|
||||
|
||||
|
||||
@api_router.get("/nodes", response_model=list[NodeRead])
|
||||
def nodes(_: CurrentUser, db: Session = Depends(get_db)) -> list[Node]:
|
||||
return db.scalars(select(Node).order_by(Node.name)).all()
|
||||
|
||||
|
||||
@api_router.get("/vms", response_model=list[WorkloadRead])
|
||||
def workloads(_: CurrentUser, db: Session = Depends(get_db)) -> list[Workload]:
|
||||
return db.scalars(select(Workload).order_by(Workload.name)).all()
|
||||
|
||||
|
||||
@api_router.get("/networks", response_model=list[NetworkRead])
|
||||
def networks(_: CurrentUser, db: Session = Depends(get_db)) -> list[Network]:
|
||||
return db.scalars(select(Network).order_by(Network.name)).all()
|
||||
|
||||
|
||||
@api_router.get("/ipam/subnets", response_model=list[SubnetRead])
|
||||
def subnets(_: CurrentUser, db: Session = Depends(get_db)) -> list[Subnet]:
|
||||
return db.scalars(select(Subnet).order_by(Subnet.cidr)).all()
|
||||
|
||||
|
||||
@api_router.get("/ipam/addresses", response_model=list[IpAddressRead])
|
||||
def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)):
|
||||
from app.models.domain import IpAddress
|
||||
|
||||
return db.scalars(select(IpAddress).order_by(IpAddress.address)).all()
|
||||
|
||||
|
||||
@api_router.post("/ipam/addresses", response_model=IpAddressRead)
|
||||
def reserve_ip(payload: IpReservationCreate, user: CurrentUser, db: Session = Depends(get_db)):
|
||||
from app.models.domain import IpAddress
|
||||
|
||||
address = IpAddress(subnet_id=payload.subnet_id, address=payload.address, status=payload.status, note=payload.note)
|
||||
db.add(address)
|
||||
db.commit()
|
||||
db.refresh(address)
|
||||
write_audit(db, action="ipam.address.created", object_type="ip_address", object_id=address.id, user_id=user.id)
|
||||
return address
|
||||
|
||||
|
||||
@api_router.get("/tenants", response_model=list[TenantRead])
|
||||
def tenants(_: CurrentUser, db: Session = Depends(get_db)) -> list[Tenant]:
|
||||
return db.scalars(select(Tenant).order_by(Tenant.name)).all()
|
||||
|
||||
|
||||
@api_router.get("/projects", response_model=list[ProjectRead])
|
||||
def projects(_: CurrentUser, db: Session = Depends(get_db)) -> list[Project]:
|
||||
return db.scalars(select(Project).order_by(Project.name)).all()
|
||||
|
||||
|
||||
@api_router.get("/security-groups", response_model=list[SecurityGroupRead])
|
||||
def security_groups(_: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityGroup]:
|
||||
return db.scalars(select(SecurityGroup).order_by(SecurityGroup.name)).all()
|
||||
|
||||
|
||||
@api_router.post("/security-groups", response_model=SecurityGroupRead)
|
||||
def create_security_group(payload: SecurityGroupCreate, user: CurrentUser, db: Session = Depends(get_db)) -> SecurityGroup:
|
||||
group = SecurityGroup(project_id=payload.project_id, name=payload.name, description=payload.description)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
write_audit(db, action="security_group.created", object_type="security_group", object_id=group.id, user_id=user.id)
|
||||
return group
|
||||
|
||||
|
||||
@api_router.get("/policies", response_model=list[PolicyRead])
|
||||
def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[Policy]:
|
||||
return db.scalars(select(Policy).order_by(Policy.name)).all()
|
||||
|
||||
|
||||
@api_router.post("/policies", response_model=PolicyRead)
|
||||
def create_policy(payload: PolicyCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Policy:
|
||||
policy = Policy(project_id=payload.project_id, name=payload.name, enabled=payload.enabled, definition=payload.definition)
|
||||
db.add(policy)
|
||||
db.commit()
|
||||
db.refresh(policy)
|
||||
write_audit(db, action="policy.created", object_type="policy", object_id=policy.id, user_id=user.id)
|
||||
return policy
|
||||
|
||||
|
||||
@api_router.post("/firewall/preview/{policy_id}", response_model=FirewallPreview)
|
||||
async def firewall_preview(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> FirewallPreview:
|
||||
policy = db.get(Policy, policy_id)
|
||||
cluster = db.scalar(select(Cluster).order_by(Cluster.name).limit(1))
|
||||
if not policy or not cluster:
|
||||
raise HTTPException(status_code=404, detail="Policy or cluster not found")
|
||||
preview = await FirewallOrchestrator().preview(cluster, policy)
|
||||
write_audit(db, action="firewall.preview", object_type="policy", object_id=policy.id, user_id=user.id, new_values=preview.model_dump())
|
||||
return preview
|
||||
|
||||
|
||||
@api_router.get("/jobs", response_model=list[JobRead])
|
||||
def jobs(_: CurrentUser, db: Session = Depends(get_db)) -> list[Job]:
|
||||
return db.scalars(select(Job).order_by(Job.created_at.desc())).all()
|
||||
|
||||
|
||||
@api_router.get("/audit", response_model=list[AuditLogRead])
|
||||
def audit(_: CurrentUser, db: Session = Depends(get_db)) -> list[AuditLog]:
|
||||
return db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(200)).all()
|
||||
|
||||
|
||||
@api_router.get("/settings")
|
||||
def settings(_: CurrentUser) -> dict:
|
||||
return {"product": "NexaFabric", "firewall_apply_requires_preview": True, "agent_optional": True}
|
||||
@@ -0,0 +1,26 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import AnyHttpUrl, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
project_name: str = "NexaFabric"
|
||||
environment: str = "development"
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
database_url: str = "sqlite:///./nexafabric.db"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
jwt_secret: str = Field(default="dev-only-change-me")
|
||||
jwt_access_token_minutes: int = 15
|
||||
jwt_refresh_token_days: int = 14
|
||||
token_encryption_key: str = "dev-only-change-me"
|
||||
demo_admin_password: str = "ChangeMe_UseEnvInstead"
|
||||
cors_origins: list[AnyHttpUrl] | list[str] = ["http://localhost:5173", "http://localhost:8080"]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return pwd_context.verify(password, password_hash)
|
||||
|
||||
|
||||
def create_token(subject: str, token_type: str, expires_delta: timedelta, claims: dict[str, Any] | None = None) -> str:
|
||||
settings = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": subject,
|
||||
"typ": token_type,
|
||||
"iat": now,
|
||||
"exp": now + expires_delta,
|
||||
}
|
||||
if claims:
|
||||
payload.update(claims)
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def create_access_token(subject: str, permissions: list[str]) -> str:
|
||||
settings = get_settings()
|
||||
return create_token(
|
||||
subject,
|
||||
"access",
|
||||
timedelta(minutes=settings.jwt_access_token_minutes),
|
||||
{"permissions": permissions},
|
||||
)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str) -> str:
|
||||
settings = get_settings()
|
||||
return create_token(subject, "refresh", timedelta(days=settings.jwt_refresh_token_days))
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
return jwt.decode(token, get_settings().jwt_secret, algorithms=["HS256"])
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import Base, SessionLocal, engine
|
||||
from app.seed.demo import seed_demo_data
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title=settings.project_name,
|
||||
description="SDN-like network and security control plane for Proxmox VE.",
|
||||
version="0.1.0",
|
||||
docs_url="/api/docs",
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[str(origin) for origin in settings.cors_origins],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with SessionLocal() as db:
|
||||
seed_demo_data(db)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from app.models.domain import * # noqa: F403
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
class ClusterMode(StrEnum):
|
||||
read_only = "read_only"
|
||||
write_enabled = "write_enabled"
|
||||
|
||||
|
||||
class JobStatus(StrEnum):
|
||||
queued = "queued"
|
||||
running = "running"
|
||||
success = "success"
|
||||
failed = "failed"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class IpStatus(StrEnum):
|
||||
free = "free"
|
||||
reserved = "reserved"
|
||||
assigned = "assigned"
|
||||
deprecated = "deprecated"
|
||||
conflict = "conflict"
|
||||
|
||||
|
||||
class RuleAction(StrEnum):
|
||||
allow = "allow"
|
||||
deny = "deny"
|
||||
reject = "reject"
|
||||
|
||||
|
||||
class Direction(StrEnum):
|
||||
ingress = "ingress"
|
||||
egress = "egress"
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255))
|
||||
password_hash: Mapped[str] = mapped_column(String(512))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
roles: Mapped[list["Role"]] = relationship(secondary="user_roles", back_populates="users")
|
||||
|
||||
|
||||
class Role(Base, TimestampMixin):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
users: Mapped[list[User]] = relationship(secondary="user_roles", back_populates="roles")
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), primary_key=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id"), primary_key=True)
|
||||
|
||||
|
||||
class Tenant(Base, TimestampMixin):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class Project(Base, TimestampMixin):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
tenant: Mapped[Tenant] = relationship()
|
||||
|
||||
|
||||
class Cluster(Base, TimestampMixin):
|
||||
__tablename__ = "clusters"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
api_url: Mapped[str] = mapped_column(String(512))
|
||||
provider: Mapped[str] = mapped_column(String(100), default="proxmox")
|
||||
mode: Mapped[ClusterMode] = mapped_column(Enum(ClusterMode), default=ClusterMode.read_only)
|
||||
token_ref: Mapped[str | None] = mapped_column(String(512))
|
||||
verify_tls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
last_sync_status: Mapped[str | None] = mapped_column(String(100))
|
||||
last_sync_error: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class Node(Base, TimestampMixin):
|
||||
__tablename__ = "nodes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
cluster_id: Mapped[str] = mapped_column(ForeignKey("clusters.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
status: Mapped[str] = mapped_column(String(100), default="unknown")
|
||||
cpu_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
memory_mb: Mapped[int] = mapped_column(Integer, default=0)
|
||||
cluster: Mapped[Cluster] = relationship()
|
||||
|
||||
|
||||
class Workload(Base, TimestampMixin):
|
||||
__tablename__ = "workloads"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
cluster_id: Mapped[str] = mapped_column(ForeignKey("clusters.id"), index=True)
|
||||
node_id: Mapped[str] = mapped_column(ForeignKey("nodes.id"), index=True)
|
||||
project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id"), index=True)
|
||||
external_id: Mapped[str] = mapped_column(String(100))
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
kind: Mapped[str] = mapped_column(String(50))
|
||||
status: Mapped[str] = mapped_column(String(100), default="unknown")
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class Network(Base, TimestampMixin):
|
||||
__tablename__ = "networks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
cluster_id: Mapped[str] = mapped_column(ForeignKey("clusters.id"), index=True)
|
||||
project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
kind: Mapped[str] = mapped_column(String(50))
|
||||
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
||||
mtu: Mapped[int] = mapped_column(Integer, default=1500)
|
||||
gateway: Mapped[str | None] = mapped_column(String(100))
|
||||
dns: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
dhcp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class Subnet(Base, TimestampMixin):
|
||||
__tablename__ = "subnets"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
network_id: Mapped[str] = mapped_column(ForeignKey("networks.id"), index=True)
|
||||
cidr: Mapped[str] = mapped_column(String(100))
|
||||
gateway: Mapped[str | None] = mapped_column(String(100))
|
||||
dns: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
dhcp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class IpAddress(Base, TimestampMixin):
|
||||
__tablename__ = "ip_addresses"
|
||||
__table_args__ = (UniqueConstraint("subnet_id", "address"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
subnet_id: Mapped[str] = mapped_column(ForeignKey("subnets.id"), index=True)
|
||||
address: Mapped[str] = mapped_column(String(100))
|
||||
status: Mapped[IpStatus] = mapped_column(Enum(IpStatus), default=IpStatus.free)
|
||||
workload_id: Mapped[str | None] = mapped_column(ForeignKey("workloads.id"), index=True)
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class SecurityGroup(Base, TimestampMixin):
|
||||
__tablename__ = "security_groups"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class SecurityRule(Base, TimestampMixin):
|
||||
__tablename__ = "security_rules"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
security_group_id: Mapped[str] = mapped_column(ForeignKey("security_groups.id"), index=True)
|
||||
direction: Mapped[Direction] = mapped_column(Enum(Direction))
|
||||
action: Mapped[RuleAction] = mapped_column(Enum(RuleAction))
|
||||
protocol: Mapped[str] = mapped_column(String(20), default="any")
|
||||
source: Mapped[str] = mapped_column(String(255), default="any")
|
||||
destination: Mapped[str] = mapped_column(String(255), default="any")
|
||||
port: Mapped[str | None] = mapped_column(String(100))
|
||||
priority: Mapped[int] = mapped_column(Integer, default=1000)
|
||||
logging: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class Policy(Base, TimestampMixin):
|
||||
__tablename__ = "policies"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
definition: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
last_compiled: Mapped[dict | None] = mapped_column(JSON)
|
||||
|
||||
|
||||
class ServiceCatalogItem(Base, TimestampMixin):
|
||||
__tablename__ = "service_catalog"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
protocol: Mapped[str] = mapped_column(String(20))
|
||||
ports: Mapped[str] = mapped_column(String(100))
|
||||
editable: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class Job(Base, TimestampMixin):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
kind: Mapped[str] = mapped_column(String(100))
|
||||
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.queued)
|
||||
progress: Mapped[int] = mapped_column(Integer, default=0)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
logs: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
action: Mapped[str] = mapped_column(String(100), index=True)
|
||||
object_type: Mapped[str] = mapped_column(String(100), index=True)
|
||||
object_id: Mapped[str | None] = mapped_column(String(100), index=True)
|
||||
old_values: Mapped[dict | None] = mapped_column(JSON)
|
||||
new_values: Mapped[dict | None] = mapped_column(JSON)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(100))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512))
|
||||
result: Mapped[str] = mapped_column(String(100), default="success")
|
||||
error_text: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from app.schemas.domain import * # noqa: F403
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class OrmModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserRead(OrmModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
display_name: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ClusterCreate(BaseModel):
|
||||
name: str
|
||||
api_url: str
|
||||
api_token: str = Field(min_length=8)
|
||||
mode: str = "read_only"
|
||||
verify_tls: bool = True
|
||||
|
||||
|
||||
class SecurityGroupCreate(BaseModel):
|
||||
project_id: str | None = None
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PolicyCreate(BaseModel):
|
||||
project_id: str | None = None
|
||||
name: str
|
||||
enabled: bool = True
|
||||
definition: dict[str, Any]
|
||||
|
||||
|
||||
class IpReservationCreate(BaseModel):
|
||||
subnet_id: str
|
||||
address: str
|
||||
status: str = "reserved"
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class ClusterRead(OrmModel):
|
||||
id: str
|
||||
name: str
|
||||
api_url: str
|
||||
provider: str
|
||||
mode: str
|
||||
last_sync_at: datetime | None
|
||||
last_sync_status: str | None
|
||||
last_sync_error: str | None
|
||||
|
||||
|
||||
class NodeRead(OrmModel):
|
||||
id: str
|
||||
cluster_id: str
|
||||
name: str
|
||||
status: str
|
||||
cpu_count: int
|
||||
memory_mb: int
|
||||
|
||||
|
||||
class WorkloadRead(OrmModel):
|
||||
id: str
|
||||
cluster_id: str
|
||||
node_id: str
|
||||
project_id: str | None
|
||||
external_id: str
|
||||
name: str
|
||||
kind: str
|
||||
status: str
|
||||
tags: list[str]
|
||||
|
||||
|
||||
class NetworkRead(OrmModel):
|
||||
id: str
|
||||
cluster_id: str
|
||||
project_id: str | None
|
||||
name: str
|
||||
kind: str
|
||||
vlan_id: int | None
|
||||
mtu: int
|
||||
gateway: str | None
|
||||
dns: list[str]
|
||||
dhcp_enabled: bool
|
||||
tags: list[str]
|
||||
description: str | None
|
||||
|
||||
|
||||
class SubnetRead(OrmModel):
|
||||
id: str
|
||||
network_id: str
|
||||
cidr: str
|
||||
gateway: str | None
|
||||
dns: list[str]
|
||||
dhcp_enabled: bool
|
||||
|
||||
|
||||
class IpAddressRead(OrmModel):
|
||||
id: str
|
||||
subnet_id: str
|
||||
address: str
|
||||
status: str
|
||||
workload_id: str | None
|
||||
note: str | None
|
||||
|
||||
|
||||
class TenantRead(OrmModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
|
||||
class ProjectRead(OrmModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
|
||||
class SecurityGroupRead(OrmModel):
|
||||
id: str
|
||||
project_id: str | None
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
|
||||
class PolicyRead(OrmModel):
|
||||
id: str
|
||||
project_id: str | None
|
||||
name: str
|
||||
version: int
|
||||
enabled: bool
|
||||
definition: dict[str, Any]
|
||||
last_compiled: dict[str, Any] | None
|
||||
|
||||
|
||||
class FirewallPreview(BaseModel):
|
||||
policy_id: str
|
||||
dry_run: bool = True
|
||||
generated_rules: list[dict[str, Any]]
|
||||
warnings: list[str]
|
||||
conflicts: list[str]
|
||||
|
||||
|
||||
class JobRead(OrmModel):
|
||||
id: str
|
||||
kind: str
|
||||
status: str
|
||||
progress: int
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
logs: list[str]
|
||||
error: str | None
|
||||
|
||||
|
||||
class AuditLogRead(OrmModel):
|
||||
id: str
|
||||
created_at: datetime
|
||||
user_id: str | None
|
||||
action: str
|
||||
object_type: str
|
||||
object_id: str | None
|
||||
old_values: dict[str, Any] | None
|
||||
new_values: dict[str, Any] | None
|
||||
ip_address: str | None
|
||||
user_agent: str | None
|
||||
result: str
|
||||
error_text: str | None
|
||||
@@ -0,0 +1,181 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import hash_password
|
||||
from app.models.domain import (
|
||||
AuditLog,
|
||||
Cluster,
|
||||
IpAddress,
|
||||
Network,
|
||||
Node,
|
||||
Policy,
|
||||
Project,
|
||||
Role,
|
||||
SecurityGroup,
|
||||
ServiceCatalogItem,
|
||||
Subnet,
|
||||
Tenant,
|
||||
User,
|
||||
Workload,
|
||||
)
|
||||
|
||||
|
||||
SERVICES = [
|
||||
("SSH", "tcp", "22"),
|
||||
("HTTP", "tcp", "80"),
|
||||
("HTTPS", "tcp", "443"),
|
||||
("DNS", "tcp/udp", "53"),
|
||||
("LDAP", "tcp", "389"),
|
||||
("LDAPS", "tcp", "636"),
|
||||
("RDP", "tcp", "3389"),
|
||||
("PostgreSQL", "tcp", "5432"),
|
||||
("MySQL/MariaDB", "tcp", "3306"),
|
||||
("MSSQL", "tcp", "1433"),
|
||||
("Redis", "tcp", "6379"),
|
||||
("SMB", "tcp", "445"),
|
||||
("SMTP", "tcp", "25"),
|
||||
("SMTPS", "tcp", "465"),
|
||||
("IMAPS", "tcp", "993"),
|
||||
("POP3S", "tcp", "995"),
|
||||
("Proxmox API", "tcp", "8006"),
|
||||
("Proxmox SPICE", "tcp", "3128"),
|
||||
]
|
||||
|
||||
|
||||
def seed_demo_data(db: Session) -> None:
|
||||
if db.scalar(select(User).where(User.email == "admin@nexafabric.local")):
|
||||
return
|
||||
|
||||
super_admin = Role(name="Super Admin", permissions=["*"])
|
||||
roles = [
|
||||
super_admin,
|
||||
Role(name="Network Admin", permissions=["networks:*", "ipam:*", "clusters:read"]),
|
||||
Role(name="Security Admin", permissions=["policies:*", "firewall:*", "security-groups:*"]),
|
||||
Role(name="Tenant Admin", permissions=["tenants:read", "projects:*"]),
|
||||
Role(name="Auditor", permissions=["audit:read", "clusters:read", "policies:read"]),
|
||||
Role(name="Read Only User", permissions=["*:read"]),
|
||||
]
|
||||
user = User(
|
||||
email="admin@nexafabric.local",
|
||||
display_name="NexaFabric Administrator",
|
||||
password_hash=hash_password(get_settings().demo_admin_password),
|
||||
roles=[super_admin],
|
||||
)
|
||||
db.add_all(roles + [user])
|
||||
|
||||
tenants = [
|
||||
Tenant(name="Platform", description="Shared infrastructure and platform services"),
|
||||
Tenant(name="Finance", description="Finance applications"),
|
||||
Tenant(name="Research", description="Lab and engineering workloads"),
|
||||
]
|
||||
db.add_all(tenants)
|
||||
db.flush()
|
||||
|
||||
projects = [
|
||||
Project(tenant_id=tenants[0].id, name="Core Services", description="DNS, auth, monitoring"),
|
||||
Project(tenant_id=tenants[1].id, name="ERP", description="Finance ERP workloads"),
|
||||
Project(tenant_id=tenants[2].id, name="Lab", description="Research lab systems"),
|
||||
]
|
||||
db.add_all(projects)
|
||||
db.flush()
|
||||
|
||||
cluster = Cluster(
|
||||
name="Demo Proxmox Cluster",
|
||||
api_url="https://pve-demo.local:8006",
|
||||
token_ref="demo-token-reference",
|
||||
last_sync_status="success",
|
||||
)
|
||||
db.add(cluster)
|
||||
db.flush()
|
||||
|
||||
nodes = [
|
||||
Node(cluster_id=cluster.id, name="pve-01", status="online", cpu_count=32, memory_mb=131072),
|
||||
Node(cluster_id=cluster.id, name="pve-02", status="online", cpu_count=32, memory_mb=131072),
|
||||
Node(cluster_id=cluster.id, name="pve-03", status="warning", cpu_count=24, memory_mb=98304),
|
||||
]
|
||||
db.add_all(nodes)
|
||||
db.flush()
|
||||
|
||||
networks = [
|
||||
Network(cluster_id=cluster.id, project_id=projects[0].id, name="mgmt", kind="bridge", vlan_id=10, gateway="10.10.10.1", dns=["10.10.10.10"], tags=["management"], description="Management bridge"),
|
||||
Network(cluster_id=cluster.id, project_id=projects[0].id, name="services", kind="vlan", vlan_id=20, gateway="10.20.0.1", dns=["10.20.0.10"], tags=["shared"], description="Shared services"),
|
||||
Network(cluster_id=cluster.id, project_id=projects[1].id, name="finance-app", kind="vxlan", vlan_id=120, gateway="10.120.0.1", dns=["10.20.0.10"], tags=["finance"], description="Finance application tier"),
|
||||
Network(cluster_id=cluster.id, project_id=projects[2].id, name="research-lab", kind="vnet", vlan_id=220, gateway="10.220.0.1", dns=["10.20.0.10"], tags=["lab"], description="Research tenant network"),
|
||||
]
|
||||
db.add_all(networks)
|
||||
db.flush()
|
||||
|
||||
subnets = [
|
||||
Subnet(network_id=networks[0].id, cidr="10.10.10.0/24", gateway="10.10.10.1", dns=["10.10.10.10"]),
|
||||
Subnet(network_id=networks[1].id, cidr="10.20.0.0/24", gateway="10.20.0.1", dns=["10.20.0.10"]),
|
||||
Subnet(network_id=networks[2].id, cidr="10.120.0.0/24", gateway="10.120.0.1", dns=["10.20.0.10"]),
|
||||
]
|
||||
db.add_all(subnets)
|
||||
db.flush()
|
||||
|
||||
workloads = []
|
||||
for idx in range(10):
|
||||
project = projects[idx % len(projects)]
|
||||
workloads.append(
|
||||
Workload(
|
||||
cluster_id=cluster.id,
|
||||
node_id=nodes[idx % len(nodes)].id,
|
||||
project_id=project.id,
|
||||
external_id=str(100 + idx),
|
||||
name=f"{project.name.lower().replace(' ', '-')}-{idx + 1}",
|
||||
kind="qemu" if idx % 3 else "lxc",
|
||||
status="running" if idx != 7 else "stopped",
|
||||
tags=["web"] if idx % 2 else ["db"],
|
||||
)
|
||||
)
|
||||
db.add_all(workloads)
|
||||
db.flush()
|
||||
|
||||
db.add_all(
|
||||
[
|
||||
IpAddress(subnet_id=subnets[0].id, address="10.10.10.20", status="assigned", workload_id=workloads[0].id),
|
||||
IpAddress(subnet_id=subnets[1].id, address="10.20.0.50", status="reserved", note="Load balancer VIP"),
|
||||
IpAddress(subnet_id=subnets[2].id, address="10.120.0.99", status="conflict", note="Duplicate detected during import"),
|
||||
]
|
||||
)
|
||||
|
||||
groups = [
|
||||
SecurityGroup(project_id=projects[0].id, name="Management", description="Administrative access"),
|
||||
SecurityGroup(project_id=projects[1].id, name="Finance Web", description="Finance web tier"),
|
||||
SecurityGroup(project_id=projects[1].id, name="Finance DB", description="Finance database tier"),
|
||||
]
|
||||
db.add_all(groups)
|
||||
|
||||
db.add_all(
|
||||
[
|
||||
Policy(
|
||||
project_id=projects[1].id,
|
||||
name="Finance web to database",
|
||||
definition={
|
||||
"source": "sg:Finance Web",
|
||||
"destination": "sg:Finance DB",
|
||||
"service": {"protocol": "tcp", "ports": "5432"},
|
||||
"action": "allow",
|
||||
"direction": "egress",
|
||||
"logging": True,
|
||||
"description": "Allow PostgreSQL from finance web tier to database tier.",
|
||||
},
|
||||
),
|
||||
Policy(
|
||||
project_id=projects[0].id,
|
||||
name="Management SSH",
|
||||
definition={
|
||||
"source": "network:mgmt",
|
||||
"destination": "any",
|
||||
"service": {"protocol": "tcp", "ports": "22"},
|
||||
"action": "allow",
|
||||
"direction": "ingress",
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
db.add_all([ServiceCatalogItem(name=name, protocol=proto, ports=ports, editable=True) for name, proto, ports in SERVICES])
|
||||
db.add(AuditLog(user_id=user.id, action="seed.created", object_type="system", result="success"))
|
||||
db.commit()
|
||||
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.domain import AuditLog
|
||||
|
||||
|
||||
def write_audit(
|
||||
db: Session,
|
||||
*,
|
||||
action: str,
|
||||
object_type: str,
|
||||
object_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
old_values: dict | None = None,
|
||||
new_values: dict | None = None,
|
||||
result: str = "success",
|
||||
error_text: str | None = None,
|
||||
) -> AuditLog:
|
||||
audit = AuditLog(
|
||||
action=action,
|
||||
object_type=object_type,
|
||||
object_id=object_id,
|
||||
user_id=user_id,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
result=result,
|
||||
error_text=error_text,
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
db.refresh(audit)
|
||||
return audit
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from app.models.domain import Cluster, Policy
|
||||
from app.schemas.domain import FirewallPreview
|
||||
from app.services.policy_engine import PolicyEngine
|
||||
from app.services.providers.base import ProviderConnection
|
||||
from app.services.providers.registry import get_provider
|
||||
|
||||
|
||||
class FirewallOrchestrator:
|
||||
def __init__(self) -> None:
|
||||
self.policy_engine = PolicyEngine()
|
||||
|
||||
async def preview(self, cluster: Cluster, policy: Policy) -> FirewallPreview:
|
||||
compiled = self.policy_engine.compile(policy)
|
||||
provider = get_provider(cluster.provider)
|
||||
connection = ProviderConnection(
|
||||
api_url=cluster.api_url,
|
||||
token=cluster.token_ref or "",
|
||||
verify_tls=cluster.verify_tls,
|
||||
read_only=cluster.mode == "read_only",
|
||||
)
|
||||
provider_preview = await provider.preview_rules(connection, compiled["rules"])
|
||||
return FirewallPreview(
|
||||
policy_id=policy.id,
|
||||
dry_run=True,
|
||||
generated_rules=provider_preview["generated"],
|
||||
warnings=[*compiled["warnings"], *provider_preview.get("warnings", [])],
|
||||
conflicts=compiled["conflicts"],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from typing import Any
|
||||
|
||||
from app.models.domain import Policy
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
def compile(self, policy: Policy) -> dict[str, Any]:
|
||||
definition = policy.definition or {}
|
||||
source = definition.get("source", "any")
|
||||
destination = definition.get("destination", "any")
|
||||
service = definition.get("service", {"protocol": "any", "ports": "any"})
|
||||
action = definition.get("action", "allow")
|
||||
direction = definition.get("direction", "ingress")
|
||||
|
||||
generated_rule = {
|
||||
"policy_id": policy.id,
|
||||
"policy_version": policy.version,
|
||||
"source": source,
|
||||
"destination": destination,
|
||||
"protocol": service.get("protocol", "any"),
|
||||
"ports": service.get("ports", "any"),
|
||||
"direction": direction,
|
||||
"action": action,
|
||||
"logging": bool(definition.get("logging", False)),
|
||||
"description": definition.get("description", policy.name),
|
||||
}
|
||||
|
||||
warnings = []
|
||||
if source == "any" and destination == "any":
|
||||
warnings.append("Policy targets all sources and destinations.")
|
||||
if action == "allow" and service.get("ports") == "any":
|
||||
warnings.append("Broad allow policy uses all ports.")
|
||||
|
||||
return {"rules": [generated_rule], "warnings": warnings, "conflicts": []}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderConnection:
|
||||
api_url: str
|
||||
token: str
|
||||
verify_tls: bool = True
|
||||
read_only: bool = True
|
||||
|
||||
|
||||
class HypervisorProvider(ABC):
|
||||
@abstractmethod
|
||||
async def test_connection(self, connection: ProviderConnection) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class InventoryProvider(ABC):
|
||||
@abstractmethod
|
||||
async def sync_inventory(self, connection: ProviderConnection) -> dict[str, list[dict[str, Any]]]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class NetworkProvider(ABC):
|
||||
@abstractmethod
|
||||
async def list_networks(self, connection: ProviderConnection) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class FirewallProvider(ABC):
|
||||
@abstractmethod
|
||||
async def preview_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Provider(
|
||||
HypervisorProvider,
|
||||
InventoryProvider,
|
||||
NetworkProvider,
|
||||
FirewallProvider,
|
||||
ABC,
|
||||
):
|
||||
name: str
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from typing import Any
|
||||
|
||||
from app.services.providers.base import Provider, ProviderConnection
|
||||
|
||||
|
||||
class DemoProvider(Provider):
|
||||
name = "demo"
|
||||
|
||||
async def test_connection(self, connection: ProviderConnection) -> dict[str, Any]:
|
||||
return {"version": "demo", "api_url": connection.api_url, "mode": "offline"}
|
||||
|
||||
async def sync_inventory(self, connection: ProviderConnection) -> dict[str, list[dict[str, Any]]]:
|
||||
return {
|
||||
"nodes": [
|
||||
{"node": "demo-pve-01", "status": "online", "maxcpu": 16, "maxmem": 68719476736},
|
||||
{"node": "demo-pve-02", "status": "online", "maxcpu": 16, "maxmem": 68719476736},
|
||||
],
|
||||
"workloads": [
|
||||
{"vmid": 201, "name": "demo-web-01", "type": "qemu", "status": "running", "node": "demo-pve-01"},
|
||||
{"vmid": 202, "name": "demo-db-01", "type": "qemu", "status": "running", "node": "demo-pve-02"},
|
||||
],
|
||||
"networks": [
|
||||
{"name": "vmbr0", "type": "bridge", "vlan": 10},
|
||||
{"name": "vnet-prod", "type": "vnet", "vlan": 120},
|
||||
],
|
||||
}
|
||||
|
||||
async def list_networks(self, connection: ProviderConnection) -> list[dict[str, Any]]:
|
||||
return (await self.sync_inventory(connection))["networks"]
|
||||
|
||||
async def preview_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {"provider": self.name, "generated": rules, "warnings": ["Demo provider preview."]}
|
||||
|
||||
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {"applied": False, "reason": "Demo provider never applies firewall changes.", "rules": rules}
|
||||
@@ -0,0 +1,56 @@
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.providers.base import Provider, ProviderConnection
|
||||
|
||||
|
||||
class ProxmoxProvider(Provider):
|
||||
name = "proxmox"
|
||||
|
||||
async def test_connection(self, connection: ProviderConnection) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=10) as client:
|
||||
response = await client.get(
|
||||
f"{connection.api_url.rstrip('/')}/api2/json/version",
|
||||
headers={"Authorization": connection.token},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json().get("data", {})
|
||||
|
||||
async def sync_inventory(self, connection: ProviderConnection) -> dict[str, list[dict[str, Any]]]:
|
||||
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
|
||||
resources = await client.get(
|
||||
f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources",
|
||||
headers={"Authorization": connection.token},
|
||||
)
|
||||
resources.raise_for_status()
|
||||
data = resources.json().get("data", [])
|
||||
|
||||
nodes = [item for item in data if item.get("type") == "node"]
|
||||
workloads = [item for item in data if item.get("type") in {"qemu", "lxc"}]
|
||||
networks = await self.list_networks(connection)
|
||||
return {"nodes": nodes, "workloads": workloads, "networks": networks}
|
||||
|
||||
async def list_networks(self, connection: ProviderConnection) -> list[dict[str, Any]]:
|
||||
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
|
||||
resources = await client.get(
|
||||
f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources",
|
||||
headers={"Authorization": connection.token},
|
||||
)
|
||||
resources.raise_for_status()
|
||||
data = resources.json().get("data", [])
|
||||
return [item for item in data if item.get("type") in {"network", "sdn"}]
|
||||
|
||||
async def preview_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {
|
||||
"provider": self.name,
|
||||
"read_only": connection.read_only,
|
||||
"generated": rules,
|
||||
"warnings": ["Preview only. No Proxmox firewall changes were sent."],
|
||||
}
|
||||
|
||||
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if connection.read_only:
|
||||
return {"applied": False, "reason": "Cluster is read-only", "rules": rules}
|
||||
return {"applied": False, "reason": "Apply adapter intentionally requires explicit implementation", "rules": rules}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from app.services.providers.base import Provider
|
||||
from app.services.providers.demo import DemoProvider
|
||||
from app.services.providers.proxmox import ProxmoxProvider
|
||||
|
||||
|
||||
_providers: dict[str, Provider] = {"proxmox": ProxmoxProvider(), "demo": DemoProvider()}
|
||||
|
||||
|
||||
def get_provider(name: str) -> Provider:
|
||||
return _providers[name]
|
||||
@@ -0,0 +1,12 @@
|
||||
import time
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("NexaFabric worker started. Configure Celery queues for production job execution.", flush=True)
|
||||
while True:
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
[project]
|
||||
name = "nexafabric-backend"
|
||||
version = "0.1.0"
|
||||
description = "NexaFabric FastAPI backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.13.2",
|
||||
"argon2-cffi>=23.1.0",
|
||||
"celery>=5.4.0",
|
||||
"cryptography>=43.0.0",
|
||||
"email-validator>=2.2.0",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.27.0",
|
||||
"passlib>=1.7.4",
|
||||
"psycopg[binary]>=3.2.1",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"redis>=5.0.8",
|
||||
"sqlalchemy>=2.0.32",
|
||||
"uvicorn[standard]>=0.30.6",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.2",
|
||||
"pytest-asyncio>=0.23.8",
|
||||
"ruff>=0.6.3",
|
||||
"mypy>=1.11.1",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
@@ -0,0 +1,26 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_healthz() -> None:
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_login_and_dashboard() -> None:
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "admin@nexafabric.local", "password": "ChangeMe_UseEnvInstead"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token = response.json()["access_token"]
|
||||
|
||||
dashboard = client.get("/api/v1/dashboard", headers={"Authorization": f"Bearer {token}"})
|
||||
assert dashboard.status_code == 200
|
||||
assert dashboard.json()["clusters"] >= 1
|
||||
|
||||
Reference in New Issue
Block a user