BAMS initial project structure
This commit is contained in:
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -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
|
||||||
|
|
||||||
7
BAMS.code-workspace
Normal file
7
BAMS.code-workspace
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
Makefile
Normal file
23
Makefile
Normal file
@@ -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 .
|
||||||
|
|
||||||
132
README.md
Normal file
132
README.md
Normal file
@@ -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
|
||||||
|
|
||||||
8
backend/go.mod
Normal file
8
backend/go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
6
backend/go.sum
Normal file
6
backend/go.sum
Normal file
@@ -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=
|
||||||
422
backend/internal/api/handlers.go
Normal file
422
backend/internal/api/handlers.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
87
backend/internal/api/router.go
Normal file
87
backend/internal/api/router.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
61
backend/internal/config/config.go
Normal file
61
backend/internal/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
60
backend/internal/logger/logger.go
Normal file
60
backend/internal/logger/logger.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
74
backend/internal/services/audit/audit.go
Normal file
74
backend/internal/services/audit/audit.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
130
backend/internal/services/bacula/service.go
Normal file
130
backend/internal/services/bacula/service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
101
backend/internal/services/disk/service.go
Normal file
101
backend/internal/services/disk/service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
222
backend/internal/services/iscsi/service.go
Normal file
222
backend/internal/services/iscsi/service.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
136
backend/internal/services/logs/service.go
Normal file
136
backend/internal/services/logs/service.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/internal/services/manager.go
Normal file
42
backend/internal/services/manager.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
183
backend/internal/services/tape/service.go
Normal file
183
backend/internal/services/tape/service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
68
backend/main.go
Normal file
68
backend/main.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
354
cockpit/bams.js
Normal file
354
cockpit/bams.js
Normal file
@@ -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(`
|
||||||
|
<p><strong>Repositories:</strong> ${disk.repositories || 0}</p>
|
||||||
|
<p><strong>Total:</strong> ${formatBytes(disk.total_capacity || 0)}</p>
|
||||||
|
<p><strong>Used:</strong> ${formatBytes(disk.used_capacity || 0)}</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update tape stats
|
||||||
|
const tape = data.tape || {};
|
||||||
|
$("#tape-stats").html(`
|
||||||
|
<p><strong>Status:</strong> ${tape.library_status || "unknown"}</p>
|
||||||
|
<p><strong>Active Drives:</strong> ${tape.drives_active || 0}</p>
|
||||||
|
<p><strong>Total Slots:</strong> ${tape.total_slots || 0}</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update iSCSI stats
|
||||||
|
const iscsi = data.iscsi || {};
|
||||||
|
$("#iscsi-stats").html(`
|
||||||
|
<p><strong>Targets:</strong> ${iscsi.targets || 0}</p>
|
||||||
|
<p><strong>Sessions:</strong> ${iscsi.sessions || 0}</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update Bacula stats
|
||||||
|
const bacula = data.bacula || {};
|
||||||
|
$("#bacula-stats").html(`
|
||||||
|
<p><strong>Status:</strong> ${bacula.status || "unknown"}</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update alerts
|
||||||
|
const alerts = data.alerts || [];
|
||||||
|
if (alerts.length > 0) {
|
||||||
|
let alertsHtml = "<ul>";
|
||||||
|
alerts.forEach(alert => {
|
||||||
|
alertsHtml += `<li class="alert alert-${alert.level || 'warning'}">${alert.message}</li>`;
|
||||||
|
});
|
||||||
|
alertsHtml += "</ul>";
|
||||||
|
$("#alerts").html(alertsHtml);
|
||||||
|
} else {
|
||||||
|
$("#alerts").html("<p>No alerts</p>");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Failed to load dashboard:", err);
|
||||||
|
$("#disk-stats, #tape-stats, #iscsi-stats, #bacula-stats").html("<p>Error loading data</p>");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRepositories() {
|
||||||
|
apiCall("/disk/repositories")
|
||||||
|
.then(repos => {
|
||||||
|
let html = "";
|
||||||
|
if (repos.length === 0) {
|
||||||
|
html = "<tr><td colspan='6'>No repositories</td></tr>";
|
||||||
|
} else {
|
||||||
|
repos.forEach(repo => {
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td>${repo.name}</td>
|
||||||
|
<td>${repo.type}</td>
|
||||||
|
<td>${repo.size}</td>
|
||||||
|
<td>${repo.used || "N/A"}</td>
|
||||||
|
<td><span class="label label-${repo.status === 'active' ? 'success' : 'default'}">${repo.status}</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteRepository('${repo.id}')">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$("#repositories-table").html(html);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Failed to load repositories:", err);
|
||||||
|
$("#repositories-table").html("<tr><td colspan='6'>Error loading repositories</td></tr>");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTapeLibrary() {
|
||||||
|
Promise.all([
|
||||||
|
apiCall("/tape/library"),
|
||||||
|
apiCall("/tape/drives"),
|
||||||
|
apiCall("/tape/slots")
|
||||||
|
]).then(([library, drives, slots]) => {
|
||||||
|
// Library status
|
||||||
|
$("#library-status").html(`
|
||||||
|
<p><strong>Status:</strong> ${library.status}</p>
|
||||||
|
<p><strong>Model:</strong> ${library.model || "N/A"}</p>
|
||||||
|
<p><strong>Total Slots:</strong> ${library.total_slots}</p>
|
||||||
|
<p><strong>Active Drives:</strong> ${library.active_drives}</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drives
|
||||||
|
let drivesHtml = "";
|
||||||
|
if (drives.length === 0) {
|
||||||
|
drivesHtml = "<p>No drives detected</p>";
|
||||||
|
} else {
|
||||||
|
drives.forEach(drive => {
|
||||||
|
drivesHtml += `
|
||||||
|
<div class="drive-item">
|
||||||
|
<strong>${drive.id}</strong>: ${drive.status}
|
||||||
|
${drive.loaded_tape ? ` (Tape: ${drive.barcode || drive.loaded_tape})` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$("#drives-list").html(drivesHtml);
|
||||||
|
|
||||||
|
// Slots
|
||||||
|
let slotsHtml = "<table class='table'><thead><tr><th>Slot</th><th>Barcode</th><th>Status</th></tr></thead><tbody>";
|
||||||
|
if (slots.length === 0) {
|
||||||
|
slotsHtml += "<tr><td colspan='3'>No slots</td></tr>";
|
||||||
|
} else {
|
||||||
|
slots.forEach(slot => {
|
||||||
|
slotsHtml += `
|
||||||
|
<tr>
|
||||||
|
<td>${slot.number}</td>
|
||||||
|
<td>${slot.barcode || "N/A"}</td>
|
||||||
|
<td>${slot.status}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
slotsHtml += "</tbody></table>";
|
||||||
|
$("#slots-list").html(slotsHtml);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("Failed to load tape library:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadiSCSITargets() {
|
||||||
|
Promise.all([
|
||||||
|
apiCall("/iscsi/targets"),
|
||||||
|
apiCall("/iscsi/sessions")
|
||||||
|
]).then(([targets, sessions]) => {
|
||||||
|
// Targets
|
||||||
|
let targetsHtml = "";
|
||||||
|
if (targets.length === 0) {
|
||||||
|
targetsHtml = "<tr><td colspan='5'>No targets</td></tr>";
|
||||||
|
} else {
|
||||||
|
targets.forEach(target => {
|
||||||
|
targetsHtml += `
|
||||||
|
<tr>
|
||||||
|
<td>${target.iqn}</td>
|
||||||
|
<td>${target.portals.join(", ") || "N/A"}</td>
|
||||||
|
<td>${target.initiators.join(", ") || "N/A"}</td>
|
||||||
|
<td><span class="label label-${target.status === 'active' ? 'success' : 'default'}">${target.status}</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="applyTarget('${target.id}')">Apply</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteTarget('${target.id}')">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$("#targets-table").html(targetsHtml);
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
let sessionsHtml = "";
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
sessionsHtml = "<tr><td colspan='4'>No active sessions</td></tr>";
|
||||||
|
} else {
|
||||||
|
sessions.forEach(session => {
|
||||||
|
sessionsHtml += `
|
||||||
|
<tr>
|
||||||
|
<td>${session.target_iqn}</td>
|
||||||
|
<td>${session.initiator_iqn}</td>
|
||||||
|
<td>${session.ip}</td>
|
||||||
|
<td>${session.state}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$("#sessions-table").html(sessionsHtml);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("Failed to load iSCSI targets:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBaculaStatus() {
|
||||||
|
apiCall("/bacula/status")
|
||||||
|
.then(status => {
|
||||||
|
$("#bacula-status").html(`
|
||||||
|
<p><strong>Status:</strong> <span class="label label-${status.status === 'running' ? 'success' : 'danger'}">${status.status}</span></p>
|
||||||
|
${status.version ? `<p><strong>Version:</strong> ${status.version}</p>` : ""}
|
||||||
|
${status.pid ? `<p><strong>PID:</strong> ${status.pid}</p>` : ""}
|
||||||
|
`);
|
||||||
|
})
|
||||||
|
.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));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
250
cockpit/index.html
Normal file
250
cockpit/index.html
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>BAMS - Backup Appliance Management System</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="../base1/cockpit.css" type="text/css" rel="stylesheet">
|
||||||
|
<script src="../base1/jquery.js"></script>
|
||||||
|
<script src="../base1/cockpit.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<nav class="navbar navbar-default">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="navbar-header">
|
||||||
|
<a class="navbar-brand" href="#">BAMS</a>
|
||||||
|
</div>
|
||||||
|
<ul class="nav navbar-nav">
|
||||||
|
<li class="active"><a href="#dashboard" data-tab="dashboard">Dashboard</a></li>
|
||||||
|
<li><a href="#storage" data-tab="storage">Storage</a></li>
|
||||||
|
<li><a href="#tape" data-tab="tape">Tape Library</a></li>
|
||||||
|
<li><a href="#iscsi" data-tab="iscsi">iSCSI Targets</a></li>
|
||||||
|
<li><a href="#bacula" data-tab="bacula">Bacula</a></li>
|
||||||
|
<li><a href="#logs" data-tab="logs">Logs</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="dashboard" class="tab-content active">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Disk Storage</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="disk-stats">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Tape Library</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="tape-stats">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">iSCSI</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="iscsi-stats">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Bacula SD</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="bacula-stats">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Alerts</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="alerts">
|
||||||
|
<p>No alerts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="storage" class="tab-content" style="display: none;">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Storage Repositories</h1>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<button class="btn btn-primary" id="create-repo-btn">Create Repository</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Used</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="repositories-table">
|
||||||
|
<tr><td colspan="6">Loading...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tape" class="tab-content" style="display: none;">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Tape Library Management</h1>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
Library Status
|
||||||
|
<button class="btn btn-sm btn-default pull-right" id="inventory-btn">Run Inventory</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body" id="library-status">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Tape Drives</div>
|
||||||
|
<div class="panel-body" id="drives-list">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Slots</div>
|
||||||
|
<div class="panel-body" id="slots-list">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="iscsi" class="tab-content" style="display: none;">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>iSCSI Target Management</h1>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<button class="btn btn-primary" id="create-target-btn">Create Target</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IQN</th>
|
||||||
|
<th>Portals</th>
|
||||||
|
<th>Initiators</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="targets-table">
|
||||||
|
<tr><td colspan="5">Loading...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Active Sessions</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Target IQN</th>
|
||||||
|
<th>Initiator IQN</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sessions-table">
|
||||||
|
<tr><td colspan="4">Loading...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bacula" class="tab-content" style="display: none;">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Bacula Integration</h1>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Storage Daemon Status</div>
|
||||||
|
<div class="panel-body" id="bacula-status">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Actions</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<button class="btn btn-default" id="bacula-inventory-btn">Run Inventory</button>
|
||||||
|
<button class="btn btn-default" id="bacula-restart-btn">Restart SD</button>
|
||||||
|
<button class="btn btn-default" id="bacula-config-btn">Generate Config</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="logs" class="tab-content" style="display: none;">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Logs & Diagnostics</h1>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<select id="log-service" class="form-control" style="display: inline-block; width: 200px;">
|
||||||
|
<option value="bams">BAMS</option>
|
||||||
|
<option value="scst">SCST</option>
|
||||||
|
<option value="iscsi">iSCSI</option>
|
||||||
|
<option value="bacula">Bacula</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-default" id="refresh-logs-btn">Refresh</button>
|
||||||
|
<button class="btn btn-default" id="download-bundle-btn">Download Support Bundle</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<pre id="logs-content" style="max-height: 600px; overflow-y: auto;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="bams.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
19
cockpit/manifest.json
Normal file
19
cockpit/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"name": "BAMS",
|
||||||
|
"displayName": "Backup Appliance Management System",
|
||||||
|
"description": "Manage disk repositories, tape libraries, iSCSI targets, and Bacula integration",
|
||||||
|
"author": "BAMS Team",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"url": "https://github.com/bams/bams",
|
||||||
|
"tools": {
|
||||||
|
"bams": {
|
||||||
|
"label": "BAMS",
|
||||||
|
"path": "index.html"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"cockpit": ">= 300"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
32
configs/bams.service
Normal file
32
configs/bams.service
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=BAMS Backend Service
|
||||||
|
Documentation=https://github.com/bams/bams
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=bams
|
||||||
|
Group=bams
|
||||||
|
WorkingDirectory=/var/lib/bams
|
||||||
|
ExecStart=/usr/local/bin/bams-backend
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=bams
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/bams /etc/bams
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
Environment="BAMS_CONFIG=/etc/bams/config.yaml"
|
||||||
|
Environment="BAMS_PORT=8080"
|
||||||
|
Environment="BAMS_LOG_LEVEL=info"
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
19
configs/config.yaml.example
Normal file
19
configs/config.yaml.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# BAMS Configuration File
|
||||||
|
# Copy to /etc/bams/config.yaml and customize
|
||||||
|
|
||||||
|
port: 8080
|
||||||
|
log_level: info
|
||||||
|
data_dir: /var/lib/bams
|
||||||
|
|
||||||
|
# SCST configuration file path
|
||||||
|
scst_config: /etc/scst.conf
|
||||||
|
|
||||||
|
# Bacula Storage Daemon configuration path
|
||||||
|
bacula_config: /etc/bacula/bacula-sd.conf
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
security:
|
||||||
|
require_https: false
|
||||||
|
allowed_users: []
|
||||||
|
# Example: allowed_users: ["admin", "operator"]
|
||||||
|
|
||||||
20
configs/polkit.rules
Normal file
20
configs/polkit.rules
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Polkit rules for BAMS privileged operations
|
||||||
|
// Place in /etc/polkit-1/rules.d/50-bams.rules
|
||||||
|
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if (action.id == "com.bams.disk.create" ||
|
||||||
|
action.id == "com.bams.disk.delete" ||
|
||||||
|
action.id == "com.bams.iscsi.modify" ||
|
||||||
|
action.id == "com.bams.bacula.restart") {
|
||||||
|
if (subject.isInGroup("bams-admin")) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.id == "com.bams.tape.operate" ||
|
||||||
|
action.id == "com.bams.bacula.inventory") {
|
||||||
|
if (subject.isInGroup("bams-admin") || subject.isInGroup("bams-operator")) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user