@@ -11,6 +11,7 @@ import (
1111 "github.com/shellhub-io/shellhub/pkg/clock"
1212 "github.com/shellhub-io/shellhub/pkg/envs"
1313 "github.com/shellhub-io/shellhub/pkg/models"
14+ log "github.com/sirupsen/logrus"
1415)
1516
1617const StatusAccepted = "accepted"
@@ -29,9 +30,27 @@ type DeviceService interface {
2930 RenameDevice (ctx context.Context , uid models.UID , name , tenant string ) error
3031 LookupDevice (ctx context.Context , namespace , name string ) (* models.Device , error )
3132 OfflineDevice (ctx context.Context , uid models.UID ) error
32- UpdateDeviceStatus (ctx context.Context , tenant string , uid models.UID , status models.DeviceStatus ) error
3333
3434 UpdateDevice (ctx context.Context , req * requests.DeviceUpdate ) error
35+ // UpdateDeviceStatus updates a device's status. Devices that are already accepted cannot change their status.
36+ //
37+ // When accepting, if a device with the same MAC address is already accepted within the same namespace, it
38+ // merges these devices unless a third device with the same hostname already exists and has a different MAC
39+ // address. The merge transfers all sessions from the old device to the new one, renames the new device to
40+ // preserve the old device's identity, and deletes the old device. Also, if another accepted device already
41+ // uses the same hostname but has a different MAC address, the operation fails.
42+ //
43+ // Environment-specific Acceptance Rules:
44+ // - Community/Enterprise: Only checks the namespace's device limit
45+ // - Cloud (billing active): Reports device acceptance to billing service for quota/payment validation
46+ // - Cloud (billing inactive): Checks if the device is removed and evaluates namespace capabilities:
47+ // * If device was previously removed: removes from removed list, then evaluates billing
48+ // * If device was not removed: counts total removed devices and checks against limits, then evaluates billing
49+ // * Billing evaluation determines if the namespace can accept more devices based on subscription status
50+ //
51+ // All operations are performed within a database transaction to ensure consistency during device merging
52+ // and counter updates.
53+ UpdateDeviceStatus (ctx context.Context , req * requests.DeviceUpdateStatus ) error
3554}
3655
3756func (s * service ) ListDevices (ctx context.Context , req * requests.DeviceList ) ([]models.Device , int , error ) {
@@ -217,138 +236,118 @@ func (s *service) OfflineDevice(ctx context.Context, uid models.UID) error {
217236 return nil
218237}
219238
220- // UpdateDeviceStatus updates the device status.
221- func (s * service ) UpdateDeviceStatus (ctx context.Context , tenant string , uid models.UID , status models.DeviceStatus ) error {
222- namespace , err := s .store .NamespaceResolve (ctx , store .NamespaceTenantIDResolver , tenant )
223- if err != nil {
224- return NewErrNamespaceNotFound (tenant , err )
225- }
226-
227- device , err := s .store .DeviceResolve (ctx , store .DeviceUIDResolver , string (uid ), s .store .Options ().InNamespace (tenant ))
228- if err != nil {
229- return NewErrDeviceNotFound (uid , err )
230- }
231-
232- if device .Status == models .DeviceStatusAccepted {
233- return NewErrDeviceStatusAccepted (nil )
234- }
235-
236- // Store the original status for counter updates
237- originalStatus := device .Status
239+ func (s * service ) UpdateDeviceStatus (ctx context.Context , req * requests.DeviceUpdateStatus ) error {
240+ return s .store .WithTransaction (ctx , s .updateDeviceStatus (req ))
241+ }
238242
239- // NOTICE: when the device is intended to be rejected or in pending status, we don't check for duplications as it
240- // is not going to be considered for connections.
241- if status == models . DeviceStatusPending || status == models . DeviceStatusRejected {
242- if err := s . store . DeviceUpdateStatus ( ctx , uid , status ); err != nil {
243- return err
243+ func ( s * service ) updateDeviceStatus ( req * requests. DeviceUpdateStatus ) store. TransactionCb {
244+ return func ( ctx context. Context ) error {
245+ namespace , err := s . store . NamespaceResolve ( ctx , store . NamespaceTenantIDResolver , req . TenantID )
246+ if err != nil {
247+ return NewErrNamespaceNotFound ( req . TenantID , err )
244248 }
245249
246- if err := s .adjustDeviceCounters (ctx , tenant , originalStatus , status ); err != nil { // nolint:revive
247- return err
250+ device , err := s .store .DeviceResolve (ctx , store .DeviceUIDResolver , req .UID , s .store .Options ().InNamespace (namespace .TenantID ))
251+ if err != nil {
252+ return NewErrDeviceNotFound (models .UID (req .UID ), err )
248253 }
249254
250- return nil
251- }
255+ if device .Status == models .DeviceStatusAccepted {
256+ log .WithFields (log.Fields {"device_uid" : device .UID }).
257+ Warn ("cannot change status - device already accepted" )
252258
253- // NOTICE: when the intended status is not accepted, we return an error because these status are not allowed
254- // to be set by the user.
255- if status != models .DeviceStatusAccepted {
256- return NewErrDeviceStatusInvalid (string (status ), nil )
257- }
259+ return NewErrDeviceStatusAccepted (nil )
260+ }
258261
259- // NOTICE: when there is an already accepted device with the same MAC address, we need to update the device UID
260- // transfer the sessions and delete the old device.
261- sameMacDev , err := s .store .DeviceResolve (ctx , store .DeviceMACResolver , device .Identity .MAC , s .store .Options ().WithDeviceStatus (models .DeviceStatusAccepted ), s .store .Options ().InNamespace (device .TenantID ))
262- if err != nil && err != store .ErrNoDocuments {
263- return NewErrDeviceNotFound (models .UID (device .UID ), err )
264- }
262+ oldStatus := device .Status
263+ newStatus := models .DeviceStatus (req .Status )
265264
266- // TODO: move this logic to store's transactions.
267- if sameMacDev != nil && sameMacDev .UID != device .UID {
268- sameDevice , _ := s .store .DeviceResolve (ctx , store .DeviceHostnameResolver , device .Name , s .store .Options ().WithDeviceStatus (models .DeviceStatusAccepted ), s .store .Options ().InNamespace (device .TenantID ))
269- if sameDevice != nil && sameDevice .Identity .MAC != device .Identity .MAC {
270- return NewErrDeviceDuplicated (device .Name , nil )
265+ if newStatus == device .Status {
266+ return nil
271267 }
272268
273- if err := s .store .SessionUpdateDeviceUID (ctx , models .UID (sameMacDev .UID ), models .UID (device .UID )); err != nil && err != store .ErrNoDocuments {
274- return err
275- }
269+ if newStatus == models .DeviceStatusAccepted {
270+ opts := []store.QueryOption {s .store .Options ().WithDeviceStatus (models .DeviceStatusAccepted ), s .store .Options ().InNamespace (namespace .TenantID )}
271+ existingMacDevice , err := s .store .DeviceResolve (ctx , store .DeviceMACResolver , device .Identity .MAC , opts ... )
272+ if err != nil && ! errors .Is (err , store .ErrNoDocuments ) {
273+ log .WithError (err ).
274+ WithFields (log.Fields {"mac" : device .Identity .MAC }).
275+ Error ("failed to retrieve device using MAC" )
276276
277- if err := s .store .DeviceRename (ctx , models .UID (device .UID ), sameMacDev .Name ); err != nil {
278- return err
279- }
277+ return err
278+ }
280279
281- if err := s .store .DeviceDelete (ctx , models .UID (sameMacDev .UID )); err != nil {
282- return err
283- }
280+ if existingMacDevice != nil && existingMacDevice .UID != device .UID {
281+ existingNameDevice , err := s .store .DeviceResolve (ctx , store .DeviceHostnameResolver , device .Name , opts ... )
282+ if err != nil && ! errors .Is (err , store .ErrNoDocuments ) {
283+ log .WithError (err ).
284+ WithFields (log.Fields {"name" : device .Name }).
285+ Error ("failed to retrieve device using name" )
284286
285- // We need to decrease the accepted device count twice because we deleted the old device.
286- if err := s .store .NamespaceIncrementDeviceCount (ctx , tenant , models .DeviceStatusAccepted , - 1 ); err != nil {
287- return err
288- }
287+ return err
288+ }
289289
290- if err := s . store . DeviceUpdateStatus ( ctx , uid , status ); err != nil {
291- return err
292- }
290+ if existingNameDevice != nil && existingNameDevice . Identity . MAC != device . Identity . MAC {
291+ log . WithFields (log. Fields { "device_uid" : device . UID , "device_mac" : device . Identity . MAC , "conflicting_device_name" : device . Name }).
292+ Error ( "device merge blocked - hostname already used by device with different MAC address" )
293293
294- if err := s .adjustDeviceCounters (ctx , tenant , originalStatus , status ); err != nil { // nolint:revive
295- return err
296- }
294+ return NewErrDeviceDuplicated (device .Name , nil )
295+ }
297296
298- return nil
299- }
297+ if err := s .mergeDevice (ctx , namespace .TenantID , existingMacDevice , device ); err != nil {
298+ log .WithError (err ).
299+ WithFields (log.Fields {"device_uid" : device .UID , "existing_device_uid" : existingMacDevice .UID , "device_mac" : device .Identity .MAC }).
300+ Error ("device merge operation failed" )
300301
301- if sameDevice , _ := s .store .DeviceResolve (ctx , store .DeviceHostnameResolver , device .Name , s .store .Options ().WithDeviceStatus (models .DeviceStatusAccepted ), s .store .Options ().InNamespace (device .TenantID )); sameDevice != nil {
302- return NewErrDeviceDuplicated (device .Name , nil )
303- }
302+ return err
303+ }
304+ } else {
305+ existingDevice , err := s .store .DeviceResolve (ctx , store .DeviceHostnameResolver , device .Name , opts ... )
306+ if err != nil && ! errors .Is (err , store .ErrNoDocuments ) {
307+ log .WithError (err ).
308+ WithFields (log.Fields {"name" : device .Name }).
309+ Error ("failed to retrieve device using name" )
310+
311+ return err
312+ }
304313
305- if status != models .DeviceStatusAccepted {
306- if err := s .store .DeviceUpdateStatus (ctx , uid , status ); err != nil {
307- return err
308- }
314+ if existingDevice != nil {
315+ log .WithFields (log.Fields {"device_uid" : device .UID , "conflicting_device_name" : device .Name }).
316+ Error ("device acceptance blocked - hostname already used by another device" )
309317
310- if err := s .adjustDeviceCounters (ctx , tenant , originalStatus , status ); err != nil { // nolint:revive
311- return err
312- }
318+ return NewErrDeviceDuplicated (device .Name , nil )
319+ }
313320
314- return nil
315- }
321+ if err := s .checkDeviceLimits (ctx , namespace , device ); err != nil {
322+ log .WithError (err ).WithFields (log.Fields {"device_uid" : device .UID }).
323+ Error ("namespace's limit reached - cannot accept another device" )
316324
317- switch {
318- case envs .IsCloud () && envs .HasBilling ():
319- if namespace .Billing .IsActive () {
320- if err := s .BillingReport (s .client , namespace .TenantID , ReportDeviceAccept ); err != nil {
321- return NewErrBillingReportNamespaceDelete (err )
322- }
323- } else {
324- if device .Status != models .DeviceStatusRemoved && namespace .HasMaxDevices () && namespace .HasLimitDevicesReached () {
325- return NewErrDeviceRemovedFull (namespace .MaxDevices , nil )
326- }
325+ return err
326+ }
327327
328- ok , err := s . BillingEvaluate ( s . client , namespace . TenantID )
329- if err != nil {
330- return NewErrBillingEvaluate (err )
331- }
328+ if envs . IsCloud () {
329+ if err := s . handleCloudBilling ( ctx , namespace ); err != nil {
330+ log . WithError (err ). WithFields (log. Fields { "device_uid" : device . UID , "billing_active" : namespace . Billing . IsActive ()}).
331+ Error ( "billing validation failed" )
332332
333- if ! ok {
334- return ErrDeviceLimit
333+ return err
334+ }
335+ }
335336 }
336337 }
337- default :
338- if namespace . HasMaxDevices () && namespace .HasMaxDevicesReached () {
339- return NewErrDeviceMaxDevicesReached ( namespace . MaxDevices )
338+
339+ if err := s . store . DeviceUpdate ( ctx , namespace .TenantID , device . UID , & models. DeviceChanges { Status : newStatus }); err != nil {
340+ return err
340341 }
341- }
342342
343- if err := s .store .DeviceUpdateStatus (ctx , uid , status ); err != nil {
344- return err
345- }
343+ for status , count := range map [models.DeviceStatus ]int64 {oldStatus : - 1 , newStatus : 1 } {
344+ if err := s .store .NamespaceIncrementDeviceCount (ctx , namespace .TenantID , status , count ); err != nil {
345+ return err
346+ }
347+ }
346348
347- if err := s .adjustDeviceCounters (ctx , tenant , originalStatus , status ); err != nil { // nolint:revive
348- return err
349+ return nil
349350 }
350-
351- return nil
352351}
353352
354353func (s * service ) UpdateDevice (ctx context.Context , req * requests.DeviceUpdate ) error {
@@ -371,3 +370,67 @@ func (s *service) UpdateDevice(ctx context.Context, req *requests.DeviceUpdate)
371370
372371 return s .store .DeviceUpdate (ctx , req .TenantID , req .UID , changes )
373372}
373+
374+ // mergeDevice merges an old device into a new device. It transfers all sessions from the old device to the new one and
375+ // renames the new device to preserve the old device's identity. The old device is then deleted and the namespace's device count is decremented.
376+ func (s * service ) mergeDevice (ctx context.Context , tenantID string , oldDevice * models.Device , newDevice * models.Device ) error {
377+ // TODO: update tunnels as well?
378+
379+ if err := s .store .SessionUpdateDeviceUID (ctx , models .UID (oldDevice .UID ), models .UID (newDevice .UID )); err != nil && ! errors .Is (err , store .ErrNoDocuments ) {
380+ return err
381+ }
382+
383+ if err := s .store .DeviceUpdate (ctx , tenantID , newDevice .UID , & models.DeviceChanges {Name : oldDevice .Name }); err != nil {
384+ return err
385+ }
386+
387+ if err := s .store .DeviceDelete (ctx , models .UID (oldDevice .UID )); err != nil {
388+ return err
389+ }
390+
391+ if err := s .store .NamespaceIncrementDeviceCount (ctx , tenantID , oldDevice .Status , - 1 ); err != nil { //nolint:revive
392+ return err
393+ }
394+
395+ return nil
396+ }
397+
398+ // checkDeviceLimits validates if the namespace can accept more devices based on environment-specific limits.
399+ func (s * service ) checkDeviceLimits (ctx context.Context , namespace * models.Namespace , device * models.Device ) error {
400+ switch {
401+ case envs .IsCloud ():
402+ if ! namespace .Billing .IsActive () && device .Status != models .DeviceStatusRemoved &&
403+ namespace .HasMaxDevices () && namespace .HasLimitDevicesReached () {
404+
405+ return NewErrDeviceRemovedFull (namespace .MaxDevices , nil )
406+ }
407+
408+ return nil
409+ default :
410+ if namespace .HasMaxDevices () && namespace .HasMaxDevicesReached () {
411+ return NewErrDeviceMaxDevicesReached (namespace .MaxDevices )
412+ }
413+
414+ return nil
415+ }
416+ }
417+
418+ // handleCloudBilling processes billing-related operations for Cloud environment.
419+ // This function has side effects: it may delete removed devices and report to billing.
420+ func (s * service ) handleCloudBilling (ctx context.Context , namespace * models.Namespace ) error {
421+ if namespace .Billing .IsActive () {
422+ if err := s .BillingReport (s .client , namespace .TenantID , ReportDeviceAccept ); err != nil {
423+ return NewErrBillingReportNamespaceDelete (err )
424+ }
425+ } else {
426+ ok , err := s .BillingEvaluate (s .client , namespace .TenantID )
427+ switch {
428+ case err != nil :
429+ return NewErrBillingEvaluate (err )
430+ case ! ok :
431+ return ErrDeviceLimit
432+ }
433+ }
434+
435+ return nil
436+ }
0 commit comments