iop/apps/control-plane/internal/credentialstore/store.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

211 lines
6.2 KiB
Go

// Package credentialstore provides a durable, dialect-abstracted repository
// for principal and token records. It selects a SQL driver from the database
// URL (PostgreSQL for postgres:// or postgresql://, SQLite for file paths),
// runs idempotent schema migrations, and exposes an opaque Close that the
// Control Plane wires to its server lifetime.
//
// The store is optional: callers pass an empty URL and receive a nil Store
// without an error, preserving the legacy startup path. Any non-empty URL
// must resolve to a working connection; otherwise the caller returns a fatal
// error before serving.
package credentialstore
import (
"context"
"database/sql"
"fmt"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
const (
dialectPostgres = "pgx"
dialectSQLite = "sqlite"
)
// dialect selects the driver name from a database URL.
//
// Dialects:
// postgres:// or postgresql:// -> "pgx" (registered by jackc/pgx/v5/stdlib)
// file path or "file:..." -> "sqlite" (registered by modernc.org/sqlite)
// anything else -> "" (unknown; caller rejects)
func dialectFromURL(rawURL string) (string, error) {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return "", fmt.Errorf("credentialstore: empty database url")
}
lower := strings.ToLower(trimmed)
switch {
case strings.HasPrefix(lower, "postgres://") || strings.HasPrefix(lower, "postgresql://"):
return dialectPostgres, nil
case strings.HasPrefix(lower, "file:") || !strings.Contains(lower, "://"):
// Treat anything without a scheme as a file path for SQLite.
return dialectSQLite, nil
default:
return "", fmt.Errorf("credentialstore: unsupported database url scheme: %s", schemeOf(trimmed))
}
}
func schemeOf(rawURL string) string {
if i := strings.Index(rawURL, "://"); i >= 0 {
return rawURL[:i]
}
return ""
}
// Store holds the underlying sql.DB handle and selected dialect. It is safe
// for concurrent use through the standard database/sql pool.
type Store struct {
db *sql.DB
dialect string
keyRegistry EnvelopeKeyRegistry
}
// Option configures optional Store behaviors.
type Option func(*Store)
// WithEnvelopeKeyRegistry configures the store's envelope key validator.
func WithEnvelopeKeyRegistry(registry EnvelopeKeyRegistry) Option {
return func(s *Store) {
if registry != nil {
s.keyRegistry = registry
}
}
}
// KeyRegistry returns the configured EnvelopeKeyRegistry or an unavailable default.
func (s *Store) KeyRegistry() EnvelopeKeyRegistry {
if s == nil || s.keyRegistry == nil {
return unavailableKeyRegistry{}
}
return s.keyRegistry
}
// Open connects to the database named by databaseURL, runs the idempotent
// schema migration, and verifies reachability with a ping. When
// databaseURL is empty, Open returns (nil, nil) so the caller can preserve
// the legacy startup path without a configured credential plane.
//
// Open never silently falls back between dialects: a configured URL must
// resolve to exactly one driver and succeed.
func Open(ctx context.Context, databaseURL string, opts ...Option) (*Store, error) {
if strings.TrimSpace(databaseURL) == "" {
return nil, nil
}
dia, err := dialectFromURL(databaseURL)
if err != nil {
return nil, err
}
dataSource := databaseURL
if dia == dialectSQLite {
dataSource = sqliteDataSourceWithBusyTimeout(databaseURL)
}
db, err := sql.Open(dia, dataSource)
if err != nil {
return nil, fmt.Errorf("credentialstore: open %s: %w", dia, err)
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(2)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("credentialstore: ping %s: %w", dia, err)
}
if err := migrate(ctx, db, dia); err != nil {
_ = db.Close()
return nil, fmt.Errorf("credentialstore: migrate: %w", err)
}
s := &Store{
db: db,
dialect: dia,
keyRegistry: unavailableKeyRegistry{},
}
for _, opt := range opts {
opt(s)
}
return s, nil
}
// sqliteDataSourceWithBusyTimeout appends the modernc SQLite busy-timeout
// pragma as the final query value. Keeping it in the DSN makes the timeout
// apply to every connection the database/sql pool opens, including separate
// Store handles used by concurrent bootstrap commands.
func sqliteDataSourceWithBusyTimeout(dataSource string) string {
const busyTimeoutPragma = "_pragma=busy_timeout%3d5000"
base, fragment, hasFragment := strings.Cut(dataSource, "#")
separator := "?"
if strings.Contains(base, "?") {
separator = "&"
}
if strings.HasSuffix(base, "?") || strings.HasSuffix(base, "&") {
separator = ""
}
result := base + separator + busyTimeoutPragma
if hasFragment {
result += "#" + fragment
}
return result
}
// bind converts portable question-mark placeholders to the ordinal syntax
// required by PostgreSQL. SQLite accepts question marks unchanged.
func (s *Store) bind(query string) string {
return bindQuery(s.dialect, query)
}
func bindQuery(dialect, query string) string {
if dialect != dialectPostgres {
return query
}
var bound strings.Builder
bound.Grow(len(query) + 8)
ordinal := 1
for _, r := range query {
if r == '?' {
fmt.Fprintf(&bound, "$%d", ordinal)
ordinal++
continue
}
bound.WriteRune(r)
}
return bound.String()
}
// Close releases the underlying database connection. It is safe to call on a
// nil receiver.
func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
// RunInTransaction executes fn inside a single transaction. On success the
// transaction is committed and fn's return value is returned. On any error
// the transaction is rolled back and the error is returned unchanged.
func (s *Store) RunInTransaction(ctx context.Context, fn func(ctx context.Context, tx *sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := fn(ctx, tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// DB exposes the underlying *sql.DB for callers that need direct access
// (e.g. tests inspecting row counts). Production code should prefer the
// typed methods on Store.
func (s *Store) DB() *sql.DB { return s.db }
// IsOpen reports whether the store holds an active connection.
func (s *Store) IsOpen() bool {
if s == nil || s.db == nil {
return false
}
return s.db.Ping() == nil
}