mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 17:48:52 +00:00
WIP: M-1 handler sentinel error mapping (checkpoint before branch cleanup)
Uncommitted migration work at the time of branch cleanup. Tagged as checkpoint/m1-migration-wip so the commit survives git gc --prune=now. Session context: Phase 3 Part B+C of the M-1 sentinel error migration was in progress. 38 modified files, 4 new files (errors.go + errors_test.go in internal/service/ and internal/api/handler/). Resume from this commit via 'git checkout checkpoint/m1-migration-wip'.
This commit is contained in:
@@ -3,11 +3,13 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// AgentRepository implements repository.AgentRepository
|
||||
@@ -72,8 +74,13 @@ func (r *AgentRepository) Get(ctx context.Context, id string) (*domain.Agent, er
|
||||
|
||||
agent, err := scanAgent(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("agent not found")
|
||||
// M-1 (P2): wrap sql.ErrNoRows with repository.ErrNotFound via %w so
|
||||
// the handler's errToStatus choke point dispatches to 404 via
|
||||
// errors.Is instead of the pre-M-1 strings.Contains(err.Error(),
|
||||
// "not found") branch at handler/agents.go. Mirrors agent_group and
|
||||
// profile repositories.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: agent %s", repository.ErrNotFound, id)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query agent: %w", err)
|
||||
}
|
||||
@@ -169,8 +176,12 @@ func (r *AgentRepository) Update(ctx context.Context, agent *domain.Agent) error
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
// M-1 (P2): wrap the zero-rows-affected condition with
|
||||
// repository.ErrNotFound so the handler's errToStatus dispatches to 404
|
||||
// via errors.Is without substring matching. Mirrors agent_group and
|
||||
// profile repositories.
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("agent not found")
|
||||
return fmt.Errorf("%w: agent %s", repository.ErrNotFound, agent.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -189,8 +200,10 @@ func (r *AgentRepository) Delete(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
// M-1 (P2): zero-rows-affected → repository.ErrNotFound wrap. Mirrors
|
||||
// agent_group and profile repositories.
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("agent not found")
|
||||
return fmt.Errorf("%w: agent %s", repository.ErrNotFound, id)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -236,8 +249,13 @@ func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string, metada
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
// M-1 (P2): zero-rows-affected → repository.ErrNotFound wrap. Note the
|
||||
// UPDATE filters on `retired_at IS NULL`, so a retired agent row also
|
||||
// returns zero-rows-affected here. The service layer short-circuits with
|
||||
// ErrAgentRetired (410) BEFORE reaching this path via Heartbeat's
|
||||
// explicit Get check, so the 404 vs 410 distinction is drawn upstream.
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("agent not found")
|
||||
return fmt.Errorf("%w: agent %s", repository.ErrNotFound, id)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -258,8 +276,13 @@ func (r *AgentRepository) GetByAPIKey(ctx context.Context, keyHash string) (*dom
|
||||
|
||||
agent, err := scanAgent(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("agent not found")
|
||||
// M-1 (P2): wrap sql.ErrNoRows with repository.ErrNotFound via %w.
|
||||
// The auth middleware calls this on every request; a missing row
|
||||
// must surface as 404 (well, 401 upstream — the middleware treats
|
||||
// "no agent matched" as auth failure) via the errToStatus choke
|
||||
// point, not via substring matching.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: agent with api key not found", repository.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query agent: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// AgentGroupRepository implements agent group CRUD with PostgreSQL.
|
||||
@@ -49,8 +51,12 @@ func (r *AgentGroupRepository) Get(ctx context.Context, id string) (*domain.Agen
|
||||
g := &domain.AgentGroup{}
|
||||
err := row.Scan(&g.ID, &g.Name, &g.Description, &g.MatchOS, &g.MatchArchitecture,
|
||||
&g.MatchIPCIDR, &g.MatchVersion, &g.Enabled, &g.CreatedAt, &g.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("agent group not found: %s", id)
|
||||
// M-1 (P2): wrap sql.ErrNoRows with repository.ErrNotFound via %w so the
|
||||
// handler's errToStatus choke point dispatches to 404 via errors.Is
|
||||
// instead of the pre-M-1 strings.Contains(err.Error(), "not found")
|
||||
// branch at handler/agent_groups.go. Mirrors profile repository.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: agent group %s", repository.ErrNotFound, id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get agent group: %w", err)
|
||||
@@ -83,8 +89,11 @@ func (r *AgentGroupRepository) Update(ctx context.Context, group *domain.AgentGr
|
||||
return fmt.Errorf("failed to update agent group: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
// M-1 (P2): wrap the zero-rows-affected condition with
|
||||
// repository.ErrNotFound so the handler's errToStatus dispatches to 404
|
||||
// via errors.Is without substring matching. Mirrors profile repository.
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("agent group not found: %s", group.ID)
|
||||
return fmt.Errorf("%w: agent group %s", repository.ErrNotFound, group.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -96,8 +105,11 @@ func (r *AgentGroupRepository) Delete(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("failed to delete agent group: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
// M-1 (P2): wrap zero-rows-affected with repository.ErrNotFound so the
|
||||
// handler's errToStatus dispatches to 404 via errors.Is. Mirrors profile
|
||||
// repository.
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("agent group not found: %s", id)
|
||||
return fmt.Errorf("%w: agent group %s", repository.ErrNotFound, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -264,8 +264,14 @@ func (r *CertificateRepository) Get(ctx context.Context, id string) (*domain.Man
|
||||
|
||||
cert, err := r.scanCertificate(ctx, row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("certificate not found")
|
||||
// M-1 (P2): wrap sql.ErrNoRows with repository.ErrNotFound via %w so
|
||||
// the handler's errToStatus choke point dispatches to 404 via
|
||||
// errors.Is instead of the pre-M-1 strings.Contains(err.Error(),
|
||||
// "not found") branch at handler/export.go (ExportPEM + ExportPKCS12).
|
||||
// scanCertificate already wraps sql.ErrNoRows via %w, so errors.Is
|
||||
// walks through. Mirrors profile and agent_group repositories.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: certificate %s", repository.ErrNotFound, id)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query certificate: %w", err)
|
||||
}
|
||||
@@ -396,8 +402,11 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
// M-1 (P2): zero-rows-affected → repository.ErrNotFound wrap so the
|
||||
// handler's errToStatus dispatches to 404 via errors.Is. Mirrors profile
|
||||
// and agent_group repositories.
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("certificate not found")
|
||||
return fmt.Errorf("%w: certificate %s", repository.ErrNotFound, cert.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -418,8 +427,10 @@ func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
// M-1 (P2): zero-rows-affected → repository.ErrNotFound wrap. Mirrors
|
||||
// profile and agent_group repositories.
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("certificate not found")
|
||||
return fmt.Errorf("%w: certificate %s", repository.ErrNotFound, id)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -583,6 +594,16 @@ func (r *CertificateRepository) GetLatestVersion(ctx context.Context, certID str
|
||||
v.KeySize = int(keySize.Int64)
|
||||
|
||||
if err != nil {
|
||||
// M-1 (P2): wrap sql.ErrNoRows with repository.ErrNotFound via %w so
|
||||
// the handler's errToStatus choke point dispatches to 404 via
|
||||
// errors.Is. The export service surfaces "no certificate version
|
||||
// found" on this path — pre-M-1 the handler branched on
|
||||
// strings.Contains(err.Error(), "not found"), now it walks the wrap
|
||||
// chain. Non-ErrNoRows errors preserve their "failed to get latest
|
||||
// certificate version" framing.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: certificate version for %s", repository.ErrNotFound, certID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get latest certificate version: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,15 @@ func (r *NotificationRepository) List(ctx context.Context, filter *repository.No
|
||||
return notifs, nil
|
||||
}
|
||||
|
||||
// UpdateStatus updates a notification's delivery status
|
||||
// UpdateStatus updates a notification's delivery status.
|
||||
//
|
||||
// M-1 (P2): zero-rows-affected now wraps repository.ErrNotFound via %w so the
|
||||
// handler's errToStatus choke point dispatches to 404 via errors.Is. Pre-M-1
|
||||
// the return was a raw fmt.Errorf("notification not found") string that the
|
||||
// service layer re-wrapped and the handler classified via strings.Contains —
|
||||
// one sentinel-message reword away from silently demoting 404 to 500. The
|
||||
// behavior (error on concurrent-delete / bad-id) is unchanged; only the error
|
||||
// identity is now type-safe.
|
||||
func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, status string, sentAt time.Time) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE notification_events SET status = $1, sent_at = $2 WHERE id = $3
|
||||
@@ -154,7 +162,7 @@ func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, st
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("notification not found")
|
||||
return fmt.Errorf("%w: notification %s", repository.ErrNotFound, id)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -307,10 +315,12 @@ func (r *NotificationRepository) RecordFailedAttempt(ctx context.Context, id str
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
// Same "not found" error shape as UpdateStatus above. The scheduler
|
||||
// logs-and-continues on this so a concurrently-deleted row doesn't
|
||||
// break the sweep.
|
||||
return fmt.Errorf("notification not found")
|
||||
// M-1 (P2): wrap repository.ErrNotFound (was raw
|
||||
// fmt.Errorf("notification not found")). Same "not found" shape as
|
||||
// UpdateStatus — the scheduler logs-and-continues on a concurrently
|
||||
// deleted row, but callers that surface the error (the handler) now
|
||||
// discriminate via errors.Is instead of substring matching.
|
||||
return fmt.Errorf("%w: notification %s", repository.ErrNotFound, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -342,7 +352,8 @@ func (r *NotificationRepository) MarkAsDead(ctx context.Context, id string, last
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("notification not found")
|
||||
// M-1 (P2): wrap repository.ErrNotFound. See UpdateStatus rationale.
|
||||
return fmt.Errorf("%w: notification %s", repository.ErrNotFound, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -379,7 +390,8 @@ func (r *NotificationRepository) Requeue(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("notification not found")
|
||||
// M-1 (P2): wrap repository.ErrNotFound. See UpdateStatus rationale.
|
||||
return fmt.Errorf("%w: notification %s", repository.ErrNotFound, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// ProfileRepository implements repository.CertificateProfileRepository
|
||||
@@ -63,8 +65,11 @@ func (r *ProfileRepository) Get(ctx context.Context, id string) (*domain.Certifi
|
||||
|
||||
p, err := scanProfile(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// M-1: wrap repository.ErrNotFound so the handler's errToStatus
|
||||
// choke point can route this to HTTP 404 via errors.Is without
|
||||
// substring-matching the "not found" message text.
|
||||
return nil, fmt.Errorf("%w: profile %s", repository.ErrNotFound, id)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query profile: %w", err)
|
||||
}
|
||||
@@ -159,7 +164,8 @@ func (r *ProfileRepository) Update(ctx context.Context, profile *domain.Certific
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("profile not found")
|
||||
// M-1: wrap repository.ErrNotFound — see Get for rationale.
|
||||
return fmt.Errorf("%w: profile %s", repository.ErrNotFound, profile.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -178,7 +184,8 @@ func (r *ProfileRepository) Delete(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("profile not found")
|
||||
// M-1: wrap repository.ErrNotFound — see Get for rationale.
|
||||
return fmt.Errorf("%w: profile %s", repository.ErrNotFound, id)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -72,7 +72,10 @@ func (r *RenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.R
|
||||
policy, err := scanRenewalPolicy(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("renewal policy not found: %s", id)
|
||||
// M-1: wrap repository.ErrNotFound so the handler's errToStatus
|
||||
// choke point can route this to HTTP 404 via errors.Is without
|
||||
// substring-matching the "not found" message text.
|
||||
return nil, fmt.Errorf("%w: renewal policy %s", repository.ErrNotFound, id)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query renewal policy: %w", err)
|
||||
}
|
||||
@@ -252,7 +255,8 @@ func (r *RenewalPolicyRepository) Update(ctx context.Context, id string, policy
|
||||
updated, err := scanRenewalPolicy(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("renewal policy not found: %s", id)
|
||||
// M-1: wrap repository.ErrNotFound — see Get for rationale.
|
||||
return fmt.Errorf("%w: renewal policy %s", repository.ErrNotFound, id)
|
||||
}
|
||||
if isUniqueViolation(err) {
|
||||
return repository.ErrRenewalPolicyDuplicateName
|
||||
@@ -283,7 +287,8 @@ func (r *RenewalPolicyRepository) Delete(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("failed to read RowsAffected for delete: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("renewal policy not found: %s", id)
|
||||
// M-1: wrap repository.ErrNotFound — see Get for rationale.
|
||||
return fmt.Errorf("%w: renewal policy %s", repository.ErrNotFound, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user