Files
mcr/internal/config/config.go
Kyle Isom 78f3eae651 Migrate db, auth, and config to mcdsl
- db.Open: delegate to mcdsl/db.Open
- db.Migrate: rewrite migrations as mcdsl/db.Migration SQL strings,
  delegate to mcdsl/db.Migrate; keep SchemaVersion via mcdsl
- auth: thin shim wrapping mcdsl/auth.Authenticator, keeps Claims
  type (with Subject, AccountType, Roles) for policy engine compat;
  delete cache.go (handled by mcdsl/auth); add ErrForbidden
- config: embed mcdsl/config.Base for standard sections (Server with
  Duration fields, Database, MCIAS, Log); keep StorageConfig and
  WebConfig as MCR-specific; use mcdsl/config.Load[T] + Validator
- WriteTimeout now defaults to 30s (mcdsl default, was 0)
- All existing tests pass (auth tests rewritten for new shim API,
  cache expiry test removed — caching tested in mcdsl)
- Net -464 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:10:46 -07:00

104 lines
2.8 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
mcdslconfig "git.wntrmute.dev/kyle/mcdsl/config"
)
// Config is the top-level MCR configuration. It embeds config.Base for
// the standard Metacircular sections and adds MCR-specific sections.
type Config struct {
mcdslconfig.Base
Storage StorageConfig `toml:"storage"`
Web WebConfig `toml:"web"`
}
// StorageConfig holds blob/layer storage settings.
type StorageConfig struct {
LayersPath string `toml:"layers_path"`
UploadsPath string `toml:"uploads_path"`
}
// WebConfig holds the web UI server settings.
type WebConfig struct {
ListenAddr string `toml:"listen_addr"`
GRPCAddr string `toml:"grpc_addr"`
CACert string `toml:"ca_cert"`
}
// Load reads a TOML config file, applies environment variable overrides
// (MCR_ prefix), sets defaults, and validates required fields.
func Load(path string) (*Config, error) {
cfg, err := mcdslconfig.Load[Config](path, "MCR")
if err != nil {
return nil, err
}
return cfg, nil
}
// Validate implements the mcdsl config.Validator interface. It checks
// MCR-specific required fields and constraints beyond what config.Base
// validates.
func (c *Config) Validate() error {
if c.Database.Path == "" {
return fmt.Errorf("database.path is required")
}
if c.Storage.LayersPath == "" {
return fmt.Errorf("storage.layers_path is required")
}
if c.MCIAS.ServerURL == "" {
return fmt.Errorf("mcias.server_url is required")
}
// Default uploads path to sibling of layers path.
if c.Storage.UploadsPath == "" && c.Storage.LayersPath != "" {
c.Storage.UploadsPath = filepath.Join(filepath.Dir(c.Storage.LayersPath), "uploads")
}
return validateSameFilesystem(c.Storage.LayersPath, c.Storage.UploadsPath)
}
// validateSameFilesystem checks that two paths reside on the same filesystem
// by comparing device IDs. If either path does not exist yet, it checks the
// nearest existing parent directory.
func validateSameFilesystem(layersPath, uploadsPath string) error {
layersDev, err := deviceID(layersPath)
if err != nil {
return fmt.Errorf("stat layers_path: %w", err)
}
uploadsDev, err := deviceID(uploadsPath)
if err != nil {
return fmt.Errorf("stat uploads_path: %w", err)
}
if layersDev != uploadsDev {
return fmt.Errorf("storage.layers_path and storage.uploads_path must be on the same filesystem")
}
return nil
}
// deviceID returns the device ID for the given path. If the path does not
// exist, it walks up to the nearest existing parent.
func deviceID(path string) (uint64, error) {
p := filepath.Clean(path)
for {
info, err := os.Stat(p)
if err == nil {
return extractDeviceID(info)
}
if !os.IsNotExist(err) {
return 0, err
}
parent := filepath.Dir(p)
if parent == p {
return 0, fmt.Errorf("no existing parent for %s", path)
}
p = parent
}
}