310 lines
12 KiB
Go
310 lines
12 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
|
|
|
|
sequenceRegistrations []sequenceRegistration
|
|
}
|
|
|
|
// Options contains the application definition and per-load input selections.
|
|
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
|
// Overrides contains 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
|
|
importDefaultsValidated bool
|
|
}
|
|
|
|
// FilesystemContext supplies filesystem roots for imports and overlays.
|
|
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
|
|
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 := compileSequencePolicy(options.Definition.sequenceRegistrations)
|
|
diagnostics = append(diagnostics, policyDiagnostics...)
|
|
if len(diagnostics) != 0 {
|
|
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
|
|
}
|
|
definition := options.Definition
|
|
definition.sequenceRegistrations = 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.SourceVersionMode = request.sourceVersionMode
|
|
mainOptions.Definition.Version = request.protectionVersion()
|
|
mainOptions.Overrides = request.overrides
|
|
if request.ordinary {
|
|
mainOptions.Overlay = request.overlay
|
|
}
|
|
|
|
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.sourceVersionMode == sourceVersionModeDefault {
|
|
versionDiagnostics := checkVersion(request.definition.Version, input.document)
|
|
accumulator.add(versionDiagnostics...)
|
|
if hasErrors(versionDiagnostics) {
|
|
return nil, accumulator.loadError()
|
|
}
|
|
}
|
|
if request.ordinary && input.document != 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.ordinary {
|
|
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 len(diagnostics) != 0 {
|
|
return nil, accumulator.loadError()
|
|
}
|
|
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 := expandInheritanceWithOrigins(composition.root, composition.origins, defaultProjectionLimits())
|
|
if len(problems) != 0 {
|
|
accumulator.add(problems...)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
if problem := inheritanceVersionDiagnostic(request.protectionVersion(), expanded); problem != nil {
|
|
accumulator.add(*problem)
|
|
return nil, accumulator.loadError()
|
|
}
|
|
input.composition = composition
|
|
input.inheritance = expanded
|
|
input.document = expanded.root
|
|
}
|
|
|
|
decodeDefinition := request.definition
|
|
decodeDefinition.Version = request.protectionVersion()
|
|
decoded, diagnostics := decodeLoadInput(schema, decodeDefinition, input)
|
|
accumulator.add(diagnostics...)
|
|
if len(diagnostics) != 0 {
|
|
return nil, accumulator.loadError()
|
|
}
|
|
resolved, diagnostics := resolveLoadInput(schema, request.definition, input, decoded)
|
|
accumulator.add(diagnostics...)
|
|
if hasErrors(diagnostics) {
|
|
return nil, accumulator.loadError()
|
|
}
|
|
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, 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, input.document, lookup)
|
|
accumulator.add(secretResolution.diagnostics...)
|
|
if hasErrors(secretResolution.diagnostics) {
|
|
return nil, accumulator.loadError()
|
|
}
|
|
if request.definition.Validate != nil {
|
|
diagnostics = runValidation(&configuration, request.definition.Validate, schema, resolved.final, input.document, 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)
|
|
}
|