Передаем на тест
This commit is contained in:
@@ -1 +1 @@
|
||||
from . import plugins, tasks, settings, connections, environments, mappings, migration, git
|
||||
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List
|
||||
from ...core.config_models import AppConfig, Environment, GlobalSettings
|
||||
from ...models.storage import StorageConfig
|
||||
from ...dependencies import get_config_manager
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger, belief_scope
|
||||
@@ -56,6 +57,33 @@ async def update_global_settings(
|
||||
return settings
|
||||
# [/DEF:update_global_settings:Function]
|
||||
|
||||
# [DEF:get_storage_settings:Function]
|
||||
# @PURPOSE: Retrieves storage-specific settings.
|
||||
# @RETURN: StorageConfig - The storage configuration.
|
||||
@router.get("/storage", response_model=StorageConfig)
|
||||
async def get_storage_settings(config_manager: ConfigManager = Depends(get_config_manager)):
|
||||
with belief_scope("get_storage_settings"):
|
||||
return config_manager.get_config().settings.storage
|
||||
# [/DEF:get_storage_settings:Function]
|
||||
|
||||
# [DEF:update_storage_settings:Function]
|
||||
# @PURPOSE: Updates storage-specific settings.
|
||||
# @PARAM: storage (StorageConfig) - The new storage settings.
|
||||
# @POST: Storage settings are updated and saved.
|
||||
# @RETURN: StorageConfig - The updated storage settings.
|
||||
@router.put("/storage", response_model=StorageConfig)
|
||||
async def update_storage_settings(storage: StorageConfig, config_manager: ConfigManager = Depends(get_config_manager)):
|
||||
with belief_scope("update_storage_settings"):
|
||||
is_valid, message = config_manager.validate_path(storage.root_path)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
settings = config_manager.get_config().settings
|
||||
settings.storage = storage
|
||||
config_manager.update_global_settings(settings)
|
||||
return config_manager.get_config().settings.storage
|
||||
# [/DEF:update_storage_settings:Function]
|
||||
|
||||
# [DEF:get_environments:Function]
|
||||
# @PURPOSE: Lists all configured Superset environments.
|
||||
# @PRE: Config manager is available.
|
||||
|
||||
126
backend/src/api/routes/storage.py
Normal file
126
backend/src/api/routes/storage.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# [DEF:storage_routes:Module]
|
||||
#
|
||||
# @SEMANTICS: storage, files, upload, download, backup, repository
|
||||
# @PURPOSE: API endpoints for file storage management (backups and repositories).
|
||||
# @LAYER: API
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.storage
|
||||
#
|
||||
# @INVARIANT: All paths must be validated against path traversal.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from typing import List, Optional
|
||||
from backend.src.models.storage import StoredFile, FileCategory
|
||||
from backend.src.dependencies import get_plugin_loader
|
||||
from backend.src.plugins.storage.plugin import StoragePlugin
|
||||
from backend.src.core.logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter(tags=["storage"])
|
||||
|
||||
# [DEF:list_files:Function]
|
||||
# @PURPOSE: List all files in the storage system, optionally filtered by category.
|
||||
#
|
||||
# @PRE: None.
|
||||
# @POST: Returns a list of StoredFile objects.
|
||||
#
|
||||
# @PARAM: category (Optional[FileCategory]) - Filter by category.
|
||||
# @RETURN: List[StoredFile] - List of files.
|
||||
#
|
||||
# @RELATION: CALLS -> StoragePlugin.list_files
|
||||
@router.get("/files", response_model=List[StoredFile])
|
||||
async def list_files(category: Optional[FileCategory] = None, plugin_loader=Depends(get_plugin_loader)):
|
||||
with belief_scope("list_files"):
|
||||
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||
if not storage_plugin:
|
||||
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||
return storage_plugin.list_files(category)
|
||||
# [/DEF:list_files:Function]
|
||||
|
||||
# [DEF:upload_file:Function]
|
||||
# @PURPOSE: Upload a file to the storage system under a specific category.
|
||||
#
|
||||
# @PRE: category must be a valid FileCategory.
|
||||
# @PRE: file must be a valid UploadFile.
|
||||
# @POST: Returns the StoredFile object of the uploaded file.
|
||||
#
|
||||
# @PARAM: category (FileCategory) - Target category.
|
||||
# @PARAM: file (UploadFile) - The file content.
|
||||
# @RETURN: StoredFile - Metadata of the uploaded file.
|
||||
#
|
||||
# @SIDE_EFFECT: Writes file to the filesystem.
|
||||
#
|
||||
# @RELATION: CALLS -> StoragePlugin.save_file
|
||||
@router.post("/upload", response_model=StoredFile, status_code=201)
|
||||
async def upload_file(
|
||||
category: FileCategory = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
plugin_loader=Depends(get_plugin_loader)
|
||||
):
|
||||
with belief_scope("upload_file"):
|
||||
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||
if not storage_plugin:
|
||||
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||
try:
|
||||
return await storage_plugin.save_file(file, category)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
# [/DEF:upload_file:Function]
|
||||
|
||||
# [DEF:delete_file:Function]
|
||||
# @PURPOSE: Delete a specific file from the storage system.
|
||||
#
|
||||
# @PRE: category must be a valid FileCategory.
|
||||
# @PRE: filename must not contain path separators.
|
||||
# @POST: File is removed from storage.
|
||||
#
|
||||
# @PARAM: category (FileCategory) - File category.
|
||||
# @PARAM: filename (str) - Name of the file.
|
||||
# @RETURN: None
|
||||
#
|
||||
# @SIDE_EFFECT: Deletes file from the filesystem.
|
||||
#
|
||||
# @RELATION: CALLS -> StoragePlugin.delete_file
|
||||
@router.delete("/files/{category}/{filename}", status_code=204)
|
||||
async def delete_file(category: FileCategory, filename: str, plugin_loader=Depends(get_plugin_loader)):
|
||||
with belief_scope("delete_file"):
|
||||
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||
if not storage_plugin:
|
||||
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||
try:
|
||||
storage_plugin.delete_file(category, filename)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
# [/DEF:delete_file:Function]
|
||||
|
||||
# [DEF:download_file:Function]
|
||||
# @PURPOSE: Retrieve a file for download.
|
||||
#
|
||||
# @PRE: category must be a valid FileCategory.
|
||||
# @PRE: filename must exist in the specified category.
|
||||
# @POST: Returns a FileResponse.
|
||||
#
|
||||
# @PARAM: category (FileCategory) - File category.
|
||||
# @PARAM: filename (str) - Name of the file.
|
||||
# @RETURN: FileResponse - The file content.
|
||||
#
|
||||
# @RELATION: CALLS -> StoragePlugin.get_file_path
|
||||
@router.get("/download/{category}/{filename}")
|
||||
async def download_file(category: FileCategory, filename: str, plugin_loader=Depends(get_plugin_loader)):
|
||||
with belief_scope("download_file"):
|
||||
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||
if not storage_plugin:
|
||||
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||
try:
|
||||
path = storage_plugin.get_file_path(category, filename)
|
||||
return FileResponse(path=path, filename=filename)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
# [/DEF:download_file:Function]
|
||||
|
||||
# [/DEF:storage_routes:Module]
|
||||
@@ -18,7 +18,7 @@ import os
|
||||
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage
|
||||
from .core.database import init_db
|
||||
|
||||
# [DEF:App:Global]
|
||||
@@ -89,6 +89,7 @@ app.include_router(environments.router, prefix="/api/environments", tags=["Envir
|
||||
app.include_router(mappings.router)
|
||||
app.include_router(migration.router)
|
||||
app.include_router(git.router)
|
||||
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
|
||||
|
||||
# [DEF:websocket_endpoint:Function]
|
||||
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from ..models.storage import StorageConfig
|
||||
|
||||
# [DEF:Schedule:DataClass]
|
||||
# @PURPOSE: Represents a backup schedule configuration.
|
||||
@@ -43,6 +44,7 @@ class LoggingConfig(BaseModel):
|
||||
# @PURPOSE: Represents global application settings.
|
||||
class GlobalSettings(BaseModel):
|
||||
backup_path: str
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
default_environment_id: Optional[str] = None
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
|
||||
|
||||
@@ -50,9 +50,18 @@ class PluginLoader:
|
||||
sys.path.insert(0, plugin_parent_dir)
|
||||
|
||||
for filename in os.listdir(self.plugin_dir):
|
||||
file_path = os.path.join(self.plugin_dir, filename)
|
||||
|
||||
# Handle directory-based plugins (packages)
|
||||
if os.path.isdir(file_path):
|
||||
init_file = os.path.join(file_path, "__init__.py")
|
||||
if os.path.exists(init_file):
|
||||
self._load_module(filename, init_file)
|
||||
continue
|
||||
|
||||
# Handle single-file plugins
|
||||
if filename.endswith(".py") and filename != "__init__.py":
|
||||
module_name = filename[:-3]
|
||||
file_path = os.path.join(self.plugin_dir, filename)
|
||||
self._load_module(module_name, file_path)
|
||||
# [/DEF:_load_plugins:Function]
|
||||
|
||||
|
||||
28
backend/src/models/storage.py
Normal file
28
backend/src/models/storage.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# [DEF:FileCategory:Class]
|
||||
class FileCategory(str, Enum):
|
||||
BACKUP = "backup"
|
||||
REPOSITORY = "repository"
|
||||
# [/DEF:FileCategory:Class]
|
||||
|
||||
# [DEF:StorageConfig:Class]
|
||||
class StorageConfig(BaseModel):
|
||||
root_path: str = Field(default="../ss-tools-storage", description="Absolute path to the storage root directory.")
|
||||
backup_structure_pattern: str = Field(default="{category}/", description="Pattern for backup directory structure.")
|
||||
repo_structure_pattern: str = Field(default="{category}/", description="Pattern for repository directory structure.")
|
||||
filename_pattern: str = Field(default="{name}_{timestamp}", description="Pattern for filenames.")
|
||||
# [/DEF:StorageConfig:Class]
|
||||
|
||||
# [DEF:StoredFile:Class]
|
||||
class StoredFile(BaseModel):
|
||||
name: str = Field(..., description="Name of the file (including extension).")
|
||||
path: str = Field(..., description="Relative path from storage root.")
|
||||
size: int = Field(..., ge=0, description="Size of the file in bytes.")
|
||||
created_at: datetime = Field(..., description="Creation timestamp.")
|
||||
category: FileCategory = Field(..., description="Category of the file.")
|
||||
mime_type: Optional[str] = Field(None, description="MIME type of the file.")
|
||||
# [/DEF:StoredFile:Class]
|
||||
3
backend/src/plugins/storage/__init__.py
Normal file
3
backend/src/plugins/storage/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .plugin import StoragePlugin
|
||||
|
||||
__all__ = ["StoragePlugin"]
|
||||
230
backend/src/plugins/storage/plugin.py
Normal file
230
backend/src/plugins/storage/plugin.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# [DEF:StoragePlugin:Module]
|
||||
#
|
||||
# @SEMANTICS: storage, files, filesystem, plugin
|
||||
# @PURPOSE: Provides core filesystem operations for managing backups and repositories.
|
||||
# @LAYER: App
|
||||
# @RELATION: IMPLEMENTS -> PluginBase
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.storage
|
||||
#
|
||||
# @INVARIANT: All file operations must be restricted to the configured storage root.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from fastapi import UploadFile
|
||||
|
||||
from ...core.plugin_base import PluginBase
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.storage import StoredFile, FileCategory, StorageConfig
|
||||
from ...dependencies import get_config_manager
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:StoragePlugin:Class]
|
||||
# @PURPOSE: Implementation of the storage management plugin.
|
||||
class StoragePlugin(PluginBase):
|
||||
"""
|
||||
Plugin for managing local file storage for backups and repositories.
|
||||
"""
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the StoragePlugin and ensures required directories exist.
|
||||
def __init__(self):
|
||||
with belief_scope("StoragePlugin:init"):
|
||||
self.ensure_directories()
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
@property
|
||||
# [DEF:id:Function]
|
||||
# @PURPOSE: Returns the unique identifier for the storage plugin.
|
||||
# @RETURN: str - "storage-manager"
|
||||
def id(self) -> str:
|
||||
return "storage-manager"
|
||||
# [/DEF:id:Function]
|
||||
|
||||
@property
|
||||
# [DEF:name:Function]
|
||||
# @PURPOSE: Returns the human-readable name of the storage plugin.
|
||||
# @RETURN: str - "Storage Manager"
|
||||
def name(self) -> str:
|
||||
return "Storage Manager"
|
||||
# [/DEF:name:Function]
|
||||
|
||||
@property
|
||||
# [DEF:description:Function]
|
||||
# @PURPOSE: Returns a description of the storage plugin.
|
||||
# @RETURN: str - Plugin description.
|
||||
def description(self) -> str:
|
||||
return "Manages local file storage for backups and repositories."
|
||||
# [/DEF:description:Function]
|
||||
|
||||
@property
|
||||
# [DEF:version:Function]
|
||||
# @PURPOSE: Returns the version of the storage plugin.
|
||||
# @RETURN: str - "1.0.0"
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
# [/DEF:version:Function]
|
||||
|
||||
# [DEF:get_schema:Function]
|
||||
# @PURPOSE: Returns the JSON schema for storage plugin parameters.
|
||||
# @RETURN: Dict[str, Any] - JSON schema.
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [c.value for c in FileCategory],
|
||||
"title": "Category"
|
||||
}
|
||||
},
|
||||
"required": ["category"]
|
||||
}
|
||||
# [/DEF:get_schema:Function]
|
||||
|
||||
# [DEF:execute:Function]
|
||||
# @PURPOSE: Executes storage-related tasks (placeholder for PluginBase compliance).
|
||||
async def execute(self, params: Dict[str, Any]):
|
||||
with belief_scope("StoragePlugin:execute"):
|
||||
logger.info(f"[StoragePlugin][Action] Executing with params: {params}")
|
||||
# [/DEF:execute:Function]
|
||||
|
||||
# [DEF:get_storage_root:Function]
|
||||
# @PURPOSE: Resolves the absolute path to the storage root.
|
||||
# @POST: Returns a Path object representing the storage root.
|
||||
def get_storage_root(self) -> Path:
|
||||
with belief_scope("StoragePlugin:get_storage_root"):
|
||||
config_manager = get_config_manager()
|
||||
storage_config = config_manager.get_config().settings.storage
|
||||
root = Path(storage_config.root_path)
|
||||
if not root.is_absolute():
|
||||
# Resolve relative to the workspace root (ss-tools)
|
||||
root = (Path(__file__).parents[4] / root).resolve()
|
||||
return root
|
||||
# [/DEF:get_storage_root:Function]
|
||||
|
||||
# [DEF:ensure_directories:Function]
|
||||
# @PURPOSE: Creates the storage root and category subdirectories if they don't exist.
|
||||
# @SIDE_EFFECT: Creates directories on the filesystem.
|
||||
def ensure_directories(self):
|
||||
with belief_scope("StoragePlugin:ensure_directories"):
|
||||
root = self.get_storage_root()
|
||||
for category in FileCategory:
|
||||
path = root / f"{category.value}s"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug(f"[StoragePlugin][Action] Ensured directory: {path}")
|
||||
# [/DEF:ensure_directories:Function]
|
||||
|
||||
# [DEF:validate_path:Function]
|
||||
# @PURPOSE: Prevents path traversal attacks by ensuring the path is within the storage root.
|
||||
# @PRE: path must be a Path object.
|
||||
# @POST: Returns the resolved absolute path if valid, otherwise raises ValueError.
|
||||
def validate_path(self, path: Path) -> Path:
|
||||
with belief_scope("StoragePlugin:validate_path"):
|
||||
root = self.get_storage_root().resolve()
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError:
|
||||
logger.error(f"[StoragePlugin][Coherence:Failed] Path traversal detected: {resolved} is not under {root}")
|
||||
raise ValueError("Access denied: Path is outside of storage root.")
|
||||
return resolved
|
||||
# [/DEF:validate_path:Function]
|
||||
|
||||
# [DEF:list_files:Function]
|
||||
# @PURPOSE: Lists all files in a specific category.
|
||||
# @PARAM: category (Optional[FileCategory]) - The category to list.
|
||||
# @RETURN: List[StoredFile] - List of file metadata objects.
|
||||
def list_files(self, category: Optional[FileCategory] = None) -> List[StoredFile]:
|
||||
with belief_scope("StoragePlugin:list_files"):
|
||||
root = self.get_storage_root()
|
||||
files = []
|
||||
|
||||
categories = [category] if category else list(FileCategory)
|
||||
|
||||
for cat in categories:
|
||||
cat_dir = root / f"{cat.value}s"
|
||||
if not cat_dir.exists():
|
||||
continue
|
||||
|
||||
for item in cat_dir.iterdir():
|
||||
if item.is_file():
|
||||
stat = item.stat()
|
||||
files.append(StoredFile(
|
||||
name=item.name,
|
||||
path=str(item.relative_to(root)),
|
||||
size=stat.st_size,
|
||||
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||
category=cat,
|
||||
mime_type=None # Could use python-magic here if needed
|
||||
))
|
||||
|
||||
return sorted(files, key=lambda x: x.created_at, reverse=True)
|
||||
# [/DEF:list_files:Function]
|
||||
|
||||
# [DEF:save_file:Function]
|
||||
# @PURPOSE: Saves an uploaded file to the specified category.
|
||||
# @PARAM: file (UploadFile) - The uploaded file.
|
||||
# @PARAM: category (FileCategory) - The target category.
|
||||
# @RETURN: StoredFile - Metadata of the saved file.
|
||||
# @SIDE_EFFECT: Writes file to disk.
|
||||
async def save_file(self, file: UploadFile, category: FileCategory) -> StoredFile:
|
||||
with belief_scope("StoragePlugin:save_file"):
|
||||
root = self.get_storage_root()
|
||||
dest_dir = root / f"{category.value}s"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest_path = self.validate_path(dest_dir / file.filename)
|
||||
|
||||
with dest_path.open("wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
stat = dest_path.stat()
|
||||
return StoredFile(
|
||||
name=dest_path.name,
|
||||
path=str(dest_path.relative_to(root)),
|
||||
size=stat.st_size,
|
||||
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||
category=category,
|
||||
mime_type=file.content_type
|
||||
)
|
||||
# [/DEF:save_file:Function]
|
||||
|
||||
# [DEF:delete_file:Function]
|
||||
# @PURPOSE: Deletes a file from the specified category.
|
||||
# @PARAM: category (FileCategory) - The category.
|
||||
# @PARAM: filename (str) - The name of the file.
|
||||
# @SIDE_EFFECT: Removes file from disk.
|
||||
def delete_file(self, category: FileCategory, filename: str):
|
||||
with belief_scope("StoragePlugin:delete_file"):
|
||||
root = self.get_storage_root()
|
||||
file_path = self.validate_path(root / f"{category.value}s" / filename)
|
||||
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
logger.info(f"[StoragePlugin][Action] Deleted file: {file_path}")
|
||||
else:
|
||||
raise FileNotFoundError(f"File {filename} not found in {category.value}s")
|
||||
# [/DEF:delete_file:Function]
|
||||
|
||||
# [DEF:get_file_path:Function]
|
||||
# @PURPOSE: Returns the absolute path of a file for download.
|
||||
# @PARAM: category (FileCategory) - The category.
|
||||
# @PARAM: filename (str) - The name of the file.
|
||||
# @RETURN: Path - Absolute path to the file.
|
||||
def get_file_path(self, category: FileCategory, filename: str) -> Path:
|
||||
with belief_scope("StoragePlugin:get_file_path"):
|
||||
root = self.get_storage_root()
|
||||
file_path = self.validate_path(root / f"{category.value}s" / filename)
|
||||
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"File {filename} not found in {category.value}s")
|
||||
|
||||
return file_path
|
||||
# [/DEF:get_file_path:Function]
|
||||
|
||||
# [/DEF:StoragePlugin:Class]
|
||||
# [/DEF:StoragePlugin:Module]
|
||||
Reference in New Issue
Block a user