add api framework
Some checks failed
CI / test-build (push) Failing after 59s

This commit is contained in:
2025-12-14 22:15:56 +07:00
parent f4683eeb73
commit a6da313dfc
13 changed files with 915 additions and 84 deletions

172
PlutoOS_SRS_v1.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

104
internal/httpapp/routes.go Normal file
View File

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

36
internal/models/auth.go Normal file
View File

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

28
internal/models/job.go Normal file
View File

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

View File

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

56
internal/models/zfs.go Normal file
View File

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

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{.Title}} • PlutoOS</title>
<title>{{.Title}} • atlasOS</title>
<!-- v1: Tailwind CDN (later: bundle local) -->
<script src="https://cdn.tailwindcss.com"></script>
@@ -15,9 +15,9 @@
<header class="border-b border-slate-800 bg-slate-950/80 backdrop-blur">
<div class="mx-auto max-w-6xl px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="h-9 w-9 rounded-lg bg-slate-800 flex items-center justify-center font-bold">P</div>
<div class="h-9 w-9 rounded-lg bg-slate-800 flex items-center justify-center font-bold">A</div>
<div>
<div class="font-semibold leading-tight">PlutoOS</div>
<div class="font-semibold leading-tight">atlasOS</div>
<div class="text-xs text-slate-400 leading-tight">Storage Controller v1</div>
</div>
</div>
@@ -38,7 +38,7 @@
<footer class="mx-auto max-w-6xl px-4 pb-10 text-xs text-slate-500">
<div class="border-t border-slate-800 pt-4 flex items-center justify-between">
<span>PlutoOS • {{nowRFC3339}}</span>
<span>atlasOS • {{nowRFC3339}}</span>
<span>Build: {{index .Build "version"}}</span>
</div>
</footer>

View File

@@ -2,7 +2,7 @@
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
<p class="text-slate-400">Welcome to PlutoOS Storage Controller</p>
<p class="text-slate-400">Welcome to atlasOS Storage Controller</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">