@@ -27,6 +27,7 @@ type ConversationSummary struct {
|
||||
SubjectID string `json:"subject_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Status string `json:"status"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
EngineMode string `json:"engine_mode"`
|
||||
RetentionMode string `json:"retention_mode"`
|
||||
AudioRetentionEnabled bool `json:"audio_retention_enabled"`
|
||||
@@ -71,12 +72,23 @@ type RTCTaskSummary struct {
|
||||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||
}
|
||||
|
||||
type HardwareAgentTaskSummary struct {
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
Transport string `json:"transport"`
|
||||
ProviderStartedAt *time.Time `json:"provider_started_at,omitempty"`
|
||||
ProviderEndedAt *time.Time `json:"provider_ended_at,omitempty"`
|
||||
LastEventAt *time.Time `json:"last_event_at,omitempty"`
|
||||
LastEventSequence int64 `json:"last_event_sequence"`
|
||||
}
|
||||
|
||||
type ConversationDetail struct {
|
||||
ConversationSummary
|
||||
HouseholdID string `json:"household_id"`
|
||||
Snapshots ConversationSnapshotSummary `json:"snapshots"`
|
||||
RTC *RTCTaskSummary `json:"rtc,omitempty"`
|
||||
History []ConversationStateEntry `json:"history"`
|
||||
HouseholdID string `json:"household_id"`
|
||||
Snapshots ConversationSnapshotSummary `json:"snapshots"`
|
||||
RTC *RTCTaskSummary `json:"rtc,omitempty"`
|
||||
HardwareAgent *HardwareAgentTaskSummary `json:"hardware_agent,omitempty"`
|
||||
History []ConversationStateEntry `json:"history"`
|
||||
}
|
||||
|
||||
type ConversationUsageSummary struct {
|
||||
@@ -109,7 +121,7 @@ func (s *Service) ListSubjectConversations(ctx context.Context, principal identi
|
||||
cursorID = &decoded.ID
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.public_id::text,$1::text,d.public_id::text,c.status,c.engine_mode,c.retention_mode,
|
||||
SELECT c.public_id::text,$1::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode,
|
||||
c.started_at,c.ended_at,CASE WHEN c.started_at IS NOT NULL AND c.ended_at IS NOT NULL THEN (EXTRACT(EPOCH FROM (c.ended_at-c.started_at))*1000)::bigint END,
|
||||
c.stop_reason,c.failure_code,c.created_at,c.updated_at
|
||||
FROM conversation c JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
|
||||
@@ -154,13 +166,17 @@ func (s *Service) GetConversation(ctx context.Context, principal identity.Princi
|
||||
var rtcProvider, rtcStatus *string
|
||||
var rtcLeaseExpiresAt, rtcLastCheckedAt *time.Time
|
||||
var rtcStartAttempts, rtcStopAttempts *int
|
||||
var hardwareProvider, hardwareStatus, hardwareTransport *string
|
||||
var hardwareStartedAt, hardwareEndedAt, hardwareLastEventAt *time.Time
|
||||
var hardwareLastSequence *int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.public_id::text,sp.public_id::text,d.public_id::text,c.status,c.engine_mode,c.retention_mode,
|
||||
SELECT c.public_id::text,sp.public_id::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode,
|
||||
c.started_at,c.ended_at,CASE WHEN c.started_at IS NOT NULL AND c.ended_at IS NOT NULL THEN (EXTRACT(EPOCH FROM (c.ended_at-c.started_at))*1000)::bigint END,
|
||||
c.stop_reason,c.failure_code,c.created_at,c.updated_at,h.public_id::text,
|
||||
cs.public_id::text,cs.snapshot_version,xs.public_id::text,xs.profile_version,xs.prompt_bundle_version,
|
||||
xs.preparation_status,xs.preparation_failure_code,
|
||||
r.provider,r.status,r.lease_expires_at,r.start_attempts,r.stop_attempts,r.last_checked_at
|
||||
r.provider,r.status,r.lease_expires_at,r.start_attempts,r.stop_attempts,r.last_checked_at,
|
||||
hs.provider,hs.status,hs.transport,hs.provider_started_at,hs.provider_ended_at,hs.last_event_at,hs.last_event_sequence
|
||||
FROM conversation c
|
||||
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id AND h.status <> 'deleted'
|
||||
JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id AND sp.status IN ('active','suspended')
|
||||
@@ -168,14 +184,16 @@ func (s *Service) GetConversation(ctx context.Context, principal identity.Princi
|
||||
JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id
|
||||
JOIN context_snapshot xs ON xs.id=c.context_snapshot_id AND xs.tenant_id=c.tenant_id
|
||||
LEFT JOIN rtc_task r ON r.conversation_id=c.id
|
||||
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id
|
||||
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
|
||||
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
|
||||
WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, conversationPublicID, principal.Issuer, principal.Subject).Scan(
|
||||
&result.ID, &result.SubjectID, &result.DeviceID, &result.Status, &result.EngineMode, &result.RetentionMode,
|
||||
&result.ID, &result.SubjectID, &result.DeviceID, &result.Status, &result.RuntimeType, &result.EngineMode, &result.RetentionMode,
|
||||
&result.StartedAt, &result.EndedAt, &duration, &result.StopReason, &result.FailureCode, &result.CreatedAt, &result.UpdatedAt, &result.HouseholdID,
|
||||
&result.Snapshots.ConsentSnapshotID, &result.Snapshots.ConsentSnapshotVersion, &result.Snapshots.ContextSnapshotID, &result.Snapshots.ProfileVersion, &result.Snapshots.PromptBundleVersion,
|
||||
&result.Snapshots.ContextPreparationStatus, &result.Snapshots.ContextPreparationFailureCode,
|
||||
&rtcProvider, &rtcStatus, &rtcLeaseExpiresAt, &rtcStartAttempts, &rtcStopAttempts, &rtcLastCheckedAt)
|
||||
&rtcProvider, &rtcStatus, &rtcLeaseExpiresAt, &rtcStartAttempts, &rtcStopAttempts, &rtcLastCheckedAt,
|
||||
&hardwareProvider, &hardwareStatus, &hardwareTransport, &hardwareStartedAt, &hardwareEndedAt, &hardwareLastEventAt, &hardwareLastSequence)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ConversationDetail{}, ErrAccessDenied
|
||||
}
|
||||
@@ -196,6 +214,19 @@ func (s *Service) GetConversation(ctx context.Context, principal identity.Princi
|
||||
result.RTC.StopAttempts = *rtcStopAttempts
|
||||
}
|
||||
}
|
||||
if hardwareProvider != nil {
|
||||
result.HardwareAgent = &HardwareAgentTaskSummary{Provider: *hardwareProvider, ProviderStartedAt: hardwareStartedAt,
|
||||
ProviderEndedAt: hardwareEndedAt, LastEventAt: hardwareLastEventAt}
|
||||
if hardwareStatus != nil {
|
||||
result.HardwareAgent.Status = *hardwareStatus
|
||||
}
|
||||
if hardwareTransport != nil {
|
||||
result.HardwareAgent.Transport = *hardwareTransport
|
||||
}
|
||||
if hardwareLastSequence != nil {
|
||||
result.HardwareAgent.LastEventSequence = *hardwareLastSequence
|
||||
}
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT h.from_status,h.to_status,h.reason,h.source,h.occurred_at
|
||||
FROM conversation_state_history h JOIN conversation c ON c.id=h.conversation_id
|
||||
@@ -264,7 +295,7 @@ func (s *Service) GetConversationUsage(ctx context.Context, principal identity.P
|
||||
|
||||
func scanConversationSummary(row rowScanner) (ConversationSummary, error) {
|
||||
var item ConversationSummary
|
||||
if err := row.Scan(&item.ID, &item.SubjectID, &item.DeviceID, &item.Status, &item.EngineMode, &item.RetentionMode,
|
||||
if err := row.Scan(&item.ID, &item.SubjectID, &item.DeviceID, &item.Status, &item.RuntimeType, &item.EngineMode, &item.RetentionMode,
|
||||
&item.StartedAt, &item.EndedAt, &item.DurationMS, &item.StopReason, &item.FailureCode, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return ConversationSummary{}, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -106,6 +107,22 @@ func (s *Service) ChangeDeviceBinding(ctx context.Context, principal identity.Pr
|
||||
result.BindingID = &newBindingID
|
||||
result.HouseholdID = &targetAccess.householdPublicID
|
||||
result.SubjectID = &input.TargetSubjectID
|
||||
} else {
|
||||
var activationState string
|
||||
if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, access.deviceID, access.tenantID).Scan(&activationState); err != nil {
|
||||
return fmt.Errorf("read unbound device activation: %w", err)
|
||||
}
|
||||
if activationState == string(deviceactivation.Bound) || activationState == string(deviceactivation.Provisioning) ||
|
||||
activationState == string(deviceactivation.Active) || activationState == string(deviceactivation.Lost) {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: access.deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Claimable, Reason: "用户解除设备绑定", Source: "user-unbind", ActorType: "user",
|
||||
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
|
||||
return fmt.Errorf("transition unbound device activation: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`, access.deviceID); err != nil {
|
||||
return fmt.Errorf("suspend unbound device provider credential: %w", err)
|
||||
}
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{
|
||||
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -43,15 +44,15 @@ func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal,
|
||||
return fmt.Errorf("resolve claim subject: %w", err)
|
||||
}
|
||||
|
||||
var claimID, deviceID, devicePublicID string
|
||||
var claimID, deviceID, devicePublicID, activationState string
|
||||
var expiresAt time.Time
|
||||
var revokedAt, usedAt *time.Time
|
||||
var usedBindingID *string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT dc.id::text,dc.device_id::text,d.public_id::text,dc.expires_at,dc.revoked_at,dc.used_at,dc.used_binding_id::text
|
||||
SELECT dc.id::text,dc.device_id::text,d.public_id::text,d.activation_state,dc.expires_at,dc.revoked_at,dc.used_at,dc.used_binding_id::text
|
||||
FROM device_claim dc JOIN device d ON d.id=dc.device_id AND d.tenant_id=dc.tenant_id
|
||||
WHERE dc.token_digest=$1 AND dc.tenant_id=$2::uuid AND d.status='registered'
|
||||
FOR UPDATE OF dc`, digest, access.tenantID).Scan(&claimID, &deviceID, &devicePublicID, &expiresAt, &revokedAt, &usedAt, &usedBindingID); errors.Is(err, pgx.ErrNoRows) {
|
||||
FOR UPDATE OF d,dc`, digest, access.tenantID).Scan(&claimID, &deviceID, &devicePublicID, &activationState, &expiresAt, &revokedAt, &usedAt, &usedBindingID); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrDeviceClaimInvalid
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("resolve device claim: %w", err)
|
||||
@@ -78,6 +79,9 @@ func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal,
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
if activationState != string(deviceactivation.Claimable) {
|
||||
return ErrDeviceClaimInvalid
|
||||
}
|
||||
var activeBinding bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, deviceID).Scan(&activeBinding); err != nil {
|
||||
return fmt.Errorf("check active device binding: %w", err)
|
||||
@@ -97,6 +101,27 @@ func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal,
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_claim SET used_at=now(),used_binding_id=$1::uuid WHERE id=$2::uuid`, bindingInternalID, claimID); err != nil {
|
||||
return fmt.Errorf("mark device claim used: %w", err)
|
||||
}
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Bound, Reason: "用户通过一次性绑定码认领设备", Source: "device-claim", ActorType: "user",
|
||||
ActorPublicID: strPtr(access.userPublicID), RequestID: requestID}); err != nil {
|
||||
return fmt.Errorf("activate claimed device binding: %w", err)
|
||||
}
|
||||
var runtime string
|
||||
if err := tx.QueryRow(ctx, `SELECT COALESCE(desired_config->>'voice_runtime','legacy_voicechat') FROM device_twin WHERE device_id=$1::uuid`, deviceID).Scan(&runtime); err != nil {
|
||||
return fmt.Errorf("read claimed device runtime: %w", err)
|
||||
}
|
||||
if runtime == "legacy_voicechat" {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Provisioning, Reason: "旧版语音运行时平台凭据预配", Source: "platform-provisioning", ActorType: "user",
|
||||
ActorPublicID: strPtr(access.userPublicID), RequestID: requestID}); err != nil {
|
||||
return fmt.Errorf("provision claimed legacy device: %w", err)
|
||||
}
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Active, Reason: "旧版语音运行时已具备绑定与平台凭据", Source: "platform-provisioning", ActorType: "user",
|
||||
ActorPublicID: strPtr(access.userPublicID), RequestID: requestID}); err != nil {
|
||||
return fmt.Errorf("activate claimed legacy device: %w", err)
|
||||
}
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{
|
||||
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||||
Action: "device.claim", ResourceType: "device_binding", ResourceID: strPtr(result.BindingID),
|
||||
|
||||
@@ -43,6 +43,7 @@ func (s *Service) ConfigureFirmwareStore(store firmwareObjectStore, ttl time.Dur
|
||||
|
||||
type FirmwareRelease struct {
|
||||
ID string `json:"id"`
|
||||
HouseholdID string `json:"household_id,omitempty"`
|
||||
Model string `json:"model"`
|
||||
HardwareRevision string `json:"hardware_revision,omitempty"`
|
||||
Version string `json:"version"`
|
||||
@@ -53,6 +54,10 @@ type FirmwareRelease struct {
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
MinimumSecurityVersion string `json:"minimum_security_version,omitempty"`
|
||||
ReleaseNotes string `json:"release_notes,omitempty"`
|
||||
ReleaseChannel string `json:"release_channel"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
CreatedSource string `json:"created_source"`
|
||||
Status string `json:"status"`
|
||||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -64,7 +69,7 @@ type FirmwareRelease struct {
|
||||
type FirmwareRollout struct {
|
||||
ID string `json:"id"`
|
||||
ReleaseID string `json:"release_id"`
|
||||
HouseholdID string `json:"household_id"`
|
||||
HouseholdID string `json:"household_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
TargetPercentage int `json:"target_percentage"`
|
||||
@@ -172,6 +177,8 @@ type FirmwareImpactDevice struct {
|
||||
type PrepareFirmwareInput struct {
|
||||
Model, HardwareRevision, Version, ExpectedDigest, Signature, SigningKeyID, MinimumSecurityVersion, ReleaseNotes string
|
||||
ExpectedSize int64
|
||||
ReleaseChannel string
|
||||
Mandatory bool
|
||||
}
|
||||
|
||||
func (s *Service) RegisterFirmwareSigningKey(ctx context.Context, p identity.Principal, householdPublicID, requestID, keyID, publicKeyBase64 string) error {
|
||||
@@ -312,7 +319,7 @@ func (s *Service) PrepareFirmwareRelease(ctx context.Context, p identity.Princip
|
||||
}
|
||||
tenantHash := sha256.Sum256([]byte(a.tenantID))
|
||||
objectRef := fmt.Sprintf("tenants/%s/firmware/%s/%s/%s.bin", hex.EncodeToString(tenantHash[:16]), safePath(in.Model), safePath(in.HardwareRevision), safePath(in.Version))
|
||||
err = tx.QueryRow(ctx, `INSERT INTO firmware_release(tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,created_by) VALUES($1::uuid,$2::uuid,$3,NULLIF($4,''),$5,$6,$7,$8,$9,$10,NULLIF($11,''),NULLIF($12,''),$13::uuid) RETURNING public_id::text,model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),status,verified_at,created_at,updated_at,object_ref`, a.tenantID, a.householdID, in.Model, in.HardwareRevision, in.Version, objectRef, in.ExpectedSize, in.ExpectedDigest, in.Signature, in.SigningKeyID, in.MinimumSecurityVersion, in.ReleaseNotes, a.userID).Scan(firmwareReleaseTargets(&item)...)
|
||||
err = tx.QueryRow(ctx, `INSERT INTO firmware_release(tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,release_channel,mandatory,created_source,created_by) VALUES($1::uuid,$2::uuid,$3,NULLIF($4,''),$5,$6,$7,$8,$9,$10,NULLIF($11,''),NULLIF($12,''),$13,$14,'CUSTOMER',$15::uuid) RETURNING public_id::text,COALESCE(household_id::text,''),model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),release_channel,mandatory,published_at,created_source,status,verified_at,created_at,updated_at,object_ref`, a.tenantID, a.householdID, in.Model, in.HardwareRevision, in.Version, objectRef, in.ExpectedSize, in.ExpectedDigest, in.Signature, in.SigningKeyID, in.MinimumSecurityVersion, in.ReleaseNotes, normalizeReleaseChannel(in.ReleaseChannel), in.Mandatory, a.userID).Scan(firmwareReleaseTargets(&item)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -336,7 +343,15 @@ func safePath(v string) string {
|
||||
return r.Replace(v)
|
||||
}
|
||||
func firmwareReleaseTargets(i *FirmwareRelease) []any {
|
||||
return []any{&i.ID, &i.Model, &i.HardwareRevision, &i.Version, &i.ExpectedSize, &i.ExpectedDigest, &i.SignatureAlgorithm, &i.Signature, &i.SigningKeyID, &i.MinimumSecurityVersion, &i.ReleaseNotes, &i.Status, &i.VerifiedAt, &i.CreatedAt, &i.UpdatedAt, &i.objectRef}
|
||||
return []any{&i.ID, &i.HouseholdID, &i.Model, &i.HardwareRevision, &i.Version, &i.ExpectedSize, &i.ExpectedDigest, &i.SignatureAlgorithm, &i.Signature, &i.SigningKeyID, &i.MinimumSecurityVersion, &i.ReleaseNotes, &i.ReleaseChannel, &i.Mandatory, &i.PublishedAt, &i.CreatedSource, &i.Status, &i.VerifiedAt, &i.CreatedAt, &i.UpdatedAt, &i.objectRef}
|
||||
}
|
||||
|
||||
func normalizeReleaseChannel(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "BETA" || value == "INTERNAL" {
|
||||
return value
|
||||
}
|
||||
return "STABLE"
|
||||
}
|
||||
|
||||
func (s *Service) ListFirmwareReleases(ctx context.Context, p identity.Principal, householdPublicID string) ([]FirmwareRelease, error) {
|
||||
@@ -346,7 +361,7 @@ func (s *Service) ListFirmwareReleases(ctx context.Context, p identity.Principal
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT public_id::text,model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),status,verified_at,created_at,updated_at,object_ref FROM firmware_release WHERE tenant_id=$1::uuid AND household_id=$2::uuid ORDER BY created_at DESC LIMIT 100`, a.tenantID, a.householdID)
|
||||
rows, err := tx.Query(ctx, `SELECT public_id::text,COALESCE(household_id::text,''),model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),release_channel,mandatory,published_at,created_source,status,verified_at,created_at,updated_at,object_ref FROM firmware_release WHERE tenant_id=$1::uuid AND household_id=$2::uuid ORDER BY created_at DESC LIMIT 100`, a.tenantID, a.householdID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -698,7 +713,7 @@ func (s *Service) resolveFirmwareRelease(ctx context.Context, p identity.Princip
|
||||
var item FirmwareRelease
|
||||
var a resourceAccess
|
||||
var role string
|
||||
err := s.pool.QueryRow(ctx, `SELECT fr.public_id::text,fr.model,COALESCE(fr.hardware_revision,''),fr.version,fr.expected_size,fr.expected_digest,fr.signature_algorithm,fr.signature,fr.signing_key_id,COALESCE(fr.minimum_security_version,''),COALESCE(fr.release_notes,''),fr.status,fr.verified_at,fr.created_at,fr.updated_at,fr.object_ref,fr.tenant_id::text,ua.id::text,ua.public_id::text,hm.role,h.id::text,h.public_id::text FROM firmware_release fr JOIN household h ON h.id=fr.household_id AND h.tenant_id=fr.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=fr.tenant_id AND ua.status='active' WHERE fr.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 LIMIT 1`, publicID, p.Issuer, p.Subject).Scan(append(firmwareReleaseTargets(&item), &a.tenantID, &a.userID, &a.userPublicID, &role, &a.householdID, &a.householdPublicID)...)
|
||||
err := s.pool.QueryRow(ctx, `SELECT fr.public_id::text,COALESCE(fr.household_id::text,''),fr.model,COALESCE(fr.hardware_revision,''),fr.version,fr.expected_size,fr.expected_digest,fr.signature_algorithm,fr.signature,fr.signing_key_id,COALESCE(fr.minimum_security_version,''),COALESCE(fr.release_notes,''),fr.release_channel,fr.mandatory,fr.published_at,fr.created_source,fr.status,fr.verified_at,fr.created_at,fr.updated_at,fr.object_ref,fr.tenant_id::text,ua.id::text,ua.public_id::text,hm.role,h.id::text,h.public_id::text FROM firmware_release fr JOIN household h ON h.id=fr.household_id AND h.tenant_id=fr.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=fr.tenant_id AND ua.status='active' WHERE fr.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 LIMIT 1`, publicID, p.Issuer, p.Subject).Scan(append(firmwareReleaseTargets(&item), &a.tenantID, &a.userID, &a.userPublicID, &role, &a.householdID, &a.householdPublicID)...)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FirmwareRelease{}, resourceAccess{}, ErrAccessDenied
|
||||
}
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
package saas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type opsFirmwareAccess struct {
|
||||
tenantID, tenantPublicID, userID, userPublicID, role string
|
||||
}
|
||||
|
||||
type FirmwareDownload struct {
|
||||
ReleaseID string `json:"release_id"`
|
||||
Version string `json:"version"`
|
||||
ExpectedSize int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Signature string `json:"signature"`
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
DownloadUntil time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func resolveOpsFirmwareAccess(ctx context.Context, q queryRow, principal identity.Principal, tenantPublicID string, mutate bool) (opsFirmwareAccess, error) {
|
||||
var access opsFirmwareAccess
|
||||
err := q.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role
|
||||
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active' AND ua.account_kind='operator'
|
||||
WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, tenantPublicID, principal.Issuer, principal.Subject).
|
||||
Scan(&access.tenantID, &access.tenantPublicID, &access.userID, &access.userPublicID, &access.role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return opsFirmwareAccess{}, ErrAccessDenied
|
||||
}
|
||||
if err != nil {
|
||||
return opsFirmwareAccess{}, fmt.Errorf("resolve operations firmware access: %w", err)
|
||||
}
|
||||
if mutate && access.role != "ops_operator" && access.role != "ops_admin" {
|
||||
return opsFirmwareAccess{}, ErrAccessDenied
|
||||
}
|
||||
return access, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListOpsFirmwareSigningKeys(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]FirmwareSigningKey, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.listFirmwareSigningKeys(ctx, access.tenantID)
|
||||
}
|
||||
|
||||
func (s *Service) RegisterOpsFirmwareSigningKey(ctx context.Context, principal identity.Principal, tenantPublicID, requestID, keyID, publicKeyBase64 string) error {
|
||||
decoded, err := base64.StdEncoding.DecodeString(publicKeyBase64)
|
||||
if err != nil || len(decoded) != ed25519.PublicKeySize || base64.StdEncoding.EncodeToString(decoded) != publicKeyBase64 || keyID == "" || len([]rune(keyID)) > 120 {
|
||||
return errors.New("invalid Ed25519 public key")
|
||||
}
|
||||
return database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var existingPublicKey, status string
|
||||
err = tx.QueryRow(ctx, `SELECT public_key_base64,status FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 FOR UPDATE`, access.tenantID, keyID).Scan(&existingPublicKey, &status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO firmware_signing_key(tenant_id,key_id,public_key_base64,created_by) VALUES($1::uuid,$2,$3,$4::uuid)`, access.tenantID, keyID, publicKeyBase64, access.userID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else if existingPublicKey != publicKeyBase64 || status != "ACTIVE" {
|
||||
return ErrFirmwareSigningKeyConflict
|
||||
}
|
||||
fingerprint := sha256.Sum256(decoded)
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.signing_key.register", ResourceType: "firmware_signing_key", RequestID: requestID, Result: "success", Detail: map[string]any{"key_id": keyID, "fingerprint_sha256": hex.EncodeToString(fingerprint[:])}})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) RevokeOpsFirmwareSigningKey(ctx context.Context, principal identity.Principal, tenantPublicID, requestID, keyID string) (FirmwareSigningKey, error) {
|
||||
var internalTenantID string
|
||||
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
internalTenantID = access.tenantID
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 FOR UPDATE`, access.tenantID, keyID).Scan(&status); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrFirmwareSigningKeyConflict
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "REVOKED" {
|
||||
return nil
|
||||
}
|
||||
var blocked bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM firmware_release fr WHERE fr.tenant_id=$1::uuid AND fr.signing_key_id=$2 AND fr.status='UPLOADING'
|
||||
UNION ALL
|
||||
SELECT 1 FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id AND fr.tenant_id=ro.tenant_id WHERE ro.tenant_id=$1::uuid AND fr.signing_key_id=$2 AND ro.status='RUNNING'
|
||||
)`, access.tenantID, keyID).Scan(&blocked); err != nil {
|
||||
return err
|
||||
}
|
||||
if blocked {
|
||||
return ErrFirmwareSigningKeyInUse
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE firmware_signing_key SET status='REVOKED',revoked_at=now() WHERE tenant_id=$1::uuid AND key_id=$2 AND status='ACTIVE'`, access.tenantID, keyID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.signing_key.revoke", ResourceType: "firmware_signing_key", RequestID: requestID, Result: "success", Detail: map[string]any{"key_id": keyID}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareSigningKey{}, err
|
||||
}
|
||||
items, err := s.listFirmwareSigningKeys(ctx, internalTenantID)
|
||||
if err != nil {
|
||||
return FirmwareSigningKey{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.KeyID == keyID {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return FirmwareSigningKey{}, ErrFirmwareSigningKeyConflict
|
||||
}
|
||||
|
||||
func (s *Service) ListOpsFirmwareReleases(ctx context.Context, principal identity.Principal, tenantPublicID, model, status string) ([]FirmwareRelease, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT public_id::text,COALESCE(household_id::text,''),model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),release_channel,mandatory,published_at,created_source,status,verified_at,created_at,updated_at,object_ref
|
||||
FROM firmware_release WHERE tenant_id=$1::uuid AND (NULLIF($2,'') IS NULL OR model=$2) AND (NULLIF($3,'') IS NULL OR status=$3)
|
||||
ORDER BY created_at DESC LIMIT 200`, access.tenantID, strings.TrimSpace(model), strings.ToUpper(strings.TrimSpace(status)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list operations firmware releases: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]FirmwareRelease, 0)
|
||||
for rows.Next() {
|
||||
var item FirmwareRelease
|
||||
if err := rows.Scan(firmwareReleaseTargets(&item)...); err != nil {
|
||||
return nil, fmt.Errorf("scan operations firmware release: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) PrepareOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, requestID string, input PrepareFirmwareInput) (FirmwareRelease, error) {
|
||||
if s.firmwareStore == nil {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
var item FirmwareRelease
|
||||
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var signingKeyActive bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 AND status='ACTIVE')`, access.tenantID, input.SigningKeyID).Scan(&signingKeyActive); err != nil {
|
||||
return err
|
||||
}
|
||||
if !signingKeyActive {
|
||||
return ErrFirmwareSigningKeyInactive
|
||||
}
|
||||
tenantHash := sha256.Sum256([]byte(access.tenantID))
|
||||
objectRef := fmt.Sprintf("tenants/%s/firmware/%s/%s/%s.bin", hex.EncodeToString(tenantHash[:16]), safePath(input.Model), safePath(input.HardwareRevision), safePath(input.Version))
|
||||
err = tx.QueryRow(ctx, `INSERT INTO firmware_release(tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,release_channel,mandatory,created_source,created_by)
|
||||
VALUES($1::uuid,NULL,$2,NULLIF($3,''),$4,$5,$6,$7,$8,$9,NULLIF($10,''),NULLIF($11,''),$12,$13,'OPS',$14::uuid)
|
||||
RETURNING public_id::text,COALESCE(household_id::text,''),model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),release_channel,mandatory,published_at,created_source,status,verified_at,created_at,updated_at,object_ref`,
|
||||
access.tenantID, input.Model, input.HardwareRevision, input.Version, objectRef, input.ExpectedSize, input.ExpectedDigest, input.Signature, input.SigningKeyID, input.MinimumSecurityVersion, input.ReleaseNotes, normalizeReleaseChannel(input.ReleaseChannel), input.Mandatory, access.userID).Scan(firmwareReleaseTargets(&item)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.prepare", ResourceType: "firmware_release", ResourceID: &item.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"model": input.Model, "version": input.Version, "digest": input.ExpectedDigest, "release_channel": item.ReleaseChannel, "mandatory": item.Mandatory}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
url, err := s.firmwareStore.PresignUpload(ctx, item.objectRef, 15*time.Minute)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
until := time.Now().Add(15 * time.Minute)
|
||||
item.UploadURL, item.UploadUntil = url, &until
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID string, mutate bool) (FirmwareRelease, opsFirmwareAccess, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, mutate)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, opsFirmwareAccess{}, err
|
||||
}
|
||||
var item FirmwareRelease
|
||||
err = s.pool.QueryRow(ctx, `SELECT public_id::text,COALESCE(household_id::text,''),model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),release_channel,mandatory,published_at,created_source,status,verified_at,created_at,updated_at,object_ref
|
||||
FROM firmware_release WHERE tenant_id=$1::uuid AND public_id=$2::uuid`, access.tenantID, releasePublicID).Scan(firmwareReleaseTargets(&item)...)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FirmwareRelease{}, opsFirmwareAccess{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, opsFirmwareAccess{}, err
|
||||
}
|
||||
return item, access, nil
|
||||
}
|
||||
|
||||
func (s *Service) FinalizeOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareRelease, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
if item.Status == "READY" {
|
||||
return item, nil
|
||||
}
|
||||
if item.Status != "UPLOADING" || s.firmwareStore == nil {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
if err := s.firmwareStore.VerifyDigest(ctx, item.objectRef, item.ExpectedDigest, item.ExpectedSize); err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
var publicKeyBase64 string
|
||||
if err := s.pool.QueryRow(ctx, `SELECT public_key_base64 FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 AND status='ACTIVE'`, access.tenantID, item.SigningKeyID).Scan(&publicKeyBase64); err != nil {
|
||||
return FirmwareRelease{}, ErrFirmwareSigningKeyInactive
|
||||
}
|
||||
publicKey, keyErr := base64.StdEncoding.DecodeString(publicKeyBase64)
|
||||
signature, signatureErr := base64.StdEncoding.DecodeString(item.Signature)
|
||||
if keyErr != nil || signatureErr != nil || len(publicKey) != ed25519.PublicKeySize || !ed25519.Verify(ed25519.PublicKey(publicKey), []byte(firmwareManifest(item.Model, item.HardwareRevision, item.Version, item.ExpectedDigest, item.ExpectedSize)), signature) {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
command, err := tx.Exec(ctx, `UPDATE firmware_release SET status='READY',verified_at=now(),published_at=COALESCE(published_at,now()),updated_at=now() WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='UPLOADING'`, access.tenantID, releasePublicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrFirmwareStateConflict
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.ready", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success", Detail: map[string]any{"digest": item.ExpectedDigest, "size": item.ExpectedSize}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
return s.currentOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID)
|
||||
}
|
||||
|
||||
func (s *Service) RenewOpsFirmwareUpload(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareRelease, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
if item.Status != "UPLOADING" || s.firmwareStore == nil {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
url, err := s.firmwareStore.PresignUpload(ctx, item.objectRef, 15*time.Minute)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
until := time.Now().Add(15 * time.Minute)
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var currentStatus string
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM firmware_release WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR SHARE`, access.tenantID, releasePublicID).Scan(¤tStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
if currentStatus != "UPLOADING" {
|
||||
return ErrFirmwareStateConflict
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.upload_renew", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success", Detail: map[string]any{"upload_until": until}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
item.UploadURL, item.UploadUntil = url, &until
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) PauseOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareRelease, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
if item.Status == "PAUSED" {
|
||||
return item, nil
|
||||
}
|
||||
if item.Status != "READY" {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var releaseID string
|
||||
if err := tx.QueryRow(ctx, `UPDATE firmware_release SET status='PAUSED',updated_at=now() WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='READY' RETURNING id::text`, access.tenantID, releasePublicID).Scan(&releaseID); err != nil {
|
||||
return ErrFirmwareStateConflict
|
||||
}
|
||||
paused, err := tx.Exec(ctx, `UPDATE firmware_rollout SET status='PAUSED',updated_at=now() WHERE release_id=$1::uuid AND status='RUNNING'`, releaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cancelled, err := tx.Exec(ctx, `UPDATE device_command dc SET status='CANCELLED',completed_at=now(),version=version+1,updated_at=now() FROM firmware_assignment fa JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE fa.command_id=dc.id AND ro.release_id=$1::uuid AND ro.status='PAUSED' AND dc.status='QUEUED'`, releaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.pause", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success", Detail: map[string]any{"paused_rollouts": paused.RowsAffected(), "cancelled_commands": cancelled.RowsAffected()}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
return s.currentOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID)
|
||||
}
|
||||
|
||||
func (s *Service) ResumeOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareRelease, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
if item.Status == "READY" {
|
||||
return item, nil
|
||||
}
|
||||
if item.Status != "PAUSED" {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
command, err := tx.Exec(ctx, `UPDATE firmware_release fr SET status='READY',updated_at=now() FROM firmware_signing_key fsk WHERE fr.tenant_id=$1::uuid AND fr.public_id=$2::uuid AND fr.status='PAUSED' AND fsk.tenant_id=fr.tenant_id AND fsk.key_id=fr.signing_key_id AND fsk.status='ACTIVE'`, access.tenantID, releasePublicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrFirmwareSigningKeyInactive
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.resume", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success"})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
return s.currentOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID)
|
||||
}
|
||||
|
||||
func (s *Service) RetireOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareRelease, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
if item.Status != "UPLOADING" && item.Status != "READY" && item.Status != "PAUSED" && item.Status != "RETIRED" {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
removeObject := item.Status == "UPLOADING" || item.Status == "RETIRED" && item.VerifiedAt == nil
|
||||
if item.Status != "RETIRED" {
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var releaseID, status string
|
||||
var verifiedAt *time.Time
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,status,verified_at FROM firmware_release WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, access.tenantID, releasePublicID).Scan(&releaseID, &status, &verifiedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "READY" || status == "PAUSED" {
|
||||
var running bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM firmware_rollout WHERE release_id=$1::uuid AND status='RUNNING')`, releaseID).Scan(&running); err != nil {
|
||||
return err
|
||||
}
|
||||
if running {
|
||||
return ErrFirmwareReleaseInUse
|
||||
}
|
||||
removeObject = false
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE firmware_release SET status='RETIRED',updated_at=now() WHERE id=$1::uuid AND status=$2`, releaseID, status); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.retire", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": status, "object_preserved": verifiedAt != nil}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
item.Status, item.UpdatedAt = "RETIRED", time.Now()
|
||||
}
|
||||
if removeObject {
|
||||
if s.firmwareStore == nil {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
if err := s.firmwareStore.Remove(ctx, item.objectRef); err != nil {
|
||||
return FirmwareRelease{}, fmt.Errorf("remove retired operations firmware object: %w", err)
|
||||
}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) currentOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID string) (FirmwareRelease, error) {
|
||||
item, _, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, false)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) OpsFirmwareDownload(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareDownload, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, false)
|
||||
if err != nil {
|
||||
return FirmwareDownload{}, err
|
||||
}
|
||||
if item.VerifiedAt == nil || (item.Status != "READY" && item.Status != "PAUSED" && item.Status != "RETIRED") || s.firmwareStore == nil {
|
||||
return FirmwareDownload{}, ErrFirmwareStateConflict
|
||||
}
|
||||
ttl := s.firmwareURLTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 5 * time.Minute
|
||||
}
|
||||
url, err := s.firmwareStore.PresignDownload(ctx, item.objectRef, ttl, "firmware-"+safePath(item.Model)+"-"+safePath(item.Version)+".bin")
|
||||
if err != nil {
|
||||
return FirmwareDownload{}, err
|
||||
}
|
||||
until := time.Now().Add(ttl)
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.download", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success", Detail: map[string]any{"expires_at": until}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareDownload{}, err
|
||||
}
|
||||
return FirmwareDownload{ReleaseID: item.ID, Version: item.Version, ExpectedSize: item.ExpectedSize, SHA256: item.ExpectedDigest, Signature: item.Signature, SigningKeyID: item.SigningKeyID, DownloadURL: url, DownloadUntil: until}, nil
|
||||
}
|
||||
|
||||
func (s *Service) StartOpsFirmwareRollout(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID, name string, percentage int, deviceIDs []string) (FirmwareRollout, error) {
|
||||
if strings.TrimSpace(name) == "" || percentage < 1 || percentage > 100 || len(deviceIDs) == 0 {
|
||||
return FirmwareRollout{}, ErrFirmwareStateConflict
|
||||
}
|
||||
deviceIDs = append([]string(nil), deviceIDs...)
|
||||
sort.Strings(deviceIDs)
|
||||
uniqueDeviceIDs := deviceIDs[:0]
|
||||
for _, deviceID := range deviceIDs {
|
||||
if len(uniqueDeviceIDs) == 0 || uniqueDeviceIDs[len(uniqueDeviceIDs)-1] != deviceID {
|
||||
uniqueDeviceIDs = append(uniqueDeviceIDs, deviceID)
|
||||
}
|
||||
}
|
||||
deviceIDs = uniqueDeviceIDs
|
||||
var result FirmwareRollout
|
||||
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var releaseID, model, hardware, version, digest, signature, keyID string
|
||||
err = tx.QueryRow(ctx, `SELECT fr.id::text,fr.model,COALESCE(fr.hardware_revision,''),fr.version,fr.expected_digest,fr.signature,fr.signing_key_id
|
||||
FROM firmware_release fr JOIN firmware_signing_key fsk ON fsk.tenant_id=fr.tenant_id AND fsk.key_id=fr.signing_key_id AND fsk.status='ACTIVE'
|
||||
WHERE fr.tenant_id=$1::uuid AND fr.public_id=$2::uuid AND fr.status='READY' FOR SHARE OF fr,fsk`, access.tenantID, releasePublicID).
|
||||
Scan(&releaseID, &model, &hardware, &version, &digest, &signature, &keyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrFirmwareStateConflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var rolloutID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO firmware_rollout(tenant_id,household_id,release_id,name,status,target_percentage,created_by,started_at)
|
||||
VALUES($1::uuid,NULL,$2::uuid,$3,'RUNNING',$4,$5::uuid,now()) RETURNING id::text,public_id::text,created_at,updated_at`,
|
||||
access.tenantID, releaseID, strings.TrimSpace(name), percentage, access.userID).Scan(&rolloutID, &result.ID, &result.CreatedAt, &result.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.ReleaseID, result.Name, result.Status, result.TargetPercentage = releasePublicID, strings.TrimSpace(name), "RUNNING", percentage
|
||||
selected := 0
|
||||
for _, devicePublicID := range deviceIDs {
|
||||
hash := sha256.Sum256([]byte(devicePublicID))
|
||||
if int(hash[0])%100 >= percentage && selected > 0 {
|
||||
continue
|
||||
}
|
||||
var deviceID, previous string
|
||||
err := tx.QueryRow(ctx, `SELECT d.id::text,COALESCE(dt.firmware_version,'') FROM device d JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
|
||||
WHERE d.tenant_id=$1::uuid AND d.public_id=$2::uuid AND d.status='registered' AND d.model=$3 AND ($4='' OR d.hardware_revision=$4)`,
|
||||
access.tenantID, devicePublicID, model, hardware).Scan(&deviceID, &previous)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: rollout target %s is incompatible or inaccessible", ErrFirmwareStateConflict, devicePublicID)
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"release_id": releasePublicID, "version": version, "sha256": digest, "signature": signature, "signing_key_id": keyID, "download_path": "/device/v2/firmware-releases/" + releasePublicID + "/download"})
|
||||
requestDigest := sha256.Sum256(payload)
|
||||
var commandID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO device_command(tenant_id,device_id,command_type,payload,requested_by,idempotency_key,request_digest,expires_at)
|
||||
VALUES($1::uuid,$2::uuid,'firmware_update',$3::jsonb,$4::uuid,$5,$6,now()+interval '24 hours') RETURNING id::text`,
|
||||
access.tenantID, deviceID, payload, access.userID, "ops-firmware-rollout:"+rolloutID+":"+deviceID, hex.EncodeToString(requestDigest[:])).Scan(&commandID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO firmware_assignment(rollout_id,tenant_id,device_id,command_id,previous_version,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,NULLIF($5,''),'ASSIGNED')`, rolloutID, access.tenantID, deviceID, commandID, previous); err != nil {
|
||||
return err
|
||||
}
|
||||
selected++
|
||||
}
|
||||
if selected == 0 {
|
||||
return errors.New("rollout selected no devices")
|
||||
}
|
||||
result.Targeted = selected
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.rollout.start", ResourceType: "firmware_rollout", ResourceID: &result.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"release_id": releasePublicID, "targeted": selected, "percentage": percentage}})
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) ListOpsFirmwareRollouts(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]FirmwareRollout, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT ro.public_id::text,fr.public_id::text,COALESCE(h.public_id::text,''),ro.name,ro.status,ro.target_percentage,
|
||||
count(fa.id),count(fa.id) FILTER(WHERE fa.status='CONFIRMED'),count(fa.id) FILTER(WHERE fa.status='FAILED'),ro.created_at,ro.updated_at
|
||||
FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id LEFT JOIN household h ON h.id=ro.household_id
|
||||
LEFT JOIN firmware_assignment fa ON fa.rollout_id=ro.id WHERE ro.tenant_id=$1::uuid
|
||||
GROUP BY ro.id,fr.public_id,h.public_id ORDER BY ro.created_at DESC LIMIT 200`, access.tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]FirmwareRollout, 0)
|
||||
for rows.Next() {
|
||||
var item FirmwareRollout
|
||||
var targeted, succeeded, failed int64
|
||||
if err := rows.Scan(&item.ID, &item.ReleaseID, &item.HouseholdID, &item.Name, &item.Status, &item.TargetPercentage, &targeted, &succeeded, &failed, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Targeted, item.Succeeded, item.Failed = int(targeted), int(succeeded), int(failed)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) GetOpsFirmwareRollout(ctx context.Context, principal identity.Principal, tenantPublicID, rolloutPublicID string) (FirmwareRollout, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return FirmwareRollout{}, err
|
||||
}
|
||||
var item FirmwareRollout
|
||||
var targeted, succeeded, failed int64
|
||||
err = s.pool.QueryRow(ctx, `SELECT ro.public_id::text,fr.public_id::text,COALESCE(h.public_id::text,''),ro.name,ro.status,ro.target_percentage,
|
||||
count(fa.id),count(fa.id) FILTER(WHERE fa.status='CONFIRMED'),count(fa.id) FILTER(WHERE fa.status='FAILED'),ro.created_at,ro.updated_at
|
||||
FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id LEFT JOIN household h ON h.id=ro.household_id
|
||||
LEFT JOIN firmware_assignment fa ON fa.rollout_id=ro.id WHERE ro.tenant_id=$1::uuid AND ro.public_id=$2::uuid
|
||||
GROUP BY ro.id,fr.public_id,h.public_id`, access.tenantID, rolloutPublicID).
|
||||
Scan(&item.ID, &item.ReleaseID, &item.HouseholdID, &item.Name, &item.Status, &item.TargetPercentage, &targeted, &succeeded, &failed, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FirmwareRollout{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return FirmwareRollout{}, err
|
||||
}
|
||||
item.Targeted, item.Succeeded, item.Failed = int(targeted), int(succeeded), int(failed)
|
||||
item.Assignments, err = s.listFirmwareAssignments(ctx, rolloutPublicID)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) PauseOpsFirmwareRollout(ctx context.Context, principal identity.Principal, tenantPublicID, rolloutPublicID, requestID string) (FirmwareRollout, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRollout{}, err
|
||||
}
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
command, err := tx.Exec(ctx, `UPDATE firmware_rollout SET status='PAUSED',updated_at=now() WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='RUNNING'`, access.tenantID, rolloutPublicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrFirmwareStateConflict
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_command dc SET status='CANCELLED',completed_at=now(),version=version+1,updated_at=now() FROM firmware_assignment fa JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE fa.command_id=dc.id AND ro.public_id=$1::uuid AND dc.status='QUEUED'`, rolloutPublicID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.rollout.pause", ResourceType: "firmware_rollout", ResourceID: &rolloutPublicID, RequestID: requestID, Result: "success"})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRollout{}, err
|
||||
}
|
||||
return s.GetOpsFirmwareRollout(ctx, principal, tenantPublicID, rolloutPublicID)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package saas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestOpsFirmwareReleaseLifecycle(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()
|
||||
store := &firmwareIntegrationStore{}
|
||||
service := NewService(pool, audit.NewRepository(), nil, nil)
|
||||
service.ConfigureFirmwareStore(store, 4*time.Minute)
|
||||
var operator identity.Principal
|
||||
if err := pool.QueryRow(ctx, `SELECT oidc_issuer,oidc_subject FROM user_account WHERE public_id='10000000-0000-0000-0000-000000000114'::uuid AND status='active' AND account_kind='operator'`).Scan(&operator.Issuer, &operator.Subject); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const tenantID = "10000000-0000-0000-0000-000000000010"
|
||||
keyID := "ops-firmware-" + uuid.NewString()
|
||||
version := "ops-" + uuid.NewString()
|
||||
var releaseID, rolloutID string
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'ops-firmware-integration-%'`)
|
||||
if rolloutID != "" {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM firmware_rollout WHERE public_id=$1::uuid`, rolloutID)
|
||||
}
|
||||
if releaseID != "" {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_command WHERE payload->>'release_id'=$1`, releaseID)
|
||||
}
|
||||
if releaseID != "" {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM firmware_release WHERE public_id=$1::uuid`, releaseID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM firmware_signing_key WHERE key_id=$1`, keyID)
|
||||
}()
|
||||
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RegisterOpsFirmwareSigningKey(ctx, operator, tenantID, "ops-firmware-integration-key", keyID, base64.StdEncoding.EncodeToString(publicKey)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firmware := []byte("synthetic operations firmware")
|
||||
digestBytes := sha256.Sum256(firmware)
|
||||
digest := hex.EncodeToString(digestBytes[:])
|
||||
model, hardware := "simulated-terminal", "sim-rev-a"
|
||||
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(firmwareManifest(model, hardware, version, digest, int64(len(firmware))))))
|
||||
prepared, err := service.PrepareOpsFirmwareRelease(ctx, operator, tenantID, "ops-firmware-integration-prepare", PrepareFirmwareInput{
|
||||
Model: model, HardwareRevision: hardware, Version: version, ExpectedSize: int64(len(firmware)), ExpectedDigest: digest,
|
||||
Signature: signature, SigningKeyID: keyID, ReleaseChannel: "BETA", Mandatory: true, ReleaseNotes: "维护端集成测试",
|
||||
})
|
||||
if err != nil || prepared.Status != "UPLOADING" || prepared.HouseholdID != "" || prepared.CreatedSource != "OPS" || prepared.ReleaseChannel != "BETA" || !prepared.Mandatory || prepared.UploadURL == "" {
|
||||
t.Fatalf("prepared=%+v err=%v", prepared, err)
|
||||
}
|
||||
releaseID = prepared.ID
|
||||
ready, err := service.FinalizeOpsFirmwareRelease(ctx, operator, tenantID, releaseID, "ops-firmware-integration-finalize")
|
||||
if err != nil || ready.Status != "READY" || ready.VerifiedAt == nil || ready.PublishedAt == nil || store.verifiedDigest != digest {
|
||||
t.Fatalf("ready=%+v store=%+v err=%v", ready, store, err)
|
||||
}
|
||||
items, err := service.ListOpsFirmwareReleases(ctx, operator, tenantID, model, "READY")
|
||||
if err != nil || len(items) != 1 || items[0].ID != releaseID {
|
||||
t.Fatalf("items=%+v err=%v", items, err)
|
||||
}
|
||||
download, err := service.OpsFirmwareDownload(ctx, operator, tenantID, releaseID, "ops-firmware-integration-download")
|
||||
if err != nil || download.ReleaseID != releaseID || download.SHA256 != digest || download.DownloadURL == "" {
|
||||
t.Fatalf("download=%+v err=%v", download, err)
|
||||
}
|
||||
rollout, err := service.StartOpsFirmwareRollout(ctx, operator, tenantID, releaseID, "ops-firmware-integration-rollout", "维护端OTA", 100, []string{"10000000-0000-0000-0000-000000000511"})
|
||||
if err != nil || rollout.Targeted != 1 || rollout.HouseholdID != "" || rollout.Status != "RUNNING" {
|
||||
t.Fatalf("rollout=%+v err=%v", rollout, err)
|
||||
}
|
||||
rolloutID = rollout.ID
|
||||
detail, err := service.GetOpsFirmwareRollout(ctx, operator, tenantID, rolloutID)
|
||||
if err != nil || len(detail.Assignments) != 1 || detail.Assignments[0].Status != "ASSIGNED" {
|
||||
t.Fatalf("rollout detail=%+v err=%v", detail, err)
|
||||
}
|
||||
pausedRollout, err := service.PauseOpsFirmwareRollout(ctx, operator, tenantID, rolloutID, "ops-firmware-integration-rollout-pause")
|
||||
if err != nil || pausedRollout.Status != "PAUSED" {
|
||||
t.Fatalf("paused rollout=%+v err=%v", pausedRollout, err)
|
||||
}
|
||||
paused, err := service.PauseOpsFirmwareRelease(ctx, operator, tenantID, releaseID, "ops-firmware-integration-pause")
|
||||
if err != nil || paused.Status != "PAUSED" {
|
||||
t.Fatalf("paused=%+v err=%v", paused, err)
|
||||
}
|
||||
resumed, err := service.ResumeOpsFirmwareRelease(ctx, operator, tenantID, releaseID, "ops-firmware-integration-resume")
|
||||
if err != nil || resumed.Status != "READY" {
|
||||
t.Fatalf("resumed=%+v err=%v", resumed, err)
|
||||
}
|
||||
retired, err := service.RetireOpsFirmwareRelease(ctx, operator, tenantID, releaseID, "ops-firmware-integration-retire")
|
||||
if err != nil || retired.Status != "RETIRED" || len(store.removed) != 0 {
|
||||
t.Fatalf("retired=%+v removed=%v err=%v", retired, store.removed, err)
|
||||
}
|
||||
customer := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
|
||||
if _, err := service.ListOpsFirmwareReleases(ctx, customer, tenantID, "", ""); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("customer ops access error=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -574,25 +574,26 @@ func (s *Service) stopConversationsAfterConsentWithdrawal(
|
||||
return []string{}, nil
|
||||
}
|
||||
type candidate struct {
|
||||
internalID, publicID, status string
|
||||
internalID, publicID, status, runtimeType string
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT c.id::text,c.public_id::text,c.status
|
||||
SELECT c.id::text,c.public_id::text,c.status,c.runtime_type
|
||||
FROM conversation c
|
||||
JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id
|
||||
JOIN rtc_task rt ON rt.conversation_id=c.id
|
||||
LEFT JOIN rtc_task rt ON rt.conversation_id=c.id
|
||||
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
|
||||
WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid
|
||||
AND c.status IN ('PREPARING','ROOM_ISSUED','STARTING_AGENT','ACTIVE')
|
||||
AND cs.effective_consents @> jsonb_build_object($3::text,true)
|
||||
ORDER BY c.created_at,c.id
|
||||
FOR UPDATE OF c,rt`, tenantID, subjectID, consentType)
|
||||
FOR UPDATE OF c`, tenantID, subjectID, consentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find conversations affected by consent withdrawal: %w", err)
|
||||
}
|
||||
candidates := make([]candidate, 0)
|
||||
for rows.Next() {
|
||||
var item candidate
|
||||
if err := rows.Scan(&item.internalID, &item.publicID, &item.status); err != nil {
|
||||
if err := rows.Scan(&item.internalID, &item.publicID, &item.status, &item.runtimeType); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("scan consent withdrawal conversation: %w", err)
|
||||
}
|
||||
@@ -621,7 +622,11 @@ func (s *Service) stopConversationsAfterConsentWithdrawal(
|
||||
if tag.RowsAffected() != 1 {
|
||||
return nil, fmt.Errorf("conversation changed during consent withdrawal")
|
||||
}
|
||||
if toStatus == "EXPIRED" {
|
||||
if item.runtimeType == "volc_hardware_agent" && toStatus == "EXPIRED" {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
|
||||
} else if item.runtimeType == "volc_hardware_agent" {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='STOPPING',updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
|
||||
} else if toStatus == "EXPIRED" {
|
||||
_, err = tx.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `UPDATE rtc_task SET status=CASE WHEN status='ACTIVE' THEN 'STOPPING' ELSE 'UNKNOWN' END,lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
|
||||
@@ -634,7 +639,7 @@ func (s *Service) stopConversationsAfterConsentWithdrawal(
|
||||
VALUES($1::uuid,$2,$3,$4,'platform')`, item.internalID, item.status, toStatus, "consent withdrawn: "+consentType); err != nil {
|
||||
return nil, fmt.Errorf("record consent withdrawal stop: %w", err)
|
||||
}
|
||||
if toStatus == "STOPPING" {
|
||||
if toStatus == "STOPPING" && item.runtimeType != "volc_hardware_agent" {
|
||||
deduplicationKey := "consent-withdrawal-stop:" + item.internalID + ":" + consentType
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"conversation_id": item.publicID,
|
||||
|
||||
Reference in New Issue
Block a user