iop/apps/control-plane/internal/credentialstore/route.go
toki 61d4e76fe8 feat(protocol-profile): GLM Coding Plan 프로필을 분리한다
General API와 Coding Plan이 서로 다른 엔드포인트와 사용량 경계를 유지하도록 프로필, credential route, provider mapping, full-cycle 검증을 함께 반영한다.
2026-08-02 13:24:42 +09:00

664 lines
23 KiB
Go

package credentialstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"iop/packages/go/config"
)
var (
// ErrRouteNotFound is returned when a requested route does not exist.
ErrRouteNotFound = errors.New("credentialstore: route not found")
// ErrIncompatibleProfile is returned when the protocol profile is incompatible with a slot's vendor and credential kind.
ErrIncompatibleProfile = errors.New("credentialstore: profile incompatible with slot credential")
// ErrCrossPrincipalSlot is returned when attempting to bind a route to a slot belonging to a different principal.
ErrCrossPrincipalSlot = errors.New("credentialstore: slot belongs to a different principal")
// ErrRouteAliasAlreadyExists is returned when a route alias is already in use for a principal.
ErrRouteAliasAlreadyExists = errors.New("credentialstore: route alias already exists for principal")
// ErrRouteRevoked is returned when attempting to modify a revoked route.
ErrRouteRevoked = errors.New("credentialstore: revoked route cannot be modified")
// ErrRouteNotActive is returned when an operation requires an active route.
ErrRouteNotActive = errors.New("credentialstore: route is not active")
)
// CreateRouteInput holds parameters for binding a route to a credential slot.
type CreateRouteInput struct {
PrincipalID string
SlotID string
Alias string
ProfileID string
UpstreamModel string
ResourceSelector string
}
// UpdateRouteInput replaces the mutable target fields of a route under a
// revision compare-and-swap. The route ID and principal ownership are stable.
type UpdateRouteInput struct {
PrincipalID string
RouteID string
CurrentRevision int64
SlotID string
Alias string
ProfileID string
UpstreamModel string
ResourceSelector string
}
// RouteRecord represents a persisted route binding row.
type RouteRecord struct {
ID string
PrincipalID string
SlotID string
Alias string
ProfileID string
UpstreamModel string
ResourceSelector string
Status string
Revision int64
CreatedAt time.Time
UpdatedAt time.Time
RevokedAt *time.Time
}
type profileAuthRule struct {
header string
scheme string
}
type credentialProfileRule map[string]profileAuthRule
// credentialProfileRules is deliberately closed. A credential slot can only
// bind to a catalog profile when its vendor, credential kind, profile ID, and
// profile authentication declaration all agree. New provider support must add
// an explicit entry here instead of inheriting a driver-level default.
var credentialProfileRules = map[string]credentialProfileRule{
"openai/bearer": {"openai": {header: "Authorization", scheme: "Bearer"}},
"gemini/bearer": {"gemini": {header: "Authorization", scheme: "Bearer"}},
"vllm/bearer": {"openai": {header: "Authorization", scheme: "Bearer"}},
"sglang/bearer": {"openai": {header: "Authorization", scheme: "Bearer"}},
"glm/bearer": {
"glm": {header: "Authorization", scheme: "Bearer"},
"glm_coding": {header: "Authorization", scheme: "Bearer"},
},
"kimi/bearer": {"kimi": {header: "Authorization", scheme: "Bearer"}},
"grok/bearer": {"grok": {header: "Authorization", scheme: "Bearer"}},
"anthropic/api_key": {"anthropic": {header: "x-api-key"}},
"minimax/bearer": {"minimax_chat": {header: "Authorization", scheme: "Bearer"}, "minimax_messages": {header: "Authorization", scheme: "Bearer"}},
"mimo/bearer": {"mimo_chat": {header: "Authorization", scheme: "Bearer"}},
"mimo/api_key": {"mimo_messages": {header: "api-key"}},
"seulgi/bearer": {"seulgi_chat": {header: "Authorization", scheme: "Bearer"}},
"seulgi/api_key": {"seulgi_messages": {header: "x-api-key"}},
"seulgivibe_openai/bearer": {"seulgi_chat": {header: "Authorization", scheme: "Bearer"}},
"seulgivibe_claude/api_key": {"seulgi_messages": {header: "x-api-key"}},
}
// credentialProtocolProfiles is a process-owned catalog for credential
// routing. The exported config snapshot is intentionally mutable for legacy
// callers, so credential compatibility must not depend on it after startup.
var credentialProtocolProfiles = config.BuiltInProtocolProfileCatalog()
func compatibilityKey(vendor, credentialKind string) string {
return normalizeCredentialPart(vendor) + "/" + normalizeCredentialPart(credentialKind)
}
func checkSlotProfileCompatibility(slot CredentialSlotRecord, profile config.ConcreteProtocolProfile) error {
rule, ok := credentialProfileRules[compatibilityKey(slot.Vendor, slot.CredentialKind)]
if !ok {
return fmt.Errorf("%w: unknown vendor/credential kind %s/%s", ErrIncompatibleProfile, slot.Vendor, slot.CredentialKind)
}
auth, ok := rule[profile.ID]
if !ok || !strings.EqualFold(strings.TrimSpace(profile.Auth.Header), auth.header) || !strings.EqualFold(strings.TrimSpace(profile.Auth.Scheme), auth.scheme) {
return fmt.Errorf("%w: %s/%s cannot use profile %s", ErrIncompatibleProfile, slot.Vendor, slot.CredentialKind, profile.ID)
}
return nil
}
// CreateRoute creates a route binding for a principal and slot.
func (s *Store) CreateRoute(ctx context.Context, in CreateRouteInput) (*RouteRecord, error) {
if strings.TrimSpace(in.PrincipalID) == "" {
return nil, fmt.Errorf("credentialstore: CreateRoute: principal_id is required")
}
if strings.TrimSpace(in.SlotID) == "" {
return nil, fmt.Errorf("credentialstore: CreateRoute: slot_id is required")
}
if strings.TrimSpace(in.UpstreamModel) == "" {
return nil, fmt.Errorf("credentialstore: CreateRoute: upstream_model is required")
}
alias := normalizeAlias(in.Alias)
resSelector := normalizeResourceSelector(in.ResourceSelector)
upstreamModel := strings.TrimSpace(in.UpstreamModel)
routeID := uuid.New().String()
now := time.Now().UTC()
var record RouteRecord
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
slot, err := s.routeSlotTx(ctx, tx, in.PrincipalID, in.SlotID)
if err != nil {
return err
}
if slot.Status == StatusRevoked {
return ErrSlotRevoked
}
if slot.Status == StatusDisabled {
return ErrSlotNotActive
}
profile, err := resolveSlotProfile(slot, in.ProfileID)
if err != nil {
return err
}
if err := s.ensureRouteIDAvailableTx(ctx, tx, in.PrincipalID, routeID); err != nil {
return err
}
if err := s.ensureRouteAliasAvailableTx(ctx, tx, in.PrincipalID, routeID, alias, ""); err != nil {
return err
}
var aliasArg any
if alias != "" {
aliasArg = alias
}
if _, err := tx.ExecContext(ctx, s.bind(`
INSERT INTO routes (id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), routeID, in.PrincipalID, slot.ID, aliasArg, profile.ID, upstreamModel, resSelector, StatusActive, 0, now, now); err != nil {
return fmt.Errorf("credentialstore: insert route: %w", err)
}
if slot.Status == StatusDraft {
if err := s.activateDraftSlotTx(ctx, tx, slot); err != nil {
return err
}
}
if err := s.bumpProjectionGenerationTx(ctx, tx); err != nil {
return err
}
record = RouteRecord{ID: routeID, PrincipalID: in.PrincipalID, SlotID: slot.ID, Alias: alias, ProfileID: profile.ID, UpstreamModel: upstreamModel, ResourceSelector: resSelector, Status: StatusActive, Revision: 0, CreatedAt: now, UpdatedAt: now}
return nil
})
if err != nil {
return nil, err
}
return &record, nil
}
func normalizeResourceSelector(value string) string {
if normalized := strings.TrimSpace(value); normalized != "" {
return normalized
}
return "default"
}
func resolveSlotProfile(slot CredentialSlotRecord, profileID string) (config.ConcreteProtocolProfile, error) {
profile, err := config.ResolveProtocolProfile(profileID, slot.Vendor, credentialProtocolProfiles)
if err != nil {
return config.ConcreteProtocolProfile{}, fmt.Errorf("%w: %v", ErrIncompatibleProfile, err)
}
if err := checkSlotProfileCompatibility(slot, profile); err != nil {
return config.ConcreteProtocolProfile{}, err
}
return profile, nil
}
func (s *Store) routeSlotTx(ctx context.Context, tx *sql.Tx, principalID, slotID string) (CredentialSlotRecord, error) {
var slot CredentialSlotRecord
if err := s.loadSlotTx(ctx, tx, principalID, slotID, &slot); err == nil {
return slot, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return CredentialSlotRecord{}, err
}
var actualPrincipalID string
err := tx.QueryRowContext(ctx, s.bind(`SELECT principal_id FROM credential_slots WHERE id=?`), slotID).Scan(&actualPrincipalID)
if err == nil && actualPrincipalID != principalID {
return CredentialSlotRecord{}, ErrCrossPrincipalSlot
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return CredentialSlotRecord{}, err
}
return CredentialSlotRecord{}, ErrSlotNotFound
}
func (s *Store) ensureRouteAliasAvailableTx(ctx context.Context, tx *sql.Tx, principalID, routeID, alias, excludeRouteID string) error {
if alias == "" {
return nil
}
query := `SELECT COUNT(*) FROM routes WHERE principal_id=? AND (alias=? OR id=?)`
args := []any{principalID, alias, alias}
if excludeRouteID != "" {
query += ` AND id<>?`
args = append(args, excludeRouteID)
}
var count int
if err := tx.QueryRowContext(ctx, s.bind(query), args...).Scan(&count); err != nil {
return fmt.Errorf("credentialstore: check route alias: %w", err)
}
if count > 0 || alias == routeID {
return ErrRouteAliasAlreadyExists
}
return nil
}
func (s *Store) ensureRouteIDAvailableTx(ctx context.Context, tx *sql.Tx, principalID, routeID string) error {
var count int
if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM routes WHERE principal_id=? AND (id=? OR alias=?)`), principalID, routeID, routeID).Scan(&count); err != nil {
return fmt.Errorf("credentialstore: check route id: %w", err)
}
if count > 0 {
return ErrRouteAliasAlreadyExists
}
return nil
}
func (s *Store) activateDraftSlotTx(ctx context.Context, tx *sql.Tx, slot CredentialSlotRecord) error {
if err := s.validateEnvelopeKey(ctx, slot.Envelope); err != nil {
return err
}
res, err := tx.ExecContext(ctx, s.bind(`UPDATE credential_slots SET status=?, revision=revision+1, updated_at=? WHERE id=? AND revision=? AND status=?`), StatusActive, time.Now().UTC(), slot.ID, slot.Revision, StatusDraft)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("%w: draft slot activation lost", ErrRevisionMismatch)
}
return nil
}
// GetRoute retrieves a route binding by principal ID and route ID.
func (s *Store) GetRoute(ctx context.Context, principalID, routeID string) (*RouteRecord, error) {
var r RouteRecord
var alias sql.NullString
var createdAt, updatedAt string
var revokedAt sql.NullString
err := s.db.QueryRowContext(ctx, s.bind(`
SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
FROM routes
WHERE principal_id=? AND id=?
`), principalID, routeID).Scan(
&r.ID, &r.PrincipalID, &r.SlotID, &alias, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
&createdAt, &updatedAt, &revokedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrRouteNotFound
}
return nil, err
}
if alias.Valid {
r.Alias = alias.String
}
r.CreatedAt, _ = parseTime(createdAt)
r.UpdatedAt, _ = parseTime(updatedAt)
if revokedAt.Valid {
parsed, _ := parseTime(revokedAt.String)
r.RevokedAt = &parsed
}
return &r, nil
}
// GetRouteByAlias retrieves a route binding by principal ID and alias.
func (s *Store) GetRouteByAlias(ctx context.Context, principalID, alias string) (*RouteRecord, error) {
var r RouteRecord
var aliasVal sql.NullString
var createdAt, updatedAt string
var revokedAt sql.NullString
err := s.db.QueryRowContext(ctx, s.bind(`
SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
FROM routes
WHERE principal_id=? AND alias=?
`), principalID, alias).Scan(
&r.ID, &r.PrincipalID, &r.SlotID, &aliasVal, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
&createdAt, &updatedAt, &revokedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrRouteNotFound
}
return nil, err
}
if aliasVal.Valid {
r.Alias = aliasVal.String
}
r.CreatedAt, _ = parseTime(createdAt)
r.UpdatedAt, _ = parseTime(updatedAt)
if revokedAt.Valid {
parsed, _ := parseTime(revokedAt.String)
r.RevokedAt = &parsed
}
return &r, nil
}
// ListRoutes returns all route bindings for a principal.
func (s *Store) ListRoutes(ctx context.Context, principalID string) ([]RouteRecord, error) {
rows, err := s.db.QueryContext(ctx, s.bind(`
SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
FROM routes
WHERE principal_id=?
ORDER BY created_at ASC
`), principalID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RouteRecord
for rows.Next() {
var r RouteRecord
var alias sql.NullString
var createdAt, updatedAt string
var revokedAt sql.NullString
if err := rows.Scan(
&r.ID, &r.PrincipalID, &r.SlotID, &alias, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
&createdAt, &updatedAt, &revokedAt,
); err != nil {
return nil, err
}
if alias.Valid {
r.Alias = alias.String
}
r.CreatedAt, _ = parseTime(createdAt)
r.UpdatedAt, _ = parseTime(updatedAt)
if revokedAt.Valid {
parsed, _ := parseTime(revokedAt.String)
r.RevokedAt = &parsed
}
out = append(out, r)
}
return out, rows.Err()
}
// ListRoutesBySlot returns all route bindings attached to a specific slot.
func (s *Store) ListRoutesBySlot(ctx context.Context, principalID, slotID string) ([]RouteRecord, error) {
rows, err := s.db.QueryContext(ctx, s.bind(`
SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
FROM routes
WHERE principal_id=? AND slot_id=?
ORDER BY created_at ASC
`), principalID, slotID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RouteRecord
for rows.Next() {
var r RouteRecord
var alias sql.NullString
var createdAt, updatedAt string
var revokedAt sql.NullString
if err := rows.Scan(
&r.ID, &r.PrincipalID, &r.SlotID, &alias, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
&createdAt, &updatedAt, &revokedAt,
); err != nil {
return nil, err
}
if alias.Valid {
r.Alias = alias.String
}
r.CreatedAt, _ = parseTime(createdAt)
r.UpdatedAt, _ = parseTime(updatedAt)
if revokedAt.Valid {
parsed, _ := parseTime(revokedAt.String)
r.RevokedAt = &parsed
}
out = append(out, r)
}
return out, rows.Err()
}
// UpdateRoute atomically replaces a route's slot/profile/model/selector/alias
// when the caller presents the current route revision.
func (s *Store) UpdateRoute(ctx context.Context, in UpdateRouteInput) (*RouteRecord, error) {
if strings.TrimSpace(in.PrincipalID) == "" || strings.TrimSpace(in.RouteID) == "" || strings.TrimSpace(in.SlotID) == "" {
return nil, fmt.Errorf("credentialstore: UpdateRoute: principal_id, route_id, and slot_id are required")
}
if strings.TrimSpace(in.UpstreamModel) == "" {
return nil, fmt.Errorf("credentialstore: UpdateRoute: upstream_model is required")
}
alias := normalizeAlias(in.Alias)
resSelector := normalizeResourceSelector(in.ResourceSelector)
upstreamModel := strings.TrimSpace(in.UpstreamModel)
var updated RouteRecord
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
var route RouteRecord
if err := s.loadRouteTx(ctx, tx, in.PrincipalID, in.RouteID, &route); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrRouteNotFound
}
return err
}
if route.Status == StatusRevoked {
return ErrRouteRevoked
}
if route.Revision != in.CurrentRevision {
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, route.Revision, in.CurrentRevision)
}
slot, err := s.routeSlotTx(ctx, tx, in.PrincipalID, in.SlotID)
if err != nil {
return err
}
if slot.Status != StatusActive {
return ErrSlotNotActive
}
profile, err := resolveSlotProfile(slot, in.ProfileID)
if err != nil {
return err
}
if err := s.ensureRouteAliasAvailableTx(ctx, tx, in.PrincipalID, in.RouteID, alias, in.RouteID); err != nil {
return err
}
var aliasArg any
if alias != "" {
aliasArg = alias
}
now := time.Now().UTC()
res, err := tx.ExecContext(ctx, s.bind(`
UPDATE routes
SET slot_id=?, alias=?, profile_id=?, upstream_model=?, resource_selector=?, revision=revision+1, updated_at=?
WHERE principal_id=? AND id=? AND revision=? AND status<>?
`), slot.ID, aliasArg, profile.ID, upstreamModel, resSelector, now, in.PrincipalID, in.RouteID, in.CurrentRevision, StatusRevoked)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("%w: failed to update route", ErrRevisionMismatch)
}
if err := s.loadRouteTx(ctx, tx, in.PrincipalID, in.RouteID, &updated); err != nil {
return err
}
return s.bumpProjectionGenerationTx(ctx, tx)
})
if err != nil {
return nil, err
}
return &updated, nil
}
func (s *Store) slotHasCompatibleBindingTx(ctx context.Context, tx *sql.Tx, slot CredentialSlotRecord) (bool, error) {
rows, err := tx.QueryContext(ctx, s.bind(`SELECT profile_id FROM routes WHERE principal_id=? AND slot_id=? AND status<>?`), slot.PrincipalID, slot.ID, StatusRevoked)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var profileID string
if err := rows.Scan(&profileID); err != nil {
return false, err
}
if _, err := resolveSlotProfile(slot, profileID); err == nil {
return true, nil
}
}
return false, rows.Err()
}
// DisableRoute disables an active route binding using CAS on revision.
func (s *Store) DisableRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
return s.casRouteStatus(ctx, principalID, routeID, currentRevision, []string{StatusActive}, StatusDisabled)
}
// EnableRoute enables a disabled route binding using CAS on revision.
func (s *Store) EnableRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
return s.enableRoute(ctx, principalID, routeID, currentRevision)
}
func (s *Store) enableRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
var updated RouteRecord
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
var route RouteRecord
if err := s.loadRouteTx(ctx, tx, principalID, routeID, &route); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrRouteNotFound
}
return err
}
if route.Revision != currentRevision {
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, route.Revision, currentRevision)
}
if route.Status == StatusRevoked {
return ErrRouteRevoked
}
if route.Status != StatusDisabled {
return ErrRouteNotActive
}
slot, err := s.routeSlotTx(ctx, tx, principalID, route.SlotID)
if err != nil {
return err
}
if slot.Status != StatusActive {
return ErrSlotNotActive
}
if _, err := resolveSlotProfile(slot, route.ProfileID); err != nil {
return err
}
now := time.Now().UTC()
res, err := tx.ExecContext(ctx, s.bind(`UPDATE routes SET status=?, revision=revision+1, updated_at=?, revoked_at=NULL WHERE principal_id=? AND id=? AND revision=? AND status=?`), StatusActive, now, principalID, routeID, currentRevision, StatusDisabled)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("%w: failed to enable route", ErrRevisionMismatch)
}
if err := s.loadRouteTx(ctx, tx, principalID, routeID, &updated); err != nil {
return err
}
return s.bumpProjectionGenerationTx(ctx, tx)
})
if err != nil {
return nil, err
}
return &updated, nil
}
// RevokeRoute revokes a route binding permanently using CAS on revision.
func (s *Store) RevokeRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
return s.casRouteStatus(ctx, principalID, routeID, currentRevision, []string{StatusActive, StatusDisabled}, StatusRevoked)
}
func (s *Store) casRouteStatus(ctx context.Context, principalID, routeID string, currentRevision int64, fromStatuses []string, toStatus string) (*RouteRecord, error) {
var updated RouteRecord
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
now := time.Now().UTC()
placeholders := strings.TrimRight(strings.Repeat("?,", len(fromStatuses)), ",")
query := `UPDATE routes SET status=?, revision=revision+1, updated_at=?, revoked_at=?
WHERE principal_id=? AND id=? AND revision=? AND status IN (` + placeholders + `)`
var revokedArg interface{}
if toStatus == StatusRevoked {
revokedArg = now
}
args := []any{toStatus, now, revokedArg, principalID, routeID, currentRevision}
for _, st := range fromStatuses {
args = append(args, st)
}
res, err := tx.ExecContext(ctx, s.bind(query), args...)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return s.classifyRouteTransitionMiss(ctx, tx, principalID, routeID, currentRevision, fromStatuses)
}
if err := s.loadRouteTx(ctx, tx, principalID, routeID, &updated); err != nil {
return err
}
return s.bumpProjectionGenerationTx(ctx, tx)
})
if err != nil {
return nil, err
}
return &updated, nil
}
func (s *Store) classifyRouteTransitionMiss(ctx context.Context, tx *sql.Tx, principalID, routeID string, currentRevision int64, fromStatuses []string) error {
var status string
var revision int64
err := tx.QueryRowContext(ctx, s.bind(`
SELECT status, revision FROM routes WHERE principal_id=? AND id=?
`), principalID, routeID).Scan(&status, &revision)
if err == sql.ErrNoRows {
return ErrRouteNotFound
}
if err != nil {
return err
}
if revision != currentRevision {
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, revision, currentRevision)
}
if status == StatusRevoked {
return ErrRouteRevoked
}
return fmt.Errorf("%w: have %s want one of %s", ErrRouteNotActive, status, strings.Join(fromStatuses, ","))
}
func (s *Store) loadRouteTx(ctx context.Context, tx *sql.Tx, principalID, routeID string, target *RouteRecord) error {
var alias sql.NullString
var createdAt, updatedAt string
var revokedAt sql.NullString
err := tx.QueryRowContext(ctx, s.bind(`
SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
FROM routes
WHERE principal_id=? AND id=?
`), principalID, routeID).Scan(
&target.ID, &target.PrincipalID, &target.SlotID, &alias, &target.ProfileID, &target.UpstreamModel, &target.ResourceSelector, &target.Status, &target.Revision,
&createdAt, &updatedAt, &revokedAt,
)
if err != nil {
return err
}
if alias.Valid {
target.Alias = alias.String
}
target.CreatedAt, _ = parseTime(createdAt)
target.UpdatedAt, _ = parseTime(updatedAt)
if revokedAt.Valid {
parsed, _ := parseTime(revokedAt.String)
target.RevokedAt = &parsed
}
return nil
}