From 6be4d86f4e74db6d0974acb957d23447349a4972 Mon Sep 17 00:00:00 2001 From: PaulDotterer Date: Fri, 15 May 2026 19:43:13 +0200 Subject: [PATCH] =?UTF-8?q?refactor(store):=20wave=20B.13=20=E2=80=94=20de?= =?UTF-8?q?vice=20repo=20(#269)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeviceRepo — read side of devices_projection: Get / IsDeleted / List / ListOnline / ListOffline / Count / CountOnline / CountOffline / HostnamesByIDs / SyncInterval The Get and List methods carry OwnerScope to model the :self-scoped permission shape that previously used FilterUserID directly; nil = admin view, non-nil = restrict to devices the user has assignment access to (the underlying query returns ErrNotFound when the scope excludes the row — handlers can't distinguish, by design). Labels stays as json.RawMessage at the boundary per the JSONB normalize plan. Compliance status (Status / CheckedAt / Total / Passing) is denormalized on the device row by the compliance projector and surfaced as-is. Call sites migrated: - internal/api/device_handler.go (large sweep, 3-way Online/Offline switch with matching count branches, deviceToProtoCtx + deviceToProtoWithAssignments signatures shifted to store.Device) - internal/api/user_selection_handler.go (2 :self-scoped Get sites) - internal/search/index.go (2 sites: ListDevices + GetDeviceByID for enrichment) - internal/control/inbox_worker.go (IsDeviceDeleted, 2 sites) - internal/api/{logs,osquery,terminal,assignment,compliance, certificate}_handler.go — GetDeviceByID for verification Non-goals: - Per-device cross-domain queries (assignments, executions, maintenance windows, compliance evaluations) migrate with their owning domains. - CountMatchingDevicesForQuery — PL/pgSQL evaluator, Wave C. - Write-side projector queries — stay on Queries(). Closes manchtools/power-manage-server#269. --- internal/api/action_dispatch.go | 4 +- internal/api/assignment_handler.go | 2 +- internal/api/certificate_handler.go | 5 +- internal/api/device_handler.go | 70 ++++++------ internal/api/internal_handler.go | 6 +- internal/api/logs_handler.go | 5 +- internal/api/osquery_handler.go | 9 +- internal/api/terminal_handler.go | 6 +- internal/api/user_selection_handler.go | 13 +-- internal/control/inbox_worker.go | 4 +- internal/search/index.go | 8 +- internal/store/device.go | 110 ++++++++++++++++++ internal/store/postgres/device.go | 151 +++++++++++++++++++++++++ internal/store/postgres/wire.go | 1 + internal/store/repos.go | 1 + 15 files changed, 316 insertions(+), 79 deletions(-) create mode 100644 internal/store/device.go create mode 100644 internal/store/postgres/device.go diff --git a/internal/api/action_dispatch.go b/internal/api/action_dispatch.go index 480a3b7f..3260870e 100644 --- a/internal/api/action_dispatch.go +++ b/internal/api/action_dispatch.go @@ -42,7 +42,7 @@ func (h *ActionHandler) DispatchAction(ctx context.Context, req *connect.Request // post-commit search listener, which reloads it fresh. We keep the // load here to fail fast with NotFound before touching anything // heavier downstream. - if _, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.DeviceId}); err != nil { + if _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.DeviceId}); err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") } @@ -707,7 +707,7 @@ func (h *ActionHandler) DispatchInstantAction(ctx context.Context, req *connect. return nil, apiErrorCtx(ctx, ErrValidationFailed, connect.CodeInvalidArgument, "invalid instant action type: "+req.Msg.InstantAction.String()) } - _, err = h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.DeviceId}) + _, err = h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.DeviceId}) if err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") } diff --git a/internal/api/assignment_handler.go b/internal/api/assignment_handler.go index ac781886..30d12c66 100644 --- a/internal/api/assignment_handler.go +++ b/internal/api/assignment_handler.go @@ -81,7 +81,7 @@ func (h *AssignmentHandler) CreateAssignment(ctx context.Context, req *connect.R // Validate target exists switch req.Msg.TargetType { case pm.AssignmentTargetType_ASSIGNMENT_TARGET_TYPE_DEVICE: - _, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.TargetId}) + _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.TargetId}) if err != nil { if store.IsNotFound(err) { return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found") diff --git a/internal/api/certificate_handler.go b/internal/api/certificate_handler.go index f5ffeddc..b9d46859 100644 --- a/internal/api/certificate_handler.go +++ b/internal/api/certificate_handler.go @@ -13,7 +13,6 @@ import ( "github.com/manchtools/power-manage/server/internal/eventtypes" "github.com/manchtools/power-manage/server/internal/eventtypes/payloads" "github.com/manchtools/power-manage/server/internal/store" - db "github.com/manchtools/power-manage/server/internal/store/generated" ) // CertificateHandler handles certificate renewal RPCs. @@ -47,9 +46,7 @@ func (h *CertificateHandler) RenewCertificate(ctx context.Context, req *connect. } // Verify the device exists and is not deleted - device, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ - ID: deviceID, - }) + device, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: deviceID}) if err != nil { if store.IsNotFound(err) { return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found") diff --git a/internal/api/device_handler.go b/internal/api/device_handler.go index e8e355df..a4717ac0 100644 --- a/internal/api/device_handler.go +++ b/internal/api/device_handler.go @@ -59,27 +59,21 @@ func (h *DeviceHandler) ListDevices(ctx context.Context, req *connect.Request[pm } } - var devices []db.DevicesProjection + var devices []store.Device + deviceRepo := h.store.Repos().Device + deviceFilter := store.ListDevicesFilter{ + Limit: pageSize, + Offset: offset, + OwnerScope: filterUID, + } switch req.Msg.StatusFilter { case pm.DeviceStatus_DEVICE_STATUS_ONLINE: - devices, err = q.ListDevicesOnline(ctx, db.ListDevicesOnlineParams{ - Limit: pageSize, - Offset: offset, - FilterUserID: filterUID, - }) + devices, err = deviceRepo.ListOnline(ctx, deviceFilter) case pm.DeviceStatus_DEVICE_STATUS_OFFLINE: - devices, err = q.ListDevicesOffline(ctx, db.ListDevicesOfflineParams{ - Limit: pageSize, - Offset: offset, - FilterUserID: filterUID, - }) + devices, err = deviceRepo.ListOffline(ctx, deviceFilter) default: - devices, err = q.ListDevices(ctx, db.ListDevicesParams{ - Limit: pageSize, - Offset: offset, - FilterUserID: filterUID, - }) + devices, err = deviceRepo.List(ctx, deviceFilter) } if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to list devices") @@ -89,11 +83,11 @@ func (h *DeviceHandler) ListDevices(ctx context.Context, req *connect.Request[pm var count int64 switch req.Msg.StatusFilter { case pm.DeviceStatus_DEVICE_STATUS_ONLINE: - count, err = q.CountDevicesOnline(ctx, filterUID) + count, err = deviceRepo.CountOnline(ctx, filterUID) case pm.DeviceStatus_DEVICE_STATUS_OFFLINE: - count, err = q.CountDevicesOffline(ctx, filterUID) + count, err = deviceRepo.CountOffline(ctx, filterUID) default: - count, err = q.CountDevices(ctx, filterUID) + count, err = deviceRepo.Count(ctx, filterUID) } if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to count devices") @@ -141,9 +135,9 @@ func (h *DeviceHandler) GetDevice(ctx context.Context, req *connect.Request[pm.G return nil, err } - device, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ - ID: req.Msg.Id, - FilterUserID: userFilterID(ctx, "GetDevice"), + device, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ + ID: req.Msg.Id, + OwnerScope: userFilterID(ctx, "GetDevice"), }) if err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") @@ -166,7 +160,7 @@ func (h *DeviceHandler) SetDeviceLabel(ctx context.Context, req *connect.Request } // Verify device exists - _, err = h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.Id}) + _, err = h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.Id}) if err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") } @@ -187,7 +181,7 @@ func (h *DeviceHandler) SetDeviceLabel(ctx context.Context, req *connect.Request } // Read back updated device - device, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.Id}) + device, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.Id}) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get updated device") } @@ -209,7 +203,7 @@ func (h *DeviceHandler) RemoveDeviceLabel(ctx context.Context, req *connect.Requ } // Verify device exists - _, err = h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.Id}) + _, err = h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.Id}) if err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") } @@ -229,7 +223,7 @@ func (h *DeviceHandler) RemoveDeviceLabel(ctx context.Context, req *connect.Requ } // Read back updated device - device, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.Id}) + device, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.Id}) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get updated device") } @@ -298,7 +292,7 @@ func (h *DeviceHandler) AssignDevice(ctx context.Context, req *connect.Request[p q := h.store.Queries() // Verify device exists - _, err = q.GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.DeviceId}) + _, err = h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.DeviceId}) if err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") } @@ -374,7 +368,7 @@ func (h *DeviceHandler) AssignDevice(ctx context.Context, req *connect.Request[p } // Read back updated device - device, err := q.GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.DeviceId}) + device, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.DeviceId}) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get updated device") } @@ -425,7 +419,7 @@ func (h *DeviceHandler) UnassignDevice(ctx context.Context, req *connect.Request } // Verify device exists - _, err = h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.DeviceId}) + _, err = h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.DeviceId}) if err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") } @@ -461,7 +455,7 @@ func (h *DeviceHandler) UnassignDevice(ctx context.Context, req *connect.Request } // Read back updated device - device, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.DeviceId}) + device, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.DeviceId}) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get updated device") } @@ -483,7 +477,7 @@ func (h *DeviceHandler) SetDeviceSyncInterval(ctx context.Context, req *connect. } // Verify device exists - _, err = h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.Id}) + _, err = h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.Id}) if err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") } @@ -504,7 +498,7 @@ func (h *DeviceHandler) SetDeviceSyncInterval(ctx context.Context, req *connect. } // Read back updated device - device, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.Id}) + device, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.Id}) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get updated device") } @@ -516,7 +510,7 @@ func (h *DeviceHandler) SetDeviceSyncInterval(ctx context.Context, req *connect. // deviceToProtoCtx converts a database device projection to a protobuf device, // populating assigned user/group IDs from junction tables. -func (h *DeviceHandler) deviceToProtoCtx(ctx context.Context, d db.DevicesProjection) *pm.Device { +func (h *DeviceHandler) deviceToProtoCtx(ctx context.Context, d store.Device) *pm.Device { q := h.store.Queries() var userIDs, groupIDs []string if rows, err := q.ListDeviceAssignedUserIDs(ctx, d.ID); err == nil { @@ -530,7 +524,7 @@ func (h *DeviceHandler) deviceToProtoCtx(ctx context.Context, d db.DevicesProjec // deviceToProtoWithAssignments converts a database device projection to a protobuf // device using pre-fetched assignment data. Used by list endpoints with batch queries. -func (h *DeviceHandler) deviceToProtoWithAssignments(d db.DevicesProjection, assignedUserIDs, assignedGroupIDs []string) *pm.Device { +func (h *DeviceHandler) deviceToProtoWithAssignments(d store.Device, assignedUserIDs, assignedGroupIDs []string) *pm.Device { device := &pm.Device{ Id: d.ID, Hostname: d.Hostname, @@ -626,7 +620,7 @@ func (h *DeviceHandler) loadDeviceHostnamesByIDs(ctx context.Context, ids []stri if len(ids) == 0 { return nil } - rows, err := h.store.Queries().GetDeviceHostnamesByIDs(ctx, ids) + rows, err := h.store.Repos().Device.HostnamesByIDs(ctx, ids) if err != nil { logEnrichmentErr("GetDeviceHostnamesByIDs", "device_id_count", fmt.Sprint(len(ids)), err) return nil @@ -819,7 +813,7 @@ func (h *DeviceHandler) CreateLuksToken(ctx context.Context, req *connect.Reques } // Verify device exists - _, err = h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.DeviceId}) + _, err = h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.DeviceId}) if err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") } @@ -907,7 +901,7 @@ func (h *DeviceHandler) RevokeLuksDeviceKey(ctx context.Context, req *connect.Re } // Verify device exists - _, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.DeviceId}) + _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.DeviceId}) if err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") } @@ -1023,7 +1017,7 @@ func (h *DeviceHandler) ListDeviceAssignees(ctx context.Context, req *connect.Re q := h.store.Queries() // Verify device exists - _, err := q.GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: req.Msg.DeviceId}) + _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.DeviceId}) if err != nil { return nil, handleGetError(ctx, err, ErrDeviceNotFound, "device not found") } diff --git a/internal/api/internal_handler.go b/internal/api/internal_handler.go index a0d595a7..d09b53cd 100644 --- a/internal/api/internal_handler.go +++ b/internal/api/internal_handler.go @@ -65,7 +65,7 @@ func (h *InternalHandler) VerifyDevice(ctx context.Context, req *connect.Request return nil, apiErrorCtx(ctx, ErrValidationFailed, connect.CodeInvalidArgument, "device_id is required") } - _, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: deviceID}) + _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: deviceID}) if err != nil { h.logger.Warn("device verification failed", "device_id", deviceID, "error", err) return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found or deleted") @@ -82,7 +82,7 @@ func (h *InternalHandler) ProxySyncActions(ctx context.Context, req *connect.Req } // Verify the device exists and is not deleted. - if _, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: deviceID}); err != nil { + if _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: deviceID}); err != nil { h.logger.Warn("sync actions for unknown/deleted device", "device_id", deviceID) return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found or deleted") } @@ -109,7 +109,7 @@ func (h *InternalHandler) ProxySyncActions(ctx context.Context, req *connect.Req return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to resolve actions") } - syncInterval, err := h.store.Queries().GetDeviceSyncInterval(ctx, deviceID) + syncInterval, err := h.store.Repos().Device.SyncInterval(ctx, deviceID) if err != nil { h.logger.Warn("failed to get sync interval, using default", "device_id", deviceID, "error", err) syncInterval = 0 diff --git a/internal/api/logs_handler.go b/internal/api/logs_handler.go index 585243c9..f703bcb0 100644 --- a/internal/api/logs_handler.go +++ b/internal/api/logs_handler.go @@ -11,7 +11,6 @@ import ( pm "github.com/manchtools/power-manage/sdk/gen/go/pm/v1" "github.com/manchtools/power-manage/server/internal/store" - "github.com/manchtools/power-manage/server/internal/store/generated" "github.com/manchtools/power-manage/server/internal/taskqueue" "github.com/hibiken/asynq" @@ -36,9 +35,7 @@ func (h *LogsHandler) QueryDeviceLogs(ctx context.Context, req *connect.Request[ msg := req.Msg // Verify device exists - _, err := h.store.Queries().GetDeviceByID(ctx, generated.GetDeviceByIDParams{ - ID: msg.DeviceId, - }) + _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.DeviceId}) if err != nil { return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found") } diff --git a/internal/api/osquery_handler.go b/internal/api/osquery_handler.go index 517ec947..50167f34 100644 --- a/internal/api/osquery_handler.go +++ b/internal/api/osquery_handler.go @@ -14,7 +14,6 @@ import ( pm "github.com/manchtools/power-manage/sdk/gen/go/pm/v1" "github.com/manchtools/power-manage/server/internal/store" - "github.com/manchtools/power-manage/server/internal/store/generated" "github.com/manchtools/power-manage/server/internal/taskqueue" "google.golang.org/protobuf/types/known/timestamppb" @@ -48,9 +47,7 @@ func (h *OSQueryHandler) DispatchOSQuery(ctx context.Context, req *connect.Reque } // Verify device exists - _, err := h.store.Queries().GetDeviceByID(ctx, generated.GetDeviceByIDParams{ - ID: msg.DeviceId, - }) + _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.DeviceId}) if err != nil { return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found") } @@ -195,9 +192,7 @@ func (h *OSQueryHandler) RefreshDeviceInventory(ctx context.Context, req *connec msg := req.Msg // Verify device exists - _, err := h.store.Queries().GetDeviceByID(ctx, generated.GetDeviceByIDParams{ - ID: msg.DeviceId, - }) + _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.DeviceId}) if err != nil { return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found") } diff --git a/internal/api/terminal_handler.go b/internal/api/terminal_handler.go index 3ffcf0e4..6d8a0a2f 100644 --- a/internal/api/terminal_handler.go +++ b/internal/api/terminal_handler.go @@ -22,7 +22,6 @@ import ( "github.com/manchtools/power-manage/server/internal/eventtypes/payloads" "github.com/manchtools/power-manage/server/internal/gateway/registry" "github.com/manchtools/power-manage/server/internal/store" - "github.com/manchtools/power-manage/server/internal/store/generated" "github.com/manchtools/power-manage/server/internal/terminal" ) @@ -112,10 +111,7 @@ func (h *TerminalHandler) StartTerminal(ctx context.Context, req *connect.Reques // to userCtx.ID, which masked admin access to bulk-enrolled // (unassigned) devices. filterUserID := userFilterID(ctx, "StartTerminal") - if _, err := h.store.Queries().GetDeviceByID(ctx, generated.GetDeviceByIDParams{ - ID: req.Msg.DeviceId, - FilterUserID: filterUserID, - }); err != nil { + if _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: req.Msg.DeviceId, OwnerScope: filterUserID}); err != nil { if store.IsNotFound(err) { return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found") } diff --git a/internal/api/user_selection_handler.go b/internal/api/user_selection_handler.go index 3a91de1b..ecc4eb17 100644 --- a/internal/api/user_selection_handler.go +++ b/internal/api/user_selection_handler.go @@ -12,7 +12,6 @@ import ( "github.com/manchtools/power-manage/server/internal/eventtypes" "github.com/manchtools/power-manage/server/internal/eventtypes/payloads" "github.com/manchtools/power-manage/server/internal/store" - db "github.com/manchtools/power-manage/server/internal/store/generated" ) // UserSelectionHandler handles user selection RPCs. @@ -41,9 +40,9 @@ func (h *UserSelectionHandler) SetUserSelection(ctx context.Context, req *connec } // Verify device access (non-admins can only access assigned devices) - _, err = h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ - ID: req.Msg.DeviceId, - FilterUserID: userFilterID(ctx, "ListDevices"), + _, err = h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ + ID: req.Msg.DeviceId, + OwnerScope: userFilterID(ctx, "ListDevices"), }) if err != nil { return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found") @@ -110,9 +109,9 @@ func (h *UserSelectionHandler) ListAvailableActions(ctx context.Context, req *co } // Verify device access (non-admins can only access assigned devices) - _, err := h.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ - ID: req.Msg.DeviceId, - FilterUserID: userFilterID(ctx, "ListDevices"), + _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ + ID: req.Msg.DeviceId, + OwnerScope: userFilterID(ctx, "ListDevices"), }) if err != nil { return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found") diff --git a/internal/control/inbox_worker.go b/internal/control/inbox_worker.go index 23632627..6eb472a0 100644 --- a/internal/control/inbox_worker.go +++ b/internal/control/inbox_worker.go @@ -86,7 +86,7 @@ func (w *InboxWorker) handleDeviceHello(ctx context.Context, t *asynq.Task) erro logger := w.logger.With("device_id", payload.DeviceID, "hostname", payload.Hostname) // Skip processing for deleted or unknown devices. - deleted, err := w.store.Queries().IsDeviceDeleted(ctx, payload.DeviceID) + deleted, err := w.store.Repos().Device.IsDeleted(ctx, payload.DeviceID) if err != nil { if store.IsNotFound(err) { logger.Debug("ignoring hello from unknown device") @@ -133,7 +133,7 @@ func (w *InboxWorker) handleDeviceHeartbeat(ctx context.Context, t *asynq.Task) } // Skip processing for deleted or unknown devices. - deleted, err := w.store.Queries().IsDeviceDeleted(ctx, payload.DeviceID) + deleted, err := w.store.Repos().Device.IsDeleted(ctx, payload.DeviceID) if err != nil { if store.IsNotFound(err) { w.logger.Debug("ignoring heartbeat from unknown device", "device_id", payload.DeviceID) diff --git a/internal/search/index.go b/internal/search/index.go index 9a78c8af..90f88d71 100644 --- a/internal/search/index.go +++ b/internal/search/index.go @@ -637,11 +637,7 @@ func (idx *Index) warmDevices(ctx context.Context) (int, error) { var total int for { - devices, err := idx.store.Queries().ListDevices(ctx, db.ListDevicesParams{ - Limit: pageSize, - Offset: offset, - FilterUserID: nil, - }) + devices, err := idx.store.Repos().Device.List(ctx, store.ListDevicesFilter{Limit: pageSize, Offset: offset, OwnerScope: nil}) if err != nil { return total, err } @@ -912,7 +908,7 @@ func (idx *Index) warmExecutions(ctx context.Context) (int, error) { // Resolve device hostname (cached). hostname, ok := deviceNames[e.DeviceID] if !ok { - d, err := idx.store.Queries().GetDeviceByID(ctx, db.GetDeviceByIDParams{ID: e.DeviceID}) + d, err := idx.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: e.DeviceID}) if err == nil { hostname = d.Hostname } diff --git a/internal/store/device.go b/internal/store/device.go new file mode 100644 index 00000000..3af04838 --- /dev/null +++ b/internal/store/device.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "encoding/json" + "time" +) + +// Device is the device projection row. CertFingerprint pins the +// agent cert the device currently uses — used by the +// CertificateHandler to refuse renewals when a different fingerprint +// presents. Labels stays as json.RawMessage at the boundary per the +// JSONB normalize plan in #242. +// +// The compliance-status quadruple (Status / CheckedAt / Total / +// Passing) is denormalized on the device row by the compliance +// projector so the device list/get path doesn't need to join the +// compliance projection per row. +type Device struct { + ID string + Hostname string + AgentVersion string + CertFingerprint *string + CertNotAfter *time.Time + RegisteredAt *time.Time + LastSeenAt *time.Time + RegistrationTokenID *string + Labels json.RawMessage + IsDeleted bool + SyncIntervalMinutes int32 + ComplianceStatus int32 + ComplianceCheckedAt *time.Time + ComplianceTotal int32 + CompliancePassing int32 +} + +// DeviceHostname is the narrow (id, hostname) shape returned by the +// bulk-hostname lookup. Used by response builders that need to +// hydrate device names without re-loading the full projection row +// per device. +type DeviceHostname struct { + ID string + Hostname string +} + +// GetDeviceKey is the composite key used by the device lookup. The +// optional OwnerScope mirrors the `:self`-scoped permission shape +// used elsewhere: nil = admin view (no scoping), non-nil = restrict +// to devices the user has any assignment to. +type GetDeviceKey struct { + ID string + OwnerScope *string +} + +// ListDevicesFilter pairs pagination with the same `:self`-scoped +// owner filter that GetDeviceKey uses. +type ListDevicesFilter struct { + Limit int32 + Offset int32 + OwnerScope *string +} + +// DeviceRepo reads device-projection state. Writes flow through +// events (DeviceRegistered / DeviceHeartbeat / DeviceDeleted / etc.) +// and the projector listener. +type DeviceRepo interface { + // Get returns a device by ID. ownerScope = nil means no + // scoping; non-nil restricts the lookup to devices the user has + // assignment access to. Returns ErrNotFound when the device is + // missing OR when the scope filter excludes it (the handler + // can't distinguish, by design). + Get(ctx context.Context, key GetDeviceKey) (Device, error) + + // IsDeleted reports whether the device is soft-deleted. Returns + // ErrNotFound when the device row never existed. + IsDeleted(ctx context.Context, id string) (bool, error) + + // List returns a page of devices, optionally scoped to a user + // via the filter's OwnerScope. + List(ctx context.Context, filter ListDevicesFilter) ([]Device, error) + + // ListOnline returns a page of devices that have phoned in + // within the online-window threshold. Pairs with CountOnline + // for paginated totals. + ListOnline(ctx context.Context, filter ListDevicesFilter) ([]Device, error) + + // ListOffline returns a page of devices past the online-window + // threshold. Pairs with CountOffline. + ListOffline(ctx context.Context, filter ListDevicesFilter) ([]Device, error) + + // Count returns the total non-deleted device count (optionally + // scoped). Pairs with List. + Count(ctx context.Context, ownerScope *string) (int64, error) + + // CountOnline / CountOffline mirror their ListXxx counterparts + // for pagination totals. + CountOnline(ctx context.Context, ownerScope *string) (int64, error) + CountOffline(ctx context.Context, ownerScope *string) (int64, error) + + // HostnamesByIDs returns the (id, hostname) pairs for the given + // device IDs in a single round-trip. Used by response builders + // in LPS / LUKS / log-query handlers that need to enrich + // per-device records with hostname. + HostnamesByIDs(ctx context.Context, ids []string) ([]DeviceHostname, error) + + // SyncInterval returns the per-device sync-interval-minutes + // configured for offline-scheduler cadence. Returns 0 (the + // "use default" sentinel) when the device row has no override. + SyncInterval(ctx context.Context, deviceID string) (int32, error) +} diff --git a/internal/store/postgres/device.go b/internal/store/postgres/device.go new file mode 100644 index 00000000..e67def29 --- /dev/null +++ b/internal/store/postgres/device.go @@ -0,0 +1,151 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/manchtools/power-manage/server/internal/store" + "github.com/manchtools/power-manage/server/internal/store/generated" +) + +// Device implements store.DeviceRepo against devices_projection. +type Device struct { + q *generated.Queries +} + +// NewDevice returns a Device repo bound to the given sqlc handle. +func NewDevice(q *generated.Queries) *Device { + return &Device{q: q} +} + +func (d *Device) Get(ctx context.Context, key store.GetDeviceKey) (store.Device, error) { + row, err := d.q.GetDeviceByID(ctx, generated.GetDeviceByIDParams{ + ID: key.ID, + FilterUserID: key.OwnerScope, + }) + if err != nil { + return store.Device{}, fmt.Errorf("device: get: %w", translateNotFound(err)) + } + return deviceFromRow(row), nil +} + +func (d *Device) IsDeleted(ctx context.Context, id string) (bool, error) { + deleted, err := d.q.IsDeviceDeleted(ctx, id) + if err != nil { + return false, fmt.Errorf("device: is deleted: %w", translateNotFound(err)) + } + return deleted, nil +} + +func (d *Device) List(ctx context.Context, filter store.ListDevicesFilter) ([]store.Device, error) { + rows, err := d.q.ListDevices(ctx, generated.ListDevicesParams{ + Limit: filter.Limit, + Offset: filter.Offset, + FilterUserID: filter.OwnerScope, + }) + if err != nil { + return nil, fmt.Errorf("device: list: %w", err) + } + return deviceRowsToSlice(rows), nil +} + +func (d *Device) ListOnline(ctx context.Context, filter store.ListDevicesFilter) ([]store.Device, error) { + rows, err := d.q.ListDevicesOnline(ctx, generated.ListDevicesOnlineParams{ + Limit: filter.Limit, + Offset: filter.Offset, + FilterUserID: filter.OwnerScope, + }) + if err != nil { + return nil, fmt.Errorf("device: list online: %w", err) + } + return deviceRowsToSlice(rows), nil +} + +func (d *Device) ListOffline(ctx context.Context, filter store.ListDevicesFilter) ([]store.Device, error) { + rows, err := d.q.ListDevicesOffline(ctx, generated.ListDevicesOfflineParams{ + Limit: filter.Limit, + Offset: filter.Offset, + FilterUserID: filter.OwnerScope, + }) + if err != nil { + return nil, fmt.Errorf("device: list offline: %w", err) + } + return deviceRowsToSlice(rows), nil +} + +func (d *Device) Count(ctx context.Context, ownerScope *string) (int64, error) { + n, err := d.q.CountDevices(ctx, ownerScope) + if err != nil { + return 0, fmt.Errorf("device: count: %w", translateNotFound(err)) + } + return n, nil +} + +func (d *Device) CountOnline(ctx context.Context, ownerScope *string) (int64, error) { + n, err := d.q.CountDevicesOnline(ctx, ownerScope) + if err != nil { + return 0, fmt.Errorf("device: count online: %w", translateNotFound(err)) + } + return n, nil +} + +func (d *Device) CountOffline(ctx context.Context, ownerScope *string) (int64, error) { + n, err := d.q.CountDevicesOffline(ctx, ownerScope) + if err != nil { + return 0, fmt.Errorf("device: count offline: %w", translateNotFound(err)) + } + return n, nil +} + +// deviceRowsToSlice translates a slice of sqlc rows to domain +// devices. Shared by List / ListOnline / ListOffline. +func deviceRowsToSlice(rows []generated.DevicesProjection) []store.Device { + out := make([]store.Device, len(rows)) + for i, r := range rows { + out[i] = deviceFromRow(r) + } + return out +} + +func (d *Device) HostnamesByIDs(ctx context.Context, ids []string) ([]store.DeviceHostname, error) { + rows, err := d.q.GetDeviceHostnamesByIDs(ctx, ids) + if err != nil { + return nil, fmt.Errorf("device: hostnames by ids: %w", err) + } + out := make([]store.DeviceHostname, len(rows)) + for i, r := range rows { + out[i] = store.DeviceHostname{ID: r.ID, Hostname: r.Hostname} + } + return out, nil +} + +func (d *Device) SyncInterval(ctx context.Context, deviceID string) (int32, error) { + mins, err := d.q.GetDeviceSyncInterval(ctx, deviceID) + if err != nil { + return 0, fmt.Errorf("device: sync interval: %w", translateNotFound(err)) + } + return mins, nil +} + +// deviceFromRow translates a sqlc projection row to the domain +// shape. Shared so the field mapping lives in one place. +func deviceFromRow(r generated.DevicesProjection) store.Device { + return store.Device{ + ID: r.ID, + Hostname: r.Hostname, + AgentVersion: r.AgentVersion, + CertFingerprint: r.CertFingerprint, + CertNotAfter: r.CertNotAfter, + RegisteredAt: r.RegisteredAt, + LastSeenAt: r.LastSeenAt, + RegistrationTokenID: r.RegistrationTokenID, + Labels: json.RawMessage(r.Labels), + IsDeleted: r.IsDeleted, + SyncIntervalMinutes: r.SyncIntervalMinutes, + ComplianceStatus: r.ComplianceStatus, + ComplianceCheckedAt: r.ComplianceCheckedAt, + ComplianceTotal: r.ComplianceTotal, + CompliancePassing: r.CompliancePassing, + } +} diff --git a/internal/store/postgres/wire.go b/internal/store/postgres/wire.go index 3aa2a0ff..cfde648b 100644 --- a/internal/store/postgres/wire.go +++ b/internal/store/postgres/wire.go @@ -16,6 +16,7 @@ func NewRepos(q *generated.Queries) *store.Repos { return &store.Repos{ AuthState: NewAuthState(q), Compliance: NewCompliance(q), + Device: NewDevice(q), DeviceGroup: NewDeviceGroup(q), IdentityLink: NewIdentityLink(q), IdentityProvider: NewIdentityProvider(q), diff --git a/internal/store/repos.go b/internal/store/repos.go index bf267f93..ae75c3f0 100644 --- a/internal/store/repos.go +++ b/internal/store/repos.go @@ -15,6 +15,7 @@ package store type Repos struct { AuthState AuthStateRepo Compliance ComplianceRepo + Device DeviceRepo DeviceGroup DeviceGroupRepo IdentityLink IdentityLinkRepo IdentityProvider IdentityProviderRepo