361 lines
14 KiB
Go
361 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
|
|
sequencePolicy sequencePolicy
|
|
deferAssembly bool
|
|
directiveContext rootDirectiveContext
|
|
importDefaultsValidated bool
|
|
}
|
|
|
|
// 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
|
|
structuralInput *document.Node
|
|
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) {
|
|
options, diagnostics := selectOptions(source, supplied)
|
|
policy, policyDiagnostics := compileSequenceIdentityPolicy(options.Definition.sequenceIdentityRegistrations)
|
|
diagnostics = append(diagnostics, policyDiagnostics...)
|
|
if len(diagnostics) != 0 {
|
|
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
|
|
}
|
|
definition := options.Definition
|
|
definition.sequenceIdentityRegistrations = nil
|
|
request := loadRequest[T]{
|
|
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
|
|
definition: definition, importDefaults: snapshotImportDefaults(options.importDefaults), sequencePolicy: policy, overlay: options.Overlay,
|
|
overrides: options.Overrides, ordinary: true,
|
|
sourceVersionMode: options.SourceVersionMode,
|
|
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, options.destination, options.SourceVersionMode, options.placementMode),
|
|
}
|
|
return executeLoadRequest(request, secrets, lookup)
|
|
}
|
|
|
|
func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
|
accumulator := diagnosticAccumulator{}
|
|
accounting := &importAccounting{}
|
|
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), placementMode: request.main.placementMode}
|
|
mainOptions.importDefaults = snapshotImportDefaults(request.importDefaults)
|
|
mainOptions.sequencePolicy = request.sequencePolicy
|
|
mainOptions.deferAssembly = true
|
|
mainOptions.SourceVersionMode = request.sourceVersionMode
|
|
mainOptions.Definition.Version = request.protectionVersion()
|
|
if request.ordinary {
|
|
mainOptions.Overlay = request.overlay
|
|
mainOptions.directiveContext = rootDirectiveContextSource
|
|
} else {
|
|
mainOptions.directiveContext = rootDirectiveContextLayered
|
|
}
|
|
|
|
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,
|
|
importDefaults: snapshotImportDefaults(request.importDefaults),
|
|
}
|
|
accumulator.versionState = versionState
|
|
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
|
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, request.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)
|
|
mainOptions.Definition.Version = versionState.effectiveRule
|
|
input, diagnostics = applyInputLayers(request.main.source, mainOptions, input)
|
|
} else {
|
|
input, diagnostics = assembleLayeredInput(request, input, accounting)
|
|
}
|
|
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 {
|
|
baseSources := make([]SourceInfo, 0, len(input.sources))
|
|
for _, source := range input.sources {
|
|
if source.Layer == LayerBase {
|
|
baseSources = append(baseSources, source)
|
|
}
|
|
}
|
|
if len(baseSources) != 0 && input.contexts[baseSources[0].ID].FS == nil {
|
|
contexts, diagnostic := contextsForAcquiredSources(request.main.source, request.main.filesystem, input.parsed.source, baseSources)
|
|
if diagnostic != nil {
|
|
accumulator.add(*diagnostic)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
mergeResourceContexts(&input.contexts, contexts)
|
|
}
|
|
}
|
|
if input.document != nil && containsInheritanceDirective(input.document) {
|
|
composition, problems := compositionForInput(input)
|
|
if len(problems) != 0 {
|
|
accumulator.add(problems...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
expanded, problems := expandInheritanceWithCollectionRules(
|
|
composition.root, composition.origins, composition.collectionPolicies, request.sequencePolicy, defaultProjectionLimits(),
|
|
)
|
|
if len(problems) != 0 {
|
|
accumulator.add(problems...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
effectiveVersion, problem := projectInheritanceVersion(request.protectionVersion(), expanded)
|
|
if problem != nil {
|
|
accumulator.add(*problem)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
request.effectiveVersion = effectiveVersion
|
|
versionState.effectiveRule = effectiveVersion
|
|
input.composition = composition
|
|
input.inheritance = expanded
|
|
input.document = expanded.root
|
|
}
|
|
|
|
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()
|
|
}
|
|
historyApplications := append(cloneHistoryApplications(resolved.applications), cloneHistoryApplications(resolved.postDecodeApps)...)
|
|
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, historyApplications, resolved.snapshots, input.inheritance) {
|
|
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, request.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, secretResolution.references, &accumulator)
|
|
}
|