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() }