From 61570cae23791bf505c63dab0e71abd5d10225c1 Mon Sep 17 00:00:00 2001 From: Dev Date: Sat, 13 Dec 2025 15:18:04 +0000 Subject: [PATCH] Add initial Go server skeleton with HTTP handlers, middleware, job runner, and stubs --- .github/copilot-instructions.md | 53 +++++++++++++++ Makefile | 18 ++++++ README.md | 42 ++++++++++++ appliance.db | Bin 0 -> 32768 bytes cmd/appliance/main.go | 86 +++++++++++++++++++++++++ go.mod | 26 ++++++++ go.sum | 38 +++++++++++ internal/domain/domain.go | 65 +++++++++++++++++++ internal/http/README.md | 8 +++ internal/http/app.go | 17 +++++ internal/http/handlers.go | 80 +++++++++++++++++++++++ internal/http/handlers_test.go | 25 +++++++ internal/http/middleware.go | 78 ++++++++++++++++++++++ internal/http/router.go | 24 +++++++ internal/infra/exec/exec.go | 42 ++++++++++++ internal/infra/sqlite/db/db.go | 15 +++++ internal/infra/sqlite/db/migrations.go | 44 +++++++++++++ internal/infra/stubs/iscsi.go | 18 ++++++ internal/infra/stubs/minio.go | 18 ++++++ internal/infra/stubs/nfs.go | 18 ++++++ internal/infra/stubs/samba.go | 18 ++++++ internal/infra/stubs/zfs.go | 18 ++++++ internal/job/runner.go | 42 ++++++++++++ internal/service/interfaces.go | 20 ++++++ internal/service/mock/mock_service.go | 47 ++++++++++++++ internal/templates/base.html | 18 ++++++ internal/templates/dashboard.html | 18 ++++++ migrations/0001_schema.sql | 25 +++++++ 28 files changed, 921 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 appliance.db create mode 100644 cmd/appliance/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/domain/domain.go create mode 100644 internal/http/README.md create mode 100644 internal/http/app.go create mode 100644 internal/http/handlers.go create mode 100644 internal/http/handlers_test.go create mode 100644 internal/http/middleware.go create mode 100644 internal/http/router.go create mode 100644 internal/infra/exec/exec.go create mode 100644 internal/infra/sqlite/db/db.go create mode 100644 internal/infra/sqlite/db/migrations.go create mode 100644 internal/infra/stubs/iscsi.go create mode 100644 internal/infra/stubs/minio.go create mode 100644 internal/infra/stubs/nfs.go create mode 100644 internal/infra/stubs/samba.go create mode 100644 internal/infra/stubs/zfs.go create mode 100644 internal/job/runner.go create mode 100644 internal/service/interfaces.go create mode 100644 internal/service/mock/mock_service.go create mode 100644 internal/templates/base.html create mode 100644 internal/templates/dashboard.html create mode 100644 migrations/0001_schema.sql diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..9040fd0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6e16e73 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..817c5c1 --- /dev/null +++ b/README.md @@ -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 +``` + diff --git a/appliance.db b/appliance.db new file mode 100644 index 0000000000000000000000000000000000000000..4b391f57faf49ae2f04d309cb73c27c584b9dba9 GIT binary patch literal 32768 zcmeI(PjAv-9Ki8*e_)ybUY1^;!xBfr<_bfX8IvKah7FLhGMxvT4m!Ye6x)(*NA@nf z`f9xRM!b5^qwV9E&SWlLOeEhY?bAL_AK>@bEmHDP5Q@)b^lKGVRkbaZ?nDW!b+g{3b1Q0*~ z0R#|0009ILK;VBD7;hz#+Tx;m(GKk+*Lmw51@WFNH;s~M2($FGYKXW;EcZLYG+vpa z(X7-;%|o$k9OguLK6GSvP6VMHj)Kd^(D%B&69l4CH;v~;Q*0a0O0B9X)^fr-9XS5w zxVG=uq0_OV<=%Gmfo7#`~~+wiU;2>2bZ#n@eh%rj8%TREM7D2C|yJmaOa&%LDu5M`G9W?T#)Pa-!$h zZrGdTVGr$gKRlPsY<51OJ<)CoNq&N*%R^$EQ)N^ak7QJ%!0`iF&0LF0cHJ-s@wK1f z)?b*S-l|qbt6te_896bugW%NjJ66vQCgJ*?JDIk>nt^O~_D`9~D6PCa%w=b8AF9it z@mgN4?NiZ-nt8888wCLb5I_I{1Q0*~0R#|0009L4wSW`fq!`a@1-o<7AINGcZ!hU< zOU3Sbp&WK!9}M?4c4`}j)7#iRTrGKSS#O*Lrwy~>9@O^zUVBj3J$vczl#6})T)shm zEni&G^DBBm=*3O_;pTerZj_gr`Km-41px#QKmY**5I_I{1Q0*~0R*N?U}09fBX0?? z{-3UiB^v|~KmY**5I_I{1Q0*~0R(;#xLW_mzyFW^^iM$m0R#|0009ILKmY**5I_Kd zDHe#I|Fiy|;t3`-1Q0*~0R#|0009ILKmY**A_1QN^EE&K0R#|0009ILKmY**5I|u1 d1$h2H{c}u)2q1s}0tg_000IagfB*srd{{.Title}}`) + } +} + +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)) +} diff --git a/internal/http/handlers_test.go b/internal/http/handlers_test.go new file mode 100644 index 0000000..7d6430b --- /dev/null +++ b/internal/http/handlers_test.go @@ -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") + } +} diff --git a/internal/http/middleware.go b/internal/http/middleware.go new file mode 100644 index 0000000..50645fe --- /dev/null +++ b/internal/http/middleware.go @@ -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) + }) + } +} diff --git a/internal/http/router.go b/internal/http/router.go new file mode 100644 index 0000000..9415d70 --- /dev/null +++ b/internal/http/router.go @@ -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) +} diff --git a/internal/infra/exec/exec.go b/internal/infra/exec/exec.go new file mode 100644 index 0000000..c1a9ef2 --- /dev/null +++ b/internal/infra/exec/exec.go @@ -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 +} diff --git a/internal/infra/sqlite/db/db.go b/internal/infra/sqlite/db/db.go new file mode 100644 index 0000000..1465cd5 --- /dev/null +++ b/internal/infra/sqlite/db/db.go @@ -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 +} diff --git a/internal/infra/sqlite/db/migrations.go b/internal/infra/sqlite/db/migrations.go new file mode 100644 index 0000000..8630ad4 --- /dev/null +++ b/internal/infra/sqlite/db/migrations.go @@ -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 +} diff --git a/internal/infra/stubs/iscsi.go b/internal/infra/stubs/iscsi.go new file mode 100644 index 0000000..ee663da --- /dev/null +++ b/internal/infra/stubs/iscsi.go @@ -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 +} diff --git a/internal/infra/stubs/minio.go b/internal/infra/stubs/minio.go new file mode 100644 index 0000000..05aab3c --- /dev/null +++ b/internal/infra/stubs/minio.go @@ -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 +} diff --git a/internal/infra/stubs/nfs.go b/internal/infra/stubs/nfs.go new file mode 100644 index 0000000..38c6a37 --- /dev/null +++ b/internal/infra/stubs/nfs.go @@ -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 +} diff --git a/internal/infra/stubs/samba.go b/internal/infra/stubs/samba.go new file mode 100644 index 0000000..78e581b --- /dev/null +++ b/internal/infra/stubs/samba.go @@ -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 +} diff --git a/internal/infra/stubs/zfs.go b/internal/infra/stubs/zfs.go new file mode 100644 index 0000000..a6f8c5b --- /dev/null +++ b/internal/infra/stubs/zfs.go @@ -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 +} diff --git a/internal/job/runner.go b/internal/job/runner.go new file mode 100644 index 0000000..4b2a1a4 --- /dev/null +++ b/internal/job/runner.go @@ -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 +} diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go new file mode 100644 index 0000000..2812706 --- /dev/null +++ b/internal/service/interfaces.go @@ -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) +} diff --git a/internal/service/mock/mock_service.go b/internal/service/mock/mock_service.go new file mode 100644 index 0000000..ec04b25 --- /dev/null +++ b/internal/service/mock/mock_service.go @@ -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 +} diff --git a/internal/templates/base.html b/internal/templates/base.html new file mode 100644 index 0000000..61534f3 --- /dev/null +++ b/internal/templates/base.html @@ -0,0 +1,18 @@ +{{define "base"}} + + + + + + + {{.Title}} + + + + +
+ {{template "content" .}} +
+ + +{{end}} diff --git a/internal/templates/dashboard.html b/internal/templates/dashboard.html new file mode 100644 index 0000000..a981b73 --- /dev/null +++ b/internal/templates/dashboard.html @@ -0,0 +1,18 @@ +{{define "content"}} +
+

{{.Title}}

+
+ +
+
+
Pools: 1
+
tank — ONLINE — 1TiB
+
+
+

Jobs

+
+
No active jobs
+
+
+
+{{end}} diff --git a/migrations/0001_schema.sql b/migrations/0001_schema.sql new file mode 100644 index 0000000..bcc98d1 --- /dev/null +++ b/migrations/0001_schema.sql @@ -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 +);