scrub operation + ZFS Pool CRUD
Some checks failed
CI / test-build (push) Failing after 2m14s

This commit is contained in:
2025-12-15 01:19:44 +07:00
parent 9779b30a65
commit abd8cef10a
9 changed files with 1124 additions and 63 deletions

View File

@@ -100,35 +100,112 @@ func (a *App) handleGetPool(w http.ResponseWriter, r *http.Request) {
func (a *App) handleDeletePool(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "pool name required"})
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
if err := a.zfs.DestroyPool(name); err != nil {
log.Printf("destroy pool error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
writeError(w, errors.ErrInternal("failed to destroy pool").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "pool destroyed", "name": name})
}
func (a *App) handleImportPool(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Options map[string]string `json:"options,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, errors.ErrBadRequest("invalid request body").WithDetails(err.Error()))
return
}
if req.Name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
if err := a.zfs.ImportPool(req.Name, req.Options); err != nil {
log.Printf("import pool error: %v", err)
writeError(w, errors.ErrInternal("failed to import pool").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "pool imported", "name": req.Name})
}
func (a *App) handleExportPool(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
var req struct {
Force bool `json:"force,omitempty"`
}
// Force is optional, decode if body exists
_ = json.NewDecoder(r.Body).Decode(&req)
if err := a.zfs.ExportPool(name, req.Force); err != nil {
log.Printf("export pool error: %v", err)
writeError(w, errors.ErrInternal("failed to export pool").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "pool exported", "name": name})
}
func (a *App) handleListAvailablePools(w http.ResponseWriter, r *http.Request) {
pools, err := a.zfs.ListAvailablePools()
if err != nil {
log.Printf("list available pools error: %v", err)
writeError(w, errors.ErrInternal("failed to list available pools").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"pools": pools,
})
}
func (a *App) handleScrubPool(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "pool name required"})
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
if err := a.zfs.ScrubPool(name); err != nil {
log.Printf("scrub pool error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
writeError(w, errors.ErrInternal("failed to start scrub").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "scrub started", "pool": name})
}
func (a *App) handleGetScrubStatus(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
status, err := a.zfs.GetScrubStatus(name)
if err != nil {
log.Printf("get scrub status error: %v", err)
writeError(w, errors.ErrInternal("failed to get scrub status").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, status)
}
// Dataset Handlers
func (a *App) handleListDatasets(w http.ResponseWriter, r *http.Request) {
pool := r.URL.Query().Get("pool")

View File

@@ -18,6 +18,7 @@ import (
"gitea.avt.data-center.id/othman.suseno/atlas/internal/services"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/snapshot"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/storage"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/tls"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/zfs"
)
@@ -50,6 +51,7 @@ type App struct {
startTime time.Time
backupService *backup.Service
maintenanceService *maintenance.Service
tlsConfig *tls.Config
}
func New(cfg Config) (*App, error) {
@@ -112,27 +114,38 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("init backup service: %w", err)
}
// Initialize maintenance service
maintenanceService := maintenance.NewService()
// Initialize TLS configuration
tlsConfig := tls.LoadConfig()
if err := tlsConfig.Validate(); err != nil {
return nil, fmt.Errorf("TLS configuration: %w", err)
}
a := &App{
cfg: cfg,
tmpl: tmpl,
mux: http.NewServeMux(),
zfs: zfsService,
snapshotPolicy: policyStore,
jobManager: jobMgr,
scheduler: scheduler,
authService: authService,
userStore: userStore,
auditStore: auditStore,
smbStore: smbStore,
nfsStore: nfsStore,
iscsiStore: iscsiStore,
database: database,
smbService: smbService,
nfsService: nfsService,
iscsiService: iscsiService,
metricsCollector: metricsCollector,
startTime: startTime,
backupService: backupService,
cfg: cfg,
tmpl: tmpl,
mux: http.NewServeMux(),
zfs: zfsService,
snapshotPolicy: policyStore,
jobManager: jobMgr,
scheduler: scheduler,
authService: authService,
userStore: userStore,
auditStore: auditStore,
smbStore: smbStore,
nfsStore: nfsStore,
iscsiStore: iscsiStore,
database: database,
smbService: smbService,
nfsService: nfsService,
iscsiService: iscsiService,
metricsCollector: metricsCollector,
startTime: startTime,
backupService: backupService,
maintenanceService: maintenanceService,
tlsConfig: tlsConfig,
}
// Start snapshot scheduler (runs every 15 minutes)
@@ -144,33 +157,36 @@ func New(cfg Config) (*App, error) {
func (a *App) Router() http.Handler {
// Middleware chain order (outer to inner):
// 1. CORS (handles preflight)
// 2. Compression (gzip)
// 3. Security headers
// 4. Request size limit (10MB)
// 5. Content-Type validation
// 6. Rate limiting
// 7. Caching (for GET requests)
// 8. Error recovery
// 9. Request ID
// 10. Logging
// 11. Audit
// 12. Authentication
// 13. Maintenance mode (blocks operations during maintenance)
// 14. Routes
return a.corsMiddleware(
a.compressionMiddleware(
a.securityHeadersMiddleware(
a.requestSizeMiddleware(10 * 1024 * 1024)(
a.validateContentTypeMiddleware(
a.rateLimitMiddleware(
a.cacheMiddleware(
a.errorMiddleware(
requestID(
logging(
a.auditMiddleware(
a.maintenanceMiddleware(
a.authMiddleware(a.mux),
// 1. HTTPS enforcement (redirect HTTP to HTTPS)
// 2. CORS (handles preflight)
// 3. Compression (gzip)
// 4. Security headers
// 5. Request size limit (10MB)
// 6. Content-Type validation
// 7. Rate limiting
// 8. Caching (for GET requests)
// 9. Error recovery
// 10. Request ID
// 11. Logging
// 12. Audit
// 13. Authentication
// 14. Maintenance mode (blocks operations during maintenance)
// 15. Routes
return a.httpsEnforcementMiddleware(
a.corsMiddleware(
a.compressionMiddleware(
a.securityHeadersMiddleware(
a.requestSizeMiddleware(10 * 1024 * 1024)(
a.validateContentTypeMiddleware(
a.rateLimitMiddleware(
a.cacheMiddleware(
a.errorMiddleware(
requestID(
logging(
a.auditMiddleware(
a.maintenanceMiddleware(
a.authMiddleware(a.mux),
),
),
),
),

View File

@@ -0,0 +1,76 @@
package httpapp
import (
"net/http"
"strings"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
)
// httpsEnforcementMiddleware enforces HTTPS connections
func (a *App) httpsEnforcementMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip HTTPS enforcement for health checks and localhost
if a.isPublicEndpoint(r.URL.Path) || isLocalhost(r) {
next.ServeHTTP(w, r)
return
}
// If TLS is enabled, enforce HTTPS
if a.tlsConfig != nil && a.tlsConfig.Enabled {
// Check if request is already over HTTPS
if r.TLS != nil {
next.ServeHTTP(w, r)
return
}
// Check X-Forwarded-Proto header (for reverse proxies)
if r.Header.Get("X-Forwarded-Proto") == "https" {
next.ServeHTTP(w, r)
return
}
// Redirect HTTP to HTTPS
httpsURL := "https://" + r.Host + r.URL.RequestURI()
http.Redirect(w, r, httpsURL, http.StatusMovedPermanently)
return
}
next.ServeHTTP(w, r)
})
}
// isLocalhost checks if the request is from localhost
func isLocalhost(r *http.Request) bool {
host := r.Host
if strings.Contains(host, ":") {
host = strings.Split(host, ":")[0]
}
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
// requireHTTPSMiddleware requires HTTPS for all requests (strict mode)
func (a *App) requireHTTPSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip for health checks
if a.isPublicEndpoint(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
// If TLS is enabled, require HTTPS
if a.tlsConfig != nil && a.tlsConfig.Enabled {
// Check if request is over HTTPS
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
writeError(w, errors.NewAPIError(
errors.ErrCodeForbidden,
"HTTPS required",
http.StatusForbidden,
).WithDetails("this endpoint requires HTTPS"))
return
}
}
next.ServeHTTP(w, r)
})
}

View File

@@ -60,12 +60,30 @@ func pathParam(r *http.Request, prefix string) string {
// handlePoolOps routes pool operations by method
func (a *App) handlePoolOps(w http.ResponseWriter, r *http.Request) {
// Extract pool name from path like /api/v1/pools/tank
name := pathParam(r, "/api/v1/pools/")
if name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
if strings.HasSuffix(r.URL.Path, "/scrub") {
if r.Method == http.MethodPost {
a.handleScrubPool(w, r)
return
} else if r.Method == http.MethodGet {
a.handleGetScrubStatus(w, r)
} else {
writeError(w, errors.NewAPIError(errors.ErrCodeBadRequest, "method not allowed", http.StatusMethodNotAllowed))
}
return
}
if strings.HasSuffix(r.URL.Path, "/export") {
if r.Method == http.MethodPost {
a.handleExportPool(w, r)
} else {
writeError(w, errors.NewAPIError(errors.ErrCodeBadRequest, "method not allowed", http.StatusMethodNotAllowed))
}
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

View File

@@ -80,6 +80,15 @@ func (a *App) routes() {
func(w http.ResponseWriter, r *http.Request) { a.handleCreatePool(w, r) },
nil, nil, nil,
))
a.mux.HandleFunc("/api/v1/pools/available", methodHandler(
func(w http.ResponseWriter, r *http.Request) { a.handleListAvailablePools(w, r) },
nil, nil, nil, nil,
))
a.mux.HandleFunc("/api/v1/pools/import", methodHandler(
nil,
func(w http.ResponseWriter, r *http.Request) { a.handleImportPool(w, r) },
nil, nil, nil,
))
a.mux.HandleFunc("/api/v1/pools/", a.handlePoolOps)
a.mux.HandleFunc("/api/v1/datasets", methodHandler(