463 lines
16 KiB
Go
463 lines
16 KiB
Go
package configkit
|
|
|
|
import (
|
|
"os"
|
|
"reflect"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
)
|
|
|
|
// Definition contains reusable application rules for a configuration type.
|
|
// Secret is a pure predicate that can add sensitivity to semantic paths.
|
|
// Definitions should be treated as read-only after being shared.
|
|
type Definition[T any] struct {
|
|
Defaults func() T
|
|
Resolve func(*T, *Resolution) error
|
|
Validate func(*T, *Validation)
|
|
Version *VersionRule
|
|
Secret func(Path) bool
|
|
}
|
|
|
|
// Options contains the application definition and per-load input selections.
|
|
// Imports enables explicit path and glob imports; Profile selects one fixed profile
|
|
// overlay; Overrides contains only values explicitly supplied by an application CLI.
|
|
type Options[T any] struct {
|
|
Definition Definition[T]
|
|
Imports bool
|
|
Profile string
|
|
Filesystem *FilesystemContext
|
|
Overrides []Override
|
|
}
|
|
|
|
// FilesystemContext supplies filesystem roots for imports and profiles.
|
|
type FilesystemContext struct {
|
|
BaseDir string
|
|
Root string
|
|
}
|
|
|
|
// Override describes one explicitly supplied application override. Value uses
|
|
// parser-neutral built-in scalars, nil, []any, or map[string]any; Flag is a safe
|
|
// display name and must not contain a raw argument or value.
|
|
type Override struct {
|
|
Path Path
|
|
Value any
|
|
Flag string
|
|
}
|
|
|
|
// Resolution exposes surviving-input presence and records audited application
|
|
// resolution changes. A resolver must not retain or concurrently use the context.
|
|
type Resolution struct {
|
|
state *resolutionState
|
|
}
|
|
|
|
// VersionRule declares the input path and application checker for a schema version.
|
|
type VersionRule struct {
|
|
Path Path
|
|
Check func(Version, *Validation) error
|
|
}
|
|
|
|
// Version preserves the absent, null, and string states of a schema-version input.
|
|
type Version struct {
|
|
Present bool
|
|
Null bool
|
|
Text string
|
|
}
|
|
|
|
// Result is a successful independently owned configuration load.
|
|
type Result[T any] struct {
|
|
Config T
|
|
|
|
metadata Metadata
|
|
sources []SourceInfo
|
|
diagnostics []Diagnostic
|
|
baseline *document.Node
|
|
input *document.Node
|
|
inputOrigins map[string]Origin
|
|
final *document.Node
|
|
resolution []resolutionSnapshot
|
|
applications []historyApplication
|
|
secrets map[string]bool
|
|
secretReferences map[string]string
|
|
}
|
|
|
|
// Metadata returns detached effective presence, origin, and history records.
|
|
func (r *Result[T]) Metadata() Metadata {
|
|
if r == nil {
|
|
return Metadata{}
|
|
}
|
|
return cloneMetadata(r.metadata)
|
|
}
|
|
|
|
// Sources returns a detached list of sources used by the load.
|
|
func (r *Result[T]) Sources() []SourceInfo {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return cloneSourceInfos(r.sources)
|
|
}
|
|
|
|
// Diagnostics returns detached nonfatal diagnostics from the load.
|
|
func (r *Result[T]) Diagnostics() []Diagnostic {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return cloneDiagnostics(r.diagnostics)
|
|
}
|
|
|
|
// Load parses and strictly decodes zero or one selected source, explicitly enabled
|
|
// imports, and one selected profile. It accepts at most one Options value and
|
|
// returns no result when any error is reported.
|
|
func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
|
|
return loadWithSecretLookup(source, os.LookupEnv, supplied...)
|
|
}
|
|
|
|
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
|
|
accumulator := diagnosticAccumulator{}
|
|
options, diagnostics := selectOptions(source, supplied)
|
|
if len(diagnostics) != 0 {
|
|
accumulator.add(diagnostics...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
|
|
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
|
|
if len(diagnostics) != 0 {
|
|
if parsed.source != nil {
|
|
accumulator.sources = append(accumulator.sources, *parsed.source)
|
|
}
|
|
accumulator.add(diagnostics...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
|
|
inputDocument := parsed.document
|
|
var composition *composedDocument
|
|
if options.Imports {
|
|
composition, accumulator.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits())
|
|
if len(diagnostics) != 0 {
|
|
accumulator.add(diagnostics...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
inputDocument = composition.root
|
|
} else {
|
|
if parsed.source != nil {
|
|
accumulator.sources = append(accumulator.sources, *parsed.source)
|
|
}
|
|
if options.Filesystem != nil && !activeProfile(options.Profile) {
|
|
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
|
accumulator.add(*diagnostic)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
}
|
|
}
|
|
if !options.Imports && inputDocument != nil {
|
|
directiveDiagnostics := disabledDirectiveDiagnostics(inputDocument, Path{})
|
|
if len(directiveDiagnostics) != 0 {
|
|
accumulator.add(directiveDiagnostics...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
}
|
|
|
|
schema, schemaIssue := buildSchema(reflect.TypeFor[T]())
|
|
if schemaIssue != nil {
|
|
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
|
return nil, accumulator.loadError()
|
|
}
|
|
|
|
versionDiagnostics := checkVersion(options.Definition.Version, inputDocument)
|
|
accumulator.add(versionDiagnostics...)
|
|
if hasErrors(versionDiagnostics) {
|
|
return nil, accumulator.loadError()
|
|
}
|
|
if activeProfile(options.Profile) {
|
|
if composition == nil {
|
|
composition, diagnostics = composeDocuments(inputDocument)
|
|
if len(diagnostics) != 0 {
|
|
accumulator.add(diagnostics...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
composition.inputBytes = parsed.bytes
|
|
composition.inputNodes = countInputNodes(parsed.document)
|
|
}
|
|
composition, accumulator.sources, diagnostics = loadProfileDocument(
|
|
source, options.Filesystem, parsed, composition, accumulator.sources,
|
|
options.Profile, options.Definition.Version, defaultImportLimits(),
|
|
)
|
|
if len(diagnostics) != 0 {
|
|
accumulator.add(diagnostics...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
inputDocument = composition.root
|
|
}
|
|
if len(options.Overrides) != 0 {
|
|
if composition == nil {
|
|
if inputDocument == nil {
|
|
composition = &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}
|
|
} else {
|
|
composition, diagnostics = composeDocuments(inputDocument)
|
|
if len(diagnostics) != 0 {
|
|
accumulator.add(diagnostics...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
composition.inputBytes = parsed.bytes
|
|
composition.inputNodes = countInputNodes(inputDocument)
|
|
}
|
|
}
|
|
composition, accumulator.sources, diagnostics = applyOverrides(
|
|
composition, accumulator.sources, options.Overrides, options.Definition.Version, defaultImportLimits(),
|
|
)
|
|
if len(diagnostics) != 0 {
|
|
accumulator.add(diagnostics...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
inputDocument = composition.root
|
|
}
|
|
|
|
defaults := reflect.Zero(reflect.TypeFor[T]())
|
|
if options.Definition.Defaults != nil {
|
|
defaults = reflect.ValueOf(options.Definition.Defaults())
|
|
}
|
|
decoded, decodeIssues := decodeConfiguration(schema, defaults, inputDocument)
|
|
if len(decodeIssues) != 0 {
|
|
for _, issue := range decodeIssues {
|
|
accumulator.add(diagnosticFromDecodeIssue(issue))
|
|
}
|
|
return nil, accumulator.loadError()
|
|
}
|
|
if composition != nil {
|
|
applyCompositionOrigins(&decoded, composition.origins)
|
|
}
|
|
|
|
configuration := decoded.value.Interface().(T)
|
|
metadata := metadataFromDecoded(decoded)
|
|
if composition != nil {
|
|
applyOverlayMetadata(&metadata, composition.overlays)
|
|
}
|
|
final := decoded.final
|
|
var resolutionSnapshots []resolutionSnapshot
|
|
if options.Definition.Resolve != nil {
|
|
resolved := runResolution(&configuration, options.Definition.Resolve, schema, decoded, inputDocument)
|
|
accumulator.add(resolved.diagnostics...)
|
|
if hasErrors(resolved.diagnostics) {
|
|
return nil, accumulator.loadError()
|
|
}
|
|
final = resolved.final
|
|
resolutionSnapshots = resolved.snapshots
|
|
applyResolutionMetadata(&metadata, resolved)
|
|
}
|
|
applications := []historyApplication(nil)
|
|
if composition != nil {
|
|
applications = cloneHistoryApplications(composition.applications)
|
|
} else if inputDocument != nil {
|
|
applications = []historyApplication{{layer: LayerBase, value: inputDocument}}
|
|
}
|
|
if !historyNodesWithinLimit(decoded.baseline, decoded.final, final, applications, resolutionSnapshots) {
|
|
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
|
return nil, accumulator.loadError()
|
|
}
|
|
// Resolver assignments may borrow application-owned mutable values. Detach
|
|
// before secret resolution mutates reference fields or validation sees them.
|
|
detached, ownershipIssue := cloneConfiguration(schema, reflect.ValueOf(configuration), defaultProjectionLimits())
|
|
if ownershipIssue != nil {
|
|
accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue)))
|
|
return nil, accumulator.loadError()
|
|
}
|
|
configuration = detached.Interface().(T)
|
|
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, inputDocument, lookup)
|
|
accumulator.add(secretResolution.diagnostics...)
|
|
if hasErrors(secretResolution.diagnostics) {
|
|
return nil, accumulator.loadError()
|
|
}
|
|
if options.Definition.Validate != nil {
|
|
validationDiagnostics := runValidation(&configuration, options.Definition.Validate, schema, final, inputDocument, secretResolution.lookups)
|
|
accumulator.add(validationDiagnostics...)
|
|
if hasErrors(validationDiagnostics) {
|
|
return nil, accumulator.loadError()
|
|
}
|
|
}
|
|
// A read-only validator can replace a value with an equal borrowed object.
|
|
// Projected mutation checks cannot detect that alias, so detach at publication.
|
|
published, ownershipIssue := cloneConfigurationState(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), true)
|
|
if ownershipIssue != nil {
|
|
accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue)))
|
|
return nil, accumulator.loadError()
|
|
}
|
|
configuration = published.Interface().(T)
|
|
secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
|
|
return &Result[T]{
|
|
Config: configuration,
|
|
metadata: metadata,
|
|
sources: cloneSourceInfos(accumulator.sources),
|
|
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
|
baseline: decoded.baseline,
|
|
input: decoded.final,
|
|
inputOrigins: cloneOriginIndex(decoded.origins),
|
|
final: final,
|
|
resolution: resolutionSnapshots,
|
|
applications: applications,
|
|
secrets: secrets,
|
|
secretReferences: cloneSecretReferences(secretResolution.references),
|
|
}, nil
|
|
}
|
|
|
|
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) {
|
|
if len(supplied) > 1 {
|
|
return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}
|
|
}
|
|
var options Options[T]
|
|
if len(supplied) == 1 {
|
|
options = supplied[0]
|
|
}
|
|
var diagnostics []Diagnostic
|
|
definition := options.Definition
|
|
if !validProfileName(options.Profile) {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen"))
|
|
}
|
|
if options.Filesystem != nil {
|
|
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
|
}
|
|
if source.kind == sourceNone && options.Imports {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
|
|
}
|
|
if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile"))
|
|
}
|
|
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context"))
|
|
}
|
|
if definition.Version != nil {
|
|
if !definition.Version.Path.valid() {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
|
|
}
|
|
if definition.Version.Check == nil {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
|
|
}
|
|
}
|
|
return options, canonicalDiagnostics(diagnostics)
|
|
}
|
|
|
|
func optionDiagnostic(code Code, message string) Diagnostic {
|
|
return Diagnostic{Code: code, Severity: SeverityError, Message: message}
|
|
}
|
|
|
|
func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
|
|
var diagnostics []Diagnostic
|
|
if node.Kind == document.Mapping {
|
|
for _, entry := range node.Mapping {
|
|
childPath := path.Key(entry.Key)
|
|
if entry.Key == "_configkit" {
|
|
location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column}
|
|
diagnostics = append(diagnostics, Diagnostic{
|
|
Code: CodeImportDisabled, Severity: SeverityError, Path: childPath,
|
|
Message: "configuration directives require imports to be enabled", Primary: &location,
|
|
})
|
|
}
|
|
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...)
|
|
}
|
|
} else if node.Kind == document.Sequence {
|
|
for index, child := range node.Sequence {
|
|
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...)
|
|
}
|
|
}
|
|
return diagnostics
|
|
}
|
|
|
|
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
|
if rule == nil {
|
|
return nil
|
|
}
|
|
version := Version{}
|
|
node, present := documentAtPath(root, rule.Path)
|
|
if present {
|
|
version.Present = true
|
|
switch node.Kind {
|
|
case document.Null:
|
|
version.Null = true
|
|
case document.String:
|
|
version.Text = node.Text
|
|
default:
|
|
location, _ := nearestDocumentLocation(root, rule.Path)
|
|
primary := documentLocationPointer(location)
|
|
return []Diagnostic{{
|
|
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
|
Message: "schema version must be a string or null", Primary: primary,
|
|
}}
|
|
}
|
|
}
|
|
state := &validationState{
|
|
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
|
|
invalidPath: CodeVersionError, document: root,
|
|
}
|
|
validation := &Validation{state: state}
|
|
err := rule.Check(version, validation)
|
|
state.active = false
|
|
if err != nil && !hasErrors(state.diagnostics) {
|
|
location, _ := nearestDocumentLocation(root, rule.Path)
|
|
state.diagnostics = append(state.diagnostics, Diagnostic{
|
|
Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(),
|
|
Message: "schema-version check failed", Primary: documentLocationPointer(location),
|
|
})
|
|
}
|
|
return canonicalDiagnostics(state.diagnostics)
|
|
}
|
|
|
|
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
|
|
if root == nil || !path.valid() {
|
|
return nil, false
|
|
}
|
|
current := root
|
|
for _, segment := range path.segments {
|
|
switch current.Kind {
|
|
case document.Mapping:
|
|
if segment.kind == pathSegmentIndex {
|
|
return nil, false
|
|
}
|
|
found := false
|
|
for _, entry := range current.Mapping {
|
|
if entry.Key == segment.key {
|
|
current = entry.Value
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, false
|
|
}
|
|
case document.Sequence:
|
|
index, ok := schemaIndex(segment)
|
|
if !ok || index >= len(current.Sequence) {
|
|
return nil, false
|
|
}
|
|
current = current.Sequence[index]
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
return current, true
|
|
}
|
|
|
|
func documentLocationPointer(location document.Location) *Location {
|
|
if !locationKnown(location) {
|
|
return nil
|
|
}
|
|
return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
|
|
}
|
|
|
|
func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic {
|
|
return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message}
|
|
}
|
|
|
|
func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic {
|
|
return Diagnostic{
|
|
Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message,
|
|
Primary: documentLocationPointer(issue.location),
|
|
}
|
|
}
|
|
|
|
func hasErrors(diagnostics []Diagnostic) bool {
|
|
for _, diagnostic := range diagnostics {
|
|
if diagnostic.Severity == SeverityError {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|