Initial commit
This commit is contained in:
commit
47c18e03fe
38
README.md
Normal file
38
README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# AWS EC2 Panel (FastAPI + Vue 3)
|
||||
|
||||
Minimal multi-tenant EC2 管理面板示例,后端使用 FastAPI/SQLAlchemy,前端使用 Vue 3 + Vite + TypeScript + Naive UI + Tailwind。
|
||||
|
||||
## 数据库初始化
|
||||
1. 确认 MySQL 8 已安装并创建空库:
|
||||
```sql
|
||||
SOURCE db_schema.md;
|
||||
```
|
||||
2. 准备 `backend/.env`(复制 `backend/.env.example` 并修改 DB_URL/JWT_SECRET 等)。
|
||||
|
||||
## 启动后端
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -U pip
|
||||
pip install fastapi uvicorn[standard] sqlalchemy[asyncio] asyncmy pydantic pydantic-settings python-jose passlib[bcrypt] boto3
|
||||
uvicorn backend.app:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
健康检查:`GET http://localhost:8000/healthz`
|
||||
|
||||
元数据接口示例:
|
||||
- 区域列表:`GET /api/v1/instances/meta/aws/regions`
|
||||
- VPC/子网/安全组:`GET /api/v1/instances/meta/aws/network?credential_id=1®ion=ap-northeast-1`
|
||||
- KeyPair 列表:`GET /api/v1/instances/meta/aws/keypairs?credential_id=1®ion=ap-northeast-1`
|
||||
|
||||
## 启动前端
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
默认前端开发服务器使用 `/api` 代理到后端(在 `vite.config.ts` 可调整)。
|
||||
|
||||
## 目录速览
|
||||
- `backend/`:FastAPI 应用、路由、SQLAlchemy 模型、AWS 封装。
|
||||
- `frontend/`:Vue 3 + Vite + Naive UI 前端。
|
||||
- `db_schema.md`:MySQL 表结构(务必保持字段/表名一致)。
|
||||
6
backend/.env
Normal file
6
backend/.env
Normal file
@ -0,0 +1,6 @@
|
||||
DB_URL=mysql+asyncmy://aws_ec2_panel:74110ZSH@localhost:3306
|
||||
JWT_SECRET=change-me
|
||||
JWT_EXPIRE_MINUTES=720
|
||||
AWS_PROXY_URL=
|
||||
AWS_TIMEOUT=30
|
||||
AUTO_OPEN_SECURITY_GROUP_ENABLED=true
|
||||
6
backend/.env.example
Normal file
6
backend/.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
DB_URL=mysql+asyncmy://root:74110ZSH@localhost:3306/aws_ec2_panel?charset=utf8mb4
|
||||
JWT_SECRET=change-me
|
||||
JWT_EXPIRE_MINUTES=720
|
||||
AWS_PROXY_URL=
|
||||
AWS_TIMEOUT=30
|
||||
AUTO_OPEN_SECURITY_GROUP_ENABLED=true
|
||||
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# aws_ec2_panel backend package initializer
|
||||
BIN
backend/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/app.cpython-311.pyc
Normal file
BIN
backend/__pycache__/app.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/config.cpython-311.pyc
Normal file
BIN
backend/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/config.cpython-312.pyc
Normal file
BIN
backend/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/db.cpython-311.pyc
Normal file
BIN
backend/__pycache__/db.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/models.cpython-311.pyc
Normal file
BIN
backend/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
1
backend/api/__init__.py
Normal file
1
backend/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# API dependencies package
|
||||
BIN
backend/api/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/api/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/deps.cpython-311.pyc
Normal file
BIN
backend/api/__pycache__/deps.cpython-311.pyc
Normal file
Binary file not shown.
63
backend/api/deps.py
Normal file
63
backend/api/deps.py
Normal file
@ -0,0 +1,63 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.core.security import decode_token
|
||||
from backend.db.session import get_session
|
||||
from backend.modules.users.models import RoleName, User
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthUser:
|
||||
user: User
|
||||
role_name: str
|
||||
customer_id: Optional[int]
|
||||
token: str
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AuthUser:
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
try:
|
||||
payload = decode_token(credentials.credentials)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
|
||||
|
||||
user = await session.scalar(
|
||||
select(User).where(User.id == int(user_id)).options(selectinload(User.role), selectinload(User.customer))
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User disabled")
|
||||
|
||||
role_name = payload.get("role") or (user.role.name if user.role else "")
|
||||
return AuthUser(user=user, role_name=role_name, customer_id=user.customer_id, token=credentials.credentials)
|
||||
|
||||
|
||||
def require_roles(roles: Iterable[RoleName]):
|
||||
allowed = {r.value for r in roles}
|
||||
|
||||
async def dependency(auth_user: AuthUser = Depends(get_current_user)) -> AuthUser:
|
||||
if auth_user.role_name not in allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
|
||||
return auth_user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_admin(auth_user: AuthUser = Depends(require_roles([RoleName.ADMIN]))) -> AuthUser:
|
||||
return auth_user
|
||||
45
backend/app.py
Normal file
45
backend/app.py
Normal file
@ -0,0 +1,45 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# Ensure models are imported so metadata is populated
|
||||
import backend.db.import_all_models # noqa: F401
|
||||
from backend.modules.auth.router import router as auth_router
|
||||
from backend.modules.users.router import router as users_router
|
||||
from backend.modules.customers.router import router as customers_router
|
||||
from backend.modules.aws_accounts.router import router as aws_credentials_router
|
||||
from backend.modules.instances.router import router as instances_router
|
||||
from backend.modules.jobs.router import router as jobs_router
|
||||
from backend.modules.audit.router import router as audit_router
|
||||
from backend.modules.settings.router import router as settings_router
|
||||
from backend.modules.menus.router import router as menus_router
|
||||
from backend.modules.instances.service import start_global_sync_scheduler
|
||||
|
||||
app = FastAPI(title="AWS EC2 Panel", version="0.2.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(customers_router)
|
||||
app.include_router(aws_credentials_router)
|
||||
app.include_router(instances_router)
|
||||
app.include_router(jobs_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(menus_router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _start_background_tasks():
|
||||
start_global_sync_scheduler()
|
||||
1
backend/auth/__init__.py
Normal file
1
backend/auth/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Authentication package for aws_ec2_panel backend
|
||||
32
backend/auth/jwt_utils.py
Normal file
32
backend/auth/jwt_utils.py
Normal file
@ -0,0 +1,32 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from ..config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.jwt_expire_minutes))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Dict[str, Any]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
return payload
|
||||
except JWTError as exc:
|
||||
raise ValueError("Invalid token") from exc
|
||||
63
backend/auth/router.py
Normal file
63
backend/auth/router.py
Normal file
@ -0,0 +1,63 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..db import get_session
|
||||
from ..dependencies import AuthUser, get_current_user
|
||||
from ..models import AuditAction, AuditLog, AuditResourceType, User
|
||||
from ..schemas import LoginRequest, TokenResponse, UserOut
|
||||
from .jwt_utils import create_access_token, verify_password
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(payload: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)) -> TokenResponse:
|
||||
user = await session.scalar(
|
||||
select(User).where(User.username == payload.username).options(selectinload(User.role), selectinload(User.customer))
|
||||
)
|
||||
if not user or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User disabled")
|
||||
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
token_payload = {"sub": str(user.id), "role": user.role.name if user.role else "", "customer_id": user.customer_id}
|
||||
access_token = create_access_token(token_payload)
|
||||
|
||||
audit = AuditLog(
|
||||
user_id=user.id,
|
||||
customer_id=user.customer_id,
|
||||
action=AuditAction.LOGIN,
|
||||
resource_type=AuditResourceType.USER,
|
||||
resource_id=user.id,
|
||||
description="User login success",
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
session.add(audit)
|
||||
await session.commit()
|
||||
|
||||
return TokenResponse(access_token=access_token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def read_me(auth_user: AuthUser = Depends(get_current_user)) -> UserOut:
|
||||
return UserOut.model_validate(auth_user.user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_token(auth_user: AuthUser = Depends(get_current_user)) -> TokenResponse:
|
||||
token_payload = {
|
||||
"sub": str(auth_user.user.id),
|
||||
"role": auth_user.role_name,
|
||||
"customer_id": auth_user.customer_id,
|
||||
}
|
||||
access_token = create_access_token(token_payload)
|
||||
return TokenResponse(access_token=access_token, user=UserOut.model_validate(auth_user.user))
|
||||
135
backend/aws_ops.py
Normal file
135
backend/aws_ops.py
Normal file
@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
|
||||
from .config import settings
|
||||
from .models import AWSCredential, CredentialType
|
||||
|
||||
|
||||
def _boto_config() -> BotoConfig:
|
||||
proxies = None
|
||||
if settings.aws_proxy_url:
|
||||
proxies = {"https": settings.aws_proxy_url, "http": settings.aws_proxy_url}
|
||||
return BotoConfig(
|
||||
connect_timeout=settings.aws_timeout,
|
||||
read_timeout=settings.aws_timeout,
|
||||
proxies=proxies,
|
||||
)
|
||||
|
||||
|
||||
def build_session(credential: AWSCredential, region: str):
|
||||
cfg = _boto_config()
|
||||
if credential.credential_type == CredentialType.ACCESS_KEY:
|
||||
session = boto3.Session(
|
||||
aws_access_key_id=credential.access_key_id,
|
||||
aws_secret_access_key=credential.secret_access_key,
|
||||
region_name=region or credential.default_region,
|
||||
)
|
||||
return session, cfg
|
||||
|
||||
# Assume role path
|
||||
base_session = boto3.Session(
|
||||
aws_access_key_id=credential.access_key_id,
|
||||
aws_secret_access_key=credential.secret_access_key,
|
||||
region_name=region or credential.default_region,
|
||||
)
|
||||
sts = base_session.client("sts", config=cfg, region_name=region or credential.default_region)
|
||||
assume_kwargs: Dict[str, Any] = {
|
||||
"RoleArn": credential.role_arn,
|
||||
"RoleSessionName": "ec2-panel",
|
||||
}
|
||||
if credential.external_id:
|
||||
assume_kwargs["ExternalId"] = credential.external_id
|
||||
resp = sts.assume_role(**assume_kwargs)
|
||||
creds = resp["Credentials"]
|
||||
session = boto3.Session(
|
||||
aws_access_key_id=creds["AccessKeyId"],
|
||||
aws_secret_access_key=creds["SecretAccessKey"],
|
||||
aws_session_token=creds["SessionToken"],
|
||||
region_name=region or credential.default_region,
|
||||
)
|
||||
return session, cfg
|
||||
|
||||
|
||||
def describe_instances(
|
||||
credential: AWSCredential,
|
||||
region: str,
|
||||
filters: Optional[List[Dict[str, Any]]] = None,
|
||||
instance_ids: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
params: Dict[str, Any] = {}
|
||||
if filters:
|
||||
params["Filters"] = filters
|
||||
if instance_ids:
|
||||
params["InstanceIds"] = instance_ids
|
||||
return client.describe_instances(**params)
|
||||
|
||||
|
||||
def describe_instance_status(
|
||||
credential: AWSCredential, region: str, instance_ids: List[str]
|
||||
) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.describe_instance_status(InstanceIds=instance_ids, IncludeAllInstances=True)
|
||||
|
||||
|
||||
def run_instances(
|
||||
credential: AWSCredential,
|
||||
region: str,
|
||||
ami_id: str,
|
||||
instance_type: str,
|
||||
key_name: Optional[str],
|
||||
security_groups: Optional[List[str]],
|
||||
subnet_id: Optional[str],
|
||||
min_count: int = 1,
|
||||
max_count: int = 1,
|
||||
name_tag: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
params: Dict[str, Any] = {
|
||||
"ImageId": ami_id,
|
||||
"InstanceType": instance_type,
|
||||
"MinCount": min_count,
|
||||
"MaxCount": max_count,
|
||||
}
|
||||
if key_name:
|
||||
params["KeyName"] = key_name
|
||||
if security_groups:
|
||||
params["SecurityGroupIds"] = security_groups
|
||||
if subnet_id:
|
||||
params["SubnetId"] = subnet_id
|
||||
if name_tag:
|
||||
params["TagSpecifications"] = [
|
||||
{"ResourceType": "instance", "Tags": [{"Key": "Name", "Value": name_tag}]}
|
||||
]
|
||||
return client.run_instances(**params)
|
||||
|
||||
|
||||
def start_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.start_instances(InstanceIds=instance_ids)
|
||||
|
||||
|
||||
def stop_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.stop_instances(InstanceIds=instance_ids)
|
||||
|
||||
|
||||
def reboot_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.reboot_instances(InstanceIds=instance_ids)
|
||||
|
||||
|
||||
def terminate_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.terminate_instances(InstanceIds=instance_ids)
|
||||
27
backend/config.py
Normal file
27
backend/config.py
Normal file
@ -0,0 +1,27 @@
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
db_url: str = Field(
|
||||
"mysql+asyncmy://root:password@localhost:3306/aws_ec2_panel?charset=utf8mb4",
|
||||
description="SQLAlchemy database URL",
|
||||
)
|
||||
jwt_secret: str = Field("change-me", description="Secret used to sign JWT tokens")
|
||||
jwt_expire_minutes: int = Field(60, description="JWT expiration in minutes")
|
||||
jwt_algorithm: str = Field("HS256", description="JWT signing algorithm")
|
||||
aws_proxy_url: Optional[str] = Field(default=None, description="Optional proxy URL for AWS SDK")
|
||||
aws_timeout: int = Field(30, description="Timeout seconds for AWS API calls")
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_prefix="", extra="ignore")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
1
backend/core/__init__.py
Normal file
1
backend/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Core package initializer
|
||||
BIN
backend/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/config.cpython-311.pyc
Normal file
BIN
backend/core/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/security.cpython-311.pyc
Normal file
BIN
backend/core/__pycache__/security.cpython-311.pyc
Normal file
Binary file not shown.
30
backend/core/config.py
Normal file
30
backend/core/config.py
Normal file
@ -0,0 +1,30 @@
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
db_url: str = Field(
|
||||
"mysql+asyncmy://root:password@localhost:3306/aws_ec2_panel?charset=utf8mb4", description="SQLAlchemy DB URL"
|
||||
)
|
||||
jwt_secret: str = Field("change-me", description="JWT secret key")
|
||||
jwt_algorithm: str = Field("HS256", description="JWT algorithm")
|
||||
jwt_expire_minutes: int = Field(60, description="JWT expiry in minutes")
|
||||
aws_proxy_url: Optional[str] = Field(default=None, description="Optional proxy for AWS SDK")
|
||||
aws_timeout: int = Field(30, description="AWS SDK timeout seconds")
|
||||
auto_open_sg_enabled: bool = Field(True, description="Auto-create/use open-all security group when none provided")
|
||||
scope_sync_min_interval_seconds: int = Field(15, description="Min interval between scope sync enqueue")
|
||||
global_sync_interval_minutes: int = Field(5, description="Interval minutes for background global sync")
|
||||
global_sync_max_concurrency: int = Field(3, description="Max concurrent scope sync tasks")
|
||||
|
||||
model_config = SettingsConfigDict(env_file=("backend/.env", ".env"), env_prefix="", extra="ignore")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
8
backend/core/logging.py
Normal file
8
backend/core/logging.py
Normal file
@ -0,0 +1,8 @@
|
||||
import logging
|
||||
|
||||
|
||||
def configure_logging(level: int = logging.INFO) -> None:
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
||||
)
|
||||
32
backend/core/security.py
Normal file
32
backend/core/security.py
Normal file
@ -0,0 +1,32 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from backend.core.config import settings
|
||||
|
||||
# Use pbkdf2_sha256 to avoid native bcrypt backend issues across platforms.
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.jwt_expire_minutes))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Dict[str, Any]:
|
||||
try:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
except JWTError as exc:
|
||||
raise ValueError("Invalid token") from exc
|
||||
17
backend/db.py
Normal file
17
backend/db.py
Normal file
@ -0,0 +1,17 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from .config import settings
|
||||
from .models import Base
|
||||
|
||||
engine = create_async_engine(settings.db_url, echo=False, pool_pre_ping=True, future=True)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
1
backend/db/__init__.py
Normal file
1
backend/db/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Database package initializer
|
||||
BIN
backend/db/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/db/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/db/__pycache__/base.cpython-311.pyc
Normal file
BIN
backend/db/__pycache__/base.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/db/__pycache__/import_all_models.cpython-311.pyc
Normal file
BIN
backend/db/__pycache__/import_all_models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/db/__pycache__/session.cpython-311.pyc
Normal file
BIN
backend/db/__pycache__/session.cpython-311.pyc
Normal file
Binary file not shown.
6
backend/db/base.py
Normal file
6
backend/db/base.py
Normal file
@ -0,0 +1,6 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all ORM models."""
|
||||
|
||||
12
backend/db/import_all_models.py
Normal file
12
backend/db/import_all_models.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""
|
||||
Import all ORM models so Base.metadata has every table registered.
|
||||
This module should be imported before calling metadata.create_all or running Alembic autogenerate.
|
||||
"""
|
||||
|
||||
from backend.modules.users.models import Role, User # noqa: F401
|
||||
from backend.modules.customers.models import Customer # noqa: F401
|
||||
from backend.modules.aws_accounts.models import AWSCredential, CustomerCredential # noqa: F401
|
||||
from backend.modules.instances.models import Instance # noqa: F401
|
||||
from backend.modules.jobs.models import Job, JobItem # noqa: F401
|
||||
from backend.modules.audit.models import AuditLog # noqa: F401
|
||||
from backend.modules.settings.models import Setting # noqa: F401
|
||||
53
backend/db/init_admin.py
Normal file
53
backend/db/init_admin.py
Normal file
@ -0,0 +1,53 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT_DIR.parent) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR.parent))
|
||||
|
||||
from backend.core.security import get_password_hash # noqa: E402
|
||||
from backend.db import import_all_models # noqa: F401,E402
|
||||
from backend.db.session import AsyncSessionLocal # noqa: E402
|
||||
from backend.modules.users.models import Role, RoleName, User # noqa: E402
|
||||
|
||||
|
||||
async def ensure_roles(session):
|
||||
existing = {r.name for r in (await session.scalars(select(Role))).all()}
|
||||
for name in [RoleName.ADMIN.value, RoleName.CUSTOMER_ADMIN.value, RoleName.CUSTOMER_USER.value]:
|
||||
if name not in existing:
|
||||
session.add(Role(name=name, description=f"{name} role"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def ensure_admin(username: str, password: str):
|
||||
async with AsyncSessionLocal() as session:
|
||||
await ensure_roles(session)
|
||||
role = await session.scalar(select(Role).where(Role.name == RoleName.ADMIN.value))
|
||||
user = await session.scalar(select(User).where(User.username == username))
|
||||
if user:
|
||||
print(f"User {username} already exists (id={user.id})")
|
||||
return
|
||||
admin = User(
|
||||
username=username,
|
||||
email=None,
|
||||
password_hash=get_password_hash(password),
|
||||
role_id=role.id,
|
||||
customer_id=None,
|
||||
is_active=1,
|
||||
)
|
||||
session.add(admin)
|
||||
try:
|
||||
await session.commit()
|
||||
await session.refresh(admin)
|
||||
print(f"Created admin user {username} with id={admin.id}")
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
print("Failed to create admin (maybe already exists)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(ensure_admin("admin", "Admin@123"))
|
||||
18
backend/db/session.py
Normal file
18
backend/db/session.py
Normal file
@ -0,0 +1,18 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from backend.core.config import settings
|
||||
from backend.db.base import Base
|
||||
|
||||
engine = create_async_engine(settings.db_url, echo=False, pool_pre_ping=True, future=True)
|
||||
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_models() -> None:
|
||||
"""Create all tables based on imported models."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
72
backend/dependencies.py
Normal file
72
backend/dependencies.py
Normal file
@ -0,0 +1,72 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from .auth.jwt_utils import decode_token
|
||||
from .db import get_session
|
||||
from .models import RoleName, User
|
||||
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthUser:
|
||||
user: User
|
||||
role_name: str
|
||||
customer_id: Optional[int]
|
||||
token: str
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AuthUser:
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
try:
|
||||
payload = decode_token(credentials.credentials)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
|
||||
|
||||
user = await session.scalar(
|
||||
select(User)
|
||||
.where(User.id == int(user_id))
|
||||
.options(selectinload(User.role), selectinload(User.customer))
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User disabled")
|
||||
|
||||
role_name = payload.get("role") or (user.role.name if user.role else "")
|
||||
customer_id = (
|
||||
payload.get("customer_id")
|
||||
or user.customer_id
|
||||
or (user.customer.id if user.customer is not None else None)
|
||||
)
|
||||
return AuthUser(user=user, role_name=role_name, customer_id=customer_id, token=credentials.credentials)
|
||||
|
||||
|
||||
def require_roles(roles: Iterable[RoleName]):
|
||||
allowed = {r.value for r in roles}
|
||||
|
||||
async def dependency(auth_user: AuthUser = Depends(get_current_user)) -> AuthUser:
|
||||
if auth_user.role_name not in allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
|
||||
return auth_user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_admin(auth_user: AuthUser = Depends(require_roles([RoleName.ADMIN]))) -> AuthUser:
|
||||
return auth_user
|
||||
29
backend/main.py
Normal file
29
backend/main.py
Normal file
@ -0,0 +1,29 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .auth import router as auth_router
|
||||
from .routers import audit_logs, aws_credentials, customers, instances, jobs, menus, users
|
||||
|
||||
app = FastAPI(title="AWS EC2 Panel", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(customers.router)
|
||||
app.include_router(aws_credentials.router)
|
||||
app.include_router(instances.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(audit_logs.router)
|
||||
app.include_router(menus.router)
|
||||
406
backend/models.py
Normal file
406
backend/models.py
Normal file
@ -0,0 +1,406 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Enum as SAEnum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.mysql import BIGINT, INTEGER, TINYINT
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base declarative class for all models."""
|
||||
|
||||
|
||||
# Enumerations centralized to avoid magic strings across the codebase.
|
||||
class CredentialType(str, Enum):
|
||||
ACCESS_KEY = "ACCESS_KEY"
|
||||
ASSUME_ROLE = "ASSUME_ROLE"
|
||||
|
||||
|
||||
class RoleName(str, Enum):
|
||||
ADMIN = "ADMIN"
|
||||
CUSTOMER_ADMIN = "CUSTOMER_ADMIN"
|
||||
CUSTOMER_USER = "CUSTOMER_USER"
|
||||
|
||||
|
||||
class InstanceStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
STOPPING = "STOPPING"
|
||||
STOPPED = "STOPPED"
|
||||
SHUTTING_DOWN = "SHUTTING_DOWN"
|
||||
TERMINATED = "TERMINATED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class InstanceDesiredStatus(str, Enum):
|
||||
RUNNING = "RUNNING"
|
||||
STOPPED = "STOPPED"
|
||||
TERMINATED = "TERMINATED"
|
||||
|
||||
|
||||
class JobType(str, Enum):
|
||||
SYNC_INSTANCES = "SYNC_INSTANCES"
|
||||
START_INSTANCES = "START_INSTANCES"
|
||||
STOP_INSTANCES = "STOP_INSTANCES"
|
||||
REBOOT_INSTANCES = "REBOOT_INSTANCES"
|
||||
TERMINATE_INSTANCES = "TERMINATE_INSTANCES"
|
||||
CREATE_INSTANCES = "CREATE_INSTANCES"
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCESS = "SUCCESS"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
class JobItemResourceType(str, Enum):
|
||||
INSTANCE = "INSTANCE"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
class JobItemAction(str, Enum):
|
||||
CREATE = "CREATE"
|
||||
START = "START"
|
||||
STOP = "STOP"
|
||||
REBOOT = "REBOOT"
|
||||
TERMINATE = "TERMINATE"
|
||||
SYNC = "SYNC"
|
||||
|
||||
|
||||
class JobItemStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCESS = "SUCCESS"
|
||||
FAILED = "FAILED"
|
||||
SKIPPED = "SKIPPED"
|
||||
|
||||
|
||||
class AuditAction(str, Enum):
|
||||
LOGIN = "LOGIN"
|
||||
LOGOUT = "LOGOUT"
|
||||
INSTANCE_CREATE = "INSTANCE_CREATE"
|
||||
INSTANCE_START = "INSTANCE_START"
|
||||
INSTANCE_STOP = "INSTANCE_STOP"
|
||||
INSTANCE_REBOOT = "INSTANCE_REBOOT"
|
||||
INSTANCE_TERMINATE = "INSTANCE_TERMINATE"
|
||||
INSTANCE_SYNC = "INSTANCE_SYNC"
|
||||
CREDENTIAL_CREATE = "CREDENTIAL_CREATE"
|
||||
CREDENTIAL_UPDATE = "CREDENTIAL_UPDATE"
|
||||
CREDENTIAL_DELETE = "CREDENTIAL_DELETE"
|
||||
CUSTOMER_CREATE = "CUSTOMER_CREATE"
|
||||
CUSTOMER_UPDATE = "CUSTOMER_UPDATE"
|
||||
CUSTOMER_DELETE = "CUSTOMER_DELETE"
|
||||
USER_CREATE = "USER_CREATE"
|
||||
USER_UPDATE = "USER_UPDATE"
|
||||
USER_DELETE = "USER_DELETE"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
class AuditResourceType(str, Enum):
|
||||
USER = "USER"
|
||||
CUSTOMER = "CUSTOMER"
|
||||
AWS_CREDENTIAL = "AWS_CREDENTIAL"
|
||||
INSTANCE = "INSTANCE"
|
||||
JOB = "JOB"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
__table_args__ = (UniqueConstraint("name", name="uniq_role_name"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="role")
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
__table_args__ = (UniqueConstraint("name", name="uniq_customer_name"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
is_active: Mapped[int] = mapped_column(TINYINT(1), server_default=text("1"), nullable=False)
|
||||
quota_instances: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True))
|
||||
notes: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="customer")
|
||||
instances: Mapped[list["Instance"]] = relationship("Instance", back_populates="customer")
|
||||
customer_credentials: Mapped[list["CustomerCredential"]] = relationship(
|
||||
"CustomerCredential", back_populates="customer"
|
||||
)
|
||||
jobs: Mapped[list["Job"]] = relationship("Job", back_populates="customer")
|
||||
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="customer")
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("username", name="uniq_username"),
|
||||
UniqueConstraint("email", name="uniq_email"),
|
||||
Index("idx_users_role", "role_id"),
|
||||
Index("idx_users_customer", "customer_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
email: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id", ondelete="RESTRICT", onupdate="CASCADE"), nullable=False)
|
||||
customer_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("customers.id", ondelete="SET NULL", onupdate="CASCADE")
|
||||
)
|
||||
is_active: Mapped[int] = mapped_column(TINYINT(1), server_default=text("1"), nullable=False)
|
||||
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
role: Mapped["Role"] = relationship("Role", back_populates="users")
|
||||
customer: Mapped[Optional["Customer"]] = relationship("Customer", back_populates="users")
|
||||
jobs: Mapped[list["Job"]] = relationship("Job", back_populates="created_by_user")
|
||||
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="user")
|
||||
|
||||
|
||||
class AWSCredential(Base):
|
||||
__tablename__ = "aws_credentials"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("account_id", "name", name="uniq_credential_account_name"),
|
||||
Index("idx_credential_account", "account_id"),
|
||||
Index("idx_credential_active", "is_active"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
account_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
credential_type: Mapped[CredentialType] = mapped_column(
|
||||
SAEnum(CredentialType), nullable=False, server_default=text("'ACCESS_KEY'")
|
||||
)
|
||||
access_key_id: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
secret_access_key: Mapped[Optional[str]] = mapped_column(String(256))
|
||||
role_arn: Mapped[Optional[str]] = mapped_column(String(256))
|
||||
external_id: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
default_region: Mapped[str] = mapped_column(String(32), nullable=False, server_default=text("'ap-northeast-1'"))
|
||||
is_active: Mapped[int] = mapped_column(TINYINT(1), server_default=text("1"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
instances: Mapped[list["Instance"]] = relationship("Instance", back_populates="credential")
|
||||
customer_credentials: Mapped[list["CustomerCredential"]] = relationship(
|
||||
"CustomerCredential", back_populates="credential"
|
||||
)
|
||||
|
||||
|
||||
class CustomerCredential(Base):
|
||||
__tablename__ = "customer_credentials"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("customer_id", "credential_id", name="uniq_customer_credential"),
|
||||
Index("idx_cc_customer", "customer_id"),
|
||||
Index("idx_cc_credential", "credential_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
customer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("customers.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
|
||||
)
|
||||
credential_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("aws_credentials.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
|
||||
)
|
||||
is_allowed: Mapped[int] = mapped_column(TINYINT(1), server_default=text("1"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
customer: Mapped["Customer"] = relationship("Customer", back_populates="customer_credentials")
|
||||
credential: Mapped["AWSCredential"] = relationship("AWSCredential", back_populates="customer_credentials")
|
||||
|
||||
|
||||
class Instance(Base):
|
||||
__tablename__ = "instances"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("account_id", "region", "instance_id", name="uniq_instance_cloud"),
|
||||
Index("idx_instances_customer", "customer_id"),
|
||||
Index("idx_instances_status", "status"),
|
||||
Index("idx_instances_region", "region"),
|
||||
Index("idx_instances_last_sync", "last_sync"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
customer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("customers.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
|
||||
)
|
||||
credential_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("aws_credentials.id", ondelete="SET NULL", onupdate="CASCADE")
|
||||
)
|
||||
account_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
region: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
az: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
instance_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
name_tag: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
instance_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
ami_id: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
key_name: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
public_ip: Mapped[Optional[str]] = mapped_column(String(45))
|
||||
private_ip: Mapped[Optional[str]] = mapped_column(String(45))
|
||||
status: Mapped[InstanceStatus] = mapped_column(
|
||||
SAEnum(InstanceStatus), nullable=False, server_default=text("'UNKNOWN'")
|
||||
)
|
||||
desired_status: Mapped[Optional[InstanceDesiredStatus]] = mapped_column(SAEnum(InstanceDesiredStatus))
|
||||
security_groups: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
subnet_id: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
vpc_id: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
launched_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
terminated_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
last_sync: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
last_cloud_state: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
customer: Mapped["Customer"] = relationship("Customer", back_populates="instances")
|
||||
credential: Mapped[Optional["AWSCredential"]] = relationship("AWSCredential", back_populates="instances")
|
||||
job_items: Mapped[list["JobItem"]] = relationship("JobItem", back_populates="instance")
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("job_uuid", name="uniq_job_uuid"),
|
||||
Index("idx_jobs_type", "job_type"),
|
||||
Index("idx_jobs_status", "status"),
|
||||
Index("idx_jobs_created_at", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
job_uuid: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
job_type: Mapped[JobType] = mapped_column(SAEnum(JobType), nullable=False)
|
||||
status: Mapped[JobStatus] = mapped_column(SAEnum(JobStatus), nullable=False, server_default=text("'PENDING'"))
|
||||
progress: Mapped[int] = mapped_column(TINYINT(unsigned=True), nullable=False, server_default=text("0"))
|
||||
total_count: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True), server_default=text("0"))
|
||||
success_count: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True), server_default=text("0"))
|
||||
fail_count: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True), server_default=text("0"))
|
||||
skipped_count: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True), server_default=text("0"))
|
||||
payload: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
error_message: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
created_by_user_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL", onupdate="CASCADE")
|
||||
)
|
||||
created_for_customer: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("customers.id", ondelete="SET NULL", onupdate="CASCADE")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
created_by_user: Mapped[Optional["User"]] = relationship("User", back_populates="jobs")
|
||||
customer: Mapped[Optional["Customer"]] = relationship("Customer", back_populates="jobs")
|
||||
items: Mapped[list["JobItem"]] = relationship("JobItem", back_populates="job")
|
||||
|
||||
|
||||
class JobItem(Base):
|
||||
__tablename__ = "job_items"
|
||||
__table_args__ = (
|
||||
Index("idx_job_items_job", "job_id"),
|
||||
Index("idx_job_items_instance", "resource_id"),
|
||||
Index("idx_job_items_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
job_id: Mapped[int] = mapped_column(ForeignKey("jobs.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False)
|
||||
resource_type: Mapped[JobItemResourceType] = mapped_column(
|
||||
SAEnum(JobItemResourceType), nullable=False, server_default=text("'INSTANCE'")
|
||||
)
|
||||
resource_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("instances.id", ondelete="SET NULL", onupdate="CASCADE")
|
||||
)
|
||||
account_id: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
region: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
instance_id: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
action: Mapped[JobItemAction] = mapped_column(SAEnum(JobItemAction), nullable=False)
|
||||
status: Mapped[JobItemStatus] = mapped_column(
|
||||
SAEnum(JobItemStatus), nullable=False, server_default=text("'PENDING'")
|
||||
)
|
||||
error_message: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
extra: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
job: Mapped["Job"] = relationship("Job", back_populates="items")
|
||||
instance: Mapped[Optional["Instance"]] = relationship("Instance", back_populates="job_items")
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
__table_args__ = (
|
||||
Index("idx_audit_customer", "customer_id"),
|
||||
Index("idx_audit_action", "action"),
|
||||
Index("idx_audit_created_at", "created_at"),
|
||||
Index("idx_audit_resource", "resource_type", "resource_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL", onupdate="CASCADE")
|
||||
)
|
||||
customer_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("customers.id", ondelete="SET NULL", onupdate="CASCADE")
|
||||
)
|
||||
action: Mapped[AuditAction] = mapped_column(SAEnum(AuditAction), nullable=False)
|
||||
resource_type: Mapped[AuditResourceType] = mapped_column(SAEnum(AuditResourceType), nullable=False)
|
||||
resource_id: Mapped[Optional[int]] = mapped_column(BIGINT(unsigned=True))
|
||||
description: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
payload: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45))
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
|
||||
user: Mapped[Optional["User"]] = relationship("User", back_populates="audit_logs")
|
||||
customer: Mapped[Optional["Customer"]] = relationship("Customer", back_populates="audit_logs")
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
__table_args__ = (UniqueConstraint("k", name="uniq_settings_key"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
k: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
v: Mapped[Optional[str]] = mapped_column(Text)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
1
backend/modules/__init__.py
Normal file
1
backend/modules/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Modules package initializer kept minimal to avoid circular imports
|
||||
BIN
backend/modules/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/modules/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/modules/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
1
backend/modules/audit/__init__.py
Normal file
1
backend/modules/audit/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# audit package initializer
|
||||
BIN
backend/modules/audit/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/modules/audit/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/audit/__pycache__/models.cpython-311.pyc
Normal file
BIN
backend/modules/audit/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/audit/__pycache__/router.cpython-311.pyc
Normal file
BIN
backend/modules/audit/__pycache__/router.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/audit/__pycache__/schemas.cpython-311.pyc
Normal file
BIN
backend/modules/audit/__pycache__/schemas.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/audit/__pycache__/service.cpython-311.pyc
Normal file
BIN
backend/modules/audit/__pycache__/service.cpython-311.pyc
Normal file
Binary file not shown.
68
backend/modules/audit/models.py
Normal file
68
backend/modules/audit/models.py
Normal file
@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Index, JSON, String, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.db.base import Base
|
||||
|
||||
|
||||
class AuditAction(str, Enum):
|
||||
LOGIN = "LOGIN"
|
||||
LOGOUT = "LOGOUT"
|
||||
INSTANCE_CREATE = "INSTANCE_CREATE"
|
||||
INSTANCE_START = "INSTANCE_START"
|
||||
INSTANCE_STOP = "INSTANCE_STOP"
|
||||
INSTANCE_REBOOT = "INSTANCE_REBOOT"
|
||||
INSTANCE_TERMINATE = "INSTANCE_TERMINATE"
|
||||
INSTANCE_SYNC = "INSTANCE_SYNC"
|
||||
CREDENTIAL_CREATE = "CREDENTIAL_CREATE"
|
||||
CREDENTIAL_UPDATE = "CREDENTIAL_UPDATE"
|
||||
CREDENTIAL_DELETE = "CREDENTIAL_DELETE"
|
||||
CUSTOMER_CREATE = "CUSTOMER_CREATE"
|
||||
CUSTOMER_UPDATE = "CUSTOMER_UPDATE"
|
||||
CUSTOMER_DELETE = "CUSTOMER_DELETE"
|
||||
USER_CREATE = "USER_CREATE"
|
||||
USER_UPDATE = "USER_UPDATE"
|
||||
USER_DELETE = "USER_DELETE"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
class AuditResourceType(str, Enum):
|
||||
USER = "USER"
|
||||
CUSTOMER = "CUSTOMER"
|
||||
AWS_CREDENTIAL = "AWS_CREDENTIAL"
|
||||
INSTANCE = "INSTANCE"
|
||||
JOB = "JOB"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
__table_args__ = (
|
||||
Index("idx_audit_customer", "customer_id"),
|
||||
Index("idx_audit_action", "action"),
|
||||
Index("idx_audit_created_at", "created_at"),
|
||||
Index("idx_audit_resource", "resource_type", "resource_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id", ondelete="SET NULL", onupdate="CASCADE"))
|
||||
customer_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("customers.id", ondelete="SET NULL", onupdate="CASCADE")
|
||||
)
|
||||
action: Mapped[AuditAction] = mapped_column(SAEnum(AuditAction), nullable=False)
|
||||
resource_type: Mapped[AuditResourceType] = mapped_column(SAEnum(AuditResourceType), nullable=False)
|
||||
resource_id: Mapped[Optional[int]] = mapped_column(BIGINT(unsigned=True))
|
||||
description: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
payload: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45))
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
|
||||
user: Mapped[Optional["User"]] = relationship("User")
|
||||
customer: Mapped[Optional["Customer"]] = relationship("Customer")
|
||||
29
backend/modules/audit/router.py
Normal file
29
backend/modules/audit/router.py
Normal file
@ -0,0 +1,29 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.api.deps import AuthUser, require_roles
|
||||
from backend.db.session import get_session
|
||||
from backend.modules.audit.models import AuditAction, AuditResourceType
|
||||
from backend.modules.audit.schemas import AuditLogOut
|
||||
from backend.modules.audit.service import list_audit_logs
|
||||
from backend.modules.users.models import RoleName
|
||||
|
||||
router = APIRouter(prefix="/api/v1/audit_logs", tags=["audit_logs"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[AuditLogOut])
|
||||
async def audit_logs(
|
||||
action: Optional[AuditAction] = None,
|
||||
user_id: Optional[int] = None,
|
||||
customer_id: Optional[int] = None,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_roles([RoleName.ADMIN, RoleName.CUSTOMER_ADMIN])),
|
||||
) -> List[AuditLogOut]:
|
||||
effective_customer_id = customer_id if auth_user.role_name == RoleName.ADMIN.value else auth_user.customer_id
|
||||
logs = await list_audit_logs(session, effective_customer_id, user_id, action, start, end)
|
||||
return [AuditLogOut.model_validate(log) for log in logs]
|
||||
24
backend/modules/audit/schemas.py
Normal file
24
backend/modules/audit/schemas.py
Normal file
@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from backend.modules.audit.models import AuditAction, AuditResourceType
|
||||
|
||||
|
||||
class AuditLogOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: Optional[int] = None
|
||||
user_name: Optional[str] = None
|
||||
customer_id: Optional[int] = None
|
||||
customer_name: Optional[str] = None
|
||||
action: AuditAction
|
||||
resource_type: AuditResourceType
|
||||
resource_id: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
payload: Optional[Any] = None
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
created_at: datetime
|
||||
41
backend/modules/audit/service.py
Normal file
41
backend/modules/audit/service.py
Normal file
@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.modules.audit.models import AuditAction, AuditLog, AuditResourceType
|
||||
|
||||
|
||||
async def list_audit_logs(
|
||||
session: AsyncSession,
|
||||
customer_id: Optional[int],
|
||||
user_id: Optional[int],
|
||||
action: Optional[AuditAction],
|
||||
start: Optional[datetime],
|
||||
end: Optional[datetime],
|
||||
) -> List[AuditLog]:
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.options(selectinload(AuditLog.user), selectinload(AuditLog.customer))
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
)
|
||||
conditions = []
|
||||
if customer_id:
|
||||
conditions.append(AuditLog.customer_id == customer_id)
|
||||
if user_id:
|
||||
conditions.append(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
conditions.append(AuditLog.action == action)
|
||||
if start:
|
||||
conditions.append(AuditLog.created_at >= start)
|
||||
if end:
|
||||
conditions.append(AuditLog.created_at <= end)
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
logs = (await session.scalars(query)).all()
|
||||
for log in logs:
|
||||
setattr(log, "user_name", log.user.username if log.user else None)
|
||||
setattr(log, "customer_name", log.customer.name if log.customer else None)
|
||||
return logs
|
||||
1
backend/modules/auth/__init__.py
Normal file
1
backend/modules/auth/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# auth package initializer
|
||||
BIN
backend/modules/auth/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/modules/auth/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/auth/__pycache__/router.cpython-311.pyc
Normal file
BIN
backend/modules/auth/__pycache__/router.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/auth/__pycache__/schemas.cpython-311.pyc
Normal file
BIN
backend/modules/auth/__pycache__/schemas.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/auth/__pycache__/service.cpython-311.pyc
Normal file
BIN
backend/modules/auth/__pycache__/service.cpython-311.pyc
Normal file
Binary file not shown.
29
backend/modules/auth/router.py
Normal file
29
backend/modules/auth/router.py
Normal file
@ -0,0 +1,29 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.api.deps import AuthUser, get_current_user
|
||||
from backend.db.session import get_session
|
||||
from backend.modules.auth.schemas import LoginRequest, TokenResponse
|
||||
from backend.modules.auth.service import authenticate_user, build_access_token, create_login_audit
|
||||
from backend.modules.users.schemas import UserOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(payload: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)) -> TokenResponse:
|
||||
user = await authenticate_user(session, payload.username, payload.password)
|
||||
token = build_access_token(user)
|
||||
await create_login_audit(session, user, request)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(auth_user: AuthUser = Depends(get_current_user)) -> UserOut:
|
||||
return UserOut.model_validate(auth_user.user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(auth_user: AuthUser = Depends(get_current_user)) -> TokenResponse:
|
||||
token = build_access_token(auth_user.user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(auth_user.user))
|
||||
18
backend/modules/auth/schemas.py
Normal file
18
backend/modules/auth/schemas.py
Normal file
@ -0,0 +1,18 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
|
||||
from backend.modules.users.schemas import UserOut
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
|
||||
44
backend/modules/auth/service.py
Normal file
44
backend/modules/auth/service.py
Normal file
@ -0,0 +1,44 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.core.security import create_access_token, verify_password
|
||||
from backend.modules.audit.models import AuditAction, AuditLog, AuditResourceType
|
||||
from backend.modules.users.models import User
|
||||
|
||||
|
||||
async def authenticate_user(session: AsyncSession, username: str, password: str) -> User:
|
||||
user = await session.scalar(
|
||||
select(User).where(User.username == username).options(selectinload(User.role), selectinload(User.customer))
|
||||
)
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User disabled")
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def create_login_audit(session: AsyncSession, user: User, request) -> None:
|
||||
audit = AuditLog(
|
||||
user_id=user.id,
|
||||
customer_id=user.customer_id,
|
||||
action=AuditAction.LOGIN,
|
||||
resource_type=AuditResourceType.USER,
|
||||
resource_id=user.id,
|
||||
description="User login success",
|
||||
ip_address=request.client.host if request and request.client else None,
|
||||
user_agent=request.headers.get("User-Agent") if request else None,
|
||||
)
|
||||
session.add(audit)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def build_access_token(user: User) -> str:
|
||||
payload = {"sub": str(user.id), "role": user.role.name if user.role else "", "customer_id": user.customer_id}
|
||||
return create_access_token(payload)
|
||||
1
backend/modules/aws_accounts/__init__.py
Normal file
1
backend/modules/aws_accounts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# aws_accounts package initializer
|
||||
Binary file not shown.
BIN
backend/modules/aws_accounts/__pycache__/models.cpython-311.pyc
Normal file
BIN
backend/modules/aws_accounts/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/aws_accounts/__pycache__/router.cpython-311.pyc
Normal file
BIN
backend/modules/aws_accounts/__pycache__/router.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/aws_accounts/__pycache__/schemas.cpython-311.pyc
Normal file
BIN
backend/modules/aws_accounts/__pycache__/schemas.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/aws_accounts/__pycache__/service.cpython-311.pyc
Normal file
BIN
backend/modules/aws_accounts/__pycache__/service.cpython-311.pyc
Normal file
Binary file not shown.
74
backend/modules/aws_accounts/models.py
Normal file
74
backend/modules/aws_accounts/models.py
Normal file
@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Index, String, UniqueConstraint, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.db.base import Base
|
||||
|
||||
|
||||
class CredentialType(str, Enum):
|
||||
ACCESS_KEY = "ACCESS_KEY"
|
||||
ASSUME_ROLE = "ASSUME_ROLE"
|
||||
|
||||
|
||||
class AWSCredential(Base):
|
||||
__tablename__ = "aws_credentials"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("account_id", "name", name="uniq_credential_account_name"),
|
||||
Index("idx_credential_account", "account_id"),
|
||||
Index("idx_credential_active", "is_active"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
account_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
credential_type: Mapped[CredentialType] = mapped_column(
|
||||
SAEnum(CredentialType), nullable=False, server_default=text("'ACCESS_KEY'")
|
||||
)
|
||||
access_key_id: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
secret_access_key: Mapped[Optional[str]] = mapped_column(String(256))
|
||||
role_arn: Mapped[Optional[str]] = mapped_column(String(256))
|
||||
external_id: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
default_region: Mapped[str] = mapped_column(String(32), nullable=False, server_default=text("'ap-northeast-1'"))
|
||||
is_active: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("1"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
customer_credentials: Mapped[list["CustomerCredential"]] = relationship(
|
||||
"CustomerCredential", back_populates="credential", passive_deletes=True
|
||||
)
|
||||
instances: Mapped[list["Instance"]] = relationship("Instance", back_populates="credential", passive_deletes=True)
|
||||
|
||||
|
||||
class CustomerCredential(Base):
|
||||
__tablename__ = "customer_credentials"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("customer_id", "credential_id", name="uniq_customer_credential"),
|
||||
Index("idx_cc_customer", "customer_id"),
|
||||
Index("idx_cc_credential", "credential_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
customer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("customers.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
|
||||
)
|
||||
credential_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("aws_credentials.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
|
||||
)
|
||||
is_allowed: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("1"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
customer: Mapped["Customer"] = relationship("Customer")
|
||||
credential: Mapped["AWSCredential"] = relationship(
|
||||
"AWSCredential", back_populates="customer_credentials", passive_deletes=True
|
||||
)
|
||||
108
backend/modules/aws_accounts/router.py
Normal file
108
backend/modules/aws_accounts/router.py
Normal file
@ -0,0 +1,108 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.api.deps import AuthUser, get_current_user, require_admin
|
||||
from backend.db.session import get_session
|
||||
from backend.modules.aws_accounts.schemas import (
|
||||
AWSCredentialCreate,
|
||||
AWSCredentialOut,
|
||||
AWSCredentialUpdate,
|
||||
CustomerCredentialCreate,
|
||||
CustomerCredentialOut,
|
||||
)
|
||||
from backend.modules.aws_accounts.service import (
|
||||
authorize_customer,
|
||||
create_credential,
|
||||
delete_credential,
|
||||
get_credential,
|
||||
list_credentials,
|
||||
update_credential,
|
||||
)
|
||||
from backend.modules.users.models import RoleName
|
||||
|
||||
router = APIRouter(prefix="/api/v1/aws_credentials", tags=["aws_credentials"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[AWSCredentialOut])
|
||||
async def get_credentials(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(get_current_user),
|
||||
) -> List[AWSCredentialOut]:
|
||||
# 客户普通用户只能看到自己所属客户被授权的凭证
|
||||
if auth_user.role_name == RoleName.ADMIN.value:
|
||||
target_customer_id = None
|
||||
else:
|
||||
target_customer_id = auth_user.customer_id
|
||||
if target_customer_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="customer_id required for non-admin user"
|
||||
)
|
||||
|
||||
credentials = await list_credentials(session, target_customer_id)
|
||||
return [AWSCredentialOut.model_validate(c) for c in credentials]
|
||||
|
||||
|
||||
@router.post("", response_model=AWSCredentialOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_credential_endpoint(
|
||||
payload: AWSCredentialCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_admin),
|
||||
) -> AWSCredentialOut:
|
||||
cred = await create_credential(session, payload.model_dump(), auth_user.user)
|
||||
return AWSCredentialOut.model_validate(cred)
|
||||
|
||||
|
||||
@router.put("/{credential_id}", response_model=AWSCredentialOut)
|
||||
async def update_credential_endpoint(
|
||||
credential_id: int,
|
||||
payload: AWSCredentialUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_admin),
|
||||
) -> AWSCredentialOut:
|
||||
cred = await update_credential(session, credential_id, payload.model_dump(exclude_unset=True), auth_user.user)
|
||||
return AWSCredentialOut.model_validate(cred)
|
||||
|
||||
|
||||
@router.get("/{credential_id}", response_model=AWSCredentialOut)
|
||||
async def get_credential_endpoint(
|
||||
credential_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(get_current_user),
|
||||
) -> AWSCredentialOut:
|
||||
cred = await get_credential(session, credential_id)
|
||||
if auth_user.role_name != RoleName.ADMIN.value:
|
||||
# ensure mapping
|
||||
_ = await session.execute(
|
||||
select(CustomerCredential).where(
|
||||
CustomerCredential.customer_id == auth_user.customer_id,
|
||||
CustomerCredential.credential_id == credential_id,
|
||||
CustomerCredential.is_allowed == 1,
|
||||
)
|
||||
)
|
||||
return AWSCredentialOut.model_validate(cred)
|
||||
|
||||
|
||||
@router.delete("/{credential_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_credential_endpoint(
|
||||
credential_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_admin),
|
||||
) -> None:
|
||||
await delete_credential(session, credential_id, auth_user.user)
|
||||
|
||||
|
||||
@router.post("/{credential_id}/authorize", response_model=CustomerCredentialOut)
|
||||
async def authorize_credential_endpoint(
|
||||
credential_id: int,
|
||||
payload: CustomerCredentialCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_admin),
|
||||
) -> CustomerCredentialOut:
|
||||
if credential_id != payload.credential_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="credential_id mismatch")
|
||||
mapping = await authorize_customer(
|
||||
session, payload.customer_id, payload.credential_id, payload.is_allowed, auth_user.user
|
||||
)
|
||||
return CustomerCredentialOut.model_validate(mapping)
|
||||
60
backend/modules/aws_accounts/schemas.py
Normal file
60
backend/modules/aws_accounts/schemas.py
Normal file
@ -0,0 +1,60 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from backend.modules.aws_accounts.models import CredentialType
|
||||
|
||||
|
||||
class AWSCredentialBase(BaseModel):
|
||||
name: str
|
||||
account_id: str
|
||||
credential_type: CredentialType = CredentialType.ACCESS_KEY
|
||||
access_key_id: Optional[str] = None
|
||||
secret_access_key: Optional[str] = None
|
||||
role_arn: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
default_region: str = "ap-northeast-1"
|
||||
is_active: int = 1
|
||||
|
||||
|
||||
class AWSCredentialCreate(AWSCredentialBase):
|
||||
pass
|
||||
|
||||
|
||||
class AWSCredentialUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
account_id: Optional[str] = None
|
||||
credential_type: Optional[CredentialType] = None
|
||||
access_key_id: Optional[str] = None
|
||||
secret_access_key: Optional[str] = None
|
||||
role_arn: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
default_region: Optional[str] = None
|
||||
is_active: Optional[int] = None
|
||||
|
||||
|
||||
class AWSCredentialOut(AWSCredentialBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CustomerCredentialBase(BaseModel):
|
||||
customer_id: int
|
||||
credential_id: int
|
||||
is_allowed: int = 1
|
||||
|
||||
|
||||
class CustomerCredentialCreate(CustomerCredentialBase):
|
||||
pass
|
||||
|
||||
|
||||
class CustomerCredentialOut(CustomerCredentialBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
117
backend/modules/aws_accounts/service.py
Normal file
117
backend/modules/aws_accounts/service.py
Normal file
@ -0,0 +1,117 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.modules.audit.models import AuditAction, AuditLog, AuditResourceType
|
||||
from backend.modules.aws_accounts.models import AWSCredential, CustomerCredential
|
||||
from backend.modules.users.models import User
|
||||
|
||||
|
||||
async def list_credentials(session: AsyncSession, customer_id: int | None = None) -> List[AWSCredential]:
|
||||
query = select(AWSCredential)
|
||||
if customer_id:
|
||||
query = (
|
||||
query.join(CustomerCredential, CustomerCredential.credential_id == AWSCredential.id)
|
||||
.where(CustomerCredential.customer_id == customer_id)
|
||||
.where(CustomerCredential.is_allowed == 1)
|
||||
)
|
||||
return (await session.scalars(query)).all()
|
||||
|
||||
|
||||
async def create_credential(session: AsyncSession, data: dict, actor: User) -> AWSCredential:
|
||||
cred = AWSCredential(**data)
|
||||
session.add(cred)
|
||||
await session.commit()
|
||||
await session.refresh(cred)
|
||||
session.add(
|
||||
AuditLog(
|
||||
user_id=actor.id,
|
||||
customer_id=actor.customer_id,
|
||||
action=AuditAction.CREDENTIAL_CREATE,
|
||||
resource_type=AuditResourceType.AWS_CREDENTIAL,
|
||||
resource_id=cred.id,
|
||||
description=f"Create credential {cred.name}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return cred
|
||||
|
||||
|
||||
async def get_credential(session: AsyncSession, credential_id: int) -> AWSCredential:
|
||||
cred = await session.get(AWSCredential, credential_id)
|
||||
if not cred:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")
|
||||
return cred
|
||||
|
||||
|
||||
async def update_credential(session: AsyncSession, credential_id: int, data: dict, actor: User) -> AWSCredential:
|
||||
cred = await get_credential(session, credential_id)
|
||||
for field, value in data.items():
|
||||
setattr(cred, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(cred)
|
||||
session.add(
|
||||
AuditLog(
|
||||
user_id=actor.id,
|
||||
customer_id=actor.customer_id,
|
||||
action=AuditAction.CREDENTIAL_UPDATE,
|
||||
resource_type=AuditResourceType.AWS_CREDENTIAL,
|
||||
resource_id=cred.id,
|
||||
description=f"Update credential {cred.name}",
|
||||
payload=data,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return cred
|
||||
|
||||
|
||||
async def delete_credential(session: AsyncSession, credential_id: int, actor: User) -> None:
|
||||
cred = await session.get(AWSCredential, credential_id)
|
||||
if not cred:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")
|
||||
await session.delete(cred)
|
||||
session.add(
|
||||
AuditLog(
|
||||
user_id=actor.id,
|
||||
customer_id=actor.customer_id,
|
||||
action=AuditAction.CREDENTIAL_DELETE,
|
||||
resource_type=AuditResourceType.AWS_CREDENTIAL,
|
||||
resource_id=credential_id,
|
||||
description=f"Delete credential {credential_id}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def authorize_customer(
|
||||
session: AsyncSession, customer_id: int, credential_id: int, is_allowed: int, actor: User
|
||||
) -> CustomerCredential:
|
||||
mapping = await session.scalar(
|
||||
select(CustomerCredential).where(
|
||||
and_(
|
||||
CustomerCredential.customer_id == customer_id,
|
||||
CustomerCredential.credential_id == credential_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
if mapping:
|
||||
mapping.is_allowed = is_allowed
|
||||
else:
|
||||
mapping = CustomerCredential(customer_id=customer_id, credential_id=credential_id, is_allowed=is_allowed)
|
||||
session.add(mapping)
|
||||
await session.commit()
|
||||
await session.refresh(mapping)
|
||||
session.add(
|
||||
AuditLog(
|
||||
user_id=actor.id,
|
||||
customer_id=customer_id,
|
||||
action=AuditAction.CREDENTIAL_UPDATE,
|
||||
resource_type=AuditResourceType.AWS_CREDENTIAL,
|
||||
resource_id=credential_id,
|
||||
description=f"Authorize credential {credential_id} to customer {customer_id}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return mapping
|
||||
1
backend/modules/customers/__init__.py
Normal file
1
backend/modules/customers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# customers package initializer
|
||||
BIN
backend/modules/customers/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/modules/customers/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/customers/__pycache__/models.cpython-311.pyc
Normal file
BIN
backend/modules/customers/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/customers/__pycache__/router.cpython-311.pyc
Normal file
BIN
backend/modules/customers/__pycache__/router.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/customers/__pycache__/schemas.cpython-311.pyc
Normal file
BIN
backend/modules/customers/__pycache__/schemas.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/customers/__pycache__/service.cpython-311.pyc
Normal file
BIN
backend/modules/customers/__pycache__/service.cpython-311.pyc
Normal file
Binary file not shown.
28
backend/modules/customers/models.py
Normal file
28
backend/modules/customers/models.py
Normal file
@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, String, UniqueConstraint, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, INTEGER, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.db.base import Base
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
__table_args__ = (UniqueConstraint("name", name="uniq_customer_name"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
is_active: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("1"))
|
||||
quota_instances: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True))
|
||||
notes: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="customer")
|
||||
50
backend/modules/customers/router.py
Normal file
50
backend/modules/customers/router.py
Normal file
@ -0,0 +1,50 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.api.deps import AuthUser, require_admin
|
||||
from backend.db.session import get_session
|
||||
from backend.modules.customers.schemas import CustomerCreate, CustomerOut, CustomerUpdate
|
||||
from backend.modules.customers.service import create_customer, list_customers, update_customer, delete_customer
|
||||
|
||||
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[CustomerOut])
|
||||
async def get_customers(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_admin),
|
||||
) -> List[CustomerOut]:
|
||||
customers = await list_customers(session)
|
||||
return [CustomerOut.model_validate(c) for c in customers]
|
||||
|
||||
|
||||
@router.post("", response_model=CustomerOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_customer_endpoint(
|
||||
payload: CustomerCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_admin),
|
||||
) -> CustomerOut:
|
||||
customer = await create_customer(session, payload.model_dump(), auth_user.user)
|
||||
return CustomerOut.model_validate(customer)
|
||||
|
||||
|
||||
@router.put("/{customer_id}", response_model=CustomerOut)
|
||||
async def update_customer_endpoint(
|
||||
customer_id: int,
|
||||
payload: CustomerUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_admin),
|
||||
) -> CustomerOut:
|
||||
customer = await update_customer(session, customer_id, payload.model_dump(exclude_unset=True), auth_user.user)
|
||||
return CustomerOut.model_validate(customer)
|
||||
|
||||
|
||||
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_customer_endpoint(
|
||||
customer_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_admin),
|
||||
):
|
||||
await delete_customer(session, customer_id, auth_user.user)
|
||||
34
backend/modules/customers/schemas.py
Normal file
34
backend/modules/customers/schemas.py
Normal file
@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
|
||||
|
||||
class CustomerBase(BaseModel):
|
||||
name: str
|
||||
contact_email: Optional[EmailStr] = None
|
||||
is_active: int = 1
|
||||
quota_instances: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CustomerCreate(CustomerBase):
|
||||
pass
|
||||
|
||||
|
||||
class CustomerUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
is_active: Optional[int] = None
|
||||
quota_instances: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CustomerOut(CustomerBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
credential_names: list[str] = []
|
||||
usernames: list[str] = []
|
||||
103
backend/modules/customers/service.py
Normal file
103
backend/modules/customers/service.py
Normal file
@ -0,0 +1,103 @@
|
||||
from collections import defaultdict
|
||||
from typing import List
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.modules.audit.models import AuditAction, AuditLog, AuditResourceType
|
||||
from backend.modules.aws_accounts.models import AWSCredential, CustomerCredential
|
||||
from backend.modules.customers.models import Customer
|
||||
from backend.modules.users.models import User
|
||||
|
||||
|
||||
async def list_customers(session: AsyncSession) -> List[Customer]:
|
||||
customers = (await session.scalars(select(Customer))).all()
|
||||
if not customers:
|
||||
return customers
|
||||
|
||||
customer_ids = [c.id for c in customers]
|
||||
|
||||
cred_map: dict[int, list[str]] = defaultdict(list)
|
||||
cred_rows = await session.execute(
|
||||
select(CustomerCredential.customer_id, AWSCredential.name, AWSCredential.account_id)
|
||||
.join(AWSCredential, AWSCredential.id == CustomerCredential.credential_id)
|
||||
.where(CustomerCredential.customer_id.in_(customer_ids))
|
||||
.where(CustomerCredential.is_allowed == 1)
|
||||
)
|
||||
for customer_id, cred_name, account_id in cred_rows:
|
||||
cred_map[customer_id].append(f"{cred_name} ({account_id})")
|
||||
|
||||
user_map: dict[int, list[str]] = defaultdict(list)
|
||||
user_rows = await session.execute(
|
||||
select(User.customer_id, User.username).where(User.customer_id.in_(customer_ids))
|
||||
)
|
||||
for customer_id, username in user_rows:
|
||||
user_map[customer_id].append(username)
|
||||
|
||||
for customer in customers:
|
||||
# attach additional presentation fields for API response
|
||||
setattr(customer, "credential_names", cred_map.get(customer.id, []))
|
||||
setattr(customer, "usernames", user_map.get(customer.id, []))
|
||||
|
||||
return customers
|
||||
|
||||
|
||||
async def create_customer(session: AsyncSession, data: dict, actor: User) -> Customer:
|
||||
customer = Customer(**data)
|
||||
session.add(customer)
|
||||
await session.commit()
|
||||
await session.refresh(customer)
|
||||
session.add(
|
||||
AuditLog(
|
||||
user_id=actor.id,
|
||||
customer_id=None,
|
||||
action=AuditAction.CUSTOMER_CREATE,
|
||||
resource_type=AuditResourceType.CUSTOMER,
|
||||
resource_id=customer.id,
|
||||
description=f"Create customer {customer.name}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return customer
|
||||
|
||||
|
||||
async def update_customer(session: AsyncSession, customer_id: int, data: dict, actor: User) -> Customer:
|
||||
customer = await session.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
|
||||
for field, value in data.items():
|
||||
setattr(customer, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(customer)
|
||||
session.add(
|
||||
AuditLog(
|
||||
user_id=actor.id,
|
||||
customer_id=customer.id,
|
||||
action=AuditAction.CUSTOMER_UPDATE,
|
||||
resource_type=AuditResourceType.CUSTOMER,
|
||||
resource_id=customer.id,
|
||||
description=f"Update customer {customer.name}",
|
||||
payload=data,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return customer
|
||||
|
||||
|
||||
async def delete_customer(session: AsyncSession, customer_id: int, actor: User) -> None:
|
||||
customer = await session.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
|
||||
await session.delete(customer)
|
||||
session.add(
|
||||
AuditLog(
|
||||
user_id=actor.id,
|
||||
customer_id=customer.id,
|
||||
action=AuditAction.CUSTOMER_DELETE,
|
||||
resource_type=AuditResourceType.CUSTOMER,
|
||||
resource_id=customer.id,
|
||||
description=f"Delete customer {customer.name}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
1
backend/modules/instances/__init__.py
Normal file
1
backend/modules/instances/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# instances package initializer
|
||||
BIN
backend/modules/instances/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/modules/instances/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/instances/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/modules/instances/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/modules/instances/__pycache__/aws_ops.cpython-311.pyc
Normal file
BIN
backend/modules/instances/__pycache__/aws_ops.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/modules/instances/__pycache__/constants.cpython-311.pyc
Normal file
BIN
backend/modules/instances/__pycache__/constants.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/instances/__pycache__/events.cpython-311.pyc
Normal file
BIN
backend/modules/instances/__pycache__/events.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/instances/__pycache__/models.cpython-311.pyc
Normal file
BIN
backend/modules/instances/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/instances/__pycache__/router.cpython-311.pyc
Normal file
BIN
backend/modules/instances/__pycache__/router.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/instances/__pycache__/schemas.cpython-311.pyc
Normal file
BIN
backend/modules/instances/__pycache__/schemas.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/instances/__pycache__/service.cpython-311.pyc
Normal file
BIN
backend/modules/instances/__pycache__/service.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/modules/instances/__pycache__/service.cpython-312.pyc
Normal file
BIN
backend/modules/instances/__pycache__/service.cpython-312.pyc
Normal file
Binary file not shown.
284
backend/modules/instances/aws_ops.py
Normal file
284
backend/modules/instances/aws_ops.py
Normal file
@ -0,0 +1,284 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from backend.core.config import settings
|
||||
from backend.modules.aws_accounts.models import AWSCredential, CredentialType
|
||||
|
||||
OPEN_ALL_SG_NAME = "panel-open-all"
|
||||
|
||||
|
||||
def _boto_config() -> BotoConfig:
|
||||
proxies = None
|
||||
if settings.aws_proxy_url:
|
||||
proxies = {"https": settings.aws_proxy_url, "http": settings.aws_proxy_url}
|
||||
return BotoConfig(connect_timeout=settings.aws_timeout, read_timeout=settings.aws_timeout, proxies=proxies)
|
||||
|
||||
|
||||
def build_session(credential: AWSCredential, region: str):
|
||||
cfg = _boto_config()
|
||||
if credential.credential_type == CredentialType.ACCESS_KEY:
|
||||
session = boto3.Session(
|
||||
aws_access_key_id=credential.access_key_id,
|
||||
aws_secret_access_key=credential.secret_access_key,
|
||||
region_name=region or credential.default_region,
|
||||
)
|
||||
return session, cfg
|
||||
|
||||
base_session = boto3.Session(
|
||||
aws_access_key_id=credential.access_key_id,
|
||||
aws_secret_access_key=credential.secret_access_key,
|
||||
region_name=region or credential.default_region,
|
||||
)
|
||||
sts = base_session.client("sts", config=cfg, region_name=region or credential.default_region)
|
||||
assume_kwargs: Dict[str, Any] = {"RoleArn": credential.role_arn, "RoleSessionName": "ec2-panel"}
|
||||
if credential.external_id:
|
||||
assume_kwargs["ExternalId"] = credential.external_id
|
||||
resp = sts.assume_role(**assume_kwargs)
|
||||
creds = resp["Credentials"]
|
||||
session = boto3.Session(
|
||||
aws_access_key_id=creds["AccessKeyId"],
|
||||
aws_secret_access_key=creds["SecretAccessKey"],
|
||||
aws_session_token=creds["SessionToken"],
|
||||
region_name=region or credential.default_region,
|
||||
)
|
||||
return session, cfg
|
||||
|
||||
|
||||
def describe_instances(
|
||||
credential: AWSCredential,
|
||||
region: str,
|
||||
filters: Optional[List[Dict[str, Any]]] = None,
|
||||
instance_ids: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
params: Dict[str, Any] = {}
|
||||
if filters:
|
||||
params["Filters"] = filters
|
||||
if instance_ids:
|
||||
params["InstanceIds"] = instance_ids
|
||||
return client.describe_instances(**params)
|
||||
|
||||
|
||||
def describe_instance_status(
|
||||
credential: AWSCredential, region: str, instance_ids: List[str]
|
||||
) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.describe_instance_status(InstanceIds=instance_ids, IncludeAllInstances=True)
|
||||
|
||||
|
||||
def run_instances(
|
||||
credential: AWSCredential,
|
||||
region: str,
|
||||
ami_id: str,
|
||||
instance_type: str,
|
||||
key_name: Optional[str],
|
||||
security_groups: Optional[List[str]],
|
||||
subnet_id: Optional[str],
|
||||
block_device_mappings: Optional[List[Dict[str, Any]]] = None,
|
||||
cpu_options: Optional[Dict[str, Any]] = None,
|
||||
min_count: int = 1,
|
||||
max_count: int = 1,
|
||||
name_tag: Optional[str] = None,
|
||||
user_data: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
params: Dict[str, Any] = {
|
||||
"ImageId": ami_id,
|
||||
"InstanceType": instance_type,
|
||||
"MinCount": min_count,
|
||||
"MaxCount": max_count,
|
||||
}
|
||||
if key_name:
|
||||
params["KeyName"] = key_name
|
||||
if security_groups:
|
||||
params["SecurityGroupIds"] = security_groups
|
||||
if subnet_id:
|
||||
params["SubnetId"] = subnet_id
|
||||
if block_device_mappings:
|
||||
params["BlockDeviceMappings"] = block_device_mappings
|
||||
if cpu_options:
|
||||
params["CreditSpecification"] = cpu_options
|
||||
if name_tag:
|
||||
params["TagSpecifications"] = [
|
||||
{"ResourceType": "instance", "Tags": [{"Key": "Name", "Value": name_tag}]},
|
||||
{"ResourceType": "volume", "Tags": [{"Key": "Name", "Value": name_tag}]},
|
||||
]
|
||||
if user_data:
|
||||
params["UserData"] = user_data
|
||||
return client.run_instances(**params)
|
||||
|
||||
|
||||
def start_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.start_instances(InstanceIds=instance_ids)
|
||||
|
||||
|
||||
def stop_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.stop_instances(InstanceIds=instance_ids)
|
||||
|
||||
|
||||
def reboot_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.reboot_instances(InstanceIds=instance_ids)
|
||||
|
||||
|
||||
def terminate_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.terminate_instances(InstanceIds=instance_ids)
|
||||
|
||||
|
||||
def get_service_quota(credential: AWSCredential, region: str, service_code: str, quota_code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Best-effort service quota lookup, used to hint at maximum runnable instances in a region.
|
||||
"""
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("service-quotas", region_name=region or credential.default_region, config=cfg)
|
||||
return client.get_service_quota(ServiceCode=service_code, QuotaCode=quota_code)
|
||||
|
||||
|
||||
def describe_vpcs(credential: AWSCredential, region: str) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.describe_vpcs()
|
||||
|
||||
|
||||
def describe_subnets(credential: AWSCredential, region: str, filters: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
params: Dict[str, Any] = {}
|
||||
if filters:
|
||||
params["Filters"] = filters
|
||||
return client.describe_subnets(**params)
|
||||
|
||||
|
||||
def describe_security_groups(
|
||||
credential: AWSCredential, region: str, filters: Optional[List[Dict[str, Any]]] = None
|
||||
) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
params: Dict[str, Any] = {}
|
||||
if filters:
|
||||
params["Filters"] = filters
|
||||
return client.describe_security_groups(**params)
|
||||
|
||||
|
||||
def describe_key_pairs(credential: AWSCredential, region: str) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.describe_key_pairs()
|
||||
|
||||
|
||||
def create_key_pair(credential: AWSCredential, region: str, key_name: str) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.create_key_pair(KeyName=key_name, KeyType="rsa", KeyFormat="pem")
|
||||
|
||||
|
||||
def describe_regions(credential: AWSCredential) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, credential.default_region)
|
||||
client = session.client("ec2", region_name=credential.default_region, config=cfg)
|
||||
return client.describe_regions(AllRegions=True)
|
||||
|
||||
|
||||
def describe_instance_types(credential: AWSCredential, region: str, filters: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
paginator = client.get_paginator("describe_instance_types")
|
||||
params: Dict[str, Any] = {}
|
||||
if filters:
|
||||
params["Filters"] = filters
|
||||
results: List[Dict[str, Any]] = []
|
||||
for page in paginator.paginate(**params):
|
||||
results.extend(page.get("InstanceTypes", []))
|
||||
return results
|
||||
|
||||
|
||||
def describe_images(credential: AWSCredential, region: str, image_ids: List[str]) -> Dict[str, Any]:
|
||||
session, cfg = build_session(credential, region)
|
||||
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
|
||||
return client.describe_images(ImageIds=image_ids)
|
||||
|
||||
|
||||
def _is_open_all_sg(sg: Dict[str, Any]) -> bool:
|
||||
ingress = sg.get("IpPermissions", [])
|
||||
egress = sg.get("IpPermissionsEgress", [])
|
||||
def _has_all(perms: List[Dict[str, Any]]) -> bool:
|
||||
for p in perms:
|
||||
if p.get("IpProtocol") == "-1":
|
||||
if any(r.get("CidrIp") == "0.0.0.0/0" for r in p.get("IpRanges", [])):
|
||||
return True
|
||||
return False
|
||||
return _has_all(ingress) and _has_all(egress)
|
||||
|
||||
|
||||
def ensure_open_all_sg_for_vpc(ec2_client, vpc_id: str) -> str:
|
||||
"""Ensure an open-all security group exists in a VPC. Returns GroupId."""
|
||||
resp = ec2_client.describe_security_groups(Filters=[{"Name": "vpc-id", "Values": [vpc_id]}])
|
||||
default_sg_id = None
|
||||
for sg in resp.get("SecurityGroups", []):
|
||||
name = sg.get("GroupName")
|
||||
if name == "default":
|
||||
default_sg_id = sg.get("GroupId")
|
||||
if (name == OPEN_ALL_SG_NAME or name == "default") and _is_open_all_sg(sg):
|
||||
return sg["GroupId"]
|
||||
|
||||
# If default exists but不是全开,直接把默认安全组放开
|
||||
if default_sg_id:
|
||||
ingress_rule = {
|
||||
"IpProtocol": "-1",
|
||||
"IpRanges": [{"CidrIp": "0.0.0.0/0"}],
|
||||
}
|
||||
egress_rule = {
|
||||
"IpProtocol": "-1",
|
||||
"IpRanges": [{"CidrIp": "0.0.0.0/0"}],
|
||||
}
|
||||
try:
|
||||
ec2_client.authorize_security_group_ingress(GroupId=default_sg_id, IpPermissions=[ingress_rule])
|
||||
except ClientError as exc: # noqa: PERF203
|
||||
if exc.response.get("Error", {}).get("Code") != "InvalidPermission.Duplicate":
|
||||
raise
|
||||
try:
|
||||
ec2_client.authorize_security_group_egress(GroupId=default_sg_id, IpPermissions=[egress_rule])
|
||||
except ClientError as exc: # noqa: PERF203
|
||||
if exc.response.get("Error", {}).get("Code") != "InvalidPermission.Duplicate":
|
||||
raise
|
||||
return default_sg_id
|
||||
# create new
|
||||
create_resp = ec2_client.create_security_group(
|
||||
GroupName=OPEN_ALL_SG_NAME,
|
||||
Description="Open all inbound/outbound for panel-created instances",
|
||||
VpcId=vpc_id,
|
||||
)
|
||||
sg_id = create_resp["GroupId"]
|
||||
ingress_rule = {
|
||||
"IpProtocol": "-1",
|
||||
"IpRanges": [{"CidrIp": "0.0.0.0/0"}],
|
||||
}
|
||||
egress_rule = {
|
||||
"IpProtocol": "-1",
|
||||
"IpRanges": [{"CidrIp": "0.0.0.0/0"}],
|
||||
}
|
||||
try:
|
||||
ec2_client.authorize_security_group_ingress(GroupId=sg_id, IpPermissions=[ingress_rule])
|
||||
except ClientError as exc: # noqa: PERF203
|
||||
if exc.response.get("Error", {}).get("Code") != "InvalidPermission.Duplicate":
|
||||
raise
|
||||
try:
|
||||
ec2_client.authorize_security_group_egress(GroupId=sg_id, IpPermissions=[egress_rule])
|
||||
except ClientError as exc: # noqa: PERF203
|
||||
if exc.response.get("Error", {}).get("Code") != "InvalidPermission.Duplicate":
|
||||
raise
|
||||
return sg_id
|
||||
51
backend/modules/instances/bootstrap_templates.py
Normal file
51
backend/modules/instances/bootstrap_templates.py
Normal file
@ -0,0 +1,51 @@
|
||||
COMMON_TEMPLATE = r"""#!/bin/bash
|
||||
set -eux
|
||||
|
||||
USER_NAME="{username}"
|
||||
USER_PWD="{password}"
|
||||
|
||||
# 1. 设置密码
|
||||
echo "$USER_NAME:$USER_PWD" | chpasswd
|
||||
|
||||
SSH_MAIN="/etc/ssh/sshd_config"
|
||||
|
||||
# 2. 修改主配置中的 PermitRootLogin / PasswordAuthentication
|
||||
if [ -f "$SSH_MAIN" ]; then
|
||||
if grep -qE '^[#[:space:]]*PermitRootLogin' "$SSH_MAIN"; then
|
||||
sed -i 's/^[#[:space:]]*PermitRootLogin.*/PermitRootLogin yes/' "$SSH_MAIN"
|
||||
else
|
||||
echo 'PermitRootLogin yes' >> "$SSH_MAIN"
|
||||
fi
|
||||
|
||||
if grep -qE '^[#[:space:]]*PasswordAuthentication' "$SSH_MAIN"; then
|
||||
sed -i 's/^[#[:space:]]*PasswordAuthentication.*/PasswordAuthentication yes/' "$SSH_MAIN"
|
||||
else
|
||||
echo 'PasswordAuthentication yes' >> "$SSH_MAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. 针对 cloud-init/ubuntu 的附加配置(若存在)
|
||||
if [ -d /etc/ssh/sshd_config.d ]; then
|
||||
for f in /etc/ssh/sshd_config.d/*.conf; do
|
||||
[ -f "$f" ] || continue
|
||||
if grep -q 'PasswordAuthentication' "$f"; then
|
||||
sed -i 's/^[#[:space:]]*PasswordAuthentication.*/PasswordAuthentication yes/' "$f"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 4. 重启 SSH 服务(尝试多种名称)
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl restart sshd 2>/dev/null || \
|
||||
systemctl restart ssh 2>/dev/null || \
|
||||
service sshd restart 2>/dev/null || \
|
||||
service ssh restart 2>/dev/null || true
|
||||
else
|
||||
service sshd restart 2>/dev/null || \
|
||||
service ssh restart 2>/dev/null || true
|
||||
fi
|
||||
"""
|
||||
|
||||
|
||||
def build_user_data(os_family: str, username: str, password: str) -> str:
|
||||
return COMMON_TEMPLATE.format(username=username, password=password)
|
||||
32
backend/modules/instances/constants.py
Normal file
32
backend/modules/instances/constants.py
Normal file
@ -0,0 +1,32 @@
|
||||
AWS_REGIONS = {
|
||||
"af-south-1": {"en": "Africa (Cape Town)", "zh": "非洲(开普敦)"},
|
||||
"ap-east-1": {"en": "Asia Pacific (Hong Kong)", "zh": "亚太地区(香港)"},
|
||||
"ap-south-1": {"en": "Asia Pacific (Mumbai)", "zh": "亚太地区(孟买)"},
|
||||
"ap-south-2": {"en": "Asia Pacific South (Hyderabad)", "zh": "亚太南部(海得拉巴)"},
|
||||
"ap-southeast-1": {"en": "Asia Pacific (Singapore)", "zh": "亚太地区(新加坡)"},
|
||||
"ap-southeast-2": {"en": "Asia Pacific (Sydney)", "zh": "亚太地区(悉尼)"},
|
||||
"ap-southeast-3": {"en": "Asia Pacific (Jakarta)", "zh": "亚太地区(雅加达)"},
|
||||
"ap-southeast-4": {"en": "Asia Pacific (Melbourne)", "zh": "亚太地区(墨尔本)"},
|
||||
"ap-northeast-1": {"en": "Asia Pacific (Tokyo)", "zh": "亚太地区(东京)"},
|
||||
"ap-northeast-2": {"en": "Asia Pacific (Seoul)", "zh": "亚太地区(首尔)"},
|
||||
"ap-northeast-3": {"en": "Asia Pacific (Osaka)", "zh": "亚太地区(大阪)"},
|
||||
"ca-central-1": {"en": "Canada (Central)", "zh": "加拿大(中部)"},
|
||||
"eu-central-1": {"en": "Europe (Frankfurt)", "zh": "欧洲(法兰克福)"},
|
||||
"eu-central-2": {"en": "Europe (Zurich)", "zh": "欧洲(苏黎世)"},
|
||||
"eu-west-1": {"en": "Europe (Ireland)", "zh": "欧洲(爱尔兰)"},
|
||||
"eu-west-2": {"en": "Europe (London)", "zh": "欧洲(伦敦)"},
|
||||
"eu-west-3": {"en": "Europe (Paris)", "zh": "欧洲(巴黎)"},
|
||||
"eu-south-1": {"en": "Europe (Milan)", "zh": "欧洲(米兰)"},
|
||||
"eu-south-2": {"en": "Europe (Spain)", "zh": "欧洲(西班牙)"},
|
||||
"eu-north-1": {"en": "Europe (Stockholm)", "zh": "欧洲(斯德哥尔摩)"},
|
||||
"me-south-1": {"en": "Middle East (Bahrain)", "zh": "中东(巴林)"},
|
||||
"me-central-1": {"en": "Middle East (UAE)", "zh": "中东(阿联酋)"},
|
||||
"sa-east-1": {"en": "South America (São Paulo)", "zh": "南美洲(圣保罗)"},
|
||||
"us-east-1": {"en": "US East (N. Virginia)", "zh": "美国东部(弗吉尼亚北部)"},
|
||||
"us-east-2": {"en": "US East (Ohio)", "zh": "美国东部(俄亥俄)"},
|
||||
"us-west-1": {"en": "US West (N. California)", "zh": "美国西部(加利福尼亚北部)"},
|
||||
"us-west-2": {"en": "US West (Oregon)", "zh": "美国西部(俄勒冈)"},
|
||||
}
|
||||
|
||||
DEFAULT_LOGIN_USERNAME = "root"
|
||||
DEFAULT_LOGIN_PASSWORD = "qeAjGO4UjCAQ3sI"
|
||||
85
backend/modules/instances/events.py
Normal file
85
backend/modules/instances/events.py
Normal file
@ -0,0 +1,85 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Optional, Set
|
||||
|
||||
from fastapi import WebSocket
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
from backend.api.deps import AuthUser
|
||||
from backend.modules.instances.models import Instance
|
||||
from backend.modules.instances.schemas import InstanceOut
|
||||
from backend.modules.users.models import RoleName
|
||||
|
||||
|
||||
class InstanceEventManager:
|
||||
"""Tracks websocket connections and dispatches instance change events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._connections: Dict[int, Set[WebSocket]] = defaultdict(set)
|
||||
self._admins: Set[WebSocket] = set()
|
||||
self._ws_to_customer: Dict[WebSocket, int] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, websocket: WebSocket, auth_user: AuthUser) -> None:
|
||||
await websocket.accept()
|
||||
customer_key = auth_user.customer_id or 0
|
||||
async with self._lock:
|
||||
self._connections[customer_key].add(websocket)
|
||||
self._ws_to_customer[websocket] = customer_key
|
||||
if auth_user.role_name == RoleName.ADMIN.value:
|
||||
self._admins.add(websocket)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
customer_key = self._ws_to_customer.pop(websocket, None)
|
||||
if customer_key is not None and customer_key in self._connections:
|
||||
self._connections[customer_key].discard(websocket)
|
||||
if not self._connections[customer_key]:
|
||||
self._connections.pop(customer_key, None)
|
||||
self._admins.discard(websocket)
|
||||
|
||||
async def broadcast(self, payload: dict, customer_id: Optional[int]) -> None:
|
||||
if customer_id is None:
|
||||
return
|
||||
payload_jsonable = jsonable_encoder(payload)
|
||||
async with self._lock:
|
||||
targets = set(self._admins) | set(self._connections.get(customer_id, set()))
|
||||
if not targets:
|
||||
return
|
||||
stale: list[WebSocket] = []
|
||||
for ws in targets:
|
||||
try:
|
||||
await ws.send_json(payload_jsonable)
|
||||
except Exception:
|
||||
stale.append(ws)
|
||||
for ws in stale:
|
||||
await self.disconnect(ws)
|
||||
|
||||
|
||||
instance_event_manager = InstanceEventManager()
|
||||
|
||||
|
||||
def serialize_instance(instance: Instance) -> dict:
|
||||
return InstanceOut.model_validate(instance).model_dump()
|
||||
|
||||
|
||||
def build_removed_payload(instance: Instance) -> dict:
|
||||
return {
|
||||
"id": instance.id,
|
||||
"instance_id": instance.instance_id,
|
||||
"account_id": instance.account_id,
|
||||
"region": instance.region,
|
||||
"customer_id": instance.customer_id,
|
||||
"credential_id": instance.credential_id,
|
||||
"status": instance.status,
|
||||
}
|
||||
|
||||
|
||||
async def broadcast_instance_update(instance: Instance) -> None:
|
||||
await instance_event_manager.broadcast(
|
||||
{"type": "instance_update", "instance": serialize_instance(instance)}, instance.customer_id
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_instance_removed(payload: dict, customer_id: Optional[int]) -> None:
|
||||
await instance_event_manager.broadcast({"type": "instance_removed", "instance": payload}, customer_id)
|
||||
98
backend/modules/instances/models.py
Normal file
98
backend/modules/instances/models.py
Normal file
@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Index, JSON, String, UniqueConstraint, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.db.base import Base
|
||||
|
||||
|
||||
class InstanceStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
STOPPING = "STOPPING"
|
||||
STOPPED = "STOPPED"
|
||||
SHUTTING_DOWN = "SHUTTING_DOWN"
|
||||
TERMINATED = "TERMINATED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class InstanceDesiredStatus(str, Enum):
|
||||
RUNNING = "RUNNING"
|
||||
STOPPED = "STOPPED"
|
||||
TERMINATED = "TERMINATED"
|
||||
|
||||
|
||||
class Instance(Base):
|
||||
__tablename__ = "instances"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("account_id", "region", "instance_id", name="uniq_instance_cloud"),
|
||||
Index("idx_instances_customer", "customer_id"),
|
||||
Index("idx_instances_status", "status"),
|
||||
Index("idx_instances_region", "region"),
|
||||
Index("idx_instances_last_sync", "last_sync"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||
customer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("customers.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
|
||||
)
|
||||
credential_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("aws_credentials.id", ondelete="SET NULL", onupdate="CASCADE")
|
||||
)
|
||||
account_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
region: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
az: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
instance_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
name_tag: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
instance_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
ami_id: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
os_name: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
key_name: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
public_ip: Mapped[Optional[str]] = mapped_column(String(45))
|
||||
private_ip: Mapped[Optional[str]] = mapped_column(String(45))
|
||||
status: Mapped[InstanceStatus] = mapped_column(
|
||||
SAEnum(InstanceStatus), nullable=False, server_default=text("'UNKNOWN'")
|
||||
)
|
||||
desired_status: Mapped[Optional[InstanceDesiredStatus]] = mapped_column(SAEnum(InstanceDesiredStatus))
|
||||
security_groups: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
subnet_id: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
vpc_id: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
launched_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
terminated_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
last_sync: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
last_cloud_state: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
||||
)
|
||||
|
||||
customer: Mapped["Customer"] = relationship("Customer")
|
||||
credential: Mapped[Optional["AWSCredential"]] = relationship("AWSCredential", back_populates="instances")
|
||||
job_items: Mapped[list["JobItem"]] = relationship("JobItem", back_populates="instance")
|
||||
|
||||
@property
|
||||
def credential_name(self) -> Optional[str]:
|
||||
if getattr(self, "credential", None):
|
||||
return self.credential.name
|
||||
return None
|
||||
|
||||
@property
|
||||
def credential_label(self) -> Optional[str]:
|
||||
if getattr(self, "credential", None):
|
||||
return f"{self.credential.name} ({self.credential.account_id})"
|
||||
return None
|
||||
|
||||
@property
|
||||
def owner_name(self) -> Optional[str]:
|
||||
if getattr(self, "customer", None):
|
||||
return self.customer.name or self.customer.contact_email
|
||||
return None
|
||||
|
||||
@property
|
||||
def customer_name(self) -> Optional[str]:
|
||||
return self.owner_name
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user