Compare commits

...

3 Commits

Author SHA1 Message Date
Grendgi
1144b11ca3 feat: expose files storage health detail
All checks were successful
CI / hygiene (push) Successful in 1s
Build and Deploy / build-and-deploy (push) Successful in 34s
CI / test (push) Successful in 32s
2026-06-17 15:47:14 +03:00
Grendgi
aad9fa1b4a chore: use common header parsing 2026-06-17 13:54:04 +03:00
Grendgi
1a84c04314 chore: update portal-common to v0.3.0 2026-06-17 13:51:39 +03:00
7 changed files with 103 additions and 26 deletions

View File

@@ -55,7 +55,7 @@ func main() {
slog.Warn("ensure bucket failed", "error", err) slog.Warn("ensure bucket failed", "error", err)
} }
healthH := handler.NewHealthHandler(pool) healthH := handler.NewHealthHandler(pool, store)
nodeRepo := repository.NewNodeRepository(pool) nodeRepo := repository.NewNodeRepository(pool)
nodeH := handler.NewNodeHandler(cfg, nodeRepo, store) nodeH := handler.NewNodeHandler(cfg, nodeRepo, store)
go runTrashPurger(ctx, nodeRepo, store) go runTrashPurger(ctx, nodeRepo, store)
@@ -67,6 +67,7 @@ func main() {
r.Get("/healthz", healthH.Healthz) r.Get("/healthz", healthH.Healthz)
r.Get("/readyz", healthH.Readyz) r.Get("/readyz", healthH.Readyz)
r.Get("/health/detail", healthH.Detail)
mountFilesAPI := func(r chi.Router) { mountFilesAPI := func(r chi.Router) {
r.Get("/nodes", nodeH.List) r.Get("/nodes", nodeH.List)

2
go.mod
View File

@@ -3,7 +3,7 @@ module files-service
go 1.25.7 go 1.25.7
require ( require (
gitea.estateliga.work/admin/portal-common v0.2.0 gitea.estateliga.work/admin/portal-common v0.3.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1 github.com/jackc/pgx/v5 v5.9.1

4
go.sum
View File

@@ -1,5 +1,5 @@
gitea.estateliga.work/admin/portal-common v0.2.0 h1:TwSxTDwSWnPJUGuCfjSy1f++MxvDIZ+HCUNMC3EFNcE= gitea.estateliga.work/admin/portal-common v0.3.0 h1:xpr9UeLXk5pCcNXcTVGZzJZr0Ni7An7DV0OkuYv9qVM=
gitea.estateliga.work/admin/portal-common v0.2.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk= gitea.estateliga.work/admin/portal-common v0.3.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=

View File

@@ -1,17 +1,24 @@
package handler package handler
import ( import (
"context"
"encoding/json"
"net/http" "net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"files-service/internal/storage"
) )
type HealthHandler struct { type HealthHandler struct {
pool *pgxpool.Pool pool *pgxpool.Pool
store *storage.Storage
} }
func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler { func NewHealthHandler(pool *pgxpool.Pool, store *storage.Storage) *HealthHandler {
return &HealthHandler{pool: pool} return &HealthHandler{pool: pool, store: store}
} }
func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) { func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) {
@@ -25,3 +32,75 @@ func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
} }
func (h *HealthHandler) Detail(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
components := []componentProbe{
h.probePostgres(ctx),
h.probeStorage(ctx),
h.probeTrash(ctx),
h.probeStorageMetadata(ctx),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"components": components})
}
type componentProbe struct {
Name string `json:"name"`
Status string `json:"status"`
LatencyMs int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
}
func (h *HealthHandler) probePostgres(ctx context.Context) componentProbe {
start := time.Now()
if err := h.pool.Ping(ctx); err != nil {
return componentProbe{Name: "postgres", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "postgres", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (h *HealthHandler) probeStorage(ctx context.Context) componentProbe {
start := time.Now()
if err := h.store.Check(ctx); err != nil {
return componentProbe{Name: "minio_storage", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "minio_storage", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (h *HealthHandler) probeTrash(ctx context.Context) componentProbe {
start := time.Now()
var due int
if err := h.pool.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM files_nodes
WHERE purge_after IS NOT NULL
AND purge_after <= now()
`).Scan(&due); err != nil {
return componentProbe{Name: "trash_purger", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
if due > 0 {
return componentProbe{Name: "trash_purger", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: "purge_due=" + strconv.Itoa(due)}
}
return componentProbe{Name: "trash_purger", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (h *HealthHandler) probeStorageMetadata(ctx context.Context) componentProbe {
start := time.Now()
var activeObjects int
if err := h.pool.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM files_nodes
WHERE deleted_at IS NULL
AND storage_key IS NOT NULL
`).Scan(&activeObjects); err != nil {
return componentProbe{Name: "storage_metadata", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{
Name: "storage_metadata",
Status: "ok",
LatencyMs: time.Since(start).Milliseconds(),
}
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
) )
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -26,19 +25,3 @@ func decodeJSON(r *http.Request, v any) error {
defer r.Body.Close() defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v) return json.NewDecoder(r.Body).Decode(v)
} }
func csvHeader(r *http.Request, key string) []string {
raw := r.Header.Get(key)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}

View File

@@ -763,9 +763,9 @@ func allowedOfficeFormat(format string) bool {
} }
func subordinates(r *http.Request) []string { func subordinates(r *http.Request) []string {
ids := csvHeader(r, "X-User-Subordinates") ids := commonmw.HeaderCSV(r, "X-User-Subordinates")
if len(ids) == 0 { if len(ids) == 0 {
ids = csvHeader(r, "X-User-Subordinate-Ids") ids = commonmw.HeaderCSV(r, "X-User-Subordinate-Ids")
} }
return ids return ids
} }

View File

@@ -66,6 +66,20 @@ func (s *Storage) EnsureBucket(ctx context.Context) error {
return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{}) return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{})
} }
func (s *Storage) Check(ctx context.Context) error {
if !s.Configured() {
return errors.New("storage not configured")
}
exists, err := s.client.BucketExists(ctx, s.cfg.Bucket)
if err != nil {
return fmt.Errorf("check bucket: %w", err)
}
if !exists {
return fmt.Errorf("bucket %q does not exist", s.cfg.Bucket)
}
return nil
}
func GenerateKey(ownerID, filename string) string { func GenerateKey(ownerID, filename string) string {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
if !AllowedExtension(ext) { if !AllowedExtension(ext) {