diff --git a/PlutoOS_SRS_v1.md b/PlutoOS_SRS_v1.md new file mode 100644 index 0000000..54e147a --- /dev/null +++ b/PlutoOS_SRS_v1.md @@ -0,0 +1,172 @@ +SOFTWARE REQUIREMENTS SPECIFICATION (SRS) +PlutoOS – Storage Controller Operating System (v1) + +================================================== + +1. INTRODUCTION +-------------------------------------------------- +1.1 Purpose +This document defines the functional and non-functional requirements for PlutoOS v1, +a storage controller operating system built on Linux with ZFS as the core storage engine. +It serves as the authoritative reference for development scope, validation, and acceptance. + +1.2 Scope +PlutoOS v1 provides: +- ZFS pool, dataset, and ZVOL management +- Storage services: SMB, NFS, iSCSI (ZVOL-backed) +- Automated snapshot management +- Role-Based Access Control (RBAC) and audit logging +- Web-based GUI and local TUI +- Monitoring and Prometheus-compatible metrics + +The following are explicitly out of scope for v1: +- High Availability (HA) or clustering +- Multi-node replication +- Object storage (S3) +- Active Directory / LDAP integration + +1.3 Definitions +Dataset : ZFS filesystem +ZVOL : ZFS block device +LUN : Logical Unit Number exposed via iSCSI +Job : Asynchronous long-running operation +Desired State : Configuration stored in DB and applied atomically to system + +================================================== + +2. SYSTEM OVERVIEW +-------------------------------------------------- +PlutoOS consists of: +- Base OS : Minimal Linux (Ubuntu/Debian) +- Data Plane : ZFS and storage services +- Control Plane: Go backend with HTMX-based UI +- Interfaces : Web GUI, TUI, Metrics endpoint + +================================================== + +3. USER CLASSES +-------------------------------------------------- +Administrator : Full system and storage control +Operator : Storage and service operations +Viewer : Read-only access + +================================================== + +4. FUNCTIONAL REQUIREMENTS +-------------------------------------------------- + +4.1 Authentication & Authorization +- System SHALL require authentication for all management access +- System SHALL enforce RBAC with predefined roles +- Access SHALL be denied by default + +4.2 ZFS Management +- System SHALL list available disks (read-only) +- System SHALL create, import, and export ZFS pools +- System SHALL report pool health status +- System SHALL create and manage datasets +- System SHALL create ZVOLs for block storage +- System SHALL support scrub operations with progress monitoring + +4.3 Snapshot Management +- System SHALL support manual snapshot creation +- System SHALL support automated snapshot policies +- System SHALL allow per-dataset snapshot enable/disable +- System SHALL prune snapshots based on retention policy + +4.4 SMB Service +- System SHALL create SMB shares mapped to datasets +- System SHALL manage share permissions +- System SHALL apply configuration atomically +- System SHALL reload service safely + +4.5 NFS Service +- System SHALL create NFS exports per dataset +- System SHALL support RW/RO and client restrictions +- System SHALL regenerate exports from desired state +- System SHALL reload NFS exports safely + +4.6 iSCSI Block Storage +- System SHALL provision ZVOL-backed LUNs +- System SHALL create iSCSI targets with IQN +- System SHALL map LUNs to targets +- System SHALL configure initiator ACLs +- System SHALL expose connection instructions + +4.7 Job Management +- System SHALL execute long-running operations as jobs +- System SHALL track job status and progress +- System SHALL persist job history +- Failed jobs SHALL not leave system inconsistent + +4.8 Audit Logging +- System SHALL log all mutating operations +- Audit log SHALL record actor, action, resource, and timestamp +- Audit log SHALL be immutable from the UI + +4.9 Web GUI +- System SHALL provide a web-based management interface +- GUI SHALL support partial updates +- GUI SHALL display system health and alerts +- Destructive actions SHALL require confirmation + +4.10 TUI +- System SHALL provide a local console interface +- TUI SHALL support initial system setup +- TUI SHALL allow monitoring and maintenance operations +- TUI SHALL function without web UI availability + +4.11 Monitoring & Metrics +- System SHALL expose /metrics in Prometheus format +- System SHALL expose pool health and capacity metrics +- System SHALL expose job failure metrics +- GUI SHALL present a metrics summary + +4.12 Update & Maintenance +- System SHALL support safe update mechanisms +- Configuration SHALL be backed up prior to updates +- Maintenance mode SHALL disable user operations + +================================================== + +5. NON-FUNCTIONAL REQUIREMENTS +-------------------------------------------------- + +5.1 Reliability +- Storage operations SHALL be transactional where possible +- System SHALL recover gracefully from partial failures + +5.2 Performance +- Management UI read operations SHOULD respond within 500ms +- Background jobs SHALL not block UI responsiveness + +5.3 Security +- HTTPS SHALL be enforced for the web UI +- Secrets SHALL NOT be logged in plaintext +- Least-privilege access SHALL be enforced + +5.4 Maintainability +- Configuration SHALL be declarative +- System SHALL provide diagnostic information for support + +================================================== + +6. CONSTRAINTS & ASSUMPTIONS +-------------------------------------------------- +- Single-node controller +- Linux kernel with ZFS support +- Local storage only + +================================================== + +7. ACCEPTANCE CRITERIA (v1) +-------------------------------------------------- +PlutoOS v1 is accepted when: +- ZFS pool, dataset, share, and LUN lifecycle works end-to-end +- Snapshot policies are active and observable +- RBAC and audit logging are enforced +- GUI, TUI, and metrics endpoints are functional +- No manual configuration file edits are required + +================================================== +END OF DOCUMENT diff --git a/cmd/pluto-api/main.go b/cmd/pluto-api/main.go deleted file mode 100644 index 6b7e68a..0000000 --- a/cmd/pluto-api/main.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "context" - "log" - "net/http" - "os" - "os/signal" - "time" - - "gitea.avt.data-center.id/othman.suseno/atlas/internal/httpapp" -) - -func main() { - addr := env("PLUTO_HTTP_ADDR", ":8080") - - app, err := httpapp.New(httpapp.Config{ - Addr: addr, - TemplatesDir: "web/templates", - StaticDir: "web/static", - }) - if err != nil { - log.Fatalf("init app: %v", err) - } - - srv := &http.Server{ - Addr: addr, - Handler: app.Router(), - ReadHeaderTimeout: 5 * time.Second, - } - - // Start server - go func() { - log.Printf("[pluto-api] listening on %s", addr) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("listen: %v", err) - } - }() - - // Graceful shutdown - stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt) - <-stop - log.Printf("[pluto-api] shutdown requested") - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if err := srv.Shutdown(ctx); err != nil { - log.Printf("[pluto-api] shutdown error: %v", err) - } else { - log.Printf("[pluto-api] shutdown complete") - } -} - -func env(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} diff --git a/internal/httpapp/api_handlers.go b/internal/httpapp/api_handlers.go new file mode 100644 index 0000000..f5d295d --- /dev/null +++ b/internal/httpapp/api_handlers.go @@ -0,0 +1,280 @@ +package httpapp + +import ( + "encoding/json" + "net/http" + + "gitea.avt.data-center.id/othman.suseno/atlas/internal/models" +) + +// pathParam is now in router_helpers.go + +// ZFS Pool Handlers +func (a *App) handleListPools(w http.ResponseWriter, r *http.Request) { + // TODO: Implement ZFS pool listing + pools := []models.Pool{} // Stub + writeJSON(w, http.StatusOK, pools) +} + +func (a *App) handleCreatePool(w http.ResponseWriter, r *http.Request) { + // TODO: Implement pool creation + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) +} + +func (a *App) handleGetPool(w http.ResponseWriter, r *http.Request) { + name := pathParam(r, "/api/v1/pools/") + // TODO: Implement pool retrieval + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name}) +} + +func (a *App) handleDeletePool(w http.ResponseWriter, r *http.Request) { + name := pathParam(r, "/api/v1/pools/") + // TODO: Implement pool deletion + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name}) +} + +func (a *App) handleScrubPool(w http.ResponseWriter, r *http.Request) { + name := pathParam(r, "/api/v1/pools/") + // TODO: Implement pool scrub + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name}) +} + +// Dataset Handlers +func (a *App) handleListDatasets(w http.ResponseWriter, r *http.Request) { + datasets := []models.Dataset{} // Stub + writeJSON(w, http.StatusOK, datasets) +} + +func (a *App) handleCreateDataset(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Pool string `json:"pool"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + // TODO: Implement dataset creation + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) +} + +func (a *App) handleGetDataset(w http.ResponseWriter, r *http.Request) { + name := pathParam(r, "/api/v1/datasets/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name}) +} + +func (a *App) handleUpdateDataset(w http.ResponseWriter, r *http.Request) { + name := pathParam(r, "/api/v1/datasets/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name}) +} + +func (a *App) handleDeleteDataset(w http.ResponseWriter, r *http.Request) { + name := pathParam(r, "/api/v1/datasets/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name}) +} + +// ZVOL Handlers +func (a *App) handleListZVOLs(w http.ResponseWriter, r *http.Request) { + zvols := []models.ZVOL{} // Stub + writeJSON(w, http.StatusOK, zvols) +} + +func (a *App) handleCreateZVOL(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) +} + +func (a *App) handleGetZVOL(w http.ResponseWriter, r *http.Request) { + name := pathParam(r, "/api/v1/zvols/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name}) +} + +func (a *App) handleDeleteZVOL(w http.ResponseWriter, r *http.Request) { + name := pathParam(r, "/api/v1/zvols/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name}) +} + +// Snapshot Handlers +func (a *App) handleListSnapshots(w http.ResponseWriter, r *http.Request) { + snapshots := []models.Snapshot{} // Stub + writeJSON(w, http.StatusOK, snapshots) +} + +func (a *App) handleCreateSnapshot(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) +} + +func (a *App) handleGetSnapshot(w http.ResponseWriter, r *http.Request) { + name := pathParam(r, "/api/v1/snapshots/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name}) +} + +func (a *App) handleDeleteSnapshot(w http.ResponseWriter, r *http.Request) { + name := pathParam(r, "/api/v1/snapshots/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name}) +} + +// Snapshot Policy Handlers +func (a *App) handleListSnapshotPolicies(w http.ResponseWriter, r *http.Request) { + policies := []models.SnapshotPolicy{} // Stub + writeJSON(w, http.StatusOK, policies) +} + +func (a *App) handleCreateSnapshotPolicy(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) +} + +func (a *App) handleGetSnapshotPolicy(w http.ResponseWriter, r *http.Request) { + dataset := pathParam(r, "/api/v1/snapshot-policies/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "dataset": dataset}) +} + +func (a *App) handleUpdateSnapshotPolicy(w http.ResponseWriter, r *http.Request) { + dataset := pathParam(r, "/api/v1/snapshot-policies/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "dataset": dataset}) +} + +func (a *App) handleDeleteSnapshotPolicy(w http.ResponseWriter, r *http.Request) { + dataset := pathParam(r, "/api/v1/snapshot-policies/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "dataset": dataset}) +} + +// SMB Share Handlers +func (a *App) handleListSMBShares(w http.ResponseWriter, r *http.Request) { + shares := []models.SMBShare{} // Stub + writeJSON(w, http.StatusOK, shares) +} + +func (a *App) handleCreateSMBShare(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) +} + +func (a *App) handleGetSMBShare(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/shares/smb/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleUpdateSMBShare(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/shares/smb/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleDeleteSMBShare(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/shares/smb/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +// NFS Export Handlers +func (a *App) handleListNFSExports(w http.ResponseWriter, r *http.Request) { + exports := []models.NFSExport{} // Stub + writeJSON(w, http.StatusOK, exports) +} + +func (a *App) handleCreateNFSExport(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) +} + +func (a *App) handleGetNFSExport(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/exports/nfs/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleUpdateNFSExport(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/exports/nfs/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleDeleteNFSExport(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/exports/nfs/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +// iSCSI Handlers +func (a *App) handleListISCSITargets(w http.ResponseWriter, r *http.Request) { + targets := []models.ISCSITarget{} // Stub + writeJSON(w, http.StatusOK, targets) +} + +func (a *App) handleCreateISCSITarget(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) +} + +func (a *App) handleGetISCSITarget(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/iscsi/targets/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleUpdateISCSITarget(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/iscsi/targets/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleDeleteISCSITarget(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/iscsi/targets/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/iscsi/targets/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleRemoveLUN(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/iscsi/targets/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +// Job Handlers +func (a *App) handleListJobs(w http.ResponseWriter, r *http.Request) { + jobs := []models.Job{} // Stub + writeJSON(w, http.StatusOK, jobs) +} + +func (a *App) handleGetJob(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/jobs/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleCancelJob(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/jobs/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +// Auth Handlers (stubs) +func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) +} + +func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"}) +} + +// User Handlers +func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { + users := []models.User{} // Stub + writeJSON(w, http.StatusOK, users) +} + +func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"}) +} + +func (a *App) handleGetUser(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/users/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/users/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id := pathParam(r, "/api/v1/users/") + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id}) +} + +// Audit Log Handlers +func (a *App) handleListAuditLogs(w http.ResponseWriter, r *http.Request) { + logs := []models.AuditLog{} // Stub + writeJSON(w, http.StatusOK, logs) +} diff --git a/internal/httpapp/app.go b/internal/httpapp/app.go index 0c40643..4f6f8bb 100644 --- a/internal/httpapp/app.go +++ b/internal/httpapp/app.go @@ -48,18 +48,7 @@ func (a *App) Router() http.Handler { return requestID(logging(a.mux)) } -func (a *App) routes() { - // Static - fs := http.FileServer(http.Dir(a.cfg.StaticDir)) - a.mux.Handle("/static/", http.StripPrefix("/static/", fs)) - - // Core pages - a.mux.HandleFunc("/", a.handleDashboard) - - // Health & metrics - a.mux.HandleFunc("/healthz", a.handleHealthz) - a.mux.HandleFunc("/metrics", a.handleMetrics) -} +// routes() is now in routes.go func parseTemplates(dir string) (*template.Template, error) { pattern := filepath.Join(dir, "*.html") diff --git a/internal/httpapp/handlers.go b/internal/httpapp/handlers.go index af2a71f..ff5f510 100644 --- a/internal/httpapp/handlers.go +++ b/internal/httpapp/handlers.go @@ -30,12 +30,12 @@ func (a *App) handleMetrics(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte( - `# HELP pluto_build_info Build info -# TYPE pluto_build_info gauge -pluto_build_info{version="v0.1.0-dev"} 1 -# HELP pluto_up Whether the pluto-api process is up -# TYPE pluto_up gauge -pluto_up 1 + `# HELP atlas_build_info Build info +# TYPE atlas_build_info gauge +atlas_build_info{version="v0.1.0-dev"} 1 +# HELP atlas_up Whether the atlas-api process is up +# TYPE atlas_up gauge +atlas_up 1 `, )) } diff --git a/internal/httpapp/router_helpers.go b/internal/httpapp/router_helpers.go new file mode 100644 index 0000000..e25c33a --- /dev/null +++ b/internal/httpapp/router_helpers.go @@ -0,0 +1,185 @@ +package httpapp + +import ( + "net/http" + "strings" +) + +// methodHandler routes requests based on HTTP method +func methodHandler(get, post, put, delete, patch http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if get != nil { + get(w, r) + } else { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + case http.MethodPost: + if post != nil { + post(w, r) + } else { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + case http.MethodPut: + if put != nil { + put(w, r) + } else { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + case http.MethodDelete: + if delete != nil { + delete(w, r) + } else { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + case http.MethodPatch: + if patch != nil { + patch(w, r) + } else { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + } +} + +// pathParam extracts the last segment from a path +func pathParam(r *http.Request, prefix string) string { + path := strings.TrimPrefix(r.URL.Path, prefix) + path = strings.Trim(path, "/") + parts := strings.Split(path, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return "" +} + +// handlePoolOps routes pool operations by method +func (a *App) handlePoolOps(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/scrub") { + if r.Method == http.MethodPost { + a.handleScrubPool(w, r) + return + } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetPool(w, r) }, + nil, + nil, + func(w http.ResponseWriter, r *http.Request) { a.handleDeletePool(w, r) }, + nil, + )(w, r) +} + +// handleDatasetOps routes dataset operations by method +func (a *App) handleDatasetOps(w http.ResponseWriter, r *http.Request) { + methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetDataset(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateDataset(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleUpdateDataset(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleDeleteDataset(w, r) }, + nil, + )(w, r) +} + +// handleZVOLOps routes ZVOL operations by method +func (a *App) handleZVOLOps(w http.ResponseWriter, r *http.Request) { + methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetZVOL(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateZVOL(w, r) }, + nil, + func(w http.ResponseWriter, r *http.Request) { a.handleDeleteZVOL(w, r) }, + nil, + )(w, r) +} + +// handleSnapshotOps routes snapshot operations by method +func (a *App) handleSnapshotOps(w http.ResponseWriter, r *http.Request) { + methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetSnapshot(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateSnapshot(w, r) }, + nil, + func(w http.ResponseWriter, r *http.Request) { a.handleDeleteSnapshot(w, r) }, + nil, + )(w, r) +} + +// handleSnapshotPolicyOps routes snapshot policy operations by method +func (a *App) handleSnapshotPolicyOps(w http.ResponseWriter, r *http.Request) { + methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetSnapshotPolicy(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateSnapshotPolicy(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleUpdateSnapshotPolicy(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleDeleteSnapshotPolicy(w, r) }, + nil, + )(w, r) +} + +// handleSMBShareOps routes SMB share operations by method +func (a *App) handleSMBShareOps(w http.ResponseWriter, r *http.Request) { + methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetSMBShare(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateSMBShare(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleUpdateSMBShare(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleDeleteSMBShare(w, r) }, + nil, + )(w, r) +} + +// handleNFSExportOps routes NFS export operations by method +func (a *App) handleNFSExportOps(w http.ResponseWriter, r *http.Request) { + methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetNFSExport(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateNFSExport(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleUpdateNFSExport(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleDeleteNFSExport(w, r) }, + nil, + )(w, r) +} + +// handleISCSITargetOps routes iSCSI target operations by method +func (a *App) handleISCSITargetOps(w http.ResponseWriter, r *http.Request) { + methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetISCSITarget(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateISCSITarget(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleUpdateISCSITarget(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleDeleteISCSITarget(w, r) }, + nil, + )(w, r) +} + +// handleJobOps routes job operations by method +func (a *App) handleJobOps(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/cancel") { + if r.Method == http.MethodPost { + a.handleCancelJob(w, r) + return + } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetJob(w, r) }, + nil, + nil, + nil, + nil, + )(w, r) +} + +// handleUserOps routes user operations by method +func (a *App) handleUserOps(w http.ResponseWriter, r *http.Request) { + methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetUser(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateUser(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleUpdateUser(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleDeleteUser(w, r) }, + nil, + )(w, r) +} diff --git a/internal/httpapp/routes.go b/internal/httpapp/routes.go new file mode 100644 index 0000000..79adba4 --- /dev/null +++ b/internal/httpapp/routes.go @@ -0,0 +1,104 @@ +package httpapp + +import "net/http" + +func (a *App) routes() { + // Static files + fs := http.FileServer(http.Dir(a.cfg.StaticDir)) + a.mux.Handle("/static/", http.StripPrefix("/static/", fs)) + + // Web UI + a.mux.HandleFunc("/", a.handleDashboard) + + // Health & metrics + a.mux.HandleFunc("/healthz", a.handleHealthz) + a.mux.HandleFunc("/metrics", a.handleMetrics) + + // API v1 routes - ZFS Management + a.mux.HandleFunc("/api/v1/pools", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListPools(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreatePool(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/pools/", a.handlePoolOps) + + a.mux.HandleFunc("/api/v1/datasets", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListDatasets(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateDataset(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/datasets/", a.handleDatasetOps) + + a.mux.HandleFunc("/api/v1/zvols", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListZVOLs(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateZVOL(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/zvols/", a.handleZVOLOps) + + // Snapshot Management + a.mux.HandleFunc("/api/v1/snapshots", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListSnapshots(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateSnapshot(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/snapshots/", a.handleSnapshotOps) + a.mux.HandleFunc("/api/v1/snapshot-policies", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListSnapshotPolicies(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateSnapshotPolicy(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/snapshot-policies/", a.handleSnapshotPolicyOps) + + // Storage Services - SMB + a.mux.HandleFunc("/api/v1/shares/smb", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListSMBShares(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateSMBShare(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/shares/smb/", a.handleSMBShareOps) + + // Storage Services - NFS + a.mux.HandleFunc("/api/v1/exports/nfs", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListNFSExports(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateNFSExport(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/exports/nfs/", a.handleNFSExportOps) + + // Storage Services - iSCSI + a.mux.HandleFunc("/api/v1/iscsi/targets", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListISCSITargets(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateISCSITarget(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/iscsi/targets/", a.handleISCSITargetOps) + + // Job Management + a.mux.HandleFunc("/api/v1/jobs", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListJobs(w, r) }, + nil, nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/jobs/", a.handleJobOps) + + // Authentication & Authorization + a.mux.HandleFunc("/api/v1/auth/login", methodHandler( + nil, + func(w http.ResponseWriter, r *http.Request) { a.handleLogin(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/auth/logout", methodHandler( + nil, + func(w http.ResponseWriter, r *http.Request) { a.handleLogout(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/users", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListUsers(w, r) }, + func(w http.ResponseWriter, r *http.Request) { a.handleCreateUser(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/users/", a.handleUserOps) + + // Audit Logs + a.mux.HandleFunc("/api/v1/audit", a.handleListAuditLogs) +} diff --git a/internal/models/auth.go b/internal/models/auth.go new file mode 100644 index 0000000..ac0b25e --- /dev/null +++ b/internal/models/auth.go @@ -0,0 +1,36 @@ +package models + +import "time" + +// Role represents a user role +type Role string + +const ( + RoleAdministrator Role = "administrator" + RoleOperator Role = "operator" + RoleViewer Role = "viewer" +) + +// User represents a system user +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + Role Role `json:"role"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AuditLog represents an audit log entry +type AuditLog struct { + ID string `json:"id"` + Actor string `json:"actor"` // user ID or system + Action string `json:"action"` // "pool.create", "share.delete", etc. + Resource string `json:"resource"` // resource type and ID + Result string `json:"result"` // "success", "failure" + Message string `json:"message,omitempty"` + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/internal/models/job.go b/internal/models/job.go new file mode 100644 index 0000000..ccc02ff --- /dev/null +++ b/internal/models/job.go @@ -0,0 +1,28 @@ +package models + +import "time" + +// JobStatus represents the state of a job +type JobStatus string + +const ( + JobStatusPending JobStatus = "pending" + JobStatusRunning JobStatus = "running" + JobStatusCompleted JobStatus = "completed" + JobStatusFailed JobStatus = "failed" + JobStatusCancelled JobStatus = "cancelled" +) + +// Job represents a long-running asynchronous operation +type Job struct { + ID string `json:"id"` + Type string `json:"type"` // "pool_create", "snapshot_create", etc. + Status JobStatus `json:"status"` + Progress int `json:"progress"` // 0-100 + Message string `json:"message"` + Error string `json:"error,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} diff --git a/internal/models/storage.go b/internal/models/storage.go new file mode 100644 index 0000000..7b36254 --- /dev/null +++ b/internal/models/storage.go @@ -0,0 +1,42 @@ +package models + +// SMBShare represents an SMB/CIFS share +type SMBShare struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` // dataset mountpoint + Dataset string `json:"dataset"` // ZFS dataset name + Description string `json:"description"` + ReadOnly bool `json:"read_only"` + GuestOK bool `json:"guest_ok"` + ValidUsers []string `json:"valid_users"` + Enabled bool `json:"enabled"` +} + +// NFSExport represents an NFS export +type NFSExport struct { + ID string `json:"id"` + Path string `json:"path"` // dataset mountpoint + Dataset string `json:"dataset"` // ZFS dataset name + Clients []string `json:"clients"` // allowed clients (CIDR or hostnames) + ReadOnly bool `json:"read_only"` + RootSquash bool `json:"root_squash"` + Enabled bool `json:"enabled"` +} + +// ISCSITarget represents an iSCSI target +type ISCSITarget struct { + ID string `json:"id"` + IQN string `json:"iqn"` // iSCSI Qualified Name + LUNs []LUN `json:"luns"` + Initiators []string `json:"initiators"` // ACL list + Enabled bool `json:"enabled"` +} + +// LUN represents a Logical Unit Number backed by a ZVOL +type LUN struct { + ID int `json:"id"` // LUN number + ZVOL string `json:"zvol"` // ZVOL name + Size uint64 `json:"size"` // bytes + Backend string `json:"backend"` // "zvol" +} diff --git a/internal/models/zfs.go b/internal/models/zfs.go new file mode 100644 index 0000000..ea955f4 --- /dev/null +++ b/internal/models/zfs.go @@ -0,0 +1,56 @@ +package models + +import "time" + +// Pool represents a ZFS pool +type Pool struct { + Name string `json:"name"` + Status string `json:"status"` // ONLINE, DEGRADED, FAULTED, etc. + Size uint64 `json:"size"` // bytes + Allocated uint64 `json:"allocated"` + Free uint64 `json:"free"` + Health string `json:"health"` + CreatedAt time.Time `json:"created_at"` +} + +// Dataset represents a ZFS filesystem +type Dataset struct { + Name string `json:"name"` + Pool string `json:"pool"` + Type string `json:"type"` // filesystem, volume + Size uint64 `json:"size"` + Used uint64 `json:"used"` + Available uint64 `json:"available"` + Mountpoint string `json:"mountpoint"` + CreatedAt time.Time `json:"created_at"` +} + +// ZVOL represents a ZFS block device +type ZVOL struct { + Name string `json:"name"` + Pool string `json:"pool"` + Size uint64 `json:"size"` // bytes + Used uint64 `json:"used"` + CreatedAt time.Time `json:"created_at"` +} + +// Snapshot represents a ZFS snapshot +type Snapshot struct { + Name string `json:"name"` + Dataset string `json:"dataset"` + Size uint64 `json:"size"` + CreatedAt time.Time `json:"created_at"` +} + +// SnapshotPolicy defines automated snapshot rules +type SnapshotPolicy struct { + Dataset string `json:"dataset"` + Frequent int `json:"frequent"` // keep N frequent snapshots + Hourly int `json:"hourly"` // keep N hourly snapshots + Daily int `json:"daily"` // keep N daily snapshots + Weekly int `json:"weekly"` // keep N weekly snapshots + Monthly int `json:"monthly"` // keep N monthly snapshots + Yearly int `json:"yearly"` // keep N yearly snapshots + Autosnap bool `json:"autosnap"` // enable/disable + Autoprune bool `json:"autoprune"` // enable/disable +} diff --git a/web/templates/base.html b/web/templates/base.html index 72b0a03..0bb6ece 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -4,7 +4,7 @@ - {{.Title}} • PlutoOS + {{.Title}} • atlasOS @@ -15,9 +15,9 @@
-
P
+
A
-
PlutoOS
+
atlasOS
Storage Controller v1
@@ -38,7 +38,7 @@
- PlutoOS • {{nowRFC3339}} + atlasOS • {{nowRFC3339}} Build: {{index .Build "version"}}
diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 8309669..282bba9 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -2,7 +2,7 @@

Dashboard

-

Welcome to PlutoOS Storage Controller

+

Welcome to atlasOS Storage Controller