backend structure build
This commit is contained in:
186
ARCHITECTURE.md
Normal file
186
ARCHITECTURE.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# BAMS Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
BAMS (Backup Appliance Management System) is a comprehensive management platform for backup appliances, providing unified control over storage, tape libraries, iSCSI targets, and Bacula integration.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Cockpit Web UI │
|
||||
│ (Cockpit Plugin) │
|
||||
└──────────────────────┬──────────────────────────────────┘
|
||||
│ HTTP/REST API
|
||||
│
|
||||
┌──────────────────────▼──────────────────────────────────┐
|
||||
│ BAMS Backend Service (Go) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐│
|
||||
│ │ Disk │ │ Tape │ │ iSCSI │ │ Bacula ││
|
||||
│ │ Service │ │ Service │ │ Service │ │ Service ││
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬──────┘ └────┬────┘│
|
||||
└───────┼─────────────┼──────────────┼──────────────┼─────┘
|
||||
│ │ │ │
|
||||
┌───────▼─────┐ ┌─────▼─────┐ ┌─────▼──────┐ ┌─────▼─────┐
|
||||
│ LVM │ │ mtx │ │ SCST │ │ systemd │
|
||||
│ ZFS │ │ sg_lib │ │ iSCSI │ │ bacula-sd│
|
||||
└─────────────┘ └───────────┘ └────────────┘ └───────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Backend Service (Go)
|
||||
|
||||
**Location**: `backend/`
|
||||
|
||||
**Structure**:
|
||||
- `main.go` - Entry point, HTTP server setup
|
||||
- `internal/api/` - REST API handlers and routing
|
||||
- `internal/services/` - Business logic services
|
||||
- `disk/` - Disk repository management (LVM/ZFS)
|
||||
- `tape/` - Tape library management
|
||||
- `iscsi/` - iSCSI target management (SCST)
|
||||
- `bacula/` - Bacula integration
|
||||
- `logs/` - Logging and diagnostics
|
||||
- `audit/` - Audit logging
|
||||
- `internal/config/` - Configuration management
|
||||
- `internal/logger/` - Logging utilities
|
||||
- `internal/utils/` - Helper functions
|
||||
|
||||
**Key Features**:
|
||||
- RESTful API with JSON responses
|
||||
- Graceful shutdown handling
|
||||
- Request validation
|
||||
- Error recovery middleware
|
||||
- CORS support
|
||||
|
||||
### 2. Cockpit Plugin (Frontend)
|
||||
|
||||
**Location**: `cockpit/`
|
||||
|
||||
**Files**:
|
||||
- `manifest.json` - Plugin metadata
|
||||
- `index.html` - Main UI structure
|
||||
- `bams.js` - JavaScript application logic
|
||||
|
||||
**Features**:
|
||||
- Dashboard with real-time monitoring
|
||||
- Storage repository management
|
||||
- Tape library operations
|
||||
- iSCSI target configuration
|
||||
- Bacula status and control
|
||||
- Log viewer
|
||||
|
||||
### 3. Configuration
|
||||
|
||||
**Location**: `configs/`
|
||||
|
||||
**Files**:
|
||||
- `bams.service` - Systemd service file
|
||||
- `config.yaml.example` - Configuration template
|
||||
- `polkit.rules` - Polkit authorization rules
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Disk Repository Creation
|
||||
|
||||
1. User creates repository via UI
|
||||
2. Frontend sends POST to `/api/v1/disk/repositories`
|
||||
3. Backend validates input
|
||||
4. Disk service creates LVM volume or ZFS zvol
|
||||
5. Repository metadata stored
|
||||
6. Response returned to UI
|
||||
|
||||
### Tape Operations
|
||||
|
||||
1. User triggers inventory/load/unload
|
||||
2. Frontend sends request to API
|
||||
3. Tape service executes `mtx` commands
|
||||
4. Results parsed and returned
|
||||
5. UI updates display
|
||||
|
||||
### iSCSI Target Management
|
||||
|
||||
1. User creates/updates target
|
||||
2. Backend validates IQN, portals, initiators
|
||||
3. SCST configuration generated
|
||||
4. Configuration applied via `scstadmin`
|
||||
5. Status returned to UI
|
||||
|
||||
## Security
|
||||
|
||||
- **Authentication**: PAM/system users via Cockpit
|
||||
- **Authorization**: Polkit rules for privileged operations
|
||||
- **Audit Logging**: All configuration changes logged
|
||||
- **Input Validation**: All user input validated
|
||||
- **Error Handling**: Panic recovery middleware
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Dashboard
|
||||
- `GET /api/v1/dashboard` - System overview
|
||||
|
||||
### Disk Repositories
|
||||
- `GET /api/v1/disk/repositories` - List repositories
|
||||
- `POST /api/v1/disk/repositories` - Create repository
|
||||
- `GET /api/v1/disk/repositories/{id}` - Get repository
|
||||
- `DELETE /api/v1/disk/repositories/{id}` - Delete repository
|
||||
|
||||
### Tape Library
|
||||
- `GET /api/v1/tape/library` - Library status
|
||||
- `POST /api/v1/tape/inventory` - Run inventory
|
||||
- `GET /api/v1/tape/drives` - List drives
|
||||
- `POST /api/v1/tape/drives/{id}/load` - Load tape
|
||||
- `POST /api/v1/tape/drives/{id}/unload` - Unload tape
|
||||
- `GET /api/v1/tape/slots` - List slots
|
||||
|
||||
### iSCSI Targets
|
||||
- `GET /api/v1/iscsi/targets` - List targets
|
||||
- `POST /api/v1/iscsi/targets` - Create target
|
||||
- `GET /api/v1/iscsi/targets/{id}` - Get target
|
||||
- `PUT /api/v1/iscsi/targets/{id}` - Update target
|
||||
- `DELETE /api/v1/iscsi/targets/{id}` - Delete target
|
||||
- `POST /api/v1/iscsi/targets/{id}/apply` - Apply configuration
|
||||
- `POST /api/v1/iscsi/targets/{id}/luns` - Add LUN
|
||||
- `DELETE /api/v1/iscsi/targets/{id}/luns/{lun}` - Remove LUN
|
||||
- `GET /api/v1/iscsi/sessions` - List sessions
|
||||
|
||||
### Bacula
|
||||
- `GET /api/v1/bacula/status` - SD status
|
||||
- `GET /api/v1/bacula/config` - Get config
|
||||
- `POST /api/v1/bacula/config` - Generate config
|
||||
- `POST /api/v1/bacula/inventory` - Run inventory
|
||||
- `POST /api/v1/bacula/restart` - Restart SD
|
||||
|
||||
### Logs & Diagnostics
|
||||
- `GET /api/v1/logs/{service}` - Get logs
|
||||
- `GET /api/v1/logs/{service}/stream` - Stream logs (WebSocket)
|
||||
- `GET /api/v1/diagnostics/bundle` - Download support bundle
|
||||
|
||||
## Deployment
|
||||
|
||||
1. Build backend: `make build`
|
||||
2. Install service: `make install`
|
||||
3. Configure: Edit `/etc/bams/config.yaml`
|
||||
4. Start service: `systemctl start bams`
|
||||
5. Access via Cockpit web interface
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Go 1.21+**
|
||||
- **Cockpit 300+**
|
||||
- **SCST** (iSCSI target framework)
|
||||
- **mtx** (tape library control)
|
||||
- **LVM tools** (for LVM repositories)
|
||||
- **ZFS tools** (for ZFS repositories, optional)
|
||||
- **Bacula** (for backup integration)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- WebSocket support for real-time log streaming
|
||||
- Multi-tenant support
|
||||
- High Availability
|
||||
- Tape encryption management
|
||||
- Cloud tiering
|
||||
- Policy-based tape lifecycle
|
||||
|
||||
@@ -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) {
|
||||
|
||||
67
backend/internal/api/middleware.go
Normal file
67
backend/internal/api/middleware.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
74
backend/internal/api/validation.go
Normal file
74
backend/internal/api/validation.go
Normal 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
|
||||
}
|
||||
100
backend/internal/services/disk/mapping.go
Normal file
100
backend/internal/services/disk/mapping.go
Normal 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
|
||||
}
|
||||
99
backend/internal/services/iscsi/lun.go
Normal file
99
backend/internal/services/iscsi/lun.go
Normal 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")
|
||||
}
|
||||
76
backend/internal/utils/helpers.go
Normal file
76
backend/internal/utils/helpers.go
Normal 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])
|
||||
}
|
||||
Reference in New Issue
Block a user