674 lines
24 KiB
Go
674 lines
24 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/stdlib"
|
|
|
|
"yovision/sense/internal/device"
|
|
)
|
|
|
|
// Postgres persists Sense state in the sense schema and consumes only Bell's
|
|
// versioned quota and Area views. Migrations are installed out of process.
|
|
type Postgres struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func OpenPostgres(ctx context.Context, dsn string) (*Postgres, error) {
|
|
configuration, err := pgx.ParseConfig(dsn)
|
|
if err != nil {
|
|
return nil, errors.New("invalid postgres DSN")
|
|
}
|
|
if configuration.RuntimeParams == nil {
|
|
configuration.RuntimeParams = make(map[string]string)
|
|
}
|
|
configuration.RuntimeParams["application_name"] = "yovision-sense"
|
|
db := stdlib.OpenDB(*configuration)
|
|
db.SetMaxOpenConns(16)
|
|
db.SetMaxIdleConns(4)
|
|
db.SetConnMaxLifetime(30 * time.Minute)
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, errors.New("connect postgres database")
|
|
}
|
|
store := &Postgres{db: db}
|
|
if err := store.verifySchemaAndPrivileges(ctx); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
func (s *Postgres) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
func (s *Postgres) verifySchemaAndPrivileges(ctx context.Context) error {
|
|
var version sql.NullInt64
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 3 {
|
|
return errors.New("postgres sense schema migration v3 is required")
|
|
}
|
|
var canReadQuotaView, canWriteQuotaView, canReadSiteSource, canWriteSiteSource bool
|
|
var canReadAreaView, canWriteAreaView, canReadAreaSource, canWriteAreaSource bool
|
|
if err := s.db.QueryRowContext(ctx, `SELECT
|
|
has_table_privilege(current_user, 'bell.site_quota_v1', 'SELECT'),
|
|
has_table_privilege(current_user, 'bell.site_quota_v1', 'INSERT,UPDATE,DELETE'),
|
|
has_table_privilege(current_user, 'bell.sites', 'SELECT'),
|
|
has_table_privilege(current_user, 'bell.sites', 'INSERT,UPDATE,DELETE'),
|
|
has_table_privilege(current_user, 'bell.area_policy_v1', 'SELECT'),
|
|
has_table_privilege(current_user, 'bell.area_policy_v1', 'INSERT,UPDATE,DELETE'),
|
|
has_table_privilege(current_user, 'bell.areas', 'SELECT'),
|
|
has_table_privilege(current_user, 'bell.areas', 'INSERT,UPDATE,DELETE')`).
|
|
Scan(
|
|
&canReadQuotaView, &canWriteQuotaView, &canReadSiteSource, &canWriteSiteSource,
|
|
&canReadAreaView, &canWriteAreaView, &canReadAreaSource, &canWriteAreaSource,
|
|
); err != nil {
|
|
return errors.New("verify postgres Bell projection privileges")
|
|
}
|
|
if !canReadQuotaView || canWriteQuotaView || canReadSiteSource || canWriteSiteSource ||
|
|
!canReadAreaView || canWriteAreaView || canReadAreaSource || canWriteAreaSource {
|
|
return errors.New("postgres role violates Bell projection privilege boundary")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error {
|
|
if value.Generation == 0 {
|
|
value.Generation = 1
|
|
}
|
|
if value.ActualState == "" {
|
|
value.ActualState = device.ActualPending
|
|
}
|
|
if err := value.Validate(); err != nil {
|
|
return fmt.Errorf("validate device: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
if value.CreatedAt.IsZero() {
|
|
value.CreatedAt = now
|
|
}
|
|
value.UpdatedAt = now
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return errors.New("begin postgres create device")
|
|
}
|
|
defer tx.Rollback()
|
|
areaVersion, err := checkPostgresAreaPolicy(
|
|
ctx, tx, value.TenantID, value.SiteID, value.AreaID,
|
|
value.HasCapability(device.CapabilityVideoCapture), now,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var quotaVersion int64
|
|
if value.ConsumesVideoChannel() {
|
|
version, quotaErr := checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now)
|
|
if quotaErr != nil {
|
|
return quotaErr
|
|
}
|
|
quotaVersion = version
|
|
}
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
|
|
id, tenant_id, site_id, area_id, serial_number, name, modality,
|
|
desired_state, actual_state, endpoint_ref, credential_ref,
|
|
path_name, generation, quota_source_version, area_policy_source_version,
|
|
created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
|
value.ID, value.TenantID, value.SiteID, value.AreaID, value.SerialNumber, value.Name,
|
|
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
|
|
value.CredentialRef, value.PathName, value.Generation, nullableVersion(quotaVersion),
|
|
areaVersion, value.CreatedAt, value.UpdatedAt)
|
|
if err != nil {
|
|
return errors.New("insert postgres device")
|
|
}
|
|
for _, capability := range sortedCapabilities(value.Capabilities) {
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO sense.device_capabilities(device_id, capability) VALUES ($1, $2)`,
|
|
value.ID, capability); err != nil {
|
|
return errors.New("insert postgres device capability")
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.reconcile_state(device_id, updated_at)
|
|
VALUES ($1, $2)`, value.ID, now); err != nil {
|
|
return errors.New("insert postgres reconcile state")
|
|
}
|
|
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
|
EventType: "device.created", TenantID: value.TenantID, SiteID: value.SiteID,
|
|
DeviceID: value.ID, Generation: value.Generation,
|
|
QuotaSourceVersion: quotaVersion, AreaPolicySourceVersion: areaVersion,
|
|
OccurredAt: now,
|
|
Payload: map[string]any{
|
|
"kind": "device_created", "area_id": value.AreaID,
|
|
"modality": value.Modality, "capabilities": sortedCapabilities(value.Capabilities),
|
|
"desired_state": value.DesiredState,
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return errors.New("commit postgres create device")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkPostgresAreaPolicy(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
tenantID, siteID, areaID string,
|
|
imaging bool,
|
|
now time.Time,
|
|
) (int64, error) {
|
|
if strings.TrimSpace(areaID) == "" {
|
|
return 0, areaPolicyUnavailable()
|
|
}
|
|
// Area projection observation is serialized before the Site quota lock.
|
|
// No admission path acquires these locks in the opposite order.
|
|
if _, err := tx.ExecContext(ctx,
|
|
`SELECT pg_advisory_xact_lock(hashtext($1), hashtext('area:' || $2))`, tenantID, areaID); err != nil {
|
|
return 0, errors.New("lock postgres Area admission")
|
|
}
|
|
var capturePolicy string
|
|
var sourceVersion int64
|
|
var sourceUpdatedAt time.Time
|
|
err := tx.QueryRowContext(ctx, `SELECT capture_policy, source_version, source_updated_at
|
|
FROM bell.area_policy_v1
|
|
WHERE tenant_id = $1 AND site_id = $2 AND area_id = $3`, tenantID, siteID, areaID).
|
|
Scan(&capturePolicy, &sourceVersion, &sourceUpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, areaPolicyUnavailable()
|
|
}
|
|
if err != nil {
|
|
return 0, areaPolicyUnavailable()
|
|
}
|
|
if (capturePolicy != "video_allowed" && capturePolicy != "non_imaging_only") ||
|
|
sourceVersion < 1 || sourceUpdatedAt.IsZero() {
|
|
return 0, areaPolicyInvalid()
|
|
}
|
|
var previous sql.NullInt64
|
|
err = tx.QueryRowContext(ctx, `SELECT source_version
|
|
FROM sense.area_policy_projection_state
|
|
WHERE tenant_id = $1 AND site_id = $2 AND area_id = $3`, tenantID, siteID, areaID).
|
|
Scan(&previous)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return 0, errors.New("read postgres Area projection state")
|
|
}
|
|
if previous.Valid && sourceVersion < previous.Int64 {
|
|
return 0, areaPolicyInvalid()
|
|
}
|
|
if imaging && capturePolicy == "non_imaging_only" {
|
|
return 0, areaPolicyDenied()
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.area_policy_projection_state(
|
|
tenant_id, site_id, area_id, source_version, synced_at
|
|
) VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (tenant_id, site_id, area_id) DO UPDATE SET
|
|
source_version = EXCLUDED.source_version,
|
|
synced_at = EXCLUDED.synced_at`, tenantID, siteID, areaID, sourceVersion, now); err != nil {
|
|
return 0, errors.New("record postgres Area projection state")
|
|
}
|
|
return sourceVersion, nil
|
|
}
|
|
|
|
func checkPostgresVideoQuota(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
tenantID, siteID string,
|
|
now time.Time,
|
|
) (int64, error) {
|
|
// A transaction-scoped lock shared by all Sense instances makes count +
|
|
// write atomic per logical site without locking Bell-owned rows.
|
|
if _, err := tx.ExecContext(ctx,
|
|
`SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, tenantID, siteID); err != nil {
|
|
return 0, errors.New("lock postgres site quota admission")
|
|
}
|
|
var limit int
|
|
var sourceVersion int64
|
|
var sourceUpdatedAt time.Time
|
|
err := tx.QueryRowContext(ctx, `SELECT max_video_channels, source_version, source_updated_at
|
|
FROM bell.site_quota_v1 WHERE tenant_id = $1 AND site_id = $2`, tenantID, siteID).
|
|
Scan(&limit, &sourceVersion, &sourceUpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, projectionUnavailable()
|
|
}
|
|
if err != nil {
|
|
return 0, projectionUnavailable()
|
|
}
|
|
if limit < 1 || limit > device.MaximumVideoChannels || sourceVersion < 1 || sourceUpdatedAt.IsZero() {
|
|
return 0, projectionInvalid()
|
|
}
|
|
var previous sql.NullInt64
|
|
err = tx.QueryRowContext(ctx, `SELECT source_version
|
|
FROM sense.site_quota_projection_state WHERE tenant_id = $1 AND site_id = $2`,
|
|
tenantID, siteID).Scan(&previous)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return 0, errors.New("read postgres quota projection state")
|
|
}
|
|
if previous.Valid && sourceVersion < previous.Int64 {
|
|
return 0, projectionInvalid()
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.site_quota_projection_state(
|
|
tenant_id, site_id, source_version, synced_at
|
|
) VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (tenant_id, site_id) DO UPDATE SET
|
|
source_version = EXCLUDED.source_version,
|
|
synced_at = EXCLUDED.synced_at`, tenantID, siteID, sourceVersion, now); err != nil {
|
|
return 0, errors.New("record postgres quota projection state")
|
|
}
|
|
var current int
|
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*)
|
|
FROM sense.devices d
|
|
JOIN sense.device_capabilities c ON c.device_id = d.id
|
|
WHERE d.tenant_id = $1 AND d.site_id = $2
|
|
AND d.desired_state = 'enabled'
|
|
AND c.capability = 'video_capture'`, tenantID, siteID).Scan(¤t); err != nil {
|
|
return 0, errors.New("count postgres site video channels")
|
|
}
|
|
if current >= limit {
|
|
return 0, &device.QuotaExceededError{TenantID: tenantID, SiteID: siteID, Limit: limit}
|
|
}
|
|
return sourceVersion, nil
|
|
}
|
|
|
|
func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired device.DesiredState) error {
|
|
if desired != device.DesiredEnabled && desired != device.DesiredDisabled {
|
|
return fmt.Errorf("invalid desired state %q", desired)
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return errors.New("begin postgres desired-state update")
|
|
}
|
|
defer tx.Rollback()
|
|
var tenantID, siteID, endpointRef, pathName string
|
|
var areaID sql.NullString
|
|
var current device.DesiredState
|
|
var generation int64
|
|
var storedQuotaVersion, storedAreaVersion sql.NullInt64
|
|
err = tx.QueryRowContext(ctx, `SELECT tenant_id, site_id, area_id, desired_state,
|
|
endpoint_ref, path_name, generation, quota_source_version, area_policy_source_version
|
|
FROM sense.devices WHERE id = $1 FOR UPDATE`, id).
|
|
Scan(
|
|
&tenantID, &siteID, &areaID, ¤t, &endpointRef, &pathName,
|
|
&generation, &storedQuotaVersion, &storedAreaVersion,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return errors.New("read postgres device desired state")
|
|
}
|
|
if current == desired {
|
|
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
|
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
|
|
DeviceID: id, Generation: generation,
|
|
QuotaSourceVersion: storedQuotaVersion.Int64,
|
|
AreaPolicySourceVersion: storedAreaVersion.Int64,
|
|
OccurredAt: time.Now().UTC(),
|
|
Payload: map[string]any{
|
|
"kind": "desired_state_accepted", "previous_desired_state": current,
|
|
"desired_state": desired, "changed": false,
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return errors.New("commit postgres no-op desired-state audit")
|
|
}
|
|
return nil
|
|
}
|
|
var quotaVersion, areaVersion int64
|
|
if desired == device.DesiredEnabled {
|
|
var hasVideo bool
|
|
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(
|
|
SELECT 1 FROM sense.device_capabilities
|
|
WHERE device_id = $1 AND capability = 'video_capture'
|
|
)`, id).Scan(&hasVideo); err != nil {
|
|
return errors.New("read postgres video capability")
|
|
}
|
|
if hasVideo {
|
|
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
|
|
return errors.New("enabled video devices require endpoint ref and path name")
|
|
}
|
|
version, areaErr := checkPostgresAreaPolicy(
|
|
ctx, tx, tenantID, siteID, areaID.String, true, time.Now().UTC(),
|
|
)
|
|
if areaErr != nil {
|
|
return areaErr
|
|
}
|
|
areaVersion = version
|
|
version, quotaErr := checkPostgresVideoQuota(ctx, tx, tenantID, siteID, time.Now().UTC())
|
|
if quotaErr != nil {
|
|
return quotaErr
|
|
}
|
|
quotaVersion = version
|
|
}
|
|
}
|
|
now := time.Now().UTC()
|
|
var updatedQuotaVersion, updatedAreaVersion sql.NullInt64
|
|
err = tx.QueryRowContext(ctx, `UPDATE sense.devices SET
|
|
desired_state = $1, actual_state = 'pending', generation = generation + 1,
|
|
quota_source_version = COALESCE($2, quota_source_version),
|
|
area_policy_source_version = COALESCE($3, area_policy_source_version),
|
|
updated_at = $4
|
|
WHERE id = $5
|
|
RETURNING generation, quota_source_version, area_policy_source_version`,
|
|
desired, nullableVersion(quotaVersion), nullableVersion(areaVersion), now, id).
|
|
Scan(&generation, &updatedQuotaVersion, &updatedAreaVersion)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return errors.New("update postgres desired state")
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
|
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL, updated_at = $1
|
|
WHERE device_id = $2`, now, id); err != nil {
|
|
return errors.New("reset postgres reconcile state")
|
|
}
|
|
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
|
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
|
|
DeviceID: id, Generation: generation,
|
|
QuotaSourceVersion: updatedQuotaVersion.Int64,
|
|
AreaPolicySourceVersion: updatedAreaVersion.Int64,
|
|
OccurredAt: now,
|
|
Payload: map[string]any{
|
|
"kind": "desired_state_accepted", "previous_desired_state": current,
|
|
"desired_state": desired, "changed": true,
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return errors.New("commit postgres desired-state update")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Postgres) GetDevice(ctx context.Context, id string) (device.Device, error) {
|
|
value, err := scanPostgresDevice(s.db.QueryRowContext(ctx, postgresDeviceSelect+` WHERE d.id = $1`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return device.Device{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return device.Device{}, errors.New("get postgres device")
|
|
}
|
|
value.Capabilities, err = s.capabilities(ctx, value.ID)
|
|
if err != nil {
|
|
return device.Device{}, err
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]ReconcileCandidate, error) {
|
|
if limit <= 0 {
|
|
return nil, nil
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, r.failure_count, r.next_attempt_at
|
|
FROM sense.devices d
|
|
JOIN sense.reconcile_state r ON r.device_id = d.id
|
|
WHERE d.desired_state = 'enabled'
|
|
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
|
|
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
|
AND (r.observed_generation < d.generation OR r.failure_count > 0)
|
|
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= $1)
|
|
ORDER BY d.updated_at, d.id LIMIT $2`, now, limit)
|
|
if err != nil {
|
|
return nil, errors.New("list postgres due reconcile devices")
|
|
}
|
|
defer rows.Close()
|
|
values := make([]ReconcileCandidate, 0)
|
|
for rows.Next() {
|
|
var candidate ReconcileCandidate
|
|
var areaID sql.NullString
|
|
var quotaVersion, areaVersion sql.NullInt64
|
|
var nextAttempt sql.NullTime
|
|
if err := rows.Scan(
|
|
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
|
|
&areaID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
|
&candidate.Device.DesiredState, &candidate.Device.ActualState,
|
|
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
|
|
&candidate.Device.PathName, &candidate.Device.Generation,
|
|
"aVersion, &areaVersion,
|
|
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
|
|
&candidate.FailureCount, &nextAttempt,
|
|
); err != nil {
|
|
return nil, errors.New("scan postgres due reconcile device")
|
|
}
|
|
candidate.Device.AreaID = areaID.String
|
|
candidate.Device.QuotaSourceVersion = quotaVersion.Int64
|
|
candidate.Device.AreaPolicySourceVersion = areaVersion.Int64
|
|
if nextAttempt.Valid {
|
|
value := nextAttempt.Time
|
|
candidate.NextAttempt = &value
|
|
}
|
|
values = append(values, candidate)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, errors.New("iterate postgres due reconcile devices")
|
|
}
|
|
for index := range values {
|
|
values[index].Device.Capabilities, err = s.capabilities(ctx, values[index].Device.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func (s *Postgres) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
|
|
if limit <= 0 {
|
|
return nil, nil
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, postgresDeviceSelect+`
|
|
WHERE d.desired_state = 'enabled'
|
|
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
|
|
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
|
ORDER BY d.id LIMIT $1`, limit)
|
|
if err != nil {
|
|
return nil, errors.New("list postgres enabled video devices")
|
|
}
|
|
defer rows.Close()
|
|
values := make([]device.Device, 0)
|
|
for rows.Next() {
|
|
value, scanErr := scanPostgresDevice(rows)
|
|
if scanErr != nil {
|
|
return nil, errors.New("scan postgres enabled video device")
|
|
}
|
|
values = append(values, value)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, errors.New("iterate postgres enabled video devices")
|
|
}
|
|
for index := range values {
|
|
values[index].Capabilities, err = s.capabilities(ctx, values[index].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func (s *Postgres) MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return errors.New("begin postgres reconciled update")
|
|
}
|
|
defer tx.Rollback()
|
|
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
|
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
|
observed_generation = $1, updated_at = $2 WHERE device_id = $3`, generation, now, id)
|
|
if err != nil {
|
|
return errors.New("mark postgres device reconciled")
|
|
}
|
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
|
return ErrNotFound
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE sense.devices
|
|
SET actual_state = 'pending', updated_at = $1 WHERE id = $2`, now, id); err != nil {
|
|
return errors.New("mark postgres reconciled device pending")
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return errors.New("commit postgres reconciled update")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Postgres) MarkReconcileFailure(
|
|
ctx context.Context,
|
|
id string,
|
|
failureCount int,
|
|
nextAttempt time.Time,
|
|
errorCode string,
|
|
now time.Time,
|
|
) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return errors.New("begin postgres reconcile failure update")
|
|
}
|
|
defer tx.Rollback()
|
|
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
|
failure_count = $1, next_attempt_at = $2, last_error_code = $3, updated_at = $4
|
|
WHERE device_id = $5`, failureCount, nextAttempt, errorCode, now, id)
|
|
if err != nil {
|
|
return errors.New("mark postgres reconcile failure")
|
|
}
|
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
|
return ErrNotFound
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE sense.devices
|
|
SET actual_state = 'failed', updated_at = $1 WHERE id = $2`, now, id); err != nil {
|
|
return errors.New("mark postgres failed device state")
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return errors.New("commit postgres reconcile failure")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Postgres) UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error {
|
|
if state != device.ActualPending && state != device.ActualOnline &&
|
|
state != device.ActualOffline && state != device.ActualFailed {
|
|
return fmt.Errorf("invalid actual state %q", state)
|
|
}
|
|
result, err := s.db.ExecContext(ctx, `UPDATE sense.devices
|
|
SET actual_state = $1, updated_at = $2 WHERE id = $3`, state, now, id)
|
|
if err != nil {
|
|
return errors.New("update postgres actual state")
|
|
}
|
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Postgres) RequestReconcile(ctx context.Context, id string, now time.Time) error {
|
|
result, err := s.db.ExecContext(ctx, `UPDATE sense.reconcile_state
|
|
SET observed_generation = 0, updated_at = $1 WHERE device_id = $2`, now, id)
|
|
if err != nil {
|
|
return errors.New("request postgres device reconciliation")
|
|
}
|
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Postgres) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT
|
|
d.id, d.path_name, d.desired_state, d.actual_state, d.generation,
|
|
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code
|
|
FROM sense.devices d
|
|
JOIN sense.reconcile_state r ON r.device_id = d.id
|
|
WHERE d.desired_state = 'enabled'
|
|
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
|
|
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
|
ORDER BY d.id`)
|
|
if err != nil {
|
|
return ConvergenceSnapshot{}, errors.New("query postgres convergence snapshot")
|
|
}
|
|
defer rows.Close()
|
|
snapshot := ConvergenceSnapshot{Devices: make([]DeviceConvergence, 0)}
|
|
for rows.Next() {
|
|
var value DeviceConvergence
|
|
var nextAttempt sql.NullTime
|
|
var lastError sql.NullString
|
|
if err := rows.Scan(
|
|
&value.ID, &value.PathName, &value.DesiredState, &value.ActualState,
|
|
&value.Generation, &value.ObservedGeneration, &value.FailureCount,
|
|
&nextAttempt, &lastError,
|
|
); err != nil {
|
|
return ConvergenceSnapshot{}, errors.New("scan postgres convergence snapshot")
|
|
}
|
|
if nextAttempt.Valid {
|
|
point := nextAttempt.Time
|
|
value.NextAttemptAt = &point
|
|
}
|
|
if lastError.Valid {
|
|
value.LastErrorCode = lastError.String
|
|
}
|
|
value.Converged = value.ObservedGeneration == value.Generation &&
|
|
value.FailureCount == 0 && value.ActualState == device.ActualOnline
|
|
if !value.Converged {
|
|
snapshot.Unconverged++
|
|
}
|
|
snapshot.Devices = append(snapshot.Devices, value)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return ConvergenceSnapshot{}, errors.New("iterate postgres convergence snapshot")
|
|
}
|
|
snapshot.Total = len(snapshot.Devices)
|
|
return snapshot, nil
|
|
}
|
|
|
|
const postgresDeviceColumns = `d.id, d.tenant_id, d.site_id, d.area_id, d.serial_number, d.name, d.modality,
|
|
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref,
|
|
d.path_name, d.generation, d.quota_source_version, d.area_policy_source_version,
|
|
d.created_at, d.updated_at`
|
|
|
|
const postgresDeviceSelect = `SELECT ` + postgresDeviceColumns + ` FROM sense.devices d`
|
|
|
|
func scanPostgresDevice(row scanner) (device.Device, error) {
|
|
var value device.Device
|
|
var areaID sql.NullString
|
|
var quotaVersion, areaVersion sql.NullInt64
|
|
err := row.Scan(
|
|
&value.ID, &value.TenantID, &value.SiteID, &areaID, &value.SerialNumber,
|
|
&value.Name, &value.Modality, &value.DesiredState, &value.ActualState,
|
|
&value.EndpointRef, &value.CredentialRef, &value.PathName,
|
|
&value.Generation, "aVersion, &areaVersion, &value.CreatedAt, &value.UpdatedAt,
|
|
)
|
|
value.AreaID = areaID.String
|
|
value.QuotaSourceVersion = quotaVersion.Int64
|
|
value.AreaPolicySourceVersion = areaVersion.Int64
|
|
return value, err
|
|
}
|
|
|
|
func (s *Postgres) capabilities(ctx context.Context, id string) ([]device.Capability, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT capability
|
|
FROM sense.device_capabilities WHERE device_id = $1 ORDER BY capability`, id)
|
|
if err != nil {
|
|
return nil, errors.New("list postgres device capabilities")
|
|
}
|
|
defer rows.Close()
|
|
values := make([]device.Capability, 0)
|
|
for rows.Next() {
|
|
var value device.Capability
|
|
if err := rows.Scan(&value); err != nil {
|
|
return nil, errors.New("scan postgres device capability")
|
|
}
|
|
values = append(values, value)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, errors.New("iterate postgres device capabilities")
|
|
}
|
|
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
|
|
return values, nil
|
|
}
|