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