Extract explicit configuration loading phases
This commit is contained in:
284
load.go
284
load.go
@@ -114,293 +114,69 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
|
||||
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
|
||||
accumulator := diagnosticAccumulator{}
|
||||
options, diagnostics := selectOptions(source, supplied)
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
|
||||
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
|
||||
input, diagnostics := assembleBaseInput(source, options)
|
||||
accumulator.sources = input.sources
|
||||
accumulator.add(diagnostics...)
|
||||
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)
|
||||
versionDiagnostics := checkVersion(options.Definition.Version, input.document)
|
||||
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))
|
||||
}
|
||||
input, diagnostics = applyInputLayers(source, options, input)
|
||||
accumulator.sources = input.sources
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
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)
|
||||
decoded, diagnostics := decodeLoadInput(schema, options.Definition, input)
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
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)
|
||||
resolved, diagnostics := resolveLoadInput(schema, options.Definition, input, decoded)
|
||||
accumulator.add(diagnostics...)
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
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) {
|
||||
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) {
|
||||
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())
|
||||
|
||||
// Detach resolver-borrowed values before reference mutation or validation.
|
||||
configuration, ownershipIssue := detachLoadConfiguration(schema, resolved.configuration, cloneUnresolvedSecrets)
|
||||
if ownershipIssue != nil {
|
||||
accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue)))
|
||||
accumulator.add(*ownershipIssue)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
configuration = detached.Interface().(T)
|
||||
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, inputDocument, lookup)
|
||||
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, input.document, 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) {
|
||||
diagnostics = runValidation(&configuration, options.Definition.Validate, schema, resolved.final, input.document, secretResolution.lookups)
|
||||
accumulator.add(diagnostics...)
|
||||
if hasErrors(diagnostics) {
|
||||
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 := cloneConfigurationWithMode(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), cloneValidatedSecrets)
|
||||
if ownershipIssue != nil {
|
||||
accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue)))
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
configuration = published.Interface().(T)
|
||||
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
|
||||
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 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 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
|
||||
resolved.configuration = configuration
|
||||
// Publication detaches equal-valued validator replacements, preserving the
|
||||
// validated secret state, then finalizes metadata and sensitivity.
|
||||
return publishLoadResult(schema, options.Definition, resolved, secretResolution.references, &accumulator)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user