Files
ConfigKit/internal/engine/load.go

360 lines
14 KiB
Go

package engine
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
sequenceIdentityRegistrations []sequenceIdentityRegistration
}
// Options contains the application definition and per-load input selections.
// Imports enables traversal of declarations in a selected main document; an
// absent source has no main declarations to traverse. Overlay selects one fixed
// overlay and requires filesystem context for NoSource. Overrides contain only
// values explicitly supplied by an application CLI.
type Options[T any] struct {
Definition Definition[T]
Imports bool
Overlay string
Filesystem *FilesystemContext
Overrides []Override
SourceVersionMode sourceVersionMode
ImportDefaults ImportDefaults
destination Path
placementMode placementMode
}
// FilesystemContext supplies filesystem roots for imports and overlays. For an
// absent host input, Root is required and BaseDir may be omitted to use Root as
// the import base. Native contexts still require both BaseDir and Root.
type FilesystemContext struct {
BaseDir string
Root string
FS *Filesystem
}
// 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 entry-state input provenance, surviving-input presence, and
// 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.
// At that path, string and decimal-integer input is supplied as Version.Text.
type VersionRule struct {
Path Path
Check func(Version, *Validation) error
}
// Version preserves the absent, null, and textual states of a schema-version input.
// Text is the exact spelling of accepted string or decimal-integer input.
type Version struct {
Present bool
Null bool
Text string
}
// Loaded is a short-lived generic carrier for a successful configuration load.
// Inspection is frozen independently of Config and does not retain application values.
type Loaded[T any] struct {
Config T
Inspection *Inspection
}
// Inspection owns frozen load snapshots and detached query state.
type Inspection struct {
metadata Metadata
sources []SourceInfo
contexts map[string]ResourceContext
diagnostics []Diagnostic
baseline *document.Node
input *document.Node
inputOrigins map[string]Origin
preCLI *document.Node
preCLIOrigins map[string]Origin
postDecodeApps []historyApplication
final *document.Node
immutableLookup *documentLookup
resolution []resolutionSnapshot
resolutionReplay []resolutionSnapshot
applications []historyApplication
inheritance []inheritanceOperation
secrets map[string]bool
secretReferences map[string]string
}
// Metadata returns detached effective presence, origin, and history records.
func (r *Inspection) Metadata() Metadata {
if r == nil {
return Metadata{}
}
return cloneMetadata(r.metadata)
}
// Sources returns a detached list of sources used by the load.
func (r *Inspection) Sources() []SourceInfo {
if r == nil {
return nil
}
return cloneSourceInfos(r.sources)
}
// SourceContext returns a passive filesystem view for an acquired source.
// Unknown IDs and sources without a filesystem context return false.
func (r *Inspection) SourceContext(id string) (ResourceContext, bool) {
if r == nil {
return ResourceContext{}, false
}
context, ok := r.contexts[id]
return context, ok
}
// Diagnostics returns detached nonfatal diagnostics from the load.
func (r *Inspection) 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 overlay. It accepts at most one Options value and
// returns no result when any error is reported.
func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (*Loaded[T], error) {
return loadWithSecretLookup(source, secrets, os.LookupEnv, supplied...)
}
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
return loadWithSecretLookupLimit(source, secrets, lookup, historyNodeLimit, supplied...)
}
func loadWithSecretLookupLimit[T any](source Source, secrets SecretBinding, lookup secretLookup, historyLimit int, supplied ...Options[T]) (*Loaded[T], error) {
if len(supplied) > 1 {
return nil, (&diagnosticAccumulator{diagnostics: []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}}).loadError()
}
var options Options[T]
if len(supplied) == 1 {
options = supplied[0]
}
input, diagnostics := normalizeInput(loadInput{
source: source, imports: options.Imports, filesystem: options.Filesystem,
destination: options.destination.clone(), placementMode: options.placementMode,
}, options.Overlay)
defaults, policy, settingsDiagnostics := loadSettings(options.ImportDefaults, options.Definition, options.SourceVersionMode)
diagnostics = append(diagnostics, settingsDiagnostics...)
diagnostics = append(diagnostics, overlayOptionDiagnostics(options.Overlay)...)
if len(diagnostics) != 0 {
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
}
definition := options.Definition
definition.sequenceIdentityRegistrations = nil
request := loadRequest[T]{
main: input,
definition: definition, importDefaults: defaults, sequencePolicy: policy, overlay: options.Overlay,
overrides: options.Overrides, ordinary: true,
sourceVersionMode: options.SourceVersionMode,
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, input.destination, options.SourceVersionMode, input.placementMode),
}
return executeLoadRequest(request, secrets, lookup, historyLimit)
}
func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup, historyLimit int) (*Loaded[T], error) {
accumulator := diagnosticAccumulator{}
accounting := &importAccounting{}
policy := acquisitionPolicy{
importDefaults: snapshotImportDefaults(request.importDefaults),
sequencePolicy: request.sequencePolicy,
importLimits: defaultImportLimits(),
}
var schema *schemaDescriptor
if request.sourceVersionMode != sourceVersionModeDefault {
var schemaIssue *schemaIssue
schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets)
if schemaIssue != nil {
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
return nil, accumulator.loadError()
}
}
versionState := &sourceVersionState{
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
}
state := &acquisitionState{accounting: accounting, versionState: versionState}
accumulator.versionState = versionState
directiveContext := rootDirectiveContextLayered
ordinaryOverlay := ""
if request.ordinary {
directiveContext = rootDirectiveContextSource
ordinaryOverlay = request.overlay
}
input, diagnostics := acquireInput(inputAcquisitionRequest{
input: request.main, role: sourceVersionMain, directiveContext: directiveContext,
protectionRule: request.protectionVersion(),
ordinaryOverlay: ordinaryOverlay,
}, policy, state, &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)})
request.main.source = input.source
accumulator.sources = input.sources
accumulator.add(versionState.diagnostics...)
reportedVersionDiagnostics := len(versionState.diagnostics)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
if schema == nil {
var schemaIssue *schemaIssue
schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets)
if schemaIssue != nil {
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
return nil, accumulator.loadError()
}
}
if request.ordinary && (input.document != nil || input.composition != nil) {
input.composition, diagnostics = compositionForInputWithPolicy(input, policy.sequencePolicy)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
input.document = input.composition.root
}
if request.sourceVersionMode == sourceVersionModeDefault && request.ordinary {
versionDiagnostics := checkVersion(request.definition.Version, input.document)
accumulator.add(versionDiagnostics...)
if hasErrors(versionDiagnostics) {
return nil, accumulator.loadError()
}
}
if request.ordinary {
versionState.locateMainVersion(input.document)
input, diagnostics = applyInputLayers(request.main.source, request.main.filesystem, request.overlay, versionState.effectiveRule, policy.sequencePolicy, input)
} else {
input, diagnostics = assembleLayeredInput(request, input, policy, state)
}
accumulator.add(versionState.diagnostics[reportedVersionDiagnostics:]...)
accumulator.sources = input.sources
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
request.effectiveVersion = versionState.effectiveRule
if request.ordinary && input.parsed.source != nil {
input, diagnostics = completeOrdinaryResourceContexts(input, request.main)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
}
var expanded bool
var effectiveVersion *VersionRule
input, effectiveVersion, expanded, diagnostics = expandStructuralInheritance(input, request.protectionVersion(), policy.sequencePolicy)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
if expanded {
request.effectiveVersion = effectiveVersion
versionState.effectiveRule = effectiveVersion
}
base := overridePreparationInput{root: input.document}
if input.composition != nil {
base.inputBytes = input.composition.inputBytes
base.inputNodes = input.composition.inputNodes
}
preparedOverrides, diagnostics := preparePostStructuralOverrides(base, input.sources, request.overrides, request.protectionVersion(), defaultImportLimits())
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
input.sources = preparedOverrides.sources
accumulator.sources = input.sources
decodeDefinition := request.definition
decodeDefinition.Version = request.protectionVersion()
decoded, diagnostics := decodeLoadInput(schema, decodeDefinition, input)
accumulator.add(diagnostics...)
if len(diagnostics) != 0 {
return nil, accumulator.loadError()
}
decodeContext := decodeContext{}
if decodeDefinition.Version != nil {
decodeContext.versionPath = &decodeDefinition.Version.Path
}
assigned, issues := applyDecodedOverrides(schema, decoded, preparedOverrides, decodeContext)
if len(issues) != 0 {
for _, issue := range issues {
accumulator.add(diagnosticFromDecodeIssue(issue))
}
return nil, accumulator.loadError()
}
postDecode := preparePostDecodeOverrideContext(input, decoded, assigned)
resolved, diagnostics := resolveLoadInput(schema, request.definition, input, postDecode)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
counter := newRetainedNodeCounter(historyLimit)
inspection, admitted := prepareInspectionHistory(resolved, counter)
if !admitted {
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
return nil, accumulator.loadError()
}
historyApplications := make([]historyApplication, 0, len(inspection.applications)+len(inspection.postDecodeApps))
historyApplications = append(historyApplications, inspection.applications...)
historyApplications = append(historyApplications, inspection.postDecodeApps...)
if !addInspectionHistory(counter, inspection) {
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
return nil, accumulator.loadError()
}
// Detach resolver-borrowed values before reference mutation or validation.
configuration, ownershipIssue := detachLoadConfiguration(schema, resolved.configuration, cloneUnresolvedSecrets)
if ownershipIssue != nil {
accumulator.add(*ownershipIssue)
return nil, accumulator.loadError()
}
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, resolved.referenceInput, lookup)
accumulator.add(secretResolution.diagnostics...)
if hasErrors(secretResolution.diagnostics) {
return nil, accumulator.loadError()
}
identityDiagnostics := validateFinalSequenceIdentities(resolved.referenceInput, policy.sequencePolicy)
accumulator.add(identityDiagnostics...)
if hasErrors(identityDiagnostics) {
return nil, accumulator.loadError()
}
if request.definition.Validate != nil {
diagnostics = runValidation(&configuration, request.definition.Validate, schema, resolved.final, resolved.referenceInput, secretResolution.lookups)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
}
resolved.configuration = configuration
// Publication detaches equal-valued validator replacements, preserving the
// validated secret state, then finalizes metadata and sensitivity.
return publishLoadResult(schema, request.definition, resolved, inspection, historyApplications, secretResolution.references, &accumulator)
}