This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/backup"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/db"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/job"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/maintenance"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/metrics"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/services"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/snapshot"
|
||||
@@ -28,26 +29,27 @@ type Config struct {
|
||||
}
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
tmpl *template.Template
|
||||
mux *http.ServeMux
|
||||
zfs *zfs.Service
|
||||
snapshotPolicy *snapshot.PolicyStore
|
||||
jobManager *job.Manager
|
||||
scheduler *snapshot.Scheduler
|
||||
authService *auth.Service
|
||||
userStore *auth.UserStore
|
||||
auditStore *audit.Store
|
||||
smbStore *storage.SMBStore
|
||||
nfsStore *storage.NFSStore
|
||||
iscsiStore *storage.ISCSIStore
|
||||
database *db.DB // Optional database connection
|
||||
smbService *services.SMBService
|
||||
nfsService *services.NFSService
|
||||
iscsiService *services.ISCSIService
|
||||
metricsCollector *metrics.Collector
|
||||
startTime time.Time
|
||||
backupService *backup.Service
|
||||
cfg Config
|
||||
tmpl *template.Template
|
||||
mux *http.ServeMux
|
||||
zfs *zfs.Service
|
||||
snapshotPolicy *snapshot.PolicyStore
|
||||
jobManager *job.Manager
|
||||
scheduler *snapshot.Scheduler
|
||||
authService *auth.Service
|
||||
userStore *auth.UserStore
|
||||
auditStore *audit.Store
|
||||
smbStore *storage.SMBStore
|
||||
nfsStore *storage.NFSStore
|
||||
iscsiStore *storage.ISCSIStore
|
||||
database *db.DB // Optional database connection
|
||||
smbService *services.SMBService
|
||||
nfsService *services.NFSService
|
||||
iscsiService *services.ISCSIService
|
||||
metricsCollector *metrics.Collector
|
||||
startTime time.Time
|
||||
backupService *backup.Service
|
||||
maintenanceService *maintenance.Service
|
||||
}
|
||||
|
||||
func New(cfg Config) (*App, error) {
|
||||
@@ -154,7 +156,8 @@ func (a *App) Router() http.Handler {
|
||||
// 10. Logging
|
||||
// 11. Audit
|
||||
// 12. Authentication
|
||||
// 13. Routes
|
||||
// 13. Maintenance mode (blocks operations during maintenance)
|
||||
// 14. Routes
|
||||
return a.corsMiddleware(
|
||||
a.compressionMiddleware(
|
||||
a.securityHeadersMiddleware(
|
||||
@@ -166,7 +169,9 @@ func (a *App) Router() http.Handler {
|
||||
requestID(
|
||||
logging(
|
||||
a.auditMiddleware(
|
||||
a.authMiddleware(a.mux),
|
||||
a.maintenanceMiddleware(
|
||||
a.authMiddleware(a.mux),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -198,6 +198,16 @@ func (a *App) handleHealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
health.Checks["iscsi"] = "healthy"
|
||||
}
|
||||
|
||||
// Check maintenance mode
|
||||
if a.maintenanceService != nil && a.maintenanceService.IsEnabled() {
|
||||
health.Checks["maintenance"] = "enabled"
|
||||
if health.Status == "healthy" {
|
||||
health.Status = "maintenance"
|
||||
}
|
||||
} else {
|
||||
health.Checks["maintenance"] = "disabled"
|
||||
}
|
||||
|
||||
// Set HTTP status based on health
|
||||
statusCode := http.StatusOK
|
||||
if health.Status == "unhealthy" {
|
||||
|
||||
162
internal/httpapp/maintenance_handlers.go
Normal file
162
internal/httpapp/maintenance_handlers.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/backup"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// handleGetMaintenanceStatus returns the current maintenance mode status
|
||||
func (a *App) handleGetMaintenanceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if a.maintenanceService == nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"maintenance service not available",
|
||||
http.StatusInternalServerError,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
status := a.maintenanceService.GetStatus()
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// handleEnableMaintenance enables maintenance mode
|
||||
func (a *App) handleEnableMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
if a.maintenanceService == nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"maintenance service not available",
|
||||
http.StatusInternalServerError,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Require administrator role
|
||||
user, ok := getUserFromContext(r)
|
||||
if !ok {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeUnauthorized,
|
||||
"authentication required",
|
||||
http.StatusUnauthorized,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != models.RoleAdministrator {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeForbidden,
|
||||
"administrator role required",
|
||||
http.StatusForbidden,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
AllowedUsers []string `json:"allowed_users,omitempty"`
|
||||
CreateBackup bool `json:"create_backup,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Description is optional, so we'll continue even if body is empty
|
||||
_ = err
|
||||
}
|
||||
|
||||
// Create backup before entering maintenance if requested
|
||||
var backupID string
|
||||
if req.CreateBackup && a.backupService != nil {
|
||||
// Collect all configuration data
|
||||
backupData := backup.BackupData{
|
||||
Users: a.userStore.List(),
|
||||
SMBShares: a.smbStore.List(),
|
||||
NFSExports: a.nfsStore.List(),
|
||||
ISCSITargets: a.iscsiStore.List(),
|
||||
Policies: a.snapshotPolicy.List(),
|
||||
Config: map[string]interface{}{
|
||||
"database_path": a.cfg.DatabasePath,
|
||||
},
|
||||
}
|
||||
|
||||
id, err := a.backupService.CreateBackup(backupData, "Automatic backup before maintenance mode")
|
||||
if err != nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"failed to create backup",
|
||||
http.StatusInternalServerError,
|
||||
).WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
backupID = id
|
||||
}
|
||||
|
||||
// Enable maintenance mode
|
||||
if err := a.maintenanceService.Enable(user.ID, req.Reason, req.AllowedUsers); err != nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"failed to enable maintenance mode",
|
||||
http.StatusInternalServerError,
|
||||
).WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Set backup ID if created
|
||||
if backupID != "" {
|
||||
a.maintenanceService.SetLastBackupID(backupID)
|
||||
}
|
||||
|
||||
status := a.maintenanceService.GetStatus()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "maintenance mode enabled",
|
||||
"status": status,
|
||||
"backup_id": backupID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDisableMaintenance disables maintenance mode
|
||||
func (a *App) handleDisableMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
if a.maintenanceService == nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"maintenance service not available",
|
||||
http.StatusInternalServerError,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Require administrator role
|
||||
user, ok := getUserFromContext(r)
|
||||
if !ok {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeUnauthorized,
|
||||
"authentication required",
|
||||
http.StatusUnauthorized,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != models.RoleAdministrator {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeForbidden,
|
||||
"administrator role required",
|
||||
http.StatusForbidden,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.maintenanceService.Disable(user.ID); err != nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"failed to disable maintenance mode",
|
||||
http.StatusInternalServerError,
|
||||
).WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "maintenance mode disabled",
|
||||
})
|
||||
}
|
||||
39
internal/httpapp/maintenance_middleware.go
Normal file
39
internal/httpapp/maintenance_middleware.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
|
||||
)
|
||||
|
||||
// maintenanceMiddleware blocks operations during maintenance mode
|
||||
func (a *App) maintenanceMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip maintenance check for read-only operations and public endpoints
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if a.isPublicEndpoint(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if maintenance mode is enabled
|
||||
if a.maintenanceService != nil && a.maintenanceService.IsEnabled() {
|
||||
// Check if user is allowed during maintenance
|
||||
user, ok := getUserFromContext(r)
|
||||
if !ok || !a.maintenanceService.IsUserAllowed(user.ID) {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeServiceUnavailable,
|
||||
"system is in maintenance mode",
|
||||
http.StatusServiceUnavailable,
|
||||
).WithDetails("the system is currently in maintenance mode and user operations are disabled"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -34,6 +34,24 @@ func (a *App) routes() {
|
||||
nil, nil, nil,
|
||||
))
|
||||
|
||||
// Maintenance Mode (requires authentication, admin-only for enable/disable)
|
||||
a.mux.HandleFunc("/api/v1/maintenance", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetMaintenanceStatus(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleEnableMaintenance)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/maintenance/disable", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleDisableMaintenance)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
|
||||
// API Documentation
|
||||
a.mux.HandleFunc("/api/docs", a.handleAPIDocs)
|
||||
a.mux.HandleFunc("/api/openapi.yaml", a.handleOpenAPISpec)
|
||||
|
||||
130
internal/maintenance/service.go
Normal file
130
internal/maintenance/service.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mode represents the maintenance mode state
|
||||
type Mode struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
enabledAt time.Time
|
||||
enabledBy string
|
||||
reason string
|
||||
allowedUsers []string // Users allowed to operate during maintenance
|
||||
lastBackupID string // ID of backup created before entering maintenance
|
||||
}
|
||||
|
||||
// Service manages maintenance mode
|
||||
type Service struct {
|
||||
mode *Mode
|
||||
}
|
||||
|
||||
// NewService creates a new maintenance service
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
mode: &Mode{
|
||||
allowedUsers: []string{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether maintenance mode is currently enabled
|
||||
func (s *Service) IsEnabled() bool {
|
||||
s.mode.mu.RLock()
|
||||
defer s.mode.mu.RUnlock()
|
||||
return s.mode.enabled
|
||||
}
|
||||
|
||||
// Enable enables maintenance mode
|
||||
func (s *Service) Enable(enabledBy, reason string, allowedUsers []string) error {
|
||||
s.mode.mu.Lock()
|
||||
defer s.mode.mu.Unlock()
|
||||
|
||||
if s.mode.enabled {
|
||||
return fmt.Errorf("maintenance mode is already enabled")
|
||||
}
|
||||
|
||||
s.mode.enabled = true
|
||||
s.mode.enabledAt = time.Now()
|
||||
s.mode.enabledBy = enabledBy
|
||||
s.mode.reason = reason
|
||||
if allowedUsers != nil {
|
||||
s.mode.allowedUsers = allowedUsers
|
||||
} else {
|
||||
s.mode.allowedUsers = []string{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disable disables maintenance mode
|
||||
func (s *Service) Disable(disabledBy string) error {
|
||||
s.mode.mu.Lock()
|
||||
defer s.mode.mu.Unlock()
|
||||
|
||||
if !s.mode.enabled {
|
||||
return fmt.Errorf("maintenance mode is not enabled")
|
||||
}
|
||||
|
||||
s.mode.enabled = false
|
||||
s.mode.enabledBy = ""
|
||||
s.mode.reason = ""
|
||||
s.mode.allowedUsers = []string{}
|
||||
s.mode.lastBackupID = ""
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus returns the current maintenance mode status
|
||||
func (s *Service) GetStatus() Status {
|
||||
s.mode.mu.RLock()
|
||||
defer s.mode.mu.RUnlock()
|
||||
|
||||
return Status{
|
||||
Enabled: s.mode.enabled,
|
||||
EnabledAt: s.mode.enabledAt,
|
||||
EnabledBy: s.mode.enabledBy,
|
||||
Reason: s.mode.reason,
|
||||
AllowedUsers: s.mode.allowedUsers,
|
||||
LastBackupID: s.mode.lastBackupID,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLastBackupID sets the backup ID created before entering maintenance
|
||||
func (s *Service) SetLastBackupID(backupID string) {
|
||||
s.mode.mu.Lock()
|
||||
defer s.mode.mu.Unlock()
|
||||
s.mode.lastBackupID = backupID
|
||||
}
|
||||
|
||||
// IsUserAllowed checks if a user is allowed to operate during maintenance
|
||||
func (s *Service) IsUserAllowed(userID string) bool {
|
||||
s.mode.mu.RLock()
|
||||
defer s.mode.mu.RUnlock()
|
||||
|
||||
if !s.mode.enabled {
|
||||
return true // No restrictions when not in maintenance
|
||||
}
|
||||
|
||||
// Check if user is in allowed list
|
||||
for _, allowed := range s.mode.allowedUsers {
|
||||
if allowed == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Status represents the maintenance mode status
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EnabledAt time.Time `json:"enabled_at,omitempty"`
|
||||
EnabledBy string `json:"enabled_by,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedUsers []string `json:"allowed_users,omitempty"`
|
||||
LastBackupID string `json:"last_backup_id,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user