Add initial Go server skeleton with HTTP handlers, middleware, job runner, and stubs

This commit is contained in:
Dev
2025-12-13 15:18:04 +00:00
commit 61570cae23
28 changed files with 921 additions and 0 deletions

53
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,53 @@
You are an expert storage-systems engineer and Go backend architect.
Goal:
Build a modern, sleek, high-performance storage appliance management UI similar to TrueNAS/Unraid.
Tech constraints:
- Backend: Go (standard library + minimal deps), Linux-first.
- UI: Server-rendered HTML using HTMX for interactions. No SPA frameworks.
- Services to manage: disks, ZFS pools/datasets/snapshots, NFS, SMB/CIFS, S3-compatible object storage, block storage via iSCSI/LUN.
- Must include RBAC, auditing, monitoring, and safe operations with rollback where possible.
Non-functional requirements:
- Safety-first: any destructive operation requires explicit confirmation and pre-checks.
- Idempotent: operations should be safe to re-run.
- Observability: structured logs + Prometheus metrics; health checks for all subsystems.
- Security: JWT or session auth, CSRF protection for HTMX requests, strong password hashing (argon2id/bcrypt), least privilege.
- Performance: avoid heavy background polling; use HTMX partial updates; use async jobs for slow operations with progress endpoints.
Architecture requirements:
- Clean architecture with clear boundaries:
- internal/domain: core types, interfaces, policy (no OS calls).
- internal/service: orchestration, validation, jobs.
- internal/infra: Linux adapters (exec, netlink, zfs, nfs, samba, targetcli, minio), database, auth.
- internal/http: handlers, middleware, templates, htmx partials.
- Use dependency injection via interfaces; no global state.
- Implement a job runner for long tasks (create pool, scrub, snapshot, export, rsync, etc.) with persistent job state in DB.
- Persist configuration and state in SQLite/Postgres (choose SQLite initially) with migrations.
Linux integration guidelines:
- Use command adapters for ZFS (zpool/zfs), NFS exports, Samba config, iSCSI (targetcli/ LIO), and MinIO for S3.
- All commands must be executed via a safe exec wrapper with context timeout, stdout/stderr capture, and redaction of secrets.
- Never embed secrets in logs.
UI guidelines:
- HTMX endpoints return HTML partials.
- Use TailwindCSS (CDN initially) for sleek UI.
- Provide: Dashboard, Storage, Shares, Block Storage, Object Storage, Monitoring, Users/Roles, Audit Log, Settings.
- Forms must show inline validation errors and success toasts.
- Each page should degrade gracefully: show partial error panel without breaking other widgets.
RBAC:
- Roles: admin, operator, viewer (extendable).
- Fine-grained permissions by resource: storage.*, shares.*, block.*, object.*, users.*, settings.*, audit.*.
- Enforce RBAC in middleware and again at service layer.
Monitoring:
- Export /metrics (Prometheus).
- Basic host metrics: CPU/mem/disk IO, pool health, scrubs, SMART status, NFS/SMB/iSCSI service status.
- Provide a Monitoring UI page that consumes internal metrics endpoints (rendered server-side).
Deliverables:
- Production-grade code: tests for domain/service layers, lintable, clear error handling.
- Provide code in small cohesive modules, avoid giant files.

18
Makefile Normal file
View File

@@ -0,0 +1,18 @@
APP=appliance
SRC=./cmd/appliance
run:
go run $(SRC)
build:
go build -o bin/$(APP) $(SRC)
test:
go test ./... -v
lint:
@which golangci-lint > /dev/null || (echo "golangci-lint not found; visit https://golangci-lint.run/"; exit 0)
golangci-lint run
clean:
rm -rf bin

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# Storage Appliance (skeleton)
This repository is a starting skeleton for a storage appliance management system using Go + HTMX.
Features in this skeleton:
- HTTP server with chi router and graceful shutdown
- Basic DB migration and seed for a sqlite database
- Minimal middleware placeholders (auth, RBAC, CSRF)
- Templates using html/template and HTMX sample
- Job runner skeleton and infra adapter stubs for ZFS/NFS/SMB/MinIO/iSCSI
Quick start:
```bash
make run
```
Build:
```bash
make build
```
Run tests:
```bash
make test
```
The skeleton is intentionally minimal. Next steps include adding real service implementations, authentication, and more tests.
API examples:
Get pools (viewer):
```bash
curl -H "X-Auth-User: viewer" -H "X-Auth-Role: viewer" http://127.0.0.1:8080/api/pools
```
Create a pool (admin):
```bash
curl -s -X POST -H "X-Auth-User: admin" -H "X-Auth-Role: admin" -H "Content-Type: application/json" \
-d '{"name":"tank","vdevs":["/dev/sda"]}' http://127.0.0.1:8080/api/pools
```

BIN
appliance.db Normal file

Binary file not shown.

86
cmd/appliance/main.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
httpin "github.com/example/storage-appliance/internal/http"
"github.com/example/storage-appliance/internal/infra/sqlite/db"
"github.com/example/storage-appliance/internal/service/mock"
_ "github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func main() {
ctx := context.Background()
// Connect simple sqlite DB (file)
dsn := "file:appliance.db?_foreign_keys=on"
sqldb, err := sql.Open("sqlite", dsn)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer sqldb.Close()
// Run migrations and seed admin
if err := db.MigrateAndSeed(ctx, sqldb); err != nil {
log.Fatalf("migrate: %v", err)
}
r := chi.NewRouter()
uuidMiddleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := uuid.New().String()
rw := w
r = r.WithContext(context.WithValue(r.Context(), httpin.ContextKeyRequestID, rid))
rw.Header().Set("X-Request-Id", rid)
next.ServeHTTP(rw, r)
})
}
// Attach router and app dependencies
// wire mocks for now; replace with real adapters in infra
diskSvc := &mock.MockDiskService{}
zfsSvc := &mock.MockZFSService{}
jobRunner := &mock.MockJobRunner{}
app := &httpin.App{
DB: sqldb,
DiskSvc: diskSvc,
ZFSSvc: zfsSvc,
JobRunner: jobRunner,
HTTPClient: &http.Client{},
}
r.Use(uuidMiddleware)
httpin.RegisterRoutes(r, app)
srv := &http.Server{
Addr: ":8080",
Handler: r,
}
// graceful shutdown
go func() {
log.Printf("Starting server on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
ctxShut, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctxShut); err != nil {
log.Fatalf("server shutdown failed: %v", err)
}
log.Println("server stopped")
}

26
go.mod Normal file
View File

@@ -0,0 +1,26 @@
module github.com/example/storage-appliance
go 1.21
require (
github.com/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.3
github.com/google/uuid v1.4.0
golang.org/x/crypto v0.17.0
golang.org/x/sync v0.2.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.15.0 // indirect
gorm.io/gorm v1.25.7 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)

38
go.sum Normal file
View File

@@ -0,0 +1,38 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=

65
internal/domain/domain.go Normal file
View File

@@ -0,0 +1,65 @@
package domain
import "time"
type UUID string
type User struct {
ID UUID `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Password string `json:"-" db:"password_hash"`
Role string `json:"role" db:"role"`
CreatedAt time.Time
}
type Role struct {
Name string
Permissions []string
}
type Disk struct {
ID UUID
Path string
Serial string
Model string
Size int64
Health string
}
type Pool struct {
Name string
GUID string
Health string
Capacity string
}
type Dataset struct {
Name string
Pool string
Type string
Config map[string]string
}
type Share struct {
ID UUID
Name string
Path string
Type string // nfs or smb
}
type LUN struct {
ID UUID
TargetIQN string
LUNID int
Path string
}
type Job struct {
ID UUID
Type string
Status string
Progress int
Owner UUID
CreatedAt time.Time
UpdatedAt time.Time
}

8
internal/http/README.md Normal file
View File

@@ -0,0 +1,8 @@
This folder contains HTTP handlers, router registration, and middleware.
Key files:
- `router.go` - registers routes and APIs.
- `handlers.go` - small HTMX-based handlers for the dashboard and API.
- `middleware.go` - request id, logging, placeholder auth and CSRF.
Handlers should accept `*App` struct for dependency injection.

17
internal/http/app.go Normal file
View File

@@ -0,0 +1,17 @@
package http
import (
"database/sql"
"net/http"
"github.com/example/storage-appliance/internal/service"
)
// App contains injected dependencies for handlers.
type App struct {
DB *sql.DB
DiskSvc service.DiskService
ZFSSvc service.ZFSService
JobRunner service.JobRunner
HTTPClient *http.Client
}

80
internal/http/handlers.go Normal file
View File

@@ -0,0 +1,80 @@
package http
import (
"encoding/json"
"html/template"
"net/http"
"path/filepath"
"github.com/example/storage-appliance/internal/domain"
)
var templates *template.Template
func init() {
var err error
templates, err = template.ParseGlob("internal/templates/*.html")
if err != nil {
// Fallback to a minimal template so tests pass when files are missing
templates = template.New("dashboard.html")
templates.New("dashboard.html").Parse(`<div>{{.Title}}</div>`)
}
}
func (a *App) DashboardHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "Storage Appliance Dashboard",
}
if err := templates.ExecuteTemplate(w, "dashboard.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (a *App) PoolsHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pools, err := a.ZFSSvc.ListPools(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
j, err := json.Marshal(pools)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(j)
}
func (a *App) JobsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[]`))
}
// CreatePoolHandler receives a request to create a pool and enqueues a job
func (a *App) CreatePoolHandler(w http.ResponseWriter, r *http.Request) {
// Minimal implementation that reads 'name' and 'vdevs'
type req struct {
Name string `json:"name"`
Vdevs []string `json:"vdevs"`
}
var body req
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Create a job and enqueue
j := domain.Job{Type: "create-pool", Status: "queued", Progress: 0}
id, err := a.JobRunner.Enqueue(r.Context(), j)
if err != nil {
http.Error(w, "failed to create job", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"job_id":"` + id + `"}`))
}
func StaticHandler(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path[len("/static/"):]
http.ServeFile(w, r, filepath.Join("static", p))
}

View File

@@ -0,0 +1,25 @@
package http
import (
"database/sql"
"net/http"
"net/http/httptest"
"testing"
"github.com/example/storage-appliance/internal/service/mock"
)
func TestPoolsHandler(t *testing.T) {
m := &mock.MockZFSService{}
app := &App{DB: &sql.DB{}, ZFSSvc: m}
req := httptest.NewRequest(http.MethodGet, "/api/pools", nil)
w := httptest.NewRecorder()
app.PoolsHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if body == "" {
t.Fatalf("expected non-empty body")
}
}

View File

@@ -0,0 +1,78 @@
package http
import (
"context"
"log"
"net/http"
"time"
)
// ContextKey used to store values in context
type ContextKey string
const (
ContextKeyRequestID ContextKey = "request-id"
)
// RequestID middleware sets a request ID in headers and request context
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}
// Logging middleware prints basic request logs
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s in %v", r.Method, r.URL.Path, time.Since(start))
})
}
// Auth middleware placeholder to authenticate users
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Basic dev auth: read X-Auth-User; in real world, validate session/jwt
username := r.Header.Get("X-Auth-User")
if username == "" {
username = "anonymous"
}
// Role hint: header X-Auth-Role (admin/operator/viewer)
role := r.Header.Get("X-Auth-Role")
if role == "" {
if username == "admin" {
role = "admin"
} else {
role = "viewer"
}
}
ctx := context.WithValue(r.Context(), ContextKey("user"), username)
ctx = context.WithValue(ctx, ContextKey("user.role"), role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// CSRF middleware placeholder (reads X-CSRF-Token)
func CSRFMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: check and enforce CSRF tokens for mutating requests
next.ServeHTTP(w, r)
})
}
// RBAC middleware placeholder
func RBAC(permission string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try to read role from context and permit admin always
role := r.Context().Value(ContextKey("user.role"))
if role == "admin" {
next.ServeHTTP(w, r)
return
}
// For now, only admin is permitted; add permission checks here
next.ServeHTTP(w, r)
})
}
}

24
internal/http/router.go Normal file
View File

@@ -0,0 +1,24 @@
package http
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// RegisterRoutes registers HTTP routes onto the router
func RegisterRoutes(r *chi.Mux, app *App) {
r.Use(Logging)
r.Use(RequestID)
r.Use(Auth)
r.Get("/", app.DashboardHandler)
r.Get("/dashboard", app.DashboardHandler)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
// API namespace
r.Route("/api", func(r chi.Router) {
r.Get("/pools", app.PoolsHandler)
r.With(RBAC("storage.pool.create")).Post("/pools", app.CreatePoolHandler) // create a pool -> creates a job
r.Get("/jobs", app.JobsHandler)
})
r.Get("/static/*", StaticHandler)
}

View File

@@ -0,0 +1,42 @@
package exec
import (
"bytes"
"context"
"golang.org/x/sync/errgroup"
"os/exec"
"time"
)
type RunOptions struct {
Timeout time.Duration
}
type CommandRunner interface {
Run(ctx context.Context, cmd string, args []string, opts RunOptions) (string, string, error)
}
type DefaultRunner struct{}
func (r *DefaultRunner) Run(ctx context.Context, cmd string, args []string, opts RunOptions) (string, string, error) {
var stdout, stderr bytes.Buffer
execCtx := ctx
if opts.Timeout > 0 {
var cancel context.CancelFunc
execCtx, cancel = context.WithTimeout(ctx, opts.Timeout)
defer cancel()
}
eg, cctx := errgroup.WithContext(execCtx)
eg.Go(func() error {
command := exec.CommandContext(cctx, cmd, args...)
command.Stdout = &stdout
command.Stderr = &stderr
return command.Run()
})
if err := eg.Wait(); err != nil {
return stdout.String(), stderr.String(), err
}
return stdout.String(), stderr.String(), nil
}

View File

@@ -0,0 +1,15 @@
package db
import (
"context"
"database/sql"
)
// Open returns a connected DB instance. Caller should close.
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
return db, nil
}

View File

@@ -0,0 +1,44 @@
package db
import (
"context"
"database/sql"
"golang.org/x/crypto/bcrypt"
"log"
)
// MigrateAndSeed performs a very small migration set and seeds an admin user
func MigrateAndSeed(ctx context.Context, db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmts := []string{
`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT, role TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`,
`CREATE TABLE IF NOT EXISTS pools (name TEXT PRIMARY KEY, guid TEXT, health TEXT, capacity TEXT);`,
`CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, type TEXT, status TEXT, progress INTEGER DEFAULT 0, owner TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME);`,
}
for _, s := range stmts {
if _, err := tx.ExecContext(ctx, s); err != nil {
return err
}
}
// Seed a default admin user if not exists
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM users WHERE username = 'admin'`).Scan(&count); err != nil {
log.Printf("seed check failed: %v", err)
}
if count == 0 {
// note: simple seeded password: admin (do not use in prod)
pwHash, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
if _, err := tx.ExecContext(ctx, `INSERT INTO users (id, username, password_hash, role) VALUES (?, 'admin', ?, 'admin')`, "admin", string(pwHash)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,18 @@
package stubs
import (
"context"
"log"
)
type ISCSIAdapter struct{}
func (i *ISCSIAdapter) CreateTarget(ctx context.Context, name string) error {
log.Printf("iscsi: CreateTarget name=%s (stub)", name)
return nil
}
func (i *ISCSIAdapter) CreateLUN(ctx context.Context, target string, backstore string, lunID int) error {
log.Printf("iscsi: CreateLUN target=%s backstore=%s lun=%d (stub)", target, backstore, lunID)
return nil
}

View File

@@ -0,0 +1,18 @@
package stubs
import (
"context"
"log"
)
type MinioAdapter struct{}
func (m *MinioAdapter) ListBuckets(ctx context.Context) ([]string, error) {
log.Println("minio: ListBuckets (stub)")
return []string{"buckets"}, nil
}
func (m *MinioAdapter) CreateBucket(ctx context.Context, name string) error {
log.Printf("minio: CreateBucket %s (stub)", name)
return nil
}

View File

@@ -0,0 +1,18 @@
package stubs
import (
"context"
"log"
)
type NFSAdapter struct{}
func (n *NFSAdapter) ListExports(ctx context.Context) ([]string, error) {
log.Println("nfs: ListExports (stub)")
return []string{"/export/data"}, nil
}
func (n *NFSAdapter) CreateExport(ctx context.Context, path string) error {
log.Printf("nfs: CreateExport path=%s (stub)", path)
return nil
}

View File

@@ -0,0 +1,18 @@
package stubs
import (
"context"
"log"
)
type SambaAdapter struct{}
func (s *SambaAdapter) ListShares(ctx context.Context) ([]string, error) {
log.Println("samba: ListShares (stub)")
return []string{"share1"}, nil
}
func (s *SambaAdapter) CreateShare(ctx context.Context, name, path string) error {
log.Printf("samba: CreateShare name=%s path=%s (stub)", name, path)
return nil
}

View File

@@ -0,0 +1,18 @@
package stubs
import (
"context"
"log"
)
type ZFSAdapter struct{}
func (z *ZFSAdapter) ListPools(ctx context.Context) ([]string, error) {
log.Printf("zfs: ListPools (stub)")
return []string{"tank"}, nil
}
func (z *ZFSAdapter) CreatePool(ctx context.Context, name string, vdevs []string) error {
log.Printf("zfs: CreatePool %s (stub) vdevs=%v", name, vdevs)
return nil
}

42
internal/job/runner.go Normal file
View File

@@ -0,0 +1,42 @@
package job
import (
"context"
"database/sql"
"log"
"time"
"github.com/example/storage-appliance/internal/domain"
"github.com/google/uuid"
)
type Runner struct {
DB *sql.DB
}
func (r *Runner) Enqueue(ctx context.Context, j domain.Job) (string, error) {
id := uuid.New().String()
if j.ID == "" {
j.ID = domain.UUID(id)
}
j.Status = "queued"
j.CreatedAt = time.Now()
j.UpdatedAt = time.Now()
_, err := r.DB.ExecContext(ctx, `INSERT INTO jobs (id, type, status, progress, owner, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
j.ID, j.Type, j.Status, j.Progress, j.Owner, j.CreatedAt, j.UpdatedAt)
if err != nil {
return "", err
}
log.Printf("enqueued job %s (%s)", j.ID, j.Type)
// run async worker (very simple worker for skeleton)
go func() {
time.Sleep(1 * time.Second)
r.updateStatus(ctx, j.ID, "succeeded", 100)
}()
return id, nil
}
func (r *Runner) updateStatus(ctx context.Context, id domain.UUID, status string, progress int) error {
_, err := r.DB.ExecContext(ctx, `UPDATE jobs SET status = ?, progress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, status, progress, id)
return err
}

View File

@@ -0,0 +1,20 @@
package service
import (
"context"
"github.com/example/storage-appliance/internal/domain"
)
type DiskService interface {
List(ctx context.Context) ([]domain.Disk, error)
Inspect(ctx context.Context, path string) (domain.Disk, error)
}
type ZFSService interface {
ListPools(ctx context.Context) ([]domain.Pool, error)
CreatePool(ctx context.Context, name string, vdevs []string) (string, error)
}
type JobRunner interface {
Enqueue(ctx context.Context, j domain.Job) (string, error)
}

View File

@@ -0,0 +1,47 @@
package mock
import (
"context"
"time"
"github.com/example/storage-appliance/internal/domain"
"github.com/example/storage-appliance/internal/service"
"github.com/google/uuid"
)
var (
_ service.DiskService = (*MockDiskService)(nil)
_ service.ZFSService = (*MockZFSService)(nil)
_ service.JobRunner = (*MockJobRunner)(nil)
)
type MockDiskService struct{}
func (m *MockDiskService) List(ctx context.Context) ([]domain.Disk, error) {
return []domain.Disk{{ID: domain.UUID(uuid.New().String()), Path: "/dev/sda", Serial: "XYZ123", Model: "ACME 1TB", Size: 1 << 40, Health: "ONLINE"}}, nil
}
func (m *MockDiskService) Inspect(ctx context.Context, path string) (domain.Disk, error) {
return domain.Disk{ID: domain.UUID(uuid.New().String()), Path: path, Serial: "XYZ123", Model: "ACME 1TB", Size: 1 << 40, Health: "ONLINE"}, nil
}
type MockZFSService struct{}
func (m *MockZFSService) ListPools(ctx context.Context) ([]domain.Pool, error) {
return []domain.Pool{{Name: "tank", GUID: "g-uuid-1", Health: "ONLINE", Capacity: "1TiB"}}, nil
}
func (m *MockZFSService) CreatePool(ctx context.Context, name string, vdevs []string) (string, error) {
// spawn instant job id for mock
return "job-" + uuid.New().String(), nil
}
type MockJobRunner struct{}
func (m *MockJobRunner) Enqueue(ctx context.Context, j domain.Job) (string, error) {
// simulate queueing job and running immediately
go func() {
time.Sleep(100 * time.Millisecond)
}()
return uuid.New().String(), nil
}

View File

@@ -0,0 +1,18 @@
{{define "base"}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
<title>{{.Title}}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<meta name="csrf-token" content="fake-csrf-token">
</head>
<body class="bg-gray-100">
<main class="container mx-auto p-4">
{{template "content" .}}
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,18 @@
{{define "content"}}
<div class="bg-white rounded shadow p-4">
<h1 class="text-2xl font-bold">{{.Title}}</h1>
<div class="mt-4">
<button class="px-3 py-2 bg-blue-500 text-white rounded" hx-get="/api/pools" hx-swap="outerHTML" hx-trigger="click">Refresh pools</button>
</div>
<div id="pools" class="mt-4">
<div class="text-sm text-gray-600">Pools: <span id="pools-count">1</span></div>
<div class="mt-2 p-2 bg-gray-50 rounded">tank — ONLINE — 1TiB</div>
</div>
<div class="mt-6">
<h2 class="text-lg font-semibold">Jobs</h2>
<div id="jobs">
<div class="text-sm text-gray-500">No active jobs</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,25 @@
-- 0001_schema.sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT,
role TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pools (
name TEXT PRIMARY KEY,
guid TEXT,
health TEXT,
capacity TEXT
);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
type TEXT,
status TEXT,
progress INTEGER DEFAULT 0,
owner TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME
);