964 lines
49 KiB
Go
964 lines
49 KiB
Go
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/deviceauth"
|
|
"data-terminal-cloud/internal/identity"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
var (
|
|
ErrFirmwareStateConflict = errors.New("firmware release or rollout state conflict")
|
|
ErrFirmwareSigningKeyConflict = errors.New("firmware signing key identity conflict")
|
|
ErrFirmwareSigningKeyInUse = errors.New("firmware signing key is still in use")
|
|
ErrFirmwareSigningKeyInactive = errors.New("firmware signing key is not active")
|
|
ErrFirmwareReleaseInUse = errors.New("firmware release has running rollouts")
|
|
)
|
|
|
|
type firmwareObjectStore interface {
|
|
PresignUpload(context.Context, string, time.Duration) (string, error)
|
|
PresignDownload(context.Context, string, time.Duration, string) (string, error)
|
|
VerifyDigest(context.Context, string, string, int64) error
|
|
Remove(context.Context, string) error
|
|
}
|
|
|
|
func (s *Service) ConfigureFirmwareStore(store firmwareObjectStore, ttl time.Duration) {
|
|
s.firmwareStore = store
|
|
s.firmwareURLTTL = ttl
|
|
}
|
|
|
|
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"`
|
|
ExpectedSize int64 `json:"expected_size"`
|
|
ExpectedDigest string `json:"expected_digest"`
|
|
SignatureAlgorithm string `json:"signature_algorithm"`
|
|
Signature string `json:"signature"`
|
|
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"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
UploadURL string `json:"upload_url,omitempty"`
|
|
UploadUntil *time.Time `json:"upload_until,omitempty"`
|
|
objectRef string
|
|
}
|
|
type FirmwareRollout struct {
|
|
ID string `json:"id"`
|
|
ReleaseID string `json:"release_id"`
|
|
HouseholdID string `json:"household_id,omitempty"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
TargetPercentage int `json:"target_percentage"`
|
|
Targeted int `json:"targeted"`
|
|
Succeeded int `json:"succeeded"`
|
|
Failed int `json:"failed"`
|
|
Assignments []FirmwareAssignment `json:"assignments,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
type FirmwareAssignment struct {
|
|
DeviceID string `json:"device_id"`
|
|
DeviceModel string `json:"device_model"`
|
|
PreviousVersion string `json:"previous_version,omitempty"`
|
|
Status string `json:"status"`
|
|
ProgressPercent *int `json:"progress_percent,omitempty"`
|
|
LastResult map[string]any `json:"last_result"`
|
|
Events []FirmwareAssignmentEvent `json:"events"`
|
|
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
type FirmwareAssignmentEvent struct {
|
|
ID string `json:"id"`
|
|
CommandID string `json:"command_id"`
|
|
CommandStatus string `json:"command_status"`
|
|
AssignmentStatus string `json:"assignment_status"`
|
|
Phase *string `json:"phase,omitempty"`
|
|
ProgressPercent *int `json:"progress_percent,omitempty"`
|
|
ResultCode *string `json:"result_code,omitempty"`
|
|
ActiveSlot *string `json:"active_slot,omitempty"`
|
|
TargetSlot *string `json:"target_slot,omitempty"`
|
|
PreviousSlot *string `json:"previous_slot,omitempty"`
|
|
FailedSlot *string `json:"failed_slot,omitempty"`
|
|
BootAttemptID *string `json:"boot_attempt_id,omitempty"`
|
|
BootHealthDeadline *string `json:"boot_health_deadline,omitempty"`
|
|
RollbackApplied *bool `json:"rollback_applied,omitempty"`
|
|
RollbackVersion *string `json:"rollback_version,omitempty"`
|
|
InstalledVersion *string `json:"installed_version,omitempty"`
|
|
VerifiedSHA256 *string `json:"verified_sha256,omitempty"`
|
|
VerifiedSigningKeyID *string `json:"verified_signing_key_id,omitempty"`
|
|
ResultDigest string `json:"result_digest"`
|
|
ReportedAt time.Time `json:"reported_at"`
|
|
}
|
|
type FirmwareSigningKey struct {
|
|
KeyID string `json:"key_id"`
|
|
FingerprintSHA256 string `json:"fingerprint_sha256"`
|
|
Status string `json:"status"`
|
|
CreatedBy string `json:"created_by"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
|
UploadingReleases int `json:"uploading_releases"`
|
|
ReadyReleases int `json:"ready_releases"`
|
|
RunningRollouts int `json:"running_rollouts"`
|
|
}
|
|
type FirmwareReleaseImpact struct {
|
|
ReleaseID string `json:"release_id"`
|
|
ReleaseStatus string `json:"release_status"`
|
|
Rollouts FirmwareImpactRolloutCount `json:"rollouts"`
|
|
Devices FirmwareImpactDeviceCount `json:"devices"`
|
|
Commands FirmwareImpactCommandCount `json:"commands"`
|
|
ObservationDevices []FirmwareImpactDevice `json:"observation_devices"`
|
|
ObservationDevicesTruncated bool `json:"observation_devices_truncated"`
|
|
CalculatedAt time.Time `json:"calculated_at"`
|
|
}
|
|
type FirmwareImpactRolloutCount struct {
|
|
Total int `json:"total"`
|
|
Running int `json:"running"`
|
|
Paused int `json:"paused"`
|
|
Completed int `json:"completed"`
|
|
RolledBack int `json:"rolled_back"`
|
|
}
|
|
type FirmwareImpactDeviceCount struct {
|
|
Total int `json:"total"`
|
|
Assigned int `json:"assigned"`
|
|
Downloading int `json:"downloading"`
|
|
Installing int `json:"installing"`
|
|
Confirmed int `json:"confirmed"`
|
|
Failed int `json:"failed"`
|
|
RolledBack int `json:"rolled_back"`
|
|
Observing int `json:"observing"`
|
|
}
|
|
type FirmwareImpactCommandCount struct {
|
|
Total int `json:"total"`
|
|
Queued int `json:"queued"`
|
|
Delivered int `json:"delivered"`
|
|
Acknowledged int `json:"acknowledged"`
|
|
Succeeded int `json:"succeeded"`
|
|
Failed int `json:"failed"`
|
|
Expired int `json:"expired"`
|
|
CancelledBeforeDelivery int `json:"cancelled_before_delivery"`
|
|
}
|
|
type FirmwareImpactDevice struct {
|
|
DeviceID string `json:"device_id"`
|
|
DeviceModel string `json:"device_model"`
|
|
RolloutID string `json:"rollout_id"`
|
|
RolloutName string `json:"rollout_name"`
|
|
RolloutStatus string `json:"rollout_status"`
|
|
AssignmentStatus string `json:"assignment_status"`
|
|
ProgressPercent *int `json:"progress_percent,omitempty"`
|
|
CommandStatus string `json:"command_status"`
|
|
ObservationReason string `json:"observation_reason"`
|
|
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
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 {
|
|
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 {
|
|
a, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, 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`, a.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)`, a.tenantID, keyID, publicKeyBase64, a.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: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "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) ListFirmwareSigningKeys(ctx context.Context, p identity.Principal, householdPublicID string) ([]FirmwareSigningKey, error) {
|
|
access, err := resolveHouseholdAccess(ctx, s.pool, p, householdPublicID, identity.CapabilityFirmwareManage, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.listFirmwareSigningKeys(ctx, access.tenantID)
|
|
}
|
|
|
|
func (s *Service) listFirmwareSigningKeys(ctx context.Context, tenantID string) ([]FirmwareSigningKey, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT k.key_id,k.public_key_base64,k.status,creator.public_id::text,k.created_at,k.revoked_at,
|
|
(SELECT count(*) FROM firmware_release fr WHERE fr.tenant_id=k.tenant_id AND fr.signing_key_id=k.key_id AND fr.status='UPLOADING'),
|
|
(SELECT count(*) FROM firmware_release fr WHERE fr.tenant_id=k.tenant_id AND fr.signing_key_id=k.key_id AND fr.status='READY'),
|
|
(SELECT count(*) 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=k.tenant_id AND fr.signing_key_id=k.key_id AND ro.status='RUNNING')
|
|
FROM firmware_signing_key k JOIN user_account creator ON creator.id=k.created_by AND creator.tenant_id=k.tenant_id
|
|
WHERE k.tenant_id=$1::uuid ORDER BY k.created_at DESC,k.key_id`, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list firmware signing keys: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
items := make([]FirmwareSigningKey, 0)
|
|
for rows.Next() {
|
|
var item FirmwareSigningKey
|
|
var publicKeyBase64 string
|
|
if err := rows.Scan(&item.KeyID, &publicKeyBase64, &item.Status, &item.CreatedBy, &item.CreatedAt, &item.RevokedAt, &item.UploadingReleases, &item.ReadyReleases, &item.RunningRollouts); err != nil {
|
|
return nil, fmt.Errorf("scan firmware signing key: %w", err)
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(publicKeyBase64)
|
|
if err != nil || len(decoded) != ed25519.PublicKeySize {
|
|
return nil, ErrFirmwareSigningKeyConflict
|
|
}
|
|
fingerprint := sha256.Sum256(decoded)
|
|
item.FingerprintSHA256 = hex.EncodeToString(fingerprint[:])
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate firmware signing keys: %w", err)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *Service) RevokeFirmwareSigningKey(ctx context.Context, p identity.Principal, householdPublicID, requestID, keyID string) (FirmwareSigningKey, error) {
|
|
var tenantID string
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
|
access, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantID = 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`, 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'
|
|
)`, tenantID, keyID).Scan(&blocked); err != nil {
|
|
return err
|
|
}
|
|
if blocked {
|
|
return ErrFirmwareSigningKeyInUse
|
|
}
|
|
command, 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'`, tenantID, keyID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if command.RowsAffected() != 1 {
|
|
return ErrFirmwareSigningKeyConflict
|
|
}
|
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: &tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "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, tenantID)
|
|
if err != nil {
|
|
return FirmwareSigningKey{}, err
|
|
}
|
|
for _, item := range items {
|
|
if item.KeyID == keyID {
|
|
return item, nil
|
|
}
|
|
}
|
|
return FirmwareSigningKey{}, ErrFirmwareSigningKeyConflict
|
|
}
|
|
|
|
func (s *Service) PrepareFirmwareRelease(ctx context.Context, p identity.Principal, householdPublicID, requestID string, in PrepareFirmwareInput) (FirmwareRelease, error) {
|
|
if s.firmwareStore == nil {
|
|
return FirmwareRelease{}, ErrFirmwareStateConflict
|
|
}
|
|
var item FirmwareRelease
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
|
a, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, 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')`, a.tenantID, in.SigningKeyID).Scan(&signingKeyActive); err != nil {
|
|
return err
|
|
}
|
|
if !signingKeyActive {
|
|
return ErrFirmwareSigningKeyInactive
|
|
}
|
|
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,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
|
|
}
|
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "firmware.release.prepare", ResourceType: "firmware_release", ResourceID: &item.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"model": in.Model, "version": in.Version, "digest": in.ExpectedDigest}})
|
|
})
|
|
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 = url
|
|
item.UploadUntil = &until
|
|
return item, nil
|
|
}
|
|
func safePath(v string) string {
|
|
v = strings.TrimSpace(v)
|
|
r := strings.NewReplacer("/", "-", "\\", "-", "..", "-")
|
|
return r.Replace(v)
|
|
}
|
|
func firmwareReleaseTargets(i *FirmwareRelease) []any {
|
|
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) {
|
|
items := []FirmwareRelease{}
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
|
a, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var item FirmwareRelease
|
|
if err := rows.Scan(firmwareReleaseTargets(&item)...); err != nil {
|
|
return err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return rows.Err()
|
|
})
|
|
return items, err
|
|
}
|
|
|
|
func (s *Service) GetFirmwareReleaseImpact(ctx context.Context, p identity.Principal, publicID string) (FirmwareReleaseImpact, error) {
|
|
item, access, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
|
|
if err != nil {
|
|
return FirmwareReleaseImpact{}, err
|
|
}
|
|
result := FirmwareReleaseImpact{ReleaseID: item.ID, ReleaseStatus: item.Status, ObservationDevices: []FirmwareImpactDevice{}, CalculatedAt: time.Now().UTC()}
|
|
err = s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
count(DISTINCT ro.id),
|
|
count(DISTINCT ro.id) FILTER(WHERE ro.status='RUNNING'),
|
|
count(DISTINCT ro.id) FILTER(WHERE ro.status='PAUSED'),
|
|
count(DISTINCT ro.id) FILTER(WHERE ro.status='COMPLETED'),
|
|
count(DISTINCT ro.id) FILTER(WHERE ro.status='ROLLED_BACK'),
|
|
count(fa.id),
|
|
count(fa.id) FILTER(WHERE fa.status='ASSIGNED'),
|
|
count(fa.id) FILTER(WHERE fa.status='DOWNLOADING'),
|
|
count(fa.id) FILTER(WHERE fa.status='INSTALLING'),
|
|
count(fa.id) FILTER(WHERE fa.status='CONFIRMED'),
|
|
count(fa.id) FILTER(WHERE fa.status='FAILED'),
|
|
count(fa.id) FILTER(WHERE fa.status='ROLLED_BACK'),
|
|
count(fa.id) FILTER(WHERE fa.status IN ('DOWNLOADING','INSTALLING') OR (fa.status='ASSIGNED' AND dc.status IN ('DELIVERED','ACKNOWLEDGED','SUCCEEDED','FAILED'))),
|
|
count(dc.id),
|
|
count(dc.id) FILTER(WHERE dc.status='QUEUED'),
|
|
count(dc.id) FILTER(WHERE dc.status='DELIVERED'),
|
|
count(dc.id) FILTER(WHERE dc.status='ACKNOWLEDGED'),
|
|
count(dc.id) FILTER(WHERE dc.status='SUCCEEDED'),
|
|
count(dc.id) FILTER(WHERE dc.status='FAILED'),
|
|
count(dc.id) FILTER(WHERE dc.status='EXPIRED'),
|
|
count(dc.id) FILTER(WHERE dc.status='CANCELLED')
|
|
FROM firmware_release fr
|
|
LEFT JOIN firmware_rollout ro ON ro.release_id=fr.id AND ro.tenant_id=fr.tenant_id
|
|
LEFT JOIN firmware_assignment fa ON fa.rollout_id=ro.id AND fa.tenant_id=fr.tenant_id
|
|
LEFT JOIN device_command dc ON dc.id=fa.command_id AND dc.tenant_id=fr.tenant_id
|
|
WHERE fr.tenant_id=$1::uuid AND fr.public_id=$2::uuid
|
|
GROUP BY fr.id`, access.tenantID, publicID).Scan(
|
|
&result.Rollouts.Total, &result.Rollouts.Running, &result.Rollouts.Paused, &result.Rollouts.Completed, &result.Rollouts.RolledBack,
|
|
&result.Devices.Total, &result.Devices.Assigned, &result.Devices.Downloading, &result.Devices.Installing, &result.Devices.Confirmed, &result.Devices.Failed, &result.Devices.RolledBack, &result.Devices.Observing,
|
|
&result.Commands.Total, &result.Commands.Queued, &result.Commands.Delivered, &result.Commands.Acknowledged, &result.Commands.Succeeded, &result.Commands.Failed, &result.Commands.Expired, &result.Commands.CancelledBeforeDelivery,
|
|
)
|
|
if err != nil {
|
|
return FirmwareReleaseImpact{}, fmt.Errorf("summarize firmware release impact: %w", err)
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT d.public_id::text,d.model,ro.public_id::text,ro.name,ro.status,fa.status,fa.progress_percent,COALESCE(dc.status,'UNKNOWN'),fa.last_reported_at,fa.updated_at
|
|
FROM firmware_release fr
|
|
JOIN firmware_rollout ro ON ro.release_id=fr.id AND ro.tenant_id=fr.tenant_id
|
|
JOIN firmware_assignment fa ON fa.rollout_id=ro.id AND fa.tenant_id=fr.tenant_id
|
|
JOIN device d ON d.id=fa.device_id AND d.tenant_id=fr.tenant_id
|
|
LEFT JOIN device_command dc ON dc.id=fa.command_id AND dc.tenant_id=fr.tenant_id
|
|
WHERE fr.tenant_id=$1::uuid AND fr.public_id=$2::uuid
|
|
AND (fa.status IN ('DOWNLOADING','INSTALLING') OR (fa.status='ASSIGNED' AND dc.status IN ('DELIVERED','ACKNOWLEDGED','SUCCEEDED','FAILED')))
|
|
ORDER BY CASE fa.status WHEN 'INSTALLING' THEN 1 WHEN 'DOWNLOADING' THEN 2 ELSE 3 END,fa.updated_at DESC,d.public_id
|
|
LIMIT 101`, access.tenantID, publicID)
|
|
if err != nil {
|
|
return FirmwareReleaseImpact{}, fmt.Errorf("list firmware observation devices: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var observation FirmwareImpactDevice
|
|
if err := rows.Scan(&observation.DeviceID, &observation.DeviceModel, &observation.RolloutID, &observation.RolloutName, &observation.RolloutStatus, &observation.AssignmentStatus, &observation.ProgressPercent, &observation.CommandStatus, &observation.LastReportedAt, &observation.UpdatedAt); err != nil {
|
|
return FirmwareReleaseImpact{}, fmt.Errorf("scan firmware observation device: %w", err)
|
|
}
|
|
observation.ObservationReason = firmwareObservationReason(observation.AssignmentStatus, observation.CommandStatus)
|
|
result.ObservationDevices = append(result.ObservationDevices, observation)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return FirmwareReleaseImpact{}, fmt.Errorf("iterate firmware observation devices: %w", err)
|
|
}
|
|
if len(result.ObservationDevices) > 100 {
|
|
result.ObservationDevices = result.ObservationDevices[:100]
|
|
result.ObservationDevicesTruncated = true
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func firmwareObservationReason(assignmentStatus, commandStatus string) string {
|
|
switch assignmentStatus {
|
|
case "INSTALLING":
|
|
return "INSTALLATION_IN_PROGRESS"
|
|
case "DOWNLOADING":
|
|
return "DOWNLOAD_IN_PROGRESS"
|
|
}
|
|
switch commandStatus {
|
|
case "DELIVERED":
|
|
return "COMMAND_DELIVERED"
|
|
case "ACKNOWLEDGED":
|
|
return "COMMAND_ACKNOWLEDGED"
|
|
default:
|
|
return "RESULT_RECONCILIATION_REQUIRED"
|
|
}
|
|
}
|
|
func (s *Service) FinalizeFirmwareRelease(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRelease, error) {
|
|
item, a, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
|
|
if err != nil {
|
|
return FirmwareRelease{}, err
|
|
}
|
|
if item.Status == "READY" {
|
|
return item, nil
|
|
}
|
|
if item.Status != "UPLOADING" {
|
|
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'`, a.tenantID, item.SigningKeyID).Scan(&publicKeyBase64); err != nil {
|
|
return FirmwareRelease{}, ErrFirmwareStateConflict
|
|
}
|
|
publicKey, err := base64.StdEncoding.DecodeString(publicKeyBase64)
|
|
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
|
return FirmwareRelease{}, ErrFirmwareStateConflict
|
|
}
|
|
signature, err := base64.StdEncoding.DecodeString(item.Signature)
|
|
if err != nil {
|
|
return FirmwareRelease{}, ErrFirmwareStateConflict
|
|
}
|
|
if !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(),updated_at=now() WHERE public_id=$1::uuid AND status='UPLOADING'`, publicID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if command.RowsAffected() != 1 {
|
|
return ErrFirmwareStateConflict
|
|
}
|
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "firmware.release.ready", ResourceType: "firmware_release", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"digest": item.ExpectedDigest, "size": item.ExpectedSize}})
|
|
})
|
|
if err != nil {
|
|
return FirmwareRelease{}, err
|
|
}
|
|
item.Status = "READY"
|
|
now := time.Now()
|
|
item.VerifiedAt = &now
|
|
return item, nil
|
|
}
|
|
|
|
func (s *Service) RenewFirmwareUpload(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRelease, error) {
|
|
if s.firmwareStore == nil {
|
|
return FirmwareRelease{}, ErrFirmwareStateConflict
|
|
}
|
|
item, access, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
|
|
if err != nil {
|
|
return FirmwareRelease{}, err
|
|
}
|
|
if item.Status != "UPLOADING" {
|
|
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)
|
|
if 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 public_id=$1::uuid FOR SHARE`, publicID).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: "firmware.release.upload_renew", ResourceType: "firmware_release", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"upload_until": until}})
|
|
}); err != nil {
|
|
return FirmwareRelease{}, err
|
|
}
|
|
item.UploadURL = url
|
|
item.UploadUntil = &until
|
|
return item, nil
|
|
}
|
|
|
|
func (s *Service) PauseFirmwareRelease(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRelease, error) {
|
|
item, access, err := s.resolveFirmwareRelease(ctx, p, publicID, 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, status string
|
|
if err := tx.QueryRow(ctx, `SELECT id::text,status FROM firmware_release WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, access.tenantID, publicID).Scan(&releaseID, &status); err != nil {
|
|
return err
|
|
}
|
|
if status == "PAUSED" {
|
|
return nil
|
|
}
|
|
if status != "READY" {
|
|
return ErrFirmwareStateConflict
|
|
}
|
|
command, err := tx.Exec(ctx, `UPDATE firmware_release SET status='PAUSED',updated_at=now() WHERE id=$1::uuid AND status='READY'`, releaseID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if command.RowsAffected() != 1 {
|
|
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: "firmware.release.pause", ResourceType: "firmware_release", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": "READY", "paused_rollouts": paused.RowsAffected(), "cancelled_commands": cancelled.RowsAffected()}})
|
|
})
|
|
if err != nil {
|
|
return FirmwareRelease{}, err
|
|
}
|
|
return s.currentFirmwareRelease(ctx, p, publicID)
|
|
}
|
|
|
|
func (s *Service) ResumeFirmwareRelease(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRelease, error) {
|
|
item, access, err := s.resolveFirmwareRelease(ctx, p, publicID, 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 {
|
|
var status, keyStatus string
|
|
err := tx.QueryRow(ctx, `SELECT fr.status,fsk.status FROM firmware_release fr JOIN firmware_signing_key fsk ON fsk.tenant_id=fr.tenant_id AND fsk.key_id=fr.signing_key_id WHERE fr.tenant_id=$1::uuid AND fr.public_id=$2::uuid FOR UPDATE OF fr,fsk`, access.tenantID, publicID).Scan(&status, &keyStatus)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrFirmwareSigningKeyInactive
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status == "READY" {
|
|
return nil
|
|
}
|
|
if status != "PAUSED" {
|
|
return ErrFirmwareStateConflict
|
|
}
|
|
if keyStatus != "ACTIVE" {
|
|
return ErrFirmwareSigningKeyInactive
|
|
}
|
|
command, err := tx.Exec(ctx, `UPDATE firmware_release SET status='READY',updated_at=now() WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='PAUSED'`, access.tenantID, publicID)
|
|
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: "firmware.release.resume", ResourceType: "firmware_release", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": "PAUSED", "rollouts_resumed": 0}})
|
|
})
|
|
if err != nil {
|
|
return FirmwareRelease{}, err
|
|
}
|
|
return s.currentFirmwareRelease(ctx, p, publicID)
|
|
}
|
|
|
|
func (s *Service) currentFirmwareRelease(ctx context.Context, p identity.Principal, publicID string) (FirmwareRelease, error) {
|
|
item, _, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
|
|
return item, err
|
|
}
|
|
|
|
func (s *Service) RetireFirmwareRelease(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRelease, error) {
|
|
item, access, err := s.resolveFirmwareRelease(ctx, p, publicID, 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, publicID).Scan(&releaseID, &status, &verifiedAt); err != nil {
|
|
return err
|
|
}
|
|
if status == "RETIRED" {
|
|
removeObject = verifiedAt == nil
|
|
return nil
|
|
}
|
|
if status != "UPLOADING" && status != "READY" && status != "PAUSED" {
|
|
return ErrFirmwareStateConflict
|
|
}
|
|
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
|
|
}
|
|
command, err := tx.Exec(ctx, `UPDATE firmware_release SET status='RETIRED',updated_at=now() WHERE id=$1::uuid AND status=$2`, releaseID, status)
|
|
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: "firmware.release.retire", ResourceType: "firmware_release", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": status, "object_preserved": verifiedAt != nil}})
|
|
})
|
|
if err != nil {
|
|
return FirmwareRelease{}, err
|
|
}
|
|
item.Status = "RETIRED"
|
|
item.UpdatedAt = 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 firmware object: %w", err)
|
|
}
|
|
}
|
|
return item, nil
|
|
}
|
|
func firmwareManifest(model, hardware, version, digest string, size int64) string {
|
|
return fmt.Sprintf("firmware-manifest-v1\nmodel=%s\nhardware_revision=%s\nversion=%s\nsha256=%s\nsize=%d\n", model, hardware, version, digest, size)
|
|
}
|
|
func (s *Service) resolveFirmwareRelease(ctx context.Context, p identity.Principal, publicID string, manage bool) (FirmwareRelease, resourceAccess, error) {
|
|
cap := identity.CapabilityDeviceManage
|
|
if manage {
|
|
cap = identity.CapabilityFirmwareManage
|
|
}
|
|
var item FirmwareRelease
|
|
var a resourceAccess
|
|
var role string
|
|
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
|
|
}
|
|
if err != nil {
|
|
return FirmwareRelease{}, resourceAccess{}, err
|
|
}
|
|
a.role = identity.Role(role)
|
|
if !identity.Can(a.role, cap) {
|
|
return FirmwareRelease{}, resourceAccess{}, ErrAccessDenied
|
|
}
|
|
return item, a, nil
|
|
}
|
|
|
|
func (s *Service) StartFirmwareRollout(ctx context.Context, p identity.Principal, householdPublicID, releasePublicID, requestID, name string, percentage int, deviceIDs []string) (FirmwareRollout, error) {
|
|
sort.Strings(deviceIDs)
|
|
var result FirmwareRollout
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
|
a, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, 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.household_id=$2::uuid AND fr.public_id=$3::uuid AND fr.status='READY' FOR SHARE OF fr,fsk`, a.tenantID, a.householdID, 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,$2::uuid,$3::uuid,$4,'RUNNING',$5,$6::uuid,now()) RETURNING id::text,public_id::text,created_at,updated_at`, a.tenantID, a.householdID, releaseID, name, percentage, a.userID).Scan(&rolloutID, &result.ID, &result.CreatedAt, &result.UpdatedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result.ReleaseID = releasePublicID
|
|
result.HouseholdID = householdPublicID
|
|
result.Name = name
|
|
result.Status = "RUNNING"
|
|
result.TargetPercentage = percentage
|
|
selected := 0
|
|
for _, devicePublicID := range deviceIDs {
|
|
h := sha256.Sum256([]byte(devicePublicID))
|
|
if int(h[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 JOIN device_binding b ON b.device_id=d.id AND b.household_id=$2::uuid AND b.unbound_at IS NULL WHERE d.tenant_id=$1::uuid AND d.public_id=$3::uuid AND d.model=$4 AND ($5='' OR d.hardware_revision=$5)`, a.tenantID, a.householdID, devicePublicID, model, hardware).Scan(&deviceID, &previous)
|
|
if err != nil {
|
|
return fmt.Errorf("rollout target %s is incompatible or inaccessible", 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"})
|
|
var commandID string
|
|
dedupe := "firmware-rollout:" + rolloutID + ":" + deviceID
|
|
requestDigest := sha256.Sum256(payload)
|
|
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`, a.tenantID, deviceID, payload, a.userID, dedupe, 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, a.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: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "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) GetFirmwareRollout(ctx context.Context, p identity.Principal, publicID string) (FirmwareRollout, error) {
|
|
var item FirmwareRollout
|
|
var role string
|
|
var targeted, succeeded, failed int64
|
|
err := s.pool.QueryRow(ctx, `SELECT ro.public_id::text,fr.public_id::text,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,hm.role FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id JOIN household h ON h.id=ro.household_id LEFT JOIN firmware_assignment fa ON fa.rollout_id=ro.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=ro.tenant_id AND ua.status='active' WHERE ro.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 GROUP BY ro.id,fr.public_id,h.public_id,hm.role`, publicID, p.Issuer, p.Subject).Scan(&item.ID, &item.ReleaseID, &item.HouseholdID, &item.Name, &item.Status, &item.TargetPercentage, &targeted, &succeeded, &failed, &item.CreatedAt, &item.UpdatedAt, &role)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return FirmwareRollout{}, ErrAccessDenied
|
|
}
|
|
if err != nil {
|
|
return FirmwareRollout{}, err
|
|
}
|
|
if !identity.Can(identity.Role(role), identity.CapabilityFirmwareManage) {
|
|
return FirmwareRollout{}, ErrAccessDenied
|
|
}
|
|
item.Targeted, item.Succeeded, item.Failed = int(targeted), int(succeeded), int(failed)
|
|
assignments, err := s.listFirmwareAssignments(ctx, publicID)
|
|
if err != nil {
|
|
return FirmwareRollout{}, err
|
|
}
|
|
item.Assignments = assignments
|
|
return item, nil
|
|
}
|
|
|
|
func (s *Service) ListFirmwareRollouts(ctx context.Context, p identity.Principal, householdPublicID string) ([]FirmwareRollout, error) {
|
|
items := []FirmwareRollout{}
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
|
a, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := tx.Query(ctx, `SELECT ro.public_id::text,fr.public_id::text,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 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.household_id=$2::uuid GROUP BY ro.id,fr.public_id,h.public_id ORDER BY ro.created_at DESC LIMIT 100`, a.tenantID, a.householdID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
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 err
|
|
}
|
|
item.Targeted, item.Succeeded, item.Failed = int(targeted), int(succeeded), int(failed)
|
|
items = append(items, item)
|
|
}
|
|
return rows.Err()
|
|
})
|
|
return items, err
|
|
}
|
|
|
|
func (s *Service) listFirmwareAssignments(ctx context.Context, rolloutPublicID string) ([]FirmwareAssignment, error) {
|
|
rows, err := s.pool.Query(ctx, `SELECT d.public_id::text,d.model,COALESCE(fa.previous_version,''),fa.status,fa.progress_percent,fa.last_result,fa.last_reported_at,fa.updated_at FROM firmware_assignment fa JOIN firmware_rollout ro ON ro.id=fa.rollout_id JOIN device d ON d.id=fa.device_id WHERE ro.public_id=$1::uuid ORDER BY d.public_id`, rolloutPublicID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []FirmwareAssignment{}
|
|
itemIndexes := map[string]int{}
|
|
for rows.Next() {
|
|
var item FirmwareAssignment
|
|
var resultJSON []byte
|
|
if err := rows.Scan(&item.DeviceID, &item.DeviceModel, &item.PreviousVersion, &item.Status, &item.ProgressPercent, &resultJSON, &item.LastReportedAt, &item.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(resultJSON, &item.LastResult); err != nil {
|
|
return nil, err
|
|
}
|
|
item.Events = []FirmwareAssignmentEvent{}
|
|
itemIndexes[item.DeviceID] = len(items)
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
rows.Close()
|
|
eventRows, err := s.pool.Query(ctx, `
|
|
SELECT d.public_id::text,e.id::text,dc.public_id::text,e.command_status,e.assignment_status,e.phase,e.progress_percent,e.result_code,
|
|
e.active_slot,e.target_slot,e.previous_slot,e.failed_slot,e.boot_attempt_id,e.boot_health_deadline,e.rollback_applied,e.rollback_version,
|
|
e.installed_version,e.verified_sha256,e.verified_signing_key_id,e.result_digest,e.reported_at
|
|
FROM firmware_assignment_event e
|
|
JOIN firmware_assignment fa ON fa.id=e.assignment_id
|
|
JOIN firmware_rollout ro ON ro.id=fa.rollout_id
|
|
JOIN device d ON d.id=fa.device_id
|
|
JOIN device_command dc ON dc.id=e.command_id
|
|
WHERE ro.public_id=$1::uuid
|
|
ORDER BY d.public_id,e.reported_at,e.id`, rolloutPublicID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer eventRows.Close()
|
|
for eventRows.Next() {
|
|
var deviceID string
|
|
var event FirmwareAssignmentEvent
|
|
if err := eventRows.Scan(&deviceID, &event.ID, &event.CommandID, &event.CommandStatus, &event.AssignmentStatus, &event.Phase, &event.ProgressPercent, &event.ResultCode,
|
|
&event.ActiveSlot, &event.TargetSlot, &event.PreviousSlot, &event.FailedSlot, &event.BootAttemptID, &event.BootHealthDeadline, &event.RollbackApplied, &event.RollbackVersion,
|
|
&event.InstalledVersion, &event.VerifiedSHA256, &event.VerifiedSigningKeyID, &event.ResultDigest, &event.ReportedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if index, ok := itemIndexes[deviceID]; ok {
|
|
items[index].Events = append(items[index].Events, event)
|
|
}
|
|
}
|
|
return items, eventRows.Err()
|
|
}
|
|
|
|
func (s *Service) PauseFirmwareRollout(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRollout, error) {
|
|
item, err := s.GetFirmwareRollout(ctx, p, publicID)
|
|
if err != nil {
|
|
return FirmwareRollout{}, err
|
|
}
|
|
if item.Status != "RUNNING" {
|
|
return FirmwareRollout{}, ErrFirmwareStateConflict
|
|
}
|
|
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
|
var tenantID, userPublicID string
|
|
err := tx.QueryRow(ctx, `SELECT ro.tenant_id::text,ua.public_id::text FROM firmware_rollout ro JOIN household_member hm ON hm.household_id=ro.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=ro.tenant_id WHERE ro.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND hm.role IN ('owner','admin') FOR UPDATE OF ro`, publicID, p.Issuer, p.Subject).Scan(&tenantID, &userPublicID)
|
|
if err != nil {
|
|
return ErrAccessDenied
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE firmware_rollout SET status='PAUSED',updated_at=now() WHERE public_id=$1::uuid AND status='RUNNING'`, publicID); err != nil {
|
|
return err
|
|
}
|
|
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'`, publicID); err != nil {
|
|
return err
|
|
}
|
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: &tenantID, ActorType: "user", ActorPublicID: &userPublicID, Action: "firmware.rollout.pause", ResourceType: "firmware_rollout", ResourceID: &publicID, RequestID: requestID, Result: "success"})
|
|
})
|
|
if err != nil {
|
|
return FirmwareRollout{}, err
|
|
}
|
|
return s.GetFirmwareRollout(ctx, p, publicID)
|
|
}
|
|
|
|
func (s *Service) StartFirmwareRollback(ctx context.Context, p identity.Principal, rolloutPublicID, rollbackReleaseID, requestID string) (FirmwareRollout, error) {
|
|
original, err := s.GetFirmwareRollout(ctx, p, rolloutPublicID)
|
|
if err != nil {
|
|
return FirmwareRollout{}, err
|
|
}
|
|
rows, err := s.pool.Query(ctx, `SELECT d.public_id::text FROM firmware_assignment fa JOIN firmware_rollout ro ON ro.id=fa.rollout_id JOIN device d ON d.id=fa.device_id WHERE ro.public_id=$1::uuid ORDER BY d.public_id`, rolloutPublicID)
|
|
if err != nil {
|
|
return FirmwareRollout{}, err
|
|
}
|
|
ids := []string{}
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return FirmwareRollout{}, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
rows.Close()
|
|
if original.Status == "RUNNING" {
|
|
if _, err := s.PauseFirmwareRollout(ctx, p, rolloutPublicID, requestID+"-pause"); err != nil {
|
|
return FirmwareRollout{}, err
|
|
}
|
|
}
|
|
return s.StartFirmwareRollout(ctx, p, original.HouseholdID, rollbackReleaseID, requestID, "回滚:"+original.Name, 100, ids)
|
|
}
|
|
|
|
func (s *Service) FirmwareDownloadForDevice(ctx context.Context, id deviceauth.Identity, releasePublicID string) (string, time.Time, error) {
|
|
if s.firmwareStore == nil {
|
|
return "", time.Time{}, ErrFirmwareStateConflict
|
|
}
|
|
var objectRef, version string
|
|
err := s.pool.QueryRow(ctx, `SELECT fr.object_ref,fr.version FROM firmware_release fr JOIN firmware_rollout ro ON ro.release_id=fr.id AND ro.status IN ('RUNNING','COMPLETED') JOIN firmware_assignment fa ON fa.rollout_id=ro.id AND fa.device_id=$2::uuid WHERE fr.public_id=$1::uuid AND fr.tenant_id=$3::uuid AND fr.status='READY' AND fa.status IN ('ASSIGNED','DOWNLOADING','INSTALLING','CONFIRMED')`, releasePublicID, id.ID, id.TenantID).Scan(&objectRef, &version)
|
|
if err != nil {
|
|
return "", time.Time{}, ErrAccessDenied
|
|
}
|
|
ttl := s.firmwareURLTTL
|
|
if ttl <= 0 {
|
|
ttl = 5 * time.Minute
|
|
}
|
|
url, err := s.firmwareStore.PresignDownload(ctx, objectRef, ttl, "firmware-"+safePath(version)+".bin")
|
|
return url, time.Now().Add(ttl), err
|
|
}
|