Skip to content

Commit 9cc569e

Browse files
heiytorgustavosbarreto
authored andcommitted
refactor(api): modernize device status update
- Refactor UpdateDeviceStatus to accept request object instead of individual parameters - Add comprehensive logging with structured fields for better debugging - Implement transaction-based device operations for data consistency - Enhance device merging logic with proper error handling and validation - Add extensive documentation for complex acceptance rules and environment-specific behaviors - Improve billing integration with proper cloud environment detection - Extract device limit validation and cloud billing handling into separate methods - Update API routes to use modern request binding and validation patterns - Add backward compatibility mapping for legacy status string values - Enhance test coverage with realistic scenarios and proper mocking - Remove deprecated adjustDeviceCounters utility in favor of inline operations - Improve MAC address conflict detection and hostname validation - Add proper transaction rollback support for failed operations
1 parent 05b0a4a commit 9cc569e

File tree

7 files changed

+755
-2404
lines changed

7 files changed

+755
-2404
lines changed

api/routes/device.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -215,28 +215,29 @@ func (h *Handler) LookupDevice(c gateway.Context) error {
215215
}
216216

217217
func (h *Handler) UpdateDeviceStatus(c gateway.Context) error {
218-
var req requests.DeviceUpdateStatus
219-
if err := c.Bind(&req); err != nil {
220-
return err
221-
}
218+
req := new(requests.DeviceUpdateStatus)
222219

223-
if err := c.Validate(&req); err != nil {
220+
if err := c.Bind(req); err != nil {
224221
return err
225222
}
226223

227-
var tenant string
228-
if c.Tenant() != nil {
229-
tenant = c.Tenant().ID
224+
// TODO: Remove this legacy status mapping in API v2.
225+
// This mapping exists solely for backward compatibility with API consumers
226+
// that were sending string values before the device status refactor.
227+
status := map[string]string{
228+
"accept": string(models.DeviceStatusAccepted),
229+
"reject": string(models.DeviceStatusRejected),
230+
"pending": string(models.DeviceStatusPending),
231+
"unused": string(models.DeviceStatusUnused),
230232
}
231233

232-
status := map[string]models.DeviceStatus{
233-
"accept": models.DeviceStatusAccepted,
234-
"reject": models.DeviceStatusRejected,
235-
"pending": models.DeviceStatusPending,
236-
"unused": models.DeviceStatusUnused,
234+
req.Status = status[req.Status]
235+
236+
if err := c.Validate(req); err != nil {
237+
return err
237238
}
238239

239-
if err := h.service.UpdateDeviceStatus(c.Ctx(), tenant, models.UID(req.UID), status[req.Status]); err != nil {
240+
if err := h.service.UpdateDeviceStatus(c.Ctx(), req); err != nil {
240241
return err
241242
}
242243

api/services/device.go

Lines changed: 167 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -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

1617
const 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

3756
func (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

354353
func (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

Comments
 (0)