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:
shankar0123
2026-04-24 00:35:12 +00:00
parent d6959a75c1
commit 36e722ba12
42 changed files with 1319 additions and 294 deletions
+30 -7
View File
@@ -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)
}
+16 -4
View File
@@ -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
}
+25 -4
View File
@@ -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)
}
+20 -8
View File
@@ -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
}
+11 -4
View File
@@ -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
}