Files
certctl/internal/api/handler/health_check.go
T
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

312 lines
8.6 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository"
)
// HealthCheckServicer defines the interface used by the health check handler.
type HealthCheckServicer interface {
Create(ctx context.Context, check *domain.EndpointHealthCheck) error
Get(ctx context.Context, id string) (*domain.EndpointHealthCheck, error)
Update(ctx context.Context, check *domain.EndpointHealthCheck) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, filter *repository.HealthCheckFilter) ([]*domain.EndpointHealthCheck, int, error)
GetHistory(ctx context.Context, healthCheckID string, limit int) ([]*domain.HealthHistoryEntry, error)
AcknowledgeIncident(ctx context.Context, id string, actor string) error
GetSummary(ctx context.Context) (*domain.HealthCheckSummary, error)
}
// HealthCheckHandler handles HTTP requests for TLS health monitoring.
type HealthCheckHandler struct {
service HealthCheckServicer
}
// NewHealthCheckHandler creates a new health check handler.
func NewHealthCheckHandler(service HealthCheckServicer) *HealthCheckHandler {
return &HealthCheckHandler{service: service}
}
// ListHealthChecks handles GET /api/v1/health-checks
func (h *HealthCheckHandler) ListHealthChecks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
query := r.URL.Query()
status := query.Get("status")
certificateID := query.Get("certificate_id")
networkScanTargetID := query.Get("network_scan_target_id")
enabledStr := query.Get("enabled")
page := parseIntDefault(query.Get("page"), 1)
perPage := parseIntDefault(query.Get("per_page"), 50)
if perPage > 500 {
perPage = 50
}
// Parse enabled flag if provided
var enabledFilter *bool
if enabledStr != "" {
enabled := enabledStr == "true"
enabledFilter = &enabled
}
filter := &repository.HealthCheckFilter{
Status: status,
CertificateID: certificateID,
NetworkScanTargetID: networkScanTargetID,
Enabled: enabledFilter,
Page: page,
PerPage: perPage,
}
checks, total, err := h.service.List(r.Context(), filter)
if err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to list health checks: %v", err))
return
}
if checks == nil {
checks = make([]*domain.EndpointHealthCheck, 0)
}
JSON(w, http.StatusOK, PagedResponse{
Data: checks,
Total: int64(total),
Page: page,
PerPage: perPage,
})
}
// GetHealthCheck handles GET /api/v1/health-checks/{id}
func (h *HealthCheckHandler) GetHealthCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "health check ID is required")
return
}
check, err := h.service.Get(r.Context(), id)
if err != nil {
Error(w, http.StatusNotFound, fmt.Sprintf("health check not found: %v", err))
return
}
JSON(w, http.StatusOK, check)
}
// CreateHealthCheck handles POST /api/v1/health-checks
func (h *HealthCheckHandler) CreateHealthCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
var check domain.EndpointHealthCheck
if err := json.NewDecoder(r.Body).Decode(&check); err != nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
return
}
if check.Endpoint == "" {
Error(w, http.StatusBadRequest, "endpoint is required")
return
}
// Set defaults
if check.CheckIntervalSecs <= 0 {
check.CheckIntervalSecs = 300
}
if check.DegradedThreshold <= 0 {
check.DegradedThreshold = 2
}
if check.DownThreshold <= 0 {
check.DownThreshold = 5
}
if check.Status == "" {
check.Status = domain.HealthStatusUnknown
}
if err := h.service.Create(r.Context(), &check); err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to create health check: %v", err))
return
}
JSON(w, http.StatusCreated, check)
}
// UpdateHealthCheck handles PUT /api/v1/health-checks/{id}
func (h *HealthCheckHandler) UpdateHealthCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "health check ID is required")
return
}
// Get existing check
existing, err := h.service.Get(r.Context(), id)
if err != nil {
Error(w, http.StatusNotFound, fmt.Sprintf("health check not found: %v", err))
return
}
var updates domain.EndpointHealthCheck
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
return
}
// Merge updates (only update provided fields)
if updates.Endpoint != "" {
existing.Endpoint = updates.Endpoint
}
if updates.ExpectedFingerprint != "" {
existing.ExpectedFingerprint = updates.ExpectedFingerprint
}
if updates.CheckIntervalSecs > 0 {
existing.CheckIntervalSecs = updates.CheckIntervalSecs
}
if updates.DegradedThreshold > 0 {
existing.DegradedThreshold = updates.DegradedThreshold
}
if updates.DownThreshold > 0 {
existing.DownThreshold = updates.DownThreshold
}
existing.Enabled = updates.Enabled
if err := h.service.Update(r.Context(), existing); err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to update health check: %v", err))
return
}
JSON(w, http.StatusOK, existing)
}
// DeleteHealthCheck handles DELETE /api/v1/health-checks/{id}
func (h *HealthCheckHandler) DeleteHealthCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "health check ID is required")
return
}
if err := h.service.Delete(r.Context(), id); err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to delete health check: %v", err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetHealthCheckHistory handles GET /api/v1/health-checks/{id}/history
func (h *HealthCheckHandler) GetHealthCheckHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "health check ID is required")
return
}
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
if limit > 1000 {
limit = 1000
}
history, err := h.service.GetHistory(r.Context(), id, limit)
if err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to get health check history: %v", err))
return
}
if history == nil {
history = make([]*domain.HealthHistoryEntry, 0)
}
JSON(w, http.StatusOK, history)
}
// AcknowledgeHealthCheck handles POST /api/v1/health-checks/{id}/acknowledge
func (h *HealthCheckHandler) AcknowledgeHealthCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "health check ID is required")
return
}
var req struct {
Actor string `json:"actor,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
return
}
if req.Actor == "" {
req.Actor = "unknown"
}
if err := h.service.AcknowledgeIncident(r.Context(), id, req.Actor); err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to acknowledge health check: %v", err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetHealthCheckSummary handles GET /api/v1/health-checks/summary
// This route must be registered BEFORE the /{id} routes
func (h *HealthCheckHandler) GetHealthCheckSummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
summary, err := h.service.GetSummary(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to get health check summary: %v", err))
return
}
JSON(w, http.StatusOK, summary)
}