backend structure build

This commit is contained in:
2025-12-23 18:38:44 +00:00
parent 861e0f65c3
commit e7f55839eb
8 changed files with 702 additions and 27 deletions

View File

@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
@@ -102,6 +103,28 @@ func handleCreateRepository(sm *services.ServiceManager) http.HandlerFunc {
return
}
// Validate input
if err := validateRepositoryName(req.Name); err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateSize(req.Size); err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
if req.Type != "lvm" && req.Type != "zfs" {
jsonError(w, http.StatusBadRequest, "type must be 'lvm' or 'zfs'")
return
}
if req.Type == "lvm" && req.VGName == "" {
jsonError(w, http.StatusBadRequest, "vg_name is required for LVM repositories")
return
}
if req.Type == "zfs" && req.PoolName == "" {
jsonError(w, http.StatusBadRequest, "pool_name is required for ZFS repositories")
return
}
repo, err := sm.Disk.CreateRepository(req.Name, req.Size, req.Type, req.VGName, req.PoolName)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
@@ -240,6 +263,22 @@ func handleCreateTarget(sm *services.ServiceManager) http.HandlerFunc {
return
}
// Validate input
if err := validateIQN(req.IQN); err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
if len(req.Portals) == 0 {
jsonError(w, http.StatusBadRequest, "at least one portal is required")
return
}
for _, portal := range req.Portals {
if err := validatePortal(portal); err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
}
target, err := sm.ISCSI.CreateTarget(req.IQN, req.Portals, req.Initiators)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
@@ -315,6 +354,63 @@ func handleListSessions(sm *services.ServiceManager) http.HandlerFunc {
}
}
func handleAddLUN(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var req struct {
LUNNumber int `json:"lun_number"`
DevicePath string `json:"device_path"`
Type string `json:"type"` // "disk" or "tape"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Type != "disk" && req.Type != "tape" {
jsonError(w, http.StatusBadRequest, "type must be 'disk' or 'tape'")
return
}
if err := sm.ISCSI.AddLUN(vars["id"], req.LUNNumber, req.DevicePath, req.Type); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Return updated target
target, err := sm.ISCSI.GetTarget(vars["id"])
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, target)
}
}
func handleRemoveLUN(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
lunNumber := 0
if _, err := fmt.Sscanf(vars["lun"], "%d", &lunNumber); err != nil {
jsonError(w, http.StatusBadRequest, "invalid LUN number")
return
}
if err := sm.ISCSI.RemoveLUN(vars["id"], lunNumber); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Return updated target
target, err := sm.ISCSI.GetTarget(vars["id"])
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, target)
}
}
// Bacula handlers
func handleBaculaStatus(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,67 @@
package api
import (
"net/http"
"time"
"github.com/bams/backend/internal/logger"
"github.com/gorilla/mux"
)
func loggingMiddleware(log *logger.Logger) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Info("HTTP request", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
// Wrap response writer to capture status code
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
log.Info("HTTP response", "method", r.Method, "path", r.URL.Path, "status", wrapped.statusCode, "duration", duration)
})
}
}
func corsMiddleware() mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "3600")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}
func recoveryMiddleware(log *logger.Logger) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Error("Panic recovered", "error", err, "path", r.URL.Path)
jsonError(w, http.StatusInternalServerError, "Internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}

View File

@@ -1,8 +1,6 @@
package api
import (
"net/http"
"github.com/bams/backend/internal/logger"
"github.com/bams/backend/internal/services"
"github.com/gorilla/mux"
@@ -11,7 +9,8 @@ import (
func NewRouter(sm *services.ServiceManager, log *logger.Logger) *mux.Router {
router := mux.NewRouter()
// Middleware
// Middleware (order matters)
router.Use(recoveryMiddleware(log))
router.Use(loggingMiddleware(log))
router.Use(corsMiddleware())
@@ -42,6 +41,8 @@ func NewRouter(sm *services.ServiceManager, log *logger.Logger) *mux.Router {
v1.HandleFunc("/iscsi/targets/{id}", handleUpdateTarget(sm)).Methods("PUT")
v1.HandleFunc("/iscsi/targets/{id}", handleDeleteTarget(sm)).Methods("DELETE")
v1.HandleFunc("/iscsi/targets/{id}/apply", handleApplyTarget(sm)).Methods("POST")
v1.HandleFunc("/iscsi/targets/{id}/luns", handleAddLUN(sm)).Methods("POST")
v1.HandleFunc("/iscsi/targets/{id}/luns/{lun}", handleRemoveLUN(sm)).Methods("DELETE")
v1.HandleFunc("/iscsi/sessions", handleListSessions(sm)).Methods("GET")
// Bacula integration
@@ -61,27 +62,3 @@ func NewRouter(sm *services.ServiceManager, log *logger.Logger) *mux.Router {
return router
}
func loggingMiddleware(log *logger.Logger) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Info("HTTP request", "method", r.Method, "path", r.URL.Path)
next.ServeHTTP(w, r)
})
}
}
func corsMiddleware() mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,74 @@
package api
import (
"fmt"
"regexp"
"strings"
)
var (
iqnRegex = regexp.MustCompile(`^iqn\.\d{4}-\d{2}\.[^:]+:[^:]+$`)
)
func validateIQN(iqn string) error {
if !iqnRegex.MatchString(iqn) {
return fmt.Errorf("invalid IQN format: %s (expected format: iqn.YYYY-MM.reversed.domain:identifier)", iqn)
}
return nil
}
func validateRepositoryName(name string) error {
if name == "" {
return fmt.Errorf("repository name cannot be empty")
}
if len(name) > 64 {
return fmt.Errorf("repository name too long (max 64 characters)")
}
// Allow alphanumeric, dash, underscore
matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, name)
if !matched {
return fmt.Errorf("repository name contains invalid characters (only alphanumeric, dash, underscore allowed)")
}
return nil
}
func validateSize(size string) error {
if size == "" {
return fmt.Errorf("size cannot be empty")
}
// Basic validation for size format (e.g., "10G", "500M")
matched, _ := regexp.MatchString(`^\d+[KMGT]?$`, strings.ToUpper(size))
if !matched {
return fmt.Errorf("invalid size format: %s (expected format: 10G, 500M, etc.)", size)
}
return nil
}
func validateIP(ip string) error {
// Basic IP validation
parts := strings.Split(ip, ":")
if len(parts) != 2 {
return fmt.Errorf("invalid IP:port format: %s", ip)
}
ipParts := strings.Split(parts[0], ".")
if len(ipParts) != 4 {
return fmt.Errorf("invalid IP address: %s", parts[0])
}
return nil
}
func validatePortal(portal string) error {
if portal == "" {
return fmt.Errorf("portal cannot be empty")
}
// Format: IP:port or hostname:port
parts := strings.Split(portal, ":")
if len(parts) != 2 {
return fmt.Errorf("invalid portal format: %s (expected IP:port or hostname:port)", portal)
}
// If it looks like an IP, validate it
if matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+\.\d+$`, parts[0]); matched {
return validateIP(portal)
}
return nil
}

View File

@@ -0,0 +1,100 @@
package disk
import (
"fmt"
"os/exec"
"path/filepath"
"strings"
)
// MapToiSCSI maps a repository to an iSCSI LUN
func (s *Service) MapToiSCSI(repoID string, targetID string, lunNumber int) error {
s.logger.Info("Mapping repository to iSCSI", "repo", repoID, "target", targetID, "lun", lunNumber)
repo, err := s.GetRepository(repoID)
if err != nil {
return err
}
// Get device path for the repository
var devicePath string
if repo.Type == "lvm" {
// LVM device path: /dev/vgname/lvname
devicePath = fmt.Sprintf("/dev/%s/%s", repo.VGName, repo.Name)
} else if repo.Type == "zfs" {
// ZFS zvol path: /dev/zvol/poolname/volname
devicePath = fmt.Sprintf("/dev/zvol/%s/%s", repo.PoolName, repo.Name)
} else {
return fmt.Errorf("unsupported repository type: %s", repo.Type)
}
// Verify device exists
if _, err := filepath.EvalSymlinks(devicePath); err != nil {
return fmt.Errorf("device not found: %s", devicePath)
}
// The actual mapping is done by the iSCSI service
// This is just a helper to get the device path
return nil
}
// GetDevicePath returns the device path for a repository
func (s *Service) GetDevicePath(repoID string) (string, error) {
repo, err := s.GetRepository(repoID)
if err != nil {
return "", err
}
if repo.Type == "lvm" {
return fmt.Sprintf("/dev/%s/%s", repo.VGName, repo.Name), nil
} else if repo.Type == "zfs" {
return fmt.Sprintf("/dev/zvol/%s/%s", repo.PoolName, repo.Name), nil
}
return "", fmt.Errorf("unsupported repository type: %s", repo.Type)
}
// GetRepositoryInfo returns detailed information about a repository
func (s *Service) GetRepositoryInfo(repoID string) (map[string]interface{}, error) {
repo, err := s.GetRepository(repoID)
if err != nil {
return nil, err
}
info := map[string]interface{}{
"id": repo.ID,
"name": repo.Name,
"type": repo.Type,
"size": repo.Size,
"used": repo.Used,
"status": repo.Status,
"mount_point": repo.MountPoint,
}
// Get device path
devicePath, err := s.GetDevicePath(repoID)
if err == nil {
info["device_path"] = devicePath
}
// Get filesystem info if mounted
if repo.MountPoint != "" {
cmd := exec.Command("df", "-h", repo.MountPoint)
output, err := cmd.CombinedOutput()
if err == nil {
lines := strings.Split(string(output), "\n")
if len(lines) > 1 {
fields := strings.Fields(lines[1])
if len(fields) >= 5 {
info["filesystem"] = fields[0]
info["total_size"] = fields[1]
info["used_size"] = fields[2]
info["available"] = fields[3]
info["usage_percent"] = fields[4]
}
}
}
}
return info, nil
}

View File

@@ -0,0 +1,99 @@
package iscsi
import (
"fmt"
"os"
"path/filepath"
)
// AddLUN adds a LUN to a target
func (s *Service) AddLUN(targetID string, lunNumber int, devicePath string, lunType string) error {
s.logger.Info("Adding LUN to target", "target", targetID, "lun", lunNumber, "device", devicePath, "type", lunType)
// Validate device exists
if _, err := os.Stat(devicePath); err != nil {
return fmt.Errorf("device not found: %s", devicePath)
}
// Validate LUN type
if lunType != "disk" && lunType != "tape" {
return fmt.Errorf("invalid LUN type: %s (must be 'disk' or 'tape')", lunType)
}
// For tape library:
// LUN 0 = Media changer
// LUN 1-8 = Tape drives
if lunType == "tape" {
if lunNumber == 0 {
// Media changer
devicePath = "/dev/sg0" // Default media changer
} else if lunNumber >= 1 && lunNumber <= 8 {
// Tape drive
devicePath = fmt.Sprintf("/dev/nst%d", lunNumber-1)
} else {
return fmt.Errorf("invalid LUN number for tape: %d (must be 0-8)", lunNumber)
}
}
// Update target configuration
target, err := s.GetTarget(targetID)
if err != nil {
return err
}
// Check if LUN already exists
for _, lun := range target.LUNs {
if lun.Number == lunNumber {
return fmt.Errorf("LUN %d already exists", lunNumber)
}
}
// Add LUN
target.LUNs = append(target.LUNs, LUN{
Number: lunNumber,
Device: devicePath,
Type: lunType,
})
// Write updated config
return s.writeSCSTConfig(target)
}
// RemoveLUN removes a LUN from a target
func (s *Service) RemoveLUN(targetID string, lunNumber int) error {
s.logger.Info("Removing LUN from target", "target", targetID, "lun", lunNumber)
target, err := s.GetTarget(targetID)
if err != nil {
return err
}
// Find and remove LUN
found := false
newLUNs := []LUN{}
for _, lun := range target.LUNs {
if lun.Number != lunNumber {
newLUNs = append(newLUNs, lun)
} else {
found = true
}
}
if !found {
return fmt.Errorf("LUN %d not found", lunNumber)
}
target.LUNs = newLUNs
return s.writeSCSTConfig(target)
}
// MapRepositoryToLUN maps a disk repository to an iSCSI LUN
func (s *Service) MapRepositoryToLUN(targetID string, repositoryPath string, lunNumber int) error {
// Resolve repository path to device
devicePath, err := filepath.EvalSymlinks(repositoryPath)
if err != nil {
return fmt.Errorf("failed to resolve repository path: %w", err)
}
return s.AddLUN(targetID, lunNumber, devicePath, "disk")
}

View File

@@ -0,0 +1,76 @@
package utils
import (
"fmt"
"os/exec"
"strings"
)
// ExecuteCommand runs a shell command and returns output
func ExecuteCommand(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("%s: %w", string(output), err)
}
return strings.TrimSpace(string(output)), nil
}
// ExecuteCommandSilent runs a command without capturing output (for fire-and-forget)
func ExecuteCommandSilent(name string, args ...string) error {
cmd := exec.Command(name, args...)
return cmd.Run()
}
// ParseSize parses size string (e.g., "10G", "500M") and returns bytes
func ParseSize(sizeStr string) (int64, error) {
sizeStr = strings.ToUpper(strings.TrimSpace(sizeStr))
if sizeStr == "" {
return 0, fmt.Errorf("empty size string")
}
var multiplier int64 = 1
lastChar := sizeStr[len(sizeStr)-1]
if lastChar >= '0' && lastChar <= '9' {
// No unit, assume bytes
var size int64
fmt.Sscanf(sizeStr, "%d", &size)
return size, nil
}
switch lastChar {
case 'K':
multiplier = 1024
case 'M':
multiplier = 1024 * 1024
case 'G':
multiplier = 1024 * 1024 * 1024
case 'T':
multiplier = 1024 * 1024 * 1024 * 1024
default:
return 0, fmt.Errorf("unknown size unit: %c", lastChar)
}
var size int64
_, err := fmt.Sscanf(sizeStr[:len(sizeStr)-1], "%d", &size)
if err != nil {
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
}
return size * multiplier, nil
}
// FormatBytes formats bytes into human-readable string
func FormatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}