Add per-node health checks with status indicators and scheduled probes.

This commit is contained in:
2026-07-19 22:40:21 +02:00
parent 5307ba1a7b
commit aac9e82766
20 changed files with 1674 additions and 65 deletions
+17
View File
@@ -10,6 +10,7 @@ import (
var errUsersManageRequired = errors.New("permission users.manage required")
var errNodesReadRequired = errors.New("permission nodes.read required")
var errNodesExecRequired = errors.New("permission nodes.exec required")
var errNodesUpdateRequired = errors.New("permission nodes.update required")
var errNodesDeleteRequired = errors.New("permission nodes.delete required")
var errActionsCreateRequired = errors.New("permission actions.create required")
var errActionsUpdateRequired = errors.New("permission actions.update required")
@@ -135,6 +136,22 @@ func (app *App) authorizeNodesExec(request *http.Request) error {
return nil
}
func (app *App) authorizeNodesUpdate(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "nodes.update") {
return errNodesUpdateRequired
}
return nil
}
func (app *App) authorizeNodesDelete(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
+159
View File
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
@@ -231,6 +232,164 @@ func (app *App) nodesTestSSHHandler(writer http.ResponseWriter, request *http.Re
})
}
type patchNodeRequest struct {
HealthCheckIntervalSeconds *int `json:"health_check_interval_seconds"`
}
func (app *App) nodesPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesUpdate(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
nodeID := strings.TrimSpace(request.PathValue("id"))
if nodeID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
return
}
var payload patchNodeRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if payload.HealthCheckIntervalSeconds == nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{
Error: "health_check_interval_seconds is required",
})
return
}
if *payload.HealthCheckIntervalSeconds < 0 {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{
Error: "health_check_interval_seconds must be >= 0",
})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
nodeIndex := -1
var node settings.Node
for index, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
nodeIndex = index
break
}
if nodeIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
return
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return
}
node.HealthCheckIntervalSeconds = *payload.HealthCheckIntervalSeconds
store.Nodes[nodeIndex] = node
if err := settings.SaveNodes(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
_ = appendNodeAudit(
app.ConfigDir,
settings.NodeAuditActionUpdate,
user.Username,
node,
fmt.Sprintf("health_check_interval_seconds=%d", node.HealthCheckIntervalSeconds),
)
writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
}
func (app *App) nodesHealthCheckHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesExec(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
if app.HealthChecker == nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "health checker unavailable"})
return
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
nodeID := strings.TrimSpace(request.PathValue("id"))
if nodeID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var node settings.Node
found := false
for _, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
found = true
break
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
return
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return
}
updated, err := app.HealthChecker.CheckNodeByID(nodeID, user.Username)
if err != nil {
if strings.Contains(err.Error(), "already running") {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, nodeResponse{Node: updated})
}
func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesExec(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
@@ -0,0 +1,211 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
)
func TestNodesHealthCheckPersistsStatus(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router, app := NewRouterWithApp(configDir)
app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{OK: true, Method: auth.PingMethodICMP}
}
app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return nil
}
createBody := []byte(`{
"id":"bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"container",
"name":"healthy-node",
"host_ip":"10.20.30.40",
"group_name":"Administrators",
"generate":{"algorithm":"ed25519"}
}`)
createRequest := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
cookie,
)
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
}
healthRequest := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee/health-check",
nil,
),
cookie,
)
healthRecorder := httptest.NewRecorder()
router.ServeHTTP(healthRecorder, healthRequest)
if healthRecorder.Code != http.StatusOK {
t.Fatalf("health status = %d body=%s", healthRecorder.Code, healthRecorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.HealthOK == nil || !*response.Node.HealthOK {
t.Fatalf("health_ok = %#v", response.Node.HealthOK)
}
if response.Node.HealthMessage == "" {
t.Fatal("expected health message")
}
}
func TestNodesHealthCheckRecordsFailure(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router, app := NewRouterWithApp(configDir)
app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{
OK: false,
Method: auth.PingMethodTCP,
Error: errors.New("unreachable"),
}
}
app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return errors.New("ssh failed")
}
createBody := []byte(`{
"id":"cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"vm",
"name":"unhealthy-node",
"host_ip":"10.20.30.41",
"group_name":"Administrators",
"generate":{"algorithm":"ed25519"}
}`)
createRequest := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
cookie,
)
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
}
healthRequest := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee/health-check",
nil,
),
cookie,
)
healthRecorder := httptest.NewRecorder()
router.ServeHTTP(healthRecorder, healthRequest)
if healthRecorder.Code != http.StatusOK {
t.Fatalf("health status = %d body=%s", healthRecorder.Code, healthRecorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.HealthOK == nil || *response.Node.HealthOK {
t.Fatalf("expected failure, got %#v", response.Node.HealthOK)
}
}
func TestNodesPatchHealthInterval(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"id":"dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"docker",
"name":"interval-node",
"host_ip":"10.20.30.42",
"group_name":"Administrators",
"generate":{"algorithm":"ed25519"}
}`)
createRequest := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
cookie,
)
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
}
patchBody := []byte(`{"health_check_interval_seconds":120}`)
patchRequest := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee",
bytes.NewReader(patchBody),
),
cookie,
)
patchRecorder := httptest.NewRecorder()
router.ServeHTTP(patchRecorder, patchRequest)
if patchRecorder.Code != http.StatusOK {
t.Fatalf("patch status = %d body=%s", patchRecorder.Code, patchRecorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(patchRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.HealthCheckIntervalSeconds != 120 {
t.Fatalf("interval = %d", response.Node.HealthCheckIntervalSeconds)
}
}
func TestNodesPatchHealthIntervalRejectsNegative(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"id":"eeeeeeee-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"container",
"name":"bad-interval",
"host_ip":"10.20.30.43",
"group_name":"Administrators",
"generate":{"algorithm":"ed25519"}
}`)
createRequest := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
cookie,
)
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
}
patchBody := []byte(`{"health_check_interval_seconds":-5}`)
patchRequest := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/nodes/eeeeeeee-bbbb-4ccc-8ddd-eeeeeeeeeeee",
bytes.NewReader(patchBody),
),
cookie,
)
patchRecorder := httptest.NewRecorder()
router.ServeHTTP(patchRecorder, patchRequest)
if patchRecorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", patchRecorder.Code, patchRecorder.Body.String())
}
}
+5
View File
@@ -22,6 +22,9 @@ func NewRouterWithApp(configDir string) (http.Handler, *App) {
if app.Executor != nil {
app.Executor.StartScheduler()
}
if app.HealthChecker != nil {
app.HealthChecker.StartScheduler()
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", HealthHandler)
@@ -62,8 +65,10 @@ func NewRouterWithApp(configDir string) (http.Handler, *App) {
mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler)
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
mux.HandleFunc("PATCH /api/v1/nodes/{id}", app.nodesPatchHandler)
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/health-check", app.nodesHealthCheckHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/resolve-commands", app.nodesResolveCommandsHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler)
+16 -12
View File
@@ -11,6 +11,7 @@ import (
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/health"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
@@ -77,10 +78,11 @@ type meResponse struct {
// App holds shared API dependencies.
type App struct {
ConfigDir string
Key []byte
Sessions *auth.SessionManager
Executor *runner.Executor
ConfigDir string
Key []byte
Sessions *auth.SessionManager
Executor *runner.Executor
HealthChecker *health.Checker
pendingTOTPMu sync.Mutex
pendingTOTP map[string]string // username -> secret (setup only)
@@ -91,18 +93,20 @@ func newApp(configDir string) (*App, error) {
if err != nil {
// Key is required for setup complete / auth; status still works without it.
app := &App{
ConfigDir: configDir,
pendingTOTP: map[string]string{},
Executor: runner.NewExecutor(configDir, nil),
ConfigDir: configDir,
pendingTOTP: map[string]string{},
Executor: runner.NewExecutor(configDir, nil),
HealthChecker: health.NewChecker(configDir, nil),
}
return app, nil
}
app := &App{
ConfigDir: configDir,
Key: key,
Sessions: auth.NewSessionManager(configDir, key),
pendingTOTP: map[string]string{},
Executor: runner.NewExecutor(configDir, key),
ConfigDir: configDir,
Key: key,
Sessions: auth.NewSessionManager(configDir, key),
pendingTOTP: map[string]string{},
Executor: runner.NewExecutor(configDir, key),
HealthChecker: health.NewChecker(configDir, key),
}
return app, nil
}