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: 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) }