package config import ( "fmt" "net" "os" "time" "forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter" "gopkg.in/yaml.v3" ) // rawConfig represents the structure of the YAML configuration file. type rawConfig struct { Routes []rawRoute `yaml:"routes"` Models []rawModel `yaml:"models"` Adapters []rawAdapter `yaml:"adapters"` Auth rawAuth `yaml:"auth"` } // rawAuth represents auth configuration in YAML. type rawAuth struct { Enabled bool `yaml:"enabled"` Issuer string `yaml:"issuer"` Audience string `yaml:"audience"` JWKSURL string `yaml:"jwksUrl"` RequiredCapability string `yaml:"requiredCapability"` } // rawRoute represents a single route in the YAML configuration. type rawRoute struct { Name string `yaml:"name"` Upstream rawUpstream `yaml:"upstream"` } // rawModel represents a single model entry in the YAML configuration. type rawModel struct { Name string `yaml:"name"` Address string `yaml:"address"` Path string `yaml:"path"` AuthRequired *bool `yaml:"authRequired"` } // rawAdapter represents a service adapter in the YAML configuration. type rawAdapter struct { ServiceName string `yaml:"serviceName"` Upstream struct { URL string `yaml:"url"` TimeoutSeconds int32 `yaml:"timeoutSeconds"` } `yaml:"upstream"` Auth struct { Required bool `yaml:"required"` Capability string `yaml:"capability"` } `yaml:"auth"` Retryable bool `yaml:"retryable"` Resources []struct { Name string `yaml:"name"` Methods []struct { Verb string `yaml:"verb"` UpstreamPath string `yaml:"upstreamPath"` RequestSchema string `yaml:"requestSchema"` ResponseSchema string `yaml:"responseSchema"` } `yaml:"methods"` } `yaml:"resources"` } // rawUpstream represents upstream configuration in YAML. type rawUpstream struct { Address string `yaml:"address"` PathRewrite string `yaml:"pathRewrite"` ConnectTimeout string `yaml:"connectTimeout"` ReadTimeout string `yaml:"readTimeout"` WriteTimeout string `yaml:"writeTimeout"` MaxBodySize int64 `yaml:"maxBodySize"` AuthRequired *bool `yaml:"authRequired"` } // LoadRoutesAndModelsFromFile loads route, model, adapter, and auth configuration from a YAML file. func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, []*serviceadapter.ServiceAdapter, AuthConfig, error) { data, err := os.ReadFile(path) if err != nil { return nil, nil, nil, AuthConfig{}, fmt.Errorf("failed to read config file %q: %w", path, err) } var raw rawConfig if err := yaml.Unmarshal(data, &raw); err != nil { return nil, nil, nil, AuthConfig{}, fmt.Errorf("failed to parse config file %q: %w", path, err) } // Load routes routes := make(map[string]*Route) for _, rawRoute := range raw.Routes { if rawRoute.Name == "" { return nil, nil, nil, AuthConfig{}, fmt.Errorf("route has empty name") } if _, exists := routes[rawRoute.Name]; exists { return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name) } upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream) if err != nil { return nil, nil, nil, AuthConfig{}, err } routes[rawRoute.Name] = &Route{ Name: rawRoute.Name, Upstream: upstream, } } // Load models models := make(map[string]*ModelUpstream) for _, rawModel := range raw.Models { if rawModel.Name == "" { return nil, nil, nil, AuthConfig{}, fmt.Errorf("model has empty name") } if _, exists := models[rawModel.Name]; exists { return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name) } if rawModel.Address == "" { return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name) } if _, _, err := net.SplitHostPort(rawModel.Address); err != nil { return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err) } // Default authRequired to global auth.enabled if not specified per-model authRequired := false if rawModel.AuthRequired != nil { authRequired = *rawModel.AuthRequired } models[rawModel.Name] = &ModelUpstream{ Name: rawModel.Name, Address: rawModel.Address, Path: rawModel.Path, AuthRequired: authRequired, } } // Load adapters adapters := make([]*serviceadapter.ServiceAdapter, 0, len(raw.Adapters)) for _, ra := range raw.Adapters { if ra.ServiceName == "" { return nil, nil, nil, AuthConfig{}, fmt.Errorf("adapter has empty serviceName") } a := &serviceadapter.ServiceAdapter{ Name: ra.ServiceName, ServiceName: ra.ServiceName, CreatedAt: time.Now(), } a.Spec.ServiceName = ra.ServiceName a.Spec.Upstream.URL = ra.Upstream.URL a.Spec.Upstream.TimeoutSeconds = ra.Upstream.TimeoutSeconds a.Spec.Auth.Required = ra.Auth.Required a.Spec.Auth.Capability = ra.Auth.Capability a.Spec.Retryable = ra.Retryable for _, rr := range ra.Resources { res := serviceadapter.Resource{Name: rr.Name} for _, rm := range rr.Methods { res.Methods = append(res.Methods, serviceadapter.Method{ Verb: rm.Verb, UpstreamPath: rm.UpstreamPath, RequestSchema: rm.RequestSchema, ResponseSchema: rm.ResponseSchema, }) } a.Spec.Resources = append(a.Spec.Resources, res) } adapters = append(adapters, a) } // Parse auth config authConfig := AuthConfig{ Enabled: raw.Auth.Enabled, Issuer: raw.Auth.Issuer, Audience: raw.Auth.Audience, JWKSURL: raw.Auth.JWKSURL, RequiredCapability: raw.Auth.RequiredCapability, } return routes, models, adapters, authConfig, nil } // LoadRoutesFromFile loads route configuration from a YAML file. // Deprecated: Use LoadRoutesAndModelsFromFile instead. func LoadRoutesFromFile(path string) (map[string]*Route, error) { routes, _, _, _, err := LoadRoutesAndModelsFromFile(path) return routes, err } // parseUpstream validates and parses upstream configuration from raw YAML. func parseUpstream(routeName string, raw rawUpstream) (Upstream, error) { // Validate address is not empty if raw.Address == "" { return Upstream{}, fmt.Errorf("route \"%s\": field 'address' is required", routeName) } // Validate address format (host:port) if _, _, err := net.SplitHostPort(raw.Address); err != nil { return Upstream{}, fmt.Errorf("route \"%s\": invalid address \"%s\": %w", routeName, raw.Address, err) } // Validate connectTimeout if raw.ConnectTimeout == "" { return Upstream{}, fmt.Errorf("route \"%s\": field 'connectTimeout' is required", routeName) } connectTimeout, err := time.ParseDuration(raw.ConnectTimeout) if err != nil { return Upstream{}, fmt.Errorf("route \"%s\": invalid connectTimeout \"%s\": %w", routeName, raw.ConnectTimeout, err) } // Validate readTimeout if raw.ReadTimeout == "" { return Upstream{}, fmt.Errorf("route \"%s\": field 'readTimeout' is required", routeName) } readTimeout, err := time.ParseDuration(raw.ReadTimeout) if err != nil { return Upstream{}, fmt.Errorf("route \"%s\": invalid readTimeout \"%s\": %w", routeName, raw.ReadTimeout, err) } // Validate writeTimeout if raw.WriteTimeout == "" { return Upstream{}, fmt.Errorf("route \"%s\": field 'writeTimeout' is required", routeName) } writeTimeout, err := time.ParseDuration(raw.WriteTimeout) if err != nil { return Upstream{}, fmt.Errorf("route \"%s\": invalid writeTimeout \"%s\": %w", routeName, raw.WriteTimeout, err) } // Validate maxBodySize is not zero (it must be explicitly set) if raw.MaxBodySize == 0 { return Upstream{}, fmt.Errorf("route \"%s\": field 'maxBodySize' is required and must be > 0", routeName) } // Validate authRequired is not missing if raw.AuthRequired == nil { return Upstream{}, fmt.Errorf("route \"%s\": field 'authRequired' is required", routeName) } return Upstream{ Address: raw.Address, PathRewrite: raw.PathRewrite, ConnectTimeout: connectTimeout, ReadTimeout: readTimeout, WriteTimeout: writeTimeout, MaxBodySize: raw.MaxBodySize, AuthRequired: *raw.AuthRequired, }, nil }