Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6ced0d3d1 | |||
| 73ce6e9e31 | |||
| ae91f57bbe | |||
| 66e080ed34 | |||
| d8d9dbfe7f | |||
| aead20fae4 | |||
| c5a2ddee7a | |||
| 418fefdeef | |||
| 21ae2159f7 | |||
| b9425c9ae2 | |||
| fbe7b61c79 | |||
| 34380f62a5 | |||
| f2c650b5eb | |||
| 5760614834 | |||
| 88e86de03e | |||
| c98435ce8e | |||
| 6102b36adf | |||
| 7395f54513 | |||
| 5dc7e18d83 | |||
| 7f46c947dd | |||
| 550f92d7c6 | |||
| da307c9f49 | |||
| b8935e77bd | |||
| 05812a6ad3 | |||
| da02a19f2c | |||
| 9b4f9ac50e | |||
| f81def9365 | |||
| eb7de8e40a | |||
| 28e7fc5f69 | |||
| 715364b079 | |||
| b9e4dfac92 | |||
| cc98be8f0a | |||
| e20a0a0998 | |||
| 88f21d09c7 | |||
| 818d36f836 | |||
| 381430a24b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,6 +25,9 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Local codebase-memory tooling artifacts
|
||||
/.codebase-memory/
|
||||
|
||||
# ---> Linux
|
||||
*~
|
||||
|
||||
@@ -81,4 +84,3 @@ Temporary Items
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
Please review `docs/development.md` for initial orientation in this repository
|
||||
and follow its task-specific reading guide.
|
||||
|
||||
Review and follow `docs/policy/agents.md` before recommending, planning, or
|
||||
implementing any changes to this repository.
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
An opinionated Go library for applications that use YAML configuration files.
|
||||
|
||||
Linux is release-validated; macOS support is best-effort. See the
|
||||
[platform support policy](docs/policy/release.md#platform-support) for details.
|
||||
|
||||
ConfigKit's initial loader accepts one strict YAML
|
||||
source (or explicit defaults-only input), optionally traverses explicit path and glob
|
||||
imports, strictly composes their disjoint definitions, applies typed defaults, and
|
||||
can overlay one selected fixed profile and check an application schema version. It
|
||||
can overlay one selected fixed overlay and check an application schema version. It
|
||||
returns detached metadata and safe diagnostics.
|
||||
Applications may perform audited defaulting and normalization through a resolver,
|
||||
then report domain errors and warnings through read-only semantic validation. Secret
|
||||
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
||||
references resolve environment names before validation, and explicit
|
||||
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
||||
CLI override records apply above profiles. Value explanation is available through
|
||||
CLI override records apply above overlays. Value explanation is available through
|
||||
detached structured records and deterministic redacted output.
|
||||
|
||||
```go
|
||||
|
||||
133
conversion.go
Normal file
133
conversion.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package configkit
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
|
||||
func fromEngineLocation(value engine.Location) Location {
|
||||
return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
|
||||
}
|
||||
func toEngineLocation(value Location) engine.Location {
|
||||
return engine.Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
|
||||
}
|
||||
func fromEngineLocationPointer(value *engine.Location) *Location {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
result := fromEngineLocation(*value)
|
||||
return &result
|
||||
}
|
||||
func toEngineLocationPointer(value *Location) *engine.Location {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
result := toEngineLocation(*value)
|
||||
return &result
|
||||
}
|
||||
func fromEngineLocations(values []engine.Location) []Location {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]Location, len(values))
|
||||
for i := range values {
|
||||
result[i] = fromEngineLocation(values[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
func toEngineLocations(values []Location) []engine.Location {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]engine.Location, len(values))
|
||||
for i := range values {
|
||||
result[i] = toEngineLocation(values[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
|
||||
}
|
||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
||||
}
|
||||
func fromEngineOrigin(value engine.Origin) Origin {
|
||||
return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||
}
|
||||
func toEngineOrigin(value Origin) engine.Origin {
|
||||
return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
||||
}
|
||||
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
||||
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
|
||||
}
|
||||
func toEngineDiagnostic(value Diagnostic) engine.Diagnostic {
|
||||
return engine.Diagnostic{Code: engine.Code(value.Code), Severity: engine.Severity(value.Severity), Path: toEnginePath(value.Path), Message: value.Message, Primary: toEngineLocationPointer(value.Primary), Related: toEngineLocations(value.Related)}
|
||||
}
|
||||
func toEngineDiagnostics(values []Diagnostic) []engine.Diagnostic {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]engine.Diagnostic, len(values))
|
||||
for i := range values {
|
||||
result[i] = toEngineDiagnostic(values[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
func fromEngineFieldInfo(value engine.FieldInfo) FieldInfo {
|
||||
return FieldInfo{Explicit: value.Explicit, Null: value.Null, Origin: fromEngineOrigin(value.Origin)}
|
||||
}
|
||||
func fromEngineChange(value engine.Change) Change {
|
||||
result := Change{Path: fromEnginePath(value.Path), Origin: fromEngineOrigin(value.Origin), Reasons: append([]string(nil), value.Reasons...)}
|
||||
if value.Previous != nil {
|
||||
result.Previous = make([]Origin, len(value.Previous))
|
||||
for i := range value.Previous {
|
||||
result.Previous[i] = fromEngineOrigin(value.Previous[i])
|
||||
}
|
||||
}
|
||||
if value.Dependencies != nil {
|
||||
result.Dependencies = make([]Path, len(value.Dependencies))
|
||||
for i := range value.Dependencies {
|
||||
result.Dependencies[i] = fromEnginePath(value.Dependencies[i])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fromEngineValueSnapshot(value engine.ValueSnapshot) ValueSnapshot {
|
||||
return ValueSnapshot{Present: value.Present, Redacted: value.Redacted, YAML: value.YAML}
|
||||
}
|
||||
|
||||
func fromEngineValueChange(value engine.ValueChange) ValueChange {
|
||||
result := ValueChange{
|
||||
Before: fromEngineValueSnapshot(value.Before),
|
||||
After: fromEngineValueSnapshot(value.After),
|
||||
Origin: fromEngineOrigin(value.Origin),
|
||||
Reasons: append([]string(nil), value.Reasons...),
|
||||
}
|
||||
if value.Dependencies != nil {
|
||||
result.Dependencies = make([]Path, len(value.Dependencies))
|
||||
for index := range value.Dependencies {
|
||||
result.Dependencies[index] = fromEnginePath(value.Dependencies[index])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fromEngineExplanation(value engine.Explanation) Explanation {
|
||||
result := Explanation{
|
||||
Path: fromEnginePath(value.Path),
|
||||
Effective: fromEngineValueSnapshot(value.Effective),
|
||||
Origin: fromEngineOrigin(value.Origin),
|
||||
}
|
||||
if value.History != nil {
|
||||
result.History = make([]ValueChange, len(value.History))
|
||||
for index := range value.History {
|
||||
result.History[index] = fromEngineValueChange(value.History[index])
|
||||
}
|
||||
}
|
||||
if value.SecretReference != nil {
|
||||
result.SecretReference = &SecretReferenceInfo{Name: value.SecretReference.Name}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fromEngineSecretFileResult(value engine.SecretFileResult) SecretFileResult {
|
||||
return SecretFileResult{Name: value.Name, Path: value.Path, Status: SecretFileStatus(value.Status), ExistingEmpty: value.ExistingEmpty}
|
||||
}
|
||||
243
diagnostic.go
243
diagnostic.go
@@ -1,248 +1,57 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
)
|
||||
|
||||
// LoadError reports safe structured diagnostics for a failed configuration load.
|
||||
// It intentionally does not expose or unwrap parser and application errors.
|
||||
type LoadError struct {
|
||||
diagnostics []Diagnostic
|
||||
sources []SourceInfo
|
||||
}
|
||||
type LoadError struct{ value *engine.LoadError }
|
||||
|
||||
// Error implements error without rendering diagnostic messages or input values.
|
||||
func (e *LoadError) Error() string {
|
||||
if e == nil {
|
||||
if e == nil || e.value == nil {
|
||||
return "configkit: configuration load failed"
|
||||
}
|
||||
count := 0
|
||||
for _, diagnostic := range e.diagnostics {
|
||||
if diagnostic.Severity == SeverityError {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 1 {
|
||||
return "configkit: configuration load failed with 1 error"
|
||||
}
|
||||
return fmt.Sprintf("configkit: configuration load failed with %d errors", count)
|
||||
return e.value.Error()
|
||||
}
|
||||
|
||||
// Diagnostics returns a deep copy of the error's canonical diagnostics.
|
||||
func (e *LoadError) Diagnostics() []Diagnostic {
|
||||
if e == nil {
|
||||
if e == nil || e.value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneDiagnostics(e.diagnostics)
|
||||
values := e.value.Diagnostics()
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]Diagnostic, len(values))
|
||||
for i := range values {
|
||||
result[i] = fromEngineDiagnostic(values[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Sources returns a deep copy of sources discovered before loading failed.
|
||||
func (e *LoadError) Sources() []SourceInfo {
|
||||
if e == nil {
|
||||
if e == nil || e.value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneSourceInfos(e.sources)
|
||||
values := e.value.Sources()
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]SourceInfo, len(values))
|
||||
for i := range values {
|
||||
result[i] = fromEngineSourceInfo(values[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
||||
// control characters and propagates writer errors, including short writes.
|
||||
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||
canonical := canonicalDiagnostics(diagnostics)
|
||||
for _, diagnostic := range canonical {
|
||||
if err := writeDiagnostic(output, diagnostic); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type diagnosticAccumulator struct {
|
||||
diagnostics []Diagnostic
|
||||
sources []SourceInfo
|
||||
}
|
||||
|
||||
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
||||
for _, diagnostic := range diagnostics {
|
||||
a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *diagnosticAccumulator) loadError() *LoadError {
|
||||
return &LoadError{
|
||||
diagnostics: canonicalDiagnostics(a.diagnostics),
|
||||
sources: cloneSourceInfos(a.sources),
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
||||
canonical := cloneDiagnostics(diagnostics)
|
||||
for i := range canonical {
|
||||
slices.SortFunc(canonical[i].Related, compareLocation)
|
||||
}
|
||||
slices.SortFunc(canonical, compareDiagnostic)
|
||||
return canonical
|
||||
}
|
||||
|
||||
func compareDiagnostic(left, right Diagnostic) int {
|
||||
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Code, right.Code); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Severity, right.Severity); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Message, right.Message); order != 0 {
|
||||
return order
|
||||
}
|
||||
return compareLocations(left.Related, right.Related)
|
||||
}
|
||||
|
||||
func compareOptionalLocation(left, right *Location) int {
|
||||
if left == nil && right == nil {
|
||||
return 0
|
||||
}
|
||||
if left == nil {
|
||||
return -1
|
||||
}
|
||||
if right == nil {
|
||||
return 1
|
||||
}
|
||||
return compareLocation(*left, *right)
|
||||
}
|
||||
|
||||
func compareLocations(left, right []Location) int {
|
||||
for i := 0; i < min(len(left), len(right)); i++ {
|
||||
if order := compareLocation(left[i], right[i]); order != 0 {
|
||||
return order
|
||||
}
|
||||
}
|
||||
return cmp.Compare(len(left), len(right))
|
||||
}
|
||||
|
||||
func compareLocation(left, right Location) int {
|
||||
if order := compareSourceID(left.SourceID, right.SourceID); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Line, right.Line); order != 0 {
|
||||
return order
|
||||
}
|
||||
return cmp.Compare(left.Column, right.Column)
|
||||
}
|
||||
|
||||
func compareSourceID(left, right string) int {
|
||||
leftOrder, leftOK := sourceSequence(left)
|
||||
rightOrder, rightOK := sourceSequence(right)
|
||||
if leftOK && rightOK {
|
||||
return cmp.Compare(leftOrder, rightOrder)
|
||||
}
|
||||
return cmp.Compare(left, right)
|
||||
}
|
||||
|
||||
func sourceSequence(sourceID string) (int, bool) {
|
||||
const prefix = "source-"
|
||||
if !strings.HasPrefix(sourceID, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix))
|
||||
if err != nil || value < 1 {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error {
|
||||
severity := diagnostic.Severity
|
||||
if severity == "" {
|
||||
severity = SeverityError
|
||||
}
|
||||
path := diagnostic.Path.String()
|
||||
if path == "" {
|
||||
path = "<root>"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(escapeControls(string(severity)))
|
||||
if diagnostic.Code != "" {
|
||||
b.WriteString(" [")
|
||||
b.WriteString(escapeControls(string(diagnostic.Code)))
|
||||
b.WriteByte(']')
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(escapeControls(path))
|
||||
if diagnostic.Primary != nil {
|
||||
b.WriteString(" (")
|
||||
writeLocationText(&b, *diagnostic.Primary)
|
||||
b.WriteByte(')')
|
||||
}
|
||||
if diagnostic.Message != "" {
|
||||
b.WriteString(": ")
|
||||
b.WriteString(escapeControls(diagnostic.Message))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
for _, related := range diagnostic.Related {
|
||||
b.WriteString(" related: ")
|
||||
writeLocationText(&b, related)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return writeString(output, b.String())
|
||||
}
|
||||
|
||||
func writeLocationText(output *strings.Builder, location Location) {
|
||||
label := location.SourceID
|
||||
if label == "" {
|
||||
label = "<synthetic>"
|
||||
}
|
||||
output.WriteString(escapeControls(label))
|
||||
if location.Line > 0 {
|
||||
output.WriteByte(':')
|
||||
output.WriteString(strconv.Itoa(location.Line))
|
||||
if location.Column > 0 {
|
||||
output.WriteByte(':')
|
||||
output.WriteString(strconv.Itoa(location.Column))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeString(output io.Writer, value string) error {
|
||||
written, err := io.WriteString(output, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written != len(value) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func escapeControls(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) {
|
||||
quoted := strconv.QuoteRune(r)
|
||||
b.WriteString(quoted[1 : len(quoted)-1])
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func hasErrors(diagnostics []Diagnostic) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Severity == SeverityError {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return engine.WriteDiagnostics(output, toEngineDiagnostics(diagnostics))
|
||||
}
|
||||
|
||||
11
doc.go
11
doc.go
@@ -1,10 +1,9 @@
|
||||
// Package configkit provides deterministic, application-owned configuration
|
||||
// loading for Go programs.
|
||||
//
|
||||
// Load currently supports one file, byte, reader, or explicit no-input source with
|
||||
// strict YAML, typed defaults, schema-version checks, detached metadata, safe
|
||||
// diagnostics, recorded application resolution, read-only semantic validation, and
|
||||
// structured load errors. Composition, profiles, overrides, secrets, and effective
|
||||
// rendering are not available yet. The configuration contract is documented in
|
||||
// docs/config.md.
|
||||
// Load supports file, byte, reader, and explicit no-input sources with strict YAML,
|
||||
// typed defaults, imports, overlays, CLI overrides, schema-version checks, detached
|
||||
// metadata, recorded application resolution, typed secret references, safe effective
|
||||
// rendering, read-only semantic validation, and structured load errors. The
|
||||
// configuration contract is documented in docs/config.md.
|
||||
package configkit
|
||||
|
||||
61
docs/adr/0006-public-facade.md
Normal file
61
docs/adr/0006-public-facade.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# ADR 0006: Keep the public facade root-owned
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented on 2026-09-12.
|
||||
|
||||
## Date
|
||||
|
||||
2026-09-12
|
||||
|
||||
## Context
|
||||
|
||||
The root `configkit` package formerly combined the supported consumer API with
|
||||
loading, decoding, inspection, and rendering machinery. That made ordinary
|
||||
consumer navigation harder and risked exposing implementation details through
|
||||
future public declarations. Moving files alone would not preserve the identity,
|
||||
method sets, callbacks, ownership rules, or reflection-sensitive behavior of the
|
||||
root's public types.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep `configkit` as the sole supported consumer import path and keep all public
|
||||
named types defined there. Lifecycle machinery lives in a cohesive
|
||||
`internal/engine`; the root facade holds private engine state and forwards
|
||||
deliberately selected methods. The dependency direction is root to engine and
|
||||
its supporting internal packages. Internal production packages do not import the
|
||||
root package.
|
||||
|
||||
The facade will use root-owned wrappers and explicit private conversions. It
|
||||
will not use public aliases to engine types, anonymous embedding that promotes
|
||||
engine methods, public engine types in signatures or fields, or public bridge
|
||||
methods. Existing document and YAML boundaries remain intact.
|
||||
|
||||
The migration used a temporary internal candidate facade while the unchanged root
|
||||
remained the only production entry point. That duplication was a bounded
|
||||
relocation mechanism, not a second supported package, feature path, or runtime
|
||||
selection mechanism; the final cutover removed it and the old root implementation.
|
||||
|
||||
The [architecture policy](../policy/architecture.md#public-api-and-dependency-direction)
|
||||
owns the binding boundary. The [component overview](../internal/overview.md)
|
||||
describes the implemented layout; the [development guide](../development.md)
|
||||
locates the completed roadmap and implementation history.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Alias root types to engine types: reduces forwarding, but makes the engine's
|
||||
exported method set and type ownership part of the consumer API.
|
||||
- Keep all machinery in the root package: avoids migration work, but leaves the
|
||||
public boundary difficult to navigate and easy to expand accidentally.
|
||||
- Introduce a generic adapter or registration framework: could reduce some
|
||||
handwritten conversions, but adds machinery and obscures the ownership rules
|
||||
this boundary needs to preserve.
|
||||
|
||||
## Consequences
|
||||
|
||||
The refactor carries explicit forwarding and conversion work, especially for
|
||||
callbacks, detached records, source ownership, frozen inspection, and the
|
||||
exact-type secret bridge. Public compatibility must be verified by behavior and
|
||||
external-package evidence rather than inferred from package names. Internal
|
||||
exports remain implementation surfaces, and the completed architecture will
|
||||
have one engine and one root facade.
|
||||
100
docs/config.md
100
docs/config.md
@@ -20,7 +20,7 @@ The zero `Source` is invalid. Constructor argument failures are retained inside
|
||||
opaque source and become safe structured diagnostics when the source is consumed.
|
||||
File sources can observe file changes between consumptions; byte sources cannot.
|
||||
|
||||
Each source is limited to 8 MiB. A load using imports or an active profile is limited
|
||||
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
|
||||
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
||||
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
||||
1,000,000 visited directory entries, including nonmatches. Reads request at most the
|
||||
@@ -33,7 +33,7 @@ truncated. Data returned together with a read error is not accepted as partial i
|
||||
root `T` must satisfy the application schema contract below. With no options it
|
||||
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
|
||||
runs exactly once after successful source parsing, schema construction, optional
|
||||
schema-version checking, and selected-profile loading. Its result is detached before
|
||||
schema-version checking, and selected-overlay loading. Its result is detached before
|
||||
input is applied. Resolver-provided mutable values are detached before secret lookup
|
||||
and validation, and a final detached copy prevents equal-valued aliases introduced
|
||||
by validators from escaping into the returned configuration. The publication copy
|
||||
@@ -43,7 +43,7 @@ The current lifecycle is:
|
||||
|
||||
```text
|
||||
validate options → parse the selected source → traverse/compose enabled imports
|
||||
→ build schema → check the optional application version → apply a selected profile
|
||||
→ build schema → check the optional application version → apply a selected overlay
|
||||
→ apply explicit CLI overrides → create and clone defaults → strictly decode
|
||||
→ recorded application resolution
|
||||
→ resolve typed secret references → semantic validation → classify secrets
|
||||
@@ -57,9 +57,9 @@ contributes no source, and continues through defaults, decode, and projection. A
|
||||
missing file remains an error; it is never interpreted as no input.
|
||||
|
||||
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
||||
semantic validator, explicit path and glob imports, one selected profile, and
|
||||
semantic validator, explicit path and glob imports, one selected overlay, and
|
||||
filesystem context. It also accepts explicit CLI override records, static secret
|
||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
|
||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays,
|
||||
and filesystem context as invalid combinations but accepts overrides. Input
|
||||
containing a reserved `configkit` mapping key is rejected while imports are
|
||||
disabled.
|
||||
@@ -93,7 +93,7 @@ This is a load-local exception: siblings and unrelated fields, dynamic values,
|
||||
integer and float destinations, durations, `Secret`, and text codecs retain ordinary
|
||||
strict decoding. The retained source and history stay numeric while typed projection
|
||||
and effective YAML contain the resulting string; explanation records this through
|
||||
the normal decoding reconciliation event. Fragment, profile, and CLI ownership
|
||||
the normal decoding reconciliation event. Fragment, overlay, and CLI ownership
|
||||
protections apply before this conversion.
|
||||
|
||||
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
||||
@@ -109,11 +109,11 @@ successful result. Callback panics remain programming failures.
|
||||
key/index distinctions using actual effective and historical container shapes;
|
||||
only parsed numeric tokens are resolved contextually. A descendant that never
|
||||
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
|
||||
are explicit and have a base or profile origin; omitted paths have a default origin.
|
||||
are explicit and have a base or overlay origin; omitted paths have a default origin.
|
||||
An explicit parent mapping does not make omitted children explicit. Null state and
|
||||
available source coordinates are retained. `History` records the Go-zero or application
|
||||
default baseline followed by exact base or profile definitions, including
|
||||
equal-valued input. Profile replacement removes displaced descendants from effective
|
||||
default baseline followed by exact base or overlay definitions, including
|
||||
equal-valued input. Overlay replacement removes displaced descendants from effective
|
||||
metadata while retaining their base definition and the parent replacement operation
|
||||
in history. All metadata queries and returned nested records are detached.
|
||||
|
||||
@@ -125,21 +125,21 @@ documentation in [metadata.go](../metadata.go) defines enumeration and path-orde
|
||||
details.
|
||||
|
||||
History is chronological: a path can have a synthetic default record, its exact base
|
||||
definition, a profile replacement, and a recorded resolution operation. A descendant
|
||||
definition, an overlay replacement, and a recorded resolution operation. A descendant
|
||||
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
||||
the descendant can therefore retain history after `Lookup` stops reporting it as
|
||||
effective. Equal-valued input is still an explicit base definition, while a derived
|
||||
value for absent input remains non-explicit.
|
||||
|
||||
A mapping assembled from the selected file and imports has one composite base origin
|
||||
whose locations retain source traversal order. A mapping also visited by a profile
|
||||
has a profile-layer composite origin with the lower contributing locations followed
|
||||
by the profile location; an empty mapping is still an explicit container even though
|
||||
whose locations retain source traversal order. A mapping also visited by an overlay
|
||||
has an overlay-layer composite origin with the lower contributing locations followed
|
||||
by the overlay location; an empty mapping is still an explicit container even though
|
||||
it contributes no children. Resolution origins are synthetic: they have layer
|
||||
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
||||
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
||||
removed. Source records remain ordered as selected source, depth-first imports, the
|
||||
selected profile, then synthetic CLI records in supplied order.
|
||||
selected overlay, then synthetic CLI records in supplied order.
|
||||
|
||||
### Secret classification and effective rendering
|
||||
|
||||
@@ -189,7 +189,7 @@ historical path. Its effective value and each chronological before/after value a
|
||||
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
||||
null is present and renders as `null`. The history starts with the canonical typed
|
||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||
traversal order, the selected profile, supplied CLI records, and audited net
|
||||
traversal order, the selected overlay, supplied CLI records, and audited net
|
||||
resolution operations. Overlapping parent/child resolution records are rendered
|
||||
as one net subtree event with combined reasons and dependencies, rather than
|
||||
pretending that the recorded assignments ran in lexical order. Explicit equal
|
||||
@@ -199,7 +199,7 @@ before domain resolution. Its origin retains the decoded path's source/default
|
||||
attribution; its reason identifies static-default retention and canonical decoding.
|
||||
This built-in inspection operation is not accepted by `Resolution.Record`.
|
||||
|
||||
Base, profile, and CLI history preserves the policy-validated input representation,
|
||||
Base, overlay, and CLI history preserves the policy-validated input representation,
|
||||
including a superseded value that could not have decoded into the final Go field.
|
||||
Baseline, effective, and resolution values use canonical typed projection. A
|
||||
decoding event bridges replayed input and canonical typed values, so intermediate
|
||||
@@ -293,10 +293,13 @@ operation once before starting concurrent work. The selected path may itself be
|
||||
symlink; ConfigKit resolves it to an existing canonical directory before examining
|
||||
its immediate entries.
|
||||
|
||||
The bootstrap enumerates at most 1,024 entries and sorts them by filename. Every
|
||||
entry must have an ASCII environment-variable name and be a regular, nonsymlink
|
||||
file. Metadata for all entries is validated before file content is read or process
|
||||
state is changed. Existing environment variables win, including variables whose
|
||||
The bootstrap enumerates at most 1,024 entries, including dot-prefixed entries.
|
||||
Entries whose names begin with `.` (files, directories, or symlinks, such as `.git`
|
||||
and `.gitignore`) are ignored before name or metadata validation, are never opened,
|
||||
and are omitted from the report. Remaining entries are sorted by filename; each
|
||||
must have an ASCII environment-variable name and be a regular, nonsymlink
|
||||
file. Metadata for all remaining entries is validated before file content is read
|
||||
or process state is changed. Existing environment variables win, including variables whose
|
||||
value is empty; their files are not opened. Each eligible file is limited to 1 MiB,
|
||||
and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one
|
||||
terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and
|
||||
@@ -326,6 +329,27 @@ receives the working configuration and a per-load `Resolution` context. The cont
|
||||
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
|
||||
null are never confused with absence or static defaults.
|
||||
|
||||
`Resolution.InputInfo(path)` returns detached effective metadata from the fixed
|
||||
decoded entry state, before application resolution runs. It includes retained static
|
||||
defaults, preserves explicit null, and uses the same exact semantic path matching as
|
||||
`Metadata.Lookup`; missing, malformed, and key/index-shape-mismatched paths return
|
||||
false without diagnostics. Its `Explicit` value agrees with `IsExplicit` for every
|
||||
found entry path. Resolution changes, additions, removals, and moves do not alter
|
||||
this entry view.
|
||||
|
||||
`Resolution.Source(id)` returns a detached record for an already acquired source in
|
||||
the same load: the main source, imports, selected overlay, and supplied CLI records
|
||||
where applicable. Empty and unknown IDs return false. It never reloads input, reads
|
||||
the filesystem, resolves symlinks, or looks up credentials. A source path exists
|
||||
only when the acquired source has one; defaults and byte, reader, and CLI sources do
|
||||
not acquire an invented file path. Applications select a base directory from exact
|
||||
origin locations and these source records according to their own policy.
|
||||
|
||||
Both queries are callback-scoped like the rest of `Resolution`: nil, zero, or
|
||||
retained contexts panic with the fixed closed-context failure outside the callback.
|
||||
Returned field and source records are detached and remain usable after callback
|
||||
exit; changing them cannot affect later queries, results, or another load.
|
||||
|
||||
`Default` assigns and records a derived value only when its path is not explicit.
|
||||
`Normalize` always assigns and records. For transformations performed directly by
|
||||
application code, `Resolution.Record` declares a derived-default or normalization
|
||||
@@ -412,41 +436,41 @@ traversal order. Traversal order never authorizes replacement. Composition clone
|
||||
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
||||
input document.
|
||||
|
||||
## Selected profiles
|
||||
## Selected overlays
|
||||
|
||||
`Options.Profile` selects at most one overlay by name. Names must match
|
||||
`Options.Overlay` selects at most one overlay by name. Names must match
|
||||
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
|
||||
`default` select no profile, and `profiles/default.yml` is never loaded implicitly.
|
||||
Every other name resolves only to `<configuration-root>/profiles/<name>.yml`, with
|
||||
no extension fallback, case normalization, inheritance, or profile stack. A selected
|
||||
missing profile is an error.
|
||||
`default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
|
||||
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
|
||||
no extension fallback, case normalization, inheritance, or overlay stack. A selected
|
||||
missing overlay is an error.
|
||||
|
||||
The profile is a separately policy-validated YAML mapping. It is loaded after the
|
||||
The overlay is a separately policy-validated YAML mapping. It is loaded after the
|
||||
base import graph is composed and its schema version is checked, but before defaults
|
||||
and typed decoding. Profile bytes, nodes, and source records count toward the same
|
||||
and typed decoding. Overlay bytes, nodes, and source records count toward the same
|
||||
aggregate limits as base inputs. Its canonical path must remain within the configured
|
||||
root and must not duplicate a file already present in the base graph.
|
||||
|
||||
Overlay is intentionally distinct from strict composition:
|
||||
|
||||
| Existing lower node | Incoming profile node | Result |
|
||||
| Existing lower node | Incoming overlay node | Result |
|
||||
| --- | --- | --- |
|
||||
| Absent | Value | Retain the profile value |
|
||||
| Absent | Value | Retain the overlay value |
|
||||
| Value | Absent | Retain the lower value |
|
||||
| Mapping | Mapping | Recurse by mapping key |
|
||||
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
|
||||
|
||||
Scalars, sequences, and null are atomic. Thus a profile sequence replaces rather
|
||||
Scalars, sequences, and null are atomic. Thus an overlay sequence replaces rather
|
||||
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
|
||||
cross-kind replacement is allowed when the final application schema accepts it.
|
||||
Replacing a subtree removes all displaced descendants from effective presence and
|
||||
origin data; history retains the displaced definitions and identifies the parent
|
||||
replacement. Surviving profile paths have profile origins, while recursively shared
|
||||
replacement. Surviving overlay paths have overlay origins, while recursively shared
|
||||
mapping containers retain their contributing locations.
|
||||
|
||||
A profile may not contain `configkit` anywhere, contribute the configured schema
|
||||
An overlay may not contain `configkit` anywhere, contribute the configured schema
|
||||
version, or replace an ancestor of that version with a scalar or null. These rules
|
||||
apply even if the supplied version text would be unchanged. Profiles cannot mask a
|
||||
apply even if the supplied version text would be unchanged. Overlays cannot mask a
|
||||
base parse, composition, or version failure because those checks run first.
|
||||
|
||||
## Explicit CLI overrides
|
||||
@@ -459,8 +483,8 @@ must never contain the raw argument or configured value. General environment
|
||||
variable overlays are an explicit non-goal: environment access is reserved for
|
||||
typed secret references.
|
||||
|
||||
Overrides apply after the selected profile and before typed defaults/decoding, so
|
||||
the input precedence is application defaults < composed base < selected profile <
|
||||
Overrides apply after the selected overlay and before typed defaults/decoding, so
|
||||
the input precedence is application defaults < composed base < selected overlay <
|
||||
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
|
||||
effective override tree. Overrides also work with `NoSource`.
|
||||
|
||||
@@ -482,7 +506,7 @@ configured schema-version path. Unknown fields and effective type mismatches rem
|
||||
ordinary strict-decoder errors.
|
||||
|
||||
At most 1,024 records are accepted, and each record creates one synthetic CLI
|
||||
source after file/profile sources in supplied order. Override sources share the
|
||||
source after file/overlay sources in supplied order. Override sources share the
|
||||
1,024-source ceiling. A record has an 8 MiB string-byte limit; override strings and
|
||||
source bytes share the 64 MiB aggregate limit. Synthetic mapping keys and values
|
||||
count toward the existing 1,000,000-node and depth-256 ceilings. Cycles, named or
|
||||
@@ -490,7 +514,7 @@ unsupported types, nonfinite floats, and limit excesses fail with safe `override
|
||||
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
|
||||
arguments or values.
|
||||
|
||||
Override origins are explicit CLI origins. History retains replaced base/profile
|
||||
Override origins are explicit CLI origins. History retains replaced base/overlay
|
||||
definitions and records equal-value replacements as well as structural changes.
|
||||
Caller-owned maps and slices are snapshotted during loading and must not be mutated
|
||||
concurrently with the call. The executable `ExampleOverride` shows translating a
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Consumer Adoption Guide
|
||||
|
||||
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and
|
||||
profile traversal, precedence, provenance, secret lookup, and safe inspection from
|
||||
selected-overlay traversal, precedence, provenance, secret lookup, and safe inspection from
|
||||
applications. Consumers still own their configuration structs, defaults, semantic
|
||||
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
||||
resource checks, remote acquisition, and service initialization.
|
||||
@@ -34,7 +34,7 @@ features appear only when the application needs them:
|
||||
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{
|
||||
Definition: rules,
|
||||
Imports: useDeclaredImports,
|
||||
Profile: selectedConfigProfile,
|
||||
Overlay: selectedConfigOverlay,
|
||||
Overrides: explicitlySetFlags,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -43,8 +43,8 @@ if err != nil {
|
||||
return start(result.Config)
|
||||
```
|
||||
|
||||
Simple consumers use neither imports nor profiles. A complex consumer can opt into
|
||||
both without redefining their syntax. ConfigKit profiles are configuration overlays;
|
||||
Simple consumers use neither imports nor selected overlays. A complex consumer can opt into
|
||||
both without redefining their syntax. ConfigKit overlays are configuration overlays;
|
||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||
execution profile concepts.
|
||||
|
||||
@@ -56,6 +56,24 @@ validate keys and ranges and convert only at their runtime boundary. Neither sha
|
||||
needs YAML reserialization, a parser hook, a ConfigKit registry, or a generic subtree
|
||||
conversion API.
|
||||
|
||||
## Source-aware application path policy
|
||||
|
||||
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained
|
||||
path-bearing example. Its application resolver uses `Resolution.InputInfo` and
|
||||
`Resolution.Source` before normalizing: relative CLI input uses an explicitly
|
||||
supplied invocation directory, retained defaults use the canonical main source,
|
||||
and a file-authored scalar uses its one declaring source. Absolute paths remain
|
||||
unchanged. Its executable test covers the matching effective YAML and explanation
|
||||
history, source-file ownership, and a symlink opened through an alias; canonical
|
||||
target-file directories, rather than the alias directory or process working
|
||||
directory, are the selected bases.
|
||||
|
||||
The selected example policy leaves a relative value unchanged and emits a warning
|
||||
when a CLI base is absent or file provenance is missing or composite. That is an
|
||||
application choice, not a ConfigKit fallback: consumers can instead reject it or
|
||||
choose another explicit base. The example intentionally performs neither target
|
||||
existence checks nor confinement checks.
|
||||
|
||||
## Intentional migration changes
|
||||
|
||||
The examples preserve domain meaning while adopting one configuration convention:
|
||||
@@ -82,6 +100,16 @@ The examples preserve domain meaning while adopting one configuration convention
|
||||
blocked empty variables remain blocked, and ordinary `Load` never changes the
|
||||
process environment.
|
||||
|
||||
### Selected-overlay migration
|
||||
|
||||
To adopt the current selected-overlay API, rename `Options.Profile`,
|
||||
`LayerProfile`, and `CodeProfile*` references to their `Overlay` equivalents; move
|
||||
selected files from `profiles/<name>.yml` to `overlays/<name>.yml`; update any
|
||||
handling of the `overlay` layer and `overlay.*` diagnostic strings; then rebuild.
|
||||
The [configuration contract](../config.md#selected-overlays) is the canonical
|
||||
behavioral reference. Application-owned profile concepts, such as `model_profile`,
|
||||
are unchanged, and any CLI flag rename remains the application's decision.
|
||||
|
||||
These examples are migration evidence, not completed changes to Narratio, Notarius,
|
||||
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
|
||||
wiring, runtime catalogs, and operational documentation remains separate downstream
|
||||
@@ -95,7 +123,7 @@ focused behavioral owner:
|
||||
| Roadmap surface | Maintained owner |
|
||||
| --- | --- |
|
||||
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
|
||||
| Source/import/profile pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
|
||||
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
|
||||
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
||||
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
||||
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
|
||||
|
||||
@@ -8,6 +8,8 @@ public behavior.
|
||||
## Initial orientation
|
||||
|
||||
1. Inspect the working tree and preserve unrelated changes.
|
||||
Agents must read and follow the [agent policy](policy/agents.md) before
|
||||
recommending, planning, or implementing changes.
|
||||
2. Read the architecture policy for code or design work.
|
||||
3. Read the policy, contract, and internal documents applicable to the task.
|
||||
4. Inspect the relevant implementation and behavioral tests before changing them.
|
||||
@@ -30,6 +32,7 @@ Accepted decisions:
|
||||
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
|
||||
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
|
||||
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
|
||||
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
||||
|
||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||
@@ -46,8 +49,20 @@ The completed string-and-integer schema-version implementation and remediation
|
||||
records are preserved in Git history at `244104f`. The
|
||||
[configuration contract](config.md#application-schema-versions) owns current behavior.
|
||||
|
||||
The completed source-origins and public-facade roadmaps, implementation plan, and
|
||||
resolved review findings are preserved in Git history at `5760614`, for example
|
||||
`git show 5760614:docs/roadmap/remediation.md`. The earlier source-origins staged
|
||||
plan remains at `28e7fc5:docs/roadmap/implementation.md`. Current resolution
|
||||
behavior belongs to the [configuration contract](config.md#recorded-application-resolution),
|
||||
and current facade ownership belongs to the [component overview](internal/overview.md).
|
||||
|
||||
## Baseline validation
|
||||
|
||||
The completed overlay rename plan and release-workflow simplification record are
|
||||
preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
|
||||
behavior belongs to the [configuration contract](config.md#selected-overlays),
|
||||
and release operations belong to the [release procedure](release.md).
|
||||
|
||||
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
||||
dependencies explicitly before entering an offline development or validation
|
||||
environment:
|
||||
@@ -80,8 +95,10 @@ Use focused checks while iterating, with validation proportionate to the change
|
||||
under the testing policy. Documentation-only work normally needs the local-link
|
||||
check below and whitespace review; behavior-bearing examples and commands also
|
||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
||||
The separate [release checker](release.md#4-validate-the-source-candidate) adds
|
||||
two-toolchain, module-hygiene, race, and cross-build gates.
|
||||
The [release checker](release.md#4-validate-the-source-candidate) performs one
|
||||
minimum-toolchain race-suite run, vet, module hygiene, and Linux cross-builds.
|
||||
It already covers examples, links, and package boundaries; do not additionally
|
||||
run the everyday checker just to repeat those checks. CI never gates a release.
|
||||
|
||||
## Task-to-test map
|
||||
|
||||
@@ -91,15 +108,15 @@ code changes. Use the component overview to find the corresponding implementatio
|
||||
|
||||
| Task | Behavioral test owners | Focused command |
|
||||
| --- | --- | --- |
|
||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../parse_test.go) | `go test ./internal/yaml` and `go test . -run '^TestParseSource'` |
|
||||
| File/reader access, imports, globs, containment | [Sources](../source_test.go), [FIFO](../source_fifo_test.go), [imports](../imports_test.go), [globs](../glob_test.go) | `go test . -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| Composition, profiles, CLI precedence | [Composition](../compose_test.go), [overlay](../overlay_test.go), [profiles](../profile_test.go), [overrides](../override_test.go) | `go test . -run 'Compose\|Overlay\|Profile\|Override'` |
|
||||
| Schema, defaults, projection, cloning | [Schema](../schema_test.go), [decoding](../decode_test.go), [projection](../projection_test.go) | `go test . -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../load_test.go), [resolution](../resolution_test.go), [validation](../validation_test.go), [ownership](../callback_ownership_test.go), [independence](../independence_test.go) | `go test . -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||
| Paths, metadata, provenance | [Paths](../path_test.go), [metadata](../metadata_test.go), [provenance](../provenance_test.go) | `go test . -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||
| Secrets, bootstrap, redaction, explanation | [References](../secret_reference_test.go), [bootstrap](../secrets_test.go), [rendering](../render_test.go), [explanation](../explain_test.go) | `go test . -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
||||
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go) | `go test . -run '^(TestLocalDocumentationLinks\|TestDependencyImportBoundary)$'` |
|
||||
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||
| Check-command behavior | [Shell regression](../release_check_test.go) | `go test . -run '^TestCheckScripts'` |
|
||||
|
||||
## Fuzz validation
|
||||
@@ -108,7 +125,7 @@ Run the bounded fuzz targets independently from the repository root. These exact
|
||||
commands keep each campaign short and constrain concurrent workers:
|
||||
|
||||
```sh
|
||||
go test . -run='^$' -fuzz='^FuzzParsePath$' -fuzztime=30s -parallel=2
|
||||
go test ./internal/engine -run='^$' -fuzz='^FuzzParsePath$' -fuzztime=30s -parallel=2
|
||||
go test ./internal/yaml -run='^$' -fuzz='^FuzzParseDocument$' -fuzztime=30s -parallel=2
|
||||
go test . -run='^$' -fuzz='^FuzzCompositionAndOverlay$' -fuzztime=30s -parallel=2
|
||||
go test ./internal/engine -run='^$' -fuzz='^FuzzCompositionAndOverlay$' -fuzztime=30s -parallel=2
|
||||
```
|
||||
|
||||
@@ -137,9 +137,11 @@ permission failures, explicit `conf.d` use, and entry/candidate budgets. This wo
|
||||
has no Narratio production equivalent; it uses the selected standard-library
|
||||
`path.Match` and bounded `File.ReadDir` adaptation rather than its flat loader.
|
||||
|
||||
### Profiles and overlay
|
||||
### Upstream profiles and ConfigKit selected overlays
|
||||
|
||||
Assigned to selected-profile overlay:
|
||||
The following upstream symbols and case names retain their original profile
|
||||
terminology. ConfigKit adapts their selected-overlay behavior without renaming the
|
||||
upstream evidence:
|
||||
|
||||
- Production: `mergeOverlayComposition`, `mergeOverlayNodes`,
|
||||
`selectPipelineProfile`, `loadPipelineProfileOverlays`, and
|
||||
@@ -153,19 +155,21 @@ Assigned to selected-profile overlay:
|
||||
`TestLoadPipelineProfilePairResolvesIndependentEffectivePipelines`.
|
||||
- Preserve: recursive mapping overlay, atomic scalar/sequence replacement,
|
||||
deterministic selection failures, source ownership, and independent results.
|
||||
- Adapt: load only the selected fixed profile path; lowercase `default` means no
|
||||
overlay. ConfigKit permits null and cross-kind replacement and does not support
|
||||
declared defaults, inheritance, arbitrary profile paths, or validating every
|
||||
- Adapt: load only the selected fixed overlay path; lowercase `default` means no
|
||||
selection. ConfigKit permits null and cross-kind replacement and does not support
|
||||
declared defaults, inheritance, arbitrary overlay paths, or validating every
|
||||
unselected overlay.
|
||||
|
||||
Implementation record (2026-09-06): `overlayDocuments`, `mergeOverlayMappings`,
|
||||
`loadProfileDocument`, and `profileDirectiveDiagnostics` adapt the assigned merge,
|
||||
selection, loading, and ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
|
||||
`TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults`, profile selection/error,
|
||||
directive/version, containment, and budget cases adapt the assigned tests. ConfigKit
|
||||
Implementation record (2026-09-06, updated 2026-09-23): `overlayDocuments`,
|
||||
`mergeOverlayMappings`, `loadSelectedOverlayDocument`, and
|
||||
`overlayDirectiveDiagnostics` adapt the assigned merge, selection, loading, and
|
||||
ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
|
||||
`TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults`, selected-overlay
|
||||
selection/error, directive/version, containment, and budget cases adapt the assigned
|
||||
tests. ConfigKit
|
||||
uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement,
|
||||
retains displaced descendant history, rejects any profile directive or version-path
|
||||
contribution, and counts the profile in aggregate budgets. Narratio's declared
|
||||
retains displaced descendant history, rejects any overlay directive or version-path
|
||||
contribution, and counts the overlay in aggregate budgets. Narratio's declared
|
||||
default, multi-overlay validation, inheritance-like application conventions, and
|
||||
null/kind restrictions were not retained.
|
||||
|
||||
@@ -196,7 +200,7 @@ previously extracted source claims, overlay displacement records, typed baseline
|
||||
and audited resolution snapshots in `load_metadata.go`. The representative
|
||||
`TestLayeredProvenanceAgreesWithResolvedSnapshot` adapts Narratio's stable ownership,
|
||||
profile ownership, deterministic effective projection, and side-effect-free
|
||||
inspection cases across one default/import/profile/resolution workflow. ConfigKit
|
||||
inspection cases across one default/import/overlay/resolution workflow. ConfigKit
|
||||
adds chronological descendant history for parent and whole-sequence operations,
|
||||
composite empty-mapping origins, canonical resolution reasons/dependencies, private
|
||||
snapshot agreement, defensive nested copies, and concurrent load isolation. Value
|
||||
|
||||
@@ -1,89 +1,39 @@
|
||||
# Internal Component Overview
|
||||
|
||||
ConfigKit exposes the root `configkit` package and keeps its document and YAML
|
||||
representations private. The implemented components and their owners are:
|
||||
ConfigKit exposes one supported root `configkit` package. The root owns public
|
||||
named types, declaration comments, constructors, and explicit conversion/wrapper
|
||||
code. It delegates loading and frozen inspection to `internal/engine` through
|
||||
private state; public signatures and fields do not expose engine types.
|
||||
|
||||
- immutable semantic `Path` construction and strict JSON Pointer parsing;
|
||||
- detached effective presence/origin and chronological history queries in
|
||||
`metadata.go`, with provenance and diagnostic record types;
|
||||
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
|
||||
- opaque file, byte, reader, and no-input source construction in `source.go`,
|
||||
with shared regular-file checks and bounded read mechanics in `source_file.go`;
|
||||
- a parser-independent ordered tree in `internal/document`, with root-private
|
||||
semantic lookup, indexing, and value traversal in `document_walk.go`;
|
||||
- the sole upstream YAML integration in `internal/yaml`, which translates validated
|
||||
input into that owned tree and exposes no parser types outside its boundary;
|
||||
`parse.go` owns shared translation into detached, ordered public diagnostics;
|
||||
- a private strict composer in `compose.go` that joins only disjoint document-tree
|
||||
definitions, retains ordered claims and composite mapping origins, reports every
|
||||
conflicting location, and never aliases an input tree;
|
||||
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
|
||||
with displacement records in `overlay.go`; overlay records retain origins,
|
||||
while value explanation uses separate history applications;
|
||||
- CLI override validation/preparation in `override.go`, parser-neutral values in
|
||||
`override_value.go`, and synthetic-source application in `override_apply.go`,
|
||||
sharing overlay, source, byte, node, and depth policies;
|
||||
- a per-operation private reflection descriptor graph in `schema.go` for supported
|
||||
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
|
||||
- descriptor-driven canonical projection in `projection.go`, detached typed
|
||||
cloning with explicit secret-state modes in `clone.go`, shared reflection and
|
||||
cycle/limit state in `value_operation.go`, and configuration-value comparison
|
||||
in `document_compare.go`; validation separately audits sensitivity and secrets;
|
||||
- strict schema-driven decoding over detached defaults in `decode.go`, including
|
||||
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
||||
baseline/final trees, and initial default/input origins;
|
||||
- public lifecycle coordination in `load.go`, option selection in `options.go`,
|
||||
input assembly in `load_input.go`, typed phases/publication in `load_result.go`,
|
||||
bounded canonical import DFS in `imports.go`, filesystem context/containment in
|
||||
`filesystem.go`, directive parsing/restrictions in `directives.go`,
|
||||
pattern-directed glob expansion in `glob.go`, schema-version checking and report
|
||||
contexts in `validation.go`, and layered metadata/history assembly in
|
||||
`load_metadata.go`;
|
||||
- recorded application defaulting and normalization in `resolution.go`, including
|
||||
explicitness queries, helper verification, structural mutation audit, coalesced
|
||||
intent, private before/after snapshots, and resolution metadata integration;
|
||||
- post-resolution schema-aware report collection and validator mutation detection in
|
||||
`validation.go`, completing the public callback lifecycle after composed input;
|
||||
- opaque typed environment references and per-load name resolution in
|
||||
`secret_reference.go`, including credential-free projection/cloning and private
|
||||
validator-state auditing;
|
||||
- explicit bounded startup environment bootstrap and safe partial reporting in
|
||||
`secrets.go`;
|
||||
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
|
||||
- canonical effective output in `render.go`, using frozen-snapshot redaction
|
||||
shared with explanation in `snapshot.go`;
|
||||
- detached public explanation records and coordination in `explain.go`, immutable
|
||||
input applications/replay and retained-node budgeting in `history.go`, shared
|
||||
safe snapshot projection in `snapshot.go`, and deterministic human output in
|
||||
`explain_render.go`; the only upstream dumper integration remains isolated in
|
||||
`internal/yaml/write.go`;
|
||||
- a separate maintained `examples/consumers` package with four sanitized consumer
|
||||
shapes, fixtures, domain validators, and runtime-boundary functions; and
|
||||
- an everyday single-toolchain check in `scripts/check.sh` and a stricter
|
||||
source-candidate checker in `scripts/release-check.sh`, with local
|
||||
documentation-link and public import-boundary gates owned by root-package tests.
|
||||
Implemented component owners:
|
||||
|
||||
Dependency direction remains inward: the public lifecycle coordinates private
|
||||
source, document, schema, and inspection helpers. Semantic paths stay in the root
|
||||
package; `internal/document` owns only representation and cloning and never imports
|
||||
the root package. Only `internal/yaml` imports the upstream YAML package. Maintained
|
||||
consumer examples use the public package and standard library.
|
||||
- `internal/engine` owns source acquisition, composition, schema inspection,
|
||||
decoding, lifecycle coordination, callback auditing, provenance/history,
|
||||
secret resolution and bootstrap, and safe inspection rendering. It never
|
||||
imports the root package.
|
||||
- Root `Source`, `Path`, `Secret`, records, lifecycle callbacks, results, and
|
||||
bootstrap reports are root-defined wrappers or records. Callback wrappers pass
|
||||
only live engine context during a callback; `Result.Config` is detached and its
|
||||
private inspection remains frozen independently.
|
||||
- Root conversion files explicitly cross the path, record, callback, secret, and
|
||||
error boundaries. There are no public aliases, anonymous engine embeddings, or
|
||||
public engine bridge methods.
|
||||
- `internal/document` owns the parser-independent ordered tree and cloning.
|
||||
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
|
||||
- Engine mechanism tests own source, composition, decoding, callback, provenance,
|
||||
secret, history, rendering, and bootstrap behavior. Root tests own public API
|
||||
compatibility, facade integration, documentation links, and dependency/public
|
||||
boundary checks. Consumer examples remain under `examples/consumers`.
|
||||
|
||||
The [configuration contract](../config.md) owns implemented input, schema, decoding,
|
||||
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
|
||||
recorded resolution, typed secret references/bootstrap, semantic validation,
|
||||
metadata, secret classification, redacted effective rendering, historical
|
||||
explanation, and ownership behavior.
|
||||
The [consumer adoption guide](../consumers/README.md) owns current application-fit
|
||||
and migration guidance.
|
||||
Dependency direction is root to engine to document/YAML support. The root may
|
||||
import only `internal/engine` among project internal production packages; engine
|
||||
and its supporting packages do not import root. The root boundary test enforces
|
||||
these constraints and rejects exported aliases, fields, or signatures involving
|
||||
internal types.
|
||||
|
||||
The source-extraction inventory records the application foundations used by the
|
||||
implementation:
|
||||
|
||||
- [Extraction inventory](extraction.md)
|
||||
|
||||
The [architecture policy](../policy/architecture.md) owns binding boundaries and
|
||||
invariants. The [deferred scope](../roadmap/deferred.md) records deliberate
|
||||
exclusions. See the
|
||||
[development guide](../development.md) for contributor orientation and validation
|
||||
and the [release procedure](../release.md) for source-candidate gates.
|
||||
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||
semantics. The [consumer adoption guide](../consumers/README.md) owns application
|
||||
guidance. The [architecture policy](../policy/architecture.md) owns contributor
|
||||
boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade
|
||||
decision. See the [development guide](../development.md) for validation commands
|
||||
and [release procedure](../release.md) for source-candidate gates.
|
||||
|
||||
91
docs/policy/agents.md
Normal file
91
docs/policy/agents.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Agent Policy
|
||||
|
||||
**Base Policy Version:** 1.1
|
||||
|
||||
**Last Updated**: 2026-09-23
|
||||
|
||||
## Purpose
|
||||
|
||||
Agents working in this repository must read and adhere to the following house rules.
|
||||
|
||||
## Implementation Discipline
|
||||
|
||||
**KEY PRINCIPLES:** Write the simplest idiomatic code that satisfies the user's
|
||||
request. Do not implicitly strengthen requirements, restrictions, or guarantees
|
||||
beyond what the user actually requested.
|
||||
|
||||
Simplify the mechanism, not the user's choices.
|
||||
|
||||
**Keep implementation machinery proportionate.** Use straightforward,
|
||||
idiomatic code and introduce only the machinery needed to support the requested
|
||||
behavior and flexibility. Optimize for conceptual simplicity and
|
||||
maintainability, not for the fewest lines, fewest options, or narrowest product
|
||||
surface.
|
||||
|
||||
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
|
||||
harden, or otherwise narrow behavior unless required by the user, an explicit
|
||||
repository contract, or an unavoidable correctness constraint. Preserve
|
||||
operator and caller choice where policy has not been established. If an
|
||||
additional restriction appears advisable, present it as a decision rather than
|
||||
silently implementing it.
|
||||
|
||||
A correctness constraint must follow from the requested behavior or an
|
||||
established invariant, not from the convenience of the chosen implementation.
|
||||
Explain the specific failure it prevents and why a less restrictive
|
||||
implementation cannot satisfy the contract.
|
||||
|
||||
**Do not invent guarantees.** Do not strengthen public contracts, guarantees,
|
||||
or invariants beyond those expressly requested by the user.
|
||||
|
||||
**Use the standard library.** For example: in almost every case, the best
|
||||
approach to copying files is to use the standard library. Writing custom code to
|
||||
implement power-loss protection at the application level, or to guard against
|
||||
bad actors with privileged and concurrent access to the host's filesystem,
|
||||
is wasteful, unnecessary, and not idiomatic. Stick to the simple, standard, and
|
||||
idiomatic approach unless another path is expressly requested by the user.
|
||||
|
||||
**An implementation detail is not repository policy.** Tests and documentation
|
||||
must not elevate incidental behavior into a durable guarantee or restriction
|
||||
unless that behavior was expressly selected as part of the contract. Existing
|
||||
explicit contracts remain authoritative; changing or weakening one requires a
|
||||
deliberate decision rather than being inferred from this principle.
|
||||
|
||||
Agent-authored plans, tests, documentation, and prior implementations are not
|
||||
evidence that an additional restriction was deliberately approved. Identify the
|
||||
authorizing requirement or decision; when that authority is unclear, surface
|
||||
the ambiguity rather than treating repetition as approval.
|
||||
|
||||
## Git Commit Policy
|
||||
|
||||
By default, create a commit after completing each coherent unit of work, using a
|
||||
concise, plain-English message describing what changed. This policy is standing
|
||||
authorization to create those local commits; a separate user request is not
|
||||
required. An explicit user instruction to leave work uncommitted takes precedence.
|
||||
|
||||
A unit of work is a focused, reviewable outcome, including its relevant tests and
|
||||
documentation. Do not commit after every intermediate edit or create empty commits.
|
||||
Complete the appropriate validation before committing. If work is incomplete or
|
||||
validation is blocked, report that state rather than presenting it as completed.
|
||||
|
||||
Before committing:
|
||||
|
||||
1. Run `git status --short` and review the relevant diff, including any staged
|
||||
changes. Identify changes that predate the current unit of work.
|
||||
2. Exclude secrets, local environment files, generated junk, and unrelated changes.
|
||||
3. Stage only the files or hunks belonging to the completed unit. Use `git add .`
|
||||
only when every included change belongs to that unit. Do not disturb unrelated
|
||||
staged or unstaged work.
|
||||
4. Review `git diff --staged` and run appropriate checks for the change. Do not
|
||||
repeat checks already passed for unchanged content merely to create a commit.
|
||||
5. Review `git log --oneline -n 10` and use
|
||||
`git commit -m "<concise plain-English commit message>"`.
|
||||
6. Report the commit identifier and any remaining uncommitted work.
|
||||
|
||||
If the unit cannot be safely separated from pre-existing work, leave it uncommitted
|
||||
and explain the overlap rather than silently including that work. If a commit
|
||||
fails, preserve the changes and report the failure; do not bypass hooks.
|
||||
|
||||
Default commit authorization does not authorize pushing, publishing, amending,
|
||||
resetting, reverting, or rewriting history. Those actions require a separate user
|
||||
instruction. Do not use `git commit -a` or `git commit --no-verify` to bypass the
|
||||
staging and validation discipline above.
|
||||
@@ -71,11 +71,23 @@ network provider into the library.
|
||||
## Public API and dependency direction
|
||||
|
||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||
single-document consumers must not need import, profile, or provider machinery.
|
||||
single-document consumers must not need import, overlay, or provider machinery.
|
||||
Do not expose parser-specific nodes, errors, or extension hooks through the
|
||||
public API. Parser integration belongs behind a private boundary, as recorded
|
||||
in [ADR 0002](../adr/0002-own-yaml-semantics.md).
|
||||
|
||||
The root `configkit` package is the sole supported consumer import path. Public
|
||||
named types stay root-owned and may hold private delegated implementation state;
|
||||
their exported signatures, fields, and method sets must not expose internal
|
||||
types or acquire internal methods through anonymous embedding. Root production
|
||||
code may depend inward on a cohesive engine and its supporting internal packages,
|
||||
but internal production packages must not import the root. Explicit forwarding
|
||||
and local conversion are acceptable where they preserve type identity,
|
||||
ownership, callback lifetime, and inspection behavior. The accepted root-wrapper
|
||||
boundary and its migration rationale are recorded in
|
||||
[ADR 0006](../adr/0006-public-facade.md); the implemented layout is described in
|
||||
the [component overview](../internal/overview.md).
|
||||
|
||||
Separate input access, document processing, composition, decoding, metadata, and
|
||||
rendering responsibilities without prescribing an interface or package for each.
|
||||
Dependencies must preserve those boundaries; package splits and abstractions
|
||||
@@ -94,7 +106,7 @@ same inputs and options. Invalid input must not produce a successful usable
|
||||
configuration. Do not run application hooks on invalid typed configuration.
|
||||
|
||||
User-controlled precedence is application defaults, composed base, selected
|
||||
profile, then explicitly supplied CLI overrides. Applications own flag parsing;
|
||||
overlay, then explicitly supplied CLI overrides. Applications own flag parsing;
|
||||
ConfigKit owns overlay semantics and provenance. Domain resolution follows these
|
||||
inputs and must remain recorded, not become a hidden override channel.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Documentation Policy
|
||||
|
||||
**Base Policy Version:** 1.0
|
||||
**Base Policy Version:** 1.1
|
||||
|
||||
**Last Updated:** 2026-09-06
|
||||
**Last Updated:** 2026-09-23
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -59,6 +59,7 @@ handling, not secret values.
|
||||
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example |
|
||||
| Contributor entry point | `docs/development.md` | Reading guide and actual baseline validation commands |
|
||||
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
|
||||
| Agent rules | `docs/policy/agents.md` | Implementation discipline, proportionality, rules against inventing contracts, and local commit authorization; not application contracts or architecture |
|
||||
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
|
||||
| Testing rules | `docs/policy/testing.md` | Risk-based sufficiency, test boundaries, and maintenance principles |
|
||||
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Release Policy
|
||||
|
||||
**Base Policy Version:** 0.1.0
|
||||
**Base Policy Version:** 1.0
|
||||
|
||||
**Last Updated:** 2026-09-07
|
||||
**Last Updated:** 2026-09-23
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -21,7 +21,8 @@ ConfigKit adopts a source-only Go module release model. The source tag and
|
||||
version-matched release note are the declared deliverables. Standalone binaries,
|
||||
container images, and separate binary/package-registry publication are not
|
||||
required. This does not waive source compilation, module hygiene, testing, or
|
||||
consumer-facing module retrieval and build verification.
|
||||
local target-build validation. Post-publication consumer retrieval is optional,
|
||||
as documented under Policy Exceptions.
|
||||
|
||||
The release procedure must define the module path, authoritative remote,
|
||||
supported Go versions and operating-system/architecture targets, prerequisite
|
||||
@@ -35,6 +36,20 @@ can still break consumers. Human diagnostic wording is stable only where a
|
||||
contract expressly promises it. Minimum Go version and platform support changes
|
||||
also require an explicit compatibility assessment.
|
||||
|
||||
### Platform support
|
||||
|
||||
Linux is the release-validated platform. macOS has best-effort support: keeping
|
||||
it working remains desirable, but neither native macOS validation nor Darwin
|
||||
cross-builds are release gates. Missing macOS evidence or a macOS-specific failure
|
||||
does not by itself block a release. Voluntary checks and fixes are welcome; there
|
||||
is no guarantee that each release has been validated on macOS.
|
||||
|
||||
The [release procedure](../release.md#release-identity-and-support) owns target
|
||||
architectures and required Linux checks. This support change applies to upcoming
|
||||
releases; historical release notes and their validation records remain unchanged.
|
||||
The v0.3.0 release note must explain the change in Compatibility, with version
|
||||
selection following this policy's existing compatibility rules.
|
||||
|
||||
## Release Model
|
||||
|
||||
A release consists of:
|
||||
@@ -60,6 +75,10 @@ responsibility is documented. Automation must not silently change the source
|
||||
candidate, move a published tag, replace a published deliverable, or repair a
|
||||
release in place.
|
||||
|
||||
A successful push of the intended annotated tag completes the source-only release.
|
||||
Never wait for CI or use its status as a readiness or completion gate. Optional
|
||||
post-publication checks must not delay reporting success.
|
||||
|
||||
## Versioning And Compatibility
|
||||
|
||||
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
|
||||
@@ -118,38 +137,28 @@ current documentation or defer required documentation changes.
|
||||
|
||||
## Candidate Preparation And Validation
|
||||
|
||||
Each repository must provide one documented release-check command or script
|
||||
that exercises the complete set of automated source-candidate gates. The
|
||||
repository-specific procedure must state all additional manual gates.
|
||||
ConfigKit provides one local release checker. Run it once against the final
|
||||
committed candidate using one maintained patch of the minimum supported Go line
|
||||
on Linux. It checks module hygiene, formatting and whitespace, the full offline
|
||||
suite with the race detector, static analysis, and CGO-disabled Linux amd64/arm64
|
||||
builds. Examples, documentation links, consumer fixtures, and package boundaries
|
||||
are covered by that suite, not separate repeated runs. Native package compilation
|
||||
is covered by the test run. A second toolchain is optional and non-gating.
|
||||
|
||||
The required gates are:
|
||||
Commit the release note and associated changes before release validation. Record
|
||||
the exact validated commit; revalidate if the candidate changes. Do not require
|
||||
both pre-commit and post-commit full checker runs for unchanged content.
|
||||
Do not publish uncommitted or unvalidated content. Local check failures still
|
||||
block publication. Skipped test cases are reported, not separate human sign-off
|
||||
requirements.
|
||||
|
||||
- dependency or module hygiene appropriate to the language and ecosystem;
|
||||
- the complete deterministic, offline default test suite;
|
||||
- race, concurrency, or equivalent safety checks where the implementation can
|
||||
be meaningfully checked for those risks;
|
||||
- static analysis and linting used by the repository;
|
||||
- production builds for every supported target that can be built in the
|
||||
release environment;
|
||||
- formatting and whitespace checks;
|
||||
- validation of maintained examples, configuration, schemas, generated
|
||||
sources, and documentation commands where applicable;
|
||||
- review of changed local documentation links;
|
||||
- removal of obsolete roadmap documents under `docs/roadmap/` that describe
|
||||
completed work included in the release;
|
||||
- review for unintended files, generated output, credentials, private data,
|
||||
and unrelated changes; and
|
||||
- any additional security, compatibility, migration, packaging, or platform
|
||||
checks required by the product.
|
||||
|
||||
Unavailable tooling or an inapplicable check must be addressed explicitly in
|
||||
the adopted policy or release procedure; it must not disappear silently from
|
||||
the candidate gates.
|
||||
|
||||
Run the complete checker while preparing the candidate. Commit the release
|
||||
note and all associated code, tests, documentation, and configuration, then
|
||||
run the complete checker again against the exact commit intended for release.
|
||||
Do not publish a release from uncommitted or unvalidated content.
|
||||
Compatibility review, release-note accuracy, removal of completed roadmaps, and
|
||||
review for unintended files, credentials, private data, and unrelated changes
|
||||
are ordinary preparation. The executing agent may perform them without routine
|
||||
human approval or separate manual evidence records. Ask the maintainer only for
|
||||
genuine unresolved decisions or missing authority. A clean existing checkout is
|
||||
sufficient; a dedicated clone is optional. Dependency and toolchain update
|
||||
research belongs to maintenance or relevant changes, not every release.
|
||||
|
||||
## Publication Guards
|
||||
|
||||
@@ -164,7 +173,8 @@ verify all of the following:
|
||||
- the authoritative remote branch points to that same commit;
|
||||
- the matching release note exists in that commit and has the required title
|
||||
and sections;
|
||||
- all required approvals and automated gates succeeded for that commit; and
|
||||
- release publication is authorized and the local automated gates succeeded for
|
||||
that commit; and
|
||||
- no local or remote tag already uses the selected version.
|
||||
|
||||
Create the version tag against the guarded commit and verify its target before
|
||||
@@ -205,21 +215,36 @@ order:
|
||||
1. maintainer prerequisites and required access;
|
||||
2. version selection and compatibility review;
|
||||
3. release-note creation;
|
||||
4. candidate preparation and the complete validation command;
|
||||
5. candidate commit, push, and exact commit recording;
|
||||
4. candidate preparation and commit;
|
||||
5. one local validation pass, exact candidate recording, and push;
|
||||
6. publication guards;
|
||||
7. exact tag and deliverable publication commands;
|
||||
8. authoritative remote and consumer-facing verification; and
|
||||
8. completion on successful tag push and optional post-publication checks; and
|
||||
9. failure handling and correction.
|
||||
|
||||
The procedure should use copyable, fail-fast commands where practical. It must
|
||||
name the primary branch, authoritative remote, tag form, published
|
||||
deliverables, supported build targets, release automation responsibilities,
|
||||
and consumer verification path. It must link to this policy instead of
|
||||
and optional consumer verification path. It must link to this policy instead of
|
||||
restating or redefining its requirements.
|
||||
|
||||
## Policy Exceptions
|
||||
|
||||
Any exception to this policy must be explicitly documented in this section,
|
||||
including the repository constraint requiring it, its rationale, scope, and
|
||||
compensating controls. Customizing release mechanics must not silently weaken
|
||||
the policy's integrity, validation, documentation, verification, or immutability
|
||||
requirements.
|
||||
|
||||
For ConfigKit's source-only releases, the maintainer accepts successful publication
|
||||
of the intended tag as sufficient completion evidence. This is an explicit
|
||||
exception to the shared standard's mandatory post-publication verification:
|
||||
authoritative tag read-back and fresh consumer retrieval are optional and
|
||||
non-gating. CI status and downstream retrieval availability must not delay release
|
||||
completion. Compensating controls are local validation of the exact candidate,
|
||||
automated pre-publication guards, matching remote main, verification of the local
|
||||
annotated tag target, explicit ref pushes, and immutable published versions.
|
||||
|
||||
For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to
|
||||
`configkit` as an error-correcting point release because there are no current
|
||||
consumers. This intentionally changes the v0.1.0 configuration contract without a
|
||||
@@ -233,5 +258,5 @@ only. The final candidate still requires the complete Linux release checker.
|
||||
This exception waives only repeating the macOS check after those documentation
|
||||
changes; it does not waive other publication or verification gates.
|
||||
|
||||
Candidate selection, publication, and post-publication verification remain
|
||||
mandatory steps in the [release procedure](../release.md).
|
||||
Candidate selection and guarded publication remain mandatory; post-publication
|
||||
verification is optional in the [release procedure](../release.md).
|
||||
|
||||
176
docs/release.md
176
docs/release.md
@@ -12,18 +12,24 @@ binary, container image, generated package, or separate registry publication.
|
||||
- Authoritative remote: `origin`, expected to fetch from
|
||||
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
||||
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported
|
||||
- Minimum Go language/toolchain line: Go 1.25, tested with a supported patched
|
||||
Go 1.25 toolchain and one newer supported toolchain
|
||||
- Build targets: Linux and macOS on amd64 and arm64
|
||||
- Minimum Go language/toolchain line: Go 1.25, release-validated with one maintained
|
||||
patch of that line; newer-toolchain testing is optional and non-gating
|
||||
- Release-gated build targets: Linux on amd64 and arm64
|
||||
- Best-effort targets: macOS on amd64 and arm64, under the
|
||||
[platform support policy](policy/release.md#platform-support)
|
||||
|
||||
The automated checker cross-builds all four targets. Before release, the maintainer
|
||||
must also obtain native test, race, symlink, and permission-fixture evidence on Linux
|
||||
and macOS; cross-compilation is not native execution evidence.
|
||||
The local Linux checker includes native race tests and CGO-disabled cross-builds.
|
||||
Its output reports skipped filesystem cases; no separate evidence record or
|
||||
human sign-off is required. Native macOS checks and Darwin cross-builds are
|
||||
optional and do not gate publication.
|
||||
|
||||
CI is never a release gate. Do not wait for it, poll its status, or delay reporting
|
||||
completion because it is pending, unavailable, or failed.
|
||||
|
||||
## 1. Prepare access and tools
|
||||
|
||||
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash 3.2 or newer,
|
||||
and two supported Go toolchains available. The checker supports the stock macOS
|
||||
The release maintainer owns this procedure. Use a clean checkout (a dedicated clone is optional) with Git, Bash 3.2 or newer,
|
||||
a maintained Go 1.25 patch, and the compiler needed for native race tests. The checker supports the stock macOS
|
||||
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module
|
||||
access through the normal credential manager before starting. Keep tokens, passwords,
|
||||
SSH material, and environment dumps outside the repository and command transcript.
|
||||
@@ -34,23 +40,24 @@ git remote get-url origin
|
||||
git ls-remote origin HEAD
|
||||
```
|
||||
|
||||
The remote URL must equal the authoritative URL above. Select the latest supported
|
||||
patch in the Go 1.25 line and a newer supported toolchain. The values below are the
|
||||
toolchains used while this runbook was introduced; update them to supported patch
|
||||
releases during candidate preparation and record the actual versions in the release
|
||||
note or validation record.
|
||||
The remote URL must equal the authoritative URL above. Acquire the selected
|
||||
toolchain and dependencies explicitly. For example, with the maintained patch
|
||||
used when this procedure was simplified:
|
||||
|
||||
```sh
|
||||
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.5
|
||||
export CONFIGKIT_NEWER_TOOLCHAIN=go1.26.7
|
||||
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14
|
||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
|
||||
GOTOOLCHAIN="$CONFIGKIT_NEWER_TOOLCHAIN" go version
|
||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
|
||||
GOTOOLCHAIN="$CONFIGKIT_NEWER_TOOLCHAIN" go mod download
|
||||
```
|
||||
|
||||
Those commands explicitly acquire toolchains and dependencies. The checker itself
|
||||
sets `GOPROXY=off` and fails if its required cache is incomplete.
|
||||
Keep toolchains maintained, but do not require latest-version discovery for each
|
||||
release. The checker sets `GOPROXY=off` and fails if the cache is incomplete.
|
||||
Toolchain and YAML-dependency update research belongs to periodic maintenance or
|
||||
relevant dependency changes. No renewed approval of an unchanged pin is required.
|
||||
|
||||
Use Bash with `set -euo pipefail` for the command blocks below so any failed gate
|
||||
stops the sequence. Publication requires user authorization; the agent policy's
|
||||
local-commit authorization is not permission to push or release.
|
||||
|
||||
## 2. Select the version and review compatibility
|
||||
|
||||
@@ -60,7 +67,7 @@ For the first release, review the whole public surface and current contracts.
|
||||
```sh
|
||||
release_version=v0.1.0
|
||||
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
git show-ref --verify --quiet "refs/tags/$release_version" && exit 1 || :
|
||||
if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
||||
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
||||
exit 1
|
||||
@@ -76,16 +83,9 @@ intentional incompatible minor-release change must be explained with consumer ac
|
||||
patch releases must remain compatible within their minor line. Review the maintained
|
||||
consumer examples and the migration guide even when signatures did not change.
|
||||
|
||||
Reassess `go.yaml.in/yaml/v4` rather than using `@latest` implicitly:
|
||||
|
||||
```sh
|
||||
go list -m -versions go.yaml.in/yaml/v4
|
||||
go list -m -json go.yaml.in/yaml/v4@latest
|
||||
```
|
||||
|
||||
If changing the explicit pin, make that a reviewed dependency change and rerun the
|
||||
complete checker, especially parser policy, limits, diagnostics, and rendering tests.
|
||||
If retaining a prerelease pin, record that decision in Compatibility.
|
||||
When dependencies change, review parser behavior and migration impact as part of
|
||||
that change. Keep Compatibility accurate about the dependency pin, but do not
|
||||
perform an external version survey for every patch release.
|
||||
|
||||
## 3. Write the release note
|
||||
|
||||
@@ -97,55 +97,46 @@ release note until there is a real candidate and reviewed content.
|
||||
|
||||
## 4. Validate the source candidate
|
||||
|
||||
Run the complete checker from the repository root after dependencies have been
|
||||
acquired. It validates module manifests in a disposable copy, both supported Go
|
||||
toolchains, offline default tests, the native race detector, vet, native builds,
|
||||
CGO-disabled cross-builds, formatting and whitespace (including untracked files),
|
||||
examples, package isolation, and local documentation links.
|
||||
Before release validation, finish compatibility review, inspect the diff and file
|
||||
list for unintended content or credentials, retire completed roadmaps, repair
|
||||
links, and review the release note. These are ordinary preparation tasks the
|
||||
executing agent may perform, not separate human approvals or evidence records.
|
||||
Ask the maintainer only for an unresolved product decision or missing authority.
|
||||
|
||||
Commit the candidate, staging only its intended changes, then run the checker
|
||||
once against that clean commit. Do not run both everyday and release checkers to
|
||||
repeat the same gates.
|
||||
|
||||
```sh
|
||||
./scripts/release-check.sh
|
||||
```
|
||||
|
||||
`go vet` is the repository's static-analysis baseline. No additional linter is
|
||||
claimed. Links to current ConfigKit files and headings are strict. Recorded links to
|
||||
sibling consumer source are checked when those checkouts exist and are otherwise
|
||||
inapplicable in an isolated clone; their immutable revisions remain recorded in the
|
||||
extraction inventory.
|
||||
|
||||
Complete these manual gates and record their evidence with the candidate:
|
||||
|
||||
- The maintainer reviews compatibility and downstream migration impact against
|
||||
`docs/config.md`, Go API comments, and the consumer guide.
|
||||
- The maintainer inspects `git status --short`, `git diff`, and the candidate file
|
||||
list for credentials, private data, generated output, unintended files, and
|
||||
unrelated changes.
|
||||
- The maintainer records successful native checker runs on Linux and macOS, including
|
||||
filesystem capability tests. If either host is unavailable, release remains
|
||||
blocked unless platform support is formally narrowed in current documentation.
|
||||
- The maintainer removes completed, obsolete documents under `docs/roadmap/`, repairs
|
||||
inbound links, and reruns the checker. ADR rationale remains historical.
|
||||
- The maintainer reviews the exact version-matched release note for accuracy and
|
||||
confirms it describes only deliverables that will actually be published.
|
||||
|
||||
## 5. Commit, push, and record the candidate
|
||||
|
||||
Commit all candidate source, contract, example, roadmap-retirement, and release-note
|
||||
changes, rerun the checker against that clean commit, then push only `main` and record
|
||||
the exact commit in Git metadata local to the clone:
|
||||
|
||||
```sh
|
||||
git add .
|
||||
git status --short
|
||||
# Stage the specific files belonging to the candidate, then review:
|
||||
git diff --staged
|
||||
git commit --message "Prepare ConfigKit $release_version"
|
||||
./scripts/release-check.sh
|
||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||
git push origin HEAD:refs/heads/main
|
||||
git rev-parse HEAD | tee .git/configkit-release-candidate
|
||||
candidate_commit=$(cat .git/configkit-release-candidate)
|
||||
candidate_commit=$(git rev-parse HEAD)
|
||||
./scripts/release-check.sh
|
||||
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||
printf '%s\n' "$candidate_commit" > "$(git rev-parse --git-path configkit-release-candidate)"
|
||||
```
|
||||
|
||||
If review changes the source after this point, create and validate a new candidate;
|
||||
do not reuse the recorded commit.
|
||||
If the final candidate is already committed, omit the staging and commit commands;
|
||||
do not create an empty commit. The checker validates module manifests in a
|
||||
disposable copy, formatting, whitespace, the full offline suite once with the race
|
||||
detector and verbose skip reporting, vet once, and CGO-disabled Linux amd64/arm64
|
||||
builds. The suite includes examples, consumer fixtures, documentation links, and
|
||||
package boundaries; no separate reruns are required. No additional linter is
|
||||
claimed. Sibling-consumer links are checked when those checkouts exist and are
|
||||
otherwise inapplicable in an isolated checkout.
|
||||
|
||||
A changed candidate requires validation again. Do not reuse a recorded candidate
|
||||
from an older revision. The record is written only after successful local checks.
|
||||
|
||||
## 5. Prepare publication
|
||||
|
||||
The following guarded sequence pushes only `main` and the selected tag. Local
|
||||
validation failures block it; CI does not. Keep the selected `release_version`
|
||||
from step 2 in the same shell. If starting a new shell, set it explicitly again.
|
||||
|
||||
## 6. Run publication guards
|
||||
|
||||
@@ -153,14 +144,12 @@ Immediately before tagging, fetch the authoritative branch and tags, restore the
|
||||
recorded variables if this is a new shell, and execute every guard:
|
||||
|
||||
```sh
|
||||
release_version=v0.1.0
|
||||
candidate_commit=$(cat .git/configkit-release-candidate)
|
||||
candidate_commit=$(cat "$(git rev-parse --git-path configkit-release-candidate)")
|
||||
git fetch --prune origin main
|
||||
git fetch --tags origin
|
||||
test "$(git branch --show-current)" = main
|
||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||
test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit"
|
||||
test "$(git remote get-url origin)" = 'https://gitea.maximumdirect.net/eric/ConfigKit.git'
|
||||
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
test -f "docs/releases/$release_version.md"
|
||||
@@ -168,17 +157,22 @@ test "$(sed -n '1p' "docs/releases/$release_version.md")" = "# ConfigKit $releas
|
||||
for heading in Summary Compatibility Upgrade Changes; do
|
||||
grep -Fxq "## $heading" "docs/releases/$release_version.md"
|
||||
done
|
||||
git show-ref --verify --quiet "refs/tags/$release_version" && exit 1 || :
|
||||
if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
||||
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
||||
exit 1
|
||||
else
|
||||
test $? -eq 2
|
||||
fi
|
||||
git push origin HEAD:refs/heads/main
|
||||
git fetch origin main
|
||||
test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit"
|
||||
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||
```
|
||||
|
||||
Confirm the recorded automated and manual approvals apply to exactly
|
||||
`$candidate_commit`. A successful older run is not evidence for a changed commit.
|
||||
The recorded local validation must apply to exactly `$candidate_commit`. No
|
||||
separate human sign-off, CI status, or manual evidence document is required.
|
||||
|
||||
## 7. Tag and publish the exact ref
|
||||
|
||||
@@ -191,15 +185,17 @@ test "$(git rev-list -n 1 "$release_version")" = "$candidate_commit"
|
||||
git push origin "refs/tags/$release_version:refs/tags/$release_version"
|
||||
```
|
||||
|
||||
The pushed tag completes the declared source-only publication. No Gitea release
|
||||
object or binary upload is required.
|
||||
A successful push of the intended tag completes the release. Report success now;
|
||||
do not wait for CI, module propagation, or any post-publication check. No Gitea
|
||||
release object or binary upload is required.
|
||||
|
||||
## 8. Verify publication and clean consumer retrieval
|
||||
## 8. Optional post-publication checks
|
||||
|
||||
Verify the authoritative tag and then retrieve that exact version into a new module
|
||||
without a local `replace` directive. Private-host credentials must already be
|
||||
configured. Use direct origin retrieval unless an explicitly approved internal Go
|
||||
proxy is the publication channel.
|
||||
Only when useful or requested, read back the remote tag or retrieve the exact
|
||||
version into a new module without a local `replace` directive. These checks are
|
||||
optional and non-gating; their failure or unavailability does not delay release
|
||||
completion. Do not poll CI or wait for module propagation. Private-host credentials
|
||||
must already be configured. Example direct-origin verification:
|
||||
|
||||
```sh
|
||||
remote_tag_commit=$(git ls-remote origin "refs/tags/$release_version^{}" | awk '{print $1}')
|
||||
@@ -226,13 +222,13 @@ EOF
|
||||
rm -rf -- "$consumer_dir"
|
||||
```
|
||||
|
||||
The final `go list` output must show the exact module path and `$release_version`.
|
||||
Record the result with the release evidence.
|
||||
If performed, the final `go list` output should show the exact module path and
|
||||
`$release_version`. Report optional verification separately from publication success.
|
||||
|
||||
## 9. Handle failures without replacement
|
||||
|
||||
Before publication, fix the candidate, rerun every gate, commit, push, and record a
|
||||
new exact candidate. An unpublished local tag may be deleted only after verifying it
|
||||
Before publication, fix and commit the candidate, validate it, record the new
|
||||
exact candidate, and repeat the guarded publication sequence. An unpublished local tag may be deleted only after verifying it
|
||||
was never pushed or used by another release channel.
|
||||
|
||||
After publication, never move, delete, or recreate the tag and never replace its
|
||||
|
||||
70
docs/releases/v0.3.0.md
Normal file
70
docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# ConfigKit v0.3.0
|
||||
|
||||
This release adds source provenance during application resolution, separates the
|
||||
public API from its internal engine, and moves macOS to best-effort support.
|
||||
|
||||
## Summary
|
||||
|
||||
Resolvers can query fixed entry-state metadata and already-acquired sources using
|
||||
`Resolution.InputInfo` and `Resolution.Source`. This supports application-owned
|
||||
decisions such as resolving relative paths against their declaring configuration
|
||||
files. See the [resolution contract](../config.md#recorded-application-resolution)
|
||||
and the maintained [source-aware path example](../consumers/README.md#source-aware-application-path-policy).
|
||||
|
||||
The root `configkit` package now owns public types and explicit wrappers around a
|
||||
single private engine. Internal changes no longer automatically expand public
|
||||
method sets through shared type aliases. See the [component overview](../internal/overview.md)
|
||||
and [facade decision](../adr/0006-public-facade.md).
|
||||
|
||||
## Compatibility
|
||||
|
||||
Existing consumer imports, exported API signatures, configuration syntax,
|
||||
precedence, secret handling, callback lifetime, and ownership behavior are
|
||||
preserved. The two resolution query methods are additive and available only
|
||||
during an active resolution callback; returned records remain detached. The
|
||||
library supplies provenance, not a new filesystem normalization policy.
|
||||
|
||||
macOS on amd64 and arm64 is now best-effort rather than release-validated.
|
||||
Neither native macOS evidence nor Darwin cross-builds are required for publication,
|
||||
and macOS-specific failures do not by themselves block a release. Linux on amd64
|
||||
and arm64 retains release-gated builds and native Linux validation. See the
|
||||
[platform support policy](../policy/release.md#platform-support).
|
||||
|
||||
The module still requires Go 1.25 or newer. The private YAML dependency remains
|
||||
pinned to `go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4
|
||||
release during preparation. It is still a prerelease dependency, isolated behind
|
||||
the existing private parser boundary; no dependency upgrade is included.
|
||||
|
||||
Linux candidate validation uses Go 1.25.14 and Go 1.26.8, including the complete
|
||||
release checker, race tests, consumer examples, filesystem fixtures, and Linux
|
||||
cross-builds. No native macOS validation is claimed for this candidate.
|
||||
|
||||
## Upgrade
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.3.0
|
||||
```
|
||||
|
||||
No code or configuration migration is required for the facade refactor. Adopt the
|
||||
new provenance queries only if your resolver needs them. Applications retain
|
||||
ownership of relative-path bases and fallback/error behavior.
|
||||
|
||||
macOS consumers should account for the reduced validation commitment and perform
|
||||
their own application checks when upgrading if macOS behavior is important to
|
||||
their deployment.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added callback-scoped, fixed entry metadata and source lookup without additional
|
||||
filesystem access or credential resolution.
|
||||
- Added a maintained Notarius-shaped path-normalization example with provenance,
|
||||
frozen effective output, and explanation coverage.
|
||||
- Moved loading, schema, resolution, provenance/history, secret, and rendering
|
||||
machinery into one internal engine while keeping public named types root-owned.
|
||||
- Added exact-type secret and callback adapters, with regression coverage for
|
||||
missing version checkers and nil/zero closed-context failures.
|
||||
- Added type-aware public-boundary checks covering aliases, collections, generic
|
||||
constraints, and exported method sets; restored public reference documentation.
|
||||
- Removed macOS release gates and retained required Linux validation.
|
||||
- Retired completed feature, implementation, and remediation documents to Git
|
||||
history and repaired navigation to current contract and component owners.
|
||||
43
docs/releases/v0.3.1.md
Normal file
43
docs/releases/v0.3.1.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# ConfigKit v0.3.1
|
||||
|
||||
This release fixes secrets-directory bootstrap when the directory contains Git
|
||||
metadata or other dot-prefixed entries.
|
||||
|
||||
## Summary
|
||||
|
||||
`LoadSecretsDir` now ignores dot-prefixed files, directories, and symlinks,
|
||||
including `.git`, `.gitignore`, and `.DS_Store`. These entries no longer prevent
|
||||
otherwise valid secrets from loading and are omitted from the bootstrap report.
|
||||
See the [bootstrap contract](../config.md#explicit-secrets-directory-bootstrap).
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is a backward-compatible bug fix for previously rejected directory contents.
|
||||
There are no Go API signature changes or migrations for previously accepted
|
||||
inputs. Validation of non-hidden entries, existing-environment precedence, and
|
||||
the existing enumeration and content limits remain unchanged. Hidden entries
|
||||
still count toward the directory enumeration limit.
|
||||
|
||||
The minimum Go version remains 1.25. Linux remains release-validated and macOS
|
||||
remains best-effort. The explicitly pinned YAML dependency remains
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`; reassessment found no newer published version.
|
||||
Retaining this prerelease pin avoids unrelated parser changes in this patch.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Update the module dependency and rebuild the consuming application:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.3.1
|
||||
```
|
||||
|
||||
No configuration migration is required. A secrets directory can retain its
|
||||
dot-prefixed metadata without treating those entries as secret candidates.
|
||||
|
||||
## Changes
|
||||
|
||||
- Filter hidden entries before filename and metadata validation and reporting.
|
||||
- Cover hidden-only and mixed directories, including Git metadata and a dangling
|
||||
hidden symlink, with regression tests.
|
||||
- Update public API comments and the canonical bootstrap contract.
|
||||
- Stop tracking local codebase-memory tooling artifacts and ignore their directory.
|
||||
56
docs/releases/v0.4.0.md
Normal file
56
docs/releases/v0.4.0.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# ConfigKit v0.4.0
|
||||
|
||||
This release renames selected configuration profiles to overlays, intentionally
|
||||
breaking the previous naming contract without compatibility aliases or fallback.
|
||||
|
||||
## Summary
|
||||
|
||||
Selected configuration now uses `Options.Overlay` and `overlays/<name>.yml`.
|
||||
Selection, merge rules, input precedence, and lifecycle behavior are unchanged.
|
||||
See the [selected-overlay contract](../config.md#selected-overlays).
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is a breaking minor release before v1.0.0. Four surfaces change:
|
||||
|
||||
- Go API: `Options.Profile` becomes `Options.Overlay`, `LayerProfile` becomes
|
||||
`LayerOverlay`, and all seven `CodeProfile*` constants become `CodeOverlay*`.
|
||||
- Selected files: `profiles/<name>.yml` becomes `overlays/<name>.yml`; there is
|
||||
no legacy-directory fallback.
|
||||
- Provenance: the layer string changes from `profile` to `overlay`.
|
||||
- Structured diagnostics: the `profile.*` code prefix becomes `overlay.*`.
|
||||
|
||||
The old Go identifiers are removed. Application-owned concepts such as
|
||||
`ModelProfile` and `model_profile` are unchanged. Ordinary explicit imports from
|
||||
a directory named `profiles` remain valid base inputs.
|
||||
|
||||
The minimum Go version remains 1.25. Linux remains release-validated and macOS
|
||||
remains best-effort. The YAML dependency remains pinned to
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`; this release does not change parser dependencies.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Update the dependency:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.4.0
|
||||
```
|
||||
|
||||
Rename the affected Go identifiers, move selected files into `overlays/`, update
|
||||
any handling of provenance-layer and diagnostic-code strings, and rebuild.
|
||||
The maintained example helper is now `LoadNarratioOverlay` rather than
|
||||
`LoadNarratioProfile`. Application CLI flag names remain application-owned;
|
||||
ConfigKit does not require renaming domain-specific profile concepts.
|
||||
See the [migration guide](../consumers/README.md#selected-overlay-migration).
|
||||
|
||||
## Changes
|
||||
|
||||
- Rename the selected feature consistently across the public facade, internal
|
||||
engine, diagnostics, source labels, examples, and current documentation.
|
||||
- Add regression coverage for the new directory, absence of legacy fallback,
|
||||
public values, explanations, and preservation of application profile fields.
|
||||
- Align contributor policies with the current shared standard.
|
||||
- Simplify release validation to one local minimum-toolchain check. CI is not a
|
||||
release gate; successful publication of the intended tag completes the
|
||||
source-only release. See the [release procedure](../release.md).
|
||||
- Retire completed overlay and release-workflow planning documents to Git history.
|
||||
@@ -15,7 +15,7 @@ The initial release deliberately excludes:
|
||||
|
||||
- automatic `conf.d` discovery;
|
||||
- remote imports, arbitrary providers, and additional configuration formats;
|
||||
- custom merge strategies, sequence merging, reset operators, and profile inheritance;
|
||||
- custom merge strategies, sequence merging, reset operators, and overlay inheritance;
|
||||
- parser-specific or custom structural-decoding extension APIs;
|
||||
- generic dynamic-subtree conversion and module-registry APIs;
|
||||
- automatic schema migrations;
|
||||
|
||||
@@ -4,6 +4,7 @@ package consumers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -28,14 +29,14 @@ type NarratioConfig struct {
|
||||
|
||||
// LoadNarratio loads the maintained import fixture with Narratio-shaped rules.
|
||||
func LoadNarratio(path string) (*configkit.Result[NarratioConfig], error) {
|
||||
return LoadNarratioProfile(path, "")
|
||||
return LoadNarratioOverlay(path, "")
|
||||
}
|
||||
|
||||
// LoadNarratioProfile selects a ConfigKit configuration overlay independently
|
||||
// LoadNarratioOverlay selects a ConfigKit configuration overlay independently
|
||||
// from the application's ModelProfile field.
|
||||
func LoadNarratioProfile(path, profile string) (*configkit.Result[NarratioConfig], error) {
|
||||
func LoadNarratioOverlay(path, overlay string) (*configkit.Result[NarratioConfig], error) {
|
||||
return configkit.Load(configkit.File(path), configkit.Options[NarratioConfig]{
|
||||
Imports: true, Profile: profile,
|
||||
Imports: true, Overlay: overlay,
|
||||
Definition: configkit.Definition[NarratioConfig]{
|
||||
Defaults: func() NarratioConfig { return NarratioConfig{PollInterval: time.Minute} },
|
||||
Resolve: resolveNarratio,
|
||||
@@ -146,6 +147,69 @@ func NotariusWindow(configuration NotariusConfig, binding int) (int, bool) {
|
||||
return int(value), true
|
||||
}
|
||||
|
||||
// NotariusPathConfig demonstrates application-owned interpretation of a
|
||||
// path-valued configuration field. ConfigKit supplies provenance; this example
|
||||
// selects its own relative-path bases.
|
||||
type NotariusPathConfig struct {
|
||||
Reference string `yaml:"reference"`
|
||||
Sensitive string `yaml:"sensitive"`
|
||||
}
|
||||
|
||||
// LoadNotariusPaths loads the path-bearing Notarius-shaped example. InvocationDir
|
||||
// is the application-supplied base for relative CLI overrides; it is never read
|
||||
// from the process working directory.
|
||||
func LoadNotariusPaths(path, invocationDir string, overrides ...configkit.Override) (*configkit.Result[NotariusPathConfig], error) {
|
||||
return configkit.Load(configkit.File(path), configkit.Options[NotariusPathConfig]{
|
||||
Imports: true, Overrides: overrides,
|
||||
Definition: configkit.Definition[NotariusPathConfig]{
|
||||
Defaults: func() NotariusPathConfig { return NotariusPathConfig{Reference: "defaults/reference.txt"} },
|
||||
Resolve: func(configuration *NotariusPathConfig, resolution *configkit.Resolution) error {
|
||||
resolveNotariusReference(configuration, resolution, invocationDir)
|
||||
return nil
|
||||
},
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/sensitive" },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func resolveNotariusReference(configuration *NotariusPathConfig, resolution *configkit.Resolution, invocationDir string) {
|
||||
path := configkit.Keys("reference")
|
||||
if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) {
|
||||
return
|
||||
}
|
||||
info, found := resolution.InputInfo(path)
|
||||
if !found {
|
||||
resolution.Warning(path, "relative reference has no entry provenance")
|
||||
return
|
||||
}
|
||||
|
||||
base := ""
|
||||
if info.Origin.Layer == configkit.LayerCLI {
|
||||
base = invocationDir
|
||||
if base == "" {
|
||||
resolution.Warning(path, "relative CLI reference has no invocation directory")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
sourceID := "source-1" // ConfigKit's primary-source identity convention.
|
||||
if info.Origin.Layer != configkit.LayerDefault {
|
||||
if len(info.Origin.Locations) != 1 {
|
||||
resolution.Warning(path, "relative reference has no unambiguous declaring source")
|
||||
return
|
||||
}
|
||||
sourceID = info.Origin.Locations[0].SourceID
|
||||
}
|
||||
source, exists := resolution.Source(sourceID)
|
||||
if !exists || source.Path == "" {
|
||||
resolution.Warning(path, "relative reference has no declaring file")
|
||||
return
|
||||
}
|
||||
base = filepath.Dir(source.Path)
|
||||
}
|
||||
configkit.Normalize(resolution, path, &configuration.Reference,
|
||||
filepath.Join(base, configuration.Reference), "resolved relative reference from entry source")
|
||||
}
|
||||
|
||||
// ModuleOptions is a WeatherReporter-shaped union of known module option fields.
|
||||
type ModuleOptions struct {
|
||||
WindowUnits *int `yaml:"window_units"`
|
||||
|
||||
@@ -29,6 +29,12 @@ func Example_notarius() {
|
||||
// Output: 4 summary 12 true <nil>
|
||||
}
|
||||
|
||||
func Example_notariusPaths() {
|
||||
result, err := consumers.LoadNotariusPaths(fixture("notarius-paths", "main.yml"), "/invocation")
|
||||
fmt.Println(filepath.Base(result.Config.Reference), err)
|
||||
// Output: imported.txt <nil>
|
||||
}
|
||||
|
||||
func Example_weatherReporter() {
|
||||
defaults, defaultsErr := consumers.LoadWeather(configkit.NoSource())
|
||||
result, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
|
||||
@@ -86,10 +92,13 @@ func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
|
||||
if origin, ok := narratio.Metadata().Lookup(configkit.Keys("artifacts", "transcript", "output")); !ok || origin.Origin.Layer != configkit.LayerResolution {
|
||||
t.Fatalf("artifact origin = %#v, %v", origin, ok)
|
||||
}
|
||||
profiled, err := consumers.LoadNarratioProfile(fixture("narratio", "pipeline.yml"), "testing")
|
||||
if err != nil || profiled.Config.PollInterval.String() != "2m0s" ||
|
||||
profiled.Config.ModelProfile != "concise" || len(profiled.Sources()) != 3 {
|
||||
t.Fatalf("configuration/application profiles = %#v, %v", profiled, err)
|
||||
overlaid, err := consumers.LoadNarratioOverlay(fixture("narratio", "pipeline.yml"), "testing")
|
||||
if err != nil || overlaid.Config.PollInterval.String() != "2m0s" ||
|
||||
overlaid.Config.ModelProfile != "concise" || len(overlaid.Sources()) != 3 {
|
||||
t.Fatalf("configuration overlay and application model profile = %#v, %v", overlaid, err)
|
||||
}
|
||||
if source := overlaid.Sources()[2]; source.Label != "overlays/testing.yml" || source.Layer != configkit.LayerOverlay {
|
||||
t.Fatalf("Narratio overlay source = %#v", source)
|
||||
}
|
||||
|
||||
notarius, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
||||
@@ -194,7 +203,7 @@ func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
|
||||
if len(diagnostics) == 0 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if strings.Contains(diagnostics[0].Message, "wrong-before-profile") {
|
||||
if strings.Contains(diagnostics[0].Message, "wrong-before-overlay") {
|
||||
t.Fatalf("diagnostic exposed raw parser input: %#v", diagnostics[0])
|
||||
}
|
||||
})
|
||||
@@ -240,12 +249,89 @@ func TestWeatherLoadedAndConstructedRulesAgreeWithoutMutation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusPathExampleUsesEntryProvenance(t *testing.T) {
|
||||
main := fixture("notarius-paths", "main.yml")
|
||||
declared := fixture("notarius-paths", "declarations", "path.yml")
|
||||
declaredCanonical := canonicalFixture(t, declared)
|
||||
wantImported := filepath.Join(filepath.Dir(declaredCanonical), "..", "assets", "imported.txt")
|
||||
result, err := consumers.LoadNotariusPaths(main, filepath.Join(t.TempDir(), "invocation"))
|
||||
if err != nil || result.Config.Reference != wantImported {
|
||||
t.Fatalf("imported reference = %#v, %v", result, err)
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), wantImported) ||
|
||||
strings.Contains(effective.String(), "PATH_REFERENCE_SECRET_CANARY") {
|
||||
t.Fatalf("effective output = %q, %v", effective.String(), err)
|
||||
}
|
||||
explanation, found := result.Explain(configkit.Keys("reference"))
|
||||
if !found || len(explanation.History) == 0 || explanation.History[len(explanation.History)-1].Before.YAML != "../assets/imported.txt\n" ||
|
||||
len(explanation.History[len(explanation.History)-1].Reasons) != 1 || explanation.History[len(explanation.History)-1].Reasons[0] != "resolved relative reference from entry source" {
|
||||
t.Fatalf("reference explanation = %#v, %v", explanation, found)
|
||||
}
|
||||
sensitive, found := result.Explain(configkit.Keys("sensitive"))
|
||||
var rendered bytes.Buffer
|
||||
writeErr := result.WriteExplanation(&rendered, configkit.Keys("sensitive"))
|
||||
if !found || !sensitive.Effective.Redacted || writeErr != nil ||
|
||||
strings.Contains(fmt.Sprintf("%#v %s", sensitive, rendered.String()), "PATH_REFERENCE_SECRET_CANARY") {
|
||||
t.Fatalf("sensitive inspection = %#v, %q, %v", sensitive, rendered.String(), writeErr)
|
||||
}
|
||||
|
||||
defaults := fixture("notarius-paths", "defaults.yml")
|
||||
defaultCanonical := canonicalFixture(t, defaults)
|
||||
defaultResult, err := consumers.LoadNotariusPaths(defaults, filepath.Join(t.TempDir(), "invocation"))
|
||||
wantDefault := filepath.Join(filepath.Dir(defaultCanonical), "defaults", "reference.txt")
|
||||
if err != nil || defaultResult.Config.Reference != wantDefault {
|
||||
t.Fatalf("default reference = %#v, %v", defaultResult, err)
|
||||
}
|
||||
|
||||
invocation := filepath.Join(t.TempDir(), "caller")
|
||||
cliResult, err := consumers.LoadNotariusPaths(main, invocation,
|
||||
configkit.Override{Path: configkit.Keys("reference"), Value: "cli/reference.txt", Flag: "--reference"})
|
||||
wantCLI := filepath.Join(invocation, "cli", "reference.txt")
|
||||
if err != nil || cliResult.Config.Reference != wantCLI {
|
||||
t.Fatalf("CLI reference = %#v, %v", cliResult, err)
|
||||
}
|
||||
missingBase, err := consumers.LoadNotariusPaths(main, "",
|
||||
configkit.Override{Path: configkit.Keys("reference"), Value: "cli/reference.txt", Flag: "--reference"})
|
||||
if err != nil || missingBase.Config.Reference != "cli/reference.txt" || len(missingBase.Diagnostics()) != 1 ||
|
||||
missingBase.Diagnostics()[0].Code != configkit.CodeResolveWarning {
|
||||
t.Fatalf("missing CLI base = %#v, %v", missingBase, err)
|
||||
}
|
||||
absolute := filepath.Join(t.TempDir(), "absolute.txt")
|
||||
absoluteResult, err := consumers.LoadNotariusPaths(main, invocation,
|
||||
configkit.Override{Path: configkit.Keys("reference"), Value: absolute, Flag: "--reference"})
|
||||
if err != nil || absoluteResult.Config.Reference != absolute {
|
||||
t.Fatalf("absolute reference = %#v, %v", absoluteResult, err)
|
||||
}
|
||||
|
||||
target := fixture("notarius-paths", "symlink-target.yml")
|
||||
link := filepath.Join(t.TempDir(), "linked-config.yml")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlink fixture unavailable: %v", err)
|
||||
}
|
||||
symlinkResult, err := consumers.LoadNotariusPaths(link, invocation)
|
||||
targetCanonical := canonicalFixture(t, target)
|
||||
wantSymlink := filepath.Join(filepath.Dir(targetCanonical), "..", "assets", "target.txt")
|
||||
if err != nil || symlinkResult.Config.Reference != wantSymlink {
|
||||
t.Fatalf("symlink reference = %#v, %v", symlinkResult, err)
|
||||
}
|
||||
}
|
||||
|
||||
func fixture(parts ...string) string {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
values := append([]string{filepath.Dir(file), "testdata"}, parts...)
|
||||
return filepath.Join(values...)
|
||||
}
|
||||
|
||||
func canonicalFixture(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
canonical, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
func preserveEnvironment(name string) func() {
|
||||
value, present := os.LookupEnv(name)
|
||||
return func() {
|
||||
|
||||
1
examples/consumers/testdata/notarius-paths/declarations/path.yml
vendored
Normal file
1
examples/consumers/testdata/notarius-paths/declarations/path.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
reference: ../assets/imported.txt
|
||||
1
examples/consumers/testdata/notarius-paths/defaults.yml
vendored
Normal file
1
examples/consumers/testdata/notarius-paths/defaults.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||
4
examples/consumers/testdata/notarius-paths/main.yml
vendored
Normal file
4
examples/consumers/testdata/notarius-paths/main.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: declarations/path.yml
|
||||
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||
2
examples/consumers/testdata/notarius-paths/symlink-target.yml
vendored
Normal file
2
examples/consumers/testdata/notarius-paths/symlink-target.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
reference: ../assets/target.txt
|
||||
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||
272
facade_candidate_test.go
Normal file
272
facade_candidate_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
var (
|
||||
_ error = (*configkit.LoadError)(nil)
|
||||
_ fmt.Formatter = configkit.Secret{}
|
||||
_ fmt.Stringer = configkit.Path{}
|
||||
_ json.Marshaler = configkit.Secret{}
|
||||
)
|
||||
|
||||
func TestFacadeValuesRemainRootOwned(t *testing.T) {
|
||||
t.Parallel()
|
||||
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
for _, candidate := range []reflect.Type{
|
||||
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
|
||||
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
|
||||
reflect.TypeFor[configkit.LoadError](), reflect.TypeFor[configkit.Metadata](),
|
||||
reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](),
|
||||
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
|
||||
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](),
|
||||
reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
|
||||
reflect.TypeFor[configkit.Result[struct{}]](),
|
||||
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](),
|
||||
reflect.TypeFor[configkit.SecretReferenceInfo](), reflect.TypeFor[configkit.Explanation](),
|
||||
reflect.TypeFor[configkit.SecretFileResult](), reflect.TypeFor[configkit.SecretsReport](),
|
||||
reflect.TypeFor[configkit.SecretsError](),
|
||||
} {
|
||||
if candidate.PkgPath() != packagePath {
|
||||
t.Fatalf("type %s belongs to %q", candidate, candidate.PkgPath())
|
||||
}
|
||||
}
|
||||
secret := configkit.SecretEnv("SAFE_NAME")
|
||||
formatted := fmt.Sprintf("%#v", secret)
|
||||
encoded, err := json.Marshal(secret)
|
||||
if formatted == "" || err != nil || string(encoded) != `"SAFE_NAME"` {
|
||||
t.Fatalf("secret rendering = %q, %s, %v", formatted, encoded, err)
|
||||
}
|
||||
if got := configkit.Keys("a/b~").Index(0).String(); got != "/a~1b~0/0" {
|
||||
t.Fatalf("path = %q", got)
|
||||
}
|
||||
if got := (&configkit.LoadError{}).Error(); got != "configkit: configuration load failed" {
|
||||
t.Fatalf("zero LoadError = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeInspectionUsesFrozenState(t *testing.T) {
|
||||
t.Setenv("FACADE_INSPECTION_SECRET", "credential")
|
||||
|
||||
type configuration struct {
|
||||
Value string `yaml:"value"`
|
||||
Secret configkit.Secret `yaml:"secret"`
|
||||
}
|
||||
result, err := configkit.Load[configuration](configkit.Bytes("inspection.yml", []byte("value: original\nsecret: FACADE_INSPECTION_SECRET\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Config.Secret.IsResolved() || result.Config.Secret.Value() != "credential" {
|
||||
t.Fatalf("secret = %#v", result.Config.Secret)
|
||||
}
|
||||
explanation, ok := result.Explain(configkit.Keys("value"))
|
||||
if !ok || explanation.Effective.YAML != "original\n" {
|
||||
t.Fatalf("explanation = %#v, %v", explanation, ok)
|
||||
}
|
||||
explanation.Effective.YAML = "mutated\n"
|
||||
result.Config.Value = "mutated"
|
||||
var effective bytes.Buffer
|
||||
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "value: original\n") || strings.Contains(effective.String(), "credential") {
|
||||
t.Fatalf("effective = %q, %v", effective.String(), err)
|
||||
}
|
||||
again, ok := result.Explain(configkit.Keys("value"))
|
||||
if !ok || again.Effective.YAML != "original\n" {
|
||||
t.Fatalf("inspection changed = %#v, %v", again, ok)
|
||||
}
|
||||
secret, ok := result.Explain(configkit.Keys("secret"))
|
||||
if !ok || secret.SecretReference == nil || secret.SecretReference.Name != "FACADE_INSPECTION_SECRET" {
|
||||
t.Fatalf("secret explanation = %#v, %v", secret, ok)
|
||||
}
|
||||
var explanationOutput bytes.Buffer
|
||||
if err := result.WriteExplanation(&explanationOutput, configkit.Keys("value")); err != nil || !strings.Contains(explanationOutput.String(), `path: "/value"`) {
|
||||
t.Fatalf("explanation output = %q, %v", explanationOutput.String(), err)
|
||||
}
|
||||
|
||||
unknown := bytes.NewBufferString("unchanged")
|
||||
if err := result.WriteExplanation(unknown, configkit.Keys("unknown")); err == nil || unknown.String() != "unchanged" {
|
||||
t.Fatalf("unknown explanation = %q, %v", unknown.String(), err)
|
||||
}
|
||||
var nilResult *configkit.Result[configuration]
|
||||
if _, ok := nilResult.Explain(configkit.Keys("value")); ok {
|
||||
t.Fatal("nil result explained a path")
|
||||
}
|
||||
if err := nilResult.WriteEffective(io.Discard); err == nil {
|
||||
t.Fatal("nil result rendered effective configuration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
|
||||
const (
|
||||
loadedName = "FACADE_BOOTSTRAP_LOADED"
|
||||
existingName = "FACADE_BOOTSTRAP_EXISTING"
|
||||
)
|
||||
restore := func(name string) {
|
||||
value, present := os.LookupEnv(name)
|
||||
t.Cleanup(func() {
|
||||
if present {
|
||||
_ = os.Setenv(name, value)
|
||||
} else {
|
||||
_ = os.Unsetenv(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
restore(loadedName)
|
||||
restore(existingName)
|
||||
if err := os.Unsetenv(loadedName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Setenv(existingName, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
directory := t.TempDir()
|
||||
if err := os.WriteFile(directory+"/"+loadedName, []byte("bootstrap\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(directory+"/"+existingName, []byte("ignored\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := configkit.LoadSecretsDir(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := report.Entries()
|
||||
if len(entries) != 2 || os.Getenv(loadedName) != "bootstrap" {
|
||||
t.Fatalf("entries/environment = %#v / %q", entries, os.Getenv(loadedName))
|
||||
}
|
||||
status := map[string]configkit.SecretFileResult{}
|
||||
for _, entry := range entries {
|
||||
status[entry.Name] = entry
|
||||
}
|
||||
if status[loadedName].Status != configkit.SecretFileLoaded || status[existingName].Status != configkit.SecretFileSkippedExisting || !status[existingName].ExistingEmpty {
|
||||
t.Fatalf("statuses = %#v", status)
|
||||
}
|
||||
entries[0].Name = "mutated"
|
||||
if report.Entries()[0].Name == "mutated" {
|
||||
t.Fatal("report entries share mutable state")
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if _, err := report.WriteTo(&rendered); err != nil || strings.Contains(rendered.String(), "bootstrap") || strings.Contains(rendered.String(), "ignored") {
|
||||
t.Fatalf("report rendering = %q, %v", rendered.String(), err)
|
||||
}
|
||||
|
||||
failedReport, err := configkit.LoadSecretsDir("")
|
||||
var secretsError *configkit.SecretsError
|
||||
if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath {
|
||||
t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
|
||||
t.Setenv("FACADE_SECRET_NAME", "credential")
|
||||
|
||||
type configuration struct {
|
||||
Version string `yaml:"version"`
|
||||
Input int `yaml:"input"`
|
||||
Derived int `yaml:"derived"`
|
||||
Secret configkit.Secret `yaml:"secret"`
|
||||
Labels map[string]string `yaml:"labels"`
|
||||
}
|
||||
var retainedResolution *configkit.Resolution
|
||||
var retainedValidation *configkit.Validation
|
||||
versionCalls, defaultsCalls, resolveCalls, validateCalls := 0, 0, 0, 0
|
||||
definition := configkit.Definition[configuration]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, validation *configkit.Validation) error {
|
||||
versionCalls++
|
||||
retainedValidation = validation
|
||||
if !version.Present || version.Text != "1" {
|
||||
validation.Error(configkit.Keys("version"), "unsupported version")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
Defaults: func() configuration {
|
||||
defaultsCalls++
|
||||
return configuration{Derived: 1, Labels: map[string]string{"default": "kept"}}
|
||||
},
|
||||
Resolve: func(configuration *configuration, resolution *configkit.Resolution) error {
|
||||
resolveCalls++
|
||||
retainedResolution = resolution
|
||||
if !resolution.IsExplicit(configkit.Keys("input")) {
|
||||
resolution.Error(configkit.Keys("input"), "input is required")
|
||||
}
|
||||
input, ok := resolution.InputInfo(configkit.Keys("input"))
|
||||
if !ok || !input.Explicit {
|
||||
resolution.Error(configkit.Keys("input"), "missing input provenance")
|
||||
}
|
||||
source, ok := resolution.Source("source-1")
|
||||
if !ok || source.Label != "facade.yml" {
|
||||
resolution.Error(configkit.Keys("input"), "missing source")
|
||||
}
|
||||
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", configkit.Keys("input"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(configuration *configuration, validation *configkit.Validation) {
|
||||
validateCalls++
|
||||
retainedValidation = validation
|
||||
if !configuration.Secret.IsResolved() || configuration.Secret.Value() != "credential" {
|
||||
validation.Error(configkit.Keys("secret"), "secret did not resolve")
|
||||
}
|
||||
},
|
||||
}
|
||||
if versionCalls != 0 || defaultsCalls != 0 || resolveCalls != 0 || validateCalls != 0 {
|
||||
t.Fatal("callbacks ran while constructing options")
|
||||
}
|
||||
|
||||
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("version: 1\ninput: 3\nsecret: FACADE_SECRET_NAME\n")), configkit.Options[configuration]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versionCalls != 1 || defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 || result.Config.Derived != 6 {
|
||||
t.Fatalf("callback counts/configuration = %d/%d/%d/%d %#v", versionCalls, defaultsCalls, resolveCalls, validateCalls, result.Config)
|
||||
}
|
||||
derived, ok := result.Metadata().Lookup(configkit.Keys("derived"))
|
||||
if !ok || derived.Explicit || derived.Origin.Operation != configkit.ChangeDerivedDefault {
|
||||
t.Fatalf("derived metadata = %#v, %v", derived, ok)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].Label != "facade.yml" {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
if result.Diagnostics() != nil {
|
||||
t.Fatalf("diagnostics = %#v", result.Diagnostics())
|
||||
}
|
||||
for _, context := range []func(){
|
||||
func() { retainedResolution.IsExplicit(configkit.Keys("input")) },
|
||||
func() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("retained callback context remained active")
|
||||
}
|
||||
}()
|
||||
context()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeLoadReturnsFacadeLoadError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type configuration struct {
|
||||
Value int `yaml:"value"`
|
||||
}
|
||||
result, err := configkit.Load[configuration](configkit.Bytes("invalid.yml", []byte("value: wrong\n")))
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
}
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeDecodeType {
|
||||
t.Fatalf("facade error = %#v", err)
|
||||
}
|
||||
}
|
||||
85
facade_compatibility_test.go
Normal file
85
facade_compatibility_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
var (
|
||||
_ error = (*configkit.LoadError)(nil)
|
||||
_ fmt.Formatter = configkit.Secret{}
|
||||
_ fmt.Stringer = configkit.Path{}
|
||||
_ json.Marshaler = configkit.Secret{}
|
||||
_ io.WriterTo = (*configkit.SecretsReport)(nil)
|
||||
)
|
||||
|
||||
type facadeCompatibilityConfig struct {
|
||||
Value string `yaml:"value"`
|
||||
Derived string `yaml:"derived"`
|
||||
}
|
||||
|
||||
func TestFacadeCompatibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
for _, candidate := range []struct {
|
||||
name string
|
||||
type_ reflect.Type
|
||||
}{
|
||||
{"Path", reflect.TypeFor[configkit.Path]()},
|
||||
{"Source", reflect.TypeFor[configkit.Source]()},
|
||||
{"Secret", reflect.TypeFor[configkit.Secret]()},
|
||||
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
|
||||
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
|
||||
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
|
||||
} {
|
||||
if candidate.type_.PkgPath() != packagePath {
|
||||
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
|
||||
}
|
||||
}
|
||||
|
||||
constructed := configkit.Options[facadeCompatibilityConfig]{
|
||||
Definition: configkit.Definition[facadeCompatibilityConfig]{},
|
||||
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
|
||||
Overrides: []configkit.Override{{
|
||||
Path: configkit.Keys("value"),
|
||||
Value: "override",
|
||||
Flag: "--value",
|
||||
}},
|
||||
}
|
||||
if got := constructed.Overrides[0].Path.String(); got != "/value" {
|
||||
t.Fatalf("constructed override path = %q, want /value", got)
|
||||
}
|
||||
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
|
||||
if publicResult.Config.Value != "constructed" {
|
||||
t.Fatal("constructed result lost its public configuration")
|
||||
}
|
||||
|
||||
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
|
||||
Definition: configkit.Definition[facadeCompatibilityConfig]{
|
||||
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
|
||||
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, configuration.Value+" default", "derive value")
|
||||
configkit.Normalize(resolution, configkit.Keys("value"), &configuration.Value, "normalized "+configuration.Value, "normalize value")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if result.Config != (facadeCompatibilityConfig{Value: "normalized input", Derived: "input default"}) {
|
||||
t.Fatalf("Load() Config = %#v", result.Config)
|
||||
}
|
||||
|
||||
_, err = configkit.Load[facadeCompatibilityConfig](configkit.Bytes("invalid.yml", []byte("value: [\n")))
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("Load() error = %T, want *configkit.LoadError", err)
|
||||
}
|
||||
}
|
||||
51
facade_context_test.go
Normal file
51
facade_context_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, state := range []struct {
|
||||
name string
|
||||
resolution *configkit.Resolution
|
||||
validation *configkit.Validation
|
||||
}{
|
||||
{name: "nil"},
|
||||
{name: "zero", resolution: &configkit.Resolution{}, validation: &configkit.Validation{}},
|
||||
} {
|
||||
t.Run(state.name, func(t *testing.T) {
|
||||
path := configkit.Keys("value")
|
||||
var target int
|
||||
for _, operation := range []struct {
|
||||
name string
|
||||
call func()
|
||||
}{
|
||||
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
|
||||
{"InputInfo", func() { state.resolution.InputInfo(path) }},
|
||||
{"Source", func() { state.resolution.Source("source") }},
|
||||
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
|
||||
{"Resolution.Error", func() { state.resolution.Error(path, "message") }},
|
||||
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
|
||||
{"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }},
|
||||
{"Normalize", func() { configkit.Normalize(state.resolution, path, &target, 1, "reason") }},
|
||||
{"Validation.Error", func() { state.validation.Error(path, "message") }},
|
||||
{"Validation.Warning", func() { state.validation.Warning(path, "message") }},
|
||||
} {
|
||||
t.Run(operation.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if got := recover(); got != "configkit: callback context is no longer active" {
|
||||
t.Errorf("panic = %v; want fixed closed-context failure", got)
|
||||
}
|
||||
if target != 0 {
|
||||
t.Error("inactive helper changed target")
|
||||
}
|
||||
}()
|
||||
operation.call()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
52
facade_internal_test.go
Normal file
52
facade_internal_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
)
|
||||
|
||||
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
data := []byte("value: retained\n")
|
||||
source := Bytes("snapshot.yml", data)
|
||||
data[7] = 'X'
|
||||
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
|
||||
if err != nil || result.Config.Value != "retained" {
|
||||
t.Fatalf("snapshotted source = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
|
||||
first := readerSource
|
||||
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
|
||||
var loadError *engine.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
|
||||
t.Fatalf("copied reader source error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parsed, err := ParsePath("/0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyed, indexed := Keys("0"), Path{}.Index(0)
|
||||
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
|
||||
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
|
||||
}
|
||||
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
|
||||
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
|
||||
}
|
||||
}
|
||||
66
facade_version_test.go
Normal file
66
facade_version_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type versionCountingReader struct {
|
||||
*strings.Reader
|
||||
reads int
|
||||
}
|
||||
|
||||
func (r *versionCountingReader) Read(data []byte) (int, error) {
|
||||
r.reads++
|
||||
return r.Reader.Read(data)
|
||||
}
|
||||
|
||||
func TestFacadeRejectsMissingVersionCheckerBeforeReading(t *testing.T) {
|
||||
t.Parallel()
|
||||
type configuration struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
reader := &versionCountingReader{Reader: strings.NewReader("version: v1\n")}
|
||||
source := configkit.Reader("version.yml", reader)
|
||||
var defaults, resolutions, validations, checks int
|
||||
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version")},
|
||||
Defaults: func() configuration { defaults++; return configuration{} },
|
||||
Resolve: func(*configuration, *configkit.Resolution) error { resolutions++; return nil },
|
||||
Validate: func(*configuration, *configkit.Validation) { validations++ },
|
||||
}}
|
||||
result, err := configkit.Load(source, options)
|
||||
var loadError *configkit.LoadError
|
||||
if result != nil || !errors.As(err, &loadError) {
|
||||
t.Fatalf("missing checker: result = %v, error = %v", result, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeOptionsInvalid {
|
||||
t.Fatalf("diagnostics = %v", diagnostics)
|
||||
}
|
||||
if reader.reads != 0 || defaults != 0 || resolutions != 0 || validations != 0 {
|
||||
t.Fatalf("invalid options performed work: reads=%d defaults=%d resolve=%d validate=%d", reader.reads, defaults, resolutions, validations)
|
||||
}
|
||||
options.Definition.Version.Check = func(version configkit.Version, validation *configkit.Validation) error {
|
||||
checks++
|
||||
if version != (configkit.Version{Present: true, Text: "v1"}) {
|
||||
t.Errorf("version = %#v", version)
|
||||
}
|
||||
validation.Warning(configkit.Keys("version"), "version accepted")
|
||||
return nil
|
||||
}
|
||||
result, err = configkit.Load(source, options)
|
||||
if err != nil || result == nil || result.Config.Version != "v1" {
|
||||
t.Fatalf("reuse with checker: result = %v, error = %v", result, err)
|
||||
}
|
||||
if checks != 1 || defaults != 1 || resolutions != 1 || validations != 1 {
|
||||
t.Fatalf("callback counts: check=%d defaults=%d resolve=%d validate=%d", checks, defaults, resolutions, validations)
|
||||
}
|
||||
diagnostics = result.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionWarning {
|
||||
t.Fatalf("version callback diagnostics = %v", diagnostics)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,15 @@ func TestDependencyImportBoundary(t *testing.T) {
|
||||
if strings.HasPrefix(importPath, "github.com/google/go-cmp/") && !strings.HasSuffix(path, "_test.go") {
|
||||
t.Errorf("%s imports test-only dependency %s", path, importPath)
|
||||
}
|
||||
production := !strings.HasSuffix(path, "_test.go")
|
||||
rootProduction := production && filepath.Dir(path) == "."
|
||||
internalProduction := production && strings.HasPrefix(filepath.ToSlash(path), "internal/")
|
||||
if rootProduction && strings.HasPrefix(importPath, "gitea.maximumdirect.net/eric/ConfigKit/internal/") && importPath != "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" {
|
||||
t.Errorf("%s bypasses the engine boundary through %s", path, importPath)
|
||||
}
|
||||
if internalProduction && importPath == "gitea.maximumdirect.net/eric/ConfigKit" {
|
||||
t.Errorf("%s imports the public root package", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
70
inspection.go
Normal file
70
inspection.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
)
|
||||
|
||||
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
||||
// Present distinguishes absence or deletion from an explicit null value.
|
||||
type ValueSnapshot struct {
|
||||
Present bool
|
||||
Redacted bool
|
||||
YAML string
|
||||
}
|
||||
|
||||
// ValueChange describes one explicit definition or audited domain operation.
|
||||
type ValueChange struct {
|
||||
Before ValueSnapshot
|
||||
After ValueSnapshot
|
||||
Origin Origin
|
||||
Reasons []string
|
||||
Dependencies []Path
|
||||
}
|
||||
|
||||
// SecretReferenceInfo describes a successfully resolved effective reference.
|
||||
// Name is the environment-variable name, never its value.
|
||||
type SecretReferenceInfo struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// Explanation is a detached safe account of one effective or historical path.
|
||||
type Explanation struct {
|
||||
Path Path
|
||||
Effective ValueSnapshot
|
||||
Origin Origin
|
||||
History []ValueChange
|
||||
SecretReference *SecretReferenceInfo
|
||||
}
|
||||
|
||||
// Explain returns safe effective and chronological values for a known final or
|
||||
// historical path. Unknown and malformed paths return false.
|
||||
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||
if r == nil {
|
||||
return Explanation{}, false
|
||||
}
|
||||
value, ok := r.inspection.Explain(toEnginePath(path))
|
||||
return fromEngineExplanation(value), ok
|
||||
}
|
||||
|
||||
// WriteEffective writes deterministic redacted YAML from the frozen resolved
|
||||
// snapshot. The output is intended for inspection and is not promised reloadable.
|
||||
// Writer errors, including short writes, are returned and may leave partial output.
|
||||
func (r *Result[T]) WriteEffective(output io.Writer) error {
|
||||
if r == nil {
|
||||
var inspection *engine.Inspection
|
||||
return inspection.WriteEffective(output)
|
||||
}
|
||||
return r.inspection.WriteEffective(output)
|
||||
}
|
||||
|
||||
// WriteExplanation writes a deterministic human-readable rendering of Explain.
|
||||
// Unknown paths produce no output. Writer failures may leave partial output.
|
||||
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
|
||||
if r == nil {
|
||||
var inspection *engine.Inspection
|
||||
return inspection.WriteExplanation(output, toEnginePath(path))
|
||||
}
|
||||
return r.inspection.WriteExplanation(output, toEnginePath(path))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -30,11 +30,11 @@ func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) {
|
||||
}
|
||||
calls := 0
|
||||
lookup := func(string) (string, bool) { calls++; return "credential", true }
|
||||
first, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
|
||||
first, err := testLoadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
|
||||
second, err := testLoadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
@@ -128,11 +128,11 @@ func (o *valueOperation) clone(value reflect.Value, descriptor *typeDescriptor,
|
||||
return result, nil
|
||||
case descriptorSecret:
|
||||
result := reflect.New(value.Type()).Elem()
|
||||
reference := value.Interface().(Secret)
|
||||
reference := o.schema.secrets.Read(value)
|
||||
if o.cloneMode == cloneValidatedSecrets {
|
||||
result.Set(value)
|
||||
} else if reference.specified {
|
||||
result.Set(reflect.ValueOf(SecretEnv(reference.EnvName())))
|
||||
} else if reference.Specified {
|
||||
result.Set(o.schema.secrets.Make(SecretState{Name: reference.Name, Specified: true}))
|
||||
}
|
||||
return result, nil
|
||||
case descriptorText:
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
@@ -179,7 +179,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
||||
if node.Kind != document.String {
|
||||
return typeMismatch(path, node, "secret environment name")
|
||||
}
|
||||
return reflect.ValueOf(SecretEnv(node.Text)), nil
|
||||
return schema.secrets.Make(SecretState{Name: node.Text, Specified: true}), nil
|
||||
case descriptorText:
|
||||
if node.Kind != document.String {
|
||||
return typeMismatch(path, node, "text string")
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
248
internal/engine/diagnostic.go
Normal file
248
internal/engine/diagnostic.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// LoadError reports safe structured diagnostics for a failed configuration load.
|
||||
// It intentionally does not expose or unwrap parser and application errors.
|
||||
type LoadError struct {
|
||||
diagnostics []Diagnostic
|
||||
sources []SourceInfo
|
||||
}
|
||||
|
||||
// Error implements error without rendering diagnostic messages or input values.
|
||||
func (e *LoadError) Error() string {
|
||||
if e == nil {
|
||||
return "configkit: configuration load failed"
|
||||
}
|
||||
count := 0
|
||||
for _, diagnostic := range e.diagnostics {
|
||||
if diagnostic.Severity == SeverityError {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 1 {
|
||||
return "configkit: configuration load failed with 1 error"
|
||||
}
|
||||
return fmt.Sprintf("configkit: configuration load failed with %d errors", count)
|
||||
}
|
||||
|
||||
// Diagnostics returns a deep copy of the error's canonical diagnostics.
|
||||
func (e *LoadError) Diagnostics() []Diagnostic {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneDiagnostics(e.diagnostics)
|
||||
}
|
||||
|
||||
// Sources returns a deep copy of sources discovered before loading failed.
|
||||
func (e *LoadError) Sources() []SourceInfo {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneSourceInfos(e.sources)
|
||||
}
|
||||
|
||||
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
||||
// control characters and propagates writer errors, including short writes.
|
||||
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||
canonical := canonicalDiagnostics(diagnostics)
|
||||
for _, diagnostic := range canonical {
|
||||
if err := writeDiagnostic(output, diagnostic); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type diagnosticAccumulator struct {
|
||||
diagnostics []Diagnostic
|
||||
sources []SourceInfo
|
||||
}
|
||||
|
||||
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
||||
for _, diagnostic := range diagnostics {
|
||||
a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *diagnosticAccumulator) loadError() *LoadError {
|
||||
return &LoadError{
|
||||
diagnostics: canonicalDiagnostics(a.diagnostics),
|
||||
sources: cloneSourceInfos(a.sources),
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
||||
canonical := cloneDiagnostics(diagnostics)
|
||||
for i := range canonical {
|
||||
slices.SortFunc(canonical[i].Related, compareLocation)
|
||||
}
|
||||
slices.SortFunc(canonical, compareDiagnostic)
|
||||
return canonical
|
||||
}
|
||||
|
||||
func compareDiagnostic(left, right Diagnostic) int {
|
||||
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Code, right.Code); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Severity, right.Severity); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Message, right.Message); order != 0 {
|
||||
return order
|
||||
}
|
||||
return compareLocations(left.Related, right.Related)
|
||||
}
|
||||
|
||||
func compareOptionalLocation(left, right *Location) int {
|
||||
if left == nil && right == nil {
|
||||
return 0
|
||||
}
|
||||
if left == nil {
|
||||
return -1
|
||||
}
|
||||
if right == nil {
|
||||
return 1
|
||||
}
|
||||
return compareLocation(*left, *right)
|
||||
}
|
||||
|
||||
func compareLocations(left, right []Location) int {
|
||||
for i := 0; i < min(len(left), len(right)); i++ {
|
||||
if order := compareLocation(left[i], right[i]); order != 0 {
|
||||
return order
|
||||
}
|
||||
}
|
||||
return cmp.Compare(len(left), len(right))
|
||||
}
|
||||
|
||||
func compareLocation(left, right Location) int {
|
||||
if order := compareSourceID(left.SourceID, right.SourceID); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Line, right.Line); order != 0 {
|
||||
return order
|
||||
}
|
||||
return cmp.Compare(left.Column, right.Column)
|
||||
}
|
||||
|
||||
func compareSourceID(left, right string) int {
|
||||
leftOrder, leftOK := sourceSequence(left)
|
||||
rightOrder, rightOK := sourceSequence(right)
|
||||
if leftOK && rightOK {
|
||||
return cmp.Compare(leftOrder, rightOrder)
|
||||
}
|
||||
return cmp.Compare(left, right)
|
||||
}
|
||||
|
||||
func sourceSequence(sourceID string) (int, bool) {
|
||||
const prefix = "source-"
|
||||
if !strings.HasPrefix(sourceID, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix))
|
||||
if err != nil || value < 1 {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error {
|
||||
severity := diagnostic.Severity
|
||||
if severity == "" {
|
||||
severity = SeverityError
|
||||
}
|
||||
path := diagnostic.Path.String()
|
||||
if path == "" {
|
||||
path = "<root>"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(escapeControls(string(severity)))
|
||||
if diagnostic.Code != "" {
|
||||
b.WriteString(" [")
|
||||
b.WriteString(escapeControls(string(diagnostic.Code)))
|
||||
b.WriteByte(']')
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(escapeControls(path))
|
||||
if diagnostic.Primary != nil {
|
||||
b.WriteString(" (")
|
||||
writeLocationText(&b, *diagnostic.Primary)
|
||||
b.WriteByte(')')
|
||||
}
|
||||
if diagnostic.Message != "" {
|
||||
b.WriteString(": ")
|
||||
b.WriteString(escapeControls(diagnostic.Message))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
for _, related := range diagnostic.Related {
|
||||
b.WriteString(" related: ")
|
||||
writeLocationText(&b, related)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return writeString(output, b.String())
|
||||
}
|
||||
|
||||
func writeLocationText(output *strings.Builder, location Location) {
|
||||
label := location.SourceID
|
||||
if label == "" {
|
||||
label = "<synthetic>"
|
||||
}
|
||||
output.WriteString(escapeControls(label))
|
||||
if location.Line > 0 {
|
||||
output.WriteByte(':')
|
||||
output.WriteString(strconv.Itoa(location.Line))
|
||||
if location.Column > 0 {
|
||||
output.WriteByte(':')
|
||||
output.WriteString(strconv.Itoa(location.Column))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeString(output io.Writer, value string) error {
|
||||
written, err := io.WriteString(output, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written != len(value) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func escapeControls(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) {
|
||||
quoted := strconv.QuoteRune(r)
|
||||
b.WriteString(quoted[1 : len(quoted)-1])
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func hasErrors(diagnostics []Diagnostic) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Severity == SeverityError {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path"
|
||||
@@ -181,24 +181,24 @@ func nodeImportDiagnostic(code Code, path Path, message string, node *document.N
|
||||
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
|
||||
}
|
||||
|
||||
func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||
func overlayDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||
if node.Kind == document.Mapping {
|
||||
for _, entry := range node.Mapping {
|
||||
childPath := current.Key(entry.Key)
|
||||
if entry.Key == directiveKey {
|
||||
location := publicLocation(entry.KeyLocation)
|
||||
return []Diagnostic{{
|
||||
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
|
||||
Message: "profiles cannot contain configuration directives", Primary: &location,
|
||||
Code: CodeOverlayDirective, Severity: SeverityError, Path: childPath,
|
||||
Message: "overlays cannot contain configuration directives", Primary: &location,
|
||||
}}
|
||||
}
|
||||
if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||
if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||
if diagnostics := overlayDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
@@ -36,7 +36,7 @@ type Explanation struct {
|
||||
|
||||
// Explain returns safe effective and chronological values for a known final or
|
||||
// historical path. Unknown and malformed paths return false.
|
||||
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||
func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
|
||||
return Explanation{}, false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -14,7 +14,7 @@ var (
|
||||
|
||||
// WriteExplanation writes a deterministic human-readable rendering of Explain.
|
||||
// Unknown paths produce no output. Writer failures may leave partial output.
|
||||
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
|
||||
func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
|
||||
if r == nil || output == nil {
|
||||
return errExplanationUnavailable
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
|
||||
rendered.WriteString(" - representation: ")
|
||||
if change.Origin.Operation == ChangeDecoding {
|
||||
rendered.WriteString("input to canonical typed\n")
|
||||
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
|
||||
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI {
|
||||
rendered.WriteString("policy-validated input\n")
|
||||
} else {
|
||||
rendered.WriteString("canonical typed\n")
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -21,13 +21,13 @@ type explanationConcurrency struct {
|
||||
} `yaml:"transcription"`
|
||||
}
|
||||
|
||||
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
||||
func TestExplainShowsDefaultBaseAndEffectiveOverlayValue(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
|
||||
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
|
||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
|
||||
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
||||
Imports: true, Profile: "testing",
|
||||
writeImportFile(t, directory, filepath.Join("overlays", "testing.yml"), "transcription:\n concurrency: 1\n")
|
||||
result, err := testLoad[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
||||
Imports: true, Overlay: "testing",
|
||||
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
|
||||
value := explanationConcurrency{}
|
||||
value.Transcription.Concurrency = 4
|
||||
@@ -44,11 +44,17 @@ func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
||||
if got := explanationValues(explanation); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n"}) {
|
||||
t.Fatalf("history values = %#v", got)
|
||||
}
|
||||
if explanation.Effective.YAML != "1\n" || explanation.Origin.Layer != LayerProfile ||
|
||||
if explanation.Effective.YAML != "1\n" || explanation.Origin.Layer != LayerOverlay ||
|
||||
len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != "source-3" ||
|
||||
explanation.Origin.Locations[0].Line != 2 {
|
||||
t.Fatalf("effective explanation = %#v", explanation)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := result.WriteExplanation(&rendered, Keys("transcription", "concurrency")); err != nil ||
|
||||
!strings.Contains(rendered.String(), `layer="overlay"`) ||
|
||||
!strings.Contains(rendered.String(), `label="overlays/testing.yml"`) {
|
||||
t.Fatalf("rendered explanation = %v\n%s", err, rendered.String())
|
||||
}
|
||||
}
|
||||
|
||||
type explanationConfig struct {
|
||||
@@ -70,27 +76,27 @@ func TestExplainReplaysCLIResolutionContainersAndSafeHistoricalValues(t *testing
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "config.yml", `concurrency: 8
|
||||
equal: same
|
||||
limit: wrong-before-profile
|
||||
limit: wrong-before-overlay
|
||||
items: [base, removed]
|
||||
labels: {base: retained}
|
||||
dynamic: {retired: HISTORICAL_EXPLAIN_CANARY, public: visible}
|
||||
credential: APP_SECRET
|
||||
classified: APP_SECRET
|
||||
`)
|
||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `concurrency: 1
|
||||
writeImportFile(t, directory, filepath.Join("overlays", "testing.yml"), `concurrency: 1
|
||||
equal: same
|
||||
limit: 7
|
||||
items: [profile]
|
||||
labels: {profile: retained}
|
||||
items: [overlay]
|
||||
labels: {overlay: retained}
|
||||
dynamic: selected
|
||||
`)
|
||||
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
|
||||
result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
|
||||
if name != "APP_SECRET" {
|
||||
t.Fatalf("secret lookup = %q", name)
|
||||
}
|
||||
return "RESOLVED_EXPLAIN_CANARY", true
|
||||
}, Options[explanationConfig]{
|
||||
Profile: "testing",
|
||||
Overlay: "testing",
|
||||
Overrides: []Override{
|
||||
{Path: Keys("concurrency"), Value: 2, Flag: "--concurrency"},
|
||||
{Path: Keys("equal"), Value: "same", Flag: "--equal"},
|
||||
@@ -130,7 +136,7 @@ dynamic: selected
|
||||
if got := explanationValues(concurrency); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n", "2\n", "3\n"}) {
|
||||
t.Fatalf("concurrency history = %#v", got)
|
||||
}
|
||||
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}) {
|
||||
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerOverlay, LayerCLI, LayerResolution}) {
|
||||
t.Fatalf("concurrency layers = %#v", got)
|
||||
}
|
||||
if concurrency.History[4].Before.YAML != "2\n" || concurrency.History[4].Reasons[0] != "normalized\nconcurrency" ||
|
||||
@@ -149,7 +155,7 @@ dynamic: selected
|
||||
}
|
||||
|
||||
limit, _ := result.Explain(Keys("limit"))
|
||||
if got := explanationValues(limit); !reflect.DeepEqual(got, []string{"0\n", "wrong-before-profile\n", "7\n"}) {
|
||||
if got := explanationValues(limit); !reflect.DeepEqual(got, []string{"0\n", "wrong-before-overlay\n", "7\n"}) {
|
||||
t.Fatalf("overridden invalid input was not retained: %#v", got)
|
||||
}
|
||||
removed, ok := result.Explain(Keys("dynamic", "retired"))
|
||||
@@ -182,7 +188,7 @@ dynamic: selected
|
||||
}
|
||||
labels, _ := result.Explain(Keys("labels"))
|
||||
if len(labels.History) != 4 || !strings.Contains(labels.Effective.YAML, "default: retained") ||
|
||||
!strings.Contains(labels.Effective.YAML, "profile: retained") || !strings.Contains(labels.Effective.YAML, "cli: retained") {
|
||||
!strings.Contains(labels.Effective.YAML, "overlay: retained") || !strings.Contains(labels.Effective.YAML, "cli: retained") {
|
||||
t.Fatalf("composite mapping history = %#v", labels)
|
||||
}
|
||||
if labels.Origin.Layer != LayerCLI || len(labels.Origin.Locations) != 3 {
|
||||
@@ -258,7 +264,7 @@ dynamic: selected
|
||||
var invalidInput bytes.Buffer
|
||||
if err := result.WriteExplanation(&invalidInput, Keys("limit")); err != nil ||
|
||||
!strings.Contains(invalidInput.String(), "policy-validated input") ||
|
||||
strings.Contains(invalidInput.String(), "wrong-before-profile\n source: layer=\"base\"\n before (canonical typed)") {
|
||||
strings.Contains(invalidInput.String(), "wrong-before-overlay\n source: layer=\"base\"\n before (canonical typed)") {
|
||||
t.Fatalf("historical representation rendering = %v\n%s", err, invalidInput.String())
|
||||
}
|
||||
}
|
||||
@@ -285,7 +291,7 @@ func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
|
||||
type config struct {
|
||||
Credential Secret `yaml:"credential"`
|
||||
}
|
||||
result, err := Load[config](Bytes("config.yml", []byte("credential: "+name+"\n")))
|
||||
result, err := testLoad[config](Bytes("config.yml", []byte("credential: "+name+"\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -340,10 +346,10 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
||||
if cli {
|
||||
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
|
||||
} else {
|
||||
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
|
||||
opts.Profile = "p"
|
||||
writeImportFile(t, root, "overlays/p.yml", "m: {b: 2}\n")
|
||||
opts.Overlay = "p"
|
||||
}
|
||||
result, err := Load(File(main), opts)
|
||||
result, err := testLoad(File(main), opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -380,11 +386,11 @@ func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing.
|
||||
Defaults: func() config { return config{Version: "4"} },
|
||||
Version: rule,
|
||||
}}
|
||||
first, err := Load(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||
first, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Load(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||
second, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -449,7 +455,7 @@ func TestSensitiveIntegerVersionStaysRedactedWithoutSecretLookup(t *testing.T) {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
lookups := 0
|
||||
result, err := loadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) {
|
||||
result, err := testLoadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) {
|
||||
lookups++
|
||||
return "CREDENTIAL_CANARY", true
|
||||
}, Options[config]{Definition: Definition[config]{
|
||||
@@ -476,7 +482,7 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T)
|
||||
}
|
||||
for _, overlap := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(overlap), func(t *testing.T) {
|
||||
result, err := Load(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
|
||||
result, err := testLoad(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
|
||||
c.M["new"] = map[string]any{"child": int64(7)}
|
||||
delete(c.M, "old")
|
||||
r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old"))
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -26,7 +26,7 @@ items: {alpha: alpha}
|
||||
|
||||
var previous []SourceInfo
|
||||
for run := 0; run < 2; run++ {
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ items: {root: root}
|
||||
writeImportFile(t, directory, "conf.d/item-a.yml", "items: {a: a}\n")
|
||||
writeImportFile(t, directory, "conf.d/question1.yml", "items: {question: question}\n")
|
||||
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ items: {root: root}
|
||||
ignoredPath := writeImportFile(t, directory, "conf.d/not-discovered.yml", "unknown: value\n")
|
||||
_ = ignoredPath
|
||||
plainPath := writeImportFile(t, directory, "plain.yml", "items: {root: only}\n")
|
||||
plain, err := Load[importTestConfig](File(plainPath), Options[importTestConfig]{Imports: true})
|
||||
plain, err := testLoad[importTestConfig](File(plainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || len(plain.Sources()) != 1 || plain.Config.Items["root"] != "only" {
|
||||
t.Fatalf("implicit discovery result = %#v, %v", plain, err)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "empty/*.yml", true)
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || len(result.Sources()) != 1 {
|
||||
t.Fatalf("optional unmatched glob = %#v, %v", result, err)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || result.Config.Items["linked"] != "linked" {
|
||||
t.Fatalf("contained directory symlink = %#v, %v", result, err)
|
||||
}
|
||||
@@ -229,11 +229,11 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) {
|
||||
func TestFilesystemContextIsValidatedWithoutImportsOrActiveOverlay(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n")
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||
Profile: "default", Filesystem: &FilesystemContext{Root: directory},
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||
Overlay: "default", Filesystem: &FilesystemContext{Root: directory},
|
||||
})
|
||||
if err != nil || result.Config.Items["root"] != "root" {
|
||||
t.Fatalf("file filesystem context = %#v, %v", result, err)
|
||||
@@ -242,20 +242,20 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: filepath.Join(directory, "missing")}}, CodeImportInvalid)
|
||||
|
||||
input := Bytes("memory.yml", []byte("items: {root: memory}\n"))
|
||||
result, err = Load[importTestConfig](input, Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}})
|
||||
result, err = testLoad[importTestConfig](input, Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}})
|
||||
if err != nil || result.Config.Items["root"] != "memory" {
|
||||
t.Fatalf("byte filesystem context = %#v, %v", result, err)
|
||||
}
|
||||
requireLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory}}, CodeOptionsInvalid)
|
||||
defaultsOnly, err := Load[importTestConfig](NoSource(), Options[importTestConfig]{Profile: "default"})
|
||||
defaultsOnly, err := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{Overlay: "default"})
|
||||
if err != nil || defaultsOnly == nil {
|
||||
t.Fatalf("default profile with NoSource = %#v, %v", defaultsOnly, err)
|
||||
t.Fatalf("default overlay with NoSource = %#v, %v", defaultsOnly, err)
|
||||
}
|
||||
|
||||
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
|
||||
selectedLink := filepath.Join(directory, "selected-link.yml")
|
||||
if err := os.Symlink(mainPath, selectedLink); err == nil {
|
||||
linked, err := Load[importTestConfig](File(selectedLink), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: directory}})
|
||||
linked, err := testLoad[importTestConfig](File(selectedLink), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: directory}})
|
||||
if err != nil || linked.Sources()[0].Path != canonicalTestPath(t, mainPath) {
|
||||
t.Fatalf("selected symlink identity = %#v, %v", linked, err)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -33,7 +33,7 @@ func TestLoadFormerDirectiveKeyIsApplicationData(t *testing.T) {
|
||||
for _, imports := range []bool{false, true} {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n")
|
||||
result, err := Load[config](File(mainPath), Options[config]{Imports: imports})
|
||||
result, err := testLoad[config](File(mainPath), Options[config]{Imports: imports})
|
||||
if err != nil {
|
||||
t.Fatalf("Imports=%v: %v", imports, err)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ settings:
|
||||
thirdPath := writeImportFile(t, directory, "nested/third.yml", "settings:\n nested: nested\n")
|
||||
secondPath := writeImportFile(t, directory, "second.yml", "settings:\n second: second\n")
|
||||
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -89,6 +89,18 @@ settings:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadImportsFromProfilesDirectoryAsBaseInput(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/shared.yml}]}\n")
|
||||
writeImportFile(t, directory, "profiles/shared.yml", "settings: {root: imported}\n")
|
||||
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || result.Config.Settings.Root != "imported" || len(result.Sources()) != 2 ||
|
||||
result.Sources()[1].Layer != LayerBase {
|
||||
t.Fatalf("profiles import = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
||||
t.Run("all duplicate claimants", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
@@ -163,7 +175,7 @@ func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
||||
optional: true
|
||||
settings: {root: retained}
|
||||
`)
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || result.Config.Settings.Root != "retained" || len(result.Sources()) != 1 {
|
||||
t.Fatalf("optional result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -253,7 +265,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n")
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) {
|
||||
t.Fatalf("symlink result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -303,7 +315,7 @@ func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T)
|
||||
{name: "reader URI label", source: func() Source { return Reader("https://example.invalid/main.yml", strings.NewReader(string(input))) }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := Load[importTestConfig](test.source(), Options[importTestConfig]{Imports: true, Filesystem: context})
|
||||
result, err := testLoad[importTestConfig](test.source(), Options[importTestConfig]{Imports: true, Filesystem: context})
|
||||
if err != nil || result.Config.Settings.First != "imported" {
|
||||
t.Fatalf("explicit filesystem result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -318,7 +330,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n")
|
||||
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
||||
})
|
||||
if err != nil || result.Config.Settings.Root != "shared" {
|
||||
@@ -455,7 +467,7 @@ func requireLoadCode(t *testing.T, source Source, options Options[importTestConf
|
||||
|
||||
func requireAnyLoadError(t *testing.T, source Source, options Options[importTestConfig]) *LoadError {
|
||||
t.Helper()
|
||||
result, err := Load[importTestConfig](source, options)
|
||||
result, err := testLoad[importTestConfig](source, options)
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, error = %#v, %v", result, err)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -38,7 +38,7 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
|
||||
}
|
||||
|
||||
const count = 32
|
||||
results := make([]*Result[independentConfig], count)
|
||||
results := make([]*testResult[independentConfig], count)
|
||||
errorsByIndex := make([]error, count)
|
||||
var group sync.WaitGroup
|
||||
for index := range count {
|
||||
@@ -46,14 +46,14 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
|
||||
go func(index int) {
|
||||
defer group.Done()
|
||||
input := fmt.Sprintf("custom: value-%d\n", index)
|
||||
results[index], errorsByIndex[index] = Load(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition})
|
||||
results[index], errorsByIndex[index] = testLoad(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition})
|
||||
}(index)
|
||||
}
|
||||
group.Wait()
|
||||
|
||||
for index, err := range errorsByIndex {
|
||||
if err != nil {
|
||||
t.Fatalf("Load(%d) failed: %v", index, err)
|
||||
t.Fatalf("testLoad(%d) failed: %v", index, err)
|
||||
}
|
||||
}
|
||||
results[0].Config.Labels["default"] = "mutated"
|
||||
@@ -73,6 +73,29 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedConfigurationIsSeparateFromInspection(t *testing.T) {
|
||||
type configuration struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
loaded, err := Load[configuration](Bytes("config.yml", []byte("value: original\n")), testSecretBinding())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Inspection == nil {
|
||||
t.Fatal("inspection is nil")
|
||||
}
|
||||
|
||||
loaded.Config.Value = "mutated"
|
||||
var effective bytes.Buffer
|
||||
if err := loaded.Inspection.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if effective.String() != "value: original\n" {
|
||||
t.Fatalf("inspection changed with configuration: %q", effective.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
|
||||
source := Reader("shared", strings.NewReader("value: one\n"))
|
||||
copied := source
|
||||
@@ -88,7 +111,7 @@ func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
|
||||
go func(candidate Source) {
|
||||
defer group.Done()
|
||||
<-start
|
||||
_, err := Load[config](candidate)
|
||||
_, err := testLoad[config](candidate)
|
||||
errorsFound <- err
|
||||
}(candidate)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -19,17 +19,17 @@ type Definition[T any] struct {
|
||||
}
|
||||
|
||||
// Options contains the application definition and per-load input selections.
|
||||
// Imports enables explicit path and glob imports; Profile selects one fixed profile
|
||||
// overlay; Overrides contains only values explicitly supplied by an application CLI.
|
||||
// Imports enables explicit path and glob imports; 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
|
||||
Profile string
|
||||
Overlay string
|
||||
Filesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
}
|
||||
|
||||
// FilesystemContext supplies filesystem roots for imports and profiles.
|
||||
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||
type FilesystemContext struct {
|
||||
BaseDir string
|
||||
Root string
|
||||
@@ -44,8 +44,9 @@ type Override struct {
|
||||
Flag string
|
||||
}
|
||||
|
||||
// Resolution exposes surviving-input presence and records audited application
|
||||
// resolution changes. A resolver must not retain or concurrently use the context.
|
||||
// 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
|
||||
}
|
||||
@@ -65,10 +66,15 @@ type Version struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
// Result is a successful independently owned configuration load.
|
||||
type Result[T any] struct {
|
||||
Config T
|
||||
// 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
|
||||
diagnostics []Diagnostic
|
||||
@@ -83,7 +89,7 @@ type Result[T any] struct {
|
||||
}
|
||||
|
||||
// Metadata returns detached effective presence, origin, and history records.
|
||||
func (r *Result[T]) Metadata() Metadata {
|
||||
func (r *Inspection) Metadata() Metadata {
|
||||
if r == nil {
|
||||
return Metadata{}
|
||||
}
|
||||
@@ -91,7 +97,7 @@ func (r *Result[T]) Metadata() Metadata {
|
||||
}
|
||||
|
||||
// Sources returns a detached list of sources used by the load.
|
||||
func (r *Result[T]) Sources() []SourceInfo {
|
||||
func (r *Inspection) Sources() []SourceInfo {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -99,7 +105,7 @@ func (r *Result[T]) Sources() []SourceInfo {
|
||||
}
|
||||
|
||||
// Diagnostics returns detached nonfatal diagnostics from the load.
|
||||
func (r *Result[T]) Diagnostics() []Diagnostic {
|
||||
func (r *Inspection) Diagnostics() []Diagnostic {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -107,13 +113,13 @@ func (r *Result[T]) Diagnostics() []Diagnostic {
|
||||
}
|
||||
|
||||
// Load parses and strictly decodes zero or one selected source, explicitly enabled
|
||||
// imports, and one selected profile. It accepts at most one Options value and
|
||||
// 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, supplied ...Options[T]) (*Result[T], error) {
|
||||
return loadWithSecretLookup(source, os.LookupEnv, supplied...)
|
||||
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, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
|
||||
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
|
||||
accumulator := diagnosticAccumulator{}
|
||||
options, diagnostics := selectOptions(source, supplied)
|
||||
accumulator.add(diagnostics...)
|
||||
@@ -127,7 +133,7 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
schema, schemaIssue := buildSchema(reflect.TypeFor[T]())
|
||||
schema, schemaIssue := buildSchema(reflect.TypeFor[T](), secrets)
|
||||
if schemaIssue != nil {
|
||||
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||
return nil, accumulator.loadError()
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
@@ -25,7 +25,7 @@ func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput
|
||||
input.document = input.composition.root
|
||||
} else {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
if options.Filesystem != nil && !activeProfile(options.Profile) {
|
||||
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
@@ -40,17 +40,17 @@ func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput
|
||||
}
|
||||
|
||||
func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) {
|
||||
if !activeProfile(options.Profile) && len(options.Overrides) == 0 {
|
||||
if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 {
|
||||
return input, nil
|
||||
}
|
||||
composition, diagnostics := compositionForInput(input)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
if activeProfile(options.Profile) {
|
||||
composition, input.sources, diagnostics = loadProfileDocument(
|
||||
if activeOverlay(options.Overlay) {
|
||||
composition, input.sources, diagnostics = loadSelectedOverlayDocument(
|
||||
source, options.Filesystem, input.parsed, composition, input.sources,
|
||||
options.Profile, options.Definition.Version, defaultImportLimits(),
|
||||
options.Overlay, options.Definition.Version, defaultImportLimits(),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
@@ -69,7 +69,7 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// compositionForInput initializes one handoff for profiles and CLI, including
|
||||
// compositionForInput initializes one handoff for overlays and CLI, including
|
||||
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
|
||||
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
|
||||
if input.composition != nil {
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
@@ -6,6 +6,47 @@ import (
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
// resolutionInput is the immutable entry-state provenance available to a
|
||||
// resolution callback. It owns detached metadata and source records so later
|
||||
// resolution updates cannot change the entry view.
|
||||
type resolutionInput struct {
|
||||
metadata Metadata
|
||||
sources map[string]SourceInfo
|
||||
}
|
||||
|
||||
func newResolutionInput(metadata Metadata, final *document.Node, sources []SourceInfo) resolutionInput {
|
||||
frozen := Metadata{
|
||||
fields: make(map[string]FieldInfo, len(metadata.fields)),
|
||||
effectivePaths: effectivePathIndex(final),
|
||||
}
|
||||
for pointer, info := range metadata.fields {
|
||||
frozen.fields[pointer] = cloneFieldInfo(info)
|
||||
}
|
||||
indexed := make(map[string]SourceInfo, len(sources))
|
||||
for _, source := range sources {
|
||||
if source.ID == "" {
|
||||
panic("configkit: source identity is missing")
|
||||
}
|
||||
if _, exists := indexed[source.ID]; exists {
|
||||
panic("configkit: source identity is duplicated")
|
||||
}
|
||||
indexed[source.ID] = cloneSourceInfo(source)
|
||||
}
|
||||
return resolutionInput{metadata: frozen, sources: indexed}
|
||||
}
|
||||
|
||||
func (input resolutionInput) lookup(path Path) (FieldInfo, bool) {
|
||||
return input.metadata.Lookup(path)
|
||||
}
|
||||
|
||||
func (input resolutionInput) source(id string) (SourceInfo, bool) {
|
||||
source, ok := input.sources[id]
|
||||
if !ok {
|
||||
return SourceInfo{}, false
|
||||
}
|
||||
return cloneSourceInfo(source), true
|
||||
}
|
||||
|
||||
func metadataFromDecoded(decoded decodedConfiguration) Metadata {
|
||||
metadata := Metadata{
|
||||
fields: make(map[string]FieldInfo),
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
@@ -51,7 +51,8 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
||||
final := decoded.final
|
||||
var resolutionSnapshots []resolutionSnapshot
|
||||
if definition.Resolve != nil {
|
||||
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document)
|
||||
entry := newResolutionInput(metadata, decoded.final, input.sources)
|
||||
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
|
||||
diagnostics = append(diagnostics, resolved.diagnostics...)
|
||||
if hasErrors(resolved.diagnostics) {
|
||||
return resolvedLoad[T]{}, diagnostics
|
||||
@@ -79,7 +80,7 @@ func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, m
|
||||
return detached.Interface().(T), nil
|
||||
}
|
||||
|
||||
func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Result[T], error) {
|
||||
func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Loaded[T], error) {
|
||||
// A read-only validator can replace a value with an equal borrowed object.
|
||||
// Detach without re-reading the environment or discarding validated state.
|
||||
configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets)
|
||||
@@ -91,8 +92,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
||||
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
|
||||
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
|
||||
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
|
||||
return &Result[T]{
|
||||
Config: configuration,
|
||||
inspection := &Inspection{
|
||||
metadata: metadata,
|
||||
sources: cloneSourceInfos(accumulator.sources),
|
||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||
@@ -104,7 +104,8 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
||||
applications: applications,
|
||||
secrets: secrets,
|
||||
secretReferences: cloneSecretReferences(references),
|
||||
}, nil
|
||||
}
|
||||
return &Loaded[T]{Config: configuration, Inspection: inspection}, nil
|
||||
}
|
||||
|
||||
func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic {
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -38,7 +38,7 @@ func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var defaultsCalls atomic.Int32
|
||||
result, err := Load(test.source(), Options[config]{Definition: Definition[config]{
|
||||
result, err := testLoad(test.source(), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config {
|
||||
defaultsCalls.Add(1)
|
||||
return config{Name: "default"}
|
||||
@@ -92,7 +92,7 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
||||
validation.Warning(versionPath, "review schema compatibility", Keys("missing_related"))
|
||||
return nil
|
||||
}}
|
||||
result, err := Load(test.source, Options[config]{Definition: Definition[config]{Version: rule}})
|
||||
result, err := testLoad(test.source, Options[config]{Definition: Definition[config]{Version: rule}})
|
||||
if test.wantErr != "" {
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
@@ -144,21 +144,21 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
|
||||
load func(Source, *VersionRule) (string, error)
|
||||
}{
|
||||
{name: "integer into string", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||
result, err := testLoad(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Config.Version, nil
|
||||
}},
|
||||
{name: "string into string", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||
result, err := testLoad(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Config.Version, nil
|
||||
}},
|
||||
{name: "integer into pointer", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||
result, err := testLoad(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
|
||||
return *result.Config.Version, nil
|
||||
}},
|
||||
{name: "string into pointer", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||
result, err := testLoad(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -194,7 +194,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
if result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil {
|
||||
if result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil {
|
||||
t.Fatalf("load without rule = %#v, %v", result, err)
|
||||
} else {
|
||||
var loadError *LoadError
|
||||
@@ -207,7 +207,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
|
||||
Label string `yaml:"label"`
|
||||
}
|
||||
rule := &VersionRule{Path: versionPath, Check: func(Version, *Validation) error { return nil }}
|
||||
if result, err := Load(Bytes("version.yml", []byte("version: 4\nlabel: 5\n")), Options[unrelatedConfig]{Definition: Definition[unrelatedConfig]{Version: rule}}); result != nil {
|
||||
if result, err := testLoad(Bytes("version.yml", []byte("version: 4\nlabel: 5\n")), Options[unrelatedConfig]{Definition: Definition[unrelatedConfig]{Version: rule}}); result != nil {
|
||||
t.Fatalf("unrelated coercion = %#v, %v", result, err)
|
||||
} else {
|
||||
var loadError *LoadError
|
||||
@@ -240,7 +240,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
|
||||
checked = version
|
||||
return nil
|
||||
}}
|
||||
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||
result, err := testLoad(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -266,7 +266,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
|
||||
called = true
|
||||
return nil
|
||||
}}
|
||||
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||
result, err := testLoad(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||
if result != nil || called {
|
||||
t.Fatalf("result = %#v, checker called = %v", result, called)
|
||||
}
|
||||
@@ -289,7 +289,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
result, err := Load(Bytes("nested.yml", []byte("versions:\n \"0\": 4\nsibling: quoted\n")), Options[nested]{Definition: Definition[nested]{Version: rule}})
|
||||
result, err := testLoad(Bytes("nested.yml", []byte("versions:\n \"0\": 4\nsibling: quoted\n")), Options[nested]{Definition: Definition[nested]{Version: rule}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
|
||||
type sequenceConfig struct {
|
||||
Versions []namedVersion `yaml:"versions"`
|
||||
}
|
||||
sequenceResult, err := Load(Bytes("sequence.yml", []byte("versions: [6]\n")), Options[sequenceConfig]{Definition: Definition[sequenceConfig]{Version: &VersionRule{Path: parsed, Check: func(Version, *Validation) error { return nil }}}})
|
||||
sequenceResult, err := testLoad(Bytes("sequence.yml", []byte("versions: [6]\n")), Options[sequenceConfig]{Definition: Definition[sequenceConfig]{Version: &VersionRule{Path: parsed, Check: func(Version, *Validation) error { return nil }}}})
|
||||
if err != nil || len(sequenceResult.Config.Versions) != 1 || sequenceResult.Config.Versions[0] != "6" {
|
||||
t.Fatalf("sequence configuration = %#v, %v", sequenceResult, err)
|
||||
}
|
||||
@@ -311,13 +311,13 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
escapedResult, err := Load(Bytes("escaped.yml", []byte("versions:\n a/b: 9\n")), Options[escaped]{Definition: Definition[escaped]{Version: &VersionRule{Path: escapedPath, Check: func(Version, *Validation) error { return nil }}}})
|
||||
escapedResult, err := testLoad(Bytes("escaped.yml", []byte("versions:\n a/b: 9\n")), Options[escaped]{Definition: Definition[escaped]{Version: &VersionRule{Path: escapedPath, Check: func(Version, *Validation) error { return nil }}}})
|
||||
if err != nil || escapedResult.Config.Versions["a/b"] != "9" {
|
||||
t.Fatalf("escaped configuration = %#v, %v", escapedResult, err)
|
||||
}
|
||||
|
||||
siblingRule := &VersionRule{Path: Keys("versions", "target"), Check: func(Version, *Validation) error { return nil }}
|
||||
if result, err := Load(Bytes("sibling.yml", []byte("versions:\n target: 4\n other: 5\n")), Options[escaped]{Definition: Definition[escaped]{Version: siblingRule}}); result != nil {
|
||||
if result, err := testLoad(Bytes("sibling.yml", []byte("versions:\n target: 4\n other: 5\n")), Options[escaped]{Definition: Definition[escaped]{Version: siblingRule}}); result != nil {
|
||||
t.Fatalf("same-type sibling result = %#v, %v", result, err)
|
||||
} else {
|
||||
requireDiagnostic(t, err, CodeDecodeType, "/versions/other")
|
||||
@@ -332,16 +332,16 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
|
||||
type config struct {
|
||||
Version int8 `yaml:"version"`
|
||||
}
|
||||
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
if err != nil || result.Config.Version != 4 {
|
||||
t.Fatalf("result = %#v, %v", result, err)
|
||||
}
|
||||
result, err = Load(Bytes("version.yml", []byte("version: 999\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
result, err = testLoad(Bytes("version.yml", []byte("version: 999\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
if result != nil {
|
||||
t.Fatalf("range result = %#v", result)
|
||||
}
|
||||
requireDiagnostic(t, err, CodeDecodeRange, "/version")
|
||||
result, err = Load(Bytes("version.yml", []byte("version: \"4\"\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
result, err = testLoad(Bytes("version.yml", []byte("version: \"4\"\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
if result != nil {
|
||||
t.Fatalf("quoted integer result = %#v", result)
|
||||
}
|
||||
@@ -352,11 +352,11 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
|
||||
type config struct {
|
||||
Version any `yaml:"version"`
|
||||
}
|
||||
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
if err != nil || result.Config.Version != int64(4) {
|
||||
t.Fatalf("result = %#v, %v", result, err)
|
||||
}
|
||||
result, err = Load(Bytes("version.yml", []byte("version: 123456789012345678901234567890\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
result, err = testLoad(Bytes("version.yml", []byte("version: 123456789012345678901234567890\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
if result != nil {
|
||||
t.Fatalf("range result = %#v", result)
|
||||
}
|
||||
@@ -371,21 +371,21 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
|
||||
type config struct {
|
||||
Version validatedText `yaml:"version"`
|
||||
}
|
||||
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
_, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
return err
|
||||
}},
|
||||
{name: "duration", load: func() error {
|
||||
type config struct {
|
||||
Version time.Duration `yaml:"version"`
|
||||
}
|
||||
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
_, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
return err
|
||||
}},
|
||||
{name: "secret", load: func() error {
|
||||
type config struct {
|
||||
Version Secret `yaml:"version"`
|
||||
}
|
||||
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
_, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
return err
|
||||
}},
|
||||
} {
|
||||
@@ -409,7 +409,7 @@ func TestLoadVersionPolicyStopsLaterHooks(t *testing.T) {
|
||||
validation.Error(Keys("version"), "unsupported configuration version")
|
||||
return nil
|
||||
}}
|
||||
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{
|
||||
result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config { defaultsCalled = true; return config{} },
|
||||
Version: rule,
|
||||
Resolve: func(*config, *Resolution) error { resolutionCalled = true; return nil },
|
||||
@@ -440,7 +440,7 @@ func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
var defaultsCalls atomic.Int32
|
||||
result, err := Load(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{
|
||||
result, err := testLoad(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config {
|
||||
defaultsCalls.Add(1)
|
||||
return config{Name: "default"}
|
||||
@@ -474,7 +474,7 @@ func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
|
||||
Nested nested `yaml:"nested"`
|
||||
Nullable map[string]string `yaml:"nullable"`
|
||||
}
|
||||
result, err := Load(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{
|
||||
result, err := testLoad(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config {
|
||||
return config{Nested: nested{Kept: "default", Set: "default"}, Nullable: map[string]string{"key": "value"}}
|
||||
},
|
||||
@@ -538,7 +538,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := Load(test.source, test.options...)
|
||||
result, err := testLoad(test.source, test.options...)
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
}
|
||||
@@ -549,7 +549,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n")))
|
||||
result, err := testLoad[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n")))
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
}
|
||||
@@ -577,9 +577,9 @@ func TestLoadReturnsNilForSourceSchemaAndDecodeFailures(t *testing.T) {
|
||||
run func() (any, error)
|
||||
code Code
|
||||
}{
|
||||
{name: "source", run: func() (any, error) { return Load[validConfig](File(filepath.Join(t.TempDir(), "missing.yml"))) }, code: CodeSourceMissing},
|
||||
{name: "schema", run: func() (any, error) { return Load[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType},
|
||||
{name: "decode", run: func() (any, error) { return Load[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField},
|
||||
{name: "source", run: func() (any, error) { return testLoad[validConfig](File(filepath.Join(t.TempDir(), "missing.yml"))) }, code: CodeSourceMissing},
|
||||
{name: "schema", run: func() (any, error) { return testLoad[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType},
|
||||
{name: "decode", run: func() (any, error) { return testLoad[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
149
internal/engine/metadata.go
Normal file
149
internal/engine/metadata.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
// Metadata provides detached effective presence/origin and chronological history
|
||||
// queries for a load. Its zero value contains no paths.
|
||||
type Metadata struct {
|
||||
effectivePaths map[string]Path
|
||||
historicalPaths map[string][]Path
|
||||
fields map[string]FieldInfo
|
||||
history map[string][]Change
|
||||
}
|
||||
|
||||
// Lookup returns information for an effective path. Unknown and invalid paths
|
||||
// return false.
|
||||
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
|
||||
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
|
||||
return FieldInfo{}, false
|
||||
}
|
||||
info, ok := m.fields[path.String()]
|
||||
if !ok {
|
||||
return FieldInfo{}, false
|
||||
}
|
||||
return cloneFieldInfo(info), true
|
||||
}
|
||||
|
||||
// IsExplicit reports whether a surviving input contribution explicitly defined
|
||||
// path.
|
||||
func (m Metadata) IsExplicit(path Path) bool {
|
||||
info, ok := m.Lookup(path)
|
||||
return ok && info.Explicit
|
||||
}
|
||||
|
||||
// History returns detached definition and resolution records for path. A record's
|
||||
// Path can be an ancestor when that container operation replaced or removed path.
|
||||
// Removed historical paths can have history even though Lookup returns false.
|
||||
// Unknown and invalid paths return nil.
|
||||
func (m Metadata) History(path Path) []Change {
|
||||
if !m.hasHistoricalPath(path) {
|
||||
return nil
|
||||
}
|
||||
history, ok := m.history[path.String()]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]Change, len(history))
|
||||
for i, change := range history {
|
||||
cloned[i] = cloneChange(change)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// HistoricalPaths returns the paths observed during loading, including effective
|
||||
// paths and paths removed by overlays or recorded resolution. It includes default
|
||||
// paths and the root, but not paths that never existed. The zero Metadata returns
|
||||
// an empty slice.
|
||||
//
|
||||
// Paths are detached, unique by canonical JSON Pointer, and sorted by that
|
||||
// spelling. Like ParsePath results, numeric tokens are resolved contextually;
|
||||
// one pointer can identify a mapping key in an earlier layer and a sequence index
|
||||
// in a later one. Use History to inspect definitions and Lookup to check whether
|
||||
// a path remains effective. Enumeration exposes no configuration values and does
|
||||
// not perform another load or resolve secrets.
|
||||
func (m Metadata) HistoricalPaths() []Path {
|
||||
pointers := make([]string, 0, len(m.historicalPaths))
|
||||
for pointer := range m.historicalPaths {
|
||||
pointers = append(pointers, pointer)
|
||||
}
|
||||
sort.Strings(pointers)
|
||||
paths := make([]Path, 0, len(pointers))
|
||||
for _, pointer := range pointers {
|
||||
// The index contains only canonical pointers produced by Path.String.
|
||||
path, _ := ParsePath(pointer)
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
|
||||
// the query. Candidate paths are constructed from actual document containers.
|
||||
func matchesPathShape(query, candidate Path) bool {
|
||||
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) {
|
||||
return false
|
||||
}
|
||||
for i, segment := range query.segments {
|
||||
actual := candidate.segments[i]
|
||||
if actual.kind == pathSegmentIndex {
|
||||
index, ok := schemaIndex(segment)
|
||||
if !ok || index != actual.index {
|
||||
return false
|
||||
}
|
||||
} else if segment.kind == pathSegmentIndex {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m Metadata) hasHistoricalPath(path Path) bool {
|
||||
if !path.valid() {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range m.historicalPaths[path.String()] {
|
||||
if matchesPathShape(path, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
|
||||
m.effectivePaths = effectivePathIndex(final)
|
||||
m.historicalPaths = make(map[string][]Path)
|
||||
add := func(path Path, _ *document.Node) {
|
||||
pointer := path.String()
|
||||
for _, prior := range m.historicalPaths[pointer] {
|
||||
if matchesPathShape(path, prior) {
|
||||
return
|
||||
}
|
||||
}
|
||||
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
|
||||
}
|
||||
walkDocument(final, Path{}, add)
|
||||
for _, tree := range earlier {
|
||||
walkDocument(tree, Path{}, add)
|
||||
}
|
||||
for _, application := range applications {
|
||||
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
|
||||
prefix := Path{}
|
||||
for _, segment := range application.path.segments {
|
||||
prefix = prefix.Key(segment.key)
|
||||
}
|
||||
walkDocument(application.value, prefix, add)
|
||||
}
|
||||
}
|
||||
|
||||
// effectivePathIndex records document-derived paths with their contextual segment
|
||||
// kinds. Callers retain no aliases to the walked tree or its paths.
|
||||
func effectivePathIndex(final *document.Node) map[string]Path {
|
||||
paths := make(map[string]Path)
|
||||
walkDocument(final, Path{}, func(path Path, _ *document.Node) {
|
||||
paths[path.String()] = path.clone()
|
||||
})
|
||||
return paths
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
func TestMetadataHistoricalPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
result, err := Load[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{
|
||||
result, err := testLoad[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{
|
||||
Overrides: []Override{
|
||||
{Path: Keys("retired"), Value: nil, Flag: "--retired"},
|
||||
{Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
|
||||
@@ -60,8 +60,8 @@ func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *
|
||||
root := writeImportFile(t, dir, "main.yml", "configkit:\n imports: [{path: first.yml}, {path: second.yml}]\ncredential: CONFIGKIT_HISTORY_UNUSED_PROD_KEY\n")
|
||||
writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n")
|
||||
writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n")
|
||||
writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n")
|
||||
result, err := Load[config](File(root), Options[config]{Imports: true, Profile: "testing"})
|
||||
writeImportFile(t, dir, "overlays/testing.yml", "definitions: null\ncredential: "+credential+"\n")
|
||||
result, err := testLoad[config](File(root), Options[config]{Imports: true, Overlay: "testing"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -148,8 +148,91 @@ func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type configuration struct {
|
||||
Default string `yaml:"default"`
|
||||
Items []string `yaml:"items"`
|
||||
Values map[string]string `yaml:"values"`
|
||||
Nullable *string `yaml:"nullable"`
|
||||
}
|
||||
schema := mustSchemaFor[configuration](t)
|
||||
document := parseDocumentForDecode(t, "items: [first]\nvalues: {'0': key, 'a/b~': escaped}\nnullable: null\n")
|
||||
decoded, issues := decodeConfiguration(schema, reflect.ValueOf(configuration{Default: "retained"}), document)
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("decode issues = %#v", issues)
|
||||
}
|
||||
metadata := metadataFromDecoded(decoded)
|
||||
input := newResolutionInput(metadata, decoded.final, []SourceInfo{{
|
||||
ID: "source-1", Label: "decode.yml", Layer: LayerBase,
|
||||
ImportedFrom: &Location{SourceID: "parent", Line: 1, Column: 1},
|
||||
}})
|
||||
|
||||
parsedItem, err := ParsePath("/items/0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedKey, err := ParsePath("/values/0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []Path{Path{}, Keys("default"), Keys("nullable"), parsedItem, parsedKey, Keys("values", "a/b~")} {
|
||||
if _, ok := input.lookup(path); !ok {
|
||||
t.Fatalf("entry path %s is missing", path)
|
||||
}
|
||||
}
|
||||
defaultInfo, _ := input.lookup(Keys("default"))
|
||||
nullInfo, _ := input.lookup(Keys("nullable"))
|
||||
if defaultInfo.Explicit || !nullInfo.Explicit || !nullInfo.Null {
|
||||
t.Fatalf("default = %#v, nullable = %#v", defaultInfo, nullInfo)
|
||||
}
|
||||
for _, path := range []Path{
|
||||
Keys("default", "child"),
|
||||
Keys("items", "0"),
|
||||
Keys("values").Index(0),
|
||||
Keys("items").Index(1),
|
||||
} {
|
||||
if _, ok := input.lookup(path); ok {
|
||||
t.Fatalf("entry lookup accepted %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
item, _ := input.lookup(parsedItem)
|
||||
item.Origin.Locations[0].SourceID = "mutated"
|
||||
source, ok := input.source("source-1")
|
||||
if !ok {
|
||||
t.Fatal("source is missing")
|
||||
}
|
||||
source.ImportedFrom.SourceID = "mutated"
|
||||
|
||||
configurationAtEntry := decoded.value.Interface().(configuration)
|
||||
resolved := runResolution(&configurationAtEntry, func(value *configuration, resolution *Resolution) error {
|
||||
value.Items[0] = "normalized"
|
||||
resolution.Record(Keys("items"), ChangeNormalization, "normalized item")
|
||||
return nil
|
||||
}, schema, decoded, document, input)
|
||||
if len(resolved.diagnostics) != 0 {
|
||||
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
|
||||
}
|
||||
applyResolutionMetadata(&metadata, resolved)
|
||||
metadata.effectivePaths = effectivePathIndex(resolved.final)
|
||||
|
||||
again, ok := input.lookup(parsedItem)
|
||||
if !ok || again.Origin.Locations[0].SourceID != "source-1" || again.Origin.Operation != "" {
|
||||
t.Fatalf("entry metadata changed after resolution = %#v, %v", again, ok)
|
||||
}
|
||||
againSource, ok := input.source("source-1")
|
||||
if !ok || againSource.ImportedFrom == nil || againSource.ImportedFrom.SourceID != "parent" {
|
||||
t.Fatalf("entry source changed = %#v, %v", againSource, ok)
|
||||
}
|
||||
if _, ok := input.source(""); ok {
|
||||
t.Fatal("empty source identity resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
|
||||
result, err := Load[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
|
||||
result, err := testLoad[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) {
|
||||
if len(supplied) > 1 {
|
||||
@@ -10,8 +10,8 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
||||
}
|
||||
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 !validOverlayName(options.Overlay) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
|
||||
}
|
||||
if options.Filesystem != nil {
|
||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
||||
@@ -19,11 +19,11 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
||||
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 source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay"))
|
||||
}
|
||||
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 options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeOverlay(options.Overlay)) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or overlays require filesystem context"))
|
||||
}
|
||||
if definition.Version != nil {
|
||||
if !definition.Version.Path.valid() {
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
@@ -20,14 +20,14 @@ type displacedDefinition struct {
|
||||
func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) {
|
||||
if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
|
||||
return nil, []Diagnostic{{
|
||||
Code: CodeProfileInvalid, Severity: SeverityError,
|
||||
Message: "profile overlay requires mapping document roots",
|
||||
Code: CodeOverlayInvalid, Severity: SeverityError,
|
||||
Message: "overlay requires mapping document roots",
|
||||
}}
|
||||
}
|
||||
result := cloneComposedDocument(base)
|
||||
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
||||
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
||||
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
|
||||
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerOverlay)
|
||||
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerOverlay, true)
|
||||
result.applications = append(result.applications, historyApplication{layer: LayerOverlay, value: overlay})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
@@ -22,7 +22,7 @@ to_scalar: {child: old}
|
||||
}
|
||||
overlay := parseCompositionDocument(t, "source-2", `shared:
|
||||
changed: new
|
||||
added: profile
|
||||
added: overlay
|
||||
sequence: []
|
||||
nullable: null
|
||||
to_map: {child: new}
|
||||
@@ -42,7 +42,7 @@ to_scalar: scalar
|
||||
}{
|
||||
{path: Keys("shared", "kept"), kind: document.String, text: "base"},
|
||||
{path: Keys("shared", "changed"), kind: document.String, text: "new"},
|
||||
{path: Keys("shared", "added"), kind: document.String, text: "profile"},
|
||||
{path: Keys("shared", "added"), kind: document.String, text: "overlay"},
|
||||
{path: Keys("sequence"), kind: document.Sequence},
|
||||
{path: Keys("nullable"), kind: document.Null},
|
||||
{path: Keys("to_map"), kind: document.Mapping},
|
||||
@@ -62,7 +62,7 @@ to_scalar: scalar
|
||||
t.Fatal("parent replacement retained a stale child origin")
|
||||
}
|
||||
shared := result.origins["/shared"]
|
||||
if shared.Layer != LayerProfile || len(shared.Locations) != 2 {
|
||||
if shared.Layer != LayerOverlay || len(shared.Locations) != 2 {
|
||||
t.Fatalf("shared mapping origin = %#v", shared)
|
||||
}
|
||||
if len(result.overlays) != 5 {
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -41,9 +41,9 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
||||
labels: {base: retained}
|
||||
api_key: BASE_SECRET
|
||||
`)
|
||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `settings:
|
||||
target: profile
|
||||
labels: {profile: retained}
|
||||
writeImportFile(t, directory, filepath.Join("overlays", "testing.yml"), `settings:
|
||||
target: overlay
|
||||
labels: {overlay: retained}
|
||||
`)
|
||||
validated := false
|
||||
overrides := []Override{
|
||||
@@ -56,13 +56,13 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
||||
{Path: Keys("settings", "labels"), Value: map[string]any{"cli": "selected"}, Flag: "--label"},
|
||||
{Path: Keys("settings", "api_key"), Value: "CLI_SECRET", Flag: "--api-key-env"},
|
||||
}
|
||||
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
|
||||
result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
|
||||
if name != "CLI_SECRET" {
|
||||
t.Fatalf("looked up superseded secret name %q", name)
|
||||
}
|
||||
return "credential-value", true
|
||||
}, Options[overrideLifecycleConfig]{
|
||||
Imports: true, Profile: "testing", Overrides: overrides,
|
||||
Imports: true, Overlay: "testing", Overrides: overrides,
|
||||
Definition: Definition[overrideLifecycleConfig]{
|
||||
Defaults: func() overrideLifecycleConfig {
|
||||
return overrideLifecycleConfig{Settings: overrideLifecycleSettings{Target: "default", Count: 9, Text: "default", Items: []string{"default"}}}
|
||||
@@ -83,10 +83,10 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("loadWithSecretLookup() error = %v", err)
|
||||
t.Fatalf("testLoadWithSecretLookup() error = %v", err)
|
||||
}
|
||||
want := overrideLifecycleSettings{
|
||||
Target: "cli", Labels: map[string]string{"base": "retained", "profile": "retained", "cli": "selected"},
|
||||
Target: "cli", Labels: map[string]string{"base": "retained", "overlay": "retained", "cli": "selected"},
|
||||
APIKey: result.Config.Settings.APIKey,
|
||||
}
|
||||
if !validated || result.Config.Settings.Count != 0 || result.Config.Settings.Enabled || result.Config.Settings.Text != "" ||
|
||||
@@ -95,7 +95,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
||||
t.Fatalf("effective settings = %#v", result.Config.Settings)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3+len(overrides) || sources[0].Layer != LayerBase || sources[1].Layer != LayerBase || sources[2].Layer != LayerProfile {
|
||||
if len(sources) != 3+len(overrides) || sources[0].Layer != LayerBase || sources[1].Layer != LayerBase || sources[2].Layer != LayerOverlay {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
for index, override := range overrides {
|
||||
@@ -104,7 +104,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
||||
}
|
||||
}
|
||||
history := result.Metadata().History(Keys("settings", "target"))
|
||||
wantLayers := []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}
|
||||
wantLayers := []Layer{LayerDefault, LayerBase, LayerOverlay, LayerCLI, LayerResolution}
|
||||
if got := historyLayers(history); !reflect.DeepEqual(got, wantLayers) {
|
||||
t.Fatalf("target history layers = %#v, want %#v; history %#v", got, wantLayers, history)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func TestOverridesSupportDefaultsOnlyMapMergeListReplacementAndNumericKeys(t *te
|
||||
"int": int8(1), "uint": uint16(2), "float": float32(3.5), "bool": false,
|
||||
"null": nil, "list": []any{"value"}, "map": map[string]any{"key": "value"},
|
||||
}
|
||||
result, err := Load(NoSource(), Options[config]{
|
||||
result, err := testLoad(NoSource(), Options[config]{
|
||||
Definition: Definition[config]{Defaults: func() config {
|
||||
return config{Labels: map[string]string{"kept": "default"}, Items: []int{9}}
|
||||
}},
|
||||
@@ -194,7 +194,7 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
||||
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := Load(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides})
|
||||
result, err := testLoad(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides})
|
||||
if result != nil {
|
||||
t.Fatalf("result = %#v, want nil", result)
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
|
||||
type config struct {
|
||||
Schema schemaConfig `yaml:"schema"`
|
||||
}
|
||||
_, err := Load(Bytes("version.yml", []byte("schema: {version: 4}\n")), Options[config]{
|
||||
_, err := testLoad(Bytes("version.yml", []byte("schema: {version: 4}\n")), Options[config]{
|
||||
Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
||||
Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}},
|
||||
})
|
||||
@@ -224,7 +224,7 @@ func TestOverrideDecodeFailureUsesFlagSourceWithoutRawValue(t *testing.T) {
|
||||
type config struct {
|
||||
Count int `yaml:"count"`
|
||||
}
|
||||
result, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
|
||||
result, err := testLoad(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
|
||||
Path: Keys("missing"), Value: "RAW-ARG-CANARY", Flag: "--missing",
|
||||
}}})
|
||||
if result != nil {
|
||||
@@ -249,7 +249,7 @@ func TestOverrideTypeMismatchUsesNormalStrictDecode(t *testing.T) {
|
||||
type config struct {
|
||||
Count int `yaml:"count"`
|
||||
}
|
||||
_, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
|
||||
_, err := testLoad(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
|
||||
Path: Keys("count"), Value: "not-an-integer", Flag: "--count",
|
||||
}}})
|
||||
loadError := requireSecretLoadCode(t, err, CodeDecodeType)
|
||||
@@ -262,7 +262,7 @@ func TestOverrideEqualReplacementStillAppearsInHistory(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
result, err := Load(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}})
|
||||
result, err := testLoad(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"math"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
148
internal/engine/path.go
Normal file
148
internal/engine/path.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Path identifies a configuration value using YAML field names. Its textual form
|
||||
// is a JSON Pointer: the root is empty, ~ escapes as ~0, and / escapes as ~1.
|
||||
// Paths are semantic rather than filesystem or URL paths: empty, dot, dot-dot,
|
||||
// percent-containing, and numeric keys retain their literal meaning. Parsed
|
||||
// numeric tokens are resolved only when traversal knows their containing type.
|
||||
//
|
||||
// Paths are immutable. The zero value is the root path.
|
||||
type Path struct {
|
||||
segments []pathSegment
|
||||
invalid bool
|
||||
}
|
||||
|
||||
type pathSegmentKind uint8
|
||||
|
||||
const (
|
||||
pathSegmentKey pathSegmentKind = iota
|
||||
pathSegmentIndex
|
||||
pathSegmentParsed
|
||||
)
|
||||
|
||||
type pathSegment struct {
|
||||
kind pathSegmentKind
|
||||
key string
|
||||
index int
|
||||
}
|
||||
|
||||
// Keys constructs a path of mapping keys. Keys with no arguments returns the
|
||||
// root path.
|
||||
func Keys(keys ...string) Path {
|
||||
p := Path{}
|
||||
for _, key := range keys {
|
||||
p = p.Key(key)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ParsePath parses a JSON Pointer. Numeric tokens remain unresolved until schema
|
||||
// or document traversal determines whether the containing value is a mapping or
|
||||
// sequence.
|
||||
func ParsePath(pointer string) (Path, error) {
|
||||
if pointer == "" {
|
||||
return Path{}, nil
|
||||
}
|
||||
if pointer[0] != '/' {
|
||||
return Path{}, errors.New("configkit: path must be empty or begin with slash")
|
||||
}
|
||||
|
||||
parts := strings.Split(pointer[1:], "/")
|
||||
segments := make([]pathSegment, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
key, ok := unescapePathToken(part)
|
||||
if !ok {
|
||||
return Path{}, errors.New("configkit: path contains an invalid tilde escape")
|
||||
}
|
||||
segments = append(segments, pathSegment{kind: pathSegmentParsed, key: key})
|
||||
}
|
||||
return Path{segments: segments}, nil
|
||||
}
|
||||
|
||||
// Key returns a new path with key appended as a mapping-key segment.
|
||||
func (p Path) Key(key string) Path {
|
||||
segments := clonePathSegments(p.segments, 1)
|
||||
segments = append(segments, pathSegment{kind: pathSegmentKey, key: key})
|
||||
return Path{segments: segments, invalid: p.invalid}
|
||||
}
|
||||
|
||||
// Index returns a new path with index appended as a sequence-index segment.
|
||||
// A negative index produces an invalid path that configuration operations reject.
|
||||
func (p Path) Index(index int) Path {
|
||||
segments := clonePathSegments(p.segments, 1)
|
||||
segments = append(segments, pathSegment{kind: pathSegmentIndex, index: index})
|
||||
return Path{segments: segments, invalid: p.invalid || index < 0}
|
||||
}
|
||||
|
||||
// String returns the path's canonical JSON Pointer spelling.
|
||||
func (p Path) String() string {
|
||||
if len(p.segments) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, segment := range p.segments {
|
||||
b.WriteByte('/')
|
||||
if segment.kind == pathSegmentIndex {
|
||||
b.WriteString(strconv.Itoa(segment.index))
|
||||
continue
|
||||
}
|
||||
b.WriteString(escapePathToken(segment.key))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (p Path) clone() Path {
|
||||
return Path{segments: clonePathSegments(p.segments, 0), invalid: p.invalid}
|
||||
}
|
||||
|
||||
func (p Path) valid() bool {
|
||||
return !p.invalid
|
||||
}
|
||||
|
||||
func clonePathSegments(segments []pathSegment, extra int) []pathSegment {
|
||||
if len(segments) == 0 && extra == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]pathSegment, len(segments), len(segments)+extra)
|
||||
copy(cloned, segments)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func escapePathToken(token string) string {
|
||||
token = strings.ReplaceAll(token, "~", "~0")
|
||||
return strings.ReplaceAll(token, "/", "~1")
|
||||
}
|
||||
|
||||
func unescapePathToken(token string) (string, bool) {
|
||||
if !strings.Contains(token, "~") {
|
||||
return token, true
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(token); i++ {
|
||||
if token[i] != '~' {
|
||||
b.WriteByte(token[i])
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(token) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
switch token[i] {
|
||||
case '0':
|
||||
b.WriteByte('~')
|
||||
case '1':
|
||||
b.WriteByte('/')
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return b.String(), true
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
@@ -199,7 +199,7 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
||||
case descriptorSecret:
|
||||
node, issue := o.node(document.String, path, depth)
|
||||
if issue == nil {
|
||||
node.Text = value.Interface().(Secret).EnvName()
|
||||
node.Text = o.schema.secrets.Read(value).Name
|
||||
}
|
||||
return node, issue
|
||||
case descriptorText:
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -359,9 +359,9 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
||||
|
||||
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
|
||||
t.Helper()
|
||||
schema, issue := buildSchema(reflect.TypeFor[T]())
|
||||
schema, issue := testBuildSchema(reflect.TypeFor[T]())
|
||||
if issue != nil {
|
||||
t.Fatalf("buildSchema() issue = %#v", issue)
|
||||
t.Fatalf("testBuildSchema() issue = %#v", issue)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
510
internal/engine/provenance_test.go
Normal file
510
internal/engine/provenance_test.go
Normal file
@@ -0,0 +1,510 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type layeredItem struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
type layeredConfig struct {
|
||||
Shared struct {
|
||||
Equal string `yaml:"equal"`
|
||||
Imported string `yaml:"imported"`
|
||||
Overlay string `yaml:"overlay"`
|
||||
} `yaml:"shared"`
|
||||
Values []int `yaml:"values"`
|
||||
Items map[string]layeredItem `yaml:"items"`
|
||||
Value any `yaml:"value"`
|
||||
Nullable *string `yaml:"nullable"`
|
||||
Derived int `yaml:"derived"`
|
||||
Empty map[string]string `yaml:"empty"`
|
||||
}
|
||||
|
||||
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports: [{path: imported.yml}]
|
||||
shared: {equal: same}
|
||||
values: [1, 2]
|
||||
items:
|
||||
keep: {value: 'yes'}
|
||||
remove: {value: 'no'}
|
||||
value: {child: base}
|
||||
nullable: base
|
||||
empty: {}
|
||||
`)
|
||||
importPath := writeImportFile(t, directory, "imported.yml", "shared: {imported: base}\n")
|
||||
overlayPath := writeImportFile(t, directory, "overlays/testing.yml", `shared: {overlay: selected}
|
||||
values: [3, 4]
|
||||
value: selected
|
||||
nullable: null
|
||||
empty: {}
|
||||
`)
|
||||
defaultNullable := "default"
|
||||
definition := Definition[layeredConfig]{
|
||||
Defaults: func() layeredConfig {
|
||||
defaults := layeredConfig{
|
||||
Values: []int{0, 0}, Value: map[string]any{"child": "default"},
|
||||
Nullable: &defaultNullable, Empty: map[string]string{"retained": "default"},
|
||||
}
|
||||
defaults.Shared.Equal = "same"
|
||||
return defaults
|
||||
},
|
||||
Resolve: func(configuration *layeredConfig, resolution *Resolution) error {
|
||||
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
|
||||
"bounded values", Keys("shared", "imported"), Keys("shared", "imported"))
|
||||
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
|
||||
"bounded values", Keys("shared", "imported"))
|
||||
delete(configuration.Items, "remove")
|
||||
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled items")
|
||||
Default(resolution, Keys("derived"), &configuration.Derived, 7,
|
||||
"derived from equal", Keys("shared", "equal"), Keys("shared", "equal"))
|
||||
resolution.Warning(Keys("values"), "values were bounded", Keys("shared", "imported"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := testLoad[layeredConfig](File(mainPath), Options[layeredConfig]{
|
||||
Imports: true, Overlay: "testing", Definition: definition,
|
||||
})
|
||||
if err != nil {
|
||||
var loadError *LoadError
|
||||
_ = errors.As(err, &loadError)
|
||||
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
|
||||
}
|
||||
wantSources := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, importPath), canonicalTestPath(t, overlayPath)}
|
||||
sources := result.Sources()
|
||||
gotSources := make([]string, len(sources))
|
||||
for index, source := range sources {
|
||||
gotSources[index] = source.Path
|
||||
}
|
||||
if !reflect.DeepEqual(gotSources, wantSources) || sources[2].Layer != LayerOverlay {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
if result.Config.Shared.Equal != "same" || result.Config.Shared.Imported != "base" || result.Config.Shared.Overlay != "selected" ||
|
||||
!reflect.DeepEqual(result.Config.Values, []int{5}) || result.Config.Nullable != nil || result.Config.Derived != 7 ||
|
||||
result.Config.Empty["retained"] != "default" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
|
||||
metadata := result.Metadata()
|
||||
equal, _ := metadata.Lookup(Keys("shared", "equal"))
|
||||
derived, _ := metadata.Lookup(Keys("derived"))
|
||||
nullable, _ := metadata.Lookup(Keys("nullable"))
|
||||
empty, _ := metadata.Lookup(Keys("empty"))
|
||||
firstValue, _ := metadata.Lookup(Keys("values").Index(0))
|
||||
if !equal.Explicit || equal.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault ||
|
||||
!nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerOverlay ||
|
||||
!empty.Explicit || empty.Origin.Layer != LayerOverlay || len(empty.Origin.Locations) != 2 ||
|
||||
!firstValue.Explicit || firstValue.Origin.Operation != ChangeNormalization {
|
||||
t.Fatalf("metadata = equal %#v derived %#v nullable %#v empty %#v value %#v", equal, derived, nullable, empty, firstValue)
|
||||
}
|
||||
if _, ok := metadata.Lookup(Keys("value", "child")); ok {
|
||||
t.Fatal("overlay-removed child remains effective")
|
||||
}
|
||||
if _, ok := metadata.Lookup(Keys("values").Index(1)); ok {
|
||||
t.Fatal("resolution-removed sequence index remains effective")
|
||||
}
|
||||
if _, ok := metadata.Lookup(Keys("items", "remove", "value")); ok {
|
||||
t.Fatal("resolution-removed mapping child remains effective")
|
||||
}
|
||||
|
||||
assertHistoryLayersAndPaths(t, metadata.History(Keys("values").Index(1)),
|
||||
[]Layer{LayerDefault, LayerBase, LayerOverlay, LayerResolution},
|
||||
[]string{"/values/1", "/values/1", "/values", "/values"})
|
||||
assertHistoryLayersAndPaths(t, metadata.History(Keys("value", "child")),
|
||||
[]Layer{LayerDefault, LayerBase, LayerOverlay},
|
||||
[]string{"/value/child", "/value/child", "/value"})
|
||||
removedHistory := metadata.History(Keys("items", "remove", "value"))
|
||||
assertHistoryLayersAndPaths(t, removedHistory,
|
||||
[]Layer{LayerBase, LayerResolution},
|
||||
[]string{"/items/remove/value", "/items"})
|
||||
if !reflect.DeepEqual(removedHistory[1].Reasons, []string{"removed disabled items"}) {
|
||||
t.Fatalf("removed history = %#v", removedHistory)
|
||||
}
|
||||
valuesHistory := metadata.History(Keys("values").Index(0))
|
||||
lastValue := valuesHistory[len(valuesHistory)-1]
|
||||
if !reflect.DeepEqual(lastValue.Reasons, []string{"bounded values"}) || len(lastValue.Dependencies) != 1 || lastValue.Dependencies[0].String() != "/shared/imported" {
|
||||
t.Fatalf("canonical resolution history = %#v", lastValue)
|
||||
}
|
||||
|
||||
schema, issue := testBuildSchema(reflect.TypeFor[layeredConfig]())
|
||||
if issue != nil {
|
||||
t.Fatal(issue)
|
||||
}
|
||||
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
|
||||
if projectionIssue != nil || !configurationValuesEqual(projected, result.final) {
|
||||
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
|
||||
}
|
||||
|
||||
metadata.history[Keys("values").Index(0).String()][0].Origin.Layer = "mutated"
|
||||
sources[1].ImportedFrom.SourceID = "mutated"
|
||||
diagnostics := result.Diagnostics()
|
||||
diagnostics[0].Related[0].SourceID = "mutated"
|
||||
result.Config.Values[0] = 99
|
||||
freshHistory := result.Metadata().History(Keys("values").Index(0))
|
||||
if freshHistory[0].Origin.Layer == "mutated" || result.Sources()[1].ImportedFrom.SourceID == "mutated" ||
|
||||
result.Diagnostics()[0].Related[0].SourceID == "mutated" {
|
||||
t.Fatal("returned inspection records alias private state")
|
||||
}
|
||||
snapshotValue, ok := documentAtPath(result.final, Keys("values").Index(0))
|
||||
if !ok || snapshotValue.Text != "5" {
|
||||
t.Fatal("caller configuration mutation changed the private final snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedOverlayLoadsRemainIndependentConcurrently(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "values: [1, 2]\n")
|
||||
writeImportFile(t, directory, "overlays/testing.yml", "values: [3, 4]\n")
|
||||
options := Options[layeredConfig]{Overlay: "testing"}
|
||||
|
||||
const loads = 16
|
||||
results := make(chan *testResult[layeredConfig], loads)
|
||||
errors := make(chan error, loads)
|
||||
var group sync.WaitGroup
|
||||
for index := 0; index < loads; index++ {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
result, err := testLoad[layeredConfig](File(mainPath), options)
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
close(results)
|
||||
close(errors)
|
||||
for err := range errors {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var collected []*testResult[layeredConfig]
|
||||
for result := range results {
|
||||
collected = append(collected, result)
|
||||
}
|
||||
if len(collected) != loads {
|
||||
t.Fatalf("completed loads = %d", len(collected))
|
||||
}
|
||||
collected[0].Config.Values[0] = 99
|
||||
collected[0].metadata.fields[Keys("values").String()] = FieldInfo{}
|
||||
for _, result := range collected[1:] {
|
||||
if !reflect.DeepEqual(result.Config.Values, []int{3, 4}) {
|
||||
t.Fatalf("concurrent result contaminated = %#v", result.Config.Values)
|
||||
}
|
||||
if info, ok := result.Metadata().Lookup(Keys("values")); !ok || info.Origin.Layer != LayerOverlay {
|
||||
t.Fatalf("concurrent metadata contaminated = %#v, %v", info, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionInputKeepsLayeredOriginsAndSources(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports: [{path: first.yml}, {path: second.yml}]
|
||||
main: main
|
||||
equal: same
|
||||
cli_equal: same
|
||||
composite: {main: main}
|
||||
`)
|
||||
firstPath := writeImportFile(t, directory, "first.yml", `imported: imported
|
||||
sequence: [first, second]
|
||||
composite: {first: first}
|
||||
`)
|
||||
secondPath := writeImportFile(t, directory, "second.yml", "composite: {second: second}\n")
|
||||
overlayPath := writeImportFile(t, directory, "overlays/testing.yml", "equal: same\noverlay: overlay\n")
|
||||
compositeSources := map[string]bool{
|
||||
canonicalTestPath(t, mainPath): true,
|
||||
canonicalTestPath(t, firstPath): true,
|
||||
canonicalTestPath(t, secondPath): true,
|
||||
}
|
||||
type config struct {
|
||||
Main string `yaml:"main"`
|
||||
Imported string `yaml:"imported"`
|
||||
Sequence []string `yaml:"sequence"`
|
||||
Composite map[string]string `yaml:"composite"`
|
||||
Equal string `yaml:"equal"`
|
||||
Overlay string `yaml:"overlay"`
|
||||
CLIEqual string `yaml:"cli_equal"`
|
||||
}
|
||||
var callbackErr error
|
||||
result, err := testLoad[config](File(mainPath), Options[config]{
|
||||
Imports: true, Overlay: "testing", Overrides: []Override{{Path: Keys("cli_equal"), Value: "same", Flag: "--cli-equal"}},
|
||||
Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
check := func(ok bool, message string) {
|
||||
if !ok && callbackErr == nil {
|
||||
callbackErr = errors.New(message)
|
||||
}
|
||||
}
|
||||
fileOwner := func(path Path, want string) FieldInfo {
|
||||
info, found := resolution.InputInfo(path)
|
||||
check(found && len(info.Origin.Locations) == 1, "scalar has no exact origin")
|
||||
if !found || len(info.Origin.Locations) != 1 {
|
||||
return FieldInfo{}
|
||||
}
|
||||
source, exists := resolution.Source(info.Origin.Locations[0].SourceID)
|
||||
check(exists && source.Path == want, "scalar source does not match its declaration")
|
||||
return info
|
||||
}
|
||||
fileOwner(Keys("main"), canonicalTestPath(t, mainPath))
|
||||
fileOwner(Keys("imported"), canonicalTestPath(t, firstPath))
|
||||
fileOwner(Keys("sequence").Index(1), canonicalTestPath(t, firstPath))
|
||||
overlay := fileOwner(Keys("equal"), canonicalTestPath(t, overlayPath))
|
||||
fileOwner(Keys("overlay"), canonicalTestPath(t, overlayPath))
|
||||
check(overlay.Origin.Layer == LayerOverlay, "overlay replacement retained base ownership")
|
||||
cli, found := resolution.InputInfo(Keys("cli_equal"))
|
||||
check(found && cli.Origin.Layer == LayerCLI && len(cli.Origin.Locations) == 1, "CLI replacement lost ownership")
|
||||
if found && len(cli.Origin.Locations) == 1 {
|
||||
source, exists := resolution.Source(cli.Origin.Locations[0].SourceID)
|
||||
check(exists && source.Layer == LayerCLI && source.Path == "", "CLI source has a file path or is missing")
|
||||
}
|
||||
|
||||
composite, found := resolution.InputInfo(Keys("composite"))
|
||||
check(found && len(composite.Origin.Locations) == 3, "composite mapping did not retain every contributor")
|
||||
for _, location := range composite.Origin.Locations {
|
||||
source, exists := resolution.Source(location.SourceID)
|
||||
check(exists && compositeSources[source.Path], "composite location has no expected source record")
|
||||
delete(compositeSources, source.Path)
|
||||
}
|
||||
check(len(compositeSources) == 0, "composite sources are incomplete")
|
||||
|
||||
imported, _ := resolution.InputInfo(Keys("imported"))
|
||||
importedSourceID := imported.Origin.Locations[0].SourceID
|
||||
imported.Origin.Locations[0].SourceID = "mutated"
|
||||
importSource, _ := resolution.Source(importedSourceID)
|
||||
check(importSource.ImportedFrom != nil, "import source is missing its import location")
|
||||
if importSource.ImportedFrom != nil {
|
||||
importSource.ImportedFrom.SourceID = "mutated"
|
||||
}
|
||||
again, _ := resolution.InputInfo(Keys("imported"))
|
||||
againSource, _ := resolution.Source(importedSourceID)
|
||||
check(again.Origin.Locations[0].SourceID == importedSourceID, "input record is aliased")
|
||||
check(againSource.ImportedFrom != nil && againSource.ImportedFrom.SourceID == "source-1", "source record is aliased")
|
||||
return callbackErr
|
||||
}},
|
||||
})
|
||||
if callbackErr != nil {
|
||||
t.Fatal(callbackErr)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Main != "main" || result.Config.Imported != "imported" || !reflect.DeepEqual(result.Config.Sequence, []string{"first", "second"}) ||
|
||||
result.Config.Equal != "same" || result.Config.Overlay != "overlay" || result.Config.CLIEqual != "same" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
|
||||
resultField, _ := result.Metadata().Lookup(Keys("imported"))
|
||||
resultSource := result.Sources()[1]
|
||||
if resultField.Origin.Locations[0].SourceID != "source-2" || resultSource.ImportedFrom == nil || resultSource.ImportedFrom.SourceID != "source-1" {
|
||||
t.Fatal("callback record mutation changed the completed result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionInputUsesEntryShapesAcrossTypedAndDynamicValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Embedded struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
type config struct {
|
||||
Embedded `yaml:",inline"`
|
||||
Mappings map[string]string `yaml:"mappings"`
|
||||
Array [2]string `yaml:"array"`
|
||||
Dynamic any `yaml:"dynamic"`
|
||||
}
|
||||
var callbackErr error
|
||||
_, err := testLoad[config](Bytes("shapes.yml", []byte("name: inlined\nmappings: {'-0': key, 'a/b~': escaped}\narray: [zero, one]\ndynamic: {nested: [dynamic]}\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
check := func(path Path, want bool) {
|
||||
_, found := resolution.InputInfo(path)
|
||||
if found != want && callbackErr == nil {
|
||||
callbackErr = errors.New("entry shape lookup disagrees with its document container")
|
||||
}
|
||||
}
|
||||
parsedMapping, _ := ParsePath("/mappings/-0")
|
||||
escapedMapping, _ := ParsePath("/mappings/a~1b~0")
|
||||
check(Keys("name"), true)
|
||||
check(parsedMapping, true)
|
||||
check(escapedMapping, true)
|
||||
check(Keys("mappings").Index(0), false)
|
||||
check(Keys("array").Index(1), true)
|
||||
check(Keys("array", "1"), false)
|
||||
check(Keys("array").Index(2), false)
|
||||
check(Keys("dynamic", "nested").Index(0), true)
|
||||
check(Keys("dynamic", "nested", "0"), false)
|
||||
negative, _ := ParsePath("/array/-1")
|
||||
check(negative, false)
|
||||
return callbackErr
|
||||
}}})
|
||||
if callbackErr != nil {
|
||||
t.Fatal(callbackErr)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionInputDistinguishesNonFileRootsAndImportedFiles(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
importPath := writeImportFile(t, directory, "fragment.yml", "imported: file\n")
|
||||
type config struct {
|
||||
Root string `yaml:"root"`
|
||||
Imported string `yaml:"imported"`
|
||||
Default string `yaml:"default"`
|
||||
}
|
||||
input := "configkit: {imports: [{path: fragment.yml}]}\nroot: memory\n"
|
||||
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source func() Source
|
||||
}{
|
||||
{name: "bytes", source: func() Source { return Bytes("memory://config.yml", []byte(input)) }},
|
||||
{name: "reader", source: func() Source { return Reader("memory://config.yml", strings.NewReader(input)) }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var callbackErr error
|
||||
_, err := testLoad[config](test.source(), Options[config]{Imports: true, Filesystem: context, Definition: Definition[config]{
|
||||
Defaults: func() config { return config{Default: "retained"} },
|
||||
Resolve: func(_ *config, resolution *Resolution) error {
|
||||
root, _ := resolution.InputInfo(Keys("root"))
|
||||
imported, _ := resolution.InputInfo(Keys("imported"))
|
||||
defaulted, _ := resolution.InputInfo(Keys("default"))
|
||||
rootSource, rootFound := resolution.Source(root.Origin.Locations[0].SourceID)
|
||||
importSource, importFound := resolution.Source(imported.Origin.Locations[0].SourceID)
|
||||
if !rootFound || rootSource.Path != "" || !importFound || importSource.Path != canonicalTestPath(t, importPath) || defaulted.Explicit {
|
||||
callbackErr = errors.New("non-file root or imported file provenance is wrong")
|
||||
}
|
||||
if _, found := resolution.Source("unknown"); found {
|
||||
callbackErr = errors.New("unknown source was found")
|
||||
}
|
||||
return callbackErr
|
||||
},
|
||||
}})
|
||||
if callbackErr != nil {
|
||||
t.Fatal(callbackErr)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var callbackErr error
|
||||
_, err := testLoad[config](Bytes("memory://standalone.yml", []byte("root: memory\n")), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config { return config{Default: "retained"} },
|
||||
Resolve: func(_ *config, resolution *Resolution) error {
|
||||
root, rootFound := resolution.InputInfo(Keys("root"))
|
||||
defaulted, defaultFound := resolution.InputInfo(Keys("default"))
|
||||
source, sourceFound := resolution.Source(root.Origin.Locations[0].SourceID)
|
||||
if !rootFound || !defaultFound || !sourceFound || source.Path != "" || defaulted.Explicit {
|
||||
callbackErr = errors.New("standalone non-file provenance is wrong")
|
||||
}
|
||||
return callbackErr
|
||||
},
|
||||
}})
|
||||
if callbackErr != nil {
|
||||
t.Fatal(callbackErr)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionInputRemainsFixedAcrossRecordedMutations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Existing string `yaml:"existing"`
|
||||
Values map[string]string `yaml:"values"`
|
||||
}
|
||||
var callbackErr error
|
||||
result, err := testLoad[config](Bytes("mutations.yml", []byte("existing: original\nvalues: {original: move}\n")), Options[config]{Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
|
||||
original, originalFound := resolution.InputInfo(Keys("values", "original"))
|
||||
_, destinationFound := resolution.InputInfo(Keys("values", "destination"))
|
||||
existing, existingFound := resolution.InputInfo(Keys("existing"))
|
||||
Normalize(resolution, Keys("existing"), &value.Existing, "normalized", "normalized existing value")
|
||||
value.Values["destination"] = value.Values["original"]
|
||||
delete(value.Values, "original")
|
||||
resolution.Record(Keys("values"), ChangeNormalization, "moved configured value")
|
||||
againOriginal, againOriginalFound := resolution.InputInfo(Keys("values", "original"))
|
||||
againDestination, againDestinationFound := resolution.InputInfo(Keys("values", "destination"))
|
||||
againExisting, againExistingFound := resolution.InputInfo(Keys("existing"))
|
||||
if !originalFound || destinationFound || !existingFound || !againOriginalFound || againDestinationFound || !againExistingFound ||
|
||||
!reflect.DeepEqual(original, againOriginal) || !reflect.DeepEqual(existing, againExisting) || !reflect.DeepEqual(againDestination, FieldInfo{}) {
|
||||
callbackErr = errors.New("entry view changed during resolution")
|
||||
}
|
||||
return callbackErr
|
||||
}}})
|
||||
if callbackErr != nil {
|
||||
t.Fatal(callbackErr)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Existing != "normalized" || result.Config.Values["destination"] != "move" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
if _, found := result.Metadata().Lookup(Keys("values", "original")); found {
|
||||
t.Fatal("final metadata retained a removed path")
|
||||
}
|
||||
destination, found := result.Metadata().Lookup(Keys("values", "destination"))
|
||||
existing, existingFound := result.Metadata().Lookup(Keys("existing"))
|
||||
if !found || destination.Origin.Layer != LayerResolution || !existingFound || existing.Origin.Layer != LayerResolution {
|
||||
t.Fatalf("final metadata = destination %#v existing %#v", destination, existing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionInputQueriesRemainIndependentAcrossLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
const loads = 16
|
||||
failures := make(chan error, loads)
|
||||
var group sync.WaitGroup
|
||||
for index := 0; index < loads; index++ {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
_, err := testLoad[config](Bytes("parallel.yml", []byte("value: stable\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
info, found := resolution.InputInfo(Keys("value"))
|
||||
source, sourceFound := resolution.Source(info.Origin.Locations[0].SourceID)
|
||||
if !found || !sourceFound || source.ID != "source-1" {
|
||||
return errors.New("independent entry lookup failed")
|
||||
}
|
||||
info.Origin.Locations[0].SourceID = "mutated"
|
||||
source.Label = "mutated"
|
||||
return nil
|
||||
}}})
|
||||
if err != nil {
|
||||
failures <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
close(failures)
|
||||
for err := range failures {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHistoryLayersAndPaths(t *testing.T, history []Change, layers []Layer, paths []string) {
|
||||
t.Helper()
|
||||
if len(history) != len(layers) || len(history) != len(paths) {
|
||||
t.Fatalf("history = %#v", history)
|
||||
}
|
||||
for index, change := range history {
|
||||
if change.Origin.Layer != layers[index] || change.Path.String() != paths[index] {
|
||||
t.Fatalf("history[%d] = %#v", index, change)
|
||||
}
|
||||
}
|
||||
}
|
||||
286
internal/engine/records.go
Normal file
286
internal/engine/records.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package engine
|
||||
|
||||
// Layer identifies the precedence layer responsible for a configuration value.
|
||||
type Layer string
|
||||
|
||||
const (
|
||||
LayerDefault Layer = "default"
|
||||
LayerBase Layer = "base"
|
||||
LayerOverlay Layer = "overlay"
|
||||
LayerCLI Layer = "cli"
|
||||
LayerResolution Layer = "resolution"
|
||||
)
|
||||
|
||||
// ChangeKind identifies a recorded operation. The zero value is
|
||||
// used for input and default origins.
|
||||
type ChangeKind string
|
||||
|
||||
const (
|
||||
// ChangeDecoding records the built-in transition to canonical typed values.
|
||||
// It is inspection metadata, not an operation accepted by Resolution.Record.
|
||||
ChangeDecoding ChangeKind = "decoding"
|
||||
ChangeDerivedDefault ChangeKind = "derived_default"
|
||||
ChangeNormalization ChangeKind = "normalization"
|
||||
)
|
||||
|
||||
// Severity classifies a diagnostic.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityError Severity = "error"
|
||||
SeverityWarning Severity = "warning"
|
||||
)
|
||||
|
||||
// Code is a stable, machine-readable diagnostic classification.
|
||||
type Code string
|
||||
|
||||
const (
|
||||
CodeSourceInvalid Code = "source.invalid"
|
||||
CodeSourceMissing Code = "source.missing"
|
||||
CodeSourcePermission Code = "source.permission"
|
||||
CodeSourceRead Code = "source.read"
|
||||
CodeSourceNotRegular Code = "source.not_regular"
|
||||
CodeSourceLimitExceeded Code = "source.limit_exceeded"
|
||||
CodeSourceReaderUsed Code = "source.reader_used"
|
||||
|
||||
CodeYAMLInvalid Code = "yaml.invalid"
|
||||
CodeYAMLEmpty Code = "yaml.empty"
|
||||
CodeYAMLMultipleDocuments Code = "yaml.multiple_documents"
|
||||
CodeYAMLRootType Code = "yaml.root_type"
|
||||
CodeYAMLDuplicateKey Code = "yaml.duplicate_key"
|
||||
CodeYAMLTag Code = "yaml.tag"
|
||||
CodeYAMLAnchor Code = "yaml.anchor"
|
||||
CodeYAMLAlias Code = "yaml.alias"
|
||||
CodeYAMLMergeKey Code = "yaml.merge_key"
|
||||
CodeYAMLScalar Code = "yaml.scalar"
|
||||
CodeYAMLDepthLimit Code = "yaml.depth_limit"
|
||||
CodeYAMLNodeLimit Code = "yaml.node_limit"
|
||||
|
||||
CodeSchemaRoot Code = "schema.root"
|
||||
CodeSchemaUnsupportedType Code = "schema.unsupported_type"
|
||||
CodeSchemaAmbiguousField Code = "schema.ambiguous_field"
|
||||
CodeSchemaTag Code = "schema.tag"
|
||||
CodeSchemaTextCodec Code = "schema.text_codec"
|
||||
CodeSchemaInline Code = "schema.inline"
|
||||
CodeSchemaCycle Code = "schema.cycle"
|
||||
CodeSchemaProjectionLimit Code = "schema.projection_limit"
|
||||
CodeSchemaCodec Code = "schema.codec"
|
||||
CodeSchemaNonFinite Code = "schema.non_finite"
|
||||
CodeSchemaDynamicType Code = "schema.dynamic_type"
|
||||
|
||||
CodeDecodeType Code = "decode.type"
|
||||
CodeDecodeNull Code = "decode.null"
|
||||
CodeDecodeRange Code = "decode.range"
|
||||
CodeDecodeDuration Code = "decode.duration"
|
||||
CodeDecodeText Code = "decode.text"
|
||||
CodeDecodeUnknownField Code = "decode.unknown_field"
|
||||
CodeDecodeArrayLength Code = "decode.array_length"
|
||||
|
||||
CodeOptionsInvalid Code = "options.invalid"
|
||||
CodeOptionsUnavailable Code = "options.unavailable"
|
||||
|
||||
CodeImportDisabled Code = "import.disabled"
|
||||
CodeImportInvalid Code = "import.invalid"
|
||||
CodeImportMissing Code = "import.missing"
|
||||
CodeImportPermission Code = "import.permission"
|
||||
CodeImportRead Code = "import.read"
|
||||
CodeImportOutsideRoot Code = "import.outside_root"
|
||||
CodeImportCycle Code = "import.cycle"
|
||||
CodeImportRepeated Code = "import.repeated"
|
||||
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
||||
CodeImportVersion Code = "import.version"
|
||||
|
||||
CodeOverlayInvalid Code = "overlay.invalid"
|
||||
CodeOverlayMissing Code = "overlay.missing"
|
||||
CodeOverlayOutsideRoot Code = "overlay.outside_root"
|
||||
CodeOverlayRepeated Code = "overlay.repeated"
|
||||
CodeOverlayDirective Code = "overlay.directive"
|
||||
CodeOverlayVersion Code = "overlay.version"
|
||||
CodeOverlayLimitExceeded Code = "overlay.limit_exceeded"
|
||||
|
||||
CodeComposeInvalid Code = "compose.invalid"
|
||||
CodeComposeConflict Code = "compose.conflict"
|
||||
|
||||
CodeVersionType Code = "version.type"
|
||||
CodeVersionError Code = "version.error"
|
||||
CodeVersionWarning Code = "version.warning"
|
||||
CodeVersionHookFailed Code = "version.hook_failed"
|
||||
|
||||
CodeResolveError Code = "resolve.error"
|
||||
CodeResolveWarning Code = "resolve.warning"
|
||||
CodeResolveHookFailed Code = "resolve.hook_failed"
|
||||
CodeResolveInvalidPath Code = "resolve.invalid_path"
|
||||
CodeResolveInvalidKind Code = "resolve.invalid_kind"
|
||||
CodeResolveInvalidRecord Code = "resolve.invalid_record"
|
||||
CodeResolveInvalidTarget Code = "resolve.invalid_target"
|
||||
CodeResolveInvalidValue Code = "resolve.invalid_value"
|
||||
CodeResolveUnrecordedChange Code = "resolve.unrecorded_change"
|
||||
|
||||
CodeValidateError Code = "validate.error"
|
||||
CodeValidateWarning Code = "validate.warning"
|
||||
CodeValidateInvalidPath Code = "validate.invalid_path"
|
||||
CodeValidateMutation Code = "validate.mutation"
|
||||
|
||||
CodeSecretsInvalidPath Code = "secrets.invalid_path"
|
||||
CodeSecretsMissing Code = "secrets.missing"
|
||||
CodeSecretsPermission Code = "secrets.permission"
|
||||
CodeSecretsRead Code = "secrets.read"
|
||||
CodeSecretsNotRegular Code = "secrets.not_regular"
|
||||
CodeSecretsInvalidName Code = "secrets.invalid_name"
|
||||
CodeSecretsLimitExceeded Code = "secrets.limit_exceeded"
|
||||
CodeSecretsInvalidValue Code = "secrets.invalid_value"
|
||||
CodeSecretsApplyFailed Code = "secrets.apply_failed"
|
||||
|
||||
CodeSecretRequired Code = "secret.required"
|
||||
CodeSecretInvalidName Code = "secret.invalid_name"
|
||||
CodeSecretUnset Code = "secret.unset"
|
||||
CodeSecretEmpty Code = "secret.empty"
|
||||
|
||||
CodeOverrideInvalidPath Code = "override.invalid_path"
|
||||
CodeOverrideInvalidLabel Code = "override.invalid_label"
|
||||
CodeOverrideInvalidValue Code = "override.invalid_value"
|
||||
CodeOverrideDuplicate Code = "override.duplicate"
|
||||
CodeOverrideOverlap Code = "override.overlap"
|
||||
CodeOverrideDirective Code = "override.directive"
|
||||
CodeOverrideVersion Code = "override.version"
|
||||
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
|
||||
|
||||
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
|
||||
)
|
||||
|
||||
// Location identifies a position in a configuration source. Zero line and column
|
||||
// values mean that coordinates are unavailable or synthetic.
|
||||
type Location struct {
|
||||
SourceID string
|
||||
Line int
|
||||
Column int
|
||||
}
|
||||
|
||||
// SourceInfo describes a source discovered during a load. Path is a canonical
|
||||
// local path when one exists. ImportedFrom identifies the declaration that
|
||||
// included this source.
|
||||
type SourceInfo struct {
|
||||
ID string
|
||||
Label string
|
||||
Path string
|
||||
Layer Layer
|
||||
ImportedFrom *Location
|
||||
}
|
||||
|
||||
// Diagnostic is a safe structured problem report. Message and source labels must
|
||||
// never contain configuration values or other secrets.
|
||||
type Diagnostic struct {
|
||||
Code Code
|
||||
Severity Severity
|
||||
Path Path
|
||||
Message string
|
||||
Primary *Location
|
||||
Related []Location
|
||||
}
|
||||
|
||||
// Origin describes where an effective definition came from. Multiple locations
|
||||
// represent a composite origin; resolution origins are synthetic and identify their
|
||||
// operation instead of a file location.
|
||||
type Origin struct {
|
||||
Layer Layer
|
||||
Locations []Location
|
||||
Operation ChangeKind
|
||||
}
|
||||
|
||||
// FieldInfo describes an effective configuration path.
|
||||
type FieldInfo struct {
|
||||
Explicit bool
|
||||
Null bool
|
||||
Origin Origin
|
||||
}
|
||||
|
||||
// Change describes a definition or application-resolution operation. Path can name
|
||||
// an ancestor operation affecting the queried descendant. Historical values are
|
||||
// intentionally not exposed by this record.
|
||||
type Change struct {
|
||||
Path Path
|
||||
Origin Origin
|
||||
Previous []Origin
|
||||
Reasons []string
|
||||
Dependencies []Path
|
||||
}
|
||||
|
||||
func cloneLocation(location *Location) *Location {
|
||||
if location == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *location
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneLocations(locations []Location) []Location {
|
||||
if locations == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]Location(nil), locations...)
|
||||
}
|
||||
|
||||
func cloneSourceInfo(source SourceInfo) SourceInfo {
|
||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||
return source
|
||||
}
|
||||
|
||||
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
|
||||
if sources == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]SourceInfo, len(sources))
|
||||
for i, source := range sources {
|
||||
cloned[i] = cloneSourceInfo(source)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneOrigin(origin Origin) Origin {
|
||||
origin.Locations = cloneLocations(origin.Locations)
|
||||
return origin
|
||||
}
|
||||
|
||||
func cloneFieldInfo(info FieldInfo) FieldInfo {
|
||||
info.Origin = cloneOrigin(info.Origin)
|
||||
return info
|
||||
}
|
||||
|
||||
func cloneChange(change Change) Change {
|
||||
change.Path = change.Path.clone()
|
||||
change.Origin = cloneOrigin(change.Origin)
|
||||
if change.Previous != nil {
|
||||
previous := make([]Origin, len(change.Previous))
|
||||
for i, origin := range change.Previous {
|
||||
previous[i] = cloneOrigin(origin)
|
||||
}
|
||||
change.Previous = previous
|
||||
}
|
||||
change.Reasons = append([]string(nil), change.Reasons...)
|
||||
if change.Dependencies != nil {
|
||||
dependencies := make([]Path, len(change.Dependencies))
|
||||
for i, path := range change.Dependencies {
|
||||
dependencies[i] = path.clone()
|
||||
}
|
||||
change.Dependencies = dependencies
|
||||
}
|
||||
return change
|
||||
}
|
||||
|
||||
func cloneDiagnostic(diagnostic Diagnostic) Diagnostic {
|
||||
diagnostic.Path = diagnostic.Path.clone()
|
||||
diagnostic.Primary = cloneLocation(diagnostic.Primary)
|
||||
diagnostic.Related = cloneLocations(diagnostic.Related)
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
func cloneDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
||||
if diagnostics == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]Diagnostic, len(diagnostics))
|
||||
for i, diagnostic := range diagnostics {
|
||||
cloned[i] = cloneDiagnostic(diagnostic)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -14,7 +14,7 @@ var errEffectiveUnavailable = errors.New("configkit: effective configuration is
|
||||
// WriteEffective writes deterministic redacted YAML from the frozen resolved
|
||||
// snapshot. The output is intended for inspection and is not promised reloadable.
|
||||
// Writer errors, including short writes, are returned and may leave partial output.
|
||||
func (r *Result[T]) WriteEffective(output io.Writer) error {
|
||||
func (r *Inspection) WriteEffective(output io.Writer) error {
|
||||
if r == nil || r.final == nil || output == nil {
|
||||
return errEffectiveUnavailable
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -71,7 +71,7 @@ empty_sequence: []
|
||||
labels: {hidden: DYNAMIC_CANARY_6a12, public: visible}
|
||||
sequence: [second, first]
|
||||
`)
|
||||
writeImportFile(t, directory, "profiles/testing.yml", "dynamic: selected\n")
|
||||
writeImportFile(t, directory, "overlays/testing.yml", "dynamic: selected\n")
|
||||
definition := Definition[renderConfig]{
|
||||
Secret: func(path Path) bool {
|
||||
return path.String() == "/labels/hidden" || path.String() == "/dynamic/retired"
|
||||
@@ -85,7 +85,7 @@ sequence: [second, first]
|
||||
return nil
|
||||
},
|
||||
}
|
||||
result, err := Load[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Profile: "testing"})
|
||||
result, err := testLoad[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Overlay: "testing"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -139,7 +139,7 @@ sequence: [second, first]
|
||||
if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short writer error = %v", err)
|
||||
}
|
||||
var nilResult *Result[renderConfig]
|
||||
var nilResult *testResult[renderConfig]
|
||||
if err := nilResult.WriteEffective(io.Discard); !errors.Is(err, errEffectiveUnavailable) {
|
||||
t.Fatalf("nil result error = %v", err)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func TestSecretValuesStayOutOfLoadFailuresAndWarnings(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.definition.Secret = func(path Path) bool { return path.String() == "/credentials" }
|
||||
result, err := Load[renderConfig](Bytes("secret-failure.yml", []byte(test.input)), Options[renderConfig]{Definition: test.definition})
|
||||
result, err := testLoad[renderConfig](Bytes("secret-failure.yml", []byte(test.input)), Options[renderConfig]{Definition: test.definition})
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, error = %#v, %v", result, err)
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func TestDynamicConcreteTagsRemainSecretThroughHistory(t *testing.T) {
|
||||
Normalize(r, Keys("derived"), &c.Derived, canary, "derive", path)
|
||||
return nil
|
||||
}
|
||||
result, err := Load(NoSource(), Options[cfg]{Definition: definition})
|
||||
result, err := testLoad(NoSource(), Options[cfg]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func TestValidatorCannotChangeDynamicSensitivityWithoutChangingText(t *testing.T
|
||||
type cfg struct {
|
||||
Payload any `yaml:"payload"`
|
||||
}
|
||||
_, err := Load(Bytes("base", []byte("payload: {token: disposable}\n")), Options[cfg]{Definition: Definition[cfg]{Validate: func(c *cfg, v *Validation) {
|
||||
_, err := testLoad(Bytes("base", []byte("payload: {token: disposable}\n")), Options[cfg]{Definition: Definition[cfg]{Validate: func(c *cfg, v *Validation) {
|
||||
c.Payload = sensitive{Token: c.Payload.(map[string]any)["token"].(string)}
|
||||
}}})
|
||||
var failure *LoadError
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
@@ -15,6 +15,7 @@ type resolutionState struct {
|
||||
schema *schemaDescriptor
|
||||
presence map[string]inputPresence
|
||||
document *document.Node
|
||||
input resolutionInput
|
||||
records map[string]*resolutionRecord
|
||||
diagnostics []Diagnostic
|
||||
}
|
||||
@@ -50,6 +51,27 @@ func (r *Resolution) IsExplicit(path Path) bool {
|
||||
return explicit
|
||||
}
|
||||
|
||||
// InputInfo returns detached entry-state metadata for path. The entry state is
|
||||
// the decoded configuration, including retained static defaults, immediately
|
||||
// before resolution starts. It is fixed while resolution records mutate the
|
||||
// working configuration; unknown or shape-mismatched paths return false.
|
||||
//
|
||||
// InputInfo is valid only during the resolution callback. See the configuration
|
||||
// contract for source and provenance semantics.
|
||||
func (r *Resolution) InputInfo(path Path) (FieldInfo, bool) {
|
||||
return r.activeState().input.lookup(path)
|
||||
}
|
||||
|
||||
// Source returns a detached record for a source acquired by this load. Empty or
|
||||
// unknown IDs return false and do not perform input, filesystem, or credential
|
||||
// access. A source path is present only when that acquired source has one.
|
||||
//
|
||||
// Source is valid only during the resolution callback. See the configuration
|
||||
// contract for source and provenance semantics.
|
||||
func (r *Resolution) Source(id string) (SourceInfo, bool) {
|
||||
return r.activeState().input.source(id)
|
||||
}
|
||||
|
||||
// Record declares the intent for a resolver mutation. A container path covers
|
||||
// mutations to its descendants.
|
||||
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
|
||||
@@ -221,9 +243,10 @@ func (state *resolutionState) addDiagnostic(code Code, path Path, message string
|
||||
state.diagnostics = append(state.diagnostics, diagnostic)
|
||||
}
|
||||
|
||||
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node) resolutionResult {
|
||||
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput) resolutionResult {
|
||||
state := &resolutionState{
|
||||
active: true, schema: schema, presence: decoded.presence, document: input,
|
||||
input: entry,
|
||||
records: make(map[string]*resolutionRecord),
|
||||
}
|
||||
context := &Resolution{state: state}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -41,7 +41,7 @@ func TestLoadRunsRecordedResolutionWithMapWriteback(t *testing.T) {
|
||||
},
|
||||
}
|
||||
input := "poll_interval: 1m\nartifacts:\n second: {output: ' second '}\n first: {output: ' first '}\n"
|
||||
result, err := Load(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition})
|
||||
result, err := testLoad(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *t
|
||||
Absent int `yaml:"absent"`
|
||||
}
|
||||
five := 5
|
||||
result, err := Load(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{
|
||||
result, err := testLoad(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config { return config{Optional: &five, Count: 5, Absent: 5} },
|
||||
Resolve: func(configuration *config, resolution *Resolution) error {
|
||||
if !resolution.IsExplicit(Keys("optional")) || !resolution.IsExplicit(Keys("count")) || resolution.IsExplicit(Keys("absent")) {
|
||||
@@ -126,7 +126,7 @@ func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
|
||||
Value int `yaml:"value"`
|
||||
Other int `yaml:"other"`
|
||||
}
|
||||
result, err := Load(NoSource(), Options[config]{Definition: Definition[config]{
|
||||
result, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config { return config{Value: 5} },
|
||||
Resolve: func(configuration *config, resolution *Resolution) error {
|
||||
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
|
||||
@@ -157,7 +157,7 @@ func TestResolutionContainerRecordCoversSubtreeRemovalAndSnapshotsAreDetached(t
|
||||
type config struct {
|
||||
Items map[string]item `yaml:"items"`
|
||||
}
|
||||
result, err := Load(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{
|
||||
result, err := testLoad(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{
|
||||
Resolve: func(configuration *config, resolution *Resolution) error {
|
||||
delete(configuration.Items, "remove")
|
||||
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries")
|
||||
@@ -188,7 +188,7 @@ func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) {
|
||||
type config struct {
|
||||
Values []int `yaml:"values"`
|
||||
}
|
||||
result, err := Load(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{
|
||||
result, err := testLoad(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{
|
||||
Resolve: func(configuration *config, resolution *Resolution) error {
|
||||
Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values")
|
||||
return nil
|
||||
@@ -237,7 +237,7 @@ func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := Load(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
|
||||
result, err := testLoad(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
|
||||
assertLoadDiagnostic(t, result, err, test.code)
|
||||
})
|
||||
}
|
||||
@@ -269,7 +269,7 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := Load(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
|
||||
result, err := testLoad(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
|
||||
loadError := assertLoadDiagnostic(t, result, err, test.code)
|
||||
for _, diagnostic := range loadError.Diagnostics() {
|
||||
if strings.Contains(diagnostic.Message, "CANARY") {
|
||||
@@ -280,6 +280,83 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Declared string `yaml:"declared"`
|
||||
Default string `yaml:"default"`
|
||||
Optional *string `yaml:"optional"`
|
||||
}
|
||||
filename := writeImportFile(t, t.TempDir(), "config.yml", "declared: file\noptional: null\n")
|
||||
var savedField FieldInfo
|
||||
var savedSource SourceInfo
|
||||
result, err := testLoad[config](File(filename), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config { return config{Default: "retained"} },
|
||||
Resolve: func(_ *config, resolution *Resolution) error {
|
||||
for _, path := range []Path{Keys("declared"), Keys("default"), Keys("optional")} {
|
||||
info, found := resolution.InputInfo(path)
|
||||
if !found || info.Explicit != resolution.IsExplicit(path) {
|
||||
return errors.New("entry metadata does not agree with explicitness")
|
||||
}
|
||||
}
|
||||
defaultInfo, _ := resolution.InputInfo(Keys("default"))
|
||||
optionalInfo, _ := resolution.InputInfo(Keys("optional"))
|
||||
if defaultInfo.Explicit || defaultInfo.Origin.Layer != LayerDefault || !optionalInfo.Explicit || !optionalInfo.Null {
|
||||
return errors.New("entry metadata lost default or null state")
|
||||
}
|
||||
if _, found := resolution.InputInfo(Keys("missing")); found {
|
||||
return errors.New("missing entry path was found")
|
||||
}
|
||||
declared, _ := resolution.InputInfo(Keys("declared"))
|
||||
if len(declared.Origin.Locations) != 1 {
|
||||
return errors.New("declared source location is missing")
|
||||
}
|
||||
source, found := resolution.Source(declared.Origin.Locations[0].SourceID)
|
||||
if !found || source.Path != canonicalTestPath(t, filename) || source.ID != "source-1" {
|
||||
return errors.New("declared source record is missing")
|
||||
}
|
||||
if _, found := resolution.Source(""); found {
|
||||
return errors.New("empty source identity was found")
|
||||
}
|
||||
if _, found := resolution.Source("unknown"); found {
|
||||
return errors.New("unknown source identity was found")
|
||||
}
|
||||
savedField, savedSource = declared, source
|
||||
return nil
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Declared != "file" || result.Config.Default != "retained" || result.Config.Optional != nil {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
savedField.Origin.Locations[0].SourceID = "mutated"
|
||||
savedSource.Label = "mutated"
|
||||
field, _ := result.Metadata().Lookup(Keys("declared"))
|
||||
if field.Origin.Locations[0].SourceID != "source-1" || result.Sources()[0].Label == "mutated" {
|
||||
t.Fatal("saved resolution records alias a completed result")
|
||||
}
|
||||
|
||||
noSource, err := testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config { return config{Default: "retained"} },
|
||||
Resolve: func(_ *config, resolution *Resolution) error {
|
||||
info, found := resolution.InputInfo(Keys("default"))
|
||||
if !found || info.Explicit || resolution.IsExplicit(Keys("default")) {
|
||||
return errors.New("defaults-only entry metadata is wrong")
|
||||
}
|
||||
if _, found := resolution.Source("source-1"); found {
|
||||
return errors.New("defaults-only load invented a source")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}})
|
||||
if err != nil || noSource.Config.Default != "retained" {
|
||||
t.Fatalf("defaults-only load = %#v, %v", noSource, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionContextPanicsAfterCallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -287,7 +364,7 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) {
|
||||
Value int `yaml:"value"`
|
||||
}
|
||||
var retained *Resolution
|
||||
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
retained = resolution
|
||||
return nil
|
||||
}}}); err != nil {
|
||||
@@ -301,6 +378,45 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) {
|
||||
retained.IsExplicit(Keys("value"))
|
||||
}
|
||||
|
||||
func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Value int `yaml:"value"`
|
||||
}
|
||||
requireClosed := func(t *testing.T, call func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != closedCallbackContextMessage {
|
||||
t.Fatalf("panic = %#v", recovered)
|
||||
}
|
||||
}()
|
||||
call()
|
||||
}
|
||||
requireClosed(t, func() { (*Resolution)(nil).InputInfo(Keys("value")) })
|
||||
requireClosed(t, func() { (&Resolution{}).Source("source-1") })
|
||||
|
||||
var successful *Resolution
|
||||
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
successful = resolution
|
||||
return nil
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireClosed(t, func() { successful.InputInfo(Keys("value")) })
|
||||
requireClosed(t, func() { successful.Source("source-1") })
|
||||
|
||||
var failed *Resolution
|
||||
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
failed = resolution
|
||||
return errors.New("resolver failed")
|
||||
}}}); err == nil {
|
||||
t.Fatal("failed resolver succeeded")
|
||||
}
|
||||
requireClosed(t, func() { failed.InputInfo(Keys("value")) })
|
||||
requireClosed(t, func() { failed.Source("source-1") })
|
||||
}
|
||||
|
||||
func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -308,7 +424,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
var retained *Validation
|
||||
if _, err := Load(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{
|
||||
if _, err := testLoad(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
|
||||
retained = validation
|
||||
return nil
|
||||
@@ -324,7 +440,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
|
||||
retained.Warning(Keys("version"), "too late")
|
||||
}
|
||||
|
||||
func assertLoadDiagnostic[T any](t *testing.T, result *Result[T], err error, code Code) *LoadError {
|
||||
func assertLoadDiagnostic[T any](t *testing.T, result *testResult[T], err error, code Code) *LoadError {
|
||||
t.Helper()
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
@@ -29,8 +29,9 @@ const (
|
||||
)
|
||||
|
||||
type schemaDescriptor struct {
|
||||
root *typeDescriptor
|
||||
types map[reflect.Type]*typeDescriptor
|
||||
root *typeDescriptor
|
||||
types map[reflect.Type]*typeDescriptor
|
||||
secrets SecretBinding
|
||||
}
|
||||
|
||||
type typeDescriptor struct {
|
||||
@@ -65,20 +66,39 @@ type schemaIssue struct {
|
||||
message string
|
||||
}
|
||||
|
||||
// SecretState is the private operation state for one exact-type secret value.
|
||||
// It is supplied by the facade and never enters inspection records.
|
||||
type SecretState struct {
|
||||
Name string
|
||||
Value string
|
||||
Specified bool
|
||||
Resolved bool
|
||||
}
|
||||
|
||||
// SecretBinding adapts the facade-owned exact secret type for one load.
|
||||
// Bindings are operation data: callers must supply the type and conversions
|
||||
// together, and a binding never becomes package-global state.
|
||||
type SecretBinding struct {
|
||||
Type reflect.Type
|
||||
Read func(reflect.Value) SecretState
|
||||
Make func(SecretState) reflect.Value
|
||||
}
|
||||
|
||||
var (
|
||||
durationType = reflect.TypeFor[time.Duration]()
|
||||
secretType = reflect.TypeFor[Secret]()
|
||||
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
|
||||
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
|
||||
)
|
||||
|
||||
func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
|
||||
func buildSchema(root reflect.Type, secrets SecretBinding) (*schemaDescriptor, *schemaIssue) {
|
||||
secrets.validate()
|
||||
if root == nil || (root.Kind() != reflect.Struct && !isStringMap(root)) {
|
||||
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a struct or string-keyed map"}
|
||||
}
|
||||
builder := schemaBuilder{
|
||||
cache: make(map[reflect.Type]*typeDescriptor),
|
||||
building: make(map[reflect.Type]bool),
|
||||
secrets: secrets,
|
||||
}
|
||||
descriptor, issue := builder.describe(root, Path{})
|
||||
if issue != nil {
|
||||
@@ -87,12 +107,13 @@ func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
|
||||
if descriptor.kind != descriptorStruct && descriptor.kind != descriptorMap {
|
||||
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a structural struct or string-keyed map"}
|
||||
}
|
||||
return &schemaDescriptor{root: descriptor, types: builder.cache}, nil
|
||||
return &schemaDescriptor{root: descriptor, types: builder.cache, secrets: secrets}, nil
|
||||
}
|
||||
|
||||
type schemaBuilder struct {
|
||||
cache map[reflect.Type]*typeDescriptor
|
||||
building map[reflect.Type]bool
|
||||
secrets SecretBinding
|
||||
}
|
||||
|
||||
func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescriptor, *schemaIssue) {
|
||||
@@ -122,7 +143,7 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript
|
||||
descriptor.kind = descriptorDuration
|
||||
return descriptor, nil
|
||||
}
|
||||
if current == secretType {
|
||||
if b.secrets.Type != nil && current == b.secrets.Type {
|
||||
descriptor.kind = descriptorSecret
|
||||
return descriptor, nil
|
||||
}
|
||||
@@ -189,6 +210,15 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript
|
||||
return descriptor, nil
|
||||
}
|
||||
|
||||
func (binding SecretBinding) validate() {
|
||||
if binding.Type == nil {
|
||||
return
|
||||
}
|
||||
if binding.Read == nil || binding.Make == nil {
|
||||
panic("configkit engine: incomplete secret binding")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current reflect.Type, path Path) *schemaIssue {
|
||||
for i := 0; i < current.NumField(); i++ {
|
||||
field := current.Field(i)
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -68,9 +68,9 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
|
||||
private chan int
|
||||
}
|
||||
|
||||
schema, issue := buildSchema(reflect.TypeFor[config]())
|
||||
schema, issue := testBuildSchema(reflect.TypeFor[config]())
|
||||
if issue != nil {
|
||||
t.Fatalf("buildSchema() issue = %#v", issue)
|
||||
t.Fatalf("testBuildSchema() issue = %#v", issue)
|
||||
}
|
||||
root := schema.root
|
||||
checks := map[string]descriptorKind{
|
||||
@@ -105,7 +105,7 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
|
||||
if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap {
|
||||
t.Fatalf("inline map = %#v", root.inlineMap)
|
||||
}
|
||||
if mapped, mapIssue := buildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap {
|
||||
if mapped, mapIssue := testBuildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap {
|
||||
t.Fatalf("string-keyed root map = %#v, issue=%#v", mapped, mapIssue)
|
||||
}
|
||||
if _, exists := root.fieldByName["ignored"]; exists {
|
||||
@@ -116,7 +116,7 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
|
||||
func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, issue := buildSchema(reflect.TypeFor[recursiveConfig]())
|
||||
first, issue := testBuildSchema(reflect.TypeFor[recursiveConfig]())
|
||||
if issue != nil {
|
||||
t.Fatal(issue.message)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T
|
||||
if children.kind != descriptorMap || children.element != first.root {
|
||||
t.Fatal("recursive map did not refer to the existing descriptor")
|
||||
}
|
||||
second, issue := buildSchema(reflect.TypeFor[recursiveConfig]())
|
||||
second, issue := testBuildSchema(reflect.TypeFor[recursiveConfig]())
|
||||
if issue != nil {
|
||||
t.Fatal(issue.message)
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func TestBuildSchemaRejectsInvalidRootsTypesTagsAndBindings(t *testing.T) {
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, issue := buildSchema(test.typeOf); issue == nil || issue.code != test.code {
|
||||
if _, issue := testBuildSchema(test.typeOf); issue == nil || issue.code != test.code {
|
||||
t.Fatalf("issue = %#v, want %s", issue, test.code)
|
||||
}
|
||||
})
|
||||
@@ -221,7 +221,7 @@ func TestBuildSchemaDoesNotExecuteApplicationMethods(t *testing.T) {
|
||||
Value panicText `yaml:"value"`
|
||||
Structural yamlHookStruct `yaml:"structural"`
|
||||
}
|
||||
if _, issue := buildSchema(reflect.TypeFor[config]()); issue != nil {
|
||||
if _, issue := testBuildSchema(reflect.TypeFor[config]()); issue != nil {
|
||||
t.Fatal(issue.message)
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func TestResolveSchemaPathUsesContainerSemantics(t *testing.T) {
|
||||
Dynamic any `yaml:"dynamic"`
|
||||
Extras map[string]string `yaml:",inline"`
|
||||
}
|
||||
schema, issue := buildSchema(reflect.TypeFor[config]())
|
||||
schema, issue := testBuildSchema(reflect.TypeFor[config]())
|
||||
if issue != nil {
|
||||
t.Fatal(issue.message)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func TestInlineMapPointersFailBeforeDefaults(t *testing.T) {
|
||||
}
|
||||
for _, nonNil := range []bool{false, true} {
|
||||
called := false
|
||||
_, err := Load(NoSource(), Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg {
|
||||
_, err := testLoad(NoSource(), Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg {
|
||||
called = true
|
||||
if nonNil {
|
||||
m := map[string]int{"value": 1}
|
||||
@@ -330,13 +330,13 @@ func TestInlineMapPointersFailBeforeDefaults(t *testing.T) {
|
||||
type recursive struct {
|
||||
Extra pointer `yaml:",inline"`
|
||||
}
|
||||
if _, err := Load[recursive](NoSource()); err == nil {
|
||||
if _, err := testLoad[recursive](NoSource()); err == nil {
|
||||
t.Fatal("recursive inline pointer was accepted")
|
||||
}
|
||||
type double struct {
|
||||
Extra **map[string]int `yaml:",inline"`
|
||||
}
|
||||
if _, err := Load[double](NoSource()); err == nil {
|
||||
if _, err := testLoad[double](NoSource()); err == nil {
|
||||
t.Fatal("double inline map pointer was accepted")
|
||||
}
|
||||
}
|
||||
153
internal/engine/secret.go
Normal file
153
internal/engine/secret.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
type secretDependency struct {
|
||||
target Path
|
||||
dependency Path
|
||||
}
|
||||
|
||||
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
|
||||
paths := make(map[string]Path)
|
||||
finalPaths := make(map[string]*document.Node)
|
||||
indexDocument(final, Path{}, finalPaths)
|
||||
for pointer := range finalPaths {
|
||||
addSecretPath(paths, pointer)
|
||||
}
|
||||
for _, application := range applications {
|
||||
applicationPaths := make(map[string]*document.Node)
|
||||
indexDocument(application.value, application.path, applicationPaths)
|
||||
for pointer := range applicationPaths {
|
||||
addSecretPath(paths, pointer)
|
||||
}
|
||||
}
|
||||
var dependencies []secretDependency
|
||||
for pointer, history := range metadata.history {
|
||||
addSecretPath(paths, pointer)
|
||||
for _, change := range history {
|
||||
paths[change.Path.String()] = change.Path.clone()
|
||||
for _, dependency := range change.Dependencies {
|
||||
paths[dependency.String()] = dependency.clone()
|
||||
dependencies = append(dependencies, secretDependency{
|
||||
target: change.Path.clone(), dependency: dependency.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
secret := make(map[string]bool)
|
||||
for _, tree := range append(projections, final) {
|
||||
projected := make(map[string]*document.Node)
|
||||
indexDocument(tree, Path{}, projected)
|
||||
for pointer, node := range projected {
|
||||
addSecretPath(paths, pointer)
|
||||
if node.Sensitive {
|
||||
secret[pointer] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for pointer, path := range paths {
|
||||
if root, ok := schemaSecretRoot(schema, path); ok {
|
||||
secret[root.String()] = true
|
||||
}
|
||||
if predicate != nil && predicate(path.clone()) {
|
||||
secret[pointer] = true
|
||||
}
|
||||
}
|
||||
for {
|
||||
changed := false
|
||||
for _, dependency := range dependencies {
|
||||
if secretIntersectsPath(paths, secret, dependency.dependency) && !secret[dependency.target.String()] {
|
||||
secret[dependency.target.String()] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return secret
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addSecretPath(paths map[string]Path, pointer string) {
|
||||
if path, err := ParsePath(pointer); err == nil {
|
||||
paths[pointer] = path
|
||||
}
|
||||
}
|
||||
|
||||
func secretIntersectsPath(paths map[string]Path, secret map[string]bool, candidate Path) bool {
|
||||
for pointer := range secret {
|
||||
classified, ok := paths[pointer]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if pathContains(classified, candidate) || pathContains(candidate, classified) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
|
||||
if schema == nil || schema.root == nil || !path.valid() {
|
||||
return Path{}, false
|
||||
}
|
||||
current := schema.root
|
||||
walked := Path{}
|
||||
for _, segment := range path.segments {
|
||||
walked.segments = append(walked.segments, segment)
|
||||
for current.kind == descriptorPointer {
|
||||
current = current.element
|
||||
}
|
||||
switch current.kind {
|
||||
case descriptorStruct:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return Path{}, false
|
||||
}
|
||||
position, ok := current.fieldByName[segment.key]
|
||||
var field *fieldDescriptor
|
||||
if ok {
|
||||
field = ¤t.fields[position]
|
||||
} else {
|
||||
field = current.inlineMap
|
||||
}
|
||||
if field == nil {
|
||||
return Path{}, false
|
||||
}
|
||||
if field.secret {
|
||||
return walked.clone(), true
|
||||
}
|
||||
if field.inlineMap {
|
||||
current = field.descriptor.element
|
||||
} else {
|
||||
current = field.descriptor
|
||||
}
|
||||
case descriptorMap:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return Path{}, false
|
||||
}
|
||||
current = current.element
|
||||
case descriptorSlice, descriptorArray:
|
||||
if _, ok := schemaIndex(segment); !ok {
|
||||
return Path{}, false
|
||||
}
|
||||
current = current.element
|
||||
case descriptorDynamic:
|
||||
return Path{}, false
|
||||
default:
|
||||
return Path{}, false
|
||||
}
|
||||
}
|
||||
return Path{}, false
|
||||
}
|
||||
|
||||
// Dynamic type replacement can change classification even when projected text
|
||||
// is equal. Validators must preserve this inspection metadata as well as values.
|
||||
func projectedSensitivity(root *document.Node) map[string]Path {
|
||||
result := make(map[string]Path)
|
||||
walkDocument(root, Path{}, func(path Path, node *document.Node) {
|
||||
if node.Sensitive {
|
||||
result[path.String()] = path.clone()
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
157
internal/engine/secret_binding_test.go
Normal file
157
internal/engine/secret_binding_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Secret is a test-local facade secret. Production engine code must not depend
|
||||
// on this type; each test load supplies the binding below explicitly.
|
||||
type Secret struct {
|
||||
name string
|
||||
value string
|
||||
specified bool
|
||||
resolved bool
|
||||
}
|
||||
|
||||
func SecretEnv(name string) Secret {
|
||||
return Secret{name: name, specified: true}
|
||||
}
|
||||
|
||||
func (s Secret) EnvName() string {
|
||||
return s.name
|
||||
}
|
||||
|
||||
func (s Secret) Value() string {
|
||||
if !s.resolved {
|
||||
return ""
|
||||
}
|
||||
return s.value
|
||||
}
|
||||
|
||||
func (s Secret) IsResolved() bool {
|
||||
return s.resolved
|
||||
}
|
||||
|
||||
func (s Secret) Format(state fmt.State, _ rune) {
|
||||
_, _ = io.WriteString(state, "configkit.SecretEnv("+strconv.Quote(s.name)+")")
|
||||
}
|
||||
|
||||
func (s Secret) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.name)
|
||||
}
|
||||
|
||||
func testSecretBinding() SecretBinding {
|
||||
return SecretBinding{
|
||||
Type: reflect.TypeFor[Secret](),
|
||||
Read: func(value reflect.Value) SecretState {
|
||||
secret := value.Interface().(Secret)
|
||||
return SecretState{Name: secret.name, Value: secret.value, Specified: secret.specified, Resolved: secret.resolved}
|
||||
},
|
||||
Make: func(state SecretState) reflect.Value {
|
||||
return reflect.ValueOf(Secret{name: state.Name, value: state.Value, specified: state.Specified, resolved: state.Resolved})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testLoad[T any](source Source, supplied ...Options[T]) (*testResult[T], error) {
|
||||
loaded, err := Load(source, testSecretBinding(), supplied...)
|
||||
return testLoaded(loaded), err
|
||||
}
|
||||
|
||||
func testLoadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*testResult[T], error) {
|
||||
loaded, err := loadWithSecretLookup(source, testSecretBinding(), lookup, supplied...)
|
||||
return testLoaded(loaded), err
|
||||
}
|
||||
|
||||
type testResult[T any] struct {
|
||||
Config T
|
||||
*Inspection
|
||||
}
|
||||
|
||||
func testLoaded[T any](loaded *Loaded[T]) *testResult[T] {
|
||||
if loaded == nil {
|
||||
return nil
|
||||
}
|
||||
return &testResult[T]{Config: loaded.Config, Inspection: loaded.Inspection}
|
||||
}
|
||||
func (r *testResult[T]) Metadata() Metadata {
|
||||
if r == nil {
|
||||
return Metadata{}
|
||||
}
|
||||
return r.Inspection.Metadata()
|
||||
}
|
||||
func (r *testResult[T]) Sources() []SourceInfo {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.Inspection.Sources()
|
||||
}
|
||||
func (r *testResult[T]) Diagnostics() []Diagnostic {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.Inspection.Diagnostics()
|
||||
}
|
||||
func (r *testResult[T]) Explain(path Path) (Explanation, bool) {
|
||||
if r == nil {
|
||||
return Explanation{}, false
|
||||
}
|
||||
return r.Inspection.Explain(path)
|
||||
}
|
||||
func (r *testResult[T]) WriteEffective(output io.Writer) error {
|
||||
if r == nil {
|
||||
return errEffectiveUnavailable
|
||||
}
|
||||
return r.Inspection.WriteEffective(output)
|
||||
}
|
||||
func (r *testResult[T]) WriteExplanation(output io.Writer, path Path) error {
|
||||
if r == nil {
|
||||
return errExplanationUnknownPath
|
||||
}
|
||||
return r.Inspection.WriteExplanation(output, path)
|
||||
}
|
||||
|
||||
func testBuildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
|
||||
return buildSchema(root, testSecretBinding())
|
||||
}
|
||||
|
||||
func TestSecretBindingsRemainExactAndIndependent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type otherSecret struct {
|
||||
name string
|
||||
}
|
||||
other := SecretBinding{
|
||||
Type: reflect.TypeFor[otherSecret](),
|
||||
Read: func(value reflect.Value) SecretState {
|
||||
return SecretState{Name: value.Interface().(otherSecret).name}
|
||||
},
|
||||
Make: func(state SecretState) reflect.Value {
|
||||
return reflect.ValueOf(otherSecret{name: state.Name})
|
||||
},
|
||||
}
|
||||
type config struct {
|
||||
First Secret `yaml:"first"`
|
||||
Second otherSecret `yaml:"second"`
|
||||
}
|
||||
firstSchema, firstIssue := buildSchema(reflect.TypeFor[config](), testSecretBinding())
|
||||
if firstIssue != nil {
|
||||
t.Fatal(firstIssue)
|
||||
}
|
||||
secondSchema, secondIssue := buildSchema(reflect.TypeFor[config](), other)
|
||||
if secondIssue != nil {
|
||||
t.Fatal(secondIssue)
|
||||
}
|
||||
firstField := firstSchema.root.fields[firstSchema.root.fieldByName["first"]].descriptor
|
||||
firstOther := firstSchema.root.fields[firstSchema.root.fieldByName["second"]].descriptor
|
||||
secondField := secondSchema.root.fields[secondSchema.root.fieldByName["first"]].descriptor
|
||||
secondOther := secondSchema.root.fields[secondSchema.root.fieldByName["second"]].descriptor
|
||||
if firstField.kind != descriptorSecret || firstOther.kind == descriptorSecret || secondField.kind == descriptorSecret || secondOther.kind != descriptorSecret {
|
||||
t.Fatalf("secret descriptor kinds = %v, %v, %v, %v", firstField.kind, firstOther.kind, secondField.kind, secondOther.kind)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,13 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
// Secret is an opaque reference to an environment variable. Its name is public
|
||||
// configuration; its resolved value is available only through Value.
|
||||
type Secret struct {
|
||||
name string
|
||||
value string
|
||||
specified bool
|
||||
resolved bool
|
||||
}
|
||||
|
||||
// SecretEnv constructs an unresolved environment-backed secret reference.
|
||||
// Invalid names are reported if the reference survives a configuration load.
|
||||
func SecretEnv(name string) Secret {
|
||||
return Secret{name: name, specified: true}
|
||||
}
|
||||
|
||||
// EnvName returns the public environment-variable name.
|
||||
func (s Secret) EnvName() string {
|
||||
return s.name
|
||||
}
|
||||
|
||||
// Value returns the resolved credential, or an empty string when unresolved.
|
||||
func (s Secret) Value() string {
|
||||
if !s.resolved {
|
||||
return ""
|
||||
}
|
||||
return s.value
|
||||
}
|
||||
|
||||
// IsResolved reports whether this reference resolved to a nonempty value during
|
||||
// its configuration load.
|
||||
func (s Secret) IsResolved() bool {
|
||||
return s.resolved
|
||||
}
|
||||
|
||||
// Format renders only the public reference name for every formatting verb.
|
||||
func (s Secret) Format(state fmt.State, _ rune) {
|
||||
_, _ = io.WriteString(state, "configkit.SecretEnv("+strconv.Quote(s.name)+")")
|
||||
}
|
||||
|
||||
// MarshalJSON emits only the public environment-variable name.
|
||||
func (s Secret) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.name)
|
||||
}
|
||||
|
||||
type secretLookup func(string) (string, bool)
|
||||
|
||||
type secretLookupResult struct {
|
||||
@@ -80,28 +32,28 @@ func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input
|
||||
if lookup == nil {
|
||||
lookup = func(string) (string, bool) { return "", false }
|
||||
}
|
||||
resolveSecretValue(root, schema.root, Path{}, false, input, lookup, &result)
|
||||
resolveSecretValue(root, schema.root, schema.secrets, Path{}, false, input, lookup, &result)
|
||||
result.diagnostics = canonicalDiagnostics(result.diagnostics)
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Path, optional bool, input *document.Node, lookup secretLookup, result *secretResolutionResult) {
|
||||
func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, secrets SecretBinding, path Path, optional bool, input *document.Node, lookup secretLookup, result *secretResolutionResult) {
|
||||
if descriptor.kind == descriptorPointer {
|
||||
if value.IsNil() {
|
||||
return
|
||||
}
|
||||
resolveSecretValue(value.Elem(), descriptor.element, path, descriptor.element.kind == descriptorSecret, input, lookup, result)
|
||||
resolveSecretValue(value.Elem(), descriptor.element, secrets, path, descriptor.element.kind == descriptorSecret, input, lookup, result)
|
||||
return
|
||||
}
|
||||
|
||||
switch descriptor.kind {
|
||||
case descriptorSecret:
|
||||
reference := value.Interface().(Secret)
|
||||
name := reference.EnvName()
|
||||
reference := secrets.Read(value)
|
||||
name := reference.Name
|
||||
if name == "" {
|
||||
code := CodeSecretRequired
|
||||
message := "required secret reference is absent"
|
||||
if optional || reference.specified {
|
||||
if optional || reference.Specified {
|
||||
code = CodeSecretInvalidName
|
||||
message = "secret environment name is empty"
|
||||
}
|
||||
@@ -125,7 +77,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
|
||||
result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretEmpty, path, "secret environment variable is empty", input))
|
||||
return
|
||||
}
|
||||
value.Set(reflect.ValueOf(Secret{name: name, value: found.value, specified: true, resolved: true}))
|
||||
value.Set(secrets.Make(SecretState{Name: name, Value: found.value, Specified: true, Resolved: true}))
|
||||
result.references[path.String()] = name
|
||||
case descriptorStruct:
|
||||
for _, member := range descriptor.members {
|
||||
@@ -133,7 +85,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
|
||||
if !member.inline {
|
||||
memberPath = path.Key(member.name)
|
||||
}
|
||||
resolveSecretValue(value.Field(member.index), member.descriptor, memberPath, false, input, lookup, result)
|
||||
resolveSecretValue(value.Field(member.index), member.descriptor, secrets, memberPath, false, input, lookup, result)
|
||||
}
|
||||
case descriptorMap:
|
||||
if value.IsNil() {
|
||||
@@ -144,7 +96,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
|
||||
for _, key := range keys {
|
||||
entry := reflect.New(value.Type().Elem()).Elem()
|
||||
entry.Set(value.MapIndex(key))
|
||||
resolveSecretValue(entry, descriptor.element, path.Key(key.String()), false, input, lookup, result)
|
||||
resolveSecretValue(entry, descriptor.element, secrets, path.Key(key.String()), false, input, lookup, result)
|
||||
value.SetMapIndex(key, entry)
|
||||
}
|
||||
case descriptorSlice, descriptorArray:
|
||||
@@ -152,7 +104,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
|
||||
return
|
||||
}
|
||||
for index := 0; index < value.Len(); index++ {
|
||||
resolveSecretValue(value.Index(index), descriptor.element, path.Index(index), false, input, lookup, result)
|
||||
resolveSecretValue(value.Index(index), descriptor.element, secrets, path.Index(index), false, input, lookup, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,25 +117,28 @@ func secretDiagnostic(code Code, path Path, message string, input *document.Node
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path {
|
||||
func changedSecretStates(root reflect.Value, schema *schemaDescriptor, lookups map[string]secretLookupResult) []Path {
|
||||
var changed []Path
|
||||
collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed)
|
||||
if schema == nil || schema.root == nil {
|
||||
return nil
|
||||
}
|
||||
collectChangedSecretStates(root, schema.root, schema.secrets, Path{}, lookups, &changed)
|
||||
slices.SortFunc(changed, func(left, right Path) int { return cmp.Compare(left.String(), right.String()) })
|
||||
return changed
|
||||
}
|
||||
|
||||
func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, path Path, lookups map[string]secretLookupResult, changed *[]Path) {
|
||||
func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, secrets SecretBinding, path Path, lookups map[string]secretLookupResult, changed *[]Path) {
|
||||
if descriptor.kind == descriptorPointer {
|
||||
if !value.IsNil() {
|
||||
collectChangedSecretStates(value.Elem(), descriptor.element, path, lookups, changed)
|
||||
collectChangedSecretStates(value.Elem(), descriptor.element, secrets, path, lookups, changed)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch descriptor.kind {
|
||||
case descriptorSecret:
|
||||
reference := value.Interface().(Secret)
|
||||
expected, ok := lookups[reference.EnvName()]
|
||||
if ok && (!expected.present || expected.value == "" || !reference.resolved || reference.value != expected.value) {
|
||||
reference := secrets.Read(value)
|
||||
expected, ok := lookups[reference.Name]
|
||||
if ok && (!expected.present || expected.value == "" || !reference.Resolved || reference.Value != expected.value) {
|
||||
*changed = append(*changed, path.clone())
|
||||
}
|
||||
case descriptorStruct:
|
||||
@@ -192,7 +147,7 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
|
||||
if !member.inline {
|
||||
memberPath = path.Key(member.name)
|
||||
}
|
||||
collectChangedSecretStates(value.Field(member.index), member.descriptor, memberPath, lookups, changed)
|
||||
collectChangedSecretStates(value.Field(member.index), member.descriptor, secrets, memberPath, lookups, changed)
|
||||
}
|
||||
case descriptorMap:
|
||||
if value.IsNil() {
|
||||
@@ -201,14 +156,14 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
|
||||
keys := value.MapKeys()
|
||||
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
|
||||
for _, key := range keys {
|
||||
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed)
|
||||
collectChangedSecretStates(value.MapIndex(key), descriptor.element, secrets, path.Key(key.String()), lookups, changed)
|
||||
}
|
||||
case descriptorSlice, descriptorArray:
|
||||
if descriptor.kind == descriptorSlice && value.IsNil() {
|
||||
return
|
||||
}
|
||||
for index := 0; index < value.Len(); index++ {
|
||||
collectChangedSecretStates(value.Index(index), descriptor.element, path.Index(index), lookups, changed)
|
||||
collectChangedSecretStates(value.Index(index), descriptor.element, secrets, path.Index(index), lookups, changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -18,6 +18,16 @@ type secretReferenceConfig struct {
|
||||
Sequence []Secret `yaml:"sequence"`
|
||||
}
|
||||
|
||||
type resolutionQueryReader struct {
|
||||
input *strings.Reader
|
||||
reads int
|
||||
}
|
||||
|
||||
func (reader *resolutionQueryReader) Read(data []byte) (int, error) {
|
||||
reader.reads++
|
||||
return reader.input.Read(data)
|
||||
}
|
||||
|
||||
func TestSecretReferencesResolveTypedShapesOncePerName(t *testing.T) {
|
||||
input := `required: SHARED_SECRET
|
||||
optional: OPTIONAL_SECRET
|
||||
@@ -41,7 +51,7 @@ sequence: [SHARED_SECRET, SEQUENCE_SECRET]
|
||||
return value, present
|
||||
}
|
||||
validationCalled := false
|
||||
result, err := loadWithSecretLookup(Bytes("secrets.yml", []byte(input)), lookup, Options[secretReferenceConfig]{
|
||||
result, err := testLoadWithSecretLookup(Bytes("secrets.yml", []byte(input)), lookup, Options[secretReferenceConfig]{
|
||||
Definition: Definition[secretReferenceConfig]{Validate: func(configuration *secretReferenceConfig, validation *Validation) {
|
||||
validationCalled = true
|
||||
if configuration.Required.Value() != "shared-value" || !configuration.Required.IsResolved() ||
|
||||
@@ -52,7 +62,7 @@ sequence: [SHARED_SECRET, SEQUENCE_SECRET]
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("loadWithSecretLookup() error = %v", err)
|
||||
t.Fatalf("testLoadWithSecretLookup() error = %v", err)
|
||||
}
|
||||
if !validationCalled || result.Config.Optional == nil || result.Config.Pointers["nil"] != nil {
|
||||
t.Fatalf("resolved config = %#v", result.Config)
|
||||
@@ -84,7 +94,7 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
|
||||
if lookup == nil {
|
||||
lookup = func(string) (string, bool) { return "", false }
|
||||
}
|
||||
_, err := loadWithSecretLookup(Bytes("secret-errors.yml", []byte(test.input)), lookup, Options[secretReferenceConfig]{})
|
||||
_, err := testLoadWithSecretLookup(Bytes("secret-errors.yml", []byte(test.input)), lookup, Options[secretReferenceConfig]{})
|
||||
loadError := requireSecretLoadCode(t, err, test.code)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Path.String() != "/required" || strings.Contains(fmt.Sprintf("%v %#v", err, err), "CREDENTIAL-CANARY") {
|
||||
@@ -97,9 +107,9 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
|
||||
type config struct {
|
||||
Optional *Secret `yaml:"optional"`
|
||||
}
|
||||
_, err := loadWithSecretLookup(Bytes("optional.yml", []byte("optional: ''\n")), func(string) (string, bool) { return "", false }, Options[config]{})
|
||||
_, err := testLoadWithSecretLookup(Bytes("optional.yml", []byte("optional: ''\n")), func(string) (string, bool) { return "", false }, Options[config]{})
|
||||
requireSecretLoadCode(t, err, CodeSecretInvalidName)
|
||||
result, err := loadWithSecretLookup(Bytes("optional.yml", []byte("optional: null\n")), func(string) (string, bool) { return "", false }, Options[config]{})
|
||||
result, err := testLoadWithSecretLookup(Bytes("optional.yml", []byte("optional: null\n")), func(string) (string, bool) { return "", false }, Options[config]{})
|
||||
if err != nil || result.Config.Optional != nil {
|
||||
t.Fatalf("nil optional result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -110,7 +120,7 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
|
||||
Secrets []Secret `yaml:"secrets"`
|
||||
}
|
||||
lookups := 0
|
||||
_, err := loadWithSecretLookup(Bytes("missing-twice.yml", []byte("secrets: [MISSING_SHARED, MISSING_SHARED]\n")), func(string) (string, bool) {
|
||||
_, err := testLoadWithSecretLookup(Bytes("missing-twice.yml", []byte("secrets: [MISSING_SHARED, MISSING_SHARED]\n")), func(string) (string, bool) {
|
||||
lookups++
|
||||
return "", false
|
||||
}, Options[config]{})
|
||||
@@ -128,7 +138,7 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
|
||||
}
|
||||
lookedUp := []string{}
|
||||
validated := ""
|
||||
result, err := loadWithSecretLookup(Bytes("resolved-secret.yml", []byte("name: SELECTED_SECRET\nsecret: OLD_SECRET\n")), func(name string) (string, bool) {
|
||||
result, err := testLoadWithSecretLookup(Bytes("resolved-secret.yml", []byte("name: SELECTED_SECRET\nsecret: OLD_SECRET\n")), func(name string) (string, bool) {
|
||||
lookedUp = append(lookedUp, name)
|
||||
return "resolved-value", true
|
||||
}, Options[config]{Definition: Definition[config]{
|
||||
@@ -141,7 +151,7 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("loadWithSecretLookup() error = %v", err)
|
||||
t.Fatalf("testLoadWithSecretLookup() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(lookedUp, []string{"SELECTED_SECRET"}) || validated != "resolved-value" || result.Config.Secret.EnvName() != "SELECTED_SECRET" {
|
||||
t.Fatalf("lookups = %#v, validated = %q, config = %#v", lookedUp, validated, result.Config)
|
||||
@@ -152,11 +162,55 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionInputQueriesDoNotReadOrResolveSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
Secret Secret `yaml:"secret"`
|
||||
}
|
||||
reader := &resolutionQueryReader{input: strings.NewReader("value: source\nsecret: QUERY_SECRET\n")}
|
||||
lookups := 0
|
||||
var callbackErr error
|
||||
result, err := testLoadWithSecretLookup(Reader("query.yml", reader), func(name string) (string, bool) {
|
||||
lookups++
|
||||
if name != "QUERY_SECRET" {
|
||||
t.Fatalf("secret lookup name = %q", name)
|
||||
}
|
||||
return "query-credential", true
|
||||
}, Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
reads := reader.reads
|
||||
value, valueFound := resolution.InputInfo(Keys("value"))
|
||||
secret, secretFound := resolution.InputInfo(Keys("secret"))
|
||||
if !valueFound || !secretFound || len(value.Origin.Locations) != 1 || len(secret.Origin.Locations) != 1 {
|
||||
callbackErr = errors.New("entry metadata is incomplete")
|
||||
return callbackErr
|
||||
}
|
||||
if source, found := resolution.Source(value.Origin.Locations[0].SourceID); !found || source.Path != "" {
|
||||
callbackErr = errors.New("entry source is incomplete")
|
||||
return callbackErr
|
||||
}
|
||||
if reader.reads != reads || lookups != 0 {
|
||||
callbackErr = errors.New("entry query performed input or credential access")
|
||||
}
|
||||
return callbackErr
|
||||
}}})
|
||||
if callbackErr != nil {
|
||||
t.Fatal(callbackErr)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lookups != 1 || result.Config.Secret.Value() != "query-credential" {
|
||||
t.Fatalf("secret resolution = lookups %d config %#v", lookups, result.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationCannotReplaceResolvedSecretWithSameName(t *testing.T) {
|
||||
type config struct {
|
||||
Secret Secret `yaml:"secret"`
|
||||
}
|
||||
_, err := loadWithSecretLookup(Bytes("validator.yml", []byte("secret: VALIDATOR_SECRET\n")), func(string) (string, bool) {
|
||||
_, err := testLoadWithSecretLookup(Bytes("validator.yml", []byte("secret: VALIDATOR_SECRET\n")), func(string) (string, bool) {
|
||||
return "CREDENTIAL-CANARY", true
|
||||
}, Options[config]{Definition: Definition[config]{Validate: func(configuration *config, validation *Validation) {
|
||||
configuration.Secret = SecretEnv(configuration.Secret.EnvName())
|
||||
@@ -171,13 +225,13 @@ func TestResolvedDefaultsAreRebuiltAndLaterLoadsRelookup(t *testing.T) {
|
||||
type config struct {
|
||||
Secret Secret `yaml:"secret"`
|
||||
}
|
||||
first, err := loadWithSecretLookup(NoSource(), func(string) (string, bool) { return "first-value", true }, Options[config]{
|
||||
first, err := testLoadWithSecretLookup(NoSource(), func(string) (string, bool) { return "first-value", true }, Options[config]{
|
||||
Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("DEFAULT_SECRET")} }},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := loadWithSecretLookup(NoSource(), func(string) (string, bool) { return "second-value", true }, Options[config]{
|
||||
second, err := testLoadWithSecretLookup(NoSource(), func(string) (string, bool) { return "second-value", true }, Options[config]{
|
||||
Definition: Definition[config]{Defaults: func() config { return first.Config }},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -189,14 +243,14 @@ func TestResolvedDefaultsAreRebuiltAndLaterLoadsRelookup(t *testing.T) {
|
||||
|
||||
t.Setenv("CONFIGKIT_RELOOKUP_SECRET", "one")
|
||||
options := Options[config]{Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("CONFIGKIT_RELOOKUP_SECRET")} }}}
|
||||
realFirst, err := Load(NoSource(), options)
|
||||
realFirst, err := testLoad(NoSource(), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Setenv("CONFIGKIT_RELOOKUP_SECRET", "two"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realSecond, err := Load(NoSource(), options)
|
||||
realSecond, err := testLoad(NoSource(), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -222,7 +276,7 @@ func TestSecretSafeFormattingJSONProjectionAndHistory(t *testing.T) {
|
||||
type config struct {
|
||||
Secret Secret `yaml:"secret"`
|
||||
}
|
||||
result, err := loadWithSecretLookup(Bytes("safe-secret.yml", []byte("secret: SAFE_SECRET_NAME\n")), func(string) (string, bool) {
|
||||
result, err := testLoadWithSecretLookup(Bytes("safe-secret.yml", []byte("secret: SAFE_SECRET_NAME\n")), func(string) (string, bool) {
|
||||
return credential, true
|
||||
}, Options[config]{})
|
||||
if err != nil {
|
||||
@@ -263,7 +317,7 @@ func TestBootstrapExistingEmptyWinsAndReferenceFails(t *testing.T) {
|
||||
type config struct {
|
||||
Secret Secret `yaml:"secret"`
|
||||
}
|
||||
_, err = Load[config](Bytes("blocked.yml", []byte("secret: "+name+"\n")))
|
||||
_, err = testLoad[config](Bytes("blocked.yml", []byte("secret: "+name+"\n")))
|
||||
requireSecretLoadCode(t, err, CodeSecretEmpty)
|
||||
if value, present := os.LookupEnv(name); !present || value != "" {
|
||||
t.Fatalf("environment changed to %q, %v", value, present)
|
||||
@@ -285,7 +339,7 @@ func TestDynamicValuesDoNotEstablishSecretReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
lookups := 0
|
||||
result, err := loadWithSecretLookup(Bytes("dynamic.yml", []byte("dynamic: ORDINARY_STRING\n")), func(string) (string, bool) {
|
||||
result, err := testLoadWithSecretLookup(Bytes("dynamic.yml", []byte("dynamic: ORDINARY_STRING\n")), func(string) (string, bool) {
|
||||
lookups++
|
||||
return "unexpected", true
|
||||
}, Options[config]{})
|
||||
@@ -300,9 +354,9 @@ func TestLoadDoesNotMutateSecretEnvironment(t *testing.T) {
|
||||
type config struct {
|
||||
Secret Secret `yaml:"secret"`
|
||||
}
|
||||
result, err := Load[config](Bytes("unchanged.yml", []byte("secret: "+name+"\n")))
|
||||
result, err := testLoad[config](Bytes("unchanged.yml", []byte("secret: "+name+"\n")))
|
||||
if err != nil || result.Config.Secret.Value() != "original" {
|
||||
t.Fatalf("Load() = %#v, %v", result, err)
|
||||
t.Fatalf("testLoad() = %#v, %v", result, err)
|
||||
}
|
||||
if got := os.Getenv(name); got != "original" {
|
||||
t.Fatalf("environment = %q", got)
|
||||
376
internal/engine/secrets.go
Normal file
376
internal/engine/secrets.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
secretsEntryLimit = 1024
|
||||
secretFileByteLimit = int64(1 << 20)
|
||||
secretsByteLimit = int64(8 << 20)
|
||||
)
|
||||
|
||||
// SecretFileStatus describes the outcome for one file considered by
|
||||
// LoadSecretsDir.
|
||||
type SecretFileStatus string
|
||||
|
||||
const (
|
||||
SecretFileLoaded SecretFileStatus = "loaded"
|
||||
SecretFileSkippedExisting SecretFileStatus = "skipped_existing"
|
||||
SecretFileNotApplied SecretFileStatus = "not_applied"
|
||||
SecretFileFailed SecretFileStatus = "failed"
|
||||
)
|
||||
|
||||
// SecretFileResult is the safe, value-free bootstrap result for one directory
|
||||
// entry. ExistingEmpty is meaningful when Status is SecretFileSkippedExisting.
|
||||
type SecretFileResult struct {
|
||||
Name string
|
||||
Path string
|
||||
Status SecretFileStatus
|
||||
ExistingEmpty bool
|
||||
}
|
||||
|
||||
// SecretsReport is a detached snapshot of an explicit secrets-directory
|
||||
// bootstrap. It does not retain file contents or environment values.
|
||||
type SecretsReport struct {
|
||||
entries []SecretFileResult
|
||||
}
|
||||
|
||||
// Entries returns a copy of the report's filename-ordered entries.
|
||||
func (r *SecretsReport) Entries() []SecretFileResult {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return slices.Clone(r.entries)
|
||||
}
|
||||
|
||||
// WriteTo renders one safe, deterministic line per entry. Names and paths are
|
||||
// quoted so control characters cannot affect terminal output.
|
||||
func (r *SecretsReport) WriteTo(output io.Writer) (int64, error) {
|
||||
if output == nil {
|
||||
return 0, errors.New("configkit: secrets report output is nil")
|
||||
}
|
||||
if r == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var total int64
|
||||
for _, entry := range r.entries {
|
||||
line := "status=" + strconv.Quote(string(entry.Status)) +
|
||||
" name=" + strconv.Quote(entry.Name) +
|
||||
" path=" + strconv.Quote(entry.Path) +
|
||||
" existing_empty=" + strconv.FormatBool(entry.ExistingEmpty) + "\n"
|
||||
written, err := io.WriteString(output, line)
|
||||
total += int64(written)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if written != len(line) {
|
||||
return total, io.ErrShortWrite
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// SecretsError reports safe structured diagnostics for a failed explicit
|
||||
// secrets-directory bootstrap. It intentionally does not unwrap filesystem or
|
||||
// environment errors.
|
||||
type SecretsError struct {
|
||||
diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
// Error implements error without rendering filenames, paths, or file contents.
|
||||
func (e *SecretsError) Error() string {
|
||||
if e == nil {
|
||||
return "configkit: secrets bootstrap failed"
|
||||
}
|
||||
count := 0
|
||||
for _, diagnostic := range e.diagnostics {
|
||||
if diagnostic.Severity == SeverityError {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 1 {
|
||||
return "configkit: secrets bootstrap failed with 1 error"
|
||||
}
|
||||
return fmt.Sprintf("configkit: secrets bootstrap failed with %d errors", count)
|
||||
}
|
||||
|
||||
// Diagnostics returns a deep copy of the bootstrap diagnostics.
|
||||
func (e *SecretsError) Diagnostics() []Diagnostic {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneDiagnostics(e.diagnostics)
|
||||
}
|
||||
|
||||
type secretsEnvironment struct {
|
||||
lookup func(string) (string, bool)
|
||||
set func(string, string) error
|
||||
}
|
||||
|
||||
type secretsAccess struct {
|
||||
openDirectory func(string) (*os.File, error)
|
||||
lstat func(string) (fs.FileInfo, error)
|
||||
openFile func(string) (*os.File, error)
|
||||
}
|
||||
|
||||
type stagedSecret struct {
|
||||
entry int
|
||||
value string
|
||||
}
|
||||
|
||||
// LoadSecretsDir explicitly loads immediate regular files from directory into
|
||||
// previously unset process environment variables. Applications should call it
|
||||
// once during startup, before concurrent work; Load never calls it.
|
||||
// Dot-prefixed entries are ignored and omitted from the report.
|
||||
func LoadSecretsDir(directory string) (*SecretsReport, error) {
|
||||
return loadSecretsDir(directory, secretsEnvironment{lookup: os.LookupEnv, set: os.Setenv}, secretsAccess{
|
||||
openDirectory: os.Open,
|
||||
lstat: os.Lstat,
|
||||
openFile: os.Open,
|
||||
})
|
||||
}
|
||||
|
||||
func loadSecretsDir(directory string, environment secretsEnvironment, access secretsAccess) (*SecretsReport, error) {
|
||||
report := &SecretsReport{}
|
||||
if strings.TrimSpace(directory) == "" {
|
||||
return report, secretsFailure(CodeSecretsInvalidPath, "secrets directory path is required")
|
||||
}
|
||||
|
||||
canonical, err := canonicalExistingPath(directory)
|
||||
if err != nil {
|
||||
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be canonicalized: "+strconv.Quote(cleanAbsolutePath(directory)))
|
||||
}
|
||||
info, err := os.Stat(canonical)
|
||||
if err != nil {
|
||||
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be inspected: "+strconv.Quote(canonical))
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return report, secretsFailure(CodeSecretsInvalidPath, "secrets directory is not a directory: "+strconv.Quote(canonical))
|
||||
}
|
||||
|
||||
directoryFile, err := access.openDirectory(canonical)
|
||||
if err != nil {
|
||||
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be opened: "+strconv.Quote(canonical))
|
||||
}
|
||||
entries, readErr := readSecretsEntries(directoryFile)
|
||||
closeErr := directoryFile.Close()
|
||||
if readErr != nil {
|
||||
return report, secretsFailure(classifySecretsFilesystemError(readErr), "secrets directory could not be enumerated: "+strconv.Quote(canonical))
|
||||
}
|
||||
if closeErr != nil {
|
||||
return report, secretsFailure(CodeSecretsRead, "secrets directory could not be closed: "+strconv.Quote(canonical))
|
||||
}
|
||||
entryLimitExceeded := len(entries) > secretsEntryLimit
|
||||
entries = slices.DeleteFunc(entries, func(entry fs.DirEntry) bool {
|
||||
return strings.HasPrefix(entry.Name(), ".")
|
||||
})
|
||||
slices.SortFunc(entries, func(left, right fs.DirEntry) int { return strings.Compare(left.Name(), right.Name()) })
|
||||
report.entries = make([]SecretFileResult, len(entries))
|
||||
for index, entry := range entries {
|
||||
name := entry.Name()
|
||||
report.entries[index] = SecretFileResult{Name: name, Path: filepath.Join(canonical, name), Status: SecretFileNotApplied}
|
||||
}
|
||||
if entryLimitExceeded {
|
||||
return report, secretsFailure(CodeSecretsLimitExceeded, "secrets directory exceeds the 1024-entry limit: "+strconv.Quote(canonical))
|
||||
}
|
||||
|
||||
for index := range report.entries {
|
||||
entry := &report.entries[index]
|
||||
if !validEnvironmentName(entry.Name) {
|
||||
entry.Status = SecretFileFailed
|
||||
return report, secretsFailure(CodeSecretsInvalidName, "secret filename is not a valid environment name: "+strconv.Quote(entry.Path))
|
||||
}
|
||||
info, err := access.lstat(entry.Path)
|
||||
if err != nil {
|
||||
entry.Status = SecretFileFailed
|
||||
return report, secretsFailure(classifySecretsFilesystemError(err), "secret file could not be inspected: "+strconv.Quote(entry.Path))
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
entry.Status = SecretFileFailed
|
||||
return report, secretsFailure(CodeSecretsNotRegular, "secret entry is not a regular file: "+strconv.Quote(entry.Path))
|
||||
}
|
||||
}
|
||||
|
||||
staged := make([]stagedSecret, 0, len(report.entries))
|
||||
var consumed int64
|
||||
for index := range report.entries {
|
||||
entry := &report.entries[index]
|
||||
if existing, present := environment.lookup(entry.Name); present {
|
||||
entry.Status = SecretFileSkippedExisting
|
||||
entry.ExistingEmpty = existing == ""
|
||||
continue
|
||||
}
|
||||
remaining := secretsByteLimit - consumed
|
||||
value, used, err := readSecretValue(entry.Path, remaining, access)
|
||||
if err != nil {
|
||||
entry.Status = SecretFileFailed
|
||||
return report, secretsFailure(secretReadCode(err), secretReadMessage(err)+": "+strconv.Quote(entry.Path))
|
||||
}
|
||||
consumed += used
|
||||
staged = append(staged, stagedSecret{entry: index, value: value})
|
||||
}
|
||||
|
||||
for _, secret := range staged {
|
||||
entry := &report.entries[secret.entry]
|
||||
if existing, present := environment.lookup(entry.Name); present {
|
||||
entry.Status = SecretFileSkippedExisting
|
||||
entry.ExistingEmpty = existing == ""
|
||||
continue
|
||||
}
|
||||
if err := environment.set(entry.Name, secret.value); err != nil {
|
||||
entry.Status = SecretFileFailed
|
||||
return report, secretsFailure(CodeSecretsApplyFailed, "secret environment variable could not be set for file: "+strconv.Quote(entry.Path))
|
||||
}
|
||||
entry.Status = SecretFileLoaded
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readSecretsEntries(directory *os.File) ([]fs.DirEntry, error) {
|
||||
entries := make([]fs.DirEntry, 0)
|
||||
for len(entries) <= secretsEntryLimit {
|
||||
batch, err := directory.ReadDir(secretsEntryLimit + 1 - len(entries))
|
||||
entries = append(entries, batch...)
|
||||
if err == io.EOF {
|
||||
return entries, nil
|
||||
}
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
if len(entries) > secretsEntryLimit {
|
||||
return entries, nil
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
type secretReadFailure int
|
||||
|
||||
const (
|
||||
secretReadFilesystem secretReadFailure = iota
|
||||
secretReadFileLimit
|
||||
secretReadTotalLimit
|
||||
secretReadNUL
|
||||
)
|
||||
|
||||
type secretReadError struct {
|
||||
kind secretReadFailure
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *secretReadError) Error() string { return "secret read failed" }
|
||||
|
||||
func readSecretValue(path string, remaining int64, access secretsAccess) (string, int64, error) {
|
||||
if remaining < 0 {
|
||||
return "", 0, &secretReadError{kind: secretReadTotalLimit}
|
||||
}
|
||||
limit := min(secretFileByteLimit, remaining)
|
||||
file, err := access.openFile(path)
|
||||
if err != nil {
|
||||
return "", 0, &secretReadError{kind: secretReadFilesystem, err: err}
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
closeErr := file.Close()
|
||||
if readErr != nil {
|
||||
return "", 0, &secretReadError{kind: secretReadFilesystem, err: readErr}
|
||||
}
|
||||
if closeErr != nil {
|
||||
return "", 0, &secretReadError{kind: secretReadFilesystem, err: closeErr}
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
kind := secretReadFileLimit
|
||||
if remaining < secretFileByteLimit {
|
||||
kind = secretReadTotalLimit
|
||||
}
|
||||
return "", 0, &secretReadError{kind: kind}
|
||||
}
|
||||
if slices.Contains(data, byte(0)) {
|
||||
return "", 0, &secretReadError{kind: secretReadNUL}
|
||||
}
|
||||
value := string(data)
|
||||
if strings.HasSuffix(value, "\r\n") {
|
||||
value = strings.TrimSuffix(value, "\r\n")
|
||||
} else {
|
||||
value = strings.TrimSuffix(value, "\n")
|
||||
}
|
||||
return value, int64(len(data)), nil
|
||||
}
|
||||
|
||||
func validEnvironmentName(name string) bool {
|
||||
if name == "" || !asciiEnvironmentFirst(name[0]) {
|
||||
return false
|
||||
}
|
||||
for index := 1; index < len(name); index++ {
|
||||
character := name[index]
|
||||
if !asciiEnvironmentFirst(character) && (character < '0' || character > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func asciiEnvironmentFirst(character byte) bool {
|
||||
return character == '_' || character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z'
|
||||
}
|
||||
|
||||
func cleanAbsolutePath(path string) string {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
return filepath.Clean(absolute)
|
||||
}
|
||||
|
||||
func classifySecretsFilesystemError(err error) Code {
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return CodeSecretsMissing
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
return CodeSecretsPermission
|
||||
default:
|
||||
return CodeSecretsRead
|
||||
}
|
||||
}
|
||||
|
||||
func secretReadCode(err error) Code {
|
||||
var failure *secretReadError
|
||||
if errors.As(err, &failure) {
|
||||
switch failure.kind {
|
||||
case secretReadFileLimit, secretReadTotalLimit:
|
||||
return CodeSecretsLimitExceeded
|
||||
case secretReadNUL:
|
||||
return CodeSecretsInvalidValue
|
||||
case secretReadFilesystem:
|
||||
return classifySecretsFilesystemError(failure.err)
|
||||
}
|
||||
}
|
||||
return CodeSecretsRead
|
||||
}
|
||||
|
||||
func secretReadMessage(err error) string {
|
||||
var failure *secretReadError
|
||||
if errors.As(err, &failure) {
|
||||
switch failure.kind {
|
||||
case secretReadFileLimit:
|
||||
return "secret file exceeds the 1 MiB limit"
|
||||
case secretReadTotalLimit:
|
||||
return "eligible secret files exceed the 8 MiB total limit"
|
||||
case secretReadNUL:
|
||||
return "secret file contains a NUL byte"
|
||||
}
|
||||
}
|
||||
return "secret file could not be read"
|
||||
}
|
||||
|
||||
func secretsFailure(code Code, message string) *SecretsError {
|
||||
return &SecretsError{diagnostics: []Diagnostic{{Code: code, Severity: SeverityError, Message: message}}}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -64,6 +64,41 @@ func TestLoadSecretsDirLoadsCanonicalSortedValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecretsDirIgnoresDotEntries(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(directory, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSecretTestFile(t, directory, ".gitignore", "CANARY\x00")
|
||||
writeSecretTestFile(t, directory, ".DS_Store", "CANARY\x00")
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Symlink(filepath.Join(directory, "missing"), filepath.Join(directory, ".hidden-link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
access := testSecretsAccess()
|
||||
access.lstat = func(path string) (fs.FileInfo, error) {
|
||||
if strings.HasPrefix(filepath.Base(path), ".") {
|
||||
t.Fatalf("inspected ignored entry %q", path)
|
||||
}
|
||||
return os.Lstat(path)
|
||||
}
|
||||
environment := map[string]string{}
|
||||
report, err := loadSecretsDir(directory, mapSecretsEnvironment(environment), access)
|
||||
if err != nil || len(report.Entries()) != 0 || len(environment) != 0 {
|
||||
t.Fatalf("hidden-only directory: error = %v, report = %#v, environment = %#v", err, report.Entries(), environment)
|
||||
}
|
||||
writeSecretTestFile(t, directory, "SECRET", "value\n")
|
||||
report, err = loadSecretsDir(directory, mapSecretsEnvironment(environment), access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []SecretFileResult{{Name: "SECRET", Path: filepath.Join(canonicalTestPath(t, directory), "SECRET"), Status: SecretFileLoaded}}
|
||||
if !reflect.DeepEqual(report.Entries(), want) || !reflect.DeepEqual(environment, map[string]string{"SECRET": "value"}) {
|
||||
t.Fatalf("mixed directory: report = %#v, environment = %#v", report.Entries(), environment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecretsDirPreservesExistingWithoutReading(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeSecretTestFile(t, directory, "EMPTY_WINNER", strings.Repeat("CANARY", int(secretFileByteLimit)))
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -12,12 +12,12 @@ import (
|
||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||
)
|
||||
|
||||
func activeProfile(name string) bool {
|
||||
func activeOverlay(name string) bool {
|
||||
return name != "" && name != "default"
|
||||
}
|
||||
|
||||
func validProfileName(name string) bool {
|
||||
if !activeProfile(name) {
|
||||
func validOverlayName(name string) bool {
|
||||
if !activeOverlay(name) {
|
||||
return true
|
||||
}
|
||||
for _, character := range name {
|
||||
@@ -31,38 +31,38 @@ func validProfileName(name string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func loadProfileDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
func loadSelectedOverlayDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||
if diagnostic != nil {
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
||||
}
|
||||
profilePath := filepath.Join(rootPath, "profiles", name+".yml")
|
||||
canonical, err := canonicalExistingPath(profilePath)
|
||||
overlayPath := filepath.Join(rootPath, "overlays", name+".yml")
|
||||
canonical, err := canonicalExistingPath(overlayPath)
|
||||
if err != nil {
|
||||
code := CodeProfileInvalid
|
||||
message := "selected profile could not be resolved"
|
||||
code := CodeOverlayInvalid
|
||||
message := "selected overlay could not be resolved"
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
code, message = CodeProfileMissing, "selected profile does not exist"
|
||||
code, message = CodeOverlayMissing, "selected overlay does not exist"
|
||||
} else if errors.Is(err, fs.ErrPermission) {
|
||||
code, message = CodeImportPermission, "selected profile cannot be accessed"
|
||||
code, message = CodeImportPermission, "selected overlay cannot be accessed"
|
||||
}
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(code, message, nil)}
|
||||
}
|
||||
if !containedPath(rootPath, canonical) {
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile resolves outside the configuration root", nil)}
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)}
|
||||
}
|
||||
for _, existing := range sources {
|
||||
if existing.Path != "" && existing.Path == canonical {
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileRepeated, "selected profile is already present in the base source graph", nil)}
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayRepeated, "selected overlay is already present in the base source graph", nil)}
|
||||
}
|
||||
}
|
||||
if len(sources)+1 > limits.sources {
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the source-count limit", nil)}
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the source-count limit", nil)}
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(rootPath)
|
||||
if err != nil {
|
||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected profile", Location{})
|
||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected overlay", Location{})
|
||||
value.Primary = nil
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{value}
|
||||
}
|
||||
@@ -74,57 +74,57 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS
|
||||
}()
|
||||
relative, err := filepath.Rel(rootPath, canonical)
|
||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)}
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay could not be addressed inside the configuration root", nil)}
|
||||
}
|
||||
|
||||
sourceID := "source-" + strconv.Itoa(len(sources)+1)
|
||||
profileLocation := &Location{SourceID: sourceID}
|
||||
profileSource := SourceInfo{ID: sourceID, Label: filepath.ToSlash(filepath.Join("profiles", name+".yml")), Path: canonical, Layer: LayerProfile}
|
||||
sources = append(cloneSourceInfos(sources), profileSource)
|
||||
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
|
||||
overlayLocation := &Location{SourceID: sourceID}
|
||||
overlaySource := SourceInfo{ID: sourceID, Label: filepath.ToSlash(filepath.Join("overlays", name+".yml")), Path: canonical, Layer: LayerOverlay}
|
||||
sources = append(cloneSourceInfos(sources), overlaySource)
|
||||
data, diagnostic := readOverlayFile(root, relative, sourceID, limits.bytes-base.inputBytes)
|
||||
if diagnostic != nil {
|
||||
return nil, sources, []Diagnostic{*diagnostic}
|
||||
}
|
||||
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
|
||||
overlayRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
|
||||
if len(issues) != 0 {
|
||||
return nil, sources, parserDiagnostics(sourceID, issues)
|
||||
}
|
||||
nodeCount := countInputNodes(profileRoot)
|
||||
nodeCount := countInputNodes(overlayRoot)
|
||||
if base.inputNodes+nodeCount > limits.nodes {
|
||||
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
|
||||
return nil, sources, []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the aggregate node limit", overlayLocation)}
|
||||
}
|
||||
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
|
||||
if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}); len(diagnostics) != 0 {
|
||||
return nil, sources, diagnostics
|
||||
}
|
||||
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
|
||||
if versionNode := fragmentVersionClaim(overlayRoot, rule); versionNode != nil {
|
||||
location := publicLocation(versionNode.Location)
|
||||
return nil, sources, []Diagnostic{{
|
||||
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
|
||||
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "overlays cannot contribute to or replace the schema-version path", Primary: &location,
|
||||
}}
|
||||
}
|
||||
closeErr := root.Close()
|
||||
rootClosed = true
|
||||
if closeErr != nil {
|
||||
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)}
|
||||
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
|
||||
}
|
||||
|
||||
profiled, diagnostics := overlayDocuments(base, profileRoot)
|
||||
if profiled != nil {
|
||||
profiled.inputBytes = base.inputBytes + int64(len(data))
|
||||
profiled.inputNodes = base.inputNodes + nodeCount
|
||||
overlayd, diagnostics := overlayDocuments(base, overlayRoot)
|
||||
if overlayd != nil {
|
||||
overlayd.inputBytes = base.inputBytes + int64(len(data))
|
||||
overlayd.inputNodes = base.inputNodes + nodeCount
|
||||
}
|
||||
return profiled, sources, diagnostics
|
||||
return overlayd, sources, diagnostics
|
||||
}
|
||||
|
||||
func readProfileFile(root *os.Root, relative, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
||||
func readOverlayFile(root *os.Root, relative, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
||||
limit := min(int64(maxSourceBytes), remaining)
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
|
||||
if failure != nil {
|
||||
value := sourceReadDiagnostic(failure, sourceID, CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit")
|
||||
value := sourceReadDiagnostic(failure, sourceID, CodeOverlayLimitExceeded, "selected overlay exceeds a source or aggregate byte limit")
|
||||
return nil, &value
|
||||
}
|
||||
return data, nil
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
type profileTestConfig struct {
|
||||
type overlayTestConfig struct {
|
||||
Settings struct {
|
||||
Kept string `yaml:"kept"`
|
||||
Changed string `yaml:"changed"`
|
||||
@@ -21,7 +21,7 @@ type profileTestConfig struct {
|
||||
Schema map[string]string `yaml:"schema"`
|
||||
}
|
||||
|
||||
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
|
||||
func TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports: [{path: base.yml}]
|
||||
@@ -33,19 +33,19 @@ nullable: base
|
||||
value: {child: base}
|
||||
`)
|
||||
basePath := writeImportFile(t, directory, "base.yml", "settings: {added: imported}\n")
|
||||
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `settings:
|
||||
changed: profile
|
||||
values: [profile]
|
||||
overlayPath := writeImportFile(t, directory, "overlays/testing.yml", `settings:
|
||||
changed: overlay
|
||||
values: [overlay]
|
||||
nullable: null
|
||||
value: scalar
|
||||
`)
|
||||
|
||||
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{
|
||||
result, err := testLoad[overlayTestConfig](File(mainPath), Options[overlayTestConfig]{
|
||||
Imports: true,
|
||||
Profile: "testing",
|
||||
Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig {
|
||||
Overlay: "testing",
|
||||
Definition: Definition[overlayTestConfig]{Defaults: func() overlayTestConfig {
|
||||
defaultValue := "default"
|
||||
return profileTestConfig{
|
||||
return overlayTestConfig{
|
||||
Values: []string{"default"}, Nullable: &defaultValue,
|
||||
Settings: struct {
|
||||
Kept string `yaml:"kept"`
|
||||
@@ -58,31 +58,31 @@ value: scalar
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Settings.Kept != "main" || result.Config.Settings.Changed != "profile" || result.Config.Settings.Added != "imported" {
|
||||
if result.Config.Settings.Kept != "main" || result.Config.Settings.Changed != "overlay" || result.Config.Settings.Added != "imported" {
|
||||
t.Fatalf("settings = %#v", result.Config.Settings)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Config.Values, []string{"profile"}) || result.Config.Nullable != nil || result.Config.Value != "scalar" {
|
||||
t.Fatalf("profile values = values %#v nullable %#v value %#v", result.Config.Values, result.Config.Nullable, result.Config.Value)
|
||||
if !reflect.DeepEqual(result.Config.Values, []string{"overlay"}) || result.Config.Nullable != nil || result.Config.Value != "scalar" {
|
||||
t.Fatalf("overlay values = values %#v nullable %#v value %#v", result.Config.Values, result.Config.Nullable, result.Config.Value)
|
||||
}
|
||||
sources := result.Sources()
|
||||
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, basePath), canonicalTestPath(t, profilePath)}
|
||||
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, basePath), canonicalTestPath(t, overlayPath)}
|
||||
gotPaths := make([]string, len(sources))
|
||||
for index, source := range sources {
|
||||
gotPaths[index] = source.Path
|
||||
}
|
||||
if !reflect.DeepEqual(gotPaths, wantPaths) || sources[2].Layer != LayerProfile {
|
||||
if !reflect.DeepEqual(gotPaths, wantPaths) || sources[2].Layer != LayerOverlay {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
changed, _ := result.Metadata().Lookup(Keys("settings", "changed"))
|
||||
kept, _ := result.Metadata().Lookup(Keys("settings", "kept"))
|
||||
if changed.Origin.Layer != LayerProfile || changed.Origin.Locations[0].SourceID != "source-3" || kept.Origin.Layer != LayerBase {
|
||||
if changed.Origin.Layer != LayerOverlay || changed.Origin.Locations[0].SourceID != "source-3" || kept.Origin.Layer != LayerBase {
|
||||
t.Fatalf("origins = changed %#v kept %#v", changed, kept)
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("value", "child")); ok {
|
||||
t.Fatal("removed child remains effective")
|
||||
}
|
||||
childHistory := result.Metadata().History(Keys("value", "child"))
|
||||
if len(childHistory) < 2 || childHistory[len(childHistory)-1].Path.String() != "/value" || childHistory[len(childHistory)-1].Origin.Layer != LayerProfile {
|
||||
if len(childHistory) < 2 || childHistory[len(childHistory)-1].Path.String() != "/value" || childHistory[len(childHistory)-1].Origin.Layer != LayerOverlay {
|
||||
t.Fatalf("removed child history = %#v", childHistory)
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
|
||||
@@ -94,34 +94,43 @@ value: scalar
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
|
||||
func TestLoadOverlaySelectionUsesOneFixedYAMLFile(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n")
|
||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
|
||||
writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n")
|
||||
writeImportFile(t, directory, "overlays/testing.yml", "settings: {changed: selected}\n")
|
||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: legacy}\n")
|
||||
writeImportFile(t, directory, "overlays/default.yml", "settings: {changed: forbidden}\n")
|
||||
|
||||
for _, name := range []string{"", "default"} {
|
||||
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name})
|
||||
result, err := testLoad[overlayTestConfig](File(mainPath), Options[overlayTestConfig]{Overlay: name})
|
||||
if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 {
|
||||
t.Fatalf("inactive profile %q = %#v, %v", name, result, err)
|
||||
t.Fatalf("inactive overlay %q = %#v, %v", name, result, err)
|
||||
}
|
||||
}
|
||||
selected, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"})
|
||||
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 {
|
||||
t.Fatalf("selected profile = %#v, %v", selected, err)
|
||||
selected, err := testLoad[overlayTestConfig](File(mainPath), Options[overlayTestConfig]{Overlay: "testing"})
|
||||
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 ||
|
||||
selected.Sources()[1].Label != "overlays/testing.yml" || selected.Sources()[1].Layer != LayerOverlay {
|
||||
t.Fatalf("selected overlay = %#v, %v", selected, err)
|
||||
}
|
||||
|
||||
t.Run("legacy directory is not selected", func(t *testing.T) {
|
||||
legacyDirectory := t.TempDir()
|
||||
legacyMain := writeImportFile(t, legacyDirectory, "main.yml", "settings: {kept: base}\n")
|
||||
writeImportFile(t, legacyDirectory, "profiles/testing.yml", "settings: {changed: legacy}\n")
|
||||
requireOverlayLoadCode(t, File(legacyMain), Options[overlayTestConfig]{Overlay: "testing"}, CodeOverlayMissing)
|
||||
})
|
||||
|
||||
for _, name := range []string{"../testing", "testing.yml", "one,two", "with space", "é", "bad\x00name"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: name}, CodeOptionsInvalid)
|
||||
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: name}, CodeOptionsInvalid)
|
||||
})
|
||||
}
|
||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "missing"}, CodeProfileMissing)
|
||||
writeImportFile(t, directory, "profiles/fallback.yaml", "settings: {changed: fallback}\n")
|
||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "fallback"}, CodeProfileMissing)
|
||||
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "missing"}, CodeOverlayMissing)
|
||||
writeImportFile(t, directory, "overlays/fallback.yaml", "settings: {changed: fallback}\n")
|
||||
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "fallback"}, CodeOverlayMissing)
|
||||
}
|
||||
|
||||
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||
func TestLoadOverlayRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
|
||||
if !version.Present || version.Text != "4" {
|
||||
return errors.New("unexpected version")
|
||||
@@ -130,55 +139,55 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||
}}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
profile string
|
||||
overlay string
|
||||
code Code
|
||||
}{
|
||||
{name: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
|
||||
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
|
||||
{name: "equal integer version", profile: "schema: {version: 4}\n", code: CodeProfileVersion},
|
||||
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
||||
{name: "root directive", overlay: "configkit: {}\n", code: CodeOverlayDirective},
|
||||
{name: "nested directive", overlay: "settings: {configkit: {}}\n", code: CodeOverlayDirective},
|
||||
{name: "equal integer version", overlay: "schema: {version: 4}\n", code: CodeOverlayVersion},
|
||||
{name: "ancestor scalar", overlay: "schema: null\n", code: CodeOverlayVersion},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
||||
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
|
||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
|
||||
writeImportFile(t, directory, "overlays/testing.yml", test.overlay)
|
||||
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "testing", Definition: Definition[overlayTestConfig]{Version: versionRule}}, test.code)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("mapping sibling is allowed", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
||||
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
|
||||
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
|
||||
writeImportFile(t, directory, "overlays/testing.yml", "schema: {other: allowed}\n")
|
||||
result, err := testLoad[overlayTestConfig](File(mainPath), Options[overlayTestConfig]{Overlay: "testing", Definition: Definition[overlayTestConfig]{Version: versionRule}})
|
||||
if err != nil || result.Config.Schema["other"] != "allowed" {
|
||||
t.Fatalf("mapping sibling = %#v, %v", result, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("profile aliases imported base", func(t *testing.T) {
|
||||
t.Run("overlay aliases imported base", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/testing.yml}]}\n")
|
||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
|
||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: overlays/testing.yml}]}\n")
|
||||
writeImportFile(t, directory, "overlays/testing.yml", "settings: {changed: value}\n")
|
||||
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Imports: true, Overlay: "testing"}, CodeOverlayRepeated)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
|
||||
t.Run("base parse precedes profile", func(t *testing.T) {
|
||||
func TestLoadOverlayKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
|
||||
t.Run("base parse precedes overlay", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "settings: [\n")
|
||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: profile}\n")
|
||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
|
||||
writeImportFile(t, directory, "overlays/testing.yml", "settings: {changed: overlay}\n")
|
||||
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "testing"}, CodeYAMLInvalid)
|
||||
})
|
||||
|
||||
t.Run("profile parse", func(t *testing.T) {
|
||||
t.Run("overlay parse", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
|
||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: [\n")
|
||||
loadError := requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
|
||||
writeImportFile(t, directory, "overlays/testing.yml", "settings: [\n")
|
||||
loadError := requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "testing"}, CodeYAMLInvalid)
|
||||
if loadError.Diagnostics()[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 {
|
||||
t.Fatalf("profile parse attribution = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
|
||||
t.Fatalf("overlay parse attribution = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -186,32 +195,32 @@ func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
|
||||
t.Run("symlink escape", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
outsideProfile := writeImportFile(t, outside, "testing.yml", "settings: {changed: outside}\n")
|
||||
if err := os.Mkdir(filepath.Join(root, "profiles"), 0o755); err != nil {
|
||||
outsideOverlay := writeImportFile(t, outside, "testing.yml", "settings: {changed: outside}\n")
|
||||
if err := os.Mkdir(filepath.Join(root, "overlays"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideProfile, filepath.Join(root, "profiles/testing.yml")); err != nil {
|
||||
if err := os.Symlink(outsideOverlay, filepath.Join(root, "overlays/testing.yml")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, root, "main.yml", "{}\n")
|
||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeProfileOutsideRoot)
|
||||
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "testing"}, CodeOverlayOutsideRoot)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("byte source context", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
|
||||
result, err := Load[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{
|
||||
Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
|
||||
writeImportFile(t, directory, "overlays/testing.yml", "settings: {changed: selected}\n")
|
||||
result, err := testLoad[overlayTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[overlayTestConfig]{
|
||||
Overlay: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
|
||||
})
|
||||
if err != nil || result.Config.Settings.Changed != "selected" {
|
||||
t.Fatalf("byte profile = %#v, %v", result, err)
|
||||
t.Fatalf("byte overlay = %#v, %v", result, err)
|
||||
}
|
||||
requireProfileLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[profileTestConfig]{Profile: "testing"}, CodeOptionsInvalid)
|
||||
requireOverlayLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[overlayTestConfig]{Overlay: "testing"}, CodeOptionsInvalid)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
|
||||
func TestLoadOverlayCountsAgainstAggregateBudgets(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
limits func(parsedSource, int) importLimits
|
||||
@@ -219,8 +228,8 @@ func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
|
||||
{name: "source count", limits: func(_ parsedSource, _ int) importLimits {
|
||||
return importLimits{sources: 1, bytes: maxTotalBytes, nodes: maxDocumentNodes}
|
||||
}},
|
||||
{name: "aggregate bytes", limits: func(main parsedSource, profileBytes int) importLimits {
|
||||
return importLimits{sources: 2, bytes: main.bytes + int64(profileBytes) - 1, nodes: maxDocumentNodes}
|
||||
{name: "aggregate bytes", limits: func(main parsedSource, overlayBytes int) importLimits {
|
||||
return importLimits{sources: 2, bytes: main.bytes + int64(overlayBytes) - 1, nodes: maxDocumentNodes}
|
||||
}},
|
||||
{name: "aggregate nodes", limits: func(main parsedSource, _ int) importLimits {
|
||||
return importLimits{sources: 2, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}
|
||||
@@ -229,8 +238,8 @@ func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
|
||||
profileData := "settings: {changed: selected}\n"
|
||||
writeImportFile(t, directory, "profiles/testing.yml", profileData)
|
||||
overlayData := "settings: {changed: selected}\n"
|
||||
writeImportFile(t, directory, "overlays/testing.yml", overlayData)
|
||||
main, diagnostics := parseSource(File(mainPath), "source-1", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
@@ -241,17 +250,17 @@ func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
|
||||
}
|
||||
base.inputBytes = main.bytes
|
||||
base.inputNodes = countInputNodes(main.document)
|
||||
_, _, diagnostics = loadProfileDocument(File(mainPath), nil, main, base, sourceInfos(main.source), "testing", nil, test.limits(main, len(profileData)))
|
||||
if len(diagnostics) == 0 || diagnostics[0].Code != CodeProfileLimitExceeded {
|
||||
_, _, diagnostics = loadSelectedOverlayDocument(File(mainPath), nil, main, base, sourceInfos(main.source), "testing", nil, test.limits(main, len(overlayData)))
|
||||
if len(diagnostics) == 0 || diagnostics[0].Code != CodeOverlayLimitExceeded {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError {
|
||||
func requireOverlayLoadCode(t *testing.T, source Source, options Options[overlayTestConfig], code Code) *LoadError {
|
||||
t.Helper()
|
||||
result, err := Load[profileTestConfig](source, options)
|
||||
result, err := testLoad[overlayTestConfig](source, options)
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, error = %#v, %v", result, err)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
216
internal/engine/source.go
Normal file
216
internal/engine/source.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSourceBytes = 8 << 20
|
||||
maxTotalBytes = 64 << 20
|
||||
)
|
||||
|
||||
// Source is an opaque configuration input. Construct sources with File, Bytes,
|
||||
// Reader, or NoSource; the zero value is invalid.
|
||||
type Source struct {
|
||||
kind sourceKind
|
||||
path string
|
||||
label string
|
||||
data []byte
|
||||
reader *readerSource
|
||||
createErr sourceCreationError
|
||||
}
|
||||
|
||||
type sourceKind uint8
|
||||
|
||||
const (
|
||||
sourceInvalid sourceKind = iota
|
||||
sourceFile
|
||||
sourceBytes
|
||||
sourceReader
|
||||
sourceNone
|
||||
)
|
||||
|
||||
type sourceCreationError uint8
|
||||
|
||||
const (
|
||||
creationValid sourceCreationError = iota
|
||||
creationInvalidPath
|
||||
creationInvalidLabel
|
||||
creationInvalidReader
|
||||
creationTooLarge
|
||||
)
|
||||
|
||||
type readerSource struct {
|
||||
input io.Reader
|
||||
used atomic.Bool
|
||||
}
|
||||
|
||||
// File returns a source that reopens path for every load attempt.
|
||||
func File(path string) Source {
|
||||
if path == "" {
|
||||
return Source{kind: sourceFile, createErr: creationInvalidPath}
|
||||
}
|
||||
return Source{kind: sourceFile, path: path, label: path}
|
||||
}
|
||||
|
||||
// Bytes snapshots data immediately. Label must be nonempty and safe to display;
|
||||
// constructor problems are reported when the source is consumed.
|
||||
func Bytes(label string, data []byte) Source {
|
||||
source := Source{kind: sourceBytes, label: label}
|
||||
if label == "" {
|
||||
source.createErr = creationInvalidLabel
|
||||
return source
|
||||
}
|
||||
if len(data) > maxSourceBytes {
|
||||
source.createErr = creationTooLarge
|
||||
return source
|
||||
}
|
||||
source.data = bytes.Clone(data)
|
||||
return source
|
||||
}
|
||||
|
||||
// Reader returns a single-use source. ConfigKit consumes input but never closes it.
|
||||
// Copies of the returned Source share the same single-use claim.
|
||||
func Reader(label string, input io.Reader) Source {
|
||||
source := Source{kind: sourceReader, label: label}
|
||||
if label == "" {
|
||||
source.createErr = creationInvalidLabel
|
||||
return source
|
||||
}
|
||||
if nilReader(input) {
|
||||
source.createErr = creationInvalidReader
|
||||
return source
|
||||
}
|
||||
source.reader = &readerSource{input: input}
|
||||
return source
|
||||
}
|
||||
|
||||
// NoSource explicitly selects a defaults-only input. It creates no source record.
|
||||
func NoSource() Source {
|
||||
return Source{kind: sourceNone}
|
||||
}
|
||||
|
||||
type sourceLimits struct {
|
||||
perSource int64
|
||||
remaining int64
|
||||
}
|
||||
|
||||
func defaultSourceLimits() sourceLimits {
|
||||
return sourceLimits{perSource: maxSourceBytes, remaining: maxTotalBytes}
|
||||
}
|
||||
|
||||
type sourceRead struct {
|
||||
data []byte
|
||||
info *SourceInfo
|
||||
}
|
||||
|
||||
func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead, []Diagnostic) {
|
||||
if diagnostic := sourceConstructorDiagnostic(source); diagnostic != nil {
|
||||
return sourceRead{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if source.kind == sourceNone {
|
||||
return sourceRead{}, nil
|
||||
}
|
||||
|
||||
limit := min(limits.perSource, limits.remaining)
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase}
|
||||
|
||||
var input io.Reader
|
||||
switch source.kind {
|
||||
case sourceFile:
|
||||
absolute, err := filepath.Abs(source.path)
|
||||
if err != nil {
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceRead, sourceID, "source path could not be resolved")}
|
||||
}
|
||||
info.Path = filepath.Clean(absolute)
|
||||
canonical, err := filepath.EvalSymlinks(info.Path)
|
||||
if err != nil {
|
||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
||||
}
|
||||
info.Path = canonical
|
||||
data, failure := readRegularFile(canonical, os.Stat, os.Open, limit)
|
||||
if failure != nil {
|
||||
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||
}
|
||||
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||
case sourceBytes:
|
||||
input = bytes.NewReader(source.data)
|
||||
case sourceReader:
|
||||
if !source.reader.used.CompareAndSwap(false, true) {
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceReaderUsed, sourceID, "reader source has already been consumed")}
|
||||
}
|
||||
input = source.reader.input
|
||||
default:
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
||||
}
|
||||
data, failure := readBoundedSource(input, limit)
|
||||
if failure != nil {
|
||||
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||
}
|
||||
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||
}
|
||||
|
||||
func sourceConstructorDiagnostic(source Source) *Diagnostic {
|
||||
var message string
|
||||
code := CodeSourceInvalid
|
||||
switch source.createErr {
|
||||
case creationValid:
|
||||
if source.kind != sourceInvalid {
|
||||
return nil
|
||||
}
|
||||
message = "source is invalid"
|
||||
case creationInvalidPath:
|
||||
message = "source path must not be empty"
|
||||
case creationInvalidLabel:
|
||||
message = "source label must not be empty"
|
||||
case creationInvalidReader:
|
||||
message = "source reader must not be nil"
|
||||
case creationTooLarge:
|
||||
code = CodeSourceLimitExceeded
|
||||
message = "source exceeds the configured byte limit"
|
||||
}
|
||||
diagnostic := sourceDiagnostic(code, "", message)
|
||||
return &diagnostic
|
||||
}
|
||||
|
||||
func classifySourceError(err error, sourceID string) Diagnostic {
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return sourceDiagnostic(CodeSourceMissing, sourceID, "source does not exist")
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
return sourceDiagnostic(CodeSourcePermission, sourceID, "source cannot be accessed")
|
||||
default:
|
||||
return sourceDiagnostic(CodeSourceRead, sourceID, "source could not be read")
|
||||
}
|
||||
}
|
||||
|
||||
func sourceDiagnostic(code Code, sourceID, message string) Diagnostic {
|
||||
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Message: message}
|
||||
if sourceID != "" {
|
||||
diagnostic.Primary = &Location{SourceID: sourceID}
|
||||
}
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
func nilReader(input io.Reader) bool {
|
||||
if input == nil {
|
||||
return true
|
||||
}
|
||||
value := reflect.ValueOf(input)
|
||||
switch value.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return value.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,10 +23,10 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
|
||||
source = File(filepath.Join(root, "pipe.yml"))
|
||||
case "import":
|
||||
opts.Imports = true
|
||||
case "profile":
|
||||
opts.Profile = "fifo"
|
||||
case "overlay":
|
||||
opts.Overlay = "fifo"
|
||||
}
|
||||
_, err := Load(source, opts)
|
||||
_, err := testLoad(source, opts)
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("expected safe load error, got %v", err)
|
||||
@@ -38,7 +38,7 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
|
||||
}
|
||||
t.Fatalf("expected nonregular classification, got %v", loadErr.Diagnostics())
|
||||
}
|
||||
for _, mode := range []string{"file", "import", "profile"} {
|
||||
for _, mode := range []string{"file", "import", "overlay"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
input := "value: 1\n"
|
||||
@@ -48,12 +48,12 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte(input), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "profiles"), 0700); err != nil {
|
||||
if err := os.Mkdir(filepath.Join(root, "overlays"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fifo := filepath.Join(root, "pipe.yml")
|
||||
if mode == "profile" {
|
||||
fifo = filepath.Join(root, "profiles", "fifo.yml")
|
||||
if mode == "overlay" {
|
||||
fifo = filepath.Join(root, "overlays", "fifo.yml")
|
||||
}
|
||||
if err := syscall.Mkfifo(fifo, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
@@ -1,4 +1,4 @@
|
||||
package configkit
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user