add maintenance mode
Some checks failed
CI / test-build (push) Failing after 2m12s

This commit is contained in:
2025-12-15 01:11:51 +07:00
parent 507961716e
commit 9779b30a65
7 changed files with 689 additions and 22 deletions

View File

@@ -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),
),
),
),
),

View File

@@ -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" {

View 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",
})
}

View 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)
})
}

View File

@@ -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)

View 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"`
}