Public source validation / validate (push) Failing after 3m8s
223 lines
8.0 KiB
Go
223 lines
8.0 KiB
Go
package onboarding
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/itworx/pulse/internal/alert"
|
|
"github.com/itworx/pulse/internal/dashboard"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
//go:embed default-dashboard.json
|
|
var defaultDashboardFS embed.FS
|
|
|
|
type Service struct {
|
|
State StateStore
|
|
Pool *pgxpool.Pool
|
|
Dashboards dashboard.Repository
|
|
Alerts alert.Store
|
|
AuthMode string
|
|
OIDCIssuer string
|
|
OIDCClient string
|
|
OIDCRedirect string
|
|
Prometheus bool
|
|
Unraid bool
|
|
// RuntimeCapabilities supplies observed source readiness. It overrides the
|
|
// configuration-only Prometheus and Unraid placeholders when present.
|
|
RuntimeCapabilities func(context.Context) ([]Capability, error)
|
|
}
|
|
|
|
func (s Service) Status(ctx context.Context) (Status, error) {
|
|
state, err := s.State.Load(ctx)
|
|
if err != nil {
|
|
return Status{}, err
|
|
}
|
|
caps := make([]Capability, 0, 7)
|
|
if s.Pool == nil {
|
|
caps = append(caps, Capability{ID: "database", State: "unavailable", Detail: "De database is niet geconfigureerd."})
|
|
} else if err := s.Pool.Ping(ctx); err != nil {
|
|
caps = append(caps, Capability{ID: "database", State: "unknown", Detail: "De database is niet bereikbaar."})
|
|
} else {
|
|
caps = append(caps, Capability{ID: "database", State: "ready", Detail: "De database is beschikbaar."})
|
|
}
|
|
if s.Prometheus {
|
|
caps = append(caps, Capability{ID: "prometheus", State: "configured", Detail: "Prometheus is geconfigureerd; de endpoint blijft verborgen."})
|
|
} else {
|
|
caps = append(caps, Capability{ID: "prometheus", State: "unknown", Detail: "Prometheus is nog niet geconfigureerd."})
|
|
}
|
|
if s.Unraid {
|
|
caps = append(caps, Capability{ID: "unraid", State: "configured", Detail: "De read-only Unraid-bron is geconfigureerd."})
|
|
} else {
|
|
caps = append(caps, Capability{ID: "unraid", State: "unknown", Detail: "De read-only Unraid-bron is nog niet geconfigureerd."})
|
|
}
|
|
if s.Pool == nil {
|
|
caps = append(caps, Capability{ID: "services", State: "unavailable", Detail: "Servicebewaking is niet beschikbaar zonder database."})
|
|
} else {
|
|
var enabled int
|
|
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM probes WHERE enabled=true AND archived_at IS NULL`).Scan(&enabled); err != nil {
|
|
caps = append(caps, Capability{ID: "services", State: "unknown", Detail: "De actieve servicechecks konden niet worden gelezen."})
|
|
} else if enabled == 0 {
|
|
caps = append(caps, Capability{ID: "services", State: "not-ready", Detail: "Er zijn nog geen veilige servicechecks geconfigureerd."})
|
|
} else {
|
|
caps = append(caps, Capability{ID: "services", State: "ready", Detail: fmt.Sprintf("%d veilige read-only servicechecks zijn actief.", enabled)})
|
|
}
|
|
}
|
|
switch {
|
|
case s.AuthMode == "mock":
|
|
caps = append(caps, Capability{ID: "auth", State: "development", Detail: "Mock-authenticatie is actief voor development."})
|
|
case strings.TrimSpace(s.OIDCIssuer) != "" && strings.TrimSpace(s.OIDCClient) != "" && strings.TrimSpace(s.OIDCRedirect) != "":
|
|
caps = append(caps, Capability{ID: "auth", State: "ready", Detail: "OIDC-configuratie is compleet; secrets worden niet getoond."})
|
|
default:
|
|
caps = append(caps, Capability{ID: "auth", State: "incomplete", Detail: "OIDC-configuratie is nog niet compleet."})
|
|
}
|
|
if s.Alerts == nil {
|
|
caps = append(caps, Capability{ID: "default-rules", State: "unavailable", Detail: "De alertregels zijn niet beschikbaar."})
|
|
} else if rules, listErr := s.Alerts.List(ctx, 100); listErr != nil {
|
|
caps = append(caps, Capability{ID: "default-rules", State: "unknown", Detail: "De alertregels konden niet worden gelezen."})
|
|
} else if len(rules) == 0 {
|
|
caps = append(caps, Capability{ID: "default-rules", State: "not-ready", Detail: "Er zijn nog geen alertregels beschikbaar."})
|
|
} else {
|
|
caps = append(caps, Capability{ID: "default-rules", State: "ready", Detail: fmt.Sprintf("%d alertregels zijn beschikbaar.", len(rules))})
|
|
}
|
|
if state.DashboardID != "" {
|
|
caps = append(caps, Capability{ID: "default-dashboard", State: "ready", Detail: "Het standaarddashboard is geselecteerd."})
|
|
} else {
|
|
caps = append(caps, Capability{ID: "default-dashboard", State: "not-ready", Detail: "Het standaarddashboard is nog niet geselecteerd."})
|
|
}
|
|
if s.RuntimeCapabilities != nil {
|
|
observed, observedErr := s.RuntimeCapabilities(ctx)
|
|
if observedErr != nil {
|
|
return Status{}, fmt.Errorf("read runtime capabilities: %w", observedErr)
|
|
}
|
|
caps = mergeCapabilities(caps, observed)
|
|
}
|
|
sort.Slice(caps, func(i, j int) bool { return caps[i].ID < caps[j].ID })
|
|
return Status{State: state, Capabilities: caps, Resume: !state.Completed && state.Step != "" && state.Step != "welcome"}, nil
|
|
}
|
|
|
|
func mergeCapabilities(configured, observed []Capability) []Capability {
|
|
byID := make(map[string]Capability, len(configured)+len(observed))
|
|
for _, capability := range configured {
|
|
byID[capability.ID] = capability
|
|
}
|
|
for _, capability := range observed {
|
|
if capability.ID == "prometheus" || capability.ID == "unraid" {
|
|
byID[capability.ID] = capability
|
|
}
|
|
}
|
|
result := make([]Capability, 0, len(byID))
|
|
for _, capability := range byID {
|
|
result = append(result, capability)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s Service) Complete(ctx context.Context, choice Choice) (Status, error) {
|
|
if choice.Dashboard == "" {
|
|
choice.Dashboard = "default"
|
|
}
|
|
if choice.Rules == "" {
|
|
choice.Rules = "default"
|
|
}
|
|
if !validChoice(choice.Dashboard) || !validChoice(choice.Rules) {
|
|
return Status{}, errors.New("onboarding choices must be default or skip")
|
|
}
|
|
state, err := s.State.Load(ctx)
|
|
if err != nil {
|
|
return Status{}, err
|
|
}
|
|
state.DashboardChoice = choice.Dashboard
|
|
state.RulesChoice = choice.Rules
|
|
state.Step = "dashboard"
|
|
if err := s.State.Save(ctx, state); err != nil {
|
|
return Status{}, err
|
|
}
|
|
if choice.Dashboard == "default" {
|
|
if state.DashboardID == "" {
|
|
id, ensureErr := s.ensureDefaultDashboard(ctx)
|
|
if ensureErr != nil {
|
|
return Status{}, ensureErr
|
|
}
|
|
state.DashboardID = id
|
|
}
|
|
}
|
|
state.Step = "rules"
|
|
if err := s.State.Save(ctx, state); err != nil {
|
|
return Status{}, err
|
|
}
|
|
if s.Alerts != nil {
|
|
rules, listErr := s.Alerts.List(ctx, 100)
|
|
if listErr != nil {
|
|
return Status{}, fmt.Errorf("read default alert rules: %w", listErr)
|
|
}
|
|
state.RulesReady = len(rules) > 0
|
|
}
|
|
if choice.Rules == "default" && !state.RulesReady {
|
|
return Status{}, errors.New("default alert rules are not ready")
|
|
}
|
|
state.Completed = true
|
|
state.Step = "complete"
|
|
if err := s.State.Save(ctx, state); err != nil {
|
|
return Status{}, err
|
|
}
|
|
return s.Status(ctx)
|
|
}
|
|
|
|
func validChoice(value string) bool { return value == "default" || value == "skip" }
|
|
|
|
func (s Service) ensureDefaultDashboard(ctx context.Context) (string, error) {
|
|
if s.Dashboards.Pool == nil {
|
|
return "", errors.New("dashboard repository is not configured")
|
|
}
|
|
items, err := s.Dashboards.List(ctx, "onboarding", 100)
|
|
if err != nil {
|
|
return "", fmt.Errorf("find default dashboard: %w", err)
|
|
}
|
|
for _, item := range items {
|
|
if item.Slug == DefaultSlug && item.ArchivedAt == nil {
|
|
return item.ID, nil
|
|
}
|
|
}
|
|
document, err := defaultDashboard()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
created, _, err := s.Dashboards.Create(ctx, "system-defaults", document, "first-run default dashboard")
|
|
if err != nil {
|
|
if errors.Is(err, dashboard.ErrConflict) {
|
|
items, listErr := s.Dashboards.List(ctx, "onboarding", 100)
|
|
if listErr == nil {
|
|
for _, item := range items {
|
|
if item.Slug == DefaultSlug && item.ArchivedAt == nil {
|
|
return item.ID, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "", fmt.Errorf("install default dashboard: %w", err)
|
|
}
|
|
return created.ID, nil
|
|
}
|
|
|
|
func defaultDashboard() (dashboard.Document, error) {
|
|
raw, err := defaultDashboardFS.ReadFile("default-dashboard.json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read embedded default dashboard: %w", err)
|
|
}
|
|
var document dashboard.Document
|
|
if err := json.Unmarshal(raw, &document); err != nil {
|
|
return nil, fmt.Errorf("decode embedded default dashboard: %w", err)
|
|
}
|
|
migrated, err := dashboard.Migrate(document)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("validate embedded default dashboard: %w", err)
|
|
}
|
|
return migrated, nil
|
|
}
|