chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestRepositoryPullRedeliverAcknowledgeCompleteAndExpireCommands(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
repository := NewRepository(pool)
|
||||
identity := deviceauth.Identity{
|
||||
ID: "10000000-0000-0000-0000-000000000501", PublicID: "10000000-0000-0000-0000-000000000511",
|
||||
TenantID: "10000000-0000-0000-0000-000000000001", Status: "registered",
|
||||
}
|
||||
successID, successPublicID := uuid.NewString(), uuid.NewString()
|
||||
failureID, failurePublicID := uuid.NewString(), uuid.NewString()
|
||||
expiredID, expiredPublicID := uuid.NewString(), uuid.NewString()
|
||||
keyPrefix := "device-command-core-" + uuid.NewString()
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_command WHERE id=ANY($1::uuid[])`, []string{successID, failureID, expiredID})
|
||||
}()
|
||||
insert := func(id, publicID, suffix, commandType, expires string) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO device_command(id,public_id,tenant_id,device_id,command_type,payload,status,requested_by,idempotency_key,request_digest,expires_at)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5,$6::jsonb,'QUEUED','10000000-0000-0000-0000-000000000101'::uuid,$7,repeat('d',64),`+expires+`)`,
|
||||
id, publicID, identity.TenantID, identity.ID, commandType, `{"fixture":"`+suffix+`"}`, keyPrefix+"-"+suffix); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
insert(successID, successPublicID, "success", "apply_config", "now()+interval '1 hour'")
|
||||
insert(failureID, failurePublicID, "failure", "diagnostics", "now()+interval '1 hour'")
|
||||
insert(expiredID, expiredPublicID, "expired", "reboot", "now()-interval '1 second'")
|
||||
|
||||
items, err := repository.PullCommands(ctx, identity, 0)
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("first pull items=%+v err=%v", items, err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Status != "DELIVERED" || item.DeliveryAttempts != 1 {
|
||||
t.Fatalf("first delivery item=%+v", item)
|
||||
}
|
||||
}
|
||||
items, err = repository.PullCommands(ctx, identity, 99)
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("redelivery items=%+v err=%v", items, err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Status != "DELIVERED" || item.DeliveryAttempts != 2 {
|
||||
t.Fatalf("redelivery item=%+v", item)
|
||||
}
|
||||
}
|
||||
var expiredStatus string
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM device_command WHERE id=$1::uuid`, expiredID).Scan(&expiredStatus); err != nil || expiredStatus != "EXPIRED" {
|
||||
t.Fatalf("expired status=%s err=%v", expiredStatus, err)
|
||||
}
|
||||
|
||||
acknowledged, err := repository.AcknowledgeCommand(ctx, identity, successPublicID, CommandAcknowledgement{Status: "ACKNOWLEDGED", ResultSummary: map[string]any{"result_code": "RECEIVED"}})
|
||||
if err != nil || acknowledged.Status != "ACKNOWLEDGED" || acknowledged.DeliveryAttempts != 2 {
|
||||
t.Fatalf("acknowledged=%+v err=%v", acknowledged, err)
|
||||
}
|
||||
repeatedAck, err := repository.AcknowledgeCommand(ctx, identity, successPublicID, CommandAcknowledgement{Status: "ACKNOWLEDGED", ResultSummary: map[string]any{"result_code": "RECEIVED"}})
|
||||
if err != nil || repeatedAck.Status != "ACKNOWLEDGED" {
|
||||
t.Fatalf("repeated acknowledgement=%+v err=%v", repeatedAck, err)
|
||||
}
|
||||
items, err = repository.PullCommands(ctx, identity, 10)
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("in-flight recovery pull items=%+v err=%v", items, err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.DeliveryAttempts != 3 {
|
||||
t.Fatalf("in-flight recovery attempts item=%+v", item)
|
||||
}
|
||||
if item.ID == successPublicID && item.Status != "ACKNOWLEDGED" {
|
||||
t.Fatalf("acknowledged command regressed during recovery pull: %+v", item)
|
||||
}
|
||||
if item.ID == failurePublicID && item.Status != "DELIVERED" {
|
||||
t.Fatalf("delivered command changed unexpectedly during recovery pull: %+v", item)
|
||||
}
|
||||
}
|
||||
completed, err := repository.AcknowledgeCommand(ctx, identity, successPublicID, CommandAcknowledgement{Status: "SUCCEEDED", ResultSummary: map[string]any{"result_code": "APPLIED"}})
|
||||
if err != nil || completed.Status != "SUCCEEDED" {
|
||||
t.Fatalf("completed=%+v err=%v", completed, err)
|
||||
}
|
||||
failed, err := repository.AcknowledgeCommand(ctx, identity, failurePublicID, CommandAcknowledgement{Status: "FAILED", ResultSummary: map[string]any{"result_code": "DIAGNOSTIC_FAILED"}})
|
||||
if err != nil || failed.Status != "FAILED" {
|
||||
t.Fatalf("failed=%+v err=%v", failed, err)
|
||||
}
|
||||
if _, err := repository.AcknowledgeCommand(ctx, identity, successPublicID, CommandAcknowledgement{Status: "FAILED", ResultSummary: map[string]any{}}); err == nil {
|
||||
t.Fatal("terminal command changed from SUCCEEDED to FAILED")
|
||||
}
|
||||
wrongDevice := identity
|
||||
wrongDevice.ID = uuid.NewString()
|
||||
if _, err := repository.AcknowledgeCommand(ctx, wrongDevice, failurePublicID, CommandAcknowledgement{Status: "FAILED", ResultSummary: map[string]any{}}); err == nil {
|
||||
t.Fatal("different device acknowledged command")
|
||||
}
|
||||
if _, err := repository.AcknowledgeCommand(ctx, identity, failurePublicID, CommandAcknowledgement{Status: "FAILED", ResultSummary: map[string]any{"invalid": make(chan int)}}); err == nil {
|
||||
t.Fatal("unencodable result summary accepted")
|
||||
}
|
||||
var successResult, failureResult string
|
||||
if err := pool.QueryRow(ctx, `SELECT result_summary->>'result_code' FROM device_command WHERE id=$1::uuid`, successID).Scan(&successResult); err != nil || successResult != "APPLIED" {
|
||||
t.Fatalf("success result=%q err=%v", successResult, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT result_summary->>'result_code' FROM device_command WHERE id=$1::uuid`, failureID).Scan(&failureResult); err != nil || failureResult != "DIAGNOSTIC_FAILED" {
|
||||
t.Fatalf("failure result=%q err=%v", failureResult, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func recordFirmwareAssignmentEvent(ctx context.Context, tx pgx.Tx, commandID, commandStatus, assignmentStatus string, progress *int, resultJSON []byte) error {
|
||||
digest := sha256.Sum256(resultJSON)
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO firmware_assignment_event(
|
||||
assignment_id,tenant_id,device_id,command_id,command_status,assignment_status,phase,progress_percent,result_code,
|
||||
active_slot,target_slot,previous_slot,failed_slot,boot_attempt_id,boot_health_deadline,rollback_applied,rollback_version,
|
||||
installed_version,verified_sha256,verified_signing_key_id,
|
||||
result_digest,result_summary
|
||||
)
|
||||
SELECT fa.id,fa.tenant_id,fa.device_id,fa.command_id,$2,$3,NULLIF($6::jsonb->>'phase',''),$4,NULLIF($6::jsonb->>'result_code',''),
|
||||
NULLIF($6::jsonb->>'active_slot',''),NULLIF($6::jsonb->>'target_slot',''),NULLIF($6::jsonb->>'previous_slot',''),
|
||||
NULLIF($6::jsonb->>'failed_slot',''),NULLIF($6::jsonb->>'boot_attempt_id',''),NULLIF($6::jsonb->>'boot_health_deadline',''),
|
||||
CASE WHEN jsonb_typeof($6::jsonb->'rollback_applied')='boolean' THEN ($6::jsonb->>'rollback_applied')::boolean ELSE NULL END,
|
||||
NULLIF($6::jsonb->>'rollback_version',''),NULLIF($6::jsonb->>'installed_version',''),NULLIF($6::jsonb->>'verified_sha256',''),
|
||||
NULLIF($6::jsonb->>'verified_signing_key_id',''),$5,$6::jsonb
|
||||
FROM firmware_assignment fa
|
||||
WHERE fa.command_id=$1::uuid
|
||||
ON CONFLICT(assignment_id,command_status,result_digest) DO NOTHING`,
|
||||
commandID, commandStatus, assignmentStatus, progress, hex.EncodeToString(digest[:]), resultJSON)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/firmwareversion"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type BootstrapReport struct {
|
||||
FirmwareVersion string
|
||||
BoardRevision string
|
||||
RTCSDKVersion string
|
||||
Codec string
|
||||
SampleRate int
|
||||
Network string
|
||||
DeviceTime time.Time
|
||||
}
|
||||
|
||||
type Binding struct {
|
||||
Bound bool `json:"bound"`
|
||||
HouseholdID *string `json:"household_id,omitempty"`
|
||||
SubjectID *string `json:"subject_id,omitempty"`
|
||||
SubjectName *string `json:"subject_name,omitempty"`
|
||||
}
|
||||
|
||||
type Bootstrap struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Status string `json:"status"`
|
||||
Model string `json:"model"`
|
||||
HardwareRevision *string `json:"hardware_revision,omitempty"`
|
||||
MinimumSecurityVersion *string `json:"minimum_security_version,omitempty"`
|
||||
MinimumFirmwareVersion *string `json:"minimum_firmware_version,omitempty"`
|
||||
FirmwareCompatible bool `json:"firmware_compatible"`
|
||||
ConfigRevision int64 `json:"config_revision"`
|
||||
Binding Binding `json:"binding"`
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
DesiredConfig json.RawMessage `json:"desired_config"`
|
||||
ReportedRevision *int64 `json:"reported_revision,omitempty"`
|
||||
Binding Binding `json:"binding"`
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DeliveryAttempts int `json:"delivery_attempts"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CommandAcknowledgement struct {
|
||||
Status string
|
||||
ResultSummary map[string]any
|
||||
}
|
||||
|
||||
type Repository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} }
|
||||
|
||||
func (r *Repository) ReportBootstrap(ctx context.Context, identity deviceauth.Identity, report BootstrapReport) (Bootstrap, error) {
|
||||
reportedConfig, err := json.Marshal(map[string]any{
|
||||
"codec": report.Codec, "sample_rate": report.SampleRate, "network": report.Network,
|
||||
"device_time": report.DeviceTime.UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("marshal device report: %w", err)
|
||||
}
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE device_twin
|
||||
SET firmware_version = $3, board_revision = NULLIF($4, ''), rtc_sdk_version = NULLIF($5, ''),
|
||||
reported_config = $6::jsonb, last_reported_at = now(), updated_at = now()
|
||||
WHERE device_id = $1::uuid AND tenant_id = $2::uuid`,
|
||||
identity.ID, identity.TenantID, report.FirmwareVersion, report.BoardRevision,
|
||||
report.RTCSDKVersion, reportedConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("update device bootstrap report: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return Bootstrap{}, fmt.Errorf("device twin is missing")
|
||||
}
|
||||
return r.readBootstrap(ctx, identity)
|
||||
}
|
||||
|
||||
func (r *Repository) GetConfiguration(ctx context.Context, identity deviceauth.Identity) (Configuration, error) {
|
||||
var result Configuration
|
||||
var householdID, subjectID, subjectName *string
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT d.public_id::text, dt.desired_revision, dt.desired_config, dt.reported_revision,
|
||||
h.public_id::text, s.public_id::text, s.preferred_name
|
||||
FROM device d
|
||||
JOIN device_twin dt ON dt.device_id = d.id AND dt.tenant_id = d.tenant_id
|
||||
LEFT JOIN device_binding b ON b.device_id = d.id AND b.tenant_id = d.tenant_id AND b.unbound_at IS NULL
|
||||
LEFT JOIN household h ON h.id = b.household_id AND h.tenant_id = b.tenant_id
|
||||
LEFT JOIN subject_profile s ON s.id = b.subject_id AND s.tenant_id = b.tenant_id
|
||||
WHERE d.id = $1::uuid AND d.tenant_id = $2::uuid`, identity.ID, identity.TenantID,
|
||||
).Scan(&result.DeviceID, &result.Revision, &result.DesiredConfig, &result.ReportedRevision,
|
||||
&householdID, &subjectID, &subjectName)
|
||||
if err != nil {
|
||||
return Configuration{}, fmt.Errorf("read device configuration: %w", err)
|
||||
}
|
||||
result.Binding = newBinding(householdID, subjectID, subjectName)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) PullCommands(ctx context.Context, identity deviceauth.Identity, limit int) ([]Command, error) {
|
||||
if limit < 1 || limit > 20 {
|
||||
limit = 10
|
||||
}
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_command SET status='EXPIRED',completed_at=now(),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at<=now()`, identity.TenantID, identity.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `WITH selected AS (SELECT id FROM device_command WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now() ORDER BY created_at,id LIMIT $3 FOR UPDATE SKIP LOCKED) UPDATE device_command dc SET status=CASE WHEN dc.status='ACKNOWLEDGED' THEN 'ACKNOWLEDGED' ELSE 'DELIVERED' END,delivery_attempts=delivery_attempts+1,delivered_at=COALESCE(delivered_at,now()),version=version+1,updated_at=now() FROM selected WHERE dc.id=selected.id RETURNING dc.public_id::text,dc.command_type,dc.payload,dc.status,dc.expires_at,dc.delivery_attempts,dc.created_at`, identity.TenantID, identity.ID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Command{}
|
||||
for rows.Next() {
|
||||
var item Command
|
||||
if err := rows.Scan(&item.ID, &item.Type, &item.Payload, &item.Status, &item.ExpiresAt, &item.DeliveryAttempts, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AcknowledgeCommand(ctx context.Context, identity deviceauth.Identity, publicID string, input CommandAcknowledgement) (Command, error) {
|
||||
resultJSON, err := json.Marshal(input.ResultSummary)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
var item Command
|
||||
var internalID string
|
||||
err = tx.QueryRow(ctx, `UPDATE device_command SET status=$4,result_summary=$5::jsonb,acknowledged_at=COALESCE(acknowledged_at,now()),completed_at=CASE WHEN $4 IN ('SUCCEEDED','FAILED') THEN COALESCE(completed_at,now()) ELSE completed_at END,version=version+1,updated_at=now() WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND device_id=$3::uuid AND expires_at>now() AND (($4='ACKNOWLEDGED' AND status='DELIVERED') OR ($4 IN ('SUCCEEDED','FAILED') AND status IN ('DELIVERED','ACKNOWLEDGED')) OR status=$4) RETURNING id::text,public_id::text,command_type,payload,status,expires_at,delivery_attempts,created_at`, publicID, identity.TenantID, identity.ID, input.Status, resultJSON).Scan(&internalID, &item.ID, &item.Type, &item.Payload, &item.Status, &item.ExpiresAt, &item.DeliveryAttempts, &item.CreatedAt)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("acknowledge device command: %w", err)
|
||||
}
|
||||
assignmentStatus, progress := firmwareAssignmentProgress(input)
|
||||
if assignmentStatus != "" {
|
||||
if _, err := tx.Exec(ctx, `UPDATE firmware_assignment SET status=$2,progress_percent=COALESCE($3,progress_percent),last_result=$4::jsonb,last_reported_at=now(),updated_at=now() WHERE command_id=$1::uuid`, internalID, assignmentStatus, progress, resultJSON); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
if err := recordFirmwareAssignmentEvent(ctx, tx, internalID, input.Status, assignmentStatus, progress, resultJSON); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE firmware_rollout ro SET status=CASE WHEN EXISTS(SELECT 1 FROM firmware_assignment fa WHERE fa.rollout_id=ro.id AND fa.status='FAILED') THEN 'PAUSED' ELSE 'COMPLETED' END,completed_at=CASE WHEN NOT EXISTS(SELECT 1 FROM firmware_assignment fa WHERE fa.rollout_id=ro.id AND fa.status NOT IN ('CONFIRMED','FAILED')) THEN now() ELSE completed_at END,updated_at=now() WHERE id IN (SELECT fa.rollout_id FROM firmware_assignment fa JOIN device_command dc ON dc.id=fa.command_id WHERE dc.public_id=$1::uuid) AND NOT EXISTS(SELECT 1 FROM firmware_assignment open WHERE open.rollout_id=ro.id AND open.status NOT IN ('CONFIRMED','FAILED'))`, publicID); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func firmwareAssignmentProgress(input CommandAcknowledgement) (string, *int) {
|
||||
if input.Status == "SUCCEEDED" {
|
||||
value := 100
|
||||
return "CONFIRMED", &value
|
||||
}
|
||||
if input.Status == "FAILED" {
|
||||
return "FAILED", nil
|
||||
}
|
||||
if input.Status != "ACKNOWLEDGED" {
|
||||
return "", nil
|
||||
}
|
||||
phase, _ := input.ResultSummary["phase"].(string)
|
||||
status := ""
|
||||
switch phase {
|
||||
case "downloading":
|
||||
status = "DOWNLOADING"
|
||||
case "installing", "staging", "reboot_pending", "boot_verifying":
|
||||
status = "INSTALLING"
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
value, ok := input.ResultSummary["progress_percent"].(float64)
|
||||
if !ok {
|
||||
return status, nil
|
||||
}
|
||||
progress := int(value)
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
if progress > 100 {
|
||||
progress = 100
|
||||
}
|
||||
return status, &progress
|
||||
}
|
||||
|
||||
func (r *Repository) readBootstrap(ctx context.Context, identity deviceauth.Identity) (Bootstrap, error) {
|
||||
var result Bootstrap
|
||||
var householdID, subjectID, subjectName, currentFirmware *string
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT d.public_id::text, d.status, d.model, d.hardware_revision,
|
||||
d.minimum_security_version, d.minimum_firmware_version, dt.firmware_version, dt.desired_revision,
|
||||
h.public_id::text, s.public_id::text, s.preferred_name
|
||||
FROM device d
|
||||
JOIN device_twin dt ON dt.device_id = d.id AND dt.tenant_id = d.tenant_id
|
||||
LEFT JOIN device_binding b ON b.device_id = d.id AND b.tenant_id = d.tenant_id AND b.unbound_at IS NULL
|
||||
LEFT JOIN household h ON h.id = b.household_id AND h.tenant_id = b.tenant_id
|
||||
LEFT JOIN subject_profile s ON s.id = b.subject_id AND s.tenant_id = b.tenant_id
|
||||
WHERE d.id = $1::uuid AND d.tenant_id = $2::uuid`, identity.ID, identity.TenantID,
|
||||
).Scan(&result.DeviceID, &result.Status, &result.Model, &result.HardwareRevision,
|
||||
&result.MinimumSecurityVersion, &result.MinimumFirmwareVersion, ¤tFirmware, &result.ConfigRevision,
|
||||
&householdID, &subjectID, &subjectName)
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("read device bootstrap: %w", err)
|
||||
}
|
||||
result.Binding = newBinding(householdID, subjectID, subjectName)
|
||||
result.FirmwareCompatible = result.MinimumFirmwareVersion == nil || (currentFirmware != nil && firmwareversion.AtLeast(*currentFirmware, *result.MinimumFirmwareVersion))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func newBinding(householdID, subjectID, subjectName *string) Binding {
|
||||
return Binding{Bound: householdID != nil && subjectID != nil, HouseholdID: householdID, SubjectID: subjectID, SubjectName: subjectName}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
)
|
||||
|
||||
func TestRepositoryReportsBootstrapAndKeepsTenantBoundary(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.Open(ctx, databaseURL, "device-integration-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
repository := NewRepository(pool)
|
||||
identity := deviceauth.Identity{
|
||||
ID: "10000000-0000-0000-0000-000000000501", PublicID: "10000000-0000-0000-0000-000000000511",
|
||||
TenantID: "10000000-0000-0000-0000-000000000001", Status: "registered",
|
||||
}
|
||||
result, err := repository.ReportBootstrap(ctx, identity, BootstrapReport{
|
||||
FirmwareVersion: "0.1.1-test", BoardRevision: "sim-rev-a", RTCSDKVersion: "mock-rtc-1",
|
||||
Codec: "opus", SampleRate: 16000, Network: "test", DeviceTime: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.DeviceID != identity.PublicID || !result.Binding.Bound || result.ConfigRevision != 1 || !result.FirmwareCompatible || result.MinimumFirmwareVersion == nil || *result.MinimumFirmwareVersion != "0.1.0-sim" {
|
||||
t.Fatalf("bootstrap = %#v", result)
|
||||
}
|
||||
configuration, err := repository.GetConfiguration(ctx, identity)
|
||||
if err != nil || configuration.DeviceID != identity.PublicID || !configuration.Binding.Bound {
|
||||
t.Fatalf("configuration = %#v, err = %v", configuration, err)
|
||||
}
|
||||
wrongTenant := identity
|
||||
wrongTenant.TenantID = "20000000-0000-0000-0000-000000000001"
|
||||
if _, err := repository.GetConfiguration(ctx, wrongTenant); err == nil {
|
||||
t.Fatal("cross-tenant device configuration was not denied")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package device
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFirmwareAssignmentProgress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input CommandAcknowledgement
|
||||
wantStatus string
|
||||
wantProgress int
|
||||
wantProgressOK bool
|
||||
}{
|
||||
{
|
||||
name: "download progress",
|
||||
input: CommandAcknowledgement{Status: "ACKNOWLEDGED", ResultSummary: map[string]any{"phase": "downloading", "progress_percent": float64(25)}},
|
||||
wantStatus: "DOWNLOADING",
|
||||
wantProgress: 25,
|
||||
wantProgressOK: true,
|
||||
},
|
||||
{
|
||||
name: "install progress",
|
||||
input: CommandAcknowledgement{Status: "ACKNOWLEDGED", ResultSummary: map[string]any{"phase": "installing", "progress_percent": float64(80)}},
|
||||
wantStatus: "INSTALLING",
|
||||
wantProgress: 80,
|
||||
wantProgressOK: true,
|
||||
},
|
||||
{
|
||||
name: "reboot health verification remains installing",
|
||||
input: CommandAcknowledgement{Status: "ACKNOWLEDGED", ResultSummary: map[string]any{"phase": "boot_verifying", "progress_percent": float64(95)}},
|
||||
wantStatus: "INSTALLING",
|
||||
wantProgress: 95,
|
||||
wantProgressOK: true,
|
||||
},
|
||||
{
|
||||
name: "negative progress is clamped",
|
||||
input: CommandAcknowledgement{Status: "ACKNOWLEDGED", ResultSummary: map[string]any{"phase": "downloading", "progress_percent": float64(-2)}},
|
||||
wantStatus: "DOWNLOADING",
|
||||
wantProgress: 0,
|
||||
wantProgressOK: true,
|
||||
},
|
||||
{
|
||||
name: "oversized progress is clamped",
|
||||
input: CommandAcknowledgement{Status: "ACKNOWLEDGED", ResultSummary: map[string]any{"phase": "installing", "progress_percent": float64(101)}},
|
||||
wantStatus: "INSTALLING",
|
||||
wantProgress: 100,
|
||||
wantProgressOK: true,
|
||||
},
|
||||
{
|
||||
name: "success confirms assignment",
|
||||
input: CommandAcknowledgement{Status: "SUCCEEDED", ResultSummary: map[string]any{"installed_version": "2.3.1"}},
|
||||
wantStatus: "CONFIRMED",
|
||||
wantProgress: 100,
|
||||
wantProgressOK: true,
|
||||
},
|
||||
{
|
||||
name: "failure marks assignment failed",
|
||||
input: CommandAcknowledgement{Status: "FAILED", ResultSummary: map[string]any{"error": "checksum mismatch"}},
|
||||
wantStatus: "FAILED",
|
||||
},
|
||||
{
|
||||
name: "unknown acknowledgement phase is ignored",
|
||||
input: CommandAcknowledgement{Status: "ACKNOWLEDGED", ResultSummary: map[string]any{"phase": "restarting"}},
|
||||
wantStatus: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
status, progress := firmwareAssignmentProgress(tt.input)
|
||||
if status != tt.wantStatus {
|
||||
t.Fatalf("status = %q, want %q", status, tt.wantStatus)
|
||||
}
|
||||
if tt.wantProgressOK {
|
||||
if progress == nil || *progress != tt.wantProgress {
|
||||
t.Fatalf("progress = %v, want %d", progress, tt.wantProgress)
|
||||
}
|
||||
return
|
||||
}
|
||||
if progress != nil {
|
||||
t.Fatalf("progress = %d, want nil", *progress)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTelemetryConfigAhead = errors.New("reported configuration is ahead of desired configuration")
|
||||
ErrTelemetryConversationMissing = errors.New("telemetry conversation is missing or belongs to another device")
|
||||
ErrTelemetryCommandConflict = errors.New("telemetry command result conflicts with command state")
|
||||
)
|
||||
|
||||
type TelemetryNetwork struct {
|
||||
Type string `json:"type"`
|
||||
RSSI *int `json:"rssi,omitempty"`
|
||||
}
|
||||
|
||||
type TelemetryRTC struct {
|
||||
Joined bool `json:"joined"`
|
||||
Uplink bool `json:"uplink"`
|
||||
Downlink bool `json:"downlink"`
|
||||
}
|
||||
|
||||
type TelemetryRecordingQueue struct {
|
||||
PendingSegments int `json:"pending_segments"`
|
||||
FailedSegments int `json:"failed_segments"`
|
||||
LastFailureCode string `json:"last_failure_code,omitempty"`
|
||||
LastFailureAt *time.Time `json:"last_failure_at,omitempty"`
|
||||
}
|
||||
|
||||
type TelemetryStatus struct {
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
ReportedConfigRevision int64 `json:"reported_config_revision"`
|
||||
Network TelemetryNetwork `json:"network"`
|
||||
RTC TelemetryRTC `json:"rtc"`
|
||||
RecordingQueue TelemetryRecordingQueue `json:"recording_queue"`
|
||||
ConversationStatus string `json:"conversation_status,omitempty"`
|
||||
}
|
||||
|
||||
type TelemetryError struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type TelemetryCommandResult struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Status string `json:"status"`
|
||||
ResultSummary map[string]any `json:"result_summary"`
|
||||
}
|
||||
|
||||
type TelemetryEvent struct {
|
||||
EventKey string `json:"event_key"`
|
||||
SequenceNo int64 `json:"sequence_no"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
EventType string `json:"event_type"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
Status *TelemetryStatus `json:"status,omitempty"`
|
||||
Error *TelemetryError `json:"error,omitempty"`
|
||||
CommandResult *TelemetryCommandResult `json:"command_result,omitempty"`
|
||||
}
|
||||
|
||||
type TelemetryBatchInput struct {
|
||||
Events []TelemetryEvent `json:"events"`
|
||||
}
|
||||
|
||||
type TelemetryConflict struct {
|
||||
EventKey string `json:"event_key"`
|
||||
SequenceNo int64 `json:"sequence_no"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type TelemetryConflictError struct {
|
||||
Conflicts []TelemetryConflict
|
||||
}
|
||||
|
||||
func (e *TelemetryConflictError) Error() string { return "telemetry event conflicts with stored event" }
|
||||
|
||||
type TelemetryAction struct {
|
||||
Type string `json:"type"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
}
|
||||
|
||||
type TelemetryBatchResponse struct {
|
||||
AcceptedCount int `json:"accepted_count"`
|
||||
DuplicateCount int `json:"duplicate_count"`
|
||||
DesiredConfigRevision int64 `json:"desired_config_revision"`
|
||||
Actions []TelemetryAction `json:"actions"`
|
||||
}
|
||||
|
||||
type TelemetryService struct {
|
||||
pool *pgxpool.Pool
|
||||
executor *idempotency.Executor
|
||||
}
|
||||
|
||||
func NewTelemetryService(pool *pgxpool.Pool, executor *idempotency.Executor) (*TelemetryService, error) {
|
||||
if pool == nil || executor == nil {
|
||||
return nil, errors.New("telemetry database and idempotency executor are required")
|
||||
}
|
||||
return &TelemetryService{pool: pool, executor: executor}, nil
|
||||
}
|
||||
|
||||
func (s *TelemetryService) Submit(ctx context.Context, identity deviceauth.Identity, idempotencyKey string, rawRequest []byte, input TelemetryBatchInput) (TelemetryBatchResponse, bool, error) {
|
||||
tenantID := identity.TenantID
|
||||
result, err := s.executor.Execute(ctx, idempotency.Request{
|
||||
TenantID: &tenantID, ActorKey: "device:" + identity.PublicID, Scope: "POST /device/v2/telemetry:batch",
|
||||
Key: idempotencyKey, RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 24 * time.Hour,
|
||||
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
response, submitErr := s.submit(operationContext, tx, identity, input)
|
||||
if submitErr != nil {
|
||||
return idempotency.Result{}, submitErr
|
||||
}
|
||||
body, marshalErr := json.Marshal(response)
|
||||
if marshalErr != nil {
|
||||
return idempotency.Result{}, marshalErr
|
||||
}
|
||||
resourceID := identity.ID
|
||||
return idempotency.Result{StatusCode: http.StatusOK, Body: body, ResourceID: &resourceID}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return TelemetryBatchResponse{}, false, err
|
||||
}
|
||||
var response TelemetryBatchResponse
|
||||
if err := json.Unmarshal(result.Body, &response); err != nil {
|
||||
return TelemetryBatchResponse{}, result.Replayed, fmt.Errorf("decode telemetry response: %w", err)
|
||||
}
|
||||
return response, result.Replayed, nil
|
||||
}
|
||||
|
||||
func (s *TelemetryService) submit(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, input TelemetryBatchInput) (TelemetryBatchResponse, error) {
|
||||
var desiredRevision int64
|
||||
var reportedRevision *int64
|
||||
if err := tx.QueryRow(ctx, `SELECT desired_revision,reported_revision FROM device_twin WHERE device_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&desiredRevision, &reportedRevision); err != nil {
|
||||
return TelemetryBatchResponse{}, fmt.Errorf("read device telemetry state: %w", err)
|
||||
}
|
||||
response := TelemetryBatchResponse{DesiredConfigRevision: desiredRevision, Actions: []TelemetryAction{}}
|
||||
conflicts := make([]TelemetryConflict, 0)
|
||||
stopActions := make(map[string]struct{})
|
||||
|
||||
for _, event := range input.Events {
|
||||
eventPayload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
digest := idempotency.Hash(eventPayload)
|
||||
rows, err := tx.Query(ctx, `SELECT event_key,sequence_no,payload_digest FROM device_telemetry_event WHERE device_id=$1::uuid AND (event_key=$2 OR sequence_no=$3) FOR UPDATE`, identity.ID, event.EventKey, event.SequenceNo)
|
||||
if err != nil {
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
matches := 0
|
||||
exact := false
|
||||
for rows.Next() {
|
||||
matches++
|
||||
var storedKey, storedDigest string
|
||||
var storedSequence int64
|
||||
if err := rows.Scan(&storedKey, &storedSequence, &storedDigest); err != nil {
|
||||
rows.Close()
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
if storedKey == event.EventKey && storedSequence == event.SequenceNo && storedDigest == digest {
|
||||
exact = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return TelemetryBatchResponse{}, rows.Err()
|
||||
}
|
||||
if matches > 0 {
|
||||
if matches == 1 && exact {
|
||||
response.DuplicateCount++
|
||||
continue
|
||||
}
|
||||
conflicts = append(conflicts, TelemetryConflict{EventKey: event.EventKey, SequenceNo: event.SequenceNo, Reason: "EVENT_KEY_OR_SEQUENCE_ALREADY_USED"})
|
||||
continue
|
||||
}
|
||||
|
||||
var conversationInternalID *string
|
||||
if event.ConversationID != "" {
|
||||
var internalID, conversationStatus string
|
||||
err := tx.QueryRow(ctx, `SELECT id::text,status FROM conversation WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND device_id=$3::uuid`, event.ConversationID, identity.TenantID, identity.ID).Scan(&internalID, &conversationStatus)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return TelemetryBatchResponse{}, ErrTelemetryConversationMissing
|
||||
}
|
||||
if err != nil {
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
conversationInternalID = &internalID
|
||||
if conversationStatus == "STOPPING" || conversationStatus == "COMPLETED" || conversationStatus == "FAILED" || conversationStatus == "EXPIRED" {
|
||||
stopActions[event.ConversationID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var errorCode, errorSeverity *string
|
||||
var commandInternalID *string
|
||||
switch event.EventType {
|
||||
case "status":
|
||||
if event.Status.ReportedConfigRevision > desiredRevision {
|
||||
return TelemetryBatchResponse{}, ErrTelemetryConfigAhead
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_twin SET
|
||||
telemetry_sequence=$3,
|
||||
reported_revision=CASE WHEN reported_revision IS NULL OR reported_revision<=$4 THEN $4 ELSE reported_revision END,
|
||||
reported_config=CASE WHEN reported_revision IS NULL OR reported_revision<=$4 THEN reported_config || jsonb_build_object('telemetry',$5::jsonb) ELSE reported_config END,
|
||||
firmware_version=NULLIF($6,''),last_reported_at=now(),updated_at=now()
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND telemetry_sequence<$3`, identity.ID, identity.TenantID,
|
||||
event.SequenceNo, event.Status.ReportedConfigRevision, eventPayload, event.Status.FirmwareVersion); err != nil {
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
if reportedRevision == nil || *reportedRevision < event.Status.ReportedConfigRevision {
|
||||
value := event.Status.ReportedConfigRevision
|
||||
reportedRevision = &value
|
||||
}
|
||||
case "error":
|
||||
errorCode, errorSeverity = &event.Error.Code, &event.Error.Severity
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_twin SET telemetry_sequence=$3,last_error_code=$4,last_error_severity=$5,last_error_at=$6,last_reported_at=now(),updated_at=now() WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND telemetry_sequence<$3`, identity.ID, identity.TenantID, event.SequenceNo, event.Error.Code, event.Error.Severity, event.OccurredAt); err != nil {
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
case "command_result":
|
||||
internalID, err := applyTelemetryCommandResult(ctx, tx, identity, *event.CommandResult)
|
||||
if err != nil {
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
commandInternalID = &internalID
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_twin SET telemetry_sequence=$3,last_reported_at=now(),updated_at=now() WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND telemetry_sequence<$3`, identity.ID, identity.TenantID, event.SequenceNo); err != nil {
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_telemetry_event(
|
||||
tenant_id,device_id,conversation_id,event_key,sequence_no,event_type,occurred_at,payload_digest,payload,error_code,error_severity,command_id
|
||||
) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12::uuid)`, identity.TenantID, identity.ID,
|
||||
conversationInternalID, event.EventKey, event.SequenceNo, event.EventType, event.OccurredAt, digest, eventPayload,
|
||||
errorCode, errorSeverity, commandInternalID); err != nil {
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
response.AcceptedCount++
|
||||
}
|
||||
if len(conflicts) > 0 {
|
||||
return TelemetryBatchResponse{}, &TelemetryConflictError{Conflicts: conflicts}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT record_device_presence($1::uuid,$2::uuid,now())`, identity.TenantID, identity.ID); err != nil {
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
if reportedRevision == nil || *reportedRevision < desiredRevision {
|
||||
response.Actions = append(response.Actions, TelemetryAction{Type: "refresh_config"})
|
||||
}
|
||||
var hasCommands bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_command WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now())`, identity.TenantID, identity.ID).Scan(&hasCommands); err != nil {
|
||||
return TelemetryBatchResponse{}, err
|
||||
}
|
||||
if hasCommands {
|
||||
response.Actions = append(response.Actions, TelemetryAction{Type: "pull_commands"})
|
||||
}
|
||||
for conversationID := range stopActions {
|
||||
response.Actions = append(response.Actions, TelemetryAction{Type: "stop_conversation", ConversationID: conversationID})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func applyTelemetryCommandResult(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, input TelemetryCommandResult) (string, error) {
|
||||
resultJSON, err := json.Marshal(input.ResultSummary)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var internalID string
|
||||
err = tx.QueryRow(ctx, `UPDATE device_command SET status=$4,result_summary=$5::jsonb,
|
||||
acknowledged_at=COALESCE(acknowledged_at,now()),
|
||||
completed_at=CASE WHEN $4 IN ('SUCCEEDED','FAILED') THEN COALESCE(completed_at,now()) ELSE completed_at END,
|
||||
version=version+1,updated_at=now()
|
||||
WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND device_id=$3::uuid AND expires_at>now()
|
||||
AND (($4='ACKNOWLEDGED' AND status='DELIVERED') OR ($4 IN ('SUCCEEDED','FAILED') AND status IN ('DELIVERED','ACKNOWLEDGED')) OR status=$4)
|
||||
RETURNING id::text`, input.CommandID, identity.TenantID, identity.ID, input.Status, resultJSON).Scan(&internalID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrTelemetryCommandConflict
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
assignmentStatus, progress := firmwareAssignmentProgress(CommandAcknowledgement{Status: input.Status, ResultSummary: input.ResultSummary})
|
||||
if assignmentStatus != "" {
|
||||
if _, err := tx.Exec(ctx, `UPDATE firmware_assignment SET status=$2,progress_percent=COALESCE($3,progress_percent),last_result=$4::jsonb,last_reported_at=now(),updated_at=now() WHERE command_id=$1::uuid`, internalID, assignmentStatus, progress, resultJSON); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := recordFirmwareAssignmentEvent(ctx, tx, internalID, input.Status, assignmentStatus, progress, resultJSON); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE firmware_rollout ro SET
|
||||
status=CASE WHEN EXISTS(SELECT 1 FROM firmware_assignment fa WHERE fa.rollout_id=ro.id AND fa.status='FAILED') THEN 'PAUSED' ELSE 'COMPLETED' END,
|
||||
completed_at=CASE WHEN NOT EXISTS(SELECT 1 FROM firmware_assignment fa WHERE fa.rollout_id=ro.id AND fa.status NOT IN ('CONFIRMED','FAILED')) THEN now() ELSE completed_at END,
|
||||
updated_at=now()
|
||||
WHERE id IN (SELECT fa.rollout_id FROM firmware_assignment fa WHERE fa.command_id=$1::uuid)
|
||||
AND NOT EXISTS(SELECT 1 FROM firmware_assignment open WHERE open.rollout_id=ro.id AND open.status NOT IN ('CONFIRMED','FAILED'))`, internalID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return internalID, nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
telemetryDeviceID = "94000000-0000-0000-0000-000000000501"
|
||||
telemetryDevicePublicID = "94000000-0000-0000-0000-000000000511"
|
||||
telemetryCommandID = "94000000-0000-0000-0000-000000000601"
|
||||
telemetryCommandPublic = "94000000-0000-0000-0000-000000000611"
|
||||
)
|
||||
|
||||
func TestTelemetryServiceAtomicallyUpdatesTwinAndCommand(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.Open(ctx, databaseURL, "device-telemetry-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
cleanupTelemetryFixture(t, ctx, pool)
|
||||
defer cleanupTelemetryFixture(t, context.Background(), pool)
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref)
|
||||
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',repeat('7',64),'telemetry-test','registered','test://telemetry')`, telemetryDeviceID, telemetryDevicePublicID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id,desired_revision,reported_revision)
|
||||
VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',2,1)`, telemetryDeviceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO device_command(id,public_id,tenant_id,device_id,command_type,payload,status,requested_by,idempotency_key,request_digest,expires_at,delivery_attempts)
|
||||
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',$3::uuid,'diagnostics','{}','DELIVERED','10000000-0000-0000-0000-000000000101','telemetry-fixture',repeat('8',64),now()+interval '1 hour',1)`, telemetryCommandID, telemetryCommandPublic, telemetryDeviceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service, err := NewTelemetryService(pool, idempotency.NewExecutor(pool))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity := deviceauth.Identity{ID: telemetryDeviceID, PublicID: telemetryDevicePublicID, TenantID: "10000000-0000-0000-0000-000000000001", Status: "registered"}
|
||||
rssi := -51
|
||||
input := TelemetryBatchInput{Events: []TelemetryEvent{
|
||||
{EventKey: "status-1", SequenceNo: 1, OccurredAt: time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC), EventType: "status", Status: &TelemetryStatus{
|
||||
FirmwareVersion: "1.2.3", ReportedConfigRevision: 1, Network: TelemetryNetwork{Type: "wifi", RSSI: &rssi},
|
||||
RTC: TelemetryRTC{}, RecordingQueue: TelemetryRecordingQueue{PendingSegments: 2},
|
||||
}},
|
||||
{EventKey: "error-2", SequenceNo: 2, OccurredAt: time.Date(2026, 8, 23, 12, 0, 1, 0, time.UTC), EventType: "error", Error: &TelemetryError{Code: "RTC_RECONNECTING", Severity: "warning"}},
|
||||
{EventKey: "command-3", SequenceNo: 3, OccurredAt: time.Date(2026, 8, 23, 12, 0, 2, 0, time.UTC), EventType: "command_result", CommandResult: &TelemetryCommandResult{
|
||||
CommandID: telemetryCommandPublic, Status: "SUCCEEDED", ResultSummary: map[string]any{"result_code": "OK"},
|
||||
}},
|
||||
}}
|
||||
body, _ := json.Marshal(input)
|
||||
response, replayed, err := service.Submit(ctx, identity, "telemetry-idempotency-0001", body, input)
|
||||
if err != nil || replayed || response.AcceptedCount != 3 || response.DuplicateCount != 0 || len(response.Actions) != 1 || response.Actions[0].Type != "refresh_config" {
|
||||
t.Fatalf("response=%#v replayed=%v err=%v", response, replayed, err)
|
||||
}
|
||||
replayedResponse, replayed, err := service.Submit(ctx, identity, "telemetry-idempotency-0001", body, input)
|
||||
if err != nil || !replayed || replayedResponse.AcceptedCount != 3 {
|
||||
t.Fatalf("replayed response=%#v replayed=%v err=%v", replayedResponse, replayed, err)
|
||||
}
|
||||
duplicateResponse, replayed, err := service.Submit(ctx, identity, "telemetry-idempotency-0002", body, input)
|
||||
if err != nil || replayed || duplicateResponse.AcceptedCount != 0 || duplicateResponse.DuplicateCount != 3 {
|
||||
t.Fatalf("duplicate response=%#v replayed=%v err=%v", duplicateResponse, replayed, err)
|
||||
}
|
||||
conflicting := input
|
||||
conflicting.Events = append([]TelemetryEvent(nil), input.Events...)
|
||||
conflicting.Events[1].Error = &TelemetryError{Code: "RTC_FAILED", Severity: "error"}
|
||||
conflictingBody, _ := json.Marshal(conflicting)
|
||||
if _, _, err := service.Submit(ctx, identity, "telemetry-idempotency-0003", conflictingBody, conflicting); err == nil {
|
||||
t.Fatal("conflicting telemetry was accepted")
|
||||
} else {
|
||||
var conflict *TelemetryConflictError
|
||||
if !errors.As(err, &conflict) || len(conflict.Conflicts) != 1 {
|
||||
t.Fatalf("conflicting telemetry error=%v", err)
|
||||
}
|
||||
}
|
||||
ahead := TelemetryBatchInput{Events: []TelemetryEvent{{EventKey: "status-4", SequenceNo: 4, OccurredAt: time.Now().UTC(), EventType: "status", Status: &TelemetryStatus{
|
||||
FirmwareVersion: "1.2.3", ReportedConfigRevision: 3, Network: TelemetryNetwork{Type: "wifi"}, RTC: TelemetryRTC{}, RecordingQueue: TelemetryRecordingQueue{},
|
||||
}}}}
|
||||
aheadBody, _ := json.Marshal(ahead)
|
||||
if _, _, err := service.Submit(ctx, identity, "telemetry-idempotency-0004", aheadBody, ahead); !errors.Is(err, ErrTelemetryConfigAhead) {
|
||||
t.Fatalf("ahead configuration error=%v", err)
|
||||
}
|
||||
|
||||
var telemetrySequence int64
|
||||
var firmware, errorCode, errorSeverity, commandStatus string
|
||||
var events int
|
||||
if err := pool.QueryRow(ctx, `SELECT telemetry_sequence,firmware_version,last_error_code,last_error_severity FROM device_twin WHERE device_id=$1::uuid`, telemetryDeviceID).Scan(&telemetrySequence, &firmware, &errorCode, &errorSeverity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM device_command WHERE id=$1::uuid`, telemetryCommandID).Scan(&commandStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM device_telemetry_event WHERE device_id=$1::uuid`, telemetryDeviceID).Scan(&events); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if telemetrySequence != 3 || firmware != "1.2.3" || errorCode != "RTC_RECONNECTING" || errorSeverity != "warning" || commandStatus != "SUCCEEDED" || events != 3 {
|
||||
t.Fatalf("sequence=%d firmware=%s error=%s/%s command=%s events=%d", telemetrySequence, firmware, errorCode, errorSeverity, commandStatus, events)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupTelemetryFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
statements := []struct {
|
||||
query string
|
||||
arg string
|
||||
}{
|
||||
{`DELETE FROM device_telemetry_event WHERE device_id=$1::uuid`, telemetryDeviceID},
|
||||
{`DELETE FROM idempotency_record WHERE actor_key=$1`, "device:" + telemetryDevicePublicID},
|
||||
{`DELETE FROM device_presence_state WHERE device_id=$1::uuid`, telemetryDeviceID},
|
||||
{`DELETE FROM device_command WHERE device_id=$1::uuid`, telemetryDeviceID},
|
||||
{`DELETE FROM device_twin WHERE device_id=$1::uuid`, telemetryDeviceID},
|
||||
{`DELETE FROM device WHERE id=$1::uuid`, telemetryDeviceID},
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := pool.Exec(ctx, statement.query, statement.arg); err != nil {
|
||||
t.Fatalf("cleanup telemetry fixture: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user