add sources
This commit is contained in:
676
backend/internal/bacula/handler.go
Normal file
676
backend/internal/bacula/handler.go
Normal file
@@ -0,0 +1,676 @@
|
||||
package bacula
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/atlasos/calypso/internal/iam"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lib/pq"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
requestTimeout = 5 * time.Second
|
||||
maxHistoryEntries = 10
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *database.DB
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
func NewHandler(db *database.DB, log *logger.Logger) *Handler {
|
||||
return &Handler{db: db, logger: log.WithFields(zap.String("component", "bacula-handler"))}
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Hostname string `json:"hostname" binding:"required"`
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
Status string `json:"status"`
|
||||
BackupTypes []string `json:"backup_types" binding:"required"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
type UpdateCapabilitiesRequest struct {
|
||||
BackupTypes []string `json:"backup_types" binding:"required"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type PingRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type ClientResponse struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
Status string `json:"status"`
|
||||
BackupTypes []string `json:"backup_types"`
|
||||
PendingBackupTypes []string `json:"pending_backup_types,omitempty"`
|
||||
PendingRequestedBy string `json:"pending_requested_by,omitempty"`
|
||||
PendingRequestedAt *time.Time `json:"pending_requested_at,omitempty"`
|
||||
PendingNotes string `json:"pending_notes,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
RegisteredBy string `json:"registered_by"`
|
||||
LastSeen *time.Time `json:"last_seen,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CapabilityHistory []CapabilityHistoryEntry `json:"capability_history,omitempty"`
|
||||
}
|
||||
|
||||
type CapabilityHistoryEntry struct {
|
||||
BackupTypes []string `json:"backup_types"`
|
||||
Source string `json:"source"`
|
||||
RequestedBy string `json:"requested_by,omitempty"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type PendingUpdateResponse struct {
|
||||
BackupTypes []string `json:"backup_types"`
|
||||
RequestedBy string `json:"requested_by,omitempty"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.BackupTypes) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "backup_types is required"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := currentUser(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
backupPayload, err := json.Marshal(req.BackupTypes)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to marshal backup types", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode backup types"})
|
||||
return
|
||||
}
|
||||
|
||||
var metadataPayload []byte
|
||||
if len(req.Metadata) > 0 {
|
||||
metadataPayload, err = json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to marshal metadata", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode metadata"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
status := req.Status
|
||||
if status == "" {
|
||||
status = "online"
|
||||
}
|
||||
|
||||
tx, err := h.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to begin database transaction", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register client"})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var row clientRow
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO bacula_clients (
|
||||
hostname, ip_address, agent_version, status, backup_types, metadata,
|
||||
registered_by_user_id, last_seen, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
ON CONFLICT (hostname) DO UPDATE SET
|
||||
ip_address = EXCLUDED.ip_address,
|
||||
agent_version = EXCLUDED.agent_version,
|
||||
status = EXCLUDED.status,
|
||||
backup_types = EXCLUDED.backup_types,
|
||||
metadata = COALESCE(EXCLUDED.metadata, bacula_clients.metadata),
|
||||
registered_by_user_id = EXCLUDED.registered_by_user_id,
|
||||
last_seen = EXCLUDED.last_seen,
|
||||
updated_at = NOW()
|
||||
RETURNING id, hostname, ip_address, agent_version, status, backup_types, metadata,
|
||||
pending_backup_types, pending_requested_by, pending_requested_at, pending_notes,
|
||||
registered_by_user_id, last_seen, created_at, updated_at
|
||||
`, req.Hostname, req.IPAddress, req.AgentVersion, status, backupPayload, metadataPayload, user.ID).Scan(
|
||||
&row.ID, &row.Hostname, &row.IPAddress, &row.AgentVersion, &row.Status, &row.BackupJSON,
|
||||
&row.MetadataJSON, &row.PendingBackupJSON, &row.PendingRequestedBy, &row.PendingRequestedAt,
|
||||
&row.PendingNotes, &row.RegisteredBy, &row.LastSeen, &row.CreatedAt, &row.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to ensure bacula client", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register client"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := buildClientResponse(&row)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to marshal client response", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build response"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := insertCapabilityHistory(ctx, tx, row.ID, req.BackupTypes, "agent", user.ID, "agent registration"); err != nil {
|
||||
h.logger.Error("failed to record capability history", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record capability history"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(resp.PendingBackupTypes) > 0 && stringSlicesEqual(resp.PendingBackupTypes, resp.BackupTypes) {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE bacula_clients
|
||||
SET pending_backup_types = NULL,
|
||||
pending_requested_by = NULL,
|
||||
pending_requested_at = NULL,
|
||||
pending_notes = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, resp.ID); err != nil {
|
||||
h.logger.Error("failed to clear pending capability update", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update client"})
|
||||
return
|
||||
}
|
||||
resp.PendingBackupTypes = nil
|
||||
resp.PendingRequestedBy = ""
|
||||
resp.PendingRequestedAt = nil
|
||||
resp.PendingNotes = ""
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
h.logger.Error("failed to commit client registration", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register client"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateCapabilities(c *gin.Context) {
|
||||
var req UpdateCapabilitiesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.BackupTypes) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "backup_types is required"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := currentUser(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
clientID := c.Param("id")
|
||||
if clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "client id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
tx, err := h.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to begin transaction", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "unable to update capabilities"})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM bacula_clients WHERE id = $1)`, clientID).Scan(&exists); err != nil {
|
||||
h.logger.Error("failed to verify client", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "unable to update client"})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "client not found"})
|
||||
return
|
||||
}
|
||||
|
||||
backupPayload, err := json.Marshal(req.BackupTypes)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to marshal backup types", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode backup types"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE bacula_clients
|
||||
SET pending_backup_types = $1,
|
||||
pending_requested_by = $2,
|
||||
pending_requested_at = NOW(),
|
||||
pending_notes = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4
|
||||
`, backupPayload, user.ID, req.Notes, clientID); err != nil {
|
||||
h.logger.Error("failed to mark pending update", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update client"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := insertCapabilityHistory(ctx, tx, clientID, req.BackupTypes, "ui", user.ID, req.Notes); err != nil {
|
||||
h.logger.Error("failed to insert capability history", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record capability change"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
h.logger.Error("failed to commit capability update", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update client"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, PendingUpdateResponse{
|
||||
BackupTypes: req.BackupTypes,
|
||||
RequestedBy: user.ID,
|
||||
RequestedAt: time.Now(),
|
||||
Notes: req.Notes,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetPendingUpdate(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
if clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "client id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
var pendingJSON []byte
|
||||
var requestedBy sql.NullString
|
||||
var requestedAt sql.NullTime
|
||||
var notes sql.NullString
|
||||
|
||||
err := h.db.QueryRowContext(ctx, `
|
||||
SELECT pending_backup_types, pending_requested_by, pending_requested_at, pending_notes
|
||||
FROM bacula_clients
|
||||
WHERE id = $1
|
||||
`, clientID).Scan(&pendingJSON, &requestedBy, &requestedAt, ¬es)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "client not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to fetch pending update", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read pending update"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(pendingJSON) == 0 {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
var backupTypes []string
|
||||
if err := json.Unmarshal(pendingJSON, &backupTypes); err != nil {
|
||||
h.logger.Error("failed to unmarshal pending backup types", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read pending update"})
|
||||
return
|
||||
}
|
||||
|
||||
response := PendingUpdateResponse{
|
||||
BackupTypes: backupTypes,
|
||||
Notes: notes.String,
|
||||
}
|
||||
if requestedBy.Valid {
|
||||
response.RequestedBy = requestedBy.String
|
||||
}
|
||||
if requestedAt.Valid {
|
||||
response.RequestedAt = requestedAt.Time
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) Ping(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
if clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "client id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req PingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// swallow body if absent
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
query := `
|
||||
UPDATE bacula_clients
|
||||
SET last_seen = NOW(),
|
||||
status = COALESCE(NULLIF($2, ''), status),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
var id string
|
||||
err := h.db.QueryRowContext(ctx, query, clientID, req.Status).Scan(&id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "client not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to update heartbeat", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update client"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ListClients(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, `
|
||||
SELECT id, hostname, ip_address, agent_version, status, backup_types, metadata,
|
||||
pending_backup_types, pending_requested_by, pending_requested_at, pending_notes,
|
||||
registered_by_user_id, last_seen, created_at, updated_at
|
||||
FROM bacula_clients
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to query clients", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch clients"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var clients []*ClientResponse
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
row := clientRow{}
|
||||
if err := rows.Scan(&row.ID, &row.Hostname, &row.IPAddress, &row.AgentVersion, &row.Status,
|
||||
&row.BackupJSON, &row.MetadataJSON, &row.PendingBackupJSON, &row.PendingRequestedBy,
|
||||
&row.PendingRequestedAt, &row.PendingNotes, &row.RegisteredBy, &row.LastSeen,
|
||||
&row.CreatedAt, &row.UpdatedAt); err != nil {
|
||||
h.logger.Error("failed to scan client row", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch clients"})
|
||||
return
|
||||
}
|
||||
resp, err := buildClientResponse(&row)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to build client response", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch clients"})
|
||||
return
|
||||
}
|
||||
clients = append(clients, resp)
|
||||
ids = append(ids, resp.ID)
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
if err := h.attachHistory(ctx, ids, clients); err != nil {
|
||||
h.logger.Error("failed to attach history", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch client history"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clients)
|
||||
}
|
||||
|
||||
func (h *Handler) GetClient(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
if clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "client id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
var row clientRow
|
||||
err := h.db.QueryRowContext(ctx, `
|
||||
SELECT id, hostname, ip_address, agent_version, status, backup_types, metadata,
|
||||
pending_backup_types, pending_requested_by, pending_requested_at, pending_notes,
|
||||
registered_by_user_id, last_seen, created_at, updated_at
|
||||
FROM bacula_clients
|
||||
WHERE id = $1
|
||||
`, clientID).Scan(&row.ID, &row.Hostname, &row.IPAddress, &row.AgentVersion, &row.Status,
|
||||
&row.BackupJSON, &row.MetadataJSON, &row.PendingBackupJSON, &row.PendingRequestedBy,
|
||||
&row.PendingRequestedAt, &row.PendingNotes, &row.RegisteredBy, &row.LastSeen,
|
||||
&row.CreatedAt, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "client not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to read client", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch client"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := buildClientResponse(&row)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to build client response", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch client"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.attachHistory(ctx, []string{resp.ID}, []*ClientResponse{resp}); err != nil {
|
||||
h.logger.Error("failed to attach history", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch client history"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) attachHistory(ctx context.Context, ids []string, clients []*ClientResponse) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, `
|
||||
SELECT client_id, backup_types, source, requested_by_user_id, requested_at, notes
|
||||
FROM bacula_client_capability_history
|
||||
WHERE client_id = ANY($1)
|
||||
ORDER BY requested_at DESC
|
||||
`, pq.Array(ids))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
clientMap := make(map[string]*ClientResponse)
|
||||
for _, client := range clients {
|
||||
clientMap[client.ID] = client
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var clientID string
|
||||
var backupJSON []byte
|
||||
var source string
|
||||
var requestedBy sql.NullString
|
||||
var requestedAt time.Time
|
||||
var notes sql.NullString
|
||||
|
||||
if err := rows.Scan(&clientID, &backupJSON, &source, &requestedBy, &requestedAt, ¬es); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, ok := clientMap[clientID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(resp.CapabilityHistory) >= maxHistoryEntries {
|
||||
continue
|
||||
}
|
||||
|
||||
var backupTypes []string
|
||||
if err := json.Unmarshal(backupJSON, &backupTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := CapabilityHistoryEntry{
|
||||
BackupTypes: backupTypes,
|
||||
Source: source,
|
||||
RequestedAt: requestedAt,
|
||||
}
|
||||
if requestedBy.Valid {
|
||||
entry.RequestedBy = requestedBy.String
|
||||
}
|
||||
if notes.Valid {
|
||||
entry.Notes = notes.String
|
||||
}
|
||||
|
||||
resp.CapabilityHistory = append(resp.CapabilityHistory, entry)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type clientRow struct {
|
||||
ID string
|
||||
Hostname string
|
||||
IPAddress string
|
||||
AgentVersion string
|
||||
Status string
|
||||
BackupJSON []byte
|
||||
MetadataJSON []byte
|
||||
PendingBackupJSON []byte
|
||||
PendingRequestedBy sql.NullString
|
||||
PendingRequestedAt sql.NullTime
|
||||
PendingNotes sql.NullString
|
||||
RegisteredBy string
|
||||
LastSeen sql.NullTime
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func buildClientResponse(row *clientRow) (*ClientResponse, error) {
|
||||
backupTypes, err := decodeStringSlice(row.BackupJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pendingTypes, err := decodeStringSlice(row.PendingBackupJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadata, err := decodeMetadata(row.MetadataJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &ClientResponse{
|
||||
ID: row.ID,
|
||||
Hostname: row.Hostname,
|
||||
IPAddress: row.IPAddress,
|
||||
AgentVersion: row.AgentVersion,
|
||||
Status: row.Status,
|
||||
BackupTypes: backupTypes,
|
||||
Metadata: metadata,
|
||||
RegisteredBy: row.RegisteredBy,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
|
||||
if len(pendingTypes) > 0 {
|
||||
resp.PendingBackupTypes = pendingTypes
|
||||
if row.PendingRequestedBy.Valid {
|
||||
resp.PendingRequestedBy = row.PendingRequestedBy.String
|
||||
}
|
||||
if row.PendingRequestedAt.Valid {
|
||||
resp.PendingRequestedAt = &row.PendingRequestedAt.Time
|
||||
}
|
||||
if row.PendingNotes.Valid {
|
||||
resp.PendingNotes = row.PendingNotes.String
|
||||
}
|
||||
}
|
||||
|
||||
if row.LastSeen.Valid {
|
||||
resp.LastSeen = &row.LastSeen.Time
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func currentUser(c *gin.Context) (*iam.User, error) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
authUser, ok := user.(*iam.User)
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return authUser, nil
|
||||
}
|
||||
|
||||
func decodeStringSlice(data []byte) ([]string, error) {
|
||||
if len(data) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
var dst []string
|
||||
if err := json.Unmarshal(data, &dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func decodeMetadata(data []byte) (map[string]interface{}, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var metadata map[string]interface{}
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func insertCapabilityHistory(ctx context.Context, tx *sql.Tx, clientID string, backupTypes []string, source, requestedBy, notes string) error {
|
||||
payload, err := json.Marshal(backupTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO bacula_client_capability_history (
|
||||
client_id, backup_types, source, requested_by_user_id, notes
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
`, clientID, payload, source, requestedBy, notes)
|
||||
return err
|
||||
}
|
||||
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
-- AtlasOS - Calypso
|
||||
-- Migration 015: Add Bacula clients and capability history tables
|
||||
--
|
||||
-- Adds tables for tracking registered Bacula agents, their backup capabilities,
|
||||
-- and a history log for UI- or agent-triggered capability changes. Pending
|
||||
-- updates are stored on the client row until the agent pulls them.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bacula_clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
hostname TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
agent_version TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'online',
|
||||
backup_types JSONB NOT NULL,
|
||||
pending_backup_types JSONB,
|
||||
pending_requested_by UUID,
|
||||
pending_requested_at TIMESTAMPTZ,
|
||||
pending_notes TEXT,
|
||||
metadata JSONB,
|
||||
registered_by_user_id UUID NOT NULL,
|
||||
last_seen TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq_bacula_clients_hostname UNIQUE (hostname)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bacula_clients_registered_by ON bacula_clients (registered_by_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bacula_clients_status ON bacula_clients (status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bacula_client_capability_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id UUID NOT NULL REFERENCES bacula_clients (id) ON DELETE CASCADE,
|
||||
backup_types JSONB NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
requested_by_user_id UUID,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bacula_client_capability_history_client ON bacula_client_capability_history (client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bacula_client_capability_history_requested_at ON bacula_client_capability_history (requested_at);
|
||||
|
||||
COMMENT ON TABLE bacula_clients IS 'Tracks Bacula clients registered with Calypso, including pending capability pushes.';
|
||||
COMMENT ON TABLE bacula_client_capability_history IS 'Audit history of backup capability changes per client.';
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/atlasos/calypso/internal/audit"
|
||||
"github.com/atlasos/calypso/internal/auth"
|
||||
"github.com/atlasos/calypso/internal/backup"
|
||||
"github.com/atlasos/calypso/internal/bacula"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/cache"
|
||||
"github.com/atlasos/calypso/internal/common/config"
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
@@ -457,6 +459,18 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
backupGroup.POST("/console/execute", requirePermission("backup", "write"), backupHandler.ExecuteBconsoleCommand)
|
||||
}
|
||||
|
||||
baculaHandler := bacula.NewHandler(db, log)
|
||||
baculaGroup := protected.Group("/bacula/clients")
|
||||
baculaGroup.Use(requireRole("bacula-admin"))
|
||||
{
|
||||
baculaGroup.POST("/register", baculaHandler.Register)
|
||||
baculaGroup.POST("/:id/capabilities", baculaHandler.UpdateCapabilities)
|
||||
baculaGroup.GET("/:id/pending-update", baculaHandler.GetPendingUpdate)
|
||||
baculaGroup.POST("/:id/ping", baculaHandler.Ping)
|
||||
baculaGroup.GET("", baculaHandler.ListClients)
|
||||
baculaGroup.GET("/:id", baculaHandler.GetClient)
|
||||
}
|
||||
|
||||
// Monitoring
|
||||
monitoringHandler := monitoring.NewHandler(db, log, alertService, metricsService, eventHub)
|
||||
monitoringGroup := protected.Group("/monitoring")
|
||||
|
||||
Reference in New Issue
Block a user