diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b68ed2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +*.o +*.a + +# Logs +*.log + +# Config (local) +config.yaml +!config.yaml.example + diff --git a/BAMS.code-workspace b/BAMS.code-workspace new file mode 100644 index 0000000..362d7c2 --- /dev/null +++ b/BAMS.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e3aa485 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +.PHONY: build install clean test + +build: + cd backend && go build -o ../bin/bams-backend . + +install: build + sudo cp bin/bams-backend /usr/local/bin/ + sudo cp -r cockpit /usr/share/cockpit/bams + sudo cp configs/bams.service /etc/systemd/system/ + sudo cp configs/config.yaml.example /etc/bams/config.yaml + sudo cp configs/polkit.rules /etc/polkit-1/rules.d/50-bams.rules + sudo systemctl daemon-reload + +clean: + rm -rf bin/ + cd backend && go clean + +test: + cd backend && go test ./... + +run: + cd backend && go run . + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0f4640 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# BAMS - Backup Appliance Management System + +A comprehensive management system for backup appliances, providing unified control over disk repositories, tape libraries, iSCSI targets, and Bacula integration. + +## Features + +- **Dashboard**: Real-time monitoring of storage, tape library, iSCSI sessions, and Bacula status +- **Disk Repository Management**: Create and manage LVM and ZFS repositories +- **Tape Library Management**: Inventory, load/unload operations for LTO-8 libraries +- **iSCSI Target Management**: Configure SCST targets with portal and initiator ACL +- **Bacula Integration**: Status monitoring, config generation, and inventory operations +- **Logs & Diagnostics**: Live log viewing and support bundle generation + +## Architecture + +- **Backend**: Go-based REST API service running as systemd service +- **Frontend**: Cockpit plugin providing web-based UI +- **Storage**: LVM and ZFS support for disk repositories +- **iSCSI**: SCST framework for iSCSI target management +- **Tape**: Support for LTO-8 SAS/FC tape libraries + +## Requirements + +- Ubuntu Server 24.04 LTS +- Go 1.21+ +- Cockpit 300+ +- SCST (stable) +- Bacula Community/Enterprise +- LTO-8 compatible tape library + +## Installation + +### 1. Build Backend + +```bash +cd backend +go mod download +go build -o bams-backend . +sudo cp bams-backend /usr/local/bin/ +``` + +### 2. Install Cockpit Plugin + +```bash +sudo cp -r cockpit /usr/share/cockpit/bams +sudo systemctl restart cockpit +``` + +### 3. Configure System + +```bash +# Create user and group +sudo useradd -r -s /bin/false bams +sudo groupadd bams-admin +sudo groupadd bams-operator + +# Create directories +sudo mkdir -p /var/lib/bams +sudo mkdir -p /etc/bams +sudo mkdir -p /var/log/bams + +# Copy configuration +sudo cp configs/config.yaml.example /etc/bams/config.yaml +sudo cp configs/bams.service /etc/systemd/system/ + +# Install polkit rules +sudo cp configs/polkit.rules /etc/polkit-1/rules.d/50-bams.rules + +# Set permissions +sudo chown -R bams:bams /var/lib/bams +sudo chown -R bams:bams /var/log/bams +sudo chmod 640 /etc/bams/config.yaml +``` + +### 4. Start Service + +```bash +sudo systemctl daemon-reload +sudo systemctl enable bams.service +sudo systemctl start bams.service +``` + +## Configuration + +Edit `/etc/bams/config.yaml` to customize: + +- Port number (default: 8080) +- Log level (debug, info, warn, error) +- Data directory +- SCST and Bacula config paths +- Security settings + +## Usage + +1. Access Cockpit web interface +2. Navigate to "BAMS" in the tools menu +3. Use the dashboard to monitor system status +4. Manage storage repositories, tape library, and iSCSI targets through the UI + +## API Endpoints + +The backend provides a REST API at `http://localhost:8080/api/v1/`: + +- `GET /dashboard` - Dashboard summary +- `GET /disk/repositories` - List repositories +- `POST /disk/repositories` - Create repository +- `GET /tape/library` - Library status +- `POST /tape/inventory` - Run inventory +- `GET /iscsi/targets` - List iSCSI targets +- `POST /iscsi/targets` - Create target +- `GET /bacula/status` - Bacula SD status +- `GET /logs/{service}` - Get logs +- `GET /diagnostics/bundle` - Download support bundle + +## Security + +- Privileged operations require polkit authorization +- Users must be in `bams-admin` or `bams-operator` groups +- HTTPS/TLS recommended for production +- Audit logging for configuration changes + +## Troubleshooting + +- Check service status: `sudo systemctl status bams` +- View logs: `sudo journalctl -u bams -f` +- Verify Cockpit plugin: Check `/usr/share/cockpit/bams` exists +- Test API: `curl http://localhost:8080/api/v1/health` + +## License + +GPL-3.0 + diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..0fa8833 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,8 @@ +module github.com/bams/backend + +go 1.21 + +require ( + github.com/gorilla/mux v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..3e12cf5 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,6 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go new file mode 100644 index 0000000..c9af395 --- /dev/null +++ b/backend/internal/api/handlers.go @@ -0,0 +1,422 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/bams/backend/internal/services" + "github.com/gorilla/mux" +) + +func handleHealth() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "timestamp": time.Now().Unix(), + }) + } +} + +func handleDashboard(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get dashboard data from all services + dashboard := map[string]interface{}{ + "disk": map[string]interface{}{ + "total_capacity": 0, + "used_capacity": 0, + "repositories": 0, + }, + "tape": map[string]interface{}{ + "library_status": "unknown", + "drives_active": 0, + "total_slots": 0, + "loaded_tapes": 0, + }, + "iscsi": map[string]interface{}{ + "targets": 0, + "sessions": 0, + }, + "bacula": map[string]interface{}{ + "status": "unknown", + }, + "alerts": []map[string]interface{}{}, + } + + // Populate from services + repos, _ := sm.Disk.ListRepositories() + if repos != nil { + dashboard["disk"].(map[string]interface{})["repositories"] = len(repos) + } + + library, _ := sm.Tape.GetLibrary() + if library != nil { + dashboard["tape"].(map[string]interface{})["library_status"] = library.Status + dashboard["tape"].(map[string]interface{})["drives_active"] = library.ActiveDrives + dashboard["tape"].(map[string]interface{})["total_slots"] = library.TotalSlots + } + + targets, _ := sm.ISCSI.ListTargets() + if targets != nil { + dashboard["iscsi"].(map[string]interface{})["targets"] = len(targets) + } + + sessions, _ := sm.ISCSI.ListSessions() + if sessions != nil { + dashboard["iscsi"].(map[string]interface{})["sessions"] = len(sessions) + } + + baculaStatus, _ := sm.Bacula.GetStatus() + if baculaStatus != nil { + dashboard["bacula"].(map[string]interface{})["status"] = baculaStatus.Status + } + + jsonResponse(w, http.StatusOK, dashboard) + } +} + +// Disk repository handlers +func handleListRepositories(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + repos, err := sm.Disk.ListRepositories() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, repos) + } +} + +func handleCreateRepository(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Size string `json:"size"` + Type string `json:"type"` // "lvm" or "zfs" + VGName string `json:"vg_name,omitempty"` + PoolName string `json:"pool_name,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "Invalid request body") + 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()) + return + } + jsonResponse(w, http.StatusCreated, repo) + } +} + +func handleGetRepository(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + repo, err := sm.Disk.GetRepository(vars["id"]) + if err != nil { + jsonError(w, http.StatusNotFound, err.Error()) + return + } + jsonResponse(w, http.StatusOK, repo) + } +} + +func handleDeleteRepository(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + if err := sm.Disk.DeleteRepository(vars["id"]); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusNoContent, nil) + } +} + +// Tape library handlers +func handleGetLibrary(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + library, err := sm.Tape.GetLibrary() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, library) + } +} + +func handleInventory(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := sm.Tape.RunInventory(); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"status": "inventory_started"}) + } +} + +func handleListDrives(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + drives, err := sm.Tape.ListDrives() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, drives) + } +} + +func handleLoadTape(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + var req struct { + Slot int `json:"slot"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "Invalid request body") + return + } + + if err := sm.Tape.LoadTape(vars["id"], req.Slot); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"status": "load_started"}) + } +} + +func handleUnloadTape(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + var req struct { + Slot int `json:"slot"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "Invalid request body") + return + } + + if err := sm.Tape.UnloadTape(vars["id"], req.Slot); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"status": "unload_started"}) + } +} + +func handleListSlots(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + slots, err := sm.Tape.ListSlots() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, slots) + } +} + +// iSCSI target handlers +func handleListTargets(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + targets, err := sm.ISCSI.ListTargets() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, targets) + } +} + +func handleCreateTarget(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + IQN string `json:"iqn"` + Portals []string `json:"portals"` + Initiators []string `json:"initiators"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "Invalid request body") + return + } + + target, err := sm.ISCSI.CreateTarget(req.IQN, req.Portals, req.Initiators) + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusCreated, target) + } +} + +func handleGetTarget(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + target, err := sm.ISCSI.GetTarget(vars["id"]) + if err != nil { + jsonError(w, http.StatusNotFound, err.Error()) + return + } + jsonResponse(w, http.StatusOK, target) + } +} + +func handleUpdateTarget(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + var req struct { + Portals []string `json:"portals"` + Initiators []string `json:"initiators"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "Invalid request body") + return + } + + target, err := sm.ISCSI.UpdateTarget(vars["id"], req.Portals, req.Initiators) + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, target) + } +} + +func handleDeleteTarget(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + if err := sm.ISCSI.DeleteTarget(vars["id"]); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusNoContent, nil) + } +} + +func handleApplyTarget(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + if err := sm.ISCSI.ApplyTarget(vars["id"]); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"status": "applied"}) + } +} + +func handleListSessions(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sessions, err := sm.ISCSI.ListSessions() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, sessions) + } +} + +// Bacula handlers +func handleBaculaStatus(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + status, err := sm.Bacula.GetStatus() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, status) + } +} + +func handleGetBaculaConfig(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + config, err := sm.Bacula.GetConfig() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, config) + } +} + +func handleGenerateBaculaConfig(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + config, err := sm.Bacula.GenerateConfig() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, config) + } +} + +func handleBaculaInventory(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := sm.Bacula.RunInventory(); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"status": "inventory_started"}) + } +} + +func handleBaculaRestart(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := sm.Bacula.Restart(); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"status": "restart_started"}) + } +} + +// Logs and diagnostics handlers +func handleGetLogs(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + lines := 100 + if linesStr := r.URL.Query().Get("lines"); linesStr != "" { + if n, err := strconv.Atoi(linesStr); err == nil { + lines = n + } + } + logs, err := sm.Logs.GetLogs(vars["service"], lines) + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResponse(w, http.StatusOK, logs) + } +} + +func handleStreamLogs(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // WebSocket streaming would be implemented here + jsonError(w, http.StatusNotImplemented, "WebSocket streaming not yet implemented") + } +} + +func handleDownloadBundle(sm *services.ServiceManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + bundle, err := sm.Logs.GenerateSupportBundle() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", "attachment; filename=bams-support-bundle.zip") + w.Write(bundle) + } +} + +// Helper functions +func jsonResponse(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if data != nil { + json.NewEncoder(w).Encode(data) + } +} + +func jsonError(w http.ResponseWriter, status int, message string) { + jsonResponse(w, status, map[string]string{"error": message}) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go new file mode 100644 index 0000000..1c71bab --- /dev/null +++ b/backend/internal/api/router.go @@ -0,0 +1,87 @@ +package api + +import ( + "net/http" + + "github.com/bams/backend/internal/logger" + "github.com/bams/backend/internal/services" + "github.com/gorilla/mux" +) + +func NewRouter(sm *services.ServiceManager, log *logger.Logger) *mux.Router { + router := mux.NewRouter() + + // Middleware + router.Use(loggingMiddleware(log)) + router.Use(corsMiddleware()) + + // API v1 routes + v1 := router.PathPrefix("/api/v1").Subrouter() + + // Dashboard + v1.HandleFunc("/dashboard", handleDashboard(sm)).Methods("GET") + + // Disk repository management + v1.HandleFunc("/disk/repositories", handleListRepositories(sm)).Methods("GET") + v1.HandleFunc("/disk/repositories", handleCreateRepository(sm)).Methods("POST") + v1.HandleFunc("/disk/repositories/{id}", handleGetRepository(sm)).Methods("GET") + v1.HandleFunc("/disk/repositories/{id}", handleDeleteRepository(sm)).Methods("DELETE") + + // Tape library management + v1.HandleFunc("/tape/library", handleGetLibrary(sm)).Methods("GET") + v1.HandleFunc("/tape/inventory", handleInventory(sm)).Methods("POST") + v1.HandleFunc("/tape/drives", handleListDrives(sm)).Methods("GET") + v1.HandleFunc("/tape/drives/{id}/load", handleLoadTape(sm)).Methods("POST") + v1.HandleFunc("/tape/drives/{id}/unload", handleUnloadTape(sm)).Methods("POST") + v1.HandleFunc("/tape/slots", handleListSlots(sm)).Methods("GET") + + // iSCSI target management + v1.HandleFunc("/iscsi/targets", handleListTargets(sm)).Methods("GET") + v1.HandleFunc("/iscsi/targets", handleCreateTarget(sm)).Methods("POST") + v1.HandleFunc("/iscsi/targets/{id}", handleGetTarget(sm)).Methods("GET") + 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/sessions", handleListSessions(sm)).Methods("GET") + + // Bacula integration + v1.HandleFunc("/bacula/status", handleBaculaStatus(sm)).Methods("GET") + v1.HandleFunc("/bacula/config", handleGetBaculaConfig(sm)).Methods("GET") + v1.HandleFunc("/bacula/config", handleGenerateBaculaConfig(sm)).Methods("POST") + v1.HandleFunc("/bacula/inventory", handleBaculaInventory(sm)).Methods("POST") + v1.HandleFunc("/bacula/restart", handleBaculaRestart(sm)).Methods("POST") + + // Logs and diagnostics + v1.HandleFunc("/logs/{service}", handleGetLogs(sm)).Methods("GET") + v1.HandleFunc("/logs/{service}/stream", handleStreamLogs(sm)).Methods("GET") + v1.HandleFunc("/diagnostics/bundle", handleDownloadBundle(sm)).Methods("GET") + + // Health check + router.HandleFunc("/health", handleHealth()).Methods("GET") + + 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) + }) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..0345cb8 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,61 @@ +package config + +import ( + "fmt" + "os" + "strconv" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Port int `yaml:"port"` + LogLevel string `yaml:"log_level"` + DataDir string `yaml:"data_dir"` + SCSTConfig string `yaml:"scst_config"` + BaculaConfig string `yaml:"bacula_config"` + Security SecurityConfig `yaml:"security"` +} + +type SecurityConfig struct { + RequireHTTPS bool `yaml:"require_https"` + AllowedUsers []string `yaml:"allowed_users"` +} + +func Load() (*Config, error) { + cfg := &Config{ + Port: 8080, + LogLevel: "info", + DataDir: "/var/lib/bams", + SCSTConfig: "/etc/scst.conf", + BaculaConfig: "/etc/bacula/bacula-sd.conf", + Security: SecurityConfig{ + RequireHTTPS: false, + AllowedUsers: []string{}, + }, + } + + // Load from environment or config file + if configPath := os.Getenv("BAMS_CONFIG"); configPath != "" { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + } + + // Override with environment variables + if portStr := os.Getenv("BAMS_PORT"); portStr != "" { + if port, err := strconv.Atoi(portStr); err == nil { + cfg.Port = port + } + } + + if logLevel := os.Getenv("BAMS_LOG_LEVEL"); logLevel != "" { + cfg.LogLevel = logLevel + } + + return cfg, nil +} diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go new file mode 100644 index 0000000..01534d0 --- /dev/null +++ b/backend/internal/logger/logger.go @@ -0,0 +1,60 @@ +package logger + +import ( + "log" + "os" +) + +type Logger struct { + level string + log *log.Logger +} + +func New(level string) *Logger { + return &Logger{ + level: level, + log: log.New(os.Stdout, "[BAMS] ", log.LstdFlags|log.Lshortfile), + } +} + +func (l *Logger) Info(msg string, fields ...interface{}) { + if l.shouldLog("info") { + l.log.Printf("[INFO] %s %v", msg, fields) + } +} + +func (l *Logger) Error(msg string, fields ...interface{}) { + if l.shouldLog("error") { + l.log.Printf("[ERROR] %s %v", msg, fields) + } +} + +func (l *Logger) Debug(msg string, fields ...interface{}) { + if l.shouldLog("debug") { + l.log.Printf("[DEBUG] %s %v", msg, fields) + } +} + +func (l *Logger) Warn(msg string, fields ...interface{}) { + if l.shouldLog("warn") { + l.log.Printf("[WARN] %s %v", msg, fields) + } +} + +func (l *Logger) shouldLog(level string) bool { + levels := map[string]int{ + "debug": 0, + "info": 1, + "warn": 2, + "error": 3, + } + currentLevel, ok := levels[l.level] + if !ok { + currentLevel = 1 + } + msgLevel, ok := levels[level] + if !ok { + return true + } + return msgLevel >= currentLevel +} diff --git a/backend/internal/services/audit/audit.go b/backend/internal/services/audit/audit.go new file mode 100644 index 0000000..a566ed8 --- /dev/null +++ b/backend/internal/services/audit/audit.go @@ -0,0 +1,74 @@ +package audit + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/bams/backend/internal/config" + "github.com/bams/backend/internal/logger" +) + +type AuditEntry struct { + Timestamp time.Time `json:"timestamp"` + User string `json:"user"` + Action string `json:"action"` + Resource string `json:"resource"` + Result string `json:"result"` // "success" or "failure" + Details string `json:"details,omitempty"` +} + +type Service struct { + config *config.Config + logger *logger.Logger + logFile *os.File +} + +func NewService(cfg *config.Config, log *logger.Logger) *Service { + auditDir := filepath.Join(cfg.DataDir, "audit") + os.MkdirAll(auditDir, 0755) + + logPath := filepath.Join(auditDir, fmt.Sprintf("audit-%s.log", time.Now().Format("2006-01-02"))) + file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640) + if err != nil { + log.Error("Failed to open audit log", "error", err) + } + + return &Service{ + config: cfg, + logger: log, + logFile: file, + } +} + +func (s *Service) Log(user, action, resource, result, details string) { + entry := AuditEntry{ + Timestamp: time.Now(), + User: user, + Action: action, + Resource: resource, + Result: result, + Details: details, + } + + data, err := json.Marshal(entry) + if err != nil { + s.logger.Error("Failed to marshal audit entry", "error", err) + return + } + + if s.logFile != nil { + s.logFile.WriteString(string(data) + "\n") + s.logFile.Sync() + } + + s.logger.Info("Audit log", "user", user, "action", action, "resource", resource, "result", result) +} + +func (s *Service) Close() { + if s.logFile != nil { + s.logFile.Close() + } +} diff --git a/backend/internal/services/bacula/service.go b/backend/internal/services/bacula/service.go new file mode 100644 index 0000000..7b4444e --- /dev/null +++ b/backend/internal/services/bacula/service.go @@ -0,0 +1,130 @@ +package bacula + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/bams/backend/internal/config" + "github.com/bams/backend/internal/logger" +) + +type Status struct { + Status string `json:"status"` // "running", "stopped", "unknown" + PID int `json:"pid,omitempty"` + Version string `json:"version,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +type Service struct { + config *config.Config + logger *logger.Logger +} + +func NewService(cfg *config.Config, log *logger.Logger) *Service { + return &Service{ + config: cfg, + logger: log, + } +} + +func (s *Service) GetStatus() (*Status, error) { + s.logger.Debug("Getting Bacula SD status") + + status := &Status{ + Status: "unknown", + } + + // Check if bacula-sd process is running + cmd := exec.Command("systemctl", "is-active", "bacula-sd") + output, err := cmd.CombinedOutput() + if err == nil { + if strings.TrimSpace(string(output)) == "active" { + status.Status = "running" + } else { + status.Status = "stopped" + } + } + + return status, nil +} + +func (s *Service) GetConfig() (string, error) { + s.logger.Debug("Getting Bacula SD config") + + if _, err := os.Stat(s.config.BaculaConfig); err != nil { + return "", fmt.Errorf("config file not found: %w", err) + } + + data, err := os.ReadFile(s.config.BaculaConfig) + if err != nil { + return "", fmt.Errorf("failed to read config: %w", err) + } + + return string(data), nil +} + +func (s *Service) GenerateConfig() (string, error) { + s.logger.Info("Generating Bacula SD config") + + // Generate template configuration + config := `# Bacula Storage Daemon Configuration +# Generated by BAMS + +Storage { + Name = bacula-sd + WorkingDirectory = /var/lib/bacula + PidDirectory = /run/bacula + Maximum Concurrent Jobs = 20 + SDAddress = 0.0.0.0 +} + +Director { + Name = bacula-dir + Password = "changeme" +} + +Device { + Name = FileStorage + Media Type = File + Archive Device = /var/lib/bacula/storage + LabelMedia = yes + Random Access = yes + AutomaticMount = yes + RemovableMedia = no + AlwaysOpen = no +} + +# Autochanger configuration will be added here +` + + return config, nil +} + +func (s *Service) RunInventory() error { + s.logger.Info("Running Bacula inventory") + + // Use bconsole to run inventory + cmd := exec.Command("bconsole", "-c", "update slots") + output, err := cmd.CombinedOutput() + if err != nil { + s.logger.Error("Failed to run inventory", "error", string(output)) + return fmt.Errorf("inventory failed: %w", err) + } + + return nil +} + +func (s *Service) Restart() error { + s.logger.Info("Restarting Bacula SD") + + cmd := exec.Command("systemctl", "restart", "bacula-sd") + output, err := cmd.CombinedOutput() + if err != nil { + s.logger.Error("Failed to restart Bacula SD", "error", string(output)) + return fmt.Errorf("restart failed: %w", err) + } + + return nil +} diff --git a/backend/internal/services/disk/service.go b/backend/internal/services/disk/service.go new file mode 100644 index 0000000..bb3b7a5 --- /dev/null +++ b/backend/internal/services/disk/service.go @@ -0,0 +1,101 @@ +package disk + +import ( + "fmt" + "os/exec" + + "github.com/bams/backend/internal/config" + "github.com/bams/backend/internal/logger" +) + +type Repository struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // "lvm" or "zfs" + Size string `json:"size"` + Used string `json:"used"` + MountPoint string `json:"mount_point"` + Status string `json:"status"` + VGName string `json:"vg_name,omitempty"` + PoolName string `json:"pool_name,omitempty"` +} + +type Service struct { + config *config.Config + logger *logger.Logger +} + +func NewService(cfg *config.Config, log *logger.Logger) *Service { + return &Service{ + config: cfg, + logger: log, + } +} + +func (s *Service) ListRepositories() ([]*Repository, error) { + // TODO: Implement actual LVM/ZFS listing + s.logger.Debug("Listing disk repositories") + return []*Repository{}, nil +} + +func (s *Service) GetRepository(id string) (*Repository, error) { + // TODO: Implement actual repository retrieval + s.logger.Debug("Getting repository", "id", id) + return nil, fmt.Errorf("repository not found: %s", id) +} + +func (s *Service) CreateRepository(name, size, repoType, vgName, poolName string) (*Repository, error) { + s.logger.Info("Creating repository", "name", name, "size", size, "type", repoType) + + var err error + if repoType == "lvm" { + err = s.createLVMRepository(name, size, vgName) + } else if repoType == "zfs" { + err = s.createZFSRepository(name, size, poolName) + } else { + return nil, fmt.Errorf("unsupported repository type: %s", repoType) + } + + if err != nil { + return nil, err + } + + return &Repository{ + ID: name, + Name: name, + Type: repoType, + Size: size, + Status: "active", + VGName: vgName, + PoolName: poolName, + }, nil +} + +func (s *Service) createLVMRepository(name, size, vgName string) error { + // Create LVM logical volume + cmd := exec.Command("lvcreate", "-L", size, "-n", name, vgName) + output, err := cmd.CombinedOutput() + if err != nil { + s.logger.Error("Failed to create LVM volume", "error", string(output)) + return fmt.Errorf("failed to create LVM volume: %w", err) + } + return nil +} + +func (s *Service) createZFSRepository(name, size, poolName string) error { + // Create ZFS zvol + zvolPath := fmt.Sprintf("%s/%s", poolName, name) + cmd := exec.Command("zfs", "create", "-V", size, zvolPath) + output, err := cmd.CombinedOutput() + if err != nil { + s.logger.Error("Failed to create ZFS zvol", "error", string(output)) + return fmt.Errorf("failed to create ZFS zvol: %w", err) + } + return nil +} + +func (s *Service) DeleteRepository(id string) error { + s.logger.Info("Deleting repository", "id", id) + // TODO: Implement actual deletion + return nil +} diff --git a/backend/internal/services/iscsi/service.go b/backend/internal/services/iscsi/service.go new file mode 100644 index 0000000..035a723 --- /dev/null +++ b/backend/internal/services/iscsi/service.go @@ -0,0 +1,222 @@ +package iscsi + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/bams/backend/internal/config" + "github.com/bams/backend/internal/logger" +) + +type Target struct { + ID string `json:"id"` + IQN string `json:"iqn"` + Portals []string `json:"portals"` + Initiators []string `json:"initiators"` + LUNs []LUN `json:"luns"` + Status string `json:"status"` +} + +type LUN struct { + Number int `json:"number"` + Device string `json:"device"` + Type string `json:"type"` // "disk" or "tape" +} + +type Session struct { + TargetIQN string `json:"target_iqn"` + InitiatorIQN string `json:"initiator_iqn"` + IP string `json:"ip"` + State string `json:"state"` +} + +type Service struct { + config *config.Config + logger *logger.Logger +} + +func NewService(cfg *config.Config, log *logger.Logger) *Service { + return &Service{ + config: cfg, + logger: log, + } +} + +func (s *Service) ListTargets() ([]*Target, error) { + s.logger.Debug("Listing iSCSI targets") + + // Read SCST configuration + targets := []*Target{} + + // Parse SCST config file + if _, err := os.Stat(s.config.SCSTConfig); err == nil { + data, err := os.ReadFile(s.config.SCSTConfig) + if err == nil { + targets = s.parseSCSTConfig(string(data)) + } + } + + return targets, nil +} + +func (s *Service) GetTarget(id string) (*Target, error) { + targets, err := s.ListTargets() + if err != nil { + return nil, err + } + + for _, target := range targets { + if target.ID == id || target.IQN == id { + return target, nil + } + } + + return nil, fmt.Errorf("target not found: %s", id) +} + +func (s *Service) CreateTarget(iqn string, portals, initiators []string) (*Target, error) { + s.logger.Info("Creating iSCSI target", "iqn", iqn) + + target := &Target{ + ID: iqn, + IQN: iqn, + Portals: portals, + Initiators: initiators, + LUNs: []LUN{}, + Status: "pending", + } + + // Write to SCST config + if err := s.writeSCSTConfig(target); err != nil { + return nil, fmt.Errorf("failed to write SCST config: %w", err) + } + + return target, nil +} + +func (s *Service) UpdateTarget(id string, portals, initiators []string) (*Target, error) { + target, err := s.GetTarget(id) + if err != nil { + return nil, err + } + + target.Portals = portals + target.Initiators = initiators + + if err := s.writeSCSTConfig(target); err != nil { + return nil, fmt.Errorf("failed to update SCST config: %w", err) + } + + return target, nil +} + +func (s *Service) DeleteTarget(id string) error { + s.logger.Info("Deleting iSCSI target", "id", id) + + // Remove from SCST config + // TODO: Implement actual deletion + return nil +} + +func (s *Service) ApplyTarget(id string) error { + s.logger.Info("Applying iSCSI target configuration", "id", id) + + // Reload SCST configuration + cmd := exec.Command("scstadmin", "-config", s.config.SCSTConfig) + output, err := cmd.CombinedOutput() + if err != nil { + s.logger.Error("Failed to apply SCST config", "error", string(output)) + return fmt.Errorf("failed to apply SCST config: %w", err) + } + + return nil +} + +func (s *Service) ListSessions() ([]*Session, error) { + s.logger.Debug("Listing iSCSI sessions") + + sessions := []*Session{} + + // Query SCST for active sessions + cmd := exec.Command("scstadmin", "-list_sessions") + output, err := cmd.CombinedOutput() + if err == nil { + sessions = s.parseSCSTSessions(string(output)) + } + + return sessions, nil +} + +func (s *Service) parseSCSTConfig(data string) []*Target { + targets := []*Target{} + // Simplified parsing - real implementation would parse SCST config format + lines := strings.Split(data, "\n") + currentTarget := &Target{} + + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "TARGET") { + if currentTarget.IQN != "" { + targets = append(targets, currentTarget) + } + currentTarget = &Target{ + IQN: strings.Fields(line)[1], + ID: strings.Fields(line)[1], + Portals: []string{}, + Initiators: []string{}, + LUNs: []LUN{}, + Status: "active", + } + } + } + + if currentTarget.IQN != "" { + targets = append(targets, currentTarget) + } + + return targets +} + +func (s *Service) parseSCSTSessions(data string) []*Session { + sessions := []*Session{} + // Parse SCST session output + lines := strings.Split(data, "\n") + + for _, line := range lines { + if strings.Contains(line, "session") { + // Parse session information + session := &Session{ + State: "active", + } + sessions = append(sessions, session) + } + } + + return sessions +} + +func (s *Service) writeSCSTConfig(target *Target) error { + // Generate SCST configuration + config := fmt.Sprintf(`TARGET %s { + enabled 1 +`, target.IQN) + + for _, portal := range target.Portals { + config += fmt.Sprintf(" portal %s {\n", portal) + config += " enabled 1\n" + config += " }\n" + } + + for _, initiator := range target.Initiators { + config += fmt.Sprintf(" initiator %s {\n", initiator) + config += " enabled 1\n" + config += " }\n" + } + + config += "}\n" + + // Append to config file (simplified - real implementation would merge properly) + return os.WriteFile(s.config.SCSTConfig, []byte(config), 0644) +} diff --git a/backend/internal/services/logs/service.go b/backend/internal/services/logs/service.go new file mode 100644 index 0000000..73591d5 --- /dev/null +++ b/backend/internal/services/logs/service.go @@ -0,0 +1,136 @@ +package logs + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/bams/backend/internal/config" + "github.com/bams/backend/internal/logger" +) + +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Level string `json:"level"` + Message string `json:"message"` +} + +type Service struct { + config *config.Config + logger *logger.Logger +} + +func NewService(cfg *config.Config, log *logger.Logger) *Service { + return &Service{ + config: cfg, + logger: log, + } +} + +func (s *Service) GetLogs(service string, lines int) ([]*LogEntry, error) { + var logPath string + + switch service { + case "scst": + logPath = "/var/log/scst.log" + case "iscsi": + logPath = "/var/log/kern.log" // iSCSI logs often in kernel log + case "bacula": + logPath = "/var/log/bacula/bacula.log" + case "bams": + logPath = "/var/log/bams/bams.log" + default: + return nil, fmt.Errorf("unknown service: %s", service) + } + + if _, err := os.Stat(logPath); err != nil { + return []*LogEntry{}, nil + } + + data, err := os.ReadFile(logPath) + if err != nil { + return nil, fmt.Errorf("failed to read log file: %w", err) + } + + entries := []*LogEntry{} + logLines := strings.Split(string(data), "\n") + + // Get last N lines + start := 0 + if len(logLines) > lines { + start = len(logLines) - lines + } + + for i := start; i < len(logLines); i++ { + if logLines[i] == "" { + continue + } + entry := &LogEntry{ + Timestamp: time.Now(), // Simplified - would parse from log line + Level: "info", + Message: logLines[i], + } + entries = append(entries, entry) + } + + return entries, nil +} + +func (s *Service) GenerateSupportBundle() ([]byte, error) { + s.logger.Info("Generating support bundle") + + // Create temporary directory + tmpDir := "/tmp/bams-bundle" + os.MkdirAll(tmpDir, 0755) + + // Collect logs + logs := []string{"scst", "iscsi", "bacula", "bams"} + for _, service := range logs { + logPath := s.getLogPath(service) + if _, err := os.Stat(logPath); err == nil { + data, _ := os.ReadFile(logPath) + os.WriteFile(filepath.Join(tmpDir, fmt.Sprintf("%s.log", service)), data, 0644) + } + } + + // Collect configs + configs := map[string]string{ + "scst.conf": s.config.SCSTConfig, + "bacula-sd.conf": s.config.BaculaConfig, + } + for name, path := range configs { + if _, err := os.Stat(path); err == nil { + data, _ := os.ReadFile(path) + os.WriteFile(filepath.Join(tmpDir, name), data, 0644) + } + } + + // Collect system info + systemInfo := fmt.Sprintf("OS: %s\n", "Linux") + os.WriteFile(filepath.Join(tmpDir, "system-info.txt"), []byte(systemInfo), 0644) + + // Create zip (simplified - would use archive/zip) + bundleData := []byte("Support bundle placeholder") + + // Cleanup + os.RemoveAll(tmpDir) + + return bundleData, nil +} + +func (s *Service) getLogPath(service string) string { + switch service { + case "scst": + return "/var/log/scst.log" + case "iscsi": + return "/var/log/kern.log" + case "bacula": + return "/var/log/bacula/bacula.log" + case "bams": + return "/var/log/bams/bams.log" + default: + return "" + } +} diff --git a/backend/internal/services/manager.go b/backend/internal/services/manager.go new file mode 100644 index 0000000..7dd38ed --- /dev/null +++ b/backend/internal/services/manager.go @@ -0,0 +1,42 @@ +package services + +import ( + "github.com/bams/backend/internal/config" + "github.com/bams/backend/internal/logger" + "github.com/bams/backend/internal/services/bacula" + "github.com/bams/backend/internal/services/disk" + "github.com/bams/backend/internal/services/iscsi" + "github.com/bams/backend/internal/services/logs" + "github.com/bams/backend/internal/services/tape" +) + +type ServiceManager struct { + Config *config.Config + Logger *logger.Logger + Disk *disk.Service + ISCSI *iscsi.Service + Tape *tape.Service + Bacula *bacula.Service + Logs *logs.Service +} + +func NewServiceManager(cfg *config.Config, log *logger.Logger) *ServiceManager { + sm := &ServiceManager{ + Config: cfg, + Logger: log, + } + + // Initialize services + sm.Disk = disk.NewService(cfg, log) + sm.ISCSI = iscsi.NewService(cfg, log) + sm.Tape = tape.NewService(cfg, log) + sm.Bacula = bacula.NewService(cfg, log) + sm.Logs = logs.NewService(cfg, log) + + return sm +} + +func (sm *ServiceManager) Shutdown() { + sm.Logger.Info("Shutting down services...") + // Graceful shutdown of all services +} diff --git a/backend/internal/services/tape/service.go b/backend/internal/services/tape/service.go new file mode 100644 index 0000000..7d5dda8 --- /dev/null +++ b/backend/internal/services/tape/service.go @@ -0,0 +1,183 @@ +package tape + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + + "github.com/bams/backend/internal/config" + "github.com/bams/backend/internal/logger" +) + +type Library struct { + Status string `json:"status"` + TotalSlots int `json:"total_slots"` + ActiveDrives int `json:"active_drives"` + Model string `json:"model"` + Serial string `json:"serial"` +} + +type Drive struct { + ID string `json:"id"` + Status string `json:"status"` + LoadedTape string `json:"loaded_tape,omitempty"` + Barcode string `json:"barcode,omitempty"` + Position int `json:"position"` +} + +type Slot struct { + Number int `json:"number"` + Barcode string `json:"barcode,omitempty"` + Status string `json:"status"` // "empty", "loaded", "import" +} + +type Service struct { + config *config.Config + logger *logger.Logger +} + +func NewService(cfg *config.Config, log *logger.Logger) *Service { + return &Service{ + config: cfg, + logger: log, + } +} + +func (s *Service) GetLibrary() (*Library, error) { + s.logger.Debug("Getting tape library info") + + // Try to detect library using mtx or sg_lib + library := &Library{ + Status: "unknown", + TotalSlots: 0, + ActiveDrives: 0, + } + + // Check for mtx (media changer) + cmd := exec.Command("mtx", "-f", "/dev/sg0", "status") + output, err := cmd.CombinedOutput() + if err == nil { + // Parse mtx output + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "Storage Element") { + library.TotalSlots++ + } + if strings.Contains(line, "Data Transfer Element") { + library.ActiveDrives++ + } + } + library.Status = "online" + } + + return library, nil +} + +func (s *Service) RunInventory() error { + s.logger.Info("Running tape library inventory") + + // Use mtx to inventory + cmd := exec.Command("mtx", "-f", "/dev/sg0", "inventory") + output, err := cmd.CombinedOutput() + if err != nil { + s.logger.Error("Failed to run inventory", "error", string(output)) + return fmt.Errorf("inventory failed: %w", err) + } + + return nil +} + +func (s *Service) ListDrives() ([]*Drive, error) { + s.logger.Debug("Listing tape drives") + + drives := []*Drive{} + + // Detect drives (up to 8) + for i := 0; i < 8; i++ { + device := fmt.Sprintf("/dev/nst%d", i) + cmd := exec.Command("mt", "-f", device, "status") + output, err := cmd.CombinedOutput() + if err == nil { + drive := &Drive{ + ID: fmt.Sprintf("drive-%d", i), + Status: "online", + Position: i, + } + + // Check if tape is loaded + if strings.Contains(string(output), "ONLINE") { + drive.Status = "loaded" + } + + drives = append(drives, drive) + } + } + + return drives, nil +} + +func (s *Service) LoadTape(driveID string, slot int) error { + s.logger.Info("Loading tape", "drive", driveID, "slot", slot) + + // Extract drive number from ID + driveNum := 0 + fmt.Sscanf(driveID, "drive-%d", &driveNum) + + // Use mtx to load + cmd := exec.Command("mtx", "-f", "/dev/sg0", "load", strconv.Itoa(slot), strconv.Itoa(driveNum)) + output, err := cmd.CombinedOutput() + if err != nil { + s.logger.Error("Failed to load tape", "error", string(output)) + return fmt.Errorf("load failed: %w", err) + } + + return nil +} + +func (s *Service) UnloadTape(driveID string, slot int) error { + s.logger.Info("Unloading tape", "drive", driveID, "slot", slot) + + // Extract drive number from ID + driveNum := 0 + fmt.Sscanf(driveID, "drive-%d", &driveNum) + + // Use mtx to unload + cmd := exec.Command("mtx", "-f", "/dev/sg0", "unload", strconv.Itoa(slot), strconv.Itoa(driveNum)) + output, err := cmd.CombinedOutput() + if err != nil { + s.logger.Error("Failed to unload tape", "error", string(output)) + return fmt.Errorf("unload failed: %w", err) + } + + return nil +} + +func (s *Service) ListSlots() ([]*Slot, error) { + s.logger.Debug("Listing tape slots") + + slots := []*Slot{} + + // Use mtx to get slot status + cmd := exec.Command("mtx", "-f", "/dev/sg0", "status") + output, err := cmd.CombinedOutput() + if err != nil { + return slots, fmt.Errorf("failed to get slot status: %w", err) + } + + // Parse mtx output + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "Storage Element") { + // Parse slot information + slot := &Slot{ + Status: "empty", + } + // Extract slot number and barcode from line + // This is simplified - real parsing would be more complex + slots = append(slots, slot) + } + } + + return slots, nil +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..14c9d78 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/bams/backend/internal/api" + "github.com/bams/backend/internal/config" + "github.com/bams/backend/internal/logger" + "github.com/bams/backend/internal/services" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Initialize logger + logger := logger.New(cfg.LogLevel) + + // Initialize services + svcManager := services.NewServiceManager(cfg, logger) + + // Initialize API router + router := api.NewRouter(svcManager, logger) + + // Create HTTP server + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + logger.Info("Starting BAMS backend server", "port", cfg.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("Server failed to start", "error", err) + os.Exit(1) + } + }() + + // Graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logger.Info("Shutting down server...") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + logger.Error("Server forced to shutdown", "error", err) + } + + svcManager.Shutdown() + logger.Info("Server exited") +} diff --git a/cockpit/bams.js b/cockpit/bams.js new file mode 100644 index 0000000..bcffff4 --- /dev/null +++ b/cockpit/bams.js @@ -0,0 +1,354 @@ +(function() { + "use strict"; + + const API_BASE = "http://localhost:8080/api/v1"; + let currentTab = "dashboard"; + + // Initialize + $(document).ready(function() { + setupTabs(); + loadDashboard(); + setupEventHandlers(); + }); + + function setupTabs() { + $("a[data-tab]").on("click", function(e) { + e.preventDefault(); + const tab = $(this).data("tab"); + switchTab(tab); + }); + } + + function switchTab(tab) { + $(".tab-content").hide(); + $(".nav li").removeClass("active"); + $(`#${tab}`).show(); + $(`a[data-tab="${tab}"]`).parent().addClass("active"); + currentTab = tab; + + // Load tab-specific data + switch(tab) { + case "dashboard": + loadDashboard(); + break; + case "storage": + loadRepositories(); + break; + case "tape": + loadTapeLibrary(); + break; + case "iscsi": + loadiSCSITargets(); + break; + case "bacula": + loadBaculaStatus(); + break; + case "logs": + loadLogs(); + break; + } + } + + function apiCall(endpoint, method, data) { + return new Promise((resolve, reject) => { + const options = { + url: `${API_BASE}${endpoint}`, + method: method || "GET", + contentType: "application/json", + success: resolve, + error: function(xhr, status, error) { + reject(new Error(error || xhr.responseText)); + } + }; + if (data) { + options.data = JSON.stringify(data); + } + $.ajax(options); + }); + } + + function loadDashboard() { + apiCall("/dashboard") + .then(data => { + // Update disk stats + const disk = data.disk || {}; + $("#disk-stats").html(` +
Repositories: ${disk.repositories || 0}
+Total: ${formatBytes(disk.total_capacity || 0)}
+Used: ${formatBytes(disk.used_capacity || 0)}
+ `); + + // Update tape stats + const tape = data.tape || {}; + $("#tape-stats").html(` +Status: ${tape.library_status || "unknown"}
+Active Drives: ${tape.drives_active || 0}
+Total Slots: ${tape.total_slots || 0}
+ `); + + // Update iSCSI stats + const iscsi = data.iscsi || {}; + $("#iscsi-stats").html(` +Targets: ${iscsi.targets || 0}
+Sessions: ${iscsi.sessions || 0}
+ `); + + // Update Bacula stats + const bacula = data.bacula || {}; + $("#bacula-stats").html(` +Status: ${bacula.status || "unknown"}
+ `); + + // Update alerts + const alerts = data.alerts || []; + if (alerts.length > 0) { + let alertsHtml = "No alerts
"); + } + }) + .catch(err => { + console.error("Failed to load dashboard:", err); + $("#disk-stats, #tape-stats, #iscsi-stats, #bacula-stats").html("Error loading data
"); + }); + } + + function loadRepositories() { + apiCall("/disk/repositories") + .then(repos => { + let html = ""; + if (repos.length === 0) { + html = "Status: ${library.status}
+Model: ${library.model || "N/A"}
+Total Slots: ${library.total_slots}
+Active Drives: ${library.active_drives}
+ `); + + // Drives + let drivesHtml = ""; + if (drives.length === 0) { + drivesHtml = "No drives detected
"; + } else { + drives.forEach(drive => { + drivesHtml += ` +| Slot | Barcode | Status |
|---|---|---|
| No slots | ||
| ${slot.number} | +${slot.barcode || "N/A"} | +${slot.status} | +
Status: ${status.status}
+ ${status.version ? `Version: ${status.version}
` : ""} + ${status.pid ? `PID: ${status.pid}
` : ""} + `); + }) + .catch(err => { + console.error("Failed to load Bacula status:", err); + }); + } + + function loadLogs() { + const service = $("#log-service").val(); + apiCall(`/logs/${service}?lines=100`) + .then(logs => { + let logsHtml = ""; + logs.forEach(entry => { + logsHtml += `[${entry.timestamp}] ${entry.level}: ${entry.message}\n`; + }); + $("#logs-content").text(logsHtml); + }) + .catch(err => { + console.error("Failed to load logs:", err); + $("#logs-content").text("Error loading logs: " + err.message); + }); + } + + function setupEventHandlers() { + $("#inventory-btn").on("click", function() { + apiCall("/tape/inventory", "POST") + .then(() => { + alert("Inventory started"); + loadTapeLibrary(); + }) + .catch(err => alert("Failed to start inventory: " + err.message)); + }); + + $("#bacula-inventory-btn").on("click", function() { + apiCall("/bacula/inventory", "POST") + .then(() => alert("Inventory started")) + .catch(err => alert("Failed to start inventory: " + err.message)); + }); + + $("#bacula-restart-btn").on("click", function() { + if (confirm("Restart Bacula Storage Daemon?")) { + apiCall("/bacula/restart", "POST") + .then(() => { + alert("Restart initiated"); + setTimeout(loadBaculaStatus, 2000); + }) + .catch(err => alert("Failed to restart: " + err.message)); + } + }); + + $("#refresh-logs-btn").on("click", loadLogs); + $("#log-service").on("change", loadLogs); + + $("#download-bundle-btn").on("click", function() { + window.location.href = `${API_BASE}/diagnostics/bundle`; + }); + } + + function formatBytes(bytes) { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]; + } + + // Global functions for inline handlers + window.deleteRepository = function(id) { + if (confirm("Delete this repository?")) { + apiCall(`/disk/repositories/${id}`, "DELETE") + .then(() => { + alert("Repository deleted"); + loadRepositories(); + }) + .catch(err => alert("Failed to delete: " + err.message)); + } + }; + + window.applyTarget = function(id) { + apiCall(`/iscsi/targets/${id}/apply`, "POST") + .then(() => alert("Target configuration applied")) + .catch(err => alert("Failed to apply: " + err.message)); + }; + + window.deleteTarget = function(id) { + if (confirm("Delete this iSCSI target?")) { + apiCall(`/iscsi/targets/${id}`, "DELETE") + .then(() => { + alert("Target deleted"); + loadiSCSITargets(); + }) + .catch(err => alert("Failed to delete: " + err.message)); + } + }; +})(); + diff --git a/cockpit/index.html b/cockpit/index.html new file mode 100644 index 0000000..3f688a9 --- /dev/null +++ b/cockpit/index.html @@ -0,0 +1,250 @@ + + + +Loading...
+Loading...
+Loading...
+Loading...
+No alerts
+