mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:01:30 +00:00
a485e31f63
Sprint 2 unified-master-audit closure. Pre-fix four service List
endpoints (target, issuer, team, agent_group) called repoFoo.List(ctx)
to fetch the full table then sliced in memory:
rows, _ := s.repo.List(ctx)
total := int64(len(rows))
start := (page - 1) * perPage
end := start + perPage
return rows[start:end], total, nil
This page-sliced in memory pattern marshals every row per request —
fine on small fleets but unacceptable for multi-tenant or large-fleet
deploys. The agent_group case was worse — the service explicitly
ignored page/perPage and returned the entire slice.
Fix:
- New ListPaginated(ctx, limit, offset) method on each of the four
repositories. Postgres implementations push LIMIT + OFFSET into
the SQL plus a SELECT COUNT(*) for the total. Mirrors the cursor
pattern already in internal/repository/postgres/certificate.go.
- Each ListPaginated normalises limit≤0→50 and offset<0→0,
matching the service-layer defaults that already existed.
- Repository interfaces grow the new method so adapters stay
swappable.
- Service List methods now call repoFoo.ListPaginated(ctx, perPage,
(page-1)*perPage) directly — no more memory-slice.
- AgentGroupService.ListAgentGroups closes the Bundle E / Audit
L-020 'page/perPage unused' gap.
Test changes:
- sliceWindow generic helper in testutil_test.go mirrors the SQL
LIMIT/OFFSET semantics for in-memory mocks.
- Six mock implementers (lifecycle_test, testutil_test x2,
agent_group_test, team_test) gain ListPaginated methods.
- TestTeamService_List_SCALE002_PaginationPropagatesToRepo pins
the page=2, perPage=3 → 3 rows of 10 invariant.
Closes SCALE-002.
159 lines
4.5 KiB
Go
159 lines
4.5 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// AgentGroupService provides business logic for agent group management.
|
|
type AgentGroupService struct {
|
|
groupRepo repository.AgentGroupRepository
|
|
auditService *AuditService
|
|
}
|
|
|
|
// NewAgentGroupService creates a new agent group service.
|
|
func NewAgentGroupService(
|
|
groupRepo repository.AgentGroupRepository,
|
|
auditService *AuditService,
|
|
) *AgentGroupService {
|
|
return &AgentGroupService{
|
|
groupRepo: groupRepo,
|
|
auditService: auditService,
|
|
}
|
|
}
|
|
|
|
// ListAgentGroups returns paginated agent groups (handler interface method).
|
|
//
|
|
// SCALE-002 closure (Sprint 2, 2026-05-16): pagination is now pushed
|
|
// into the SQL layer via AgentGroupRepository.ListPaginated, closing
|
|
// the Bundle E / Audit L-020 "page/perPage unused" gap.
|
|
func (s *AgentGroupService) ListAgentGroups(ctx context.Context, page, perPage int) ([]domain.AgentGroup, int64, error) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 {
|
|
perPage = 50
|
|
}
|
|
offset := (page - 1) * perPage
|
|
groups, total, err := s.groupRepo.ListPaginated(ctx, perPage, offset)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list agent groups: %w", err)
|
|
}
|
|
var result []domain.AgentGroup
|
|
for _, g := range groups {
|
|
if g != nil {
|
|
result = append(result, *g)
|
|
}
|
|
}
|
|
return result, total, nil
|
|
}
|
|
|
|
// GetAgentGroup returns a single agent group (handler interface method).
|
|
func (s *AgentGroupService) GetAgentGroup(ctx context.Context, id string) (*domain.AgentGroup, error) {
|
|
return s.groupRepo.Get(ctx, id)
|
|
}
|
|
|
|
// CreateAgentGroup creates a new agent group with validation (handler interface method).
|
|
func (s *AgentGroupService) CreateAgentGroup(ctx context.Context, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
|
if err := validateAgentGroup(&group); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if group.ID == "" {
|
|
group.ID = generateID("ag")
|
|
}
|
|
now := time.Now()
|
|
if group.CreatedAt.IsZero() {
|
|
group.CreatedAt = now
|
|
}
|
|
if group.UpdatedAt.IsZero() {
|
|
group.UpdatedAt = now
|
|
}
|
|
|
|
if err := s.groupRepo.Create(ctx, &group); err != nil {
|
|
return nil, fmt.Errorf("failed to create agent group: %w", err)
|
|
}
|
|
|
|
if s.auditService != nil {
|
|
if auditErr := s.auditService.RecordEvent(ctx, "api", domain.ActorTypeUser,
|
|
"create_agent_group", "agent_group", group.ID, nil); auditErr != nil {
|
|
slog.Error("failed to record audit event", "error", auditErr)
|
|
}
|
|
}
|
|
|
|
return &group, nil
|
|
}
|
|
|
|
// UpdateAgentGroup modifies an existing agent group (handler interface method).
|
|
func (s *AgentGroupService) UpdateAgentGroup(ctx context.Context, id string, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
|
if err := validateAgentGroup(&group); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
group.ID = id
|
|
if err := s.groupRepo.Update(ctx, &group); err != nil {
|
|
return nil, fmt.Errorf("failed to update agent group: %w", err)
|
|
}
|
|
|
|
if s.auditService != nil {
|
|
if auditErr := s.auditService.RecordEvent(ctx, "api", domain.ActorTypeUser,
|
|
"update_agent_group", "agent_group", id, nil); auditErr != nil {
|
|
slog.Error("failed to record audit event", "error", auditErr)
|
|
}
|
|
}
|
|
|
|
return &group, nil
|
|
}
|
|
|
|
// DeleteAgentGroup removes an agent group (handler interface method).
|
|
func (s *AgentGroupService) DeleteAgentGroup(ctx context.Context, id string) error {
|
|
if err := s.groupRepo.Delete(ctx, id); err != nil {
|
|
return fmt.Errorf("failed to delete agent group: %w", err)
|
|
}
|
|
|
|
if s.auditService != nil {
|
|
if auditErr := s.auditService.RecordEvent(ctx, "api", domain.ActorTypeUser,
|
|
"delete_agent_group", "agent_group", id, nil); auditErr != nil {
|
|
slog.Error("failed to record audit event", "error", auditErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListMembers returns agents in a group.
|
|
func (s *AgentGroupService) ListMembers(ctx context.Context, id string) ([]domain.Agent, int64, error) {
|
|
agents, err := s.groupRepo.ListMembers(ctx, id)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list group members: %w", err)
|
|
}
|
|
|
|
var result []domain.Agent
|
|
for _, a := range agents {
|
|
if a != nil {
|
|
result = append(result, *a)
|
|
}
|
|
}
|
|
|
|
return result, int64(len(result)), nil
|
|
}
|
|
|
|
// validateAgentGroup checks that an agent group's configuration is valid.
|
|
func validateAgentGroup(g *domain.AgentGroup) error {
|
|
if g.Name == "" {
|
|
return fmt.Errorf("agent group name is required")
|
|
}
|
|
if len(g.Name) > 255 {
|
|
return fmt.Errorf("agent group name exceeds 255 characters")
|
|
}
|
|
return nil
|
|
}
|