This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/probe"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type PostgresProvider struct {
|
||||
Pool *pgxpool.Pool
|
||||
Policy StatusPolicy
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewPostgresProvider(pool *pgxpool.Pool, policy StatusPolicy) (*PostgresProvider, error) {
|
||||
if pool == nil {
|
||||
return nil, errors.New("service status provider requires a database pool")
|
||||
}
|
||||
policy = policy.withDefaults()
|
||||
if err := policy.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PostgresProvider{Pool: pool, Policy: policy}, nil
|
||||
}
|
||||
|
||||
func (p *PostgresProvider) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if p == nil || p.Pool == nil {
|
||||
return Snapshot{}, errors.New("service status provider is unavailable")
|
||||
}
|
||||
if ctx == nil {
|
||||
return Snapshot{}, errors.New("service status context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
policy := p.Policy.withDefaults()
|
||||
if err := policy.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if p.Now != nil {
|
||||
now = p.Now().UTC()
|
||||
}
|
||||
rows, err := p.Pool.Query(ctx, `
|
||||
WITH bounded_services AS (
|
||||
SELECT id, entity_id, source_id, name, description, revision, archived_at, created_at, updated_at
|
||||
FROM services
|
||||
WHERE archived_at IS NULL
|
||||
ORDER BY id ASC
|
||||
LIMIT $1
|
||||
), bounded_results AS (
|
||||
SELECT s.id::text AS service_id, s.entity_id::text AS entity_id, s.source_id::text AS source_id,
|
||||
s.name, s.description, s.revision, s.archived_at, s.created_at, s.updated_at,
|
||||
COALESCE((SELECT jsonb_agg(jsonb_build_object('id', pc.id::text, 'name', pc.name, 'type', pc.probe_type, 'intervalSeconds', pc.interval_seconds, 'timeoutSeconds', pc.timeout_seconds, 'enabled', pc.enabled, 'followRedirects', pc.follow_redirects, 'verifyTls', pc.verify_tls, 'revision', pc.revision) ORDER BY pc.id) FROM probes pc WHERE pc.service_id = s.id AND pc.archived_at IS NULL), '[]'::jsonb) AS probe_configs,
|
||||
p.id::text AS probe_id, pr.id::text AS result_id, pr.observed_at, pr.completed_at,
|
||||
pr.state, pr.response_time_ms, pr.status_code, pr.error_class, pr.error_message, pr.attributes,
|
||||
certificate.id::text AS certificate_id, certificate.service_id::text AS certificate_service_id,
|
||||
certificate.endpoint_id::text AS certificate_endpoint_id, certificate.observed_at AS certificate_observed_at,
|
||||
certificate.expires_at AS certificate_expires_at, certificate.issuer AS certificate_issuer,
|
||||
certificate.subject AS certificate_subject, certificate.hostname_valid AS certificate_hostname_valid,
|
||||
certificate.verification_state AS certificate_verification_state,
|
||||
row_number() OVER (PARTITION BY s.id ORDER BY pr.observed_at DESC NULLS LAST, p.id ASC, pr.id ASC) AS result_rank
|
||||
FROM bounded_services s
|
||||
LEFT JOIN probes p ON p.service_id = s.id AND p.archived_at IS NULL
|
||||
LEFT JOIN probe_results pr ON pr.probe_id = p.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT sc.id, sc.service_id, sc.endpoint_id, sc.observed_at, sc.expires_at,
|
||||
sc.issuer, sc.subject, sc.hostname_valid, sc.verification_state
|
||||
FROM service_certificates sc
|
||||
WHERE sc.service_id = s.id
|
||||
ORDER BY sc.observed_at DESC, sc.id ASC
|
||||
LIMIT 1
|
||||
) certificate ON true
|
||||
)
|
||||
SELECT service_id, COALESCE(entity_id, ''), COALESCE(source_id, ''), name, description, revision, archived_at, created_at, updated_at, probe_configs,
|
||||
probe_id, result_id, observed_at, completed_at, state, response_time_ms, status_code, error_class, error_message, attributes,
|
||||
certificate_id, certificate_service_id, certificate_endpoint_id, certificate_observed_at,
|
||||
certificate_expires_at, certificate_issuer, certificate_subject, certificate_hostname_valid,
|
||||
certificate_verification_state
|
||||
FROM bounded_results
|
||||
WHERE result_rank <= $2 OR result_rank IS NULL
|
||||
ORDER BY service_id ASC, observed_at DESC NULLS LAST, probe_id ASC NULLS LAST, result_id ASC NULLS LAST`, policy.MaxServices, policy.MaxHistory)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("query service status: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
inputs := make([]ServiceInput, 0, policy.MaxServices)
|
||||
byID := make(map[string]int, policy.MaxServices)
|
||||
for rows.Next() {
|
||||
var serviceID, entityID, sourceID, name, description string
|
||||
var revision int64
|
||||
var archivedAt, createdAt, updatedAt *time.Time
|
||||
var probeID, resultID *string
|
||||
var observedAt, completedAt *time.Time
|
||||
var state, errorClass, errorMessage *string
|
||||
var responseTimeMS, statusCode *int
|
||||
var certificateID, certificateServiceID, certificateEndpointID *string
|
||||
var certificateObservedAt, certificateExpiresAt *time.Time
|
||||
var certificateIssuer, certificateSubject, certificateVerificationState *string
|
||||
var certificateHostnameValid *bool
|
||||
var attributes []byte
|
||||
var probeConfigs []byte
|
||||
if err := rows.Scan(&serviceID, &entityID, &sourceID, &name, &description, &revision, &archivedAt, &createdAt, &updatedAt, &probeConfigs, &probeID, &resultID, &observedAt, &completedAt, &state, &responseTimeMS, &statusCode, &errorClass, &errorMessage, &attributes, &certificateID, &certificateServiceID, &certificateEndpointID, &certificateObservedAt, &certificateExpiresAt, &certificateIssuer, &certificateSubject, &certificateHostnameValid, &certificateVerificationState); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("scan service status: %w", err)
|
||||
}
|
||||
index, exists := byID[serviceID]
|
||||
if !exists {
|
||||
item := ServiceInput{Service: Service{ID: serviceID, EntityID: entityID, SourceID: sourceID, Name: name, Description: description, State: StateUnknown, Revision: revision, ArchivedAt: archivedAt, CreatedAt: timeValue(createdAt), UpdatedAt: timeValue(updatedAt)}}
|
||||
inputs = append(inputs, item)
|
||||
index = len(inputs) - 1
|
||||
byID[serviceID] = index
|
||||
if len(probeConfigs) > 0 {
|
||||
if err := json.Unmarshal(probeConfigs, &inputs[index].ProbeConfigs); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("decode service probe configuration: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
input := &inputs[index]
|
||||
if input.LatestCertificate == nil && certificateID != nil && certificateServiceID != nil && certificateObservedAt != nil && certificateVerificationState != nil {
|
||||
input.LatestCertificate = &probe.Certificate{
|
||||
ID: *certificateID,
|
||||
ServiceID: *certificateServiceID,
|
||||
EndpointID: stringValue(certificateEndpointID),
|
||||
ObservedAt: certificateObservedAt.UTC(),
|
||||
ExpiresAt: certificateExpiresAt,
|
||||
Issuer: stringValue(certificateIssuer),
|
||||
Subject: stringValue(certificateSubject),
|
||||
HostnameValid: certificateHostnameValid,
|
||||
VerificationState: *certificateVerificationState,
|
||||
}
|
||||
}
|
||||
if probeID == nil || resultID == nil || observedAt == nil || state == nil {
|
||||
continue
|
||||
}
|
||||
result := probe.Result{ID: *resultID, ProbeID: *probeID, ObservedAt: observedAt.UTC(), CompletedAt: timeValue(completedAt).UTC(), State: *state, ResponseTimeMS: responseTimeMS, StatusCode: statusCode}
|
||||
if errorClass != nil {
|
||||
result.ErrorClass = *errorClass
|
||||
}
|
||||
if errorMessage != nil {
|
||||
result.ErrorMessage = *errorMessage
|
||||
}
|
||||
if len(attributes) > 0 {
|
||||
if err := json.Unmarshal(attributes, &result.Attributes); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("decode service result attributes: %w", err)
|
||||
}
|
||||
}
|
||||
appendProbeResult(input, *probeID, result)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("read service status rows: %w", err)
|
||||
}
|
||||
return BuildSnapshot(now, inputs, policy)
|
||||
}
|
||||
|
||||
func stringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func appendProbeResult(input *ServiceInput, probeID string, result probe.Result) {
|
||||
for index := range input.Probes {
|
||||
if input.Probes[index].ProbeID == probeID {
|
||||
input.Probes[index].Results = append(input.Probes[index].Results, result)
|
||||
return
|
||||
}
|
||||
}
|
||||
input.Probes = append(input.Probes, ProbeHistory{ProbeID: probeID, Results: []probe.Result{result}})
|
||||
}
|
||||
|
||||
func timeValue(value *time.Time) time.Time {
|
||||
if value == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
Reference in New Issue
Block a user