Compare commits
123 Commits
v0.4.2
...
315f464dfc
| Author | SHA1 | Date | |
|---|---|---|---|
| 315f464dfc | |||
| a029a2a754 | |||
| 945e111c4e | |||
| 2d8df22dd1 | |||
| 53601e68ed | |||
| 751787ec24 | |||
| f50122d9f8 | |||
| c71faf2065 | |||
| 2f5d57ce1d | |||
| a1c557603f | |||
| a2fab66aba | |||
| 2f1f7f4b8a | |||
| 667a536ae7 | |||
| ea55ab43ce | |||
| d3d468a8b4 | |||
| 6353598bd6 | |||
| 3c2d7facb3 | |||
| 6fffde4af8 | |||
| 9e04a397c4 | |||
| c54a9bf51e | |||
| 8f3396d6f0 | |||
| 1c2610c2c0 | |||
| 92025d65cb | |||
| a4b5a6840a | |||
| 2ebdfb539b | |||
| a37e6895e1 | |||
| 8ae542b3a3 | |||
| 174efb65f6 | |||
| 756149beaa | |||
| e5dddef204 | |||
| ade5d6a273 | |||
| c20ea4b916 | |||
| 2e17e365a1 | |||
| fcf914a2ab | |||
| 3697a51124 | |||
| 9194c5b52a | |||
| e82b1c9054 | |||
| 6f59cdd237 | |||
| 1a885934c2 | |||
| 5917cebf50 | |||
| 8e91a2b16b | |||
| 08402d098b | |||
| 53b79528d9 | |||
| f849e06b77 | |||
| b37948cfea | |||
| 9323f89ad1 | |||
| 6c62226838 | |||
| 799ebab5a6 | |||
| c8546ae599 | |||
| d32c94eca3 | |||
| 344f4f73d7 | |||
| 5fedc11c8f | |||
| 5b8b7a2415 | |||
| e591e97f22 | |||
| 71e142afb4 | |||
| ca1ba04676 | |||
| 528495c75a | |||
| aa3b4a5881 | |||
| db2d8ed217 | |||
| d34a98d34b | |||
| 9ac65ccc08 | |||
| edc6660654 | |||
| a2084df11a | |||
| b59f002314 | |||
| 52793301ba | |||
| 0c77deed05 | |||
| 48619de024 | |||
| c546124029 | |||
| 673faea097 | |||
| 91ae4fa7ef | |||
| f1722274c0 | |||
| d53021ea2c | |||
| a22aaa5dfa | |||
| b1ecd9a078 | |||
| 84b59c690d | |||
| ac90b9835b | |||
| f887fac809 | |||
| c03457c814 | |||
| e9b6b63e5e | |||
| 1dc50d8cdf | |||
| 0e92085002 | |||
| f96f2b8295 | |||
| 1917dcf921 | |||
| 0ac4731903 | |||
| c42930c6d5 | |||
| 2d3ea588f1 | |||
| 5aafd943b4 | |||
| c0605fab44 | |||
| dc09959cca | |||
| 83c59ea7d3 | |||
| b45f7a0410 | |||
| 2ca1f80094 | |||
| df9a02539c | |||
| 501d59f21b | |||
| f916e7d1f9 | |||
| 462e57df3e | |||
| 4207110e2f | |||
| fdbc306cfb | |||
| 78d4b59bbe | |||
| f564fef84c | |||
| 01e38b5840 | |||
| ac1eec2e30 | |||
| 0a4b774bfe | |||
| b604aa167b | |||
| 15f5083282 | |||
| cbf031aedc | |||
| 34e18c84c1 | |||
| 36362ee6af | |||
| 5a77728a81 | |||
| 1cdf90d831 | |||
| 90c6e8ed7e | |||
| 1ff3c812a7 | |||
| 6111cb23a1 | |||
| de28718941 | |||
| 8d9a3a8dfa | |||
| 27df838513 | |||
| 36172ed20e | |||
| e1ce24aabb | |||
| e4bbedc695 | |||
| 798091b276 | |||
| 5c1aa1814c | |||
| ec26591b27 | |||
| 99338baabb |
62
README.md
62
README.md
@@ -5,18 +5,26 @@ An opinionated Go library for applications that use YAML configuration files.
|
|||||||
Linux is release-validated; macOS support is best-effort. See the
|
Linux is release-validated; macOS support is best-effort. See the
|
||||||
[platform support policy](docs/policy/release.md#platform-support) for details.
|
[platform support policy](docs/policy/release.md#platform-support) for details.
|
||||||
|
|
||||||
ConfigKit's initial loader accepts one strict YAML
|
ConfigKit's simple `Load` accepts one strict YAML source (or explicit
|
||||||
source (or explicit defaults-only input), optionally traverses explicit path and glob
|
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
|
||||||
imports, strictly composes their disjoint definitions, applies typed defaults, and
|
source groups with an independently selected main input. Sources can be host
|
||||||
can overlay one selected fixed overlay and check an application schema version. It
|
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
|
||||||
returns detached metadata and safe diagnostics.
|
Enabled path and glob imports compose strictly within their group; precedence
|
||||||
|
between groups combines mappings recursively. Both entry points return a complete,
|
||||||
|
validated configuration with detached metadata and safe diagnostics.
|
||||||
Applications may perform audited defaulting and normalization through a resolver,
|
Applications may perform audited defaulting and normalization through a resolver,
|
||||||
then report domain errors and warnings through read-only semantic validation. Secret
|
then report domain errors and warnings through read-only semantic validation. Secret
|
||||||
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
||||||
references resolve environment names before validation, and explicit
|
references resolve environment names before validation, and explicit
|
||||||
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
||||||
CLI override records apply above overlays. Value explanation is available through
|
CLI override records apply above overlays. Value explanation is available through
|
||||||
detached structured records and deterministic redacted output.
|
detached structured records and deterministic redacted output. Built-in
|
||||||
|
`extends` relationships let a mapping inherit from a named sibling during the
|
||||||
|
same load, retaining explicit presence and source/derivation history. The root
|
||||||
|
`Diff` API compares frozen effective values from two successful same-type loads;
|
||||||
|
the [configuration contract](docs/config.md#effective-configuration-diffs) and
|
||||||
|
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
|
||||||
|
its safe changed-region records and maintained workflows.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -33,13 +41,41 @@ func ReadConfig(path string) (Config, error) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The [configuration contract](docs/config.md) describes implemented behavior. The
|
## Design philosophy
|
||||||
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
|
|
||||||
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
|
ConfigKit favors one coherent, predictable configuration model over flexibility
|
||||||
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
|
for reproducing each consumer's conventions. Its goal is to let applications
|
||||||
[development guide](docs/development.md) for repository orientation and the
|
load once, inspect where final values came from, and distinguish explicit values
|
||||||
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
from omissions with minimal configuration bookkeeping. Applications retain their
|
||||||
steps.
|
domain rules and execution responsibilities.
|
||||||
|
|
||||||
|
Loading is synchronous and complete on return. Applications normally load once
|
||||||
|
near startup and then use the final configuration and metadata; ConfigKit stays
|
||||||
|
inactive until explicitly called again. Applications may initiate additional
|
||||||
|
loads as needed. See the [loading lifecycle](docs/config.md#completion-and-resource-lifetime)
|
||||||
|
for completion and ownership details.
|
||||||
|
|
||||||
|
Within a configuration tier, independent contributions compose strictly: shared
|
||||||
|
mappings combine, while overlapping non-mapping definitions are errors. Between
|
||||||
|
precedence tiers, mappings combine recursively, retaining omitted lower-priority
|
||||||
|
children; other values replace lower-priority values. The main document and its
|
||||||
|
imports form one strictly composed tier. Explicit underlay groups sit below it
|
||||||
|
and above typed defaults; a selected overlay and explicit CLI overrides sit
|
||||||
|
above it. The [configuration contract](docs/config.md#strict-document-composition)
|
||||||
|
owns the detailed combination rules and input-specific constraints.
|
||||||
|
|
||||||
|
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
|
||||||
|
these priorities and how genuine new needs can justify extending the shared
|
||||||
|
model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions.
|
||||||
|
|
||||||
|
The [configuration contract](docs/config.md) describes implemented behavior,
|
||||||
|
including [input destinations](docs/config.md#input-destinations). The
|
||||||
|
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
|
||||||
|
layered, and mounted-collection examples alongside the migration shapes. The
|
||||||
|
accepted placement decision is recorded in [ADR 0011](docs/adr/0011-input-destinations.md).
|
||||||
|
See the [development guide](docs/development.md) for repository orientation and
|
||||||
|
the [release procedure](docs/release.md) for maintainer-only candidate and
|
||||||
|
publication steps.
|
||||||
|
|
||||||
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||||
|
|
||||||
|
|||||||
@@ -43,16 +43,36 @@ func toEngineLocations(values []Location) []engine.Location {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
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)}
|
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
|
||||||
}
|
}
|
||||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
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)}
|
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
||||||
}
|
}
|
||||||
func fromEngineOrigin(value engine.Origin) Origin {
|
func fromEngineOrigin(value engine.Origin) Origin {
|
||||||
return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||||
|
if value.Inheritance != nil {
|
||||||
|
result.Inheritance = make([]InheritanceStep, len(value.Inheritance))
|
||||||
|
for index, step := range value.Inheritance {
|
||||||
|
result.Inheritance[index] = InheritanceStep{
|
||||||
|
From: fromEnginePath(step.From), To: fromEnginePath(step.To), Reference: fromEnginePath(step.Reference),
|
||||||
|
Locations: fromEngineLocations(step.Locations),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
func toEngineOrigin(value Origin) engine.Origin {
|
func toEngineOrigin(value Origin) engine.Origin {
|
||||||
return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
||||||
|
if value.Inheritance != nil {
|
||||||
|
result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance))
|
||||||
|
for index, step := range value.Inheritance {
|
||||||
|
result.Inheritance[index] = engine.InheritanceStep{
|
||||||
|
From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference),
|
||||||
|
Locations: toEngineLocations(step.Locations),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
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)}
|
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)}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName))
|
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName))
|
||||||
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", "CONFIGKIT_NEWER_TOOLCHAIN=", "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
|
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
|
||||||
// Log only targets selected by the script, not inherited host settings.
|
// Log only targets selected by the script, not inherited host settings.
|
||||||
cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=")
|
cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=")
|
||||||
if name == "failed-go-check" {
|
if name == "failed-go-check" {
|
||||||
@@ -108,90 +108,3 @@ func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckScriptsReleaseGates(t *testing.T) {
|
|
||||||
script, err := os.ReadFile("scripts/release-check.sh")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, scenario := range []string{"success", "failed-test", "missing-toolchain"} {
|
|
||||||
t.Run(scenario, func(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
bin := t.TempDir()
|
|
||||||
for _, directory := range []string{"scripts", "nested"} {
|
|
||||||
if err := os.Mkdir(filepath.Join(root, directory), 0700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checker := filepath.Join(root, "scripts", "release-check.sh")
|
|
||||||
if err := os.WriteFile(checker, script, 0700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
calls := filepath.Join(bin, "go.calls")
|
|
||||||
goStub := `#!/bin/sh
|
|
||||||
printf '%s|%s|%s|%s|%s|%s\n' "$GOTOOLCHAIN" "$GOPROXY" "${CGO_ENABLED-}" "${GOOS-}" "${GOARCH-}" "$*" >> "$CONFIGKIT_TEST_GO_LOG"
|
|
||||||
if [ "${CONFIGKIT_TEST_FAIL-}" = toolchain ] && [ "${1-}" = version ]; then exit 37; fi
|
|
||||||
if [ "${CONFIGKIT_TEST_FAIL-}" = test ] && [ "${1-}" = test ]; then exit 37; fi
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(goStub), 0700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cmd := exec.Command("/bin/bash", checker)
|
|
||||||
cmd.Dir = filepath.Join(root, "nested")
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
||||||
"CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline",
|
|
||||||
"CONFIGKIT_TEST_GO_LOG="+calls,
|
|
||||||
"GOOS=", "GOARCH=", "CGO_ENABLED=",
|
|
||||||
)
|
|
||||||
switch scenario {
|
|
||||||
case "failed-test":
|
|
||||||
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=test")
|
|
||||||
case "missing-toolchain":
|
|
||||||
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=toolchain")
|
|
||||||
}
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if scenario == "success" && err != nil {
|
|
||||||
t.Fatalf("checker: %v\n%s", err, output)
|
|
||||||
}
|
|
||||||
if scenario != "success" && err == nil {
|
|
||||||
t.Fatalf("checker accepted %s", scenario)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(calls)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
if !strings.HasPrefix(line, "test-baseline|off|") {
|
|
||||||
t.Fatalf("toolchain or offline setting lost: %q", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if scenario == "missing-toolchain" {
|
|
||||||
if len(lines) != 1 || !strings.HasSuffix(lines[0], "|version") {
|
|
||||||
t.Fatalf("checker ran after unavailable toolchain: %q", lines)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(lines) < 2 || lines[1] != "test-baseline|off||||test -mod=readonly -vet=off -race -v ./..." {
|
|
||||||
t.Fatalf("release suite command changed: %q", lines)
|
|
||||||
}
|
|
||||||
if scenario == "failed-test" {
|
|
||||||
if len(lines) != 2 {
|
|
||||||
t.Fatalf("checker continued after failure: %q", lines)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
want := []string{
|
|
||||||
"test-baseline|off||||version",
|
|
||||||
"test-baseline|off||||test -mod=readonly -vet=off -race -v ./...",
|
|
||||||
"test-baseline|off||||vet -mod=readonly ./...",
|
|
||||||
"test-baseline|off|0|linux|amd64|build -mod=readonly ./...",
|
|
||||||
"test-baseline|off|0|linux|arm64|build -mod=readonly ./...",
|
|
||||||
}
|
|
||||||
if strings.Join(lines, "\n") != strings.Join(want, "\n") {
|
|
||||||
t.Fatalf("unexpected release gates:\n%s", data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
53
diff.go
Normal file
53
diff.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
|
||||||
|
// Difference describes one canonical changed region between two successful
|
||||||
|
// loads. Left is the earlier value and Right is the later value. Each snapshot
|
||||||
|
// is detached; Present distinguishes absence from an explicit null, and
|
||||||
|
// Redacted reports whether combined sensitivity affected that side. Mappings
|
||||||
|
// are compared recursively, while sequences are reported as atomic values.
|
||||||
|
// Records are ordered by canonical JSON pointer. HiddenChanged indicates that
|
||||||
|
// a value concealed by combined sensitivity changed, without revealing which
|
||||||
|
// concealed member changed.
|
||||||
|
type Difference struct {
|
||||||
|
Path Path
|
||||||
|
Left ValueSnapshot
|
||||||
|
Right ValueSnapshot
|
||||||
|
HiddenChanged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff compares the frozen effective values of two successful loads of the
|
||||||
|
// same configuration type. It does not reread sources, environment variables,
|
||||||
|
// or rerun defaults or resolvers. Equal values produce no records even when
|
||||||
|
// their sources, defaults, or resolution histories differ. Credential contents
|
||||||
|
// are not compared: typed secret references compare by reference name only.
|
||||||
|
//
|
||||||
|
// Both results must be non-nil successful loads with inspection state; nil or
|
||||||
|
// zero results return an error and no records. Load errors are returned by
|
||||||
|
// Load, so callers must handle those before calling Diff. Snapshots are safe
|
||||||
|
// for presentation but are not a complete reloadable configuration.
|
||||||
|
func Diff[T any](left, right *Result[T]) ([]Difference, error) {
|
||||||
|
var leftInspection, rightInspection *engine.Inspection
|
||||||
|
if left != nil {
|
||||||
|
leftInspection = left.inspection
|
||||||
|
}
|
||||||
|
if right != nil {
|
||||||
|
rightInspection = right.inspection
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := engine.Diff(leftInspection, rightInspection)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
differences := make([]Difference, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
differences[index] = Difference{
|
||||||
|
Path: fromEnginePath(value.Path),
|
||||||
|
Left: fromEngineValueSnapshot(value.Left),
|
||||||
|
Right: fromEngineValueSnapshot(value.Right),
|
||||||
|
HiddenChanged: value.HiddenChanged,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return differences, nil
|
||||||
|
}
|
||||||
118
docs/adr/0007-source-backed-underlays.md
Normal file
118
docs/adr/0007-source-backed-underlays.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# ADR 0007: Add Source-Backed Underlays with Consistent Recursive Precedence
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed draft for review. Completed planning and remediation records are
|
||||||
|
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
|
||||||
|
[configuration contract](../config.md#layered-loading) owns current behavior.
|
||||||
|
|
||||||
|
This extends [ADR 0001](0001-separate-composition-and-overlay.md) and preserves
|
||||||
|
its distinction between strict composition and intentional overlay.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-09-29
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Configuration-backed defaults provide lower-priority settings that applications
|
||||||
|
can override. These sources carry imports, locations, and relative-resource
|
||||||
|
contexts. Integrating them into ConfigKit's loading lifecycle gives their values
|
||||||
|
the same presence tracking and inspectable provenance as other configuration
|
||||||
|
inputs.
|
||||||
|
|
||||||
|
The [architecture goals](../policy/architecture.md#success-criteria) prioritize
|
||||||
|
one coherent, predictable configuration model with consistent rules across
|
||||||
|
source representations and loading interfaces. Consumers should load once,
|
||||||
|
receive a complete validated configuration, and inspect its final values' origins.
|
||||||
|
Reducing integration code is important subject to those principles.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Add underlays to the existing precedence model
|
||||||
|
|
||||||
|
Introduce explicitly supplied source-backed underlays between typed defaults
|
||||||
|
and main configuration. The proposed precedence, from lowest to highest, is:
|
||||||
|
|
||||||
|
**Typed application defaults < source underlays < strictly composed main
|
||||||
|
configuration < selected overlay < explicit CLI overrides.**
|
||||||
|
|
||||||
|
Typed defaults remain the Go-value baseline. Underlays supply lower-priority
|
||||||
|
configuration with source provenance and, where applicable, imports and resource
|
||||||
|
context. This ordering describes value precedence; typed defaults fill fields
|
||||||
|
absent from the assembled input under the existing defaulting rules.
|
||||||
|
|
||||||
|
Preserve strict composition of the main document and its imports before applying
|
||||||
|
the resulting configuration over underlays. Independent contributions within an
|
||||||
|
underlay tier also compose strictly. Explicitly ordered tiers express intentional
|
||||||
|
precedence. Strict composition reports conflicting contributions within each tier.
|
||||||
|
|
||||||
|
Across precedence tiers, reuse the existing recursive mapping-overlay semantics.
|
||||||
|
Omitted children retain lower-priority input; existing scalar, sequence, null,
|
||||||
|
and cross-kind replacement semantics remain unchanged. Reusing these operations
|
||||||
|
keeps the combination rules consistent across the loading model.
|
||||||
|
|
||||||
|
### Keep source representation separate from composition semantics
|
||||||
|
|
||||||
|
Native `fs.FS` and programmatic inputs join the shared loading model.
|
||||||
|
Equivalent inputs using the same
|
||||||
|
operation have the same presence and precedence semantics regardless of medium.
|
||||||
|
Programmatic values enter directly as labeled configuration sources.
|
||||||
|
|
||||||
|
Retain the contributing source's identity and resource context through layering.
|
||||||
|
A retained underlay value keeps its originating source's resource context.
|
||||||
|
Consumers remain responsible for opening and interpreting their domain resources
|
||||||
|
under the supplied filesystem's lifetime and access semantics. Exact
|
||||||
|
context-access and input APIs remain roadmap decisions.
|
||||||
|
|
||||||
|
### Deliver one final configuration and its inspection metadata
|
||||||
|
|
||||||
|
Assemble effective input, then run the typed loading lifecycle once for the load.
|
||||||
|
Preserve explicit zero,
|
||||||
|
false, empty, and null input versus omission through configuration and metadata
|
||||||
|
together.
|
||||||
|
|
||||||
|
Extend existing provenance and history through underlays, defaults, and recorded
|
||||||
|
application resolution. The returned result includes the completed combination,
|
||||||
|
defaulting, and provenance work. Domain resolution and validation
|
||||||
|
remain application-owned within the recorded lifecycle, consistent with
|
||||||
|
[ADR 0003](0003-preserve-presence-and-resolution-history.md).
|
||||||
|
|
||||||
|
Preserve the existing opt-in schema-version ownership, path protections, and
|
||||||
|
check timing before precedence
|
||||||
|
assembly and typed decoding. The roadmap owns how layered input identifies that
|
||||||
|
owner when version checking is configured.
|
||||||
|
|
||||||
|
Favor near-linear processing in input, retained history, and output size through
|
||||||
|
incremental assembly of load-owned state. This is an engineering objective
|
||||||
|
subordinate to clarity and semantic consistency. Preserve the root-owned facade and
|
||||||
|
private implementation boundary established by [ADR 0006](0006-public-facade.md).
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Leave bundled defaults to consumers.** Avoids extending loading, but makes
|
||||||
|
consumers reproduce merging, presence tracking, and provenance reconciliation.
|
||||||
|
It does not meet the goal of obtaining a complete configuration in one load.
|
||||||
|
- **Offer recursive or atomic combination per layer.** Makes both behaviors
|
||||||
|
explicit and could serve more consumers, but expands the semantic surface and
|
||||||
|
its interactions with presence and provenance without a demonstrated need that
|
||||||
|
outweighs a single consistent rule. A future concrete requirement may justify
|
||||||
|
reconsideration; this feature does not add the option.
|
||||||
|
- **Flatten all documents into last-wins layers.** Simplifies ordering but turns
|
||||||
|
accidental main/import conflicts into overrides, weakening the intentional
|
||||||
|
distinction established by ADR 0001.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers gain source-backed defaults within the shared loading lifecycle.
|
||||||
|
Strict contributions and intentional precedence remain distinct, with consistent
|
||||||
|
combination rules across source media. Presence and provenance remain
|
||||||
|
part of the same loading lifecycle as the final values.
|
||||||
|
|
||||||
|
Higher-priority mappings retain omitted lower-priority children, so application
|
||||||
|
validation assesses the final combined configuration.
|
||||||
|
|
||||||
|
Implementation must integrate source context and underlay history into the shared
|
||||||
|
engine with attention to assembly and history-retention costs. Current behavior
|
||||||
|
documentation will change when the feature lands. The roadmap retains ownership
|
||||||
|
of the remaining API decisions.
|
||||||
82
docs/adr/0008-complete-on-return-loading.md
Normal file
82
docs/adr/0008-complete-on-return-loading.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# ADR 0008: Synchronous, Complete-on-Return Configuration Loading
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed draft for review. The
|
||||||
|
[configuration contract](../config.md#completion-and-resource-lifetime) describes
|
||||||
|
the implemented loading lifecycle and
|
||||||
|
[source resource contexts](../config.md#source-resource-contexts).
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-09-29
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications need a clear point at which configuration is ready for use.
|
||||||
|
Completing configuration processing and inspection metadata together gives
|
||||||
|
applications a coherent result whose values and origins agree. A bounded loading
|
||||||
|
lifecycle also makes resource ownership and startup failures straightforward to
|
||||||
|
reason about.
|
||||||
|
|
||||||
|
ConfigKit's [architecture goals](../policy/architecture.md#success-criteria)
|
||||||
|
emphasize complete configuration, inspectable provenance, predictable behavior,
|
||||||
|
and minimal consumer bookkeeping. Applications own their execution lifecycle and
|
||||||
|
the timing of configuration acquisition.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Make loading a synchronous, complete-on-return operation. Within each load,
|
||||||
|
ConfigKit completes input assembly, defaulting, typed decoding, configured
|
||||||
|
application resolution, secret lookup, validation, and snapshotting before
|
||||||
|
returning the final configuration and metadata. Reported failures return
|
||||||
|
diagnostics and a nil result.
|
||||||
|
|
||||||
|
Release all library-owned loading handles before returning on success or failure.
|
||||||
|
The resulting configuration and metadata are independently usable, caller-owned
|
||||||
|
data with a retained inspection snapshot. Caller-supplied readers and filesystems
|
||||||
|
remain under caller ownership and lifetime control.
|
||||||
|
|
||||||
|
After the call completes, ConfigKit remains inactive until explicitly invoked
|
||||||
|
again. Applications normally load once at or near startup and may initiate
|
||||||
|
additional loads when needed. Each load follows the same lifecycle and source
|
||||||
|
reuse contracts. Inspection, rendering, and separately requested operations run
|
||||||
|
in response to explicit application calls.
|
||||||
|
|
||||||
|
For the planned resource-context capability, retain source context as passive
|
||||||
|
information or a borrowed caller-supplied filesystem. Subsequent resource access
|
||||||
|
acquires its own handles with explicit ownership. This preserves a clear boundary
|
||||||
|
between configuration completion and application resource use.
|
||||||
|
|
||||||
|
These choices give consumers one completion boundary for configuration and
|
||||||
|
provenance, predictable resource lifetimes, and control over when ConfigKit runs.
|
||||||
|
Application callbacks remain responsible for their own effects and resources.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Background watching or refresh.** Can automate responses to source changes,
|
||||||
|
but adds scheduling, shutdown, publication, and resource-lifetime concerns to
|
||||||
|
the library. Explicit calls keep those responsibilities with the application.
|
||||||
|
- **Lazy completion after return.** Can defer work until particular values are
|
||||||
|
used, but moves configuration failures into application execution and makes
|
||||||
|
the readiness of values and metadata harder to establish.
|
||||||
|
- **Transactional atomic loading.** Could imply simultaneous observation of all
|
||||||
|
external inputs and rollback of callback effects. Filesystems, environment
|
||||||
|
variables, and application callbacks do not share a transaction mechanism.
|
||||||
|
Synchronous completion provides the required usable-result boundary without
|
||||||
|
promising those external guarantees.
|
||||||
|
- **Retain loading handles in results.** Can preserve open filesystem access,
|
||||||
|
but gives configuration results an ongoing resource lifecycle and cleanup
|
||||||
|
obligation. Passive source context supports later explicit resource access
|
||||||
|
while keeping loading resources scoped to the load call.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Applications can treat successful return as the point at which the final
|
||||||
|
configuration and its metadata are ready for use. Startup loading remains simple,
|
||||||
|
and additional loads have an explicit application-owned trigger.
|
||||||
|
|
||||||
|
Configuration inspection operates on retained data. Resource access observes the
|
||||||
|
backing resource when explicitly requested and has its own failure and ownership
|
||||||
|
boundary. Implementations of new source kinds and layering features preserve the
|
||||||
|
same completion boundary, including cleanup on reported load failures.
|
||||||
76
docs/adr/0009-sibling-inheritance.md
Normal file
76
docs/adr/0009-sibling-inheritance.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# ADR 0009: Resolve Sibling Inheritance After Source Assembly
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
|
||||||
|
owns current behavior. The completed implementation plan is preserved in Git
|
||||||
|
history at `83c59ea`.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-09-29
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Named configuration mappings often share settings while retaining individual
|
||||||
|
overrides. ConfigKit already distinguishes strict composition of independent
|
||||||
|
source contributions from recursive overlay between explicit precedence tiers.
|
||||||
|
Doing another untracked merge in applications would lose omission, source
|
||||||
|
context, and historical explanation information. A shared structural operation
|
||||||
|
must preserve the same presence and provenance model as the rest of loading.
|
||||||
|
|
||||||
|
Inheritance also interacts with source precedence, nested namespaces, version
|
||||||
|
ownership, and secrets. Resolving it before source assembly would validate
|
||||||
|
superseded references and copy values that a later tier should replace. Fully
|
||||||
|
resolving nested relationships inside a reusable parent would bind them to the
|
||||||
|
wrong sibling scope when copied into a child.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Reserve `extends` as a built-in input key on mappings. A string names one exact
|
||||||
|
sibling mapping; null cancels the relationship. Complete source acquisition,
|
||||||
|
strict composition, selected overlay, and CLI precedence first. Then resolve
|
||||||
|
each effective relationship in the parser-independent document tree, before
|
||||||
|
typed defaults and decoding. A child recursively overlays its own payload on
|
||||||
|
its resolved parent using the existing mapping-combination rules. This creates
|
||||||
|
neither a source tier nor a configurable merge strategy.
|
||||||
|
|
||||||
|
Resolve enclosing sibling chains before nested relationships. Reuse memoized
|
||||||
|
parent templates with unresolved nested instructions; evaluate those instructions
|
||||||
|
at each destination. Preserve original source applications separately from
|
||||||
|
structural transfer/control evidence. Effective origins carry concrete
|
||||||
|
source-to-destination derivation, and inspection projects relevant inheritance
|
||||||
|
events between source replay and typed reconciliation. Inherited input counts as
|
||||||
|
explicit presence, while direct replacement retains its own effective origin.
|
||||||
|
|
||||||
|
The existing main-input version check remains at its original point. Reject
|
||||||
|
structural contributions or directive consumption affecting its protected path.
|
||||||
|
Classify sensitivity at corresponding source and destination paths and retain
|
||||||
|
historical redaction. Secret references remain unresolved input until the
|
||||||
|
existing lookup point. Keep all machinery behind the root-owned public facade.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Application-owned inheritance after `Load`.** Leaves every consumer to
|
||||||
|
recreate ordering, presence, provenance, error reporting, and redaction after
|
||||||
|
ConfigKit has already declared a result complete.
|
||||||
|
- **Resolve each source before precedence assembly.** Makes superseded
|
||||||
|
references active and can copy a parent definition before higher-priority
|
||||||
|
input finishes changing it.
|
||||||
|
- **Resolve nested relationships in a cached parent subtree.** Shares more
|
||||||
|
ready-made output but binds nested sibling lookups to the parent's scope,
|
||||||
|
giving incorrect values at a child destination.
|
||||||
|
- **Registered collections, configurable keywords, or multiple-parent merge
|
||||||
|
modes.** Increase public setup and semantic branches without a requirement
|
||||||
|
that warrants diverging from one structural rule.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers can load a configuration once and inspect direct, inherited,
|
||||||
|
defaulted, and application-derived values through the existing result surfaces.
|
||||||
|
`extends` is no longer an ordinary application field, so existing configurations
|
||||||
|
using that key as data must migrate. Expanding many children can materialize
|
||||||
|
substantially more output than the input contains; projection and retained-history
|
||||||
|
budgets bound that work without charging copies as newly acquired sources.
|
||||||
|
Applications still own domain validation, entry selection, and runtime resource
|
||||||
|
use.
|
||||||
99
docs/adr/0010-effective-configuration-diff.md
Normal file
99
docs/adr/0010-effective-configuration-diff.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# ADR 0010: Compare Frozen Effective Configuration Snapshots
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted and implemented. The
|
||||||
|
[configuration contract](../config.md#effective-configuration-diffs) owns current
|
||||||
|
behavior. Completed planning and remediation records are preserved in Git
|
||||||
|
history at `1c2610c` under `docs/roadmap/`.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-09
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications need to inspect how two completed configurations differ, including
|
||||||
|
the effective impact of an overlay after defaults and application resolution.
|
||||||
|
ConfigKit already retains canonical typed inspection snapshots, semantic paths,
|
||||||
|
presence information, and sensitivity classification for successful loads.
|
||||||
|
|
||||||
|
Extending this inspection model lets applications share configuration comparison
|
||||||
|
and safe disclosure while keeping their presentation and domain interpretation.
|
||||||
|
The operation should follow ConfigKit's existing equality, ownership, and secret
|
||||||
|
boundaries so consumers can reason about loading and inspection consistently.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Provide structured effective-value comparison between successful results of the
|
||||||
|
same configuration type. Compare their retained canonical inspection snapshots
|
||||||
|
through the root-owned public facade. Each comparison uses the completed load
|
||||||
|
state and returns detached records with a semantic path and safe left and right
|
||||||
|
value snapshots.
|
||||||
|
|
||||||
|
Use canonical value equality and the existing structural comparison rules.
|
||||||
|
Mappings compare recursively, sequences report atomic changes, and scalar or
|
||||||
|
kind replacements report the affected region. Presence distinguishes additions
|
||||||
|
and removals from explicit null. Equal effective values produce an empty diff;
|
||||||
|
source attribution, explicit-input status, and history remain separately
|
||||||
|
inspectable through each result. Canonical pointer ordering makes reporting
|
||||||
|
deterministic.
|
||||||
|
|
||||||
|
Detect changes before masking values. Apply sensitivity from both loads to both
|
||||||
|
returned sides, including historical and dependency-derived classification.
|
||||||
|
Preserve public content where the shapes permit corresponding sensitive regions
|
||||||
|
to be identified. When a structural replacement prevents safe correspondence,
|
||||||
|
mask the complete reported replacement region on both present sides. Absence
|
||||||
|
retains its own representation.
|
||||||
|
|
||||||
|
Keep masking and hidden-change information distinct. Each record indicates
|
||||||
|
whether an actual change is withheld by redaction, allowing applications to
|
||||||
|
display a changed-sensitive-value indicator even when safe snapshots render
|
||||||
|
identically. Public-only changes alongside unchanged masked content retain that
|
||||||
|
distinction.
|
||||||
|
|
||||||
|
Typed secret references participate through their configured environment names.
|
||||||
|
Resolved credential bytes remain behind the existing explicit secret accessor.
|
||||||
|
The comparison uses the same frozen inspection boundary as effective rendering
|
||||||
|
and explanation.
|
||||||
|
|
||||||
|
ConfigKit owns comparison and paired safe disclosure. Applications own labels,
|
||||||
|
layout, and rendering of the returned changed regions. Reusing the existing
|
||||||
|
snapshot representation and engine boundary keeps the extension consistent with
|
||||||
|
the library's established inspection model.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Compare input text or separately rendered YAML.** Supports familiar textual
|
||||||
|
displays, but input text precedes composition and resolution, and independently
|
||||||
|
redacted output can hide changes or apply different disclosure rules. Canonical
|
||||||
|
comparison with paired masking represents the completed configuration.
|
||||||
|
- **Leave comparison to applications using returned Go values.** Offers direct
|
||||||
|
access to application types, but duplicates structural and redaction machinery
|
||||||
|
and observes caller mutation. Retained inspection snapshots preserve the
|
||||||
|
completed load's values and established ownership boundary.
|
||||||
|
- **Align sequence elements and return context-rich rendering data.** Enables
|
||||||
|
finer-grained displays, but requires additional correspondence and disclosure
|
||||||
|
rules. Atomic sequence records and existing value snapshots satisfy the
|
||||||
|
selected changed-region workflows with a smaller public model.
|
||||||
|
- **Compare resolved credentials or retain credential-derived tokens.** Could
|
||||||
|
detect rotation behind an unchanged reference, but expands retained sensitive
|
||||||
|
state and introduces a separate trusted comparison mechanism. Reference-name
|
||||||
|
comparison preserves the existing inspection boundary.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Applications can compare independently loaded configurations using ConfigKit's
|
||||||
|
shared equality, presence, and disclosure rules. Existing composition and load
|
||||||
|
semantics continue to determine the values being compared. Caller mutation of a
|
||||||
|
returned configuration leaves its retained comparison snapshot unchanged.
|
||||||
|
|
||||||
|
Paired redaction can conceal public content within a structurally incompatible
|
||||||
|
replacement. The changed region and hidden-change indicator still communicate
|
||||||
|
its effective impact. Credential rotation behind an unchanged environment name
|
||||||
|
produces equal reference values for comparison.
|
||||||
|
|
||||||
|
The implementation requires operation-local disclosure preparation and focused
|
||||||
|
coverage for presence, shape changes, historical sensitivity, and detached
|
||||||
|
results. Applications retain control of presentation; changed-region records
|
||||||
|
supply the safe data needed for that responsibility.
|
||||||
70
docs/adr/0011-input-destinations.md
Normal file
70
docs/adr/0011-input-destinations.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# ADR 0011: Place Configuration Inputs at Mapping Destinations
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted. The [input-destinations roadmap](../roadmap/input-destinations.md)
|
||||||
|
owns implementation status and the [implementation plan](../roadmap/implementation.md)
|
||||||
|
owns sequencing and completion evidence.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-14
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Consumers assembling collections of independently authored configuration
|
||||||
|
fragments need each source to contribute beneath a selected application path.
|
||||||
|
Requiring consumers to rewrite source documents or merge completed values would
|
||||||
|
separate placement from ConfigKit's existing composition, precedence, source
|
||||||
|
identity, and inspection behavior. Placement should remain part of the ordinary
|
||||||
|
loading lifecycle and preserve the root-owned public facade.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add an optional `Destination Path` to root-owned `Input` and expose the effective
|
||||||
|
destination on existing `SourceInfo` records. The empty path means the existing
|
||||||
|
application root. A non-empty destination is a sequence of literal mapping keys;
|
||||||
|
explicit sequence-index segments are invalid. Existing reserved structural keys
|
||||||
|
remain reserved, while keys that merely contain their spelling remain ordinary
|
||||||
|
keys.
|
||||||
|
|
||||||
|
Acquire and interpret each source using its original physical identity and
|
||||||
|
source-local directives, then place its application body before strict
|
||||||
|
composition and the existing final lifecycle. Imported application bodies
|
||||||
|
inherit the input destination. Placement applies to main and underlay inputs;
|
||||||
|
ordinary root loading, selected overlays, and CLI inputs retain root placement.
|
||||||
|
The main-owned version rule continues to check its effective application path
|
||||||
|
at its existing lifecycle point.
|
||||||
|
|
||||||
|
Placement preserves authored node locations and source identity. Introduced
|
||||||
|
mapping structure belongs to the contributing source and uses that document's
|
||||||
|
root location as its source-coordinate fallback. A present empty mapping
|
||||||
|
materializes its destination; an absent `NoSource` contributes nothing. The
|
||||||
|
result continues through the existing strict composition, overlay, inheritance,
|
||||||
|
typed loading, diagnostics, provenance, history, sensitivity, and resource
|
||||||
|
context mechanisms.
|
||||||
|
|
||||||
|
The added exported fields preserve keyed and zero-value struct use. Callers
|
||||||
|
using unkeyed composite literals for `Input` or `SourceInfo` must migrate to
|
||||||
|
keyed literals. Treat this feature as minor-release work; select the version and
|
||||||
|
document the compatibility impact during separately authorized release
|
||||||
|
preparation, consistent with the release policy.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Rewrite or merge sources in consumers.** This makes each consumer reproduce
|
||||||
|
placement, strict composition, source attribution, and inspection semantics.
|
||||||
|
- **Add a separate loading entry point or public wrapper type.** This creates a
|
||||||
|
parallel API for a behavior that belongs to existing per-input loading.
|
||||||
|
- **Load each fragment as a complete application before placement.** This runs
|
||||||
|
defaults, decoding, resolution, and validation on incomplete fragments rather
|
||||||
|
than once on the assembled application.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers can assemble contributions at effective mapping paths in one ordinary
|
||||||
|
layered load and inspect each source's placement through existing source
|
||||||
|
records. Paths, conflicts, and later application operations use the placed
|
||||||
|
structure while source coordinates and relative-resource contexts continue to
|
||||||
|
identify their original sources. Public field additions require release-note
|
||||||
|
guidance for callers using unkeyed literals.
|
||||||
295
docs/config.md
295
docs/config.md
@@ -12,13 +12,21 @@ ConfigKit exposes opaque sources through these constructors:
|
|||||||
| Constructor | Current contract |
|
| Constructor | Current contract |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
|
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
|
||||||
|
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
|
||||||
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
|
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
|
||||||
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
|
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
|
||||||
|
| `Mapping(label, values)` | Requires a safe nonempty label and a non-nil root `map[string]any`. Snapshots supported built-in values into an owned mapping document at construction; `{}` is valid. Constructor conversion failures become safe deferred diagnostics. |
|
||||||
| `NoSource()` | Selects defaults-only loading, creates no source record, and marks no paths explicit. |
|
| `NoSource()` | Selects defaults-only loading, creates no source record, and marks no paths explicit. |
|
||||||
|
|
||||||
The zero `Source` is invalid. Constructor argument failures are retained inside the
|
The zero `Source` is invalid. Constructor argument failures are retained inside the
|
||||||
opaque source and become safe structured diagnostics when the source is consumed.
|
opaque source and become safe structured diagnostics when the source is consumed.
|
||||||
File sources can observe file changes between consumptions; byte sources cannot.
|
File and filesystem sources can observe changes between consumptions; byte and
|
||||||
|
mapping sources cannot. Mapping accepts nil, built-in string, boolean, signed or
|
||||||
|
unsigned integer, finite float, `[]any`, and `map[string]any` values recursively.
|
||||||
|
Typed nil maps and slices represent explicit null; empty collections remain empty.
|
||||||
|
Keys are sorted for stable provenance, integer decimal spelling stays exact, and
|
||||||
|
cycles or unsupported types are rejected without exposing values. Mapping input
|
||||||
|
uses the same directive and version rules as YAML, not CLI override restrictions.
|
||||||
|
|
||||||
Each source is limited to 8 MiB. A load using imports or an active overlay 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
|
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
||||||
@@ -26,6 +34,8 @@ 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
|
1,000,000 visited directory entries, including nonmatches. Reads request at most the
|
||||||
remaining allowance plus one byte so excess data is rejected rather than silently
|
remaining allowance plus one byte so excess data is rejected rather than silently
|
||||||
truncated. Data returned together with a read error is not accepted as partial input.
|
truncated. Data returned together with a read error is not accepted as partial input.
|
||||||
|
For mapping sources, key and string bytes use the same accounting as programmatic
|
||||||
|
overrides, and the converted tree uses the same node and depth bounds.
|
||||||
|
|
||||||
## Single-source loading
|
## Single-source loading
|
||||||
|
|
||||||
@@ -44,13 +54,14 @@ The current lifecycle is:
|
|||||||
```text
|
```text
|
||||||
validate options → parse the selected source → traverse/compose enabled imports
|
validate options → parse the selected source → traverse/compose enabled imports
|
||||||
→ build schema → check the optional application version → apply a selected overlay
|
→ build schema → check the optional application version → apply a selected overlay
|
||||||
→ apply explicit CLI overrides → create and clone defaults → strictly decode
|
→ apply explicit CLI overrides → expand sibling inheritance
|
||||||
|
→ create and clone defaults → strictly decode
|
||||||
→ recorded application resolution
|
→ recorded application resolution
|
||||||
→ resolve typed secret references → semantic validation → classify secrets
|
→ resolve typed secret references → semantic validation → classify secrets
|
||||||
→ snapshot configuration and metadata
|
→ snapshot configuration and metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
`File`, `Bytes`, and `Reader` each contribute the first base source with deterministic
|
`File`, `Filesystem.File`, `Bytes`, `Reader`, and `Mapping` each contribute the first base source with deterministic
|
||||||
ID `source-1`; enabled imports continue in depth-first order as `source-2` onward.
|
ID `source-1`; enabled imports continue in depth-first order as `source-2` onward.
|
||||||
`NoSource` skips parsing, supplies an absent version to a configured version checker,
|
`NoSource` skips parsing, supplies an absent version to a configured version checker,
|
||||||
contributes no source, and continues through defaults, decode, and projection. A
|
contributes no source, and continues through defaults, decode, and projection. A
|
||||||
@@ -66,11 +77,193 @@ disabled.
|
|||||||
|
|
||||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||||
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
||||||
`Metadata`, a defensive source list, and defensive nonfatal diagnostics. Mutating
|
`Metadata`, a defensive source list, and defensive nonfatal diagnostics. Each source
|
||||||
|
record's `Kind` identifies file, filesystem, bytes, reader, mapping, or CLI acquisition
|
||||||
|
independently of its precedence `Layer`. Host file paths remain canonical;
|
||||||
|
filesystem paths are slash-relative within their namespace; synthetic inputs have
|
||||||
|
no path. Mutating
|
||||||
the returned configuration or inspection records cannot alter ConfigKit's private
|
the returned configuration or inspection records cannot alter ConfigKit's private
|
||||||
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
|
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
|
||||||
frozen redacted projection.
|
frozen redacted projection.
|
||||||
|
|
||||||
|
### Completion and resource lifetime
|
||||||
|
|
||||||
|
`Load` is synchronous and complete on return. Success means input assembly,
|
||||||
|
defaulting, typed decoding, configured application resolution, secret lookup,
|
||||||
|
validation, and configuration/metadata snapshotting have completed. A reported
|
||||||
|
load failure returns diagnostics and a nil result, as described above.
|
||||||
|
|
||||||
|
Before returning on success or failure, ConfigKit closes the filesystem handles
|
||||||
|
it opened for loading, including files and directory/root handles. Caller-owned
|
||||||
|
filesystems are borrowed, never closed. The returned
|
||||||
|
configuration and inspection metadata require no close operation and remain
|
||||||
|
usable independently of the source files. A supplied `Reader` remains
|
||||||
|
caller-owned and is never closed by ConfigKit.
|
||||||
|
|
||||||
|
ConfigKit performs no background work after returning: it does not watch inputs,
|
||||||
|
refresh secrets, or mutate published results. Inspection and rendering run only
|
||||||
|
when explicitly called and use the retained snapshot. Applications normally load
|
||||||
|
once near startup, but may explicitly load again using sources that satisfy their
|
||||||
|
documented reuse rules. Additional loads produce independently owned results.
|
||||||
|
|
||||||
|
Completion is not a transaction over external state. Files and environment
|
||||||
|
variables are observed during processing rather than as one simultaneous external
|
||||||
|
snapshot. ConfigKit does not roll back application callback effects or manage work
|
||||||
|
or resources created by application callbacks. The explicit secrets-directory
|
||||||
|
bootstrap remains a separate operation invoked by the application.
|
||||||
|
|
||||||
|
## Layered loading
|
||||||
|
|
||||||
|
`LoadLayered[T](main, options...)` accepts zero or one `LayeredOptions[T]` value.
|
||||||
|
Its `main` is an `Input` containing a source, import opt-in, and optional
|
||||||
|
filesystem context. A zero source is invalid; `Input{Source: NoSource()}` omits
|
||||||
|
the main document. Each `Underlay` contains inputs that compose strictly as
|
||||||
|
one tier, including each input's enabled imports. Empty groups contribute
|
||||||
|
nothing. Underlay groups are listed from lowest to highest priority. Repeating
|
||||||
|
a file within one group remains an import/repetition error; the same reusable
|
||||||
|
source may be selected again in another tier.
|
||||||
|
|
||||||
|
ConfigKit acquires and composes the main input first, constructs the schema,
|
||||||
|
and checks the optional application version from the main tier only. Underlay
|
||||||
|
roots cannot claim that version path (`underlay.version`); imported fragments
|
||||||
|
retain `import.version` protection. It then acquires and composes underlay
|
||||||
|
groups in order, recursively combines the groups, and applies the main tier,
|
||||||
|
one selected overlay, and explicit CLI overrides. Scalar, sequence, and null
|
||||||
|
definitions replace lower values between tiers; mappings combine recursively.
|
||||||
|
Syntax and strict-composition errors inside a tier cannot be masked by a
|
||||||
|
higher-priority tier. Defaults, typed decoding, resolution, secret lookup,
|
||||||
|
validation, and publication run once on the final assembled input.
|
||||||
|
|
||||||
|
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
|
||||||
|
independent of the main source's namespace. Its name and root-relative lookup
|
||||||
|
follow the selected-overlay rules below; inactive overlays ignore the context.
|
||||||
|
The same path in a different filesystem namespace is not a repeated file.
|
||||||
|
Ordinary `Load` continues to infer its selected-overlay context from its source.
|
||||||
|
|
||||||
|
Source IDs follow acquisition order—main and its depth-first imports, then each
|
||||||
|
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
||||||
|
history follows precedence order. Source `Layer` describes precedence while
|
||||||
|
`Kind` describes acquisition. Underlay records use `LayerUnderlay` and a
|
||||||
|
one-based `UnderlayIndex` giving their original tier position; all other records
|
||||||
|
have index zero. Explicit empty groups and `NoSource` consume no source ID.
|
||||||
|
Byte, source, node, and glob-entry budgets are shared across the load, while
|
||||||
|
the existing per-source and import-depth bounds still apply.
|
||||||
|
|
||||||
|
#### Input destinations
|
||||||
|
|
||||||
|
`Input.Destination` places the source's application mapping beneath a path
|
||||||
|
relative to the configuration root. Its zero or empty value means root, so
|
||||||
|
existing inputs keep their behavior. Main inputs and underlay inputs use the
|
||||||
|
same destination model. Destination segments are literal mapping keys: dots,
|
||||||
|
slashes, and numeric-looking text are not split or interpreted as sequence
|
||||||
|
indexes. Explicit index segments and paths that introduce the reserved
|
||||||
|
`configkit` or mapping-valued `extends` structure are invalid input options and
|
||||||
|
produce a structured `LoadError` before source acquisition.
|
||||||
|
|
||||||
|
Source-root `configkit.imports` directives are processed before placement, and
|
||||||
|
each imported application contribution inherits its input's destination,
|
||||||
|
including transitive imports. A valid destination on `NoSource()` contributes
|
||||||
|
nothing; an actual empty mapping materializes its destination containers. Once
|
||||||
|
placed, contributions follow the same strict composition within a tier and
|
||||||
|
recursive precedence behavior described above. A selected overlay and CLI
|
||||||
|
overrides remain rooted at the application root and are not moved beneath the
|
||||||
|
main input's destination.
|
||||||
|
|
||||||
|
`SourceInfo.Destination` reports the placement for each acquired input source,
|
||||||
|
including imports. Placement does not change source IDs, acquisition identity,
|
||||||
|
physical source locations, or passive resource contexts. Introduced ancestor
|
||||||
|
mappings use the contributing document's root location as a source fallback;
|
||||||
|
authored content retains its original source coordinates. Returned source
|
||||||
|
records are detached copies.
|
||||||
|
|
||||||
|
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
|
||||||
|
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
|
||||||
|
and explicit resource access without a second configuration pass.
|
||||||
|
|
||||||
|
### Sibling inheritance
|
||||||
|
|
||||||
|
Any input mapping may declare `extends: name` to inherit from the mapping named
|
||||||
|
`name` among its immediate siblings. Names are exact keys, including the empty
|
||||||
|
string; lookup does not search ancestor or unrelated scopes. The document root
|
||||||
|
and a sequence-element mapping have no sibling namespace for their own string
|
||||||
|
relationship, though mappings nested inside them can have one. An `extends`
|
||||||
|
value of null cancels a relationship without looking up a parent. Other value
|
||||||
|
types, missing or non-mapping parents, and cycles fail the load with value-free
|
||||||
|
diagnostics.
|
||||||
|
|
||||||
|
`extends` is reserved structural input across ordinary and layered loads, every
|
||||||
|
source kind, imports, selected overlays, and explicit CLI overrides. It is
|
||||||
|
consumed before typed decoding and need not appear in the application's Go
|
||||||
|
types. Applications previously using `extends` as an ordinary YAML field must
|
||||||
|
rename that key and its field. No collection registration or keyword setup is
|
||||||
|
required.
|
||||||
|
|
||||||
|
ConfigKit completes strict composition within tiers and precedence assembly
|
||||||
|
between tiers before resolving relationships. An omitted `extends` retains a
|
||||||
|
lower-priority relationship; `extends: null` cancels it while preserving the
|
||||||
|
child's own settings. Superseded references are historical evidence, not active
|
||||||
|
relationships to validate. Each child recursively overlays its own settings
|
||||||
|
over its resolved parent: explicit zero, false, empty string, sequence, and
|
||||||
|
null values replace inherited values, while mappings combine recursively.
|
||||||
|
The child retains its key. All effective relationships resolve, including
|
||||||
|
entries the application may never select for execution. Partial parent
|
||||||
|
definitions undergo normal typed decoding and application-owned validation,
|
||||||
|
not a special structural exemption.
|
||||||
|
|
||||||
|
Enclosing relationships resolve first. Nested instructions carried by a parent
|
||||||
|
are then resolved separately at each destination, so a child's nested sibling
|
||||||
|
override can change an inherited nested relationship. Typed defaults and
|
||||||
|
application-derived values do not start another inheritance pass. Inherited
|
||||||
|
input counts as explicit presence for `Metadata.IsExplicit`,
|
||||||
|
`Resolution.IsExplicit`, and absence-based resolver defaults, even for false,
|
||||||
|
zero, empty, or null input. `Origin.Inheritance` distinguishes it from a direct
|
||||||
|
declaration with concrete source, destination, and controlling directive paths.
|
||||||
|
A direct child replacement has its own effective origin; displaced ancestry
|
||||||
|
remains inspectable in history. An inherited relative resource keeps the
|
||||||
|
declaring source's passive `SourceContext`, not the child's directory.
|
||||||
|
|
||||||
|
The optional schema-version checker still runs once against the main input at
|
||||||
|
its original point. Inheritance cannot add, replace, or consume the protected
|
||||||
|
version path; an unrelated relationship inside an enclosing mapping does not
|
||||||
|
invalidate an unchanged version. Expansion uses the decoded projection
|
||||||
|
node/depth limits without charging copied values as acquired sources;
|
||||||
|
retained evidence uses the existing history-node limit. See the
|
||||||
|
[consumer example](consumers/README.md#named-sibling-inheritance) for one-load
|
||||||
|
usage.
|
||||||
|
|
||||||
|
### Source resource contexts
|
||||||
|
|
||||||
|
`Result.SourceContext(id)` and callback-scoped `Resolution.SourceContext(id)`
|
||||||
|
return a passive `ResourceContext` for a source with filesystem context, or
|
||||||
|
false for unknown and contextless IDs. The result query is nil-safe; the
|
||||||
|
resolution query follows the callback's lifetime guard. Queries perform no
|
||||||
|
filesystem access. Obtain IDs from exact origin locations rather than guessing
|
||||||
|
one context for a composite origin or an application-derived value.
|
||||||
|
|
||||||
|
`ResourceContext.FS` is rooted at the source's configured root, and `BaseDir`
|
||||||
|
is its slash-relative directory inside that root. This includes imported
|
||||||
|
sources, selected overlays, host files even without enabled imports, native
|
||||||
|
files, and byte, reader, or mapping inputs with an explicit context. CLI and
|
||||||
|
contextless synthetic inputs have no resource context. To open a relative
|
||||||
|
resource, join `BaseDir` with the application resource name using slash-path
|
||||||
|
operations and open that path through `FS`.
|
||||||
|
|
||||||
|
For a resource-valued field, query `result.Metadata().Lookup(fieldPath)`, inspect
|
||||||
|
its origin locations, choose a source ID only when the application's policy can
|
||||||
|
identify the intended contributor, then call `result.SourceContext(id)`. The
|
||||||
|
context belongs to that source even when the field survived from a lower-priority
|
||||||
|
underlay. A composite origin has no automatically selected resource base.
|
||||||
|
|
||||||
|
Native contexts borrow the caller's filesystem within the configured root.
|
||||||
|
Host contexts retain a canonical root path, not an open root handle; each
|
||||||
|
resource `Open` checks containment and opens a fresh handle that the returned
|
||||||
|
file's `Close` releases. Applications own and close the files they open.
|
||||||
|
Resources are application data, not configuration input, and do not consume
|
||||||
|
configuration byte or node budgets. The loaded configuration and metadata stay
|
||||||
|
usable if source files later change or disappear. Later resource operations
|
||||||
|
observe the backing filesystem then and may fail if files move, disappear, or
|
||||||
|
a caller-owned filesystem closes. This is not a snapshot or universal sandbox.
|
||||||
|
|
||||||
### Application schema versions
|
### Application schema versions
|
||||||
|
|
||||||
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
||||||
@@ -108,8 +301,9 @@ successful result. Callback panics remain programming failures.
|
|||||||
`Result.Metadata` describes every final canonical path. Queries retain explicit
|
`Result.Metadata` describes every final canonical path. Queries retain explicit
|
||||||
key/index distinctions using actual effective and historical container shapes;
|
key/index distinctions using actual effective and historical container shapes;
|
||||||
only parsed numeric tokens are resolved contextually. A descendant that never
|
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
|
existed is not historical merely because an atomic ancestor was replaced. Exact
|
||||||
are explicit and have a base or overlay origin; omitted paths have a default origin.
|
surviving direct or inherited input paths are explicit and retain their source
|
||||||
|
layer and derivation; omitted paths have a default origin.
|
||||||
An explicit parent mapping does not make omitted children explicit. Null state and
|
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
|
available source coordinates are retained. `History` records the Go-zero or application
|
||||||
default baseline followed by exact base or overlay definitions, including
|
default baseline followed by exact base or overlay definitions, including
|
||||||
@@ -124,8 +318,9 @@ domain ownership rules remain application responsibilities. The method's Go
|
|||||||
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
|
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
|
||||||
details.
|
details.
|
||||||
|
|
||||||
History is chronological: a path can have a synthetic default record, its exact base
|
History is chronological: a path can have a synthetic default record, its exact
|
||||||
definition, an overlay replacement, and a recorded resolution operation. A descendant
|
base definition, an overlay replacement, structural inheritance, and a recorded
|
||||||
|
resolution operation. A descendant
|
||||||
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
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
|
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
|
effective. Equal-valued input is still an explicit base definition, while a derived
|
||||||
@@ -141,6 +336,15 @@ operation. Reasons and dependency paths retain first occurrence order with dupli
|
|||||||
removed. Source records remain ordered as selected source, depth-first imports, the
|
removed. Source records remain ordered as selected source, depth-first imports, the
|
||||||
selected overlay, then synthetic CLI records in supplied order.
|
selected overlay, then synthetic CLI records in supplied order.
|
||||||
|
|
||||||
|
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
|
||||||
|
lists concrete transfers from contributor paths through destinations, with
|
||||||
|
each step's controlling `extends` path and location. `History` retains source
|
||||||
|
applications at their original paths and projects parent contributions, child
|
||||||
|
replacements, and consumed controls at destination paths after source assembly
|
||||||
|
and before typed decoding. `HistoricalPaths` includes consumed directives and
|
||||||
|
displaced inherited descendants. A control can have history even when `Lookup`
|
||||||
|
reports it absent. Queries return detached records.
|
||||||
|
|
||||||
### Secret classification and effective rendering
|
### Secret classification and effective rendering
|
||||||
|
|
||||||
Secret classification combines `configkit:"secret"` field tags with the optional
|
Secret classification combines `configkit:"secret"` field tags with the optional
|
||||||
@@ -156,6 +360,11 @@ Propagation reaches a fixed point, so record/map iteration order does not affect
|
|||||||
result and dependency cycles terminate. A dependency on a classified descendant or
|
result and dependency cycles terminate. A dependency on a classified descendant or
|
||||||
on a container that contains one is treated conservatively as sensitive. Applications
|
on a container that contains one is treated conservatively as sensitive. Applications
|
||||||
must classify secret-producing transformations whose dependencies are not recorded.
|
must classify secret-producing transformations whose dependencies are not recorded.
|
||||||
|
Inherited value and control paths link source and destination classifications
|
||||||
|
through chains and nested copies; unrelated sibling fields are not classified
|
||||||
|
merely because another field is secret. Displaced historical evidence remains
|
||||||
|
redacted. Secret references are copied only as unresolved names; credential
|
||||||
|
lookup stays after decoding and application resolution.
|
||||||
|
|
||||||
`Result.WriteEffective` writes the frozen resolved snapshot as deterministic redacted
|
`Result.WriteEffective` writes the frozen resolved snapshot as deterministic redacted
|
||||||
YAML. Mappings sort by decoded key string, sequences retain order, indentation is two
|
YAML. Mappings sort by decoded key string, sequences retain order, indentation is two
|
||||||
@@ -190,7 +399,10 @@ parser values. `Present=false` with empty YAML means absence or deletion; explic
|
|||||||
null is present and renders as `null`. The history starts with the canonical typed
|
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
|
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||||
traversal order, the selected overlay, 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
|
resolution operations. Structural inheritance events appear after source replay
|
||||||
|
and before typed reconciliation, including consumed or canceled `extends` input
|
||||||
|
and equal-valued child replacements. Overlapping parent/child resolution records
|
||||||
|
are rendered
|
||||||
as one net subtree event with combined reasons and dependencies, rather than
|
as one net subtree event with combined reasons and dependencies, rather than
|
||||||
pretending that the recorded assignments ran in lexical order. Explicit equal
|
pretending that the recorded assignments ran in lexical order. Explicit equal
|
||||||
writes remain visible. When input replay
|
writes remain visible. When input replay
|
||||||
@@ -238,6 +450,46 @@ an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
|
|||||||
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
|
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
|
||||||
bundle these independent reports into a command framework.
|
bundle these independent reports into a command framework.
|
||||||
|
|
||||||
|
## Effective configuration diffs
|
||||||
|
|
||||||
|
`configkit.Diff(left, right)` compares two completed successful loads of the same
|
||||||
|
configuration type. `left` is the earlier state and `right` is the later state.
|
||||||
|
Applications load both sides with the sources and options they choose; to inspect
|
||||||
|
an overlay's impact, load the same base once without the overlay and once with it
|
||||||
|
selected. The [consumer guide](consumers/README.md#effective-configuration-diffs)
|
||||||
|
links to maintained examples. The public API returns safe changed-region records;
|
||||||
|
applications own their presentation.
|
||||||
|
|
||||||
|
Comparison uses the frozen canonical effective inspection values, not mutable
|
||||||
|
`Result.Config`, source text, provenance, or history. It does not reopen sources,
|
||||||
|
reread environment variables, or rerun defaults or callbacks. Equal effective
|
||||||
|
values produce no records even when sources, explicit-input status, defaults, or
|
||||||
|
resolution histories differ; for example, explicit zero and defaulted zero are
|
||||||
|
equal. Mappings compare recursively, sequences compare atomically, and scalar,
|
||||||
|
kind, or null/container replacements report their affected region. Records use
|
||||||
|
the existing comparator's canonical pointer ordering. `Present=false` with empty
|
||||||
|
YAML means absence; explicit null is present and renders as `null`.
|
||||||
|
|
||||||
|
Before either side is returned, sensitivity from both loads is applied to both
|
||||||
|
snapshots, including historical and dependency-derived classification. A
|
||||||
|
classification on either side protects the corresponding values on both sides.
|
||||||
|
`Redacted` reports masking, while `HiddenChanged` reports an actual concealed
|
||||||
|
value or presence change; it is not implied merely by redaction. A structural
|
||||||
|
replacement that prevents safe correspondence masks the complete reported
|
||||||
|
region, and an absent side remains absent. Record membership establishes that a
|
||||||
|
change occurred even when safe snapshots render identically. Literal
|
||||||
|
marker-looking input is ordinary input; consumers use the record fields rather
|
||||||
|
than interpreting marker text.
|
||||||
|
|
||||||
|
Typed secret references contribute their configured environment names to the
|
||||||
|
frozen comparison, never resolved credential bytes. Credential rotation behind
|
||||||
|
an unchanged reference is therefore not detected. The records contain changed
|
||||||
|
regions only, not paired full-document context; independently calling
|
||||||
|
`WriteEffective` on each result does not create cross-protected context. Nil or
|
||||||
|
uninitialized results return an error with no records. A failed `Load` retains
|
||||||
|
its normal nil-result/`LoadError` behavior, which callers handle before invoking
|
||||||
|
`Diff`.
|
||||||
|
|
||||||
## Typed secret references
|
## Typed secret references
|
||||||
|
|
||||||
Credential-bearing fields use the opaque `Secret` type and store an environment
|
Credential-bearing fields use the opaque `Secret` type and store an environment
|
||||||
@@ -442,8 +694,8 @@ input document.
|
|||||||
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
|
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
|
||||||
`default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
|
`default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
|
||||||
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
|
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
|
||||||
no extension fallback, case normalization, inheritance, or overlay stack. A selected
|
no extension fallback, case normalization, inheritance between overlay files,
|
||||||
missing overlay is an error.
|
or overlay stack. A selected missing overlay is an error.
|
||||||
|
|
||||||
The overlay 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
|
base import graph is composed and its schema version is checked, but before defaults
|
||||||
@@ -557,18 +809,25 @@ walk failures, matched directories, invalid documents, escapes, and repeated tar
|
|||||||
remain errors. Final candidates are bounded by the remaining source limit before
|
remain errors. Final candidates are bounded by the remaining source limit before
|
||||||
their contents are accumulated.
|
their contents are accumulated.
|
||||||
|
|
||||||
The configuration root defaults to a selected file's canonical directory. A file
|
For host files, the configuration root defaults to the selected file's canonical
|
||||||
source may supply `FilesystemContext.Root` to widen that root but may not supply
|
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
||||||
`BaseDir`. Whenever byte and reader sources supply filesystem context, they must
|
may not supply `BaseDir` or a native `FS`. Byte, reader, and mapping inputs with host context
|
||||||
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic
|
must provide both `BaseDir` and `Root` as absolute existing directories.
|
||||||
labels, including URI-looking labels, never imply filesystem paths. The base and
|
For `Filesystem.File`, the default root is `.` in its namespace and the base is
|
||||||
selected file must be inside the canonical root. Nonregular targets are rejected before opening to avoid blocking on FIFOs;
|
the selected file's directory. An explicit context must use the same `Filesystem`
|
||||||
|
namespace and may set `Root` to contain the file and its base; it may not supply
|
||||||
|
`BaseDir`. Byte, reader, and mapping inputs with native context provide both `BaseDir` and
|
||||||
|
`Root` as slash-relative `fs.ValidPath` directories in `FS` (`.` means its root),
|
||||||
|
with the base inside the root. Context paths are checked even when imports and
|
||||||
|
overlays are inactive. Diagnostic labels, including URI-looking labels, never
|
||||||
|
imply filesystem paths. Nonregular targets are rejected before opening to avoid blocking on FIFOs;
|
||||||
post-open regular-file checks remain in place under the stable-input assumption.
|
post-open regular-file checks remain in place under the stable-input assumption.
|
||||||
ConfigKit canonicalizes symlinks,
|
ConfigKit canonicalizes symlinks,
|
||||||
checks containment with relative paths, and opens the canonical root-relative target
|
checks containment with relative paths, and opens the canonical root-relative target
|
||||||
through a library-owned root handle. Contained relative or absolute symlink targets
|
through a library-owned root handle. Contained relative or absolute symlink targets
|
||||||
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
||||||
distinct paths remain distinct sources.
|
distinct paths remain distinct sources. Native filesystems use slash-path operations
|
||||||
|
and their own symlink behavior; imports stay in their selected namespace and root.
|
||||||
|
|
||||||
An active-stack repeat is `import.cycle` and reports the complete cycle's import
|
An active-stack repeat is `import.cycle` and reports the complete cycle's import
|
||||||
sites. A previously visited canonical path is `import.repeated` and reports both
|
sites. A previously visited canonical path is `import.repeated` and reports both
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
|||||||
resource checks, remote acquisition, and service initialization.
|
resource checks, remote acquisition, and service initialization.
|
||||||
|
|
||||||
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
|
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
|
||||||
four sanitized shapes using only ConfigKit and the Go standard library:
|
sanitized shapes using only ConfigKit and the Go standard library:
|
||||||
|
|
||||||
| Shape | Demonstrated fit | Application code deliberately retained |
|
| Shape | Demonstrated fit | Application code deliberately retained |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -15,6 +15,11 @@ four sanitized shapes using only ConfigKit and the Go standard library:
|
|||||||
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
||||||
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
||||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
||||||
|
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
|
||||||
|
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
|
||||||
|
| Mounted collections | Literal input destinations, peer prompt/profile fragments, local inheritance, imported collection metadata, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
|
||||||
|
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
|
||||||
|
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
|
||||||
|
|
||||||
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
||||||
executable examples and ordinary tests. It contains no private application data,
|
executable examples and ordinary tests. It contains no private application data,
|
||||||
@@ -48,6 +53,93 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
|
|||||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||||
execution profile concepts.
|
execution profile concepts.
|
||||||
|
|
||||||
|
## Effective configuration diffs
|
||||||
|
|
||||||
|
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
|
||||||
|
with one application definition, handles each load error, calls `configkit.Diff`,
|
||||||
|
and presents the safe records through consumer-owned `FormatDifferences`.
|
||||||
|
[`CompareRolloutOverlay`](../../examples/consumers/diff.go) loads the same base
|
||||||
|
once without an overlay and once with the selected overlay, so omitted lower
|
||||||
|
priority values remain present. Its resolver records a derived endpoint URL after
|
||||||
|
overlay application, and its nested typed secret reference is classified before
|
||||||
|
the resulting records are presented.
|
||||||
|
|
||||||
|
The maintained [behavior tests and executable examples](../../examples/consumers/diff_test.go)
|
||||||
|
use temporary files and disposable secret values. Presenters should use
|
||||||
|
`ValueSnapshot.Present` for absence, record membership for change, and
|
||||||
|
`HiddenChanged` for a withheld change indicator. Do not parse the redaction marker
|
||||||
|
or add separately rendered full contexts that lack combined sensitivity. The
|
||||||
|
[configuration contract](../config.md#effective-configuration-diffs) owns shared
|
||||||
|
equality, granularity, and disclosure semantics.
|
||||||
|
|
||||||
|
## Named sibling inheritance
|
||||||
|
|
||||||
|
[`LoadPresets`](../../examples/consumers/inheritance.go) loads the maintained
|
||||||
|
[`presets.yml`](../../examples/consumers/testdata/inheritance/presets.yml) in one
|
||||||
|
call. `custom` inherits `enabled: false` from `base`, while its nested
|
||||||
|
`settings.primary` changes the inherited `settings.secondary` result at the
|
||||||
|
custom destination. The application struct has no `extends` field; ConfigKit
|
||||||
|
consumes the relationship before strict typed decoding.
|
||||||
|
|
||||||
|
The [executable example](../../examples/consumers/consumers_test.go) reads
|
||||||
|
`Metadata.IsExplicit` for the inherited false value, then follows
|
||||||
|
`Origin.Inheritance` back to the parent path while `Origin.Locations` identifies
|
||||||
|
the declaring source. Applications still choose which preset to execute and
|
||||||
|
validate domain meaning. The [configuration contract](../config.md#sibling-inheritance)
|
||||||
|
owns exact lookup, precedence, cancellation, and migration rules.
|
||||||
|
|
||||||
|
## Native filesystem and layered input
|
||||||
|
|
||||||
|
[`LoadBundled`](../../examples/consumers/layered.go) keeps the simple `Load` shape:
|
||||||
|
it selects a file in an application-owned embedded filesystem and needs no
|
||||||
|
underlay setup. [`LoadCatalog`](../../examples/consumers/layered.go) uses
|
||||||
|
`LoadLayered` with an imported embedded-defaults group, a separate
|
||||||
|
`Mapping` group, and a host main file supplied by its caller. The groups
|
||||||
|
compose strictly within themselves and recursively across precedence boundaries;
|
||||||
|
the main file's explicit `false` and `0` replace lower values without being
|
||||||
|
mistaken for omissions. Its validator owns allowed modes and resource-name policy.
|
||||||
|
|
||||||
|
[`ReadWelcome`](../../examples/consumers/layered.go) follows the effective field's
|
||||||
|
origin location to a source ID, queries `Result.SourceContext`, and opens the
|
||||||
|
resource relative to that context's `BaseDir`. It does not assume the main file
|
||||||
|
owns a retained underlay value. A composite origin needs an application choice;
|
||||||
|
ConfigKit does not guess a directory. The context query is passive, while later
|
||||||
|
resource reads observe the caller-owned filesystem or a freshly opened host root
|
||||||
|
and may fail independently of the completed configuration load. The
|
||||||
|
[configuration contract](../config.md#source-resource-contexts) owns exact
|
||||||
|
lifetime and precedence rules.
|
||||||
|
|
||||||
|
## Input destinations and mounted collections
|
||||||
|
|
||||||
|
Main and underlay inputs use the same destination field; imported application
|
||||||
|
contributions inherit the declaring input's destination. A destination is an
|
||||||
|
application mapping path, independent of filesystem context. The configuration
|
||||||
|
contract owns the complete behavior and validation rules.
|
||||||
|
|
||||||
|
The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go)
|
||||||
|
mounts one mapping source beneath literal collection/prompt keys and loads a
|
||||||
|
second input without a destination to show that root placement remains the
|
||||||
|
default. Consumers pass paths, not filenames or namespace strings for ConfigKit
|
||||||
|
to interpret.
|
||||||
|
|
||||||
|
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
|
||||||
|
embedded collection manifest with a source-root import, separate prompt and
|
||||||
|
profile peers, and a higher-priority input that changes only one prompt's
|
||||||
|
ordinary `version` field. The main document alone supplies the application
|
||||||
|
schema version. A prompt's local `extends` relationship resolves after all
|
||||||
|
tiers, so `custom` inherits the final `brief` content and fields while retaining
|
||||||
|
its own profile selection.
|
||||||
|
|
||||||
|
The resolver follows each effective `content_file` origin to its declaring
|
||||||
|
source context. This also works for `custom`'s inherited resource. The
|
||||||
|
application-owned [`ReadCollectionPromptContent`](../../examples/consumers/destinations.go)
|
||||||
|
demonstrates the same lookup after loading; ConfigKit does not open or interpret
|
||||||
|
the resource. [`destinations_test.go`](../../examples/consumers/destinations_test.go)
|
||||||
|
checks source destinations and authored coordinates, presence, inherited values,
|
||||||
|
relative resources, and strict peer conflicts. The
|
||||||
|
[configuration contract](../config.md#input-destinations) owns shared placement,
|
||||||
|
import, source-location, and ownership behavior.
|
||||||
|
|
||||||
Known module catalogs should use canonical object entries with a module identifier
|
Known module catalogs should use canonical object entries with a module identifier
|
||||||
and typed options. A small union struct is sufficient when the available option
|
and typed options. A small union struct is sufficient when the available option
|
||||||
fields are known; validation selects which fields apply to each module. Runtime-
|
fields are known; validation selects which fields apply to each module. Runtime-
|
||||||
@@ -126,6 +218,10 @@ focused behavioral owner:
|
|||||||
| Source/import/selected-overlay 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 |
|
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
||||||
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
||||||
|
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
|
||||||
|
| Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` |
|
||||||
|
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |
|
||||||
|
| Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` |
|
||||||
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
|
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
|
||||||
|
|
||||||
The package-level import-boundary test covers the consumer examples too: supported
|
The package-level import-boundary test covers the consumer examples too: supported
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public behavior.
|
|||||||
| Task | Read |
|
| Task | Read |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Repository orientation or component responsibility | [Component overview](internal/overview.md) |
|
| Repository orientation or component responsibility | [Component overview](internal/overview.md) |
|
||||||
|
| Input destinations | [Configuration contract](config.md#input-destinations), [ADR 0011](adr/0011-input-destinations.md), and the destination tests below |
|
||||||
| Documentation changes | The owning document |
|
| Documentation changes | The owning document |
|
||||||
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
||||||
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
|
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
|
||||||
@@ -54,6 +55,8 @@ Accepted decisions:
|
|||||||
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.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)
|
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
|
||||||
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
||||||
|
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
||||||
|
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
@@ -64,8 +67,9 @@ git show cb5b435:docs/roadmap/remediation.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
Release-candidate, publication, and verification work follows the operational
|
Release-candidate, publication, and verification work follows the operational
|
||||||
[release procedure](release.md). Its single publication command validates the
|
[release procedure](release.md). Its module-selected ReleaseKit `publish`
|
||||||
committed candidate and publishes only the intended refs.
|
invocation validates the committed candidate and publishes only the intended
|
||||||
|
tag; those release checks remain distinct from the everyday development check.
|
||||||
|
|
||||||
The completed string-and-integer schema-version implementation and remediation
|
The completed string-and-integer schema-version implementation and remediation
|
||||||
records are preserved in Git history at `244104f`. The
|
records are preserved in Git history at `244104f`. The
|
||||||
@@ -80,14 +84,40 @@ and current facade ownership belongs to the [component overview](internal/overvi
|
|||||||
|
|
||||||
## Baseline validation
|
## Baseline validation
|
||||||
|
|
||||||
|
The completed filesystem-source and underlay roadmap, original feature request,
|
||||||
|
implementation plan, and resolved remediation record are preserved in Git history
|
||||||
|
at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the
|
||||||
|
[configuration contract](config.md#layered-loading). Built-in sibling
|
||||||
|
inheritance is now part of the [configuration contract](config.md#sibling-inheritance).
|
||||||
|
The completed request, roadmap, plan, and resolved review findings are preserved
|
||||||
|
in Git history at `83c59ea` under `docs/roadmap/`.
|
||||||
|
|
||||||
The completed overlay rename plan and release-workflow simplification record are
|
The completed overlay rename plan and release-workflow simplification record are
|
||||||
preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
|
preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
|
||||||
behavior belongs to the [configuration contract](config.md#selected-overlays),
|
behavior belongs to the [configuration contract](config.md#selected-overlays),
|
||||||
and release operations belong to the [release procedure](release.md).
|
and release operations belong to the [release procedure](release.md).
|
||||||
|
|
||||||
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
The completed ReleaseKit adoption, startup performance request and experiments,
|
||||||
dependencies explicitly before entering an offline development or validation
|
benchmark evidence, performance audit, implementation plan, and remediation
|
||||||
environment:
|
record are preserved in Git history at `8e91a2b` under `docs/roadmap/`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 8e91a2b:docs/roadmap/startup-benchmark.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Remaining optimization candidates belong to
|
||||||
|
[deferred scope](roadmap/deferred.md#deferred-performance-experiments).
|
||||||
|
|
||||||
|
The completed effective-configuration diff roadmap, implementation plan, and
|
||||||
|
resolved review findings are preserved in Git history at `1c2610c` under
|
||||||
|
`docs/roadmap/`. The
|
||||||
|
[configuration contract](config.md#effective-configuration-diffs) owns current
|
||||||
|
comparison and disclosure behavior, and
|
||||||
|
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
|
||||||
|
|
||||||
|
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||||
|
Acquire declared dependencies explicitly before entering an offline
|
||||||
|
development or validation environment:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go mod download
|
go mod download
|
||||||
@@ -117,11 +147,10 @@ Use focused checks while iterating, with validation proportionate to the change
|
|||||||
under the testing policy. Documentation-only work normally needs the local-link
|
under the testing policy. Documentation-only work normally needs the local-link
|
||||||
check below and whitespace review; behavior-bearing examples and commands also
|
check below and whitespace review; behavior-bearing examples and commands also
|
||||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
||||||
The [publication command](release.md#4-publish-the-committed-candidate) invokes
|
The [release procedure](release.md#4-publish-the-committed-candidate) performs
|
||||||
the release checker once. It performs one minimum-toolchain offline race-suite
|
its selected validation through one `publish` invocation. The everyday checker
|
||||||
run, one full vet pass, and Linux cross-builds using readonly module mode.
|
remains a development gate and is not a separate release prerequisite. CI never
|
||||||
It already covers examples, links, and package boundaries; do not additionally
|
gates a release.
|
||||||
run the everyday checker just to repeat those checks. CI never gates a release.
|
|
||||||
|
|
||||||
## Task-to-test map
|
## Task-to-test map
|
||||||
|
|
||||||
@@ -133,15 +162,23 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 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'` |
|
| 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'` |
|
| 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'` |
|
||||||
|
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||||
| 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'` |
|
| 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'` |
|
||||||
|
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||||
|
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
||||||
|
| Input destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier and import tests](../internal/engine/destination_tiers_test.go), [integration and provenance tests](../internal/engine/destination_integration_test.go), [public facade tests](../facade_destination_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Mounted'`, `go test . -run 'Destination|FacadeMounted'`, and `go test ./examples/consumers -run 'CollectionBundle|CollectionPeer'` |
|
||||||
| 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'` |
|
| 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'` |
|
| 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'` |
|
| 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'` |
|
||||||
|
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
||||||
| 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'` |
|
| 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'` |
|
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
|
||||||
|
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native and layered examples](../examples/consumers/layered_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||||
|
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
||||||
|
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
||||||
| 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'` |
|
| 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'` |
|
||||||
| Development and release checker behavior | [Shell regression](../release_check_test.go) | `go test . -run 'TestCheckScripts\|TestDevelopmentCheck'` |
|
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
||||||
| Guarded release publication and recovery | [Publication regression](../release_publish_test.go) | `go test . -run '^TestReleasePublication'` |
|
| Release profile wiring | [Release profile test](../releasekit_profile_test.go) | `go test . -run '^TestReleaseKitProfile$'` |
|
||||||
|
|
||||||
## Fuzz validation
|
## Fuzz validation
|
||||||
|
|
||||||
|
|||||||
@@ -7,23 +7,34 @@ private state; public signatures and fields do not expose engine types.
|
|||||||
|
|
||||||
Implemented component owners:
|
Implemented component owners:
|
||||||
|
|
||||||
- `internal/engine` owns source acquisition, composition, schema inspection,
|
- `internal/engine` owns host and native-filesystem source acquisition,
|
||||||
decoding, lifecycle coordination, callback auditing, provenance/history,
|
grouped strict composition, recursive precedence assembly, per-input mapping
|
||||||
secret resolution and bootstrap, and safe inspection rendering. It never
|
placement within that assembly, and destination-local sibling inheritance
|
||||||
imports the root package.
|
before typed decoding. It retains source placement alongside physical source
|
||||||
- Root `Source`, `Path`, `Secret`, records, lifecycle callbacks, results, and
|
identity and owns schema
|
||||||
bootstrap reports are root-defined wrappers or records. Callback wrappers pass
|
inspection, lifecycle coordination, callback auditing,
|
||||||
only live engine context during a callback; `Result.Config` is detached and its
|
provenance/history, passive source-context retention, secret resolution and
|
||||||
private inspection remains frozen independently.
|
bootstrap, safe inspection rendering, and effective-value comparison over
|
||||||
|
frozen inspection snapshots. It never imports the root package.
|
||||||
|
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `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
|
- Root conversion files explicitly cross the path, record, callback, secret, and
|
||||||
error boundaries. There are no public aliases, anonymous engine embeddings, or
|
error boundaries. There are no public aliases, anonymous engine embeddings, or
|
||||||
public engine bridge methods.
|
public engine bridge methods.
|
||||||
|
- Root `Diff` and `Difference` provide the public comparison boundary and
|
||||||
|
convert detached engine records to root-owned paths and snapshots; consumer
|
||||||
|
examples own presentation of the safe changed regions.
|
||||||
- `internal/document` owns the parser-independent ordered tree and cloning.
|
- `internal/document` owns the parser-independent ordered tree and cloning.
|
||||||
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
|
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
|
||||||
- Engine mechanism tests own source, composition, decoding, callback, provenance,
|
- Engine mechanism tests own source, composition, inheritance, decoding,
|
||||||
secret, history, rendering, and bootstrap behavior. Root tests own public API
|
callback, provenance,
|
||||||
compatibility, facade integration, documentation links, and dependency/public
|
source-context, secret, history, rendering, and bootstrap behavior. Root tests
|
||||||
boundary checks. Consumer examples remain under `examples/consumers`.
|
own public API compatibility, facade integration, documentation links, and
|
||||||
|
dependency/public boundary checks. Consumer examples remain under
|
||||||
|
`examples/consumers`.
|
||||||
|
|
||||||
Dependency direction is root to engine to document/YAML support. The root may
|
Dependency direction is root to engine to document/YAML support. The root may
|
||||||
import only `internal/engine` among project internal production packages; engine
|
import only `internal/engine` among project internal production packages; engine
|
||||||
@@ -32,8 +43,14 @@ these constraints and rejects exported aliases, fields, or signatures involving
|
|||||||
internal types.
|
internal types.
|
||||||
|
|
||||||
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||||
semantics. The [consumer adoption guide](../consumers/README.md) owns application
|
semantics, including [input destinations](../config.md#input-destinations) and
|
||||||
guidance. The [architecture policy](../policy/architecture.md) owns contributor
|
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||||
boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade
|
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
||||||
|
The [architecture policy](../policy/architecture.md) owns contributor
|
||||||
|
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
||||||
|
and [ADR 0010](../adr/0010-effective-configuration-diff.md) records the diff
|
||||||
|
decision; [ADR 0011](../adr/0011-input-destinations.md) records the placement
|
||||||
decision. See the [development guide](../development.md) for validation commands
|
decision. See the [development guide](../development.md) for validation commands
|
||||||
and [release procedure](../release.md) for source-candidate gates.
|
and [release procedure](../release.md) for source-candidate gates. [ADR
|
||||||
|
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in
|
||||||
|
post-assembly structural operation.
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
## Standard Repository Policies
|
## Standard Repository Policies
|
||||||
|
|
||||||
Material: `docs/policy/meta.md`, `docs/policy/oss.md`,
|
Material: `docs/policy/release.md`, adapted from
|
||||||
|
`e833faddf120c789a9b45348edaf75720de16348`; `docs/policy/meta.md`, `docs/policy/oss.md`,
|
||||||
`docs/policy/documentation.md`, `docs/policy/agents.md`,
|
`docs/policy/documentation.md`, `docs/policy/agents.md`,
|
||||||
`docs/policy/architecture.md`, `docs/policy/testing.md`, and
|
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
|
||||||
`docs/policy/release.md`; root `AGENTS.md`; and the Policy Reading Guide in
|
Policy Reading Guide in `docs/development.md`, adapted from
|
||||||
`docs/development.md`, adapted for this repository.
|
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
@@ -14,10 +15,14 @@ SPDX-License-Identifier: CC-BY-SA-4.0
|
|||||||
|
|
||||||
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE).
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||||
|
|
||||||
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
||||||
|
|
||||||
## go-yaml v4
|
## go-yaml v4
|
||||||
|
|
||||||
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
|
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.3
|
**Base Policy Version:** 1.3
|
||||||
|
|
||||||
**Last Updated**: 2026-09-28
|
**Last Updated**: 2026-09-29
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||||
|
|
||||||
@@ -34,6 +34,23 @@ Do not promote incidental implementation choices into architectural invariants.
|
|||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
|
ConfigKit prioritizes a coherent configuration model: consistency and
|
||||||
|
predictability take precedence over flexibility for accommodating individual
|
||||||
|
downstream conventions. Consumers should learn one set of rules, not different
|
||||||
|
combination or presence semantics depending on the source format or consuming
|
||||||
|
application. Distinct operations such as strict composition and intentional
|
||||||
|
overlay express distinct purposes within that model.
|
||||||
|
|
||||||
|
Consumers should be able to load once and receive a complete, validated
|
||||||
|
configuration with inspectable provenance for its final values, including
|
||||||
|
defaults and recorded application-derived values. Configuration and metadata
|
||||||
|
together must distinguish explicit zero, false, empty, and null input from
|
||||||
|
omission; ordinary Go values alone need not encode those distinctions.
|
||||||
|
Application-owned resolution belongs within the recorded loading lifecycle, so
|
||||||
|
consumers need no subsequent generic merging, defaulting, or provenance
|
||||||
|
reconstruction. This does not transfer domain execution or runtime resource
|
||||||
|
management into ConfigKit.
|
||||||
|
|
||||||
1. **Reduce application infrastructure:** Can applications delete substantial
|
1. **Reduce application infrastructure:** Can applications delete substantial
|
||||||
configuration infrastructure while retaining clear ownership of their domain
|
configuration infrastructure while retaining clear ownership of their domain
|
||||||
rules?
|
rules?
|
||||||
@@ -41,8 +58,11 @@ Do not promote incidental implementation choices into architectural invariants.
|
|||||||
configuration model, with only the flexibility needed to express genuine
|
configuration model, with only the flexibility needed to express genuine
|
||||||
application requirements?
|
application requirements?
|
||||||
|
|
||||||
Both criteria must hold. ConfigKit should not preserve divergent consumer
|
Both criteria must hold. Minimize consumer loading and processing code subject
|
||||||
conventions merely for compatibility, nor impose restrictions that prevent useful
|
to the consistency, presence, and provenance principles above, rather than
|
||||||
|
trading those principles for consumer-specific shortcuts. ConfigKit should not
|
||||||
|
preserve divergent consumer conventions merely for compatibility, nor impose
|
||||||
|
restrictions that prevent useful
|
||||||
domain information from being expressed. Prefer a canonical representation using
|
domain information from being expressed. Prefer a canonical representation using
|
||||||
existing primitives before adding another mechanism. Extend the model when a
|
existing primitives before adding another mechanism. Extend the model when a
|
||||||
concrete requirement cannot be accommodated cleanly, not simply because a
|
concrete requirement cannot be accommodated cleanly, not simply because a
|
||||||
@@ -79,6 +99,13 @@ types, field meanings, defaults, normalization, semantic validation, and schema
|
|||||||
version policy. Applications also own main-file search conventions, CLI wiring,
|
version policy. Applications also own main-file search conventions, CLI wiring,
|
||||||
remote acquisition, runtime initialization, and resource checks.
|
remote acquisition, runtime initialization, and resource checks.
|
||||||
|
|
||||||
|
The engine applies each input's mapping destination within the existing source
|
||||||
|
assembly and retains that placement with source identity and inspection records;
|
||||||
|
the root facade owns the public fields and conversions. The
|
||||||
|
[configuration contract](../config.md#input-destinations) defines the observable
|
||||||
|
placement behavior, and [ADR 0011](../adr/0011-input-destinations.md) records the
|
||||||
|
accepted decision.
|
||||||
|
|
||||||
Library code must not depend on consuming applications or initialize their
|
Library code must not depend on consuming applications or initialize their
|
||||||
runtime resources. It must not perform implicit network access, discover
|
runtime resources. It must not perform implicit network access, discover
|
||||||
application-specific paths, or change process-global state during ordinary `Load`.
|
application-specific paths, or change process-global state during ordinary `Load`.
|
||||||
@@ -88,6 +115,22 @@ must never run implicitly as part of configuration loading.
|
|||||||
Remote data can enter through application-supplied input without introducing a
|
Remote data can enter through application-supplied input without introducing a
|
||||||
network provider into the library.
|
network provider into the library.
|
||||||
|
|
||||||
|
## Loading Lifecycle
|
||||||
|
|
||||||
|
Configuration loading is a synchronous, complete-on-return operation. A successful
|
||||||
|
load provides the final configuration and inspection metadata after the configured
|
||||||
|
processing and validation phases complete. A failed load reports diagnostics and
|
||||||
|
returns no usable partial result. The normal application pattern is one load near
|
||||||
|
startup; applications may explicitly initiate additional loads.
|
||||||
|
|
||||||
|
ConfigKit releases its library-owned loading handles before returning and remains
|
||||||
|
inactive until the application explicitly invokes it again. It performs no
|
||||||
|
background watching, refresh, or mutation of published results. Caller-owned
|
||||||
|
resources and application callback effects remain the caller's responsibility.
|
||||||
|
The [configuration contract](../config.md#completion-and-resource-lifetime) owns
|
||||||
|
the precise completion and ownership behavior. The proposed decision record is
|
||||||
|
[ADR 0008](../adr/0008-complete-on-return-loading.md).
|
||||||
|
|
||||||
## Public API And Dependency Direction
|
## Public API And Dependency Direction
|
||||||
|
|
||||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||||
@@ -116,18 +159,41 @@ framework on this library.
|
|||||||
|
|
||||||
## Configuration Integrity
|
## Configuration Integrity
|
||||||
|
|
||||||
Composition and intentional overlay are distinct operations. Composition must
|
Within a configuration tier, independent contributions use strict composition:
|
||||||
not silently become last-wins override through file ordering. The decision and
|
shared mappings combine recursively, while overlapping non-mapping definitions
|
||||||
alternatives are recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
are conflicts, including equal values. Between precedence tiers, intentional
|
||||||
|
overlay combines mappings recursively, retains omitted lower-priority children,
|
||||||
|
and replaces other present values according to the existing overlay rules.
|
||||||
|
Tier precedence is explicit; traversal order within a tier establishes stable
|
||||||
|
reporting, not permission to override. The distinction and its rationale are
|
||||||
|
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
||||||
|
|
||||||
|
The main document and its imports form a strictly composed tier. Explicit
|
||||||
|
underlay groups compose strictly within each group, then combine recursively
|
||||||
|
from lowest to highest beneath the main tier. Source acquisition kind does not
|
||||||
|
determine its precedence role. Input-specific constraints, including CLI path
|
||||||
|
rules and opt-in version ownership, remain part of the configuration contract. Consistent combination
|
||||||
|
semantics do not require identical acquisition or lifetime behavior from every
|
||||||
|
source kind.
|
||||||
|
|
||||||
|
After source precedence is complete, built-in sibling `extends` relationships
|
||||||
|
resolve structurally in the same parser-independent representation, using the
|
||||||
|
existing recursive overlay rules. This adds neither an acquired source nor a
|
||||||
|
consumer registration mechanism. Enclosing relationships precede nested
|
||||||
|
destination-local resolution; typed defaults, application resolution, and
|
||||||
|
secret lookup retain their existing lifecycle roles. The [configuration
|
||||||
|
contract](../config.md#sibling-inheritance) owns the exact syntax and presence
|
||||||
|
rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale.
|
||||||
|
|
||||||
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
||||||
parser defaults. Processing order and diagnostics must be deterministic for the
|
parser defaults. Processing order and diagnostics must be deterministic for the
|
||||||
same inputs and options. Invalid input must not produce a successful usable
|
same inputs and options. Invalid input must not produce a successful usable
|
||||||
configuration. Do not run application hooks on invalid typed configuration.
|
configuration. Do not run application hooks on invalid typed configuration.
|
||||||
|
|
||||||
User-controlled precedence is application defaults, composed base, selected
|
User-controlled precedence is application defaults, ordered underlays, composed
|
||||||
overlay, then explicitly supplied CLI overrides. Applications own flag parsing;
|
main input, selected overlay, then explicitly supplied CLI overrides.
|
||||||
ConfigKit owns overlay semantics and provenance. Domain resolution follows these
|
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.
|
inputs and must remain recorded, not become a hidden override channel.
|
||||||
|
|
||||||
Preserve node-based composition until the effective input is ready for typed
|
Preserve node-based composition until the effective input is ready for typed
|
||||||
@@ -142,6 +208,13 @@ See [ADR 0005](../adr/0005-explicit-overrides-and-safe-explanation.md).
|
|||||||
Detailed syntax and operation tables belong to the
|
Detailed syntax and operation tables belong to the
|
||||||
[current configuration contract](../config.md).
|
[current configuration contract](../config.md).
|
||||||
|
|
||||||
|
The frozen inspection boundary also supports comparing completed same-type
|
||||||
|
results without reopening sources or rerunning the load lifecycle. The
|
||||||
|
[configuration contract](../config.md#effective-configuration-diffs) owns
|
||||||
|
comparison and disclosure behavior, the [component overview](../internal/overview.md)
|
||||||
|
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
|
||||||
|
records the accepted decision.
|
||||||
|
|
||||||
## Ownership And Confidentiality
|
## Ownership And Confidentiality
|
||||||
|
|
||||||
Returned configuration and metadata must not share mutable state across loads.
|
Returned configuration and metadata must not share mutable state across loads.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Release Policy
|
# Release Policy
|
||||||
|
|
||||||
**Base Policy Version:** 1.4
|
**Base Policy Version:** 1.6
|
||||||
|
|
||||||
**Last Updated**: 2026-09-28
|
**Last Updated**: 2026-10-09
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/release.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -12,6 +12,9 @@ This policy defines a proportionate workflow for validating and publishing an
|
|||||||
intended source revision under a fresh version. Releases should require little
|
intended source revision under a fresh version. Releases should require little
|
||||||
routine intervention, avoid repeated work, and preserve published contents.
|
routine intervention, avoid repeated work, and preserve published contents.
|
||||||
|
|
||||||
|
Adopt stronger verification or reproducibility requirements through an explicit
|
||||||
|
project-specific decision justified by the intended audience and support needs.
|
||||||
|
|
||||||
The repository-specific commands belong in `docs/release.md`. Historical release
|
The repository-specific commands belong in `docs/release.md`. Historical release
|
||||||
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
||||||
policy and link to it rather than introduce additional release requirements.
|
policy and link to it rather than introduce additional release requirements.
|
||||||
@@ -54,10 +57,11 @@ authoritative primary branch, completes the release. The source commit may be
|
|||||||
pushed before the tag or published together with it through an atomic push.
|
pushed before the tag or published together with it through an atomic push.
|
||||||
The procedure must choose and document the supported approach.
|
The procedure must choose and document the supported approach.
|
||||||
|
|
||||||
ConfigKit uses sequential explicit pushes: first the captured candidate to
|
ConfigKit uses sequential publication: first push the committed candidate to
|
||||||
authoritative `main`, then the annotated tag. Its
|
authoritative `main`, then invoke ReleaseKit once to validate that candidate and
|
||||||
[release procedure](../release.md) names `origin` and checks that its fetch and
|
publish its annotated tag. The [release procedure](../release.md) names
|
||||||
push destinations resolve to the authoritative URL.
|
`origin`; ReleaseKit checks its exact effective push URL before validation and
|
||||||
|
tag publication.
|
||||||
|
|
||||||
Release commands must finish at successful publication. They must not wait for
|
Release commands must finish at successful publication. They must not wait for
|
||||||
or poll CI, require successful CI status, inspect downloadable artifacts, or
|
or poll CI, require successful CI status, inspect downloadable artifacts, or
|
||||||
@@ -76,8 +80,9 @@ the tagged source. The procedure must identify those deliverables and the
|
|||||||
responsibility of their automation. Published deliverables must be traceable
|
responsibility of their automation. Published deliverables must be traceable
|
||||||
to that source and identify the same version.
|
to that source and identify the same version.
|
||||||
|
|
||||||
ConfigKit currently declares no additional deliverables. Its source-tag push
|
ConfigKit currently declares no additional deliverables. Confirmed publication
|
||||||
is the completion point.
|
of the intended source tag is the completion point. CI, macOS validation,
|
||||||
|
hosted delivery, and consumer retrieval are outside the release gates.
|
||||||
|
|
||||||
## Versioning And Compatibility
|
## Versioning And Compatibility
|
||||||
|
|
||||||
@@ -96,7 +101,8 @@ requires a deliberate update to the policy and procedure where needed.
|
|||||||
For releases at or after `v1.0.0`:
|
For releases at or after `v1.0.0`:
|
||||||
|
|
||||||
- a major release may make intentional incompatible changes;
|
- a major release may make intentional incompatible changes;
|
||||||
- a minor release may add backward-compatible behavior; and
|
- a minor release may add backward-compatible behavior or raise the Go consumer
|
||||||
|
minimum as described below; and
|
||||||
- a patch release may make backward-compatible fixes.
|
- a patch release may make backward-compatible fixes.
|
||||||
|
|
||||||
Before `v1.0.0`, a minor release may change a documented public, operational,
|
Before `v1.0.0`, a minor release may change a documented public, operational,
|
||||||
@@ -108,13 +114,53 @@ Additional compatibility promises and deliberate exceptions belong in the
|
|||||||
adopted policy or canonical contract documentation. Release notes explain
|
adopted policy or canonical contract documentation. Release notes explain
|
||||||
changes; they do not redefine current compatibility contracts.
|
changes; they do not redefine current compatibility contracts.
|
||||||
|
|
||||||
|
## Consumer Minimum And Release Toolchain
|
||||||
|
|
||||||
|
Distinguish the minimum toolchain supported for consumers from the toolchain
|
||||||
|
maintainers use to build and validate a release. Record the consumer minimum in
|
||||||
|
the language's standard metadata and explain its support meaning in canonical
|
||||||
|
documentation. Define the selected release toolchain and its setup commands in
|
||||||
|
`docs/release.md`. Toolchain selection and upgrade cadence are repository-specific
|
||||||
|
choices.
|
||||||
|
|
||||||
|
For Go repositories, normally declare the initial stable release of the selected
|
||||||
|
series as the consumer minimum, such as `go 1.26.0`. Use a higher patch minimum
|
||||||
|
when a dependency or an identified correctness fix requires it, and document
|
||||||
|
the reason.
|
||||||
|
|
||||||
|
For release-policy purposes, successful build and release validation on the
|
||||||
|
selected Go patch release are sufficient to presume support across that Go
|
||||||
|
series, subject to the declared consumer minimum and concrete evidence of
|
||||||
|
incompatibility. Investigate specific compatibility concerns as they arise.
|
||||||
|
|
||||||
|
Use the selected release toolchain for candidate validation and release builds,
|
||||||
|
including CI jobs performing those tasks. A newer validation patch version alone
|
||||||
|
leaves the consumer minimum unchanged.
|
||||||
|
|
||||||
|
A minor release may raise the Go consumer minimum, including from one Go series
|
||||||
|
to the next, both before and after `v1.0.0`. This is an explicit exception to the
|
||||||
|
backward-compatibility requirement for minor releases. Patch releases preserve
|
||||||
|
the consumer minimum. Explain an increase's impact and required consumer action
|
||||||
|
in the release note. Other incompatible changes remain subject to the versioning
|
||||||
|
rules above.
|
||||||
|
|
||||||
|
### ConfigKit Toolchain Selection
|
||||||
|
|
||||||
|
For minor and major releases, select the newest stable Go series available in
|
||||||
|
the development environment. For patch releases, retain the series recorded in
|
||||||
|
`go.mod` and its consumer minimum. Use the latest locally available compatible
|
||||||
|
patch in the selected series, with `GOTOOLCHAIN=local`, to build the
|
||||||
|
module-selected ReleaseKit CLI and validate the candidate. The same Go
|
||||||
|
executable builds the tool and runs its validation commands.
|
||||||
|
|
||||||
## Release Documentation And Preparation
|
## Release Documentation And Preparation
|
||||||
|
|
||||||
Every new release must include `docs/releases/<version>.md` in the tagged
|
Every new release must include `docs/releases/<version>.md` in the tagged
|
||||||
source tree. Use the complete version tag as the filename and begin with a
|
source tree. Use the complete version tag as the filename and begin with a
|
||||||
title containing the product name and that exact version.
|
title containing the product name and that exact version.
|
||||||
|
|
||||||
ConfigKit requires the first line `# ConfigKit <version>`.
|
ConfigKit requires the first line `# ConfigKit <version>`. ReleaseKit's
|
||||||
|
publication guard checks the release-note H1 for the product and version.
|
||||||
|
|
||||||
Include a short summary and any relevant compatibility impact or upgrade
|
Include a short summary and any relevant compatibility impact or upgrade
|
||||||
instructions. Choose sections suited to the release and keep the note
|
instructions. Choose sections suited to the release and keep the note
|
||||||
@@ -146,10 +192,9 @@ checks publication state, and publishes the intended refs. A validation-only
|
|||||||
mode or command may be provided for diagnosis, but must not be a required step
|
mode or command may be provided for diagnosis, but must not be a required step
|
||||||
before publication repeats the same checks.
|
before publication repeats the same checks.
|
||||||
|
|
||||||
ConfigKit uses `./scripts/release.sh vMAJOR.MINOR.PATCH`. The separate
|
ConfigKit uses the module-selected ReleaseKit CLI's `publish
|
||||||
`scripts/release-check.sh` remains an optional diagnostic command; the
|
vMAJOR.MINOR.PATCH` command. Its `check` command is optional for diagnosis; it
|
||||||
publication command invokes it once and does not use a persistent candidate
|
is not a required preliminary pass to publication.
|
||||||
record.
|
|
||||||
|
|
||||||
Run the complete deterministic, offline default test suite once for the final
|
Run the complete deterministic, offline default test suite once for the final
|
||||||
candidate, following the [testing policy](testing.md). For repositories that
|
candidate, following the [testing policy](testing.md). For repositories that
|
||||||
@@ -157,13 +202,17 @@ publish builds, verify the required build targets. Select those targets for
|
|||||||
the application's actual support commitments; native testing on each platform
|
the application's actual support commitments; native testing on each platform
|
||||||
and access to particular hardware are not default release prerequisites.
|
and access to particular hardware are not default release prerequisites.
|
||||||
|
|
||||||
ConfigKit's one maintained Go 1.25 baseline runs the full suite with the race
|
Prepare dependencies and build the tool before offline validation; the
|
||||||
detector and test-time vet disabled. This protects library behavior, ownership,
|
publication command is the required validation run, with no separate successful
|
||||||
and concurrent use; the suite already includes examples, consumer fixtures,
|
test or CI run required before it.
|
||||||
documentation links, and package boundaries. One full vet pass adds static
|
|
||||||
checks beyond the test-time subset. CGO-disabled Linux amd64 and arm64 builds
|
The selected profile runs the complete deterministic, offline default suite
|
||||||
cover supported configurations distinct from native race compilation. macOS
|
once with the race detector and test-time vet disabled. The suite already
|
||||||
and newer-toolchain checks are optional.
|
includes examples, consumer fixtures, documentation links, and package
|
||||||
|
boundaries. One full vet pass adds static checks beyond the test-time subset.
|
||||||
|
CGO-disabled Linux amd64 and arm64 package compilation covers supported
|
||||||
|
configurations distinct from native race compilation. macOS and newer-toolchain
|
||||||
|
checks are optional.
|
||||||
|
|
||||||
Additional gates must address a concrete risk that the existing checks do not
|
Additional gates must address a concrete risk that the existing checks do not
|
||||||
adequately cover. Do not automatically require linting, race testing, module
|
adequately cover. Do not automatically require linting, race testing, module
|
||||||
@@ -172,9 +221,10 @@ expanded platform matrices solely because they are possible. Retain such
|
|||||||
checks when their distinct protection justifies their cost. A one-time migration
|
checks when their distinct protection justifies their cost. A one-time migration
|
||||||
check must not silently become a permanent release requirement.
|
check must not silently become a permanent release requirement.
|
||||||
|
|
||||||
The selected Go commands use readonly module mode with `GOPROXY=off`; acquire
|
ReleaseKit runs its selected Go commands with readonly module mode and
|
||||||
the baseline toolchain and dependencies before validation. Formatting and
|
`GOPROXY=off`; acquire dependencies and build the tool before candidate
|
||||||
whitespace remain ordinary development checks in `scripts/check.sh`.
|
validation. Formatting and whitespace remain ordinary development checks in
|
||||||
|
`scripts/check.sh`.
|
||||||
Review module tidiness when dependencies change, not as a release gate.
|
Review module tidiness when dependencies change, not as a release gate.
|
||||||
|
|
||||||
Release tests must not require real credentials, mutable application services,
|
Release tests must not require real credentials, mutable application services,
|
||||||
@@ -206,16 +256,19 @@ Automate the checks needed to publish the intended source under a fresh version:
|
|||||||
authoritative remote;
|
authoritative remote;
|
||||||
- the worktree and index are clean on the primary release branch;
|
- the worktree and index are clean on the primary release branch;
|
||||||
- the source being published is the exact candidate that passed validation,
|
- the source being published is the exact candidate that passed validation,
|
||||||
including its matching release note;
|
including its matching release note H1;
|
||||||
- the remote primary branch can be advanced to that candidate without
|
- the remote primary branch can be advanced to that candidate without
|
||||||
discarding remote history, and is published before the tag; and
|
discarding remote history, and is published before the tag; and
|
||||||
- the tag identifies the validated candidate and only the intended refs are
|
- the tag identifies the validated candidate and only the intended refs are
|
||||||
published.
|
published.
|
||||||
|
|
||||||
ConfigKit also checks that `origin` has exactly the authoritative fetch and
|
ReleaseKit checks that `origin`'s effective push URL exactly matches the
|
||||||
push destination, including configured push URLs. The command checks the
|
authoritative URL before candidate validation. This does not assert the
|
||||||
candidate and unused version again after validation, and checks local state
|
configured fetch URL or require the release-note H1 to be the first line; the
|
||||||
before creating the tag. Ignored local files do not block publication.
|
required `# ConfigKit <version>` first-line format remains a content rule. The
|
||||||
|
command checks the candidate and unused version again after validation, and
|
||||||
|
checks local state before creating the tag. Ignored local files do not block
|
||||||
|
publication.
|
||||||
|
|
||||||
Fetch the authoritative branch and tag state as needed for these checks.
|
Fetch the authoritative branch and tag state as needed for these checks.
|
||||||
Do not force branch or tag updates to bypass a failed guard. Publish explicit
|
Do not force branch or tag updates to bypass a failed guard. Publish explicit
|
||||||
@@ -248,10 +301,10 @@ release without replacing its contents or reusing its version.
|
|||||||
## Repository Release Procedure
|
## Repository Release Procedure
|
||||||
|
|
||||||
Maintain `docs/release.md` as a short, executable maintainer runbook. It must
|
Maintain `docs/release.md` as a short, executable maintainer runbook. It must
|
||||||
identify prerequisites, the primary branch and authoritative remote, version
|
identify prerequisites, the selected release toolchain, the primary branch and
|
||||||
and note preparation, one publication command with local checks and guards,
|
authoritative remote, version and note preparation, one publication command with
|
||||||
source-only deliverables, the successful-tag-push completion point, and
|
local checks and guards, source-only deliverables, the successful-tag-push
|
||||||
partial or unknown-state recovery.
|
completion point, and partial or unknown-state recovery.
|
||||||
|
|
||||||
Use copyable commands where practical. Keep optional diagnostics and consumer
|
Use copyable commands where practical. Keep optional diagnostics and consumer
|
||||||
verification clearly optional. The procedure must not turn preparation work,
|
verification clearly optional. The procedure must not turn preparation work,
|
||||||
|
|||||||
298
docs/release.md
298
docs/release.md
@@ -1,7 +1,7 @@
|
|||||||
# ConfigKit Release Procedure
|
# ConfigKit Release Procedure
|
||||||
|
|
||||||
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
|
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
|
||||||
ConfigKit publishes one source-only Go module. The immutable annotated tag and
|
ConfigKit publishes a source-only Go module. The immutable annotated tag and
|
||||||
matching checked-in release note are the deliverables; there is no binary,
|
matching checked-in release note are the deliverables; there is no binary,
|
||||||
container image, generated package, or separate registry publication.
|
container image, generated package, or separate registry publication.
|
||||||
|
|
||||||
@@ -9,183 +9,221 @@ container image, generated package, or separate registry publication.
|
|||||||
|
|
||||||
- Module: `gitea.maximumdirect.net/eric/ConfigKit`
|
- Module: `gitea.maximumdirect.net/eric/ConfigKit`
|
||||||
- Primary branch: `main`
|
- Primary branch: `main`
|
||||||
- Authoritative remote: `origin`, with fetch and push destination
|
- Authoritative remote: `origin`
|
||||||
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
- Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
||||||
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
|
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
|
||||||
- Minimum Go line: Go 1.25, validated with one maintained patch
|
- Minimum Go version: the first stable patch of the selected Go series, or a
|
||||||
|
higher patch when required by a dependency or specific correctness fix, as
|
||||||
|
recorded in `go.mod`; see the
|
||||||
|
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
||||||
- Release-gated targets: Linux amd64 and arm64
|
- Release-gated targets: Linux amd64 and arm64
|
||||||
- Best-effort targets: macOS amd64 and arm64, under the
|
- Best-effort targets: macOS amd64 and arm64, under the
|
||||||
[platform support policy](policy/release.md#platform-support)
|
[platform support policy](policy/release.md#platform-support)
|
||||||
|
|
||||||
The checker runs one offline race suite, one full vet pass, and CGO-disabled
|
Release validation runs one offline race-enabled Go suite, one full vet pass,
|
||||||
builds for both Linux targets. It reports skipped filesystem cases. macOS checks
|
and CGO-disabled package compilation for both Linux targets. macOS and CI are
|
||||||
and newer-toolchain tests are optional. CI is never a readiness or completion
|
not release gates; do not wait for or poll CI.
|
||||||
gate; do not wait for or poll it.
|
|
||||||
|
|
||||||
## 1. Prepare access and tools
|
## 1. Select Go and prepare the tool
|
||||||
|
|
||||||
Use a clean checkout with Git, Bash 3.2 or newer, a maintained Go 1.25 patch,
|
Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
|
||||||
and a native compiler for race tests. Configure private-host access through the
|
tests. Choose the release type before selecting Go. For a minor or major release,
|
||||||
normal credential manager. Keep credentials and environment dumps outside the
|
select the newest stable Go series available in the development environment.
|
||||||
repository and command transcript.
|
For a patch release, retain the series recorded in `go.mod` and leave its consumer
|
||||||
|
minimum unchanged. Follow the
|
||||||
|
[toolchain selection policy](policy/release.md#configkit-toolchain-selection).
|
||||||
|
|
||||||
Verify access without displaying credentials:
|
Put the selected series' latest locally available compatible patch first on
|
||||||
|
`PATH`, ensuring it meets `go.mod`'s minimum. The same `go` executable must build
|
||||||
|
ReleaseKit and run its child validation commands. Set local toolchain selection
|
||||||
|
and verify it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git remote get-url origin
|
export GOTOOLCHAIN=local
|
||||||
git ls-remote origin HEAD
|
go version
|
||||||
|
go env GOVERSION
|
||||||
|
command -v go
|
||||||
```
|
```
|
||||||
|
|
||||||
The fetch URL and all configured push destinations must resolve to the
|
Only when a minor or major release advances the consumer minimum to a newer Go
|
||||||
authoritative URL above. The publication command checks this again. Acquire the
|
series, derive that series' initial stable release from the selected executable:
|
||||||
selected toolchain and dependencies explicitly before offline validation:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14
|
selected_go=$(GOTOOLCHAIN=local go env GOVERSION)
|
||||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
|
selected_series=${selected_go#go}
|
||||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
|
selected_series=${selected_series%.*}
|
||||||
|
release_minimum="$selected_series.0"
|
||||||
|
go mod edit -go="$release_minimum"
|
||||||
```
|
```
|
||||||
|
|
||||||
A maintained patch may replace the example pin. No per-release latest-version
|
If a dependency or identified correctness fix requires a higher patch minimum,
|
||||||
survey is required. Dependencies must be cached; the checker sets `GOPROXY=off`.
|
use that required version instead and document the reason. When the series is
|
||||||
Publication requires user authorization. Standing authorization for local
|
unchanged, leave the minimum unchanged unless a minor or major release
|
||||||
commits does not authorize a push or release.
|
deliberately changes it under the
|
||||||
|
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
||||||
|
For example, validating with Go 1.26.8 leaves `go 1.26.0` unchanged. There is no
|
||||||
|
separate requirement to validate with Go 1.26.0.
|
||||||
|
|
||||||
## 2. Select the version and review compatibility
|
Prepare dependencies whether or not the minimum changes:
|
||||||
|
|
||||||
Choose an unused stable version. Compare the candidate with the previous tag.
|
```sh
|
||||||
Review exported Go signatures and ownership, YAML and scalar semantics,
|
go mod download
|
||||||
precedence, diagnostic codes, minimum Go version, and platform support. A
|
```
|
||||||
signature-preserving change may still affect consumers. Before `v1.0.0`,
|
|
||||||
intentional incompatible minor-release changes need a note explaining impact
|
|
||||||
and consumer action; patch releases remain compatible within their minor line.
|
|
||||||
Review the maintained consumer examples and migration guide.
|
|
||||||
|
|
||||||
When dependencies change, review parser behavior and migration impact as part
|
Build the tool selected by `go.mod` into temporary storage outside the checkout.
|
||||||
of that change. Module tidiness belongs to dependency-change review, not the
|
Ensure `$TMPDIR` names a system temporary location, not the repository. These
|
||||||
release checker. Do not repeat a dependency-version survey for every release.
|
commands never install another ReleaseKit version:
|
||||||
|
|
||||||
## 3. Write and commit the release note
|
```sh
|
||||||
|
releasekit_tmp=$(mktemp -d "${TMPDIR:-/tmp}/configkit-releasekit.XXXXXX")
|
||||||
|
releasekit_bin="$releasekit_tmp/releasekit"
|
||||||
|
go build -o "$releasekit_bin" gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||||
|
go list -m gitea.maximumdirect.net/eric/ReleaseKit
|
||||||
|
"$releasekit_bin" version
|
||||||
|
```
|
||||||
|
|
||||||
Create `docs/releases/<version>.md` for the selected version. Its first line
|
The module-selected executable is ReleaseKit v0.4.1. Use the built executable
|
||||||
must be `# ConfigKit <version>`. Give a meaningful summary and include
|
for publication to preserve its exit status and publication-state diagnostics.
|
||||||
compatibility impact or upgrade instructions when applicable. Link to current
|
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
||||||
contracts for exact behavior. Review the note and affected canonical documents
|
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||||
as ordinary preparation, then commit the complete candidate.
|
executable first on `PATH` through the publication command.
|
||||||
|
|
||||||
End every new note with these links, replacing `VERSION` with the complete tag:
|
Verify access to the authoritative remote without displaying credentials:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git ls-remote https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD refs/heads/main
|
||||||
|
```
|
||||||
|
|
||||||
|
Publication requires explicit authorization. Local implementation commits do
|
||||||
|
not authorize a push or release.
|
||||||
|
|
||||||
|
## 2. Prepare and commit the candidate
|
||||||
|
|
||||||
|
Choose an unused stable version locally and on the authoritative remote. Compare
|
||||||
|
the candidate with the previous tag and review exported Go signatures and
|
||||||
|
ownership, YAML and resolution semantics, diagnostic contracts, minimum Go
|
||||||
|
version, and platform support. Review maintained consumer examples and the
|
||||||
|
migration guidance. Explain any consumer-minimum increase and required consumer
|
||||||
|
action under the
|
||||||
|
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
||||||
|
|
||||||
|
Create `docs/releases/<version>.md`. Its first line must be exactly
|
||||||
|
`# ConfigKit <version>`, and it must end with the license and attribution footer:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md)
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Confirm the three target files exist in the candidate and review the version in
|
Replace `VERSION` with the full tag and confirm the three target files exist in
|
||||||
the URLs. The future tag need not resolve remotely yet; this review adds no
|
the candidate. Retire completed roadmaps, preserve unfinished and deferred
|
||||||
network gate. ConfigKit uses checked-in notes without hosted release objects.
|
work, and repair affected links. Review the complete candidate for unintended
|
||||||
If a hosted page is used later, reuse the note and preserve its footer. Do not
|
files and credentials, then commit it on `main`. These are ordinary preparation
|
||||||
rewrite historical notes or add downloadable license assets.
|
tasks, not separate approval gates. Any `go.mod` change belongs in this
|
||||||
|
candidate.
|
||||||
|
|
||||||
|
## 3. Push the candidate to `main`
|
||||||
|
|
||||||
|
Push only that committed candidate to the literal authoritative URL. This does
|
||||||
|
not follow local tags:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push --no-follow-tags https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD:refs/heads/main
|
||||||
|
```
|
||||||
|
|
||||||
|
ReleaseKit requires authoritative `main` to equal this candidate before it
|
||||||
|
validates. A failure before this push means ReleaseKit publication has not
|
||||||
|
started. If the push result is uncertain, inspect authoritative `main` and
|
||||||
|
continue only when it is confirmed at the candidate. If validation later fails,
|
||||||
|
no release tag is published; correct the issue in a new commit, push the new
|
||||||
|
candidate to `main`, and invoke validation again.
|
||||||
|
|
||||||
## 4. Publish the committed candidate
|
## 4. Publish the committed candidate
|
||||||
|
|
||||||
Before publication, review compatibility, release-note accuracy, and the file
|
With the authorized candidate already on authoritative `main`, invoke the
|
||||||
list for unintended content or credentials. Retire completed roadmaps as part of
|
module-selected native executable exactly once:
|
||||||
release preparation, preserving unfinished and deferred work and repairing
|
|
||||||
links. These are ordinary preparation tasks, not separate human sign-offs.
|
|
||||||
|
|
||||||
With the release explicitly authorized, run the one publication command.
|
|
||||||
The script resolves the repository root from its own path, so it may be invoked
|
|
||||||
from another directory by using its path there. Replace the sample version with
|
|
||||||
the selected version:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/release.sh v0.5.0
|
"$releasekit_bin" publish vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
The command requires `main`, a clean index/worktree, an unused version locally
|
Replace the placeholder with the selected version. ReleaseKit checks the
|
||||||
and on the authoritative remote, and a matching note in the captured commit.
|
effective push URL against `expected_push_url`, verifies the candidate on
|
||||||
Ignored local files do not block it. It checks remote ancestry, runs
|
`main`, and checks the release-note H1 for the product and version. It then
|
||||||
[`release-check.sh`](../scripts/release-check.sh) once against that commit,
|
runs the selected race-enabled standard suite once with test-time vet disabled,
|
||||||
rechecks candidate and version state, pushes only that commit to `main`, then
|
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
|
||||||
creates and pushes one annotated tag. The tag message is the checked-in note.
|
arm64. It publishes only the annotated tag for that candidate. Confirmed
|
||||||
It never commits or repairs the checkout.
|
publication of the intended tag completes the release. The tag points to the
|
||||||
|
captured commit and uses the complete checked-in note as its annotation message.
|
||||||
A successful intended tag push completes publication. The command reports the
|
The optional `check`
|
||||||
version and commit immediately. No CI, module retrieval, remote read-back, or
|
command is available for diagnosis but is not a separate required pass:
|
||||||
hosted release object is required. The checker may be run separately for
|
|
||||||
diagnosis, but it is not a prerequisite: a fresh publication invocation
|
|
||||||
validates its candidate once.
|
|
||||||
|
|
||||||
## 5. Recover from a failed or uncertain push
|
|
||||||
|
|
||||||
The command performs one bounded authoritative-state check after a failed push.
|
|
||||||
If the main push failed but authoritative main equals the candidate, it proceeds.
|
|
||||||
If the tag push failed but its exact tag object reached the remote, it reports
|
|
||||||
success. Otherwise it reports branch-only, conflicting, or unknown state and
|
|
||||||
retains any local tag.
|
|
||||||
|
|
||||||
Inspect exact refs before any retry. Set `release_version` to the attempted
|
|
||||||
version and compare main with the candidate from the command's report:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_version=v0.5.0
|
"$releasekit_bin" check vMAJOR.MINOR.PATCH
|
||||||
authoritative=https://gitea.maximumdirect.net/eric/ConfigKit.git
|
|
||||||
if remote_main=$(git ls-remote --refs --exit-code "$authoritative" refs/heads/main); then
|
|
||||||
printf 'remote main: %s\n' "$remote_main"
|
|
||||||
else
|
|
||||||
result=$?
|
|
||||||
if [ "$result" -eq 2 ]; then
|
|
||||||
printf 'authoritative main confirmed absent\n' >&2
|
|
||||||
else
|
|
||||||
printf 'remote main state unknown; inspect before retrying\n' >&2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
ref="refs/tags/$release_version"
|
|
||||||
if remote_tag=$(git ls-remote --refs --exit-code "$authoritative" "$ref"); then
|
|
||||||
printf 'remote tag: %s\n' "$remote_tag"
|
|
||||||
else
|
|
||||||
result=$?
|
|
||||||
if [ "$result" -eq 2 ]; then
|
|
||||||
printf 'remote tag confirmed absent\n'
|
|
||||||
else
|
|
||||||
printf 'remote tag state unknown; do not reuse the version\n' >&2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if local_tag=$(git rev-parse --verify "refs/tags/$release_version" 2>/dev/null); then
|
|
||||||
printf 'local tag object: %s\n' "$local_tag"
|
|
||||||
else
|
|
||||||
printf 'no local tag object\n'
|
|
||||||
fi
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A query failure is not proof that the tag is absent. Compare the remote tag
|
Status 0 confirms publication, status 1 reports a known publication failure or
|
||||||
object ID with the local annotated tag object ID; its peeled commit alone is
|
conflict, status 2 reports usage, profile, or version errors, and status 3 means
|
||||||
insufficient to establish identical publication. A conflicting published tag
|
the push outcome remains unknown. Preserve both the native exit status and its
|
||||||
must not be moved or replaced. If remote main advanced but the tag was not
|
diagnostics.
|
||||||
published, report that partial progress. Only after confirming the local tag
|
|
||||||
was never published or used by another channel may a maintainer remove it:
|
The command does not push or repair `main`, wait for CI, or publish binaries or
|
||||||
|
a hosted release page. A successful intended-tag publication does not require
|
||||||
|
remote read-back or consumer retrieval; those checks remain optional.
|
||||||
|
|
||||||
|
## 5. Recover from a failed or uncertain tag push
|
||||||
|
|
||||||
|
The executable reports the publication state, phase, version, candidate commit,
|
||||||
|
tag-object ID, effective endpoint, and error on standard error. It performs one
|
||||||
|
bounded authoritative reconciliation after a failed tag push. Exit status 3
|
||||||
|
means the tag outcome remains unknown; do not blindly retry or move a tag.
|
||||||
|
|
||||||
|
For an uncertain result, use the exact endpoint, candidate commit, version, and
|
||||||
|
tag-object ID from the report. Copy the captured endpoint into the variable
|
||||||
|
below using normal shell quoting. Check that Git still resolves it to itself,
|
||||||
|
then query the authoritative refs once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
authoritative='COPY_CAPTURED_ENDPOINT'
|
||||||
|
release_version='vMAJOR.MINOR.PATCH'
|
||||||
|
test "$(git ls-remote --get-url -- "$authoritative")" = "$authoritative" &&
|
||||||
|
git ls-remote --refs -- "$authoritative" refs/heads/main "refs/tags/$release_version"
|
||||||
|
```
|
||||||
|
|
||||||
|
A failed URL-equality check or remote query leaves the outcome unresolved.
|
||||||
|
Correct transport configuration before continuing; a rewritten destination
|
||||||
|
cannot establish absence at the captured endpoint.
|
||||||
|
|
||||||
|
Compare the `main` object ID with the reported candidate commit and the tag
|
||||||
|
object ID with the reported tag-object ID. `--refs` returns the annotated tag
|
||||||
|
object itself, not merely its peeled commit. If the tag object matches, the
|
||||||
|
release is published. If the query fails, the result is conflicting, or the
|
||||||
|
endpoint or candidate differs, stop and resolve the state before retrying. If
|
||||||
|
the endpoint is reachable, the tag is confirmed absent, and `main` still equals
|
||||||
|
the reported candidate, remove a retained local tag only after confirming it
|
||||||
|
was not published or used by another channel; then rerun `publish`, which
|
||||||
|
revalidates the candidate:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git tag -d "$release_version"
|
git tag -d "$release_version"
|
||||||
|
"$releasekit_bin" publish "$release_version"
|
||||||
```
|
```
|
||||||
|
|
||||||
A subsequent invocation revalidates the candidate; a correctly published tag
|
A validation failure after the candidate reached `main` leaves an untagged
|
||||||
needs no rerun. Do not roll back main or automatically delete a tag. Transport
|
candidate there. Correct it with a new commit rather than rewriting or rolling
|
||||||
or authentication failure alone does not require a new version. A defect in
|
back `main`. A conflicting published tag must never be moved or reused. A
|
||||||
published source requires a corrected commit, a new semantically appropriate
|
defect in published source requires a corrected commit, a new semantically
|
||||||
version and note, and the release procedure again.
|
appropriate version, and a matching release note.
|
||||||
|
|
||||||
## 6. Optional consumer verification
|
## 6. Optional consumer verification
|
||||||
|
|
||||||
Only when useful or requested, read back the remote tag or retrieve the exact
|
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
|
version into a new module without a local `replace` directive. These checks are
|
||||||
are optional and non-gating. Do not poll CI or wait for module propagation.
|
optional and non-gating. Do not poll CI or wait for module propagation. For
|
||||||
Private-host credentials must already be configured. For direct-origin
|
direct-origin verification, use the exact published version in a temporary
|
||||||
verification, set the version to the release just published (replace the sample
|
consumer module:
|
||||||
below), then use a temporary consumer module and check the exact module path
|
|
||||||
and version reported by `go list -m`:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_version=v0.5.0
|
release_version=vMAJOR.MINOR.PATCH
|
||||||
consumer_dir=$(mktemp -d)
|
consumer_dir=$(mktemp -d)
|
||||||
(
|
(
|
||||||
cd "$consumer_dir"
|
cd "$consumer_dir"
|
||||||
|
|||||||
53
docs/releases/v0.5.0.md
Normal file
53
docs/releases/v0.5.0.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# ConfigKit v0.5.0
|
||||||
|
|
||||||
|
This minor release adds source-backed layered configuration and built-in sibling
|
||||||
|
inheritance, so applications can load one complete configuration while retaining
|
||||||
|
presence, source context, and explainable derivation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- `LoadLayered` combines ordered underlay groups beneath the main input, selected
|
||||||
|
overlay, and explicit CLI overrides. Inputs within a group still compose
|
||||||
|
strictly; precedence tiers combine mappings recursively. The main input
|
||||||
|
remains the owner of the optional schema-version check.
|
||||||
|
- `NewFilesystem(...).File(...)` accepts caller-owned `fs.FS` sources, and
|
||||||
|
`Mapping(...)` snapshots supported built-in values as a labeled source.
|
||||||
|
`Result.SourceContext` and `Resolution.SourceContext` expose passive resource
|
||||||
|
contexts for values from file-backed sources.
|
||||||
|
- `extends` now resolves a mapping against one named sibling after source
|
||||||
|
precedence and before typed decoding. Inherited values count as explicit
|
||||||
|
input. Origins, history, explanations, and redacted rendering retain the
|
||||||
|
source and the structural path by which a value reached its destination.
|
||||||
|
Review corrections preserve immutable nested-history evidence and bounded
|
||||||
|
expansion work.
|
||||||
|
|
||||||
|
See the [configuration contract](../config.md#layered-loading),
|
||||||
|
[inheritance rules](../config.md#sibling-inheritance), and
|
||||||
|
[consumer examples](../consumers/README.md) for current behavior.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
This is an intentional pre-v1 incompatible minor release for configurations
|
||||||
|
that use `extends` as ordinary data. That key is now reserved wherever an input
|
||||||
|
mapping occurs; it is consumed before decoding. Rename any such YAML key and
|
||||||
|
corresponding Go field, then rebuild. No collection registration or opt-in flag
|
||||||
|
is available. Configurations without `extends` retain their existing meaning.
|
||||||
|
|
||||||
|
Applications adopting the new features may move source-backed defaults into
|
||||||
|
explicit `Underlay` groups, use `SourceContext` for relative resources, or
|
||||||
|
replace application-owned sibling merging with one `extends` relationship per
|
||||||
|
mapping. Adoption is optional. The maintained
|
||||||
|
[named-preset example](../consumers/README.md#named-sibling-inheritance)
|
||||||
|
shows inherited `false` and destination-local nested resolution.
|
||||||
|
|
||||||
|
Select the new module version:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.5.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The minimum Go line remains 1.25. Linux amd64 and arm64 remain the
|
||||||
|
release-validated targets; macOS remains best-effort. The YAML dependency is
|
||||||
|
unchanged at `go.yaml.in/yaml/v4 v4.0.0-rc.6`.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.5.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.5.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.5.0/docs/oss/attribution.md)
|
||||||
63
docs/releases/v0.6.0.md
Normal file
63
docs/releases/v0.6.0.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# ConfigKit v0.6.0
|
||||||
|
|
||||||
|
This minor release reduces avoidable configuration-loading and inspection work,
|
||||||
|
adopts ReleaseKit for source publication, and raises the consumer Go minimum to
|
||||||
|
1.26.0. The public configuration model remains unchanged from v0.5.0.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Reduce temporary path allocations by sizing pointer-formatting buffers,
|
||||||
|
combining final metadata traversal, reusing parsed sensitivity paths, and
|
||||||
|
avoiding path construction when detecting inheritance. Effective and
|
||||||
|
historical metadata indexes retain independently owned paths.
|
||||||
|
- Replace repeated resolution-coverage, metadata-update, CLI-overlap, and
|
||||||
|
sensitivity-propagation scans with private indexes and incremental processing.
|
||||||
|
Preserve diagnostic ordering, history, contextual path shapes, and redaction.
|
||||||
|
- Make presence-only metadata queries avoid materializing provenance. Prepare
|
||||||
|
resolution replay descriptors and immutable mapping and inheritance-history
|
||||||
|
lookups for inspection, and use ordered membership indexes for provenance
|
||||||
|
collections. Returned records remain detached; explanations still replay
|
||||||
|
complete trees and render on demand.
|
||||||
|
- Add a maintained public-API startup construction benchmark. The initial path
|
||||||
|
formatting and metadata traversal experiments reproduced allocation reductions
|
||||||
|
across local and downstream workloads. Later algorithmic improvements were
|
||||||
|
validated behaviorally without a new benchmark campaign. Busy-host timings
|
||||||
|
were inconclusive; no fixed speedup or allocation threshold is promised.
|
||||||
|
- Replace ConfigKit-owned release scripts with a small profile and the
|
||||||
|
module-selected ReleaseKit v0.3.0 tool. The
|
||||||
|
[release procedure](../release.md) documents candidate preparation, tag-only
|
||||||
|
publication, and failed-push recovery. Existing release checks and the
|
||||||
|
successful-tag-push completion point remain unchanged; CI is not a gate.
|
||||||
|
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
||||||
|
validation toolchain. Retire completed planning and measurement records into
|
||||||
|
Git history while preserving [deferred work](../roadmap/deferred.md).
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
The consumer minimum increases from Go 1.25.0 to **Go 1.26.0**. Install Go 1.26.0
|
||||||
|
or newer before upgrading, and update application build and CI environments that
|
||||||
|
still use Go 1.25. The `go.mod` directive records the consumer minimum; using a
|
||||||
|
newer validation patch does not itself raise that minimum. See the
|
||||||
|
[consumer minimum policy](../policy/release.md#consumer-minimum-and-release-toolchain).
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
No application-code or configuration migration is required from v0.5.0. Public
|
||||||
|
Go signatures, strict within-tier composition, recursive tier precedence,
|
||||||
|
sibling inheritance, presence/null distinctions, provenance, secret handling,
|
||||||
|
and synchronous completion retain their existing contracts. See the
|
||||||
|
[configuration contract](../config.md) and
|
||||||
|
[consumer guide](../consumers/README.md).
|
||||||
|
|
||||||
|
The YAML dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`. ReleaseKit is a
|
||||||
|
declared Go tool dependency for maintainer commands, not a library runtime
|
||||||
|
import; its requirements participate in ConfigKit's module graph. Linux amd64
|
||||||
|
and arm64 remain release-validated targets, and macOS remains best-effort.
|
||||||
|
Maintainers should use the current release procedure instead of the retired
|
||||||
|
`scripts/release.sh` and `scripts/release-check.sh` commands.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/docs/oss/attribution.md)
|
||||||
58
docs/releases/v0.7.0.md
Normal file
58
docs/releases/v0.7.0.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ConfigKit v0.7.0
|
||||||
|
|
||||||
|
This minor release adds structured comparison of completed configurations.
|
||||||
|
Applications can inspect changes between two configuration files or the
|
||||||
|
effective impact of a selected overlay, with ConfigKit handling comparison and
|
||||||
|
safe value disclosure while applications own presentation.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Add `Diff[T](left, right *Result[T]) ([]Difference, error)` through the public
|
||||||
|
facade. It compares frozen effective inspection values from successful loads,
|
||||||
|
including defaults and recorded application resolution, rather than source
|
||||||
|
text or later mutations of `Result.Config`.
|
||||||
|
- Return detached changed-region records containing a semantic path, safe left
|
||||||
|
and right `ValueSnapshot` values, and `HiddenChanged`. Mappings compare
|
||||||
|
recursively, sequences report atomic changes, and records follow canonical
|
||||||
|
pointer order. Presence distinguishes additions and removals from explicit
|
||||||
|
null; equal effective values produce no records regardless of provenance or
|
||||||
|
loading history.
|
||||||
|
- Protect both snapshots with sensitivity from both loads, including historical
|
||||||
|
and dependency-derived classification. Compatible regions retain public
|
||||||
|
siblings; incompatible structural replacements use complete-region masking
|
||||||
|
where required. `HiddenChanged` identifies actual changes withheld by masking,
|
||||||
|
rather than merely indicating that a snapshot contains redacted content.
|
||||||
|
- Preserve visible public scalar changes when only historical sensitive
|
||||||
|
descendants remain, including additions and removals. Historical explanation
|
||||||
|
redaction and masking of incompatible present shapes remain intact.
|
||||||
|
- Add maintained file-comparison and base-versus-overlay examples with
|
||||||
|
consumer-owned formatting, plus an executable public API example. See the
|
||||||
|
[consumer guide](../consumers/README.md#effective-configuration-diffs) and
|
||||||
|
[configuration contract](../config.md#effective-configuration-diffs).
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
No application-code or configuration migration is required from v0.6.0.
|
||||||
|
Adopting the new API is optional. Loading, composition, inheritance, provenance,
|
||||||
|
existing inspection, and secret-handling contracts remain unchanged.
|
||||||
|
|
||||||
|
Load both configurations successfully before calling `Diff`; nil or
|
||||||
|
uninitialized results return an error with no records. Comparison does not
|
||||||
|
reopen sources, rerun callbacks, or reread environment variables. Typed secret
|
||||||
|
references compare by configured reference name, not resolved credential bytes,
|
||||||
|
so credential rotation behind an unchanged name is not reported. Returned
|
||||||
|
snapshots describe changed regions, not cross-protected full-document context.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.7.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. The YAML dependency remains
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
|
||||||
|
ReleaseKit v0.3.0. Linux amd64 and arm64 remain release-validated targets;
|
||||||
|
macOS remains best-effort. Publication follows the existing
|
||||||
|
[release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/docs/oss/attribution.md)
|
||||||
22
docs/releases/v0.7.1.md
Normal file
22
docs/releases/v0.7.1.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# ConfigKit v0.7.1
|
||||||
|
|
||||||
|
This patch updates the maintainer release tool to ReleaseKit v0.4.1. ConfigKit's
|
||||||
|
runtime behavior and public API are unchanged.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
No application-code or configuration migration is required from v0.7.0. The
|
||||||
|
consumer minimum remains Go 1.26.0, and the YAML dependency is unchanged.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.7.1
|
||||||
|
```
|
||||||
|
|
||||||
|
ReleaseKit v0.4.1 is a maintainer tool dependency and does not change consumer
|
||||||
|
configuration or runtime behavior. Linux amd64 and arm64 remain
|
||||||
|
release-validated targets; macOS remains best-effort. Publication follows the
|
||||||
|
existing [release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/docs/oss/attribution.md)
|
||||||
@@ -11,18 +11,47 @@ General environment-variable configuration overlays are not supported. Typed sec
|
|||||||
references and explicit directory bootstrap are the environment convention. Literal
|
references and explicit directory bootstrap are the environment convention. Literal
|
||||||
credential configuration and implicit secret-directory discovery are unsupported.
|
credential configuration and implicit secret-directory discovery are unsupported.
|
||||||
|
|
||||||
The initial release deliberately excludes:
|
The following remain deliberately excluded:
|
||||||
|
|
||||||
- automatic `conf.d` discovery;
|
- automatic `conf.d` discovery;
|
||||||
- remote imports, arbitrary providers, and additional configuration formats;
|
- remote imports, arbitrary providers, and additional configuration formats;
|
||||||
- custom merge strategies, sequence merging, reset operators, and overlay inheritance;
|
- custom merge strategies, sequence merging, reset operators, multiple-parent
|
||||||
|
inheritance, and cross-scope references;
|
||||||
|
- named overlay inheritance;
|
||||||
- parser-specific or custom structural-decoding extension APIs;
|
- parser-specific or custom structural-decoding extension APIs;
|
||||||
- generic dynamic-subtree conversion and module-registry APIs;
|
- generic dynamic-subtree conversion and module-registry APIs;
|
||||||
- automatic schema migrations;
|
- automatic schema migrations;
|
||||||
- hot reload, runtime resource replacement, and remote resource validation;
|
- hot reload, runtime resource replacement, and remote resource validation;
|
||||||
- a bundled CLI framework or application commands; and
|
- a bundled CLI framework or application commands; and
|
||||||
- configuration diffs and fingerprints.
|
- configuration fingerprints.
|
||||||
|
|
||||||
Applications may build their own validate, effective-value, source, bootstrap, and
|
Applications may build their own validate, effective-value, source, bootstrap, and
|
||||||
explanation commands around the library. Broadening these boundaries requires
|
explanation commands around the library. Broadening these boundaries requires
|
||||||
concrete consumer evidence, compatibility review, and an explicit design change.
|
concrete consumer evidence, compatibility review, and an explicit design change.
|
||||||
|
|
||||||
|
Effective configuration diffs are implemented over frozen successful-load
|
||||||
|
inspections; their current comparison and disclosure contract is in the
|
||||||
|
[configuration contract](../config.md#effective-configuration-diffs), with
|
||||||
|
consumer workflows in the [adoption guide](../consumers/README.md#effective-configuration-diffs).
|
||||||
|
Fingerprints remain deferred.
|
||||||
|
|
||||||
|
Built-in sibling inheritance using `extends` is implemented as described in the
|
||||||
|
[configuration contract](../config.md#sibling-inheritance). It is distinct from
|
||||||
|
inheritance between selected overlay files, which remains excluded above.
|
||||||
|
|
||||||
|
## Deferred performance experiments
|
||||||
|
|
||||||
|
The completed performance audit left these internal optimization candidates for
|
||||||
|
separate investigation:
|
||||||
|
|
||||||
|
- branch-local explanation replay instead of the current whole-tree replay;
|
||||||
|
- redesigning the private metadata-copy handoff while preserving detached public
|
||||||
|
records;
|
||||||
|
- caching schema descriptors across loads; and
|
||||||
|
- indexing complex contributor-trace membership while preserving its equality
|
||||||
|
rules and compact linked traces.
|
||||||
|
|
||||||
|
These are not committed implementation work. Reconsider them with evidence that
|
||||||
|
the benefit justifies the ownership, retained-memory, or equivalence risks. The
|
||||||
|
completed audit and implementation evidence are preserved in Git history at
|
||||||
|
`8e91a2b` under `docs/roadmap/`.
|
||||||
|
|||||||
580
docs/roadmap/implementation.md
Normal file
580
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
# Implement configuration input destinations
|
||||||
|
|
||||||
|
Status: complete. Stages 1–12 are implemented and audited. The
|
||||||
|
[feature roadmap](input-destinations.md) owns the approved behavior and rationale; the
|
||||||
|
[consumer request](promptkit-feature-request.md#public-acceptance-scenarios)
|
||||||
|
supplies the public acceptance scenarios. This document owns execution and
|
||||||
|
completion evidence. Planning baseline: `6353598`.
|
||||||
|
|
||||||
|
## Working instructions
|
||||||
|
|
||||||
|
- Start each stage with `AGENTS.md`, [development.md](../development.md), the
|
||||||
|
applicable policies, and working-tree inspection. Use graph-first code
|
||||||
|
discovery. Preserve unrelated changes and previously completed stages.
|
||||||
|
- Implement only that stage and its supporting tests/documentation. Mark it
|
||||||
|
complete here with actual commands and evidence, then commit the nonempty
|
||||||
|
validated unit with a short plain-English message. Subsequent stages remain
|
||||||
|
planned. A stage is not complete solely because its narrow tests pass.
|
||||||
|
- Keep early mechanics private until the complete engine behavior is ready.
|
||||||
|
Root destination fields land in Stage 10. Ordinary root loading must remain
|
||||||
|
working throughout. Do not publish a partially supported public placement API.
|
||||||
|
- Follow the existing engine/document/YAML boundaries. Reuse strict composition,
|
||||||
|
recursive overlay, sibling inheritance, paths, history, and sensitivity.
|
||||||
|
No dependencies, toolchain changes, alternate mergers, mount registry,
|
||||||
|
per-fragment lifecycle, import-destination syntax, resource discovery, or new
|
||||||
|
release gates are needed. These are scope boundaries, not new prohibitions
|
||||||
|
on separately approved future work.
|
||||||
|
- Update current-behavior documentation when public behavior lands; keep future
|
||||||
|
claims here or in the roadmap until then. Add the ADR in Stage 1 under the
|
||||||
|
documentation policy's accepted-decision exception.
|
||||||
|
- Use focused offline tests while iterating; run `./scripts/check.sh` before
|
||||||
|
committing production changes. Documentation-only stages need whitespace and
|
||||||
|
local-link checks. Stage 12 adds full ownership/race validation. Tests protect
|
||||||
|
observable behavior, not wrapper allocation or helper-call counts. Reuse
|
||||||
|
coverage rather than repeat an entire loading matrix at every layer.
|
||||||
|
- No benchmark campaign or public complexity guarantee is required. Prefer
|
||||||
|
straightforward bounded walks and wrapping each acquired contribution once;
|
||||||
|
avoid rescanning all preceding contributions for every new input.
|
||||||
|
- Do not push, tag, publish, update downstream dependencies, or retire unfinished
|
||||||
|
records. Release compatibility review is separate from publication.
|
||||||
|
|
||||||
|
## Resolved implementation decisions
|
||||||
|
|
||||||
|
1. Add root-owned `Input.Destination Path` and `SourceInfo.Destination Path`,
|
||||||
|
mirrored privately in engine input/source records. Extend `loadInput` and
|
||||||
|
explicit root/engine conversion and cloning. No new loading entry point,
|
||||||
|
public wrapper type, option, or origin field is needed. Ordinary `Load`
|
||||||
|
continues to supply root placement.
|
||||||
|
2. The zero path means root. Validate `Path.valid()` and reject every explicit
|
||||||
|
index segment, including a negative index. Normalize parsed tokens to literal
|
||||||
|
mapping-key segments: `/0` means the key `0`, not sequence element zero.
|
||||||
|
Empty, dot, dot-dot, slash-containing, tilde-containing, and numeric keys keep
|
||||||
|
existing semantic-path meaning. Do not split dots, interpret filesystem
|
||||||
|
traversal, enforce identifier grammar, or add namespace uniqueness.
|
||||||
|
3. Reject destination segments equal to `configkit` or `extends`: placement
|
||||||
|
would introduce a reserved mapping key with a mapping value. Substrings such
|
||||||
|
as `myextends` are ordinary keys. This preserves the approved structural
|
||||||
|
rules rather than adding an application naming policy. Source-root import
|
||||||
|
directives are still processed before placement.
|
||||||
|
4. Validate destination shape/reservations for all declared inputs during
|
||||||
|
grouped option preflight, before reading any source. Reuse
|
||||||
|
`CodeOptionsInvalid` for invalid destination declarations; include the
|
||||||
|
offending semantic path when representable, with no fabricated physical
|
||||||
|
location. An invalid source still follows existing source-option validation.
|
||||||
|
A valid destination on `NoSource` contributes no nodes, source record, or
|
||||||
|
budget charges. No extra error type or exact message contract is introduced.
|
||||||
|
5. Separate acquired source syntax from application placement. Retain original
|
||||||
|
parsed source metadata for filesystem/directive handling. Strip source-root
|
||||||
|
directives, then wrap each application body at the normalized destination
|
||||||
|
before fragment version checks and strict composition. Imports inherit the
|
||||||
|
same destination transitively. Do not wrap both a source application and its
|
||||||
|
recomposed result; every retained application must already use effective
|
||||||
|
coordinates. Content roots remain mappings under existing source contracts.
|
||||||
|
6. Wrapping a body at `Keys("a", "b")` creates `{a: {b: body}}` as private
|
||||||
|
document mappings. Actual body nodes and entries keep their physical source
|
||||||
|
coordinates. Introduced mappings/entries use the contributing document's root
|
||||||
|
location as a documented source fallback, not purported authored coordinates.
|
||||||
|
They have the same contributing source ID and create no synthetic source.
|
||||||
|
Retained history trees remain immutable and separate from assembly mutation.
|
||||||
|
7. A present empty body creates explicit destination mappings, including an
|
||||||
|
import-only source whose application body is empty. It still cannot clear
|
||||||
|
lower mappings. A missing optional import and `NoSource` remain absent. Empty
|
||||||
|
source text remains the existing invalid document, not an empty mapping.
|
||||||
|
8. `VersionRule.Path` stays an effective application path. Check main once after
|
||||||
|
its own imports compose and before underlays/overlay/CLI, at the existing
|
||||||
|
phase. Imported and underlay claims are checked against placed application
|
||||||
|
content, including scalar/null/sequence ancestors replacing a protected path.
|
||||||
|
A mapping ancestor with no protected descendant is not a version claim.
|
||||||
|
Mounted domain `version` fields are ordinary data. Existing inheritance,
|
||||||
|
selected-overlay, CLI protection and exact-path numeric conversion remain;
|
||||||
|
no nested blanket conversion or additional fragment checker is introduced.
|
||||||
|
9. Placement does not alter physical source identity, filesystem namespace,
|
||||||
|
source IDs, acquisition order, imported-from locations, or resource contexts.
|
||||||
|
Repeated physical sources in one strict group still fail even at different
|
||||||
|
destinations. The same physical source in separate tiers retains the existing
|
||||||
|
independent source applications/records and each record's own destination.
|
||||||
|
Overlay and CLI source records have root `Destination`; they are not mounted
|
||||||
|
merely because main is mounted. Active layered overlays retain their explicit
|
||||||
|
independent filesystem selection and application-root meaning.
|
||||||
|
10. Value-level paths use effective coordinates. Keep genuine source-local
|
||||||
|
import-directive diagnostics and parse/read failures at their original
|
||||||
|
locations; do not invent a logical child path when parsing cannot identify
|
||||||
|
one. Prefix existing application-relative diagnostic paths exactly once
|
||||||
|
where available. Preserve structured codes, related physical locations,
|
||||||
|
deterministic ordering, and value-free messages.
|
||||||
|
11. Use current depth/node limits, not a new destination cap. With root depth
|
||||||
|
zero, each destination segment adds one level and, under `countInputNodes`,
|
||||||
|
one mapping node plus one key. Retain current raw input accounting (including
|
||||||
|
directives); charge only the additional `2 * segmentCount` synthetic nodes
|
||||||
|
per acquired body, once, to the shared input budget. Placement adds no bytes,
|
||||||
|
acquired sources, import nesting, or glob visits. Validate placed application
|
||||||
|
depth and per-document node bounds before composition; share accounting
|
||||||
|
across inputs and imports. Precheck wrapper depth/count before allocating
|
||||||
|
wrappers. Use existing source/import limit diagnostics in their acquisition
|
||||||
|
contexts; retain projection, expansion, and history bounds independently.
|
||||||
|
Root placement adds no charge or new rejection. NoSource adds none either.
|
||||||
|
12. Cloned source records include independently owned destination paths at every
|
||||||
|
existing publication boundary: results, load errors, metadata/inspection,
|
||||||
|
callback input information, and root conversion. Passive resource contexts
|
||||||
|
remain keyed by the real source identity; inspection must work without an
|
||||||
|
open configuration handle after load. This does not extend caller-owned
|
||||||
|
filesystem or callback resource lifetimes.
|
||||||
|
|
||||||
|
## Stage 1 — Record the decision and capture the baseline
|
||||||
|
|
||||||
|
Status: complete. Scope: decision record and evidence, no feature code.
|
||||||
|
|
||||||
|
1. Read the roadmap, request, relevant policies, and ADRs 0001, 0003, 0006,
|
||||||
|
0008, and 0009. Inspect the owners identified in the roadmap and the function
|
||||||
|
map below. Confirm the baseline and worktree before starting.
|
||||||
|
2. Run `./scripts/check.sh`; record the commit, Go version, and results here.
|
||||||
|
Locate existing coverage for root loading, strict underlays, version ownership,
|
||||||
|
repeated sources, history, resource contexts, and detached records.
|
||||||
|
3. Write `docs/adr/0011-input-destinations.md` after confirming the number is
|
||||||
|
unused. Record the approved input-placement decision as Accepted, with the
|
||||||
|
roadmap owning implementation status. Follow the repository ADR format.
|
||||||
|
Focus on ConfigKit, put rejected options only in Alternatives Considered,
|
||||||
|
and avoid negative parallelism elsewhere. No PromptKit-specific semantics.
|
||||||
|
4. Record the compatibility assessment under the release policy: adding fields
|
||||||
|
preserves zero-value/keyed-literal use, but callers using unkeyed literals
|
||||||
|
for `Input` or `SourceInfo` must migrate to keyed literals. Treat the feature
|
||||||
|
as minor-release work; select a version and write its note only during
|
||||||
|
separately authorized release preparation.
|
||||||
|
|
||||||
|
Validated: `./scripts/check.sh` (go1.26.8 linux/amd64) passed on candidate
|
||||||
|
`d3d468a8b47d780787fca329ff65b107d98ca59f`; this includes formatting,
|
||||||
|
whitespace, local documentation links, tests, vet, and build. The planning
|
||||||
|
baseline `6353598bd6cce2eb78a5fe990e144cc0cadf1280` is present in history. The
|
||||||
|
worktree was clean before this stage.
|
||||||
|
|
||||||
|
Coverage located for later destination work:
|
||||||
|
|
||||||
|
- Root and equivalent source loading: `TestEquivalentSourceFormsPreserveInputSemantics`
|
||||||
|
(`facade_equivalence_test.go`) and
|
||||||
|
`TestFacadeLayeredZeroUnderlaysMatchesOrdinaryLoad` (`facade_layered_test.go`).
|
||||||
|
- Strict underlays and group budgets: `TestGroupedLoadStrictConflictsRepetitionAndBudgets`
|
||||||
|
and `TestGroupedLoadPrecedenceSourceOrderAndSingleLifecycle`
|
||||||
|
(`internal/engine/load_request_test.go`).
|
||||||
|
- Version ownership and imported claims: `TestLoadRejectsImportConflictsAndFragmentVersions`
|
||||||
|
(`internal/engine/imports_test.go`) and version-path coverage in
|
||||||
|
`internal/engine/load_test.go`.
|
||||||
|
- Repeated physical sources: `TestRepeatedImportsAcrossTiersAndReaderConsumption`
|
||||||
|
(`facade_equivalence_test.go`).
|
||||||
|
- History and source contexts: `TestUnderlayHistoryRetainsLaterMappingContributors`
|
||||||
|
(`facade_underlay_origins_test.go`) and
|
||||||
|
`TestFacadeSourceContextsFollowOriginsWithoutQueryIO`
|
||||||
|
(`facade_resource_context_test.go`).
|
||||||
|
- Detached records: `TestLoadMetadataReflectsDefaultsInputAndDetachedRecords`
|
||||||
|
(`internal/engine/load_test.go`) and
|
||||||
|
`TestMetadataQueriesReturnDetachedRecords` (`internal/engine/metadata_test.go`).
|
||||||
|
|
||||||
|
The decision is recorded in [ADR 0011](../adr/0011-input-destinations.md).
|
||||||
|
Compatibility assessment: adding the two public fields breaks callers using
|
||||||
|
unkeyed literals for `Input` or `SourceInfo`; keyed and zero-value use remains
|
||||||
|
compatible. The release policy permits the intentional pre-1.0 contract change
|
||||||
|
in a minor release when the release note states impact and migration. Version
|
||||||
|
selection and release-note preparation remain out of scope until separately
|
||||||
|
authorized. No feature code or current behavior contract changed.
|
||||||
|
|
||||||
|
## Stage 2 — Add private destination normalization and wrapping
|
||||||
|
|
||||||
|
Status: complete. Scope: pure private mechanics with bounded unit tests.
|
||||||
|
|
||||||
|
1. Add a small engine helper file for normalized mapping-only destinations and
|
||||||
|
constructing placed document bodies. Follow decisions 2–7; keep helpers
|
||||||
|
private and avoid introducing a second path abstraction.
|
||||||
|
2. Validate invalid paths, explicit indices, and exact reserved tokens. Normalize
|
||||||
|
parsed numeric tokens into keys, preserving literal text and pointer escaping.
|
||||||
|
3. Build wrappers without mutating the supplied tree. Preserve body locations;
|
||||||
|
give introduced entries/mappings the source-root fallback. Implement depth and
|
||||||
|
node measurement/prechecks usable with existing limits, including the extra
|
||||||
|
synthetic-node count; aggregate charging is wired in Stage 7.
|
||||||
|
4. Cover root identity/equivalence, nested empty mapping, literal/escaped/numeric
|
||||||
|
keys, invalid index/path, exact versus substring reserved keys, owned paths,
|
||||||
|
unchanged body coordinates, and bounded wrapper checks. Do not count helper
|
||||||
|
invocations or fix an allocation strategy in tests.
|
||||||
|
|
||||||
|
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument'`
|
||||||
|
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. The private
|
||||||
|
helpers normalize mapping-only destinations, preserve authored nodes and source
|
||||||
|
coordinates, and check per-document depth/node bounds before allocating wrappers.
|
||||||
|
No loader accepts a new public field yet.
|
||||||
|
|
||||||
|
## Stage 3 — Integrate private non-import input placement
|
||||||
|
|
||||||
|
Status: complete. Scope: engine request plumbing and ordinary acquired bodies.
|
||||||
|
|
||||||
|
1. Add destination fields to engine `Input`, `loadInput`, and `SourceInfo`.
|
||||||
|
Preserve ownership through engine `LoadLayered` request construction,
|
||||||
|
`cloneSourceInfo`, and source-record copies. Keep the root facade unchanged.
|
||||||
|
2. Extend grouped preflight to validate/normalize destinations before source
|
||||||
|
reads. Use the existing diagnostic accumulator and options-invalid category.
|
||||||
|
Ordinary `Load` supplies root placement through its existing request adapter.
|
||||||
|
3. Thread placement into `assembleBaseInputTracked` without confusing raw
|
||||||
|
`parsedSource` with effective `assembledInput.document`. Preserve the root
|
||||||
|
path through existing wrappers/callers. For imports-disabled acquisition,
|
||||||
|
validate disabled directives on raw content, place the body, and retain the
|
||||||
|
destination on the acquired source record. NoSource still short-circuits.
|
||||||
|
4. Use engine loads to cover mounted main and underlay bodies, omitted versus
|
||||||
|
explicit root destinations, empty mapping versus NoSource, and preflight
|
||||||
|
rejection without consuming a test-owned reader. Reuse current source-kind
|
||||||
|
acquisition tests; full source equivalence is verified publicly in Stage 10.
|
||||||
|
|
||||||
|
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument|Grouped'`
|
||||||
|
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. Private non-import
|
||||||
|
placement reaches assembly, with root behavior unchanged. Imports are the next
|
||||||
|
private integration step.
|
||||||
|
|
||||||
|
## Stage 4 — Carry placement through import acquisition
|
||||||
|
|
||||||
|
Status: complete. Scope: source-local directives and placed imported applications.
|
||||||
|
|
||||||
|
1. Thread a normalized destination through `loadImportedDocumentsTracked`,
|
||||||
|
`loadImportedWithAccessTracked`, and `importTraversal`, retaining root defaults
|
||||||
|
for current private callers. Place the selected body's application content
|
||||||
|
after `splitImportDirectives`; do the same for every imported body.
|
||||||
|
2. Set placement on selected/imported source records, including error records
|
||||||
|
once a source has been acquired. Keep `ImportedFrom` at the physical directive.
|
||||||
|
Run imported `fragmentVersionClaim` against placed bodies and retain placed
|
||||||
|
bodies in `state.documents` before `composeDocuments` builds applications.
|
||||||
|
3. Leave filesystem joins, containment, glob traversal, optional missing inputs,
|
||||||
|
active cycles, visited identities, and acquisition IDs/order unchanged.
|
||||||
|
Destination must not become part of the repeated-source identity key.
|
||||||
|
4. Test native and host transitive imports, nested source-root directives,
|
||||||
|
import-only bodies, and mounted imported protected versus ordinary version
|
||||||
|
paths. Verify a repeated file at two destinations in one group still fails,
|
||||||
|
including an import/direct-input alias. Reuse cycle/containment regressions.
|
||||||
|
|
||||||
|
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument|Import|Grouped'`
|
||||||
|
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. Retained imported
|
||||||
|
contributions have effective placement and physical source identity, with no
|
||||||
|
double wrapping or application-level directive leakage.
|
||||||
|
|
||||||
|
## Stage 5 — Complete strict tiers and main version ownership
|
||||||
|
|
||||||
|
Status: complete. Scope: assembly and version interactions, not a new merger.
|
||||||
|
|
||||||
|
1. Review `acquireUnderlayGroup`: version checks must use placed content, and
|
||||||
|
documents taken from `acquired.composition.applications` must already be
|
||||||
|
placed. Recompose strict peers with ordinary `composeDocuments`; preserve
|
||||||
|
per-source applications, origins, UnderlayIndex, and input accounting.
|
||||||
|
2. In `executeLoadRequest`, main's version check uses its placed main/import
|
||||||
|
composition at the existing point. `assembleLayeredInput` recursively overlays
|
||||||
|
completed underlay groups beneath main, selected overlay, and CLI as before.
|
||||||
|
Do not mount the final assembly or prepend main's destination to upper tiers.
|
||||||
|
3. Test disjoint same-destination peers, different destinations, equal and unequal
|
||||||
|
conflicts, ancestor/descendant destinations, and a strict conflict despite an
|
||||||
|
upper override. Verify recursive mapping overlays, atomic sequences, null,
|
||||||
|
explicit false/zero, and version-only domain overrides retaining lower fields.
|
||||||
|
4. Exercise mounted main with a nested VersionRule, absent root declarations,
|
||||||
|
imported/underlay direct and ancestor claims, and nonclaiming mapping ancestors.
|
||||||
|
Check the hook runs once and does not inspect each fragment independently.
|
||||||
|
Preserve exact configured-path integer conversion and ordinary nested typing.
|
||||||
|
|
||||||
|
Validate: destination/composition/overlay/version/grouped tests and everyday
|
||||||
|
check. Validated with `go test ./internal/engine -run 'LayeredDestination'`,
|
||||||
|
`go test ./internal/engine -run 'Destination|Composition|Overlay|Version|Grouped'`,
|
||||||
|
and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The new mounted
|
||||||
|
cases verify strict same-tier conflicts and disjoint composition, independent
|
||||||
|
destinations and nested paths, recursive/atomic overlay behavior, explicit
|
||||||
|
null/false/zero, and main-only nested version checking after imports compose.
|
||||||
|
Done: placed and authored equivalents agree on values and conflicts; main
|
||||||
|
ownership and lifecycle timing are preserved.
|
||||||
|
|
||||||
|
## Stage 6 — Reconcile diagnostic coordinate systems
|
||||||
|
|
||||||
|
Status: complete. Scope: useful paths without fictional source locations.
|
||||||
|
|
||||||
|
1. Audit early acquisition, import, fragment-version, and composition failures.
|
||||||
|
Use one narrow path-prefix translation where diagnostics genuinely identify
|
||||||
|
application content; do not blanket-prefix every diagnostic or double-prefix
|
||||||
|
diagnostics already produced by placed trees.
|
||||||
|
2. Preserve syntax/read/directive locations and related import sites. Value
|
||||||
|
conflicts and later typed/resolver/validation failures use effective paths
|
||||||
|
but original content coordinates. Invalid introduced structure identifies
|
||||||
|
the destination declaration rather than a nonexistent source wrapper line.
|
||||||
|
3. Add focused assertions for a mounted conflict with two real sources, a mounted
|
||||||
|
decode or validation error, an imported version error, invalid destination,
|
||||||
|
and malformed source-local directives. Test stable codes/paths/locations and
|
||||||
|
nil results, not whole messages or callback choreography.
|
||||||
|
|
||||||
|
Validate: destination/diagnostic/import/version/decode tests and everyday check.
|
||||||
|
Validated with `go test ./internal/engine -run 'LayeredDestinationDiagnostics'`,
|
||||||
|
`go test ./internal/engine -run 'Destination|Diagnostic|Import|Version|Decode'`,
|
||||||
|
and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The mounted
|
||||||
|
conflict, decode, and imported-version assertions use effective application
|
||||||
|
paths with authored source locations; invalid destinations have no physical
|
||||||
|
location; malformed source-root directives retain their authored path and
|
||||||
|
location. Later value diagnostics are produced from placed trees, while directive
|
||||||
|
diagnostics retain their raw source coordinates. Post-review remediation also
|
||||||
|
rebases known Mapping value-builder failure paths, which are produced before
|
||||||
|
placement. `TestFacadeDestinationMappingFailurePaths` covers mounted main and
|
||||||
|
underlay failures, escaped keys and sequence-index intent, cycles, unchanged
|
||||||
|
root paths, and unchanged source-constructor failures. Its mounted value cases
|
||||||
|
failed before the fix and pass afterward.
|
||||||
|
Done: diagnostics consistently distinguish application paths from source syntax.
|
||||||
|
|
||||||
|
## Stage 7 — Complete shared placement accounting
|
||||||
|
|
||||||
|
Status: complete. Scope: resource bounds with no new public limit system.
|
||||||
|
|
||||||
|
1. Wire Stage 2's prechecks and synthetic-node charges into both tracked ordinary
|
||||||
|
acquisition and import traversal, using `importAccounting` and current limit
|
||||||
|
structures. Charge each source body once, including empty import bodies.
|
||||||
|
2. Preserve existing raw byte/source/node accounting and imported directive
|
||||||
|
inspection. Additional wrappers count in per-body depth/node checks and
|
||||||
|
shared aggregate nodes; recomposition must not charge them again. Ensure
|
||||||
|
selected overlays and CLI start from totals including placement charges.
|
||||||
|
3. Test below/at/above relevant bounds using small private limit fixtures where
|
||||||
|
existing seams permit; add a narrow private seam if necessary. Cover deep
|
||||||
|
body plus destination, many shallow mounted inputs, imported wrappers, and
|
||||||
|
empty mappings. Verify root placement and NoSource do not add charges.
|
||||||
|
Preserve history/expansion limits; do not turn fixture values into defaults.
|
||||||
|
|
||||||
|
Validated with `go test ./internal/engine -run 'Destination|Import|Grouped|Overlay|Override'`
|
||||||
|
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. New coverage in
|
||||||
|
`TestDestinationPlacementCountsAgainstSharedNodeBudget`,
|
||||||
|
`TestMultipleMountedInputsSharePlacementCharges`,
|
||||||
|
`TestImportedEmptyBodiesChargeEachDestinationWrapper`,
|
||||||
|
`TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and
|
||||||
|
`TestNoSourceAndRootPlacementAddNoWrapperCharges` checks totals below, at, and
|
||||||
|
above the boundary, multiple shallow inputs, imported empty bodies, and charging
|
||||||
|
of upper tiers from mounted inputs. `TestPlaceDocumentRootAndBounds` continues
|
||||||
|
to cover per-document depth/node boundaries; existing grouped accounting tests
|
||||||
|
cover unchanged raw source/byte charging. Done: placement cannot bypass current
|
||||||
|
budgets and introduces no unrelated caps.
|
||||||
|
|
||||||
|
## Stage 8 — Preserve provenance, history, and source resource contexts
|
||||||
|
|
||||||
|
Status: complete. Scope: engine inspection and retained evidence.
|
||||||
|
|
||||||
|
1. Audit `compositionOrigins`, `completeCompositionOrigins`, application replay,
|
||||||
|
metadata construction/finalization, and source-record cloning against placed
|
||||||
|
applications. Prefer feeding placed trees into existing processing rather
|
||||||
|
than adding another path-offset index or rewriting final metadata afterward.
|
||||||
|
2. Verify explicit destination containers, actual leaves, composite origins,
|
||||||
|
superseded/removed descendants, and root/source fallbacks. Historical and
|
||||||
|
effective indexes keep independently owned paths and existing shape semantics.
|
||||||
|
3. Preserve source-ID resource contexts through imports, surviving lower-tier
|
||||||
|
values, and inheritance. Test completed-result and resolution-time context
|
||||||
|
queries against real relative files, without making ConfigKit open the
|
||||||
|
referenced application resource or retain loading handles.
|
||||||
|
4. Exercise the same physical source in separate tiers with different placement,
|
||||||
|
inspect each source record, and verify independent destination paths. Repeat
|
||||||
|
inspection after comparison and independent loads to detect retained mutation.
|
||||||
|
|
||||||
|
Validated with `go test ./internal/engine -run 'Destination|Metadata|Provenance|History|Resource|SourceContext'`,
|
||||||
|
`go test -race ./internal/engine`, and `./scripts/check.sh` (go1.26.8
|
||||||
|
linux/amd64); all passed. `TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords`
|
||||||
|
checks synthetic-container fallback locations, authored leaf coordinates,
|
||||||
|
composite origins, removed-descendant history, and the same host file retained
|
||||||
|
at distinct destinations in separate tiers before and after comparison and an
|
||||||
|
independent load. `TestLayeredImportedInheritedValuesRetainResourceContexts`
|
||||||
|
uses real temporary files to verify resolution-time and completed-result
|
||||||
|
relative-resource lookup through an imported surviving parent and its inherited
|
||||||
|
child. Existing metadata and resource-context tests continue to cover detached
|
||||||
|
records and loading-handle closure. Done: inspection explains effective
|
||||||
|
placement and physical origin without consumer reconstruction.
|
||||||
|
|
||||||
|
## Stage 9 — Verify final inheritance, sensitivity, and lifecycle integration
|
||||||
|
|
||||||
|
Status: complete. Scope: complete private engine behavior before facade exposure.
|
||||||
|
|
||||||
|
1. Resolve sibling inheritance only after full source precedence using the
|
||||||
|
existing expansion path. Cover a child and parent supplied by separate peers,
|
||||||
|
nested destination-local siblings, cancellation, and an inherited relative
|
||||||
|
resource retaining its declaring context. Cross-collection search is unchanged.
|
||||||
|
2. Reuse existing inheritance-version, overlay, and CLI guards; add targeted
|
||||||
|
mounted cases for actual protected paths and their ancestors. Do not reject
|
||||||
|
unrelated inheritance merely because it shares a mapping ancestor.
|
||||||
|
3. Test effective-path secret predicates, field tags and recorded dependencies,
|
||||||
|
superseded classified values, safe effective output/history/explanation, and
|
||||||
|
bilateral diff redaction using disposable canaries. Compare mounted versus
|
||||||
|
explicitly wrapped loads for equal effective values and no differences.
|
||||||
|
4. Verify defaults, decoding, resolution, secret lookup, and validation execute
|
||||||
|
once on the final shape; failures return no usable result. Reuse existing
|
||||||
|
ordinary-load lifetime/ownership coverage and add mounted protection where
|
||||||
|
placement creates a distinct risk.
|
||||||
|
|
||||||
|
Validate: destination/inheritance/secret/sensitivity/explain/diff/lifecycle tests
|
||||||
|
and everyday check. Validated with
|
||||||
|
`go test ./internal/engine -run 'Destination|Inheritance|Secret|Sensitivity|Explain|Diff|Load|Version|Grouped' -count=1`,
|
||||||
|
`go test -race ./internal/engine`, and `./scripts/check.sh` (go1.26.8
|
||||||
|
linux/amd64); all passed.
|
||||||
|
|
||||||
|
`TestLayeredDestinationInheritanceUsesFinalSiblingScopes` verifies parent and
|
||||||
|
child contributions from separate peers, a higher-tier parent update, nested
|
||||||
|
destination-local siblings, and cancellation. `TestLayeredDestinationInheritanceDoesNotSearchOtherCollections`
|
||||||
|
keeps sibling lookup local. `TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers`
|
||||||
|
protects inherited values, scalar/null ancestors, consumed directives, selected
|
||||||
|
overlays, and CLI overrides at mounted paths while allowing nonclaiming mapping
|
||||||
|
ancestors and unrelated inheritance. The coverage exposed an ancestor gap in
|
||||||
|
the existing inheritance-version check; it now applies the shared fragment claim
|
||||||
|
logic to the transferred parent subtree.
|
||||||
|
|
||||||
|
`TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape` checks one
|
||||||
|
defaults/resolution/secret-lookup/validation lifecycle, final mounted decoding,
|
||||||
|
field tags and effective-path predicates, superseded history, and safe effective
|
||||||
|
output/explanation against canaries; it compares mounted and explicitly wrapped
|
||||||
|
loads for equal configuration and no diff. `TestLayeredDestinationDiffUsesBilateralSensitivity`
|
||||||
|
checks redaction from either side and recorded dependencies, and
|
||||||
|
`TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks` checks a
|
||||||
|
mounted decode failure returns no result. Stage 8's
|
||||||
|
`TestLayeredImportedInheritedValuesRetainResourceContexts` continues to verify
|
||||||
|
the declaring context for inherited relative resources. Done: approved engine
|
||||||
|
semantics integrate without per-fragment loading or a post-return processing
|
||||||
|
pass.
|
||||||
|
|
||||||
|
## Stage 10 — Expose the root-owned facade and public acceptance tests
|
||||||
|
|
||||||
|
Status: complete. Scope: the two public fields and complete consumer behavior.
|
||||||
|
|
||||||
|
1. Add `Input.Destination Path` and `SourceInfo.Destination Path` to root types.
|
||||||
|
Extend `toEngineInput`, `fromEngineSourceInfo`, `toEngineSourceInfo`, and all
|
||||||
|
source publication paths with owned conversion. Run existing public/import
|
||||||
|
boundary tests; expose no engine nodes, aliases, or bridge methods.
|
||||||
|
2. Document zero/root behavior, mapping-only paths, main/underlay parity,
|
||||||
|
inherited import placement, source-coordinate fallback, and source ownership
|
||||||
|
beside declarations. Update the configuration contract for implemented input
|
||||||
|
destinations, preserving canonical ownership of shared semantics.
|
||||||
|
3. Add external-package public-load coverage for equivalent host file, native
|
||||||
|
fs.FS file, byte, reader, and Mapping sources. Cover default/root destinations,
|
||||||
|
literal escaped/numeric keys, empty mapping, NoSource, and invalid declarations.
|
||||||
|
Include one public mounted main case, not just underlays.
|
||||||
|
4. Add representative public tests tying final configuration to metadata,
|
||||||
|
source placement, history, resources, callbacks, and safe diffs. Mutate returned
|
||||||
|
record copies and repeat queries/loads to confirm detachment. Reuse engine
|
||||||
|
matrices; public tests demonstrate supported integration rather than every
|
||||||
|
private branch. Confirm failed loads expose detached source destinations.
|
||||||
|
|
||||||
|
Validated with `go test . -run 'Destination|FacadeLayered|PublicFacadeBoundary|DependencyImportBoundary' -count=1`,
|
||||||
|
`go test -race ./...`, and `./scripts/check.sh` (go1.26.8 linux/amd64); all
|
||||||
|
passed.
|
||||||
|
|
||||||
|
`TestFacadeInputDestinationsAcrossSourceKinds` exercises host, native
|
||||||
|
filesystem, bytes, reader, and mapping inputs with literal escaped and
|
||||||
|
numeric-looking mapping keys. `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths`
|
||||||
|
covers omitted/empty root placement, empty mapping presence, absent `NoSource`,
|
||||||
|
invalid index/reserved destinations, and reader preflight. `TestFacadeImportedSourcesRetainInputDestination`
|
||||||
|
checks public transitive import placement. `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff`
|
||||||
|
ties mounted main/underlay values to metadata, both source destinations, callback
|
||||||
|
source context, relative-resource access during and after loading, history, safe
|
||||||
|
effective/explanation rendering, and bilateral diff redaction; it also mutates
|
||||||
|
returned records and confirms later queries and comparisons remain unchanged.
|
||||||
|
`TestFacadeFailedLoadExposesDetachedSourceDestination` verifies failure records,
|
||||||
|
and `TestSourceInfoDestinationConversionsAndCopies` covers root/engine source
|
||||||
|
record conversion. Existing public-boundary tests pass with the new root-owned
|
||||||
|
fields. Done: destination selection and inspection are available through the
|
||||||
|
supported ConfigKit import path.
|
||||||
|
|
||||||
|
## Stage 11 — Add maintained simple and collection-style consumer examples
|
||||||
|
|
||||||
|
Status: complete. Scope: executable adoption examples with consumer-owned types.
|
||||||
|
|
||||||
|
1. Add a minimal mounted-fragment example using `Input.Destination` in an
|
||||||
|
ordinary layered load. Show literal keys and root-default compatibility
|
||||||
|
without introducing consumer parsing or generic merge helpers.
|
||||||
|
2. Add one representative collection assembly under `examples/consumers`: peer
|
||||||
|
prompt/profile fragments, collection metadata with ordinary `version`, a
|
||||||
|
source-root import, local sibling inheritance, and an intentional higher-tier
|
||||||
|
version-only override retaining the body. Keep the application VersionRule
|
||||||
|
main-owned. Use a controlled native filesystem or temporary files.
|
||||||
|
3. Demonstrate effective-path inspection plus source-relative resource lookup
|
||||||
|
during resolution and from the result, including an inherited/surviving field.
|
||||||
|
Application code owns opening/interpreting the resource and any qualified IDs.
|
||||||
|
4. Add tests for final values, presence, actual source placement/coordinates,
|
||||||
|
resource context, and expected conflict behavior. Link maintained examples
|
||||||
|
from the consumer guide; add a small executable root example if useful for
|
||||||
|
discovery without duplicating the complete scenario.
|
||||||
|
|
||||||
|
Validated with `go test ./examples/consumers -count=1`,
|
||||||
|
`go test . -run '^Example' -count=1`, and `./scripts/check.sh` (go1.26.8
|
||||||
|
linux/amd64); all passed, including local documentation links.
|
||||||
|
|
||||||
|
`Example_inputDestination` demonstrates a literal mounted mapping path and an
|
||||||
|
ordinary root input without consumer-side parsing or merging.
|
||||||
|
`LoadCollectionBundle` and `TestCollectionBundleAssemblesMountedPeersAndResources`
|
||||||
|
exercise prompt/profile peer sources, imported collection metadata with an
|
||||||
|
ordinary version label, main-owned schema-version checking, local sibling
|
||||||
|
inheritance, and a higher-tier prompt version-only change that retains the rest
|
||||||
|
of its body. The resolver and `ReadCollectionPromptContent` follow direct and
|
||||||
|
inherited `content_file` provenance to the declaring native-filesystem context.
|
||||||
|
The test checks final values, explicit presence, source destinations and authored
|
||||||
|
coordinates, relative resources, and the inherited field's origin.
|
||||||
|
`TestCollectionPeerPromptConflictsRemainStrict` preserves expected conflict
|
||||||
|
behavior. The examples are linked from the consumer adoption guide. Done: simple
|
||||||
|
and representative consumers substantiate the same model without a compensating
|
||||||
|
structural pass.
|
||||||
|
|
||||||
|
## Stage 12 — Reconcile documentation and audit the complete implementation
|
||||||
|
|
||||||
|
Status: complete. Scope: final integration audit and evidence, not release work.
|
||||||
|
|
||||||
|
1. Update README navigation, consumer guidance, the component overview, and
|
||||||
|
development reading/test maps. Add ADR 0011 to navigation. Update architecture
|
||||||
|
policy only as needed to recognize placement within existing assembly and
|
||||||
|
source ownership, linking precise behavior to the configuration contract.
|
||||||
|
Do not change unrelated policies, deferred boundaries, or published notes.
|
||||||
|
2. Audit every resolved decision, roadmap behavior, and request acceptance row
|
||||||
|
against implementation and tests. Record the corresponding test names here;
|
||||||
|
for reused coverage explain the behavior it proves. Do not mark complete
|
||||||
|
based on green tests that do not exercise mounting.
|
||||||
|
3. Review the complete baseline-to-candidate diff for accidental strengthening:
|
||||||
|
extra naming rules, namespace uniqueness, main-only restrictions, changed
|
||||||
|
merge/version semantics, invented source-read ordering guarantees, new limits,
|
||||||
|
or expanded secret policies. Remove temporary helpers/unsupported branches
|
||||||
|
left by staged integration. Confirm original source coordinates remain real.
|
||||||
|
4. Run `./scripts/check.sh` and
|
||||||
|
`GOTOOLCHAIN=local GOPROXY=off go test -race ./...`. Review source-kind and
|
||||||
|
placement ownership coverage, error disclosure, complete-on-return handling,
|
||||||
|
and root/private boundaries. Record commit, Go version, results, and any
|
||||||
|
limitations. No release command, CI waiting, benchmark, or downstream upgrade.
|
||||||
|
5. Mark this plan and the roadmap implemented only when code, public examples,
|
||||||
|
and owning contracts agree. Retain completed planning evidence until separately
|
||||||
|
authorized release preparation.
|
||||||
|
|
||||||
|
Audit evidence for the approved decisions and request acceptance surface:
|
||||||
|
|
||||||
|
| Behavior reviewed | Evidence and what it proves |
|
||||||
|
| --- | --- |
|
||||||
|
| Root-owned `Input.Destination` and `SourceInfo.Destination`; detached conversions and publication | `TestSourceInfoDestinationConversionsAndCopies`, `TestFacadeFailedLoadExposesDetachedSourceDestination`, and `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` verify root/engine conversion, error records, and returned record detachment. `TestPublicFacadeBoundary` and `TestDependencyImportBoundary` verify no engine types cross the facade. |
|
||||||
|
| Literal mapping keys, parsed numeric keys, reserved structural tokens, and invalid-path preflight | `TestNormalizeDestinationUsesLiteralMappingKeys`, `TestNormalizeDestinationRejectsIndicesAndReservedKeys`, and `TestLayeredLoadPreflightsDestinationsBeforeReadingSources` cover mapping-only normalization and rejection before reader consumption; `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths` verifies the public error boundary. |
|
||||||
|
| Placement of acquired bodies, empty mappings versus absence, imports, all source kinds, physical identity, and authored coordinates | `TestFacadeInputDestinationsAcrossSourceKinds` covers host/native/bytes/reader/mapping sources; `TestFacadeSameFragmentMountsAtSeparateCollectionDestinations` proves independent public collections can retain the same local key. `TestLayeredImportOnlyInputMaterializesDestination`, `TestLayeredHostImportsInheritInputDestinationTransitively`, `TestLayeredNativeFilesystemImportsInheritInputDestination`, and `TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity` cover inherited import placement and unchanged identity. `TestPlaceDocumentWrapsEmptyMappingAndPreservesSourceCoordinates`, `TestPlaceDocumentPreservesAuthoredEntryCoordinates`, and `TestLayeredDestinationDiagnosticsUseEffectivePathsAndAuthoredLocations` distinguish fallback wrapper locations from authored coordinates. |
|
||||||
|
| Strict composition, explicit-wrapper equivalence, precedence, empty values, and domain `version` overrides | `TestLayeredDestinationStrictCompositionBoundaries`, `TestLayeredDestinationMatchesExplicitlyWrappedInput`, `TestEmptyMountedMappingRetainsLowerChildren`, and `TestLayeredDestinationOverlayRetainsOrdinaryValueSemantics` prove existing group and overlay semantics are retained; the latter includes ordinary field-only updates rather than whole-record replacement. |
|
||||||
|
| Main-owned version checks and imported, underlay, inheritance, overlay, CLI, and ancestor protection | `TestLayeredDestinationChecksVersionOnceOnComposedMain`, `TestLayeredDestinationVersionClaimsUsePlacedApplicationPaths`, `TestLayeredImportedVersionClaimsUseEffectiveDestination`, and `TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers` cover effective paths, one main check, protected-path claims/ancestors, and nonclaiming mapping ancestors. `TestLoadVersionDestinationTypesKeepOrdinaryDecoding` preserves exact configured-path conversion and ordinary nested decoding. |
|
||||||
|
| Final-shape sibling inheritance, no cross-collection lookup, one lifecycle, and failure without a usable partial result | `TestLayeredDestinationInheritanceUsesFinalSiblingScopes`, `TestLayeredDestinationInheritanceDoesNotSearchOtherCollections`, `TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape`, and `TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks` exercise these behaviors. Reused inheritance, lifecycle, and callback tests cover generic behavior that destination placement does not alter. |
|
||||||
|
| Effective paths, presence, provenance/history, safe rendering, sensitivity, bilateral diff, and source-relative resources | `TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords`, `TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape`, `TestLayeredDestinationDiffUsesBilateralSensitivity`, `TestLayeredImportedInheritedValuesRetainResourceContexts`, and `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` connect mounted values to inspection. The imported-inheritance test specifically proves the inherited value keeps the declaring source's passive resource context. |
|
||||||
|
| Shared depth/node limits and unchanged root/absence accounting | `TestPlaceDocumentRootAndBounds`, `TestDestinationPlacementCountsAgainstSharedNodeBudget`, `TestMultipleMountedInputsSharePlacementCharges`, `TestImportedEmptyBodiesChargeEachDestinationWrapper`, `TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and `TestNoSourceAndRootPlacementAddNoWrapperCharges` cover per-document and aggregate limits without adding a new limit class. |
|
||||||
|
| Maintained consumer usage and strict peer behavior | `Example_inputDestination`, `TestCollectionBundleAssemblesMountedPeersAndResources`, and `TestCollectionPeerPromptConflictsRemainStrict` verify the simple and representative collection examples against actual values, resources, source records, and conflict behavior. |
|
||||||
|
|
||||||
|
The wider baseline-to-candidate review covered all 38 changed files (`6353598..2d8df22`): production changes are limited to the root facade fields/conversions, private input/import assembly and accounting, source record ownership, and the inheritance version-claim correction described above. The contract and tests do not impose identifier grammar, namespace uniqueness, main-only placement, source-read ordering, new budgets, nested version conversion, or expanded secret policy. The implementation wraps only acquired application bodies after processing source-root directives; placement does not change physical source identity, authored node locations, filesystem resolution, or later-tier semantics. Stage evidence above reuses existing general composition, resource, inheritance, version, lifecycle, sensitivity, and boundary tests where those behaviors are unchanged; mounted-specific cases are named separately.
|
||||||
|
|
||||||
|
Completion record: audited source candidate `2d8df22dd1cb9fde8e60853da3e2260731acf2bb`. On `go1.26.8 linux/amd64`, `./scripts/check.sh` passed, including formatting, whitespace, documentation links, tests, vet, and build. `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` also passed with dependency downloads disabled. No remaining implementation limitations were found; release preparation, CI, benchmark campaigns, and downstream upgrades remain separately scoped.
|
||||||
|
|
||||||
|
Done: one complete layered load supports input destinations with coherent values,
|
||||||
|
diagnostics, inspection, limits, and source ownership across the approved surface.
|
||||||
|
|
||||||
|
## Implementation owner map
|
||||||
|
|
||||||
|
Use graph tools to locate these symbols rather than assume line numbers:
|
||||||
|
|
||||||
|
- Public input forwarding: `layered.go`, `records.go`, `conversion.go`
|
||||||
|
(source conversions), and existing facade tests.
|
||||||
|
- Private request and lifecycle: `internal/engine/layered.go`, `load_request.go`
|
||||||
|
(`loadInput`, `acquireUnderlayGroup`, `assembleLayeredInput`), `load.go`
|
||||||
|
(`executeLoadRequest`), and `load_input.go` (`assembleBaseInputTracked`).
|
||||||
|
- Directives and imports: `directives.go` (`splitImportDirectives`,
|
||||||
|
`fragmentVersionClaim`), and `imports.go` (`visitPath`).
|
||||||
|
- Shared document semantics: `compose.go`, `document_walk.go`
|
||||||
|
(`countInputNodes`), `path.go`, `parse.go`, and `value_operation.go` limits.
|
||||||
|
- Inspection: `metadata.go`, `history.go`, `explain.go`, `resource_context.go`,
|
||||||
|
`inheritance_origin.go`, `render.go`, and `diff.go`; source copies live in
|
||||||
|
engine `records.go`. Confirm current filenames through the component overview.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None required for implementation. The four policy choices are approved; this
|
||||||
|
plan selects API spelling, validation timing/error category, coordinate handling,
|
||||||
|
and accounting within them. Surface a newly discovered incompatibility or scope
|
||||||
|
change with evidence rather than resolving it by silently narrowing the feature
|
||||||
|
or strengthening an unrelated contract.
|
||||||
171
docs/roadmap/input-destinations.md
Normal file
171
docs/roadmap/input-destinations.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Configuration Input Destinations Roadmap
|
||||||
|
|
||||||
|
Status: implemented. This roadmap records the approved behavior and rationale
|
||||||
|
for the [PromptKit feature request](promptkit-feature-request.md). The
|
||||||
|
[implementation plan](implementation.md) records the completed sequence and
|
||||||
|
audit evidence; the [configuration contract](../config.md#input-destinations)
|
||||||
|
owns current behavior.
|
||||||
|
|
||||||
|
## Targeted end state
|
||||||
|
|
||||||
|
A consumer can assign a mapping destination to a configuration input and perform
|
||||||
|
one ordinary `LoadLayered` call. The input's application content contributes
|
||||||
|
beneath that destination while retaining normal composition, precedence,
|
||||||
|
inheritance, typed loading, diagnostics, and inspection. PromptKit can assemble
|
||||||
|
collections without parsing and rewriting sources, merging values, or rebuilding
|
||||||
|
provenance itself.
|
||||||
|
|
||||||
|
For example, a fragment defining `summarize.alerts` can contribute beneath
|
||||||
|
`collections`, `weatherreporter`, `prompts`. An independent source with the same
|
||||||
|
local key can contribute beneath another collection without conflict. Collection
|
||||||
|
and prompt names remain ordinary application keys, not ConfigKit namespaces.
|
||||||
|
|
||||||
|
The public surface adds an optional `Destination Path` field on `Input`, not a
|
||||||
|
new loading entry point or an operation on completed results. Content-bearing
|
||||||
|
main inputs and underlay inputs support
|
||||||
|
the same destination model; root placement remains the default.
|
||||||
|
|
||||||
|
The following behavior defines the target:
|
||||||
|
|
||||||
|
- Omitted and empty destinations retain root placement. Destinations consist of
|
||||||
|
literal mapping keys, including dots, slashes, and numeric-looking text.
|
||||||
|
Explicit sequence-index destinations are outside the requested capability.
|
||||||
|
- A valid destination on `NoSource` contributes nothing. An actual empty
|
||||||
|
application mapping materializes its destination mappings as explicit input,
|
||||||
|
matching authored wrappers, without clearing lower-tier children.
|
||||||
|
- Existing structural reservations apply to introduced mappings. Destinations
|
||||||
|
that materialize `configkit` or a mapping-valued `extends` contrary to existing
|
||||||
|
contracts fail through the structured loading-error boundary. Keys merely
|
||||||
|
containing those words remain ordinary keys.
|
||||||
|
- Host files, native filesystem files, bytes, readers, and mapping sources all
|
||||||
|
support placement under their existing acquisition and ownership contracts.
|
||||||
|
Filesystem context, import opt-in, and precedence remain independent choices.
|
||||||
|
- Mounted contributions have the same application values and conflict behavior
|
||||||
|
as explicitly wrapped contributions, with accurate original source locations.
|
||||||
|
Disjoint mappings combine; overlapping non-mapping values within a group
|
||||||
|
conflict even when equal. A later tier cannot repair a strict-group conflict.
|
||||||
|
- Different destinations can coexist in one group, and the same destination can
|
||||||
|
occur in different tiers. Ancestor/descendant destinations follow ordinary
|
||||||
|
composition and overlay rules without namespace uniqueness restrictions.
|
||||||
|
Later tiers recursively overlay mappings and replace other values. Empty
|
||||||
|
mappings do not clear lower values; sequences remain atomic.
|
||||||
|
- A prompt's ordinary `version` override changes that field and retains omitted
|
||||||
|
lower fields. It neither identifies a merge record nor replaces a whole prompt.
|
||||||
|
- Source-root `configkit.imports` remains structural. Imported application data
|
||||||
|
inherits the input destination transitively. Import paths still resolve from
|
||||||
|
their declaring source; containment, optionality, cycles, and repeated-source
|
||||||
|
rules remain applicable. Placement does not change physical source identity.
|
||||||
|
- Sibling inheritance runs after assembly in the effective destination scope.
|
||||||
|
Peer fragments can supply a child and its sibling parent. Qualified PromptKit
|
||||||
|
references do not acquire cross-collection inheritance meaning.
|
||||||
|
- Version ownership is enforced at effective application paths. A collection's
|
||||||
|
mounted `version` is ordinary data, while underlay/import contributions to the
|
||||||
|
actual protected path, or replacements of its ancestors, remain invalid.
|
||||||
|
There is one main-owned version check and no blanket nested numeric conversion.
|
||||||
|
- Defaults, decoding, resolution, secret lookup, and validation run once on the
|
||||||
|
assembled application, not independently on fragments. Callbacks and value
|
||||||
|
diagnostics use effective paths. Failures use structured load errors and yield
|
||||||
|
no usable partial result.
|
||||||
|
- Presence, provenance, history, explanations, effective rendering, and diffs
|
||||||
|
agree with the final values. Sensitivity is evaluated at effective application
|
||||||
|
paths under the existing historical and dependency rules.
|
||||||
|
- Existing source records expose placement, including inherited import
|
||||||
|
placement. Introduced ancestor mappings retain the contributing source's
|
||||||
|
identity; any root-location fallback identifies that document, not an authored
|
||||||
|
wrapper. Actual content retains its real source coordinates.
|
||||||
|
- Original source locations and passive resource contexts survive placement,
|
||||||
|
overrides, and inheritance. `Result.SourceContext` and
|
||||||
|
`Resolution.SourceContext` still identify the declaring filesystem and base
|
||||||
|
directory for a field such as `content_file: ./summary.md`.
|
||||||
|
- Destinations and the structure they introduce participate in existing load
|
||||||
|
limits; placement cannot bypass depth or node accounting. Source ownership,
|
||||||
|
complete-on-return loading, and detached result behavior remain intact.
|
||||||
|
|
||||||
|
Import-destination syntax, independent collection specification headers, and
|
||||||
|
cross-collection inheritance are separate possible follow-ups. This work does
|
||||||
|
not add directory discovery, collection installation, remote acquisition,
|
||||||
|
namespace ownership enforcement, dependency solving, live reload, filename-based
|
||||||
|
identity, arbitrary transformations, or new merge strategies. PromptKit owns its
|
||||||
|
collection inventory, qualified IDs, and execution semantics.
|
||||||
|
|
||||||
|
## Implementation baseline (v0.7.1)
|
||||||
|
|
||||||
|
The request reviewed v0.7.1. At that baseline, the relevant boundaries were:
|
||||||
|
|
||||||
|
- [Input](../../layered.go) holds a source, import opt-in, and filesystem context,
|
||||||
|
but no destination. Root types delegate to the engine through the existing
|
||||||
|
[facade boundary](../internal/overview.md).
|
||||||
|
- [Layered loading](../config.md#layered-loading) already provides strict groups,
|
||||||
|
ordered precedence, shared acquisition budgets, and one final lifecycle.
|
||||||
|
[Underlay acquisition](../../internal/engine/load_request.go) gathers source
|
||||||
|
applications and recomposes them into their strict group.
|
||||||
|
- [Input acquisition](../../internal/engine/load_input.go) and
|
||||||
|
[import traversal](../../internal/engine/imports.go) process source-root
|
||||||
|
directives and source identity before final assembly. Imported version claims
|
||||||
|
are checked during traversal; underlay claims are checked before group
|
||||||
|
composition. These currently operate without a destination.
|
||||||
|
- [Version claim checks](../../internal/engine/directives.go) inspect a document
|
||||||
|
using the configured application path. Wrapping only an already acquired
|
||||||
|
result would leave early fragment checks and diagnostic paths incorrect.
|
||||||
|
- [Sibling inheritance](../adr/0009-sibling-inheritance.md) already runs after
|
||||||
|
source precedence and preserves source-to-destination derivation. Placement
|
||||||
|
must feed that existing mechanism rather than resolve fragments independently.
|
||||||
|
- Existing source records identify acquisition and precedence but do not expose
|
||||||
|
input placement. Existing history retains source applications, so moving only
|
||||||
|
the final tree would be insufficient for coherent inspection.
|
||||||
|
|
||||||
|
The architectural fit is an extension of input assembly, not a parser extension
|
||||||
|
or a second configuration model. The
|
||||||
|
[architecture policy](../policy/architecture.md) supplies the ownership and
|
||||||
|
consistency constraints; [deferred scope](deferred.md) continues to govern the
|
||||||
|
unrequested features above.
|
||||||
|
|
||||||
|
## Selected Design Decisions
|
||||||
|
|
||||||
|
### Main-input destinations and absent inputs
|
||||||
|
|
||||||
|
Allow the same destination model for content-bearing main inputs and underlays,
|
||||||
|
with `VersionRule.Path` remaining an effective application path and any
|
||||||
|
specification declaration still owned by main. The checker retains its existing
|
||||||
|
handling of an absent declaration and its existing lifecycle phase. `NoSource`
|
||||||
|
contributes nothing even with a valid destination. This keeps `Input` coherent
|
||||||
|
across its roles and preserves absence without per-fragment version checks.
|
||||||
|
|
||||||
|
### Public placement inspection and synthetic ancestors
|
||||||
|
|
||||||
|
Expose effective placement as `SourceInfo.Destination Path` on existing source
|
||||||
|
records, including inherited import placement.
|
||||||
|
Use original source identity for introduced ancestor mappings, documenting that
|
||||||
|
any root-location fallback identifies the contributing document rather than a
|
||||||
|
physically authored wrapper. Actual content keeps its real coordinates. This
|
||||||
|
allows consumers to connect placement to sources without a new query system.
|
||||||
|
|
||||||
|
Preserve that relationship through inherited fields, composite origins, and the
|
||||||
|
same source selected in separate tiers. Placement is per source application;
|
||||||
|
consumers can inspect it without inferring it from final paths.
|
||||||
|
|
||||||
|
### Reserved structural keys in destinations
|
||||||
|
|
||||||
|
Use existing `Path` conventions for literal keys and parsed
|
||||||
|
numeric tokens, reject explicit index segments, and preserve current structural
|
||||||
|
reservations in the effective tree. Reject destination segments that would
|
||||||
|
materialize `configkit` or a mapping-valued `extends` contrary to those contracts,
|
||||||
|
with a clear structured destination error. Ordinary keys containing those words,
|
||||||
|
dots, or slashes remain ordinary keys. Source-root directives are processed before
|
||||||
|
placement and are not rejected simply because their application body is mounted.
|
||||||
|
|
||||||
|
Mounted and authored wrappers therefore retain the same structural meaning.
|
||||||
|
Destination diagnostics identify placement as the cause of invalid introduced
|
||||||
|
structure, using existing error categories where suitable.
|
||||||
|
|
||||||
|
### Empty mappings and presence of introduced containers
|
||||||
|
|
||||||
|
Mounting an actual empty application mapping materializes its
|
||||||
|
destination mappings as explicit contributions, matching authored wrappers.
|
||||||
|
This preserves the request's equivalence principle and lets consumers inspect
|
||||||
|
an intentionally empty collection. Such mappings still do not clear lower-tier
|
||||||
|
children. An absent source contributes nothing.
|
||||||
|
|
||||||
|
This applies equally to empty imported application bodies and inputs containing
|
||||||
|
only import directives. Presence follows the contribution's meaning rather than
|
||||||
|
whether an implementation happened to allocate wrapper nodes.
|
||||||
319
docs/roadmap/promptkit-feature-request.md
Normal file
319
docs/roadmap/promptkit-feature-request.md
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# PromptKit Feature Request: Configuration Input Destinations
|
||||||
|
|
||||||
|
Status: consumer feature request; not accepted or implemented.
|
||||||
|
|
||||||
|
## Requested Capability
|
||||||
|
|
||||||
|
Provide a public API through which a consumer can select a destination mapping
|
||||||
|
path for each configuration input. The input's application content contributes
|
||||||
|
beneath that path while retaining ConfigKit's normal composition, precedence,
|
||||||
|
inheritance, validation, diagnostics, and inspection behavior.
|
||||||
|
|
||||||
|
PromptKit needs this capability for its proposal 3, **Collections With Mounted
|
||||||
|
Configuration Fragments**, recorded in PromptKit's
|
||||||
|
`docs/roadmap/configuration-collections-mounted-fragments.md`. This request
|
||||||
|
describes the public API needs and observable acceptance criteria. It does not
|
||||||
|
prescribe ConfigKit's internal design or an implementation plan.
|
||||||
|
|
||||||
|
The reviewed public baseline is ConfigKit v0.7.1. The current
|
||||||
|
[layered-loading contract](../config.md#layered-loading) lets consumers choose
|
||||||
|
sources, import policy, filesystem context, and precedence groups, but does not
|
||||||
|
provide an input destination. Existing behavior remains authoritative until an
|
||||||
|
extension is adopted.
|
||||||
|
|
||||||
|
## Consumer Problem
|
||||||
|
|
||||||
|
PromptKit applications assemble independently maintained collections of prompts,
|
||||||
|
inference profiles, and backends. Collections can contain identical local names,
|
||||||
|
such as `summarize.alerts`, without identifying the same resource. PromptKit wants
|
||||||
|
an effective configuration organized by collection, while letting authors omit
|
||||||
|
repeated collection and resource-container wrappers from individual files.
|
||||||
|
|
||||||
|
A file selected from a collection's `prompts` directory would contain:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
summarize.alerts:
|
||||||
|
version: "1"
|
||||||
|
default_profile: provider/brief
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: Summarize the weather alerts.
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
```
|
||||||
|
|
||||||
|
Given destination `[collections, weatherreporter, prompts]`, its contribution
|
||||||
|
would appear in the final application configuration as:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
collections:
|
||||||
|
weatherreporter:
|
||||||
|
prompts:
|
||||||
|
summarize.alerts:
|
||||||
|
version: "1"
|
||||||
|
default_profile: provider/brief
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: Summarize the weather alerts.
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
```
|
||||||
|
|
||||||
|
Another collection can contribute `summarize.alerts` beneath
|
||||||
|
`[collections, homewatch, prompts]` without conflicting. After final handoff,
|
||||||
|
PromptKit interprets these as `weatherreporter/summarize.alerts` and
|
||||||
|
`homewatch/summarize.alerts`. The profile reference is illustrative and must be
|
||||||
|
supplied separately by the application configuration.
|
||||||
|
|
||||||
|
PromptKit will support one installed definition per prompt. Its optional flat
|
||||||
|
`version` is ordinary domain metadata, not a merge identity. This request does
|
||||||
|
not need identified-record sequences, sequence-root documents, or a custom
|
||||||
|
merge policy.
|
||||||
|
|
||||||
|
Authors could write the complete wrappers today, but that repeats assembly
|
||||||
|
details in every fragment. Consumer-side parsing and wrapping would put generic
|
||||||
|
source processing back into PromptKit and complicate source attribution.
|
||||||
|
PromptKit instead wants to select sources and destinations, then receive one
|
||||||
|
final configuration through ConfigKit's supported loading lifecycle.
|
||||||
|
|
||||||
|
## Minimum Public API
|
||||||
|
|
||||||
|
### Per-Input Destination
|
||||||
|
|
||||||
|
Allow a destination to be attached to each `Input` passed to `LoadLayered`.
|
||||||
|
One possible public spelling is an optional `Destination Path` field on `Input`.
|
||||||
|
The name and exact signature are suggestions; equivalent public API designs
|
||||||
|
are welcome.
|
||||||
|
|
||||||
|
Illustrative consumer code using the proposed field, which does not exist in
|
||||||
|
v0.7.1:
|
||||||
|
|
||||||
|
```go
|
||||||
|
promptInput := configkit.Input{
|
||||||
|
Source: configkit.File("/usr/local/share/collections/weatherreporter/prompts/alerts.yml"),
|
||||||
|
Destination: configkit.Keys("collections", "weatherreporter", "prompts"),
|
||||||
|
}
|
||||||
|
|
||||||
|
peers := configkit.Underlay{
|
||||||
|
Inputs: []configkit.Input{promptInput},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer would include `peers` in its existing `LayeredOptions` and invoke
|
||||||
|
`LoadLayered` with its normal root main input and application `Definition`.
|
||||||
|
This is a partial API sketch, not an executable example or a proposed new
|
||||||
|
loading entry point. A single public layered load is sufficient for PromptKit;
|
||||||
|
it does not require a separate mounting operation on completed results.
|
||||||
|
|
||||||
|
The API must let the consumer:
|
||||||
|
|
||||||
|
- Express a destination relative to the application configuration root using
|
||||||
|
literal mapping-key segments. Dots, slashes, and numeric-looking text inside
|
||||||
|
a key are not implicit path separators or array indexes. PromptKit's namespace
|
||||||
|
grammar remains its own domain rule.
|
||||||
|
- Omit the destination to retain existing root placement. An explicitly empty
|
||||||
|
destination should have the same meaning. Mapping destinations are sufficient;
|
||||||
|
sequence-index destinations are not needed by this request.
|
||||||
|
- Assign different destinations to inputs in the same strict composition group,
|
||||||
|
and assign the same destination to inputs in separate precedence groups.
|
||||||
|
- Use the destination with existing content-bearing source kinds: host files,
|
||||||
|
native filesystem files, bytes, readers, and mapping sources, subject to their
|
||||||
|
existing acquisition and ownership contracts.
|
||||||
|
- Retain the existing import opt-in and filesystem context independently of the
|
||||||
|
destination. A configuration destination is not a filesystem root or base
|
||||||
|
directory.
|
||||||
|
|
||||||
|
PromptKit can continue to enumerate an explicitly selected directory and attach
|
||||||
|
the same destination to its returned inputs. ConfigKit does not need a new
|
||||||
|
directory scanner, namespace registry, filename convention, or PromptKit schema.
|
||||||
|
The namespace comes from the application's assembly declaration, not from an
|
||||||
|
implicit interpretation of a directory basename.
|
||||||
|
|
||||||
|
### Representative Assembly
|
||||||
|
|
||||||
|
These are application choices, not ConfigKit-specific reserved paths:
|
||||||
|
|
||||||
|
| Selected inputs | Destination key segments |
|
||||||
|
| --- | --- |
|
||||||
|
| Application main document | Root |
|
||||||
|
| `weatherreporter/collection.yml` | `collections`, `weatherreporter` |
|
||||||
|
| YAML files under `weatherreporter/prompts` | `collections`, `weatherreporter`, `prompts` |
|
||||||
|
| YAML files under `homewatch/prompts` | `collections`, `homewatch`, `prompts` |
|
||||||
|
| YAML files under `provider/profiles` | `collections`, `provider`, `profiles` |
|
||||||
|
| YAML files under `provider/backends` | `collections`, `provider`, `backends` |
|
||||||
|
|
||||||
|
Peer collection contributions belong to a single strict underlay group when the
|
||||||
|
application wants conflict detection among them. Intentional operator overrides
|
||||||
|
use later groups or main. Destination selection must not establish precedence
|
||||||
|
or make source traversal order authorize replacement.
|
||||||
|
|
||||||
|
## Required Observable Behavior
|
||||||
|
|
||||||
|
### Composition And Precedence
|
||||||
|
|
||||||
|
Mounted and explicitly wrapped contributions must have equivalent application
|
||||||
|
values and conflict behavior at their effective destinations. Actual source
|
||||||
|
locations naturally differ and must remain accurate.
|
||||||
|
|
||||||
|
Retain the [strict composition](../config.md#strict-document-composition) and
|
||||||
|
[layering](../config.md#layered-loading) rules:
|
||||||
|
|
||||||
|
- Disjoint mapping entries at the same destination combine.
|
||||||
|
- Overlapping non-mapping values within a strict group conflict, including equal
|
||||||
|
scalars, nulls, and sequences. Different destinations do not collide merely
|
||||||
|
because the source documents share a local key.
|
||||||
|
- Later precedence layers recursively override mappings and replace other
|
||||||
|
values. A strict conflict cannot be repaired by an upper layer.
|
||||||
|
- Empty mappings do not clear lower definitions; ordinary arrays remain atomic.
|
||||||
|
In particular, `messages` does not acquire sequence-merging behavior.
|
||||||
|
- Overlapping destination paths and contributions at their ancestors follow
|
||||||
|
those same rules. No namespace-level uniqueness restriction is requested.
|
||||||
|
|
||||||
|
Changing only a prompt's `version` in a later layer changes that field while
|
||||||
|
retaining omitted lower-layer fields. ConfigKit must not treat a domain version
|
||||||
|
label as a request to replace an entire definition.
|
||||||
|
|
||||||
|
### Imports And Existing Structural Directives
|
||||||
|
|
||||||
|
An input with imports enabled must be able to use its existing source-root
|
||||||
|
`configkit.imports` directive while contributing application data at a nested
|
||||||
|
destination. The directive remains structural configuration, not an application
|
||||||
|
field below that destination.
|
||||||
|
|
||||||
|
For the minimum API, imports inherit their importing input's destination,
|
||||||
|
including transitive imports. Import paths continue to resolve relative to
|
||||||
|
their declaring source's filesystem context. A mounted document must not send
|
||||||
|
its imported application fields back to the configuration root accidentally.
|
||||||
|
|
||||||
|
Existing opt-in, containment, missing-input, cycle, and repeated-source behavior
|
||||||
|
must remain applicable. A different destination is not a request to bypass the
|
||||||
|
current rejection of a repeated physical source within a strict group.
|
||||||
|
|
||||||
|
Sibling `extends` must resolve against mappings at their effective destinations.
|
||||||
|
For example, two independently supplied profile fragments mounted beneath the
|
||||||
|
same collection's `profiles` mapping can establish `extends: brief` once they
|
||||||
|
compose. Inheritance remains ConfigKit-owned; PromptKit does not perform another
|
||||||
|
relationship-resolution pass.
|
||||||
|
|
||||||
|
### Protected Specification Paths
|
||||||
|
|
||||||
|
The distinction between source-local field names and effective application
|
||||||
|
paths is essential. PromptKit's main document will declare `version: 1` at the
|
||||||
|
configuration root. A separate collection metadata file can contain:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "2.4.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Mounted at `[collections, weatherreporter]`, that field is an ordinary collection
|
||||||
|
release label. It must not be rejected as an attempt to contribute the protected
|
||||||
|
root specification field. The same applies to a prompt's nested optional
|
||||||
|
version. Only the main-owned specification declaration uses the application's
|
||||||
|
configured [VersionRule](../config.md#application-schema-versions).
|
||||||
|
|
||||||
|
Conversely, placement must not permit an underlay or imported fragment to claim
|
||||||
|
or replace the actual protected specification path or an ancestor that would
|
||||||
|
replace it. The public contract must define protection in terms of effective
|
||||||
|
application paths, rather than reserving the spelling `version` in every source.
|
||||||
|
Protection must remain valid with imports, inheritance, overlays, and explicit
|
||||||
|
CLI overrides.
|
||||||
|
|
||||||
|
PromptKit needs one main-owned specification check with the existing lifecycle.
|
||||||
|
It does not need main mounted away from the root, integer-to-string conversion
|
||||||
|
for every nested `version`, or separate compatibility checks for every fragment.
|
||||||
|
|
||||||
|
### Typed Loading, Diagnostics, And Inspection
|
||||||
|
|
||||||
|
The destination must be visible consistently through the ordinary public
|
||||||
|
loading and inspection surface:
|
||||||
|
|
||||||
|
- Final typed configuration and application callbacks use effective destination
|
||||||
|
paths. Defaults, resolution, and validation retain the existing single-load
|
||||||
|
lifecycle; fragments are not independently validated as whole applications.
|
||||||
|
- Metadata and presence queries distinguish omission, explicit null, and other
|
||||||
|
explicit values at the mounted paths.
|
||||||
|
- Diagnostics identify the effective conflicting or invalid path and the real
|
||||||
|
contributing source locations. Consumers can determine which source and
|
||||||
|
placement produced a value without recreating that relationship themselves.
|
||||||
|
- Effective rendering, explanations, history, and configuration comparison agree
|
||||||
|
with the final configuration. Existing sensitivity and disclosure rules apply
|
||||||
|
to mounted fields, including application path-based secret classification.
|
||||||
|
- Unsupported destination forms and destination-related loading failures use
|
||||||
|
the normal structured error boundary. Exact new diagnostic codes, if needed,
|
||||||
|
are for ConfigKit to define.
|
||||||
|
|
||||||
|
PromptKit does not require a separate mount registry or new inspection facade.
|
||||||
|
Existing inspection APIs are sufficient if they expose accurate effective paths,
|
||||||
|
source attribution, and placement information. ConfigKit should document how
|
||||||
|
source-local locations relate to effective paths.
|
||||||
|
|
||||||
|
### Source-Relative Resources And Lifecycle
|
||||||
|
|
||||||
|
For a mounted field such as `content_file: ./summary.md`, obtaining its effective
|
||||||
|
origin and calling `Result.SourceContext` or `Resolution.SourceContext` must
|
||||||
|
still identify the declaring source's filesystem and base directory. The same
|
||||||
|
holds when that field survives an override or is inherited into another mapping.
|
||||||
|
Its configuration destination must not change the meaning of the relative file
|
||||||
|
reference.
|
||||||
|
|
||||||
|
Retain existing source ownership, load completion, result lifetime, containment,
|
||||||
|
and resource-access behavior. Destinations must participate in documented load
|
||||||
|
limits without creating a way to bypass them; this request does not prescribe
|
||||||
|
new limit values or their accounting machinery.
|
||||||
|
|
||||||
|
## Optional Follow-Ups, Not Prerequisites
|
||||||
|
|
||||||
|
**Import destinations in YAML.** A destination on an import directive could let
|
||||||
|
publishers describe assembly in configuration rather than Go. Proposal 3
|
||||||
|
recommends destinations relative to the importing document's destination, with
|
||||||
|
omission retaining that destination. This is useful, but per-input placement
|
||||||
|
through the public API is sufficient for initial PromptKit adoption. No new
|
||||||
|
directive spelling is selected by this request.
|
||||||
|
|
||||||
|
**Independent specification headers.** A collection entry document might later
|
||||||
|
declare its own supported specification version. That needs a distinct contract
|
||||||
|
for checking the header and determining its role in the composed configuration.
|
||||||
|
Mounting collection release metadata does not supply this capability. Keep it a
|
||||||
|
separate request if PromptKit decides it is necessary.
|
||||||
|
|
||||||
|
**Cross-collection inheritance.** Nested collection profiles are not immediate
|
||||||
|
siblings across collections. Current sibling inheritance remains sufficient for
|
||||||
|
the initial proposal, with authored definitions flattened where necessary.
|
||||||
|
Generic cross-path inheritance could be considered separately if consumer needs
|
||||||
|
justify revisiting the [deferred scope](deferred.md). Mounting must not silently
|
||||||
|
reinterpret `extends: provider/brief` as a cross-collection reference.
|
||||||
|
|
||||||
|
PromptKit also does not request collection installation, automatic directory
|
||||||
|
discovery, remote fetching, namespace ownership enforcement, dependency solving,
|
||||||
|
live reload, filename-derived identity, or arbitrary document transformations.
|
||||||
|
Its qualified resource IDs, optional prompt version semantics, collection
|
||||||
|
inventory, and execution APIs remain PromptKit responsibilities.
|
||||||
|
|
||||||
|
## Public Acceptance Scenarios
|
||||||
|
|
||||||
|
Evaluate the extension through supported public loading and inspection APIs:
|
||||||
|
|
||||||
|
| Scenario | Expected consumer-visible result |
|
||||||
|
| --- | --- |
|
||||||
|
| Destination omitted or empty | Existing root-placement behavior |
|
||||||
|
| Same fragment shape mounted under two distinct collection keys from independent sources | Two separate effective resources |
|
||||||
|
| Disjoint inputs at the same destination in one group | Mapping contributions combine |
|
||||||
|
| Equal or unequal overlapping scalar fields in one group | Structured composition conflict with both source locations |
|
||||||
|
| Same destination across deliberate precedence tiers | Existing recursive overlay behavior |
|
||||||
|
| Version-only prompt override | Label changes; omitted prompt body fields survive |
|
||||||
|
| Nested mounted import without a destination directive | Imported fields contribute at the inherited destination |
|
||||||
|
| Local profile inheritance across mounted peer fragments | Parent resolves at the effective sibling scope |
|
||||||
|
| Collection metadata containing `version` mounted below root | Ordinary nested field, independent of root VersionRule |
|
||||||
|
| Mounted/imported contribution actually targeting the protected version path | Rejected under version ownership rules |
|
||||||
|
| Mounted field using source-relative content, including inheritance | Original declaring resource context remains available |
|
||||||
|
| Mounted explicit null and omitted field | Distinct effective presence information |
|
||||||
|
| Mounted validation error or secret field | Effective paths, physical locations, and existing disclosure rules agree |
|
||||||
|
| File, native filesystem, byte, reader, or mapping input with equivalent content | Equivalent application values, subject to existing source contracts |
|
||||||
|
| Unsupported destination or failed load | Normal structured failure, no usable partial result |
|
||||||
|
|
||||||
|
The minimum usable outcome is a consumer that selects collection sources and
|
||||||
|
destinations, performs one ordinary layered load, and receives final typed
|
||||||
|
configuration with reliable diagnostics and inspection. It should need no
|
||||||
|
consumer-side source rewriting, generic merging, or provenance reconstruction.
|
||||||
89
docs/roadmap/remediation.md
Normal file
89
docs/roadmap/remediation.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Input destination review findings
|
||||||
|
|
||||||
|
Reviewed `d3d468a..945e111` against the approved
|
||||||
|
[input-destinations roadmap](input-destinations.md),
|
||||||
|
[implementation plan](implementation.md), and
|
||||||
|
[ADR 0011](../adr/0011-input-destinations.md).
|
||||||
|
|
||||||
|
The root-owned destination fields, placement before strict composition,
|
||||||
|
transitive import placement, effective-path version ownership, final lifecycle,
|
||||||
|
source records, resource contexts, retained history, and existing disclosure
|
||||||
|
mechanisms broadly match the selected architecture. The required correction
|
||||||
|
below concerns an early diagnostic path, not loading or composition semantics.
|
||||||
|
No additional unintended strengthening of contracts was identified.
|
||||||
|
|
||||||
|
## 1. Rebase known Mapping-source failure paths to the input destination (P2)
|
||||||
|
|
||||||
|
Status: complete. Known Mapping value-builder paths are now prefixed with the
|
||||||
|
normalized destination at acquisition, preserving semantic key/index segments.
|
||||||
|
Public regressions cover mounted main and underlay failures, escaped keys,
|
||||||
|
sequence indices, cyclic values, unchanged root paths, and source-constructor
|
||||||
|
errors retaining source coordinates. The mounted value cases failed before the
|
||||||
|
fix and pass afterward. Existing directive/syntax checks remain unchanged.
|
||||||
|
Focused destination/source/diagnostic/import tests, `./scripts/check.sh`, and
|
||||||
|
`GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed on
|
||||||
|
`go1.26.8 linux/amd64`.
|
||||||
|
|
||||||
|
Affected: `assembleBaseInputTracked` in
|
||||||
|
[load_input.go](../../internal/engine/load_input.go), lines 27–32 at the reviewed
|
||||||
|
commit; the Mapping failure originates in `readSource` in
|
||||||
|
[source.go](../../internal/engine/source.go), lines 206–208.
|
||||||
|
|
||||||
|
`Mapping` snapshots its input through the existing value builder. When a nested
|
||||||
|
value is unsupported, cyclic, or exceeds a value-builder limit, the retained
|
||||||
|
issue already identifies a semantic path inside that application mapping.
|
||||||
|
`readSource` turns it into a structured diagnostic. The early return from
|
||||||
|
`assembleBaseInputTracked` annotates the source record with its destination but
|
||||||
|
returns those diagnostic paths unchanged.
|
||||||
|
|
||||||
|
For example, select this input as either main or an underlay:
|
||||||
|
|
||||||
|
```go
|
||||||
|
configkit.Input{
|
||||||
|
Source: configkit.Mapping("bad mapping", map[string]any{
|
||||||
|
"item": map[string]any{"bad": func() {}},
|
||||||
|
}),
|
||||||
|
Destination: configkit.Keys("catalog", "entries"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The load correctly fails with `source.invalid`, but `Diagnostic.Path` is
|
||||||
|
`/item/bad` rather than `/catalog/entries/item/bad`. The source record correctly
|
||||||
|
reports `/catalog/entries`, so the two inspection records use inconsistent
|
||||||
|
application coordinates. A consumer displaying or associating the failure with
|
||||||
|
the final application shape would point to the wrong configuration field.
|
||||||
|
|
||||||
|
This diverges from implementation decision 10 and Stage 6: existing paths that
|
||||||
|
genuinely identify application content must be translated to effective paths
|
||||||
|
exactly once. A Mapping value-builder issue has that information; it is not a
|
||||||
|
source-read or YAML syntax failure for which a logical child path is unavailable.
|
||||||
|
|
||||||
|
Required remediation:
|
||||||
|
|
||||||
|
- Prefix known application-relative Mapping value-builder diagnostic paths with
|
||||||
|
the normalized input destination at the acquisition boundary. Preserve key
|
||||||
|
escaping and sequence-index intent in the existing semantic Path model.
|
||||||
|
- Keep source IDs, original primary/related locations, codes, value-free
|
||||||
|
messages, source destinations, and nil-result behavior unchanged. Root
|
||||||
|
placement must retain its current paths.
|
||||||
|
- Do not blanket-prefix parse/read failures, source-constructor errors, or
|
||||||
|
structural import-directive diagnostics. Do not invent logical child paths
|
||||||
|
for failures that do not identify application content.
|
||||||
|
- Add bounded regression coverage through the public API for mounted main and
|
||||||
|
underlay Mapping failures, alongside unchanged root placement. Reuse existing
|
||||||
|
syntax/directive tests to protect their different coordinate convention.
|
||||||
|
- Run the focused destination/source/diagnostic checks and the everyday check.
|
||||||
|
Reconcile the completed plan's diagnostic evidence with this correction;
|
||||||
|
do not redefine the agreed effective-path behavior to accept the gap.
|
||||||
|
|
||||||
|
## Review validation
|
||||||
|
|
||||||
|
- `GOTOOLCHAIN=local ./scripts/check.sh` passed at `945e111` on
|
||||||
|
`go1.26.8 linux/amd64` (formatting, whitespace, tests, vet, and build).
|
||||||
|
- `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed.
|
||||||
|
- `GOTOOLCHAIN=local GOPROXY=off go test -count=1 ./...` passed.
|
||||||
|
- An additional external-package probe in a disposable checkout outside the
|
||||||
|
repository failed for both main and underlay placement, observing `/item/bad`
|
||||||
|
instead of `/catalog/entries/item/bad` with `source.invalid`.
|
||||||
|
- No production fixes or regression tests were added to this repository during
|
||||||
|
the review. No performance measurements or new guarantees were inferred.
|
||||||
@@ -30,6 +30,32 @@ func ExampleNoSource() {
|
|||||||
// Output: 4 0 <nil>
|
// Output: 4 0 <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExampleDiff() {
|
||||||
|
type Config struct {
|
||||||
|
Workers int `yaml:"workers"`
|
||||||
|
}
|
||||||
|
before, err := configkit.Load[Config](configkit.Mapping("before", map[string]any{"workers": 2}))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
after, err := configkit.Load[Config](configkit.Mapping("after", map[string]any{"workers": 4}))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
differences, err := configkit.Diff(before, after)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, difference := range differences {
|
||||||
|
fmt.Printf("%s: %s -> %s\n", difference.Path.String(),
|
||||||
|
strings.TrimSpace(difference.Left.YAML), strings.TrimSpace(difference.Right.YAML))
|
||||||
|
}
|
||||||
|
// Output: /workers: 2 -> 4
|
||||||
|
}
|
||||||
|
|
||||||
func ExampleValidation_Error() {
|
func ExampleValidation_Error() {
|
||||||
type Config struct {
|
type Config struct {
|
||||||
First string `yaml:"first"`
|
First string `yaml:"first"`
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ func Example_seriatim() {
|
|||||||
// Output: visitor true the event occurred <nil> <nil>
|
// Output: visitor true the event occurred <nil> <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Example_inheritedPresets() {
|
||||||
|
result, err := consumers.LoadPresets(configkit.File(fixture("inheritance", "presets.yml")))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := configkit.Keys("presets", "custom", "enabled")
|
||||||
|
info, _ := result.Metadata().Lookup(path)
|
||||||
|
custom := result.Config.Presets["custom"]
|
||||||
|
fmt.Println(custom.Enabled, result.Metadata().IsExplicit(path), custom.Settings["secondary"]["width"])
|
||||||
|
fmt.Println(info.Origin.Locations[0].SourceID, info.Origin.Inheritance[0].From)
|
||||||
|
// Output:
|
||||||
|
// false true 20
|
||||||
|
// source-1 /presets/base/enabled
|
||||||
|
}
|
||||||
|
|
||||||
func Example_weatherStartup() {
|
func Example_weatherStartup() {
|
||||||
const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED"
|
const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED"
|
||||||
const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN"
|
const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN"
|
||||||
|
|||||||
159
examples/consumers/destinations.go
Normal file
159
examples/consumers/destinations.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package consumers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CollectionBundleConfig is a representative application-owned collection
|
||||||
|
// shape. Collection and prompt version labels remain ordinary domain fields.
|
||||||
|
type CollectionBundleConfig struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Collections map[string]CollectionDefinition `yaml:"collections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectionDefinition struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Prompts map[string]CollectionPrompt `yaml:"prompts"`
|
||||||
|
Profiles map[string]CollectionProfile `yaml:"profiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectionPrompt struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
DefaultProfile string `yaml:"default_profile"`
|
||||||
|
ContentFile string `yaml:"content_file"`
|
||||||
|
ResourceText string `yaml:"resource_text"`
|
||||||
|
Messages []CollectionMessage `yaml:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectionMessage struct {
|
||||||
|
Role string `yaml:"role"`
|
||||||
|
Content string `yaml:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectionProfile struct {
|
||||||
|
Format string `yaml:"format"`
|
||||||
|
Temperature float64 `yaml:"temperature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCollectionBundle demonstrates one layered collection load. Its main
|
||||||
|
// document owns the application schema version; imported collection metadata,
|
||||||
|
// prompt/profile peers, and an operator prompt-version override use ordinary
|
||||||
|
// destinations and domain fields.
|
||||||
|
func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
||||||
|
files, err := fs.Sub(bundledFiles, "testdata/collection-bundle")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bundle := configkit.NewFilesystem("collection bundle", files)
|
||||||
|
collectionDestination := configkit.Keys("collections", "weatherreporter")
|
||||||
|
promptDestination := collectionDestination.Key("prompts")
|
||||||
|
profileDestination := collectionDestination.Key("profiles")
|
||||||
|
return configkit.LoadLayered(
|
||||||
|
configkit.Input{Source: configkit.Bytes("application main", []byte("version: 1\n"))},
|
||||||
|
configkit.LayeredOptions[CollectionBundleConfig]{
|
||||||
|
Definition: configkit.Definition[CollectionBundleConfig]{
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
if !version.Present || version.Text != "1" {
|
||||||
|
return errors.New("unsupported application schema version")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
Resolve: resolveCollectionResources,
|
||||||
|
Validate: validateCollectionBundle,
|
||||||
|
},
|
||||||
|
Underlays: []configkit.Underlay{
|
||||||
|
{Inputs: []configkit.Input{
|
||||||
|
{Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination},
|
||||||
|
{Source: bundle.File("collections/weather/prompts/brief.yml"), Destination: promptDestination},
|
||||||
|
{Source: bundle.File("collections/weather/prompts/custom.yml"), Destination: promptDestination},
|
||||||
|
{Source: bundle.File("collections/weather/profiles/compact.yml"), Destination: profileDestination},
|
||||||
|
{Source: bundle.File("collections/weather/profiles/weather.yml"), Destination: profileDestination},
|
||||||
|
}},
|
||||||
|
{Inputs: []configkit.Input{{
|
||||||
|
Source: bundle.File("operators/weather-prompt-version.yml"), Destination: promptDestination,
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCollectionResources(configuration *CollectionBundleConfig, resolution *configkit.Resolution) error {
|
||||||
|
for collectionID, collection := range configuration.Collections {
|
||||||
|
for promptID, prompt := range collection.Prompts {
|
||||||
|
if prompt.ContentFile == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
||||||
|
info, found := resolution.InputInfo(contentPath)
|
||||||
|
if !found || len(info.Origin.Locations) != 1 {
|
||||||
|
return fmt.Errorf("prompt resource has no single declaring source")
|
||||||
|
}
|
||||||
|
resourceSource, found := resolution.Source(info.Origin.Locations[0].SourceID)
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("prompt resource source is unavailable")
|
||||||
|
}
|
||||||
|
context, found := resolution.SourceContext(resourceSource.ID)
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("prompt resource context is unavailable")
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read prompt resource: %w", err)
|
||||||
|
}
|
||||||
|
prompt.ResourceText = string(data)
|
||||||
|
collection.Prompts[promptID] = prompt
|
||||||
|
resolution.Record(
|
||||||
|
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"),
|
||||||
|
configkit.ChangeNormalization, "read source-relative prompt content", contentPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
configuration.Collections[collectionID] = collection
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCollectionBundle(configuration *CollectionBundleConfig, validation *configkit.Validation) {
|
||||||
|
for collectionID, collection := range configuration.Collections {
|
||||||
|
for promptID, prompt := range collection.Prompts {
|
||||||
|
if prompt.DefaultProfile == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, found := collection.Profiles[prompt.DefaultProfile]; !found {
|
||||||
|
validation.Error(configkit.Keys("collections", collectionID, "prompts", promptID, "default_profile"), "must name a profile in this collection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCollectionPromptContent follows the effective content field's physical
|
||||||
|
// source, including when it was inherited from a peer prompt.
|
||||||
|
func ReadCollectionPromptContent(result *configkit.Result[CollectionBundleConfig], collectionID, promptID string) (string, error) {
|
||||||
|
if result == nil {
|
||||||
|
return "", errors.New("collection result is nil")
|
||||||
|
}
|
||||||
|
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
||||||
|
info, found := result.Metadata().Lookup(contentPath)
|
||||||
|
if !found || len(info.Origin.Locations) != 1 {
|
||||||
|
return "", errors.New("prompt resource has no single declaring source")
|
||||||
|
}
|
||||||
|
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !found {
|
||||||
|
return "", errors.New("prompt resource context is unavailable")
|
||||||
|
}
|
||||||
|
prompt, found := result.Config.Collections[collectionID].Prompts[promptID]
|
||||||
|
if !found {
|
||||||
|
return "", errors.New("prompt is unavailable")
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
116
examples/consumers/destinations_test.go
Normal file
116
examples/consumers/destinations_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example_inputDestination() {
|
||||||
|
mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
|
Source: configkit.Mapping("weather prompt", map[string]any{
|
||||||
|
"summarize.alerts": map[string]any{"version": "1"},
|
||||||
|
}),
|
||||||
|
Destination: configkit.Keys("collections", "weather.reporter", "prompts"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
|
Source: configkit.Bytes("standalone", []byte("name: standalone\n")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, found := mounted.Config["collections"]
|
||||||
|
fmt.Println(found, root.Config["name"])
|
||||||
|
// Output: true standalone
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
|
||||||
|
result, err := consumers.LoadCollectionBundle()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadCollectionBundle: %v", err)
|
||||||
|
}
|
||||||
|
collection := result.Config.Collections["weatherreporter"]
|
||||||
|
if result.Config.Version != "1" || collection.Name != "Weather Reporter" || collection.Version != "2.4.0" {
|
||||||
|
t.Fatalf("main and collection versions = %q, %#v", result.Config.Version, collection)
|
||||||
|
}
|
||||||
|
brief := collection.Prompts["brief"]
|
||||||
|
custom := collection.Prompts["custom"]
|
||||||
|
if brief.Version != "2" || brief.DefaultProfile != "compact" || brief.ContentFile != "welcome.txt" || len(brief.Messages) != 1 {
|
||||||
|
t.Fatalf("version-only override replaced lower prompt fields: %#v", brief)
|
||||||
|
}
|
||||||
|
if custom.Version != "2" || custom.DefaultProfile != "weather" || custom.ContentFile != brief.ContentFile ||
|
||||||
|
len(custom.Messages) != 1 || custom.ResourceText != "Welcome to the weather collection.\n" {
|
||||||
|
t.Fatalf("inherited prompt = %#v", custom)
|
||||||
|
}
|
||||||
|
weatherProfile := collection.Profiles["weather"]
|
||||||
|
if weatherProfile.Format != "text" || weatherProfile.Temperature != 0.4 {
|
||||||
|
t.Fatalf("inherited profile = %#v", weatherProfile)
|
||||||
|
}
|
||||||
|
resource, err := consumers.ReadCollectionPromptContent(result, "weatherreporter", "custom")
|
||||||
|
if err != nil || resource != custom.ResourceText {
|
||||||
|
t.Fatalf("inherited prompt resource = %q, %v", resource, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version"))
|
||||||
|
if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerBase || len(versionInfo.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok)
|
||||||
|
}
|
||||||
|
collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version")
|
||||||
|
collectionVersion, ok := result.Metadata().Lookup(collectionVersionPath)
|
||||||
|
if !ok || !collectionVersion.Explicit || collectionVersion.Origin.Layer != configkit.LayerUnderlay || len(collectionVersion.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("ordinary collection version metadata = %#v, %v", collectionVersion, ok)
|
||||||
|
}
|
||||||
|
var metadataSource configkit.SourceInfo
|
||||||
|
var parentPromptSource configkit.SourceInfo
|
||||||
|
var promptVersionSource configkit.SourceInfo
|
||||||
|
for _, source := range result.Sources() {
|
||||||
|
switch source.Path {
|
||||||
|
case "collections/weather/metadata.yml":
|
||||||
|
metadataSource = source
|
||||||
|
case "collections/weather/prompts/brief.yml":
|
||||||
|
parentPromptSource = source
|
||||||
|
case "operators/weather-prompt-version.yml":
|
||||||
|
promptVersionSource = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collectionVersionLocation := collectionVersion.Origin.Locations[0]
|
||||||
|
if metadataSource.ID == "" || collectionVersionLocation.SourceID != metadataSource.ID ||
|
||||||
|
metadataSource.Destination.String() != "/collections/weatherreporter" || collectionVersionLocation.Line != 1 {
|
||||||
|
t.Fatalf("imported collection version source = %#v, %#v", metadataSource, collectionVersionLocation)
|
||||||
|
}
|
||||||
|
if promptVersionSource.ID == "" || promptVersionSource.Destination.String() != "/collections/weatherreporter/prompts" || promptVersionSource.UnderlayIndex != 2 {
|
||||||
|
t.Fatalf("prompt version source = %#v", promptVersionSource)
|
||||||
|
}
|
||||||
|
contentPath := configkit.Keys("collections", "weatherreporter", "prompts", "custom", "content_file")
|
||||||
|
contentInfo, ok := result.Metadata().Lookup(contentPath)
|
||||||
|
if !ok || !contentInfo.Explicit || len(contentInfo.Origin.Inheritance) != 1 || len(contentInfo.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("inherited content field metadata = %#v, %v", contentInfo, ok)
|
||||||
|
}
|
||||||
|
contentLocation := contentInfo.Origin.Locations[0]
|
||||||
|
if parentPromptSource.ID == "" || contentLocation.SourceID != parentPromptSource.ID || contentLocation.Line != 4 ||
|
||||||
|
parentPromptSource.Destination.String() != "/collections/weatherreporter/prompts" {
|
||||||
|
t.Fatalf("inherited resource source coordinates = %#v, source %#v", contentLocation, parentPromptSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionPeerPromptConflictsRemainStrict(t *testing.T) {
|
||||||
|
destination := configkit.Keys("collections", "weatherreporter", "prompts")
|
||||||
|
_, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
|
{Source: configkit.Bytes("peer-a", []byte("brief: {version: '1'}\n")), Destination: destination},
|
||||||
|
{Source: configkit.Bytes("peer-b", []byte("brief: {version: '1'}\n")), Destination: destination},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||||
|
t.Fatalf("duplicate peer prompt error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
examples/consumers/diff.go
Normal file
136
examples/consumers/diff.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package consumers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileComparisonConfig is a small application-owned shape for comparing
|
||||||
|
// caller-selected configuration files.
|
||||||
|
type FileComparisonConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Entries map[string]any `yaml:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareFiles loads two caller-selected files with the same application
|
||||||
|
// definition, then formats only the safe records returned by configkit.Diff.
|
||||||
|
func CompareFiles(leftPath, rightPath string) (string, error) {
|
||||||
|
options := configkit.Options[FileComparisonConfig]{
|
||||||
|
Definition: configkit.Definition[FileComparisonConfig]{
|
||||||
|
Defaults: func() FileComparisonConfig {
|
||||||
|
return FileComparisonConfig{Entries: map[string]any{}}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
left, err := configkit.Load(configkit.File(leftPath), options)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load left configuration: %w", err)
|
||||||
|
}
|
||||||
|
right, err := configkit.Load(configkit.File(rightPath), options)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load right configuration: %w", err)
|
||||||
|
}
|
||||||
|
differences, err := configkit.Diff(left, right)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return FormatDifferences(differences), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RolloutConfig models a service endpoint whose URL is derived by the
|
||||||
|
// application resolver and whose credential reference is classified.
|
||||||
|
type RolloutConfig struct {
|
||||||
|
Service RolloutService `yaml:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RolloutService contains endpoint settings and a classified credential.
|
||||||
|
type RolloutService struct {
|
||||||
|
Endpoint RolloutEndpoint `yaml:"endpoint"`
|
||||||
|
Credential RolloutCredential `yaml:"credential"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RolloutEndpoint is the effective destination used by the application.
|
||||||
|
type RolloutEndpoint struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RolloutCredential holds only a typed secret reference in configuration.
|
||||||
|
type RolloutCredential struct {
|
||||||
|
Token configkit.Secret `yaml:"token" configkit:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRollout loads a base file, optionally with one selected configuration
|
||||||
|
// overlay. Resolution derives the effective URL after the overlay is applied.
|
||||||
|
func LoadRollout(basePath, overlayName string) (*configkit.Result[RolloutConfig], error) {
|
||||||
|
return configkit.Load(configkit.File(basePath), configkit.Options[RolloutConfig]{
|
||||||
|
Overlay: overlayName,
|
||||||
|
Definition: configkit.Definition[RolloutConfig]{
|
||||||
|
Resolve: resolveRollout,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareRolloutOverlay compares the effective base configuration with that
|
||||||
|
// same file plus one selected overlay. It presents only safe diff records.
|
||||||
|
func CompareRolloutOverlay(basePath, overlayName string) (string, error) {
|
||||||
|
base, err := LoadRollout(basePath, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load base configuration: %w", err)
|
||||||
|
}
|
||||||
|
withOverlay, err := LoadRollout(basePath, overlayName)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load overlay configuration: %w", err)
|
||||||
|
}
|
||||||
|
differences, err := configkit.Diff(base, withOverlay)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return FormatDifferences(differences), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resolution) error {
|
||||||
|
endpoint := &configuration.Service.Endpoint
|
||||||
|
if endpoint.Host == "" || endpoint.Port <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
|
||||||
|
if endpoint.URL != url {
|
||||||
|
endpoint.URL = url
|
||||||
|
resolution.Record(configkit.Keys("service", "endpoint", "url"),
|
||||||
|
configkit.ChangeNormalization, "derived endpoint URL",
|
||||||
|
configkit.Keys("service", "endpoint", "host"),
|
||||||
|
configkit.Keys("service", "endpoint", "port"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDifferences is consumer-owned presentation. It distinguishes absence
|
||||||
|
// through Present and reports withheld changes through HiddenChanged without
|
||||||
|
// interpreting redaction marker text.
|
||||||
|
func FormatDifferences(differences []configkit.Difference) string {
|
||||||
|
if len(differences) == 0 {
|
||||||
|
return "No effective changes.\n"
|
||||||
|
}
|
||||||
|
var output strings.Builder
|
||||||
|
for _, difference := range differences {
|
||||||
|
fmt.Fprintf(&output, "%s: left=%s right=%s", difference.Path.String(),
|
||||||
|
formatSnapshot(difference.Left), formatSnapshot(difference.Right))
|
||||||
|
if difference.HiddenChanged {
|
||||||
|
output.WriteString(" hidden-change")
|
||||||
|
}
|
||||||
|
output.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return output.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSnapshot(snapshot configkit.ValueSnapshot) string {
|
||||||
|
if !snapshot.Present {
|
||||||
|
return "<absent>"
|
||||||
|
}
|
||||||
|
return strconv.Quote(snapshot.YAML)
|
||||||
|
}
|
||||||
187
examples/consumers/diff_test.go
Normal file
187
examples/consumers/diff_test.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleCompareFiles() {
|
||||||
|
directory, err := os.MkdirTemp("", "configkit-diff-files-")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(directory)
|
||||||
|
leftPath := filepath.Join(directory, "before.yml")
|
||||||
|
rightPath := filepath.Join(directory, "after.yml")
|
||||||
|
if err := os.WriteFile(leftPath, []byte("name: before\nentries: {}\n"), 0o600); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(rightPath, []byte("name: after\nentries:\n added: null\n"), 0o600); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Print(output)
|
||||||
|
// Output:
|
||||||
|
// /entries/added: left=<absent> right="null\n"
|
||||||
|
// /name: left="before\n" right="after\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleCompareRolloutOverlay() {
|
||||||
|
const baseToken = "CONFIGKIT_DIFF_EXAMPLE_BASE_TOKEN"
|
||||||
|
const overlayToken = "CONFIGKIT_DIFF_EXAMPLE_OVERLAY_TOKEN"
|
||||||
|
defer preserveEnvironment(baseToken)()
|
||||||
|
defer preserveEnvironment(overlayToken)()
|
||||||
|
_ = os.Setenv(baseToken, "disposable-example-base-credential")
|
||||||
|
_ = os.Setenv(overlayToken, "disposable-example-overlay-credential")
|
||||||
|
|
||||||
|
directory, err := os.MkdirTemp("", "configkit-diff-overlay-")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(directory)
|
||||||
|
basePath := filepath.Join(directory, "config.yml")
|
||||||
|
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: " + baseToken + "\n"
|
||||||
|
overlay := "service:\n endpoint:\n host: canary.example\n credential:\n token: " + overlayToken + "\n"
|
||||||
|
if err := os.WriteFile(basePath, []byte(base), 0o600); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(overlayPath, []byte(overlay), 0o600); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Print(output)
|
||||||
|
// Output:
|
||||||
|
// /service/credential/token: left="'[REDACTED]'\n" right="'[REDACTED]'\n" hidden-change
|
||||||
|
// /service/endpoint/host: left="base.example\n" right="canary.example\n"
|
||||||
|
// /service/endpoint/url: left="https://base.example:8443\n" right="https://canary.example:8443\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareFilesUsesCallerSelectedSourcesAndSafePresence(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
leftPath := filepath.Join(directory, "before.yml")
|
||||||
|
rightPath := filepath.Join(directory, "after.yml")
|
||||||
|
writeConfiguration(t, leftPath, "name: before\nentries: {}\n")
|
||||||
|
writeConfiguration(t, rightPath, "name: after\nentries:\n added: null\n")
|
||||||
|
|
||||||
|
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "/entries/added: left=<absent> right=\"null\\n\"\n" +
|
||||||
|
"/name: left=\"before\\n\" right=\"after\\n\"\n"
|
||||||
|
if output != want {
|
||||||
|
t.Fatalf("comparison output = %q, want %q", output, want)
|
||||||
|
}
|
||||||
|
if again, err := consumers.CompareFiles(leftPath, rightPath); err != nil || again != output {
|
||||||
|
t.Fatalf("repeat output = %q, %v", again, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareFilesReturnsEachLoadError(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
leftPath := filepath.Join(directory, "before.yml")
|
||||||
|
rightPath := filepath.Join(directory, "after.yml")
|
||||||
|
writeConfiguration(t, leftPath, "name: before\n")
|
||||||
|
writeConfiguration(t, rightPath, "unknown: invalid\n")
|
||||||
|
|
||||||
|
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||||
|
if err == nil || output != "" || !strings.Contains(err.Error(), "load right configuration") {
|
||||||
|
t.Fatalf("right load failure = %q, %v", output, err)
|
||||||
|
}
|
||||||
|
if _, err := consumers.CompareFiles(filepath.Join(directory, "missing.yml"), rightPath); err == nil || !strings.Contains(err.Error(), "load left configuration") {
|
||||||
|
t.Fatalf("left load failure = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareRolloutOverlayShowsResolvedSafeChangesAndRetainsSiblings(t *testing.T) {
|
||||||
|
t.Setenv("CONFIGKIT_DIFF_BASE_TOKEN", "disposable-base-credential")
|
||||||
|
t.Setenv("CONFIGKIT_DIFF_OVERLAY_TOKEN", "disposable-overlay-credential")
|
||||||
|
directory := t.TempDir()
|
||||||
|
basePath := filepath.Join(directory, "config.yml")
|
||||||
|
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeConfiguration(t, basePath, "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: CONFIGKIT_DIFF_BASE_TOKEN\n")
|
||||||
|
writeConfiguration(t, overlayPath, "service:\n endpoint:\n host: canary.example\n credential:\n token: CONFIGKIT_DIFF_OVERLAY_TOKEN\n")
|
||||||
|
|
||||||
|
base, err := consumers.LoadRollout(basePath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
withOverlay, err := consumers.LoadRollout(basePath, "canary")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if base.Config.Service.Endpoint.Port != 8443 || withOverlay.Config.Service.Endpoint.Port != 8443 {
|
||||||
|
t.Fatalf("lower-priority port was not retained: base=%#v overlay=%#v", base.Config.Service.Endpoint, withOverlay.Config.Service.Endpoint)
|
||||||
|
}
|
||||||
|
urlInfo, found := withOverlay.Metadata().Lookup(configkit.Keys("service", "endpoint", "url"))
|
||||||
|
if !found || urlInfo.Origin.Layer != configkit.LayerResolution || withOverlay.Config.Service.Endpoint.URL != "https://canary.example:8443" {
|
||||||
|
t.Fatalf("resolved endpoint = %#v, metadata=%#v, %v", withOverlay.Config.Service.Endpoint, urlInfo, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
differences, err := configkit.Diff(base, withOverlay)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(differences) != 3 {
|
||||||
|
t.Fatalf("differences = %#v", differences)
|
||||||
|
}
|
||||||
|
wantPaths := []string{"/service/credential/token", "/service/endpoint/host", "/service/endpoint/url"}
|
||||||
|
for index, difference := range differences {
|
||||||
|
if got := difference.Path.String(); got != wantPaths[index] {
|
||||||
|
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secret := differences[0]
|
||||||
|
if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged || secret.Left.YAML != "'[REDACTED]'\n" || secret.Right.YAML != "'[REDACTED]'\n" {
|
||||||
|
t.Fatalf("classified credential difference = %#v", secret)
|
||||||
|
}
|
||||||
|
if strings.Contains(consumers.FormatDifferences(differences), "disposable-") {
|
||||||
|
t.Fatal("formatted differences exposed credential contents")
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
|
||||||
|
if err != nil || output != consumers.FormatDifferences(differences) {
|
||||||
|
t.Fatalf("formatted base/overlay comparison = %q, %v", output, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDifferencesUsesRecordMembershipForNoChange(t *testing.T) {
|
||||||
|
if got := consumers.FormatDifferences(nil); got != "No effective changes.\n" {
|
||||||
|
t.Fatalf("no-change output = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfiguration(t *testing.T, filename, contents string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
examples/consumers/inheritance.go
Normal file
20
examples/consumers/inheritance.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package consumers
|
||||||
|
|
||||||
|
import configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
|
||||||
|
// PresetCatalog is ordinary application data; no inheritance fields or
|
||||||
|
// registration hooks are required in the typed configuration.
|
||||||
|
type PresetCatalog struct {
|
||||||
|
Presets map[string]Preset `yaml:"presets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Preset struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Settings map[string]map[string]int `yaml:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPresets performs one load; callers may inspect inherited presence and
|
||||||
|
// source origins on the returned result before choosing a preset to execute.
|
||||||
|
func LoadPresets(source configkit.Source) (*configkit.Result[PresetCatalog], error) {
|
||||||
|
return configkit.Load[PresetCatalog](source)
|
||||||
|
}
|
||||||
35
examples/consumers/inheritance_test.go
Normal file
35
examples/consumers/inheritance_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPresetCatalogOneLoadAndDerivation(t *testing.T) {
|
||||||
|
result, err := consumers.LoadPresets(configkit.File(fixture("inheritance", "presets.yml")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Presets["base"].Settings["secondary"]["width"] != 10 ||
|
||||||
|
result.Config.Presets["custom"].Settings["secondary"]["width"] != 20 ||
|
||||||
|
result.Config.Presets["custom"].Enabled {
|
||||||
|
t.Fatalf("expanded presets = %#v", result.Config.Presets)
|
||||||
|
}
|
||||||
|
enabled := configkit.Keys("presets", "custom", "enabled")
|
||||||
|
if !result.Metadata().IsExplicit(enabled) {
|
||||||
|
t.Fatal("inherited false lost explicit presence")
|
||||||
|
}
|
||||||
|
info, found := result.Metadata().Lookup(enabled)
|
||||||
|
if !found || info.Origin.Locations[0].SourceID != "source-1" ||
|
||||||
|
len(info.Origin.Inheritance) != 1 || info.Origin.Inheritance[0].From.String() != "/presets/base/enabled" {
|
||||||
|
t.Fatalf("origin and derivation = %#v, %v", info, found)
|
||||||
|
}
|
||||||
|
nested := configkit.Keys("presets", "custom", "settings", "secondary", "width")
|
||||||
|
info, found = result.Metadata().Lookup(nested)
|
||||||
|
if !found || len(info.Origin.Inheritance) != 1 ||
|
||||||
|
info.Origin.Inheritance[0].From.String() != "/presets/custom/settings/primary/width" {
|
||||||
|
t.Fatalf("destination-local derivation = %#v, %v", info, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
examples/consumers/layered.go
Normal file
95
examples/consumers/layered.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package consumers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle
|
||||||
|
var bundledFiles embed.FS
|
||||||
|
|
||||||
|
// BundledConfig is a small configuration loaded directly from an embedded file.
|
||||||
|
type BundledConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadBundled demonstrates a native filesystem source without layered options.
|
||||||
|
func LoadBundled() (*configkit.Result[BundledConfig], error) {
|
||||||
|
files, err := fs.Sub(bundledFiles, "testdata/native-source")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return configkit.Load[BundledConfig](configkit.NewFilesystem("bundled", files).File("config.yml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogConfig keeps resource names and domain rules in the application.
|
||||||
|
type CatalogConfig struct {
|
||||||
|
Mode string `yaml:"mode"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Workers int `yaml:"workers"`
|
||||||
|
Templates map[string]string `yaml:"templates"`
|
||||||
|
Labels map[string]string `yaml:"labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCatalog combines two underlay tiers beneath an independently located
|
||||||
|
// host main file. ConfigKit assembles and validates the complete result once.
|
||||||
|
func LoadCatalog(mainPath string) (*configkit.Result[CatalogConfig], error) {
|
||||||
|
files, err := fs.Sub(bundledFiles, "testdata/layered-bundle")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bundle := configkit.NewFilesystem("catalog bundle", files)
|
||||||
|
return configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)},
|
||||||
|
configkit.LayeredOptions[CatalogConfig]{
|
||||||
|
Underlays: []configkit.Underlay{
|
||||||
|
{Inputs: []configkit.Input{{Source: bundle.File("defaults/config.yml"), Imports: true}}},
|
||||||
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("local policy", map[string]any{
|
||||||
|
"workers": 2,
|
||||||
|
"labels": map[string]any{"tenant": "example"},
|
||||||
|
})}}},
|
||||||
|
},
|
||||||
|
Definition: configkit.Definition[CatalogConfig]{
|
||||||
|
Defaults: func() CatalogConfig { return CatalogConfig{Mode: "demo"} },
|
||||||
|
Validate: validateCatalog,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCatalog(configuration *CatalogConfig, validation *configkit.Validation) {
|
||||||
|
if configuration.Mode != "demo" && configuration.Mode != "production" {
|
||||||
|
validation.Error(configkit.Keys("mode"), "must be demo or production")
|
||||||
|
}
|
||||||
|
if configuration.Workers < 0 {
|
||||||
|
validation.Error(configkit.Keys("workers"), "must be nonnegative")
|
||||||
|
}
|
||||||
|
if name := configuration.Templates["welcome"]; name == "." || !fs.ValidPath(name) {
|
||||||
|
validation.Error(configkit.Keys("templates", "welcome"), "must be a relative resource name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadWelcome follows the effective template field's one declaring source to
|
||||||
|
// its retained resource context. Opening and interpreting the resource remain
|
||||||
|
// application work; this does not reopen configuration or change metadata.
|
||||||
|
func ReadWelcome(result *configkit.Result[CatalogConfig]) (string, error) {
|
||||||
|
if result == nil {
|
||||||
|
return "", errors.New("catalog result is nil")
|
||||||
|
}
|
||||||
|
info, found := result.Metadata().Lookup(configkit.Keys("templates", "welcome"))
|
||||||
|
if !found || len(info.Origin.Locations) != 1 {
|
||||||
|
return "", errors.New("welcome template has no single declaring source")
|
||||||
|
}
|
||||||
|
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !found {
|
||||||
|
return "", errors.New("welcome template has no resource context")
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Templates["welcome"]))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
71
examples/consumers/layered_test.go
Normal file
71
examples/consumers/layered_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example_nativeFilesystem() {
|
||||||
|
result, err := consumers.LoadBundled()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(result.Config.Name, result.Config.Enabled)
|
||||||
|
// Output: embedded false
|
||||||
|
}
|
||||||
|
|
||||||
|
func Example_layeredCatalog() {
|
||||||
|
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
welcome, err := consumers.ReadWelcome(result)
|
||||||
|
fmt.Println(result.Config.Mode, result.Config.Enabled, result.Config.Workers,
|
||||||
|
result.Config.Labels["origin"], result.Config.Labels["tenant"], strings.TrimSpace(welcome), err)
|
||||||
|
// Output: production false 0 bundle example Welcome from the bundle. <nil>
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
|
||||||
|
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"enabled", "workers"} {
|
||||||
|
info, found := result.Metadata().Lookup(configkit.Keys(key))
|
||||||
|
if !found || !info.Explicit || info.Origin.Layer != configkit.LayerBase {
|
||||||
|
t.Fatalf("%s presence = %#v, %v", key, info, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, found := result.Metadata().Lookup(configkit.Keys("templates", "welcome"))
|
||||||
|
if !found || info.Origin.Layer != configkit.LayerUnderlay || len(info.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("welcome origin = %#v, %v", info, found)
|
||||||
|
}
|
||||||
|
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !found || context.BaseDir != "defaults" {
|
||||||
|
t.Fatalf("welcome context = %#v, %v", context, found)
|
||||||
|
}
|
||||||
|
welcome, err := consumers.ReadWelcome(result)
|
||||||
|
if err != nil || strings.TrimSpace(welcome) != "Welcome from the bundle." {
|
||||||
|
t.Fatalf("welcome = %q, %v", welcome, err)
|
||||||
|
}
|
||||||
|
if result.Config.Labels["tenant"] != "example" || result.Config.Labels["origin"] != "bundle" {
|
||||||
|
t.Fatalf("recursive underlay labels = %#v", result.Config.Labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogValidationRejectsInvalidMode(t *testing.T) {
|
||||||
|
_, err := consumers.LoadCatalog(fixture("layered-main", "invalid.yml"))
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 ||
|
||||||
|
loadError.Diagnostics()[0].Code != configkit.CodeValidateError ||
|
||||||
|
loadError.Diagnostics()[0].Path.String() != "/mode" {
|
||||||
|
t.Fatalf("invalid mode error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
examples/consumers/testdata/collection-bundle/collections/weather/collection.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/collection.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: metadata.yml
|
||||||
|
name: Weather Reporter
|
||||||
2
examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml
vendored
Normal file
2
examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
version: "2.4.0"
|
||||||
|
description: Imported collection metadata.
|
||||||
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
compact:
|
||||||
|
format: text
|
||||||
|
temperature: 0.2
|
||||||
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
weather:
|
||||||
|
extends: compact
|
||||||
|
temperature: 0.4
|
||||||
7
examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml
vendored
Normal file
7
examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
brief:
|
||||||
|
version: "1"
|
||||||
|
default_profile: compact
|
||||||
|
content_file: welcome.txt
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: Write a concise weather summary.
|
||||||
3
examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
custom:
|
||||||
|
extends: brief
|
||||||
|
default_profile: weather
|
||||||
1
examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt
vendored
Normal file
1
examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Welcome to the weather collection.
|
||||||
2
examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml
vendored
Normal file
2
examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
brief:
|
||||||
|
version: "2"
|
||||||
10
examples/consumers/testdata/inheritance/presets.yml
vendored
Normal file
10
examples/consumers/testdata/inheritance/presets.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
presets:
|
||||||
|
base:
|
||||||
|
enabled: false
|
||||||
|
settings:
|
||||||
|
primary: {width: 10}
|
||||||
|
secondary: {extends: primary}
|
||||||
|
custom:
|
||||||
|
extends: base
|
||||||
|
settings:
|
||||||
|
primary: {width: 20}
|
||||||
7
examples/consumers/testdata/layered-bundle/defaults/config.yml
vendored
Normal file
7
examples/consumers/testdata/layered-bundle/defaults/config.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: ../shared.yml
|
||||||
|
enabled: true
|
||||||
|
workers: 4
|
||||||
|
templates:
|
||||||
|
welcome: messages/welcome.txt
|
||||||
1
examples/consumers/testdata/layered-bundle/defaults/messages/welcome.txt
vendored
Normal file
1
examples/consumers/testdata/layered-bundle/defaults/messages/welcome.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Welcome from the bundle.
|
||||||
2
examples/consumers/testdata/layered-bundle/shared.yml
vendored
Normal file
2
examples/consumers/testdata/layered-bundle/shared.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
labels:
|
||||||
|
origin: bundle
|
||||||
3
examples/consumers/testdata/layered-main/config.yml
vendored
Normal file
3
examples/consumers/testdata/layered-main/config.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mode: production
|
||||||
|
enabled: false
|
||||||
|
workers: 0
|
||||||
1
examples/consumers/testdata/layered-main/invalid.yml
vendored
Normal file
1
examples/consumers/testdata/layered-main/invalid.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mode: unsupported
|
||||||
2
examples/consumers/testdata/native-source/config.yml
vendored
Normal file
2
examples/consumers/testdata/native-source/config.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
name: embedded
|
||||||
|
enabled: false
|
||||||
@@ -31,6 +31,9 @@ func TestFacadeValuesRemainRootOwned(t *testing.T) {
|
|||||||
reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](),
|
reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](),
|
||||||
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
|
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
|
||||||
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](),
|
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](),
|
||||||
|
reflect.TypeFor[configkit.Filesystem](), reflect.TypeFor[configkit.ResourceContext](),
|
||||||
|
reflect.TypeFor[configkit.Input](), reflect.TypeFor[configkit.Underlay](),
|
||||||
|
reflect.TypeFor[configkit.LayeredOptions[struct{}]](),
|
||||||
reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
|
reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
|
||||||
reflect.TypeFor[configkit.Result[struct{}]](),
|
reflect.TypeFor[configkit.Result[struct{}]](),
|
||||||
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](),
|
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
|
|||||||
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
|
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
|
||||||
{"InputInfo", func() { state.resolution.InputInfo(path) }},
|
{"InputInfo", func() { state.resolution.InputInfo(path) }},
|
||||||
{"Source", func() { state.resolution.Source("source") }},
|
{"Source", func() { state.resolution.Source("source") }},
|
||||||
|
{"SourceContext", func() { state.resolution.SourceContext("source") }},
|
||||||
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
|
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
|
||||||
{"Resolution.Error", func() { state.resolution.Error(path, "message") }},
|
{"Resolution.Error", func() { state.resolution.Error(path, "message") }},
|
||||||
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
|
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
|
||||||
|
|||||||
443
facade_destination_test.go
Normal file
443
facade_destination_test.go
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
hostPath := filepath.Join(directory, "host.yml")
|
||||||
|
if err := os.WriteFile(hostPath, []byte("value: host\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
native := configkit.NewFilesystem("native", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("value: native\n")},
|
||||||
|
})
|
||||||
|
const literalValuePath = "/collections/weather.reporter/profiles/a~1b/~0/0"
|
||||||
|
destination := configkit.Keys("collections", "weather.reporter", "profiles", "a/b", "~", "0")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
source configkit.Source
|
||||||
|
want string
|
||||||
|
kind configkit.SourceKind
|
||||||
|
}{
|
||||||
|
{name: "host file", source: configkit.File(hostPath), want: "host", kind: configkit.SourceKindFile},
|
||||||
|
{name: "native filesystem", source: native.File("main.yml"), want: "native", kind: configkit.SourceKindFilesystem},
|
||||||
|
{name: "bytes", source: configkit.Bytes("bytes", []byte("value: bytes\n")), want: "bytes", kind: configkit.SourceKindBytes},
|
||||||
|
{name: "reader", source: configkit.Reader("reader", strings.NewReader("value: reader\n")), want: "reader", kind: configkit.SourceKindReader},
|
||||||
|
{name: "mapping", source: configkit.Mapping("mapping", map[string]any{"value": "mapping"}), want: "mapping", kind: configkit.SourceKindMapping},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: destination})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
if got := facadeDestinationValue(t, result.Config, "collections", "weather.reporter", "profiles", "a/b", "~", "0", "value"); got != test.want {
|
||||||
|
t.Fatalf("placed value = %#v, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Kind != test.kind || sources[0].Destination.String() != destination.String() || destination.String() != literalValuePath {
|
||||||
|
t.Fatalf("source placement = %#v, destination = %q", sources, destination.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
destination configkit.Path
|
||||||
|
}{
|
||||||
|
{name: "omitted destination"},
|
||||||
|
{name: "explicit empty destination", destination: configkit.Path{}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("root", []byte("value: root\n")), Destination: test.destination})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("root load: %v", err)
|
||||||
|
}
|
||||||
|
if result.Config["value"] != "root" || len(result.Sources()) != 1 || result.Sources()[0].Destination.String() != "" {
|
||||||
|
t.Fatalf("root placement = %#v, sources %#v", result.Config, result.Sources())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyPath := configkit.Keys("collections", "empty")
|
||||||
|
empty, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
|
Source: configkit.Mapping("empty", map[string]any{}), Destination: emptyPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty mapping load: %v", err)
|
||||||
|
}
|
||||||
|
if value := facadeDestinationMap(t, empty.Config, "collections")["empty"]; value == nil {
|
||||||
|
t.Fatal("empty mapping did not materialize its destination")
|
||||||
|
}
|
||||||
|
if info, ok := empty.Metadata().Lookup(emptyPath); !ok || !info.Explicit {
|
||||||
|
t.Fatalf("empty destination metadata = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
absentPath := configkit.Keys("collections", "absent")
|
||||||
|
absent, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource(), Destination: absentPath})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NoSource with a destination: %v", err)
|
||||||
|
}
|
||||||
|
if len(absent.Sources()) != 0 {
|
||||||
|
t.Fatalf("NoSource created source records: %#v", absent.Sources())
|
||||||
|
}
|
||||||
|
if _, ok := absent.Metadata().Lookup(absentPath); ok {
|
||||||
|
t.Fatal("NoSource materialized its destination")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := &destinationCountingReader{}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
source configkit.Source
|
||||||
|
destination configkit.Path
|
||||||
|
wantReads int
|
||||||
|
}{
|
||||||
|
{name: "index segment", source: configkit.Reader("unread", reader), destination: configkit.Keys("catalog").Index(0)},
|
||||||
|
{name: "reserved configkit key", source: configkit.NoSource(), destination: configkit.Keys("configkit")},
|
||||||
|
{name: "reserved extends key", source: configkit.NoSource(), destination: configkit.Keys("catalog", "extends")},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: test.destination})
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||||
|
t.Fatalf("invalid destination error = %#v", err)
|
||||||
|
}
|
||||||
|
if reader.reads != test.wantReads {
|
||||||
|
t.Fatalf("reader calls = %d, want %d", reader.reads, test.wantReads)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := filepath.Join(directory, "main.yml")
|
||||||
|
fragmentPath := filepath.Join(directory, "fragment.yml")
|
||||||
|
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: fragment.yml}]}\nmain: present\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fragmentPath, []byte("imported: present\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
destination := configkit.Keys("collections", "weatherreporter")
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
|
Source: configkit.File(mainPath), Imports: true, Destination: destination,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mounted import load: %v", err)
|
||||||
|
}
|
||||||
|
collection := facadeDestinationMap(t, result.Config, "collections", "weatherreporter")
|
||||||
|
if collection["main"] != "present" || collection["imported"] != "present" {
|
||||||
|
t.Fatalf("mounted imported values = %#v", collection)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() {
|
||||||
|
t.Fatalf("mounted import source destinations = %#v", sources)
|
||||||
|
}
|
||||||
|
if sources[1].ImportedFrom == nil {
|
||||||
|
t.Fatalf("imported source lacks its authored directive: %#v", sources[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSameFragmentMountsAtSeparateCollectionDestinations(t *testing.T) {
|
||||||
|
weatherDestination := configkit.Keys("collections", "weather", "prompts")
|
||||||
|
homeDestination := configkit.Keys("collections", "homewatch", "prompts")
|
||||||
|
result, err := configkit.LoadLayered(
|
||||||
|
configkit.Input{Source: configkit.NoSource()},
|
||||||
|
configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
|
{Source: configkit.Bytes("weather", []byte("summarize.alerts: {title: weather}\n")), Destination: weatherDestination},
|
||||||
|
{Source: configkit.Bytes("homewatch", []byte("summarize.alerts: {title: home}\n")), Destination: homeDestination},
|
||||||
|
}}}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
path []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{path: []string{"collections", "weather", "prompts", "summarize.alerts", "title"}, want: "weather"},
|
||||||
|
{path: []string{"collections", "homewatch", "prompts", "summarize.alerts", "title"}, want: "home"},
|
||||||
|
} {
|
||||||
|
if got := facadeDestinationValue(t, result.Config, test.path...); got != test.want {
|
||||||
|
t.Errorf("value at %v = %#v, want %q", test.path, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != weatherDestination.String() || sources[1].Destination.String() != homeDestination.String() {
|
||||||
|
t.Fatalf("independent collection source placements = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type destinationCountingReader struct{ reads int }
|
||||||
|
|
||||||
|
func (reader *destinationCountingReader) Read([]byte) (int, error) {
|
||||||
|
reader.reads++
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
filesystem := configkit.NewFilesystem("resources", fstest.MapFS{
|
||||||
|
"assets/summary.md": &fstest.MapFile{Data: []byte("source-relative summary")},
|
||||||
|
})
|
||||||
|
context := &configkit.FilesystemContext{Root: ".", BaseDir: "assets", FS: &filesystem}
|
||||||
|
destination := configkit.Keys("catalog", "entries")
|
||||||
|
tokenPath := configkit.Keys("catalog", "entries", "item", "token")
|
||||||
|
titlePath := configkit.Keys("catalog", "entries", "item", "title")
|
||||||
|
var callbacks struct{ resolve, validate int }
|
||||||
|
load := func(token string) *configkit.Result[map[string]any] {
|
||||||
|
t.Helper()
|
||||||
|
main := configkit.Input{
|
||||||
|
Source: configkit.Bytes("main", []byte("item:\n title: main\n resource: summary.md\n token: "+token+"\n")),
|
||||||
|
Destination: destination, Filesystem: context,
|
||||||
|
}
|
||||||
|
underlay := configkit.Input{
|
||||||
|
Source: configkit.Bytes("underlay", []byte("item:\n title: lower\n retained: lower-value\n token: LOWER-TOKEN-CANARY\n")),
|
||||||
|
Destination: destination,
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
|
||||||
|
Definition: configkit.Definition[map[string]any]{
|
||||||
|
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
callbacks.resolve++
|
||||||
|
resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
|
||||||
|
input, ok := resolution.InputInfo(resourcePath)
|
||||||
|
if !ok || len(input.Origin.Locations) != 1 {
|
||||||
|
return fmt.Errorf("mounted resource input has no single source origin")
|
||||||
|
}
|
||||||
|
source, ok := resolution.Source(input.Origin.Locations[0].SourceID)
|
||||||
|
if !ok || source.Destination.String() != destination.String() {
|
||||||
|
return fmt.Errorf("resolution source did not retain its destination")
|
||||||
|
}
|
||||||
|
resourceContext, ok := resolution.SourceContext(source.ID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("resolution source context is unavailable")
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(resourceContext.FS, path.Join(resourceContext.BaseDir, "summary.md"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read source-relative resource: %w", err)
|
||||||
|
}
|
||||||
|
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
|
||||||
|
entry["resource_text"] = string(data)
|
||||||
|
resolution.Record(configkit.Keys("catalog", "entries", "item", "resource_text"), configkit.ChangeNormalization, "read source-relative summary", resourcePath)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
|
||||||
|
Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mounted load: %v", err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
first := load("FIRST-TOKEN-CANARY")
|
||||||
|
second := load("SECOND-TOKEN-CANARY")
|
||||||
|
if callbacks.resolve != 2 || callbacks.validate != 2 {
|
||||||
|
t.Fatalf("lifecycle callbacks = %#v", callbacks)
|
||||||
|
}
|
||||||
|
for _, result := range []*configkit.Result[map[string]any]{first, second} {
|
||||||
|
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, result.Config, "catalog"), "entries"), "item")
|
||||||
|
if entry["title"] != "main" || entry["retained"] != "lower-value" || entry["resource_text"] != "source-relative summary" {
|
||||||
|
t.Fatalf("mounted main/underlay configuration = %#v", entry)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() {
|
||||||
|
t.Fatalf("public source destinations = %#v", sources)
|
||||||
|
}
|
||||||
|
resource, ok := result.SourceContext(sources[0].ID)
|
||||||
|
if !ok || resource.BaseDir != "assets" {
|
||||||
|
t.Fatalf("completed source context = %#v, %v", resource, ok)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, "summary.md"))
|
||||||
|
if err != nil || string(data) != "source-relative summary" {
|
||||||
|
t.Fatalf("completed relative resource = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerBase {
|
||||||
|
t.Fatalf("mounted title metadata = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
if history := result.Metadata().History(titlePath); len(history) < 2 {
|
||||||
|
t.Fatalf("mounted title history = %#v", history)
|
||||||
|
}
|
||||||
|
if explanation, ok := result.Explain(tokenPath); !ok || !explanation.Effective.Redacted {
|
||||||
|
t.Fatalf("mounted secret explanation = %#v, %v", explanation, ok)
|
||||||
|
}
|
||||||
|
var effective, explanation bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := result.WriteExplanation(&explanation, tokenPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
output := effective.String() + explanation.String()
|
||||||
|
for _, canary := range []string{"FIRST-TOKEN-CANARY", "SECOND-TOKEN-CANARY", "LOWER-TOKEN-CANARY"} {
|
||||||
|
if strings.Contains(output, canary) {
|
||||||
|
t.Fatalf("public inspection leaked %q: %s", canary, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutating published records and query copies must not alter the retained result.
|
||||||
|
returnedSources := first.Sources()
|
||||||
|
returnedSources[0].Destination = configkit.Keys("mutated")
|
||||||
|
if got := first.Sources()[0].Destination.String(); got != destination.String() {
|
||||||
|
t.Fatalf("source destination changed after caller mutation: %q", got)
|
||||||
|
}
|
||||||
|
info, ok := first.Metadata().Lookup(titlePath)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("mounted metadata lookup failed")
|
||||||
|
}
|
||||||
|
info.Origin.Locations[0].SourceID = "mutated"
|
||||||
|
if fresh, ok := first.Metadata().Lookup(titlePath); !ok || fresh.Origin.Locations[0].SourceID == "mutated" {
|
||||||
|
t.Fatalf("metadata copy mutation escaped: %#v, %v", fresh, ok)
|
||||||
|
}
|
||||||
|
history := first.Metadata().History(titlePath)
|
||||||
|
if len(history) == 0 {
|
||||||
|
t.Fatal("mounted history is empty")
|
||||||
|
}
|
||||||
|
history[0].Path = configkit.Keys("mutated")
|
||||||
|
if fresh := first.Metadata().History(titlePath); len(fresh) == 0 || fresh[0].Path.String() != titlePath.String() {
|
||||||
|
t.Fatalf("history copy mutation escaped: %#v", fresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
differences, err := configkit.Diff(first, second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(differences) != 1 || differences[0].Path.String() != tokenPath.String() ||
|
||||||
|
!differences[0].Left.Redacted || !differences[0].Right.Redacted ||
|
||||||
|
strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "TOKEN-CANARY") {
|
||||||
|
t.Fatalf("public mounted secret diff = %#v", differences)
|
||||||
|
}
|
||||||
|
if callbacks.resolve != 2 || callbacks.validate != 2 {
|
||||||
|
t.Fatalf("inspection reran lifecycle callbacks: %#v", callbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeFailedLoadExposesDetachedSourceDestination(t *testing.T) {
|
||||||
|
type configuration struct {
|
||||||
|
Tenant struct {
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
} `yaml:"tenant"`
|
||||||
|
}
|
||||||
|
destination := configkit.Keys("tenant")
|
||||||
|
_, err := configkit.LoadLayered[configuration](configkit.Input{
|
||||||
|
Source: configkit.Bytes("invalid", []byte("count: not-an-integer\n")), Destination: destination,
|
||||||
|
})
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("failed load error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Destination.String() != destination.String() {
|
||||||
|
t.Fatalf("failed source destinations = %#v", sources)
|
||||||
|
}
|
||||||
|
sources[0].Destination = configkit.Keys("caller-change")
|
||||||
|
if fresh := loadError.Sources(); len(fresh) != 1 || fresh[0].Destination.String() != destination.String() {
|
||||||
|
t.Fatalf("failed source destination was not detached: %#v", fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDestinationMappingFailurePaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cycle := map[string]any{}
|
||||||
|
cycle["self"] = cycle
|
||||||
|
for _, fixture := range []struct {
|
||||||
|
name string
|
||||||
|
values map[string]any
|
||||||
|
path func(configkit.Path) configkit.Path
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unsupported nested sequence value",
|
||||||
|
values: map[string]any{"a/b": []any{map[string]any{"~": func() {}}}},
|
||||||
|
path: func(base configkit.Path) configkit.Path { return base.Key("a/b").Index(0).Key("~") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cyclic value",
|
||||||
|
values: map[string]any{"item": cycle},
|
||||||
|
path: func(base configkit.Path) configkit.Path { return base.Key("item").Key("self") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "constructor failure keeps source coordinates",
|
||||||
|
path: func(configkit.Path) configkit.Path { return configkit.Path{} },
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(fixture.name, func(t *testing.T) {
|
||||||
|
for _, role := range []string{"root", "mounted main", "mounted underlay"} {
|
||||||
|
t.Run(role, func(t *testing.T) {
|
||||||
|
var destination configkit.Path
|
||||||
|
if role != "root" {
|
||||||
|
destination = configkit.Keys("catalog", "entries")
|
||||||
|
}
|
||||||
|
wantPath := fixture.path(destination)
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("invalid mapping", fixture.values), Destination: destination}
|
||||||
|
var result *configkit.Result[map[string]any]
|
||||||
|
var err error
|
||||||
|
if role == "mounted underlay" {
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
result, err = configkit.LoadLayered[map[string]any](input)
|
||||||
|
}
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if result != nil || !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("failed load = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
diagnostic := diagnostics[0]
|
||||||
|
if diagnostic.Code != configkit.CodeSourceInvalid || !reflect.DeepEqual(diagnostic.Path, wantPath) {
|
||||||
|
t.Fatalf("diagnostic = %#v; want source.invalid at %q", diagnostic, wantPath.String())
|
||||||
|
}
|
||||||
|
if diagnostic.Primary == nil || *diagnostic.Primary != (configkit.Location{SourceID: "source-1"}) || len(diagnostic.Related) != 0 {
|
||||||
|
t.Fatalf("mapping source locations = %#v, %#v", diagnostic.Primary, diagnostic.Related)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Destination.String() != destination.String() {
|
||||||
|
t.Fatalf("error sources = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func facadeDestinationMap(t *testing.T, value map[string]any, keys ...string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
for _, key := range keys {
|
||||||
|
child, ok := value[key].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%q = %#v, want mapping", key, value[key])
|
||||||
|
}
|
||||||
|
value = child
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func facadeDestinationValue(t *testing.T, value map[string]any, keys ...string) any {
|
||||||
|
t.Helper()
|
||||||
|
return facadeDestinationMap(t, value, keys[:len(keys)-1]...)[keys[len(keys)-1]]
|
||||||
|
}
|
||||||
327
facade_diff_test.go
Normal file
327
facade_diff_test.go
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type facadeDiffConfig struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Items []string `yaml:"items"`
|
||||||
|
Dynamic map[string]any `yaml:"dynamic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type facadeDiffSecretConfig struct {
|
||||||
|
Secret configkit.Secret `yaml:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testing.T) {
|
||||||
|
type configuration struct {
|
||||||
|
Items []any `yaml:"items"`
|
||||||
|
}
|
||||||
|
load := func(public string) *configkit.Result[configuration] {
|
||||||
|
t.Helper()
|
||||||
|
result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")),
|
||||||
|
configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||||
|
Secret: func(path configkit.Path) bool { return path.String() == "/items/0/secret" },
|
||||||
|
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||||
|
current.Items[0] = "public scalar"
|
||||||
|
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "normalize first item")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
left, right := load("1"), load("2")
|
||||||
|
history, ok := left.Explain(configkit.Keys("items").Index(0).Key("secret"))
|
||||||
|
if !ok || history.Effective.Present || len(history.History) == 0 {
|
||||||
|
t.Fatalf("removed historical secret = %#v, known=%v", history, ok)
|
||||||
|
}
|
||||||
|
maskedHistory := false
|
||||||
|
for _, change := range history.History {
|
||||||
|
maskedHistory = maskedHistory || change.Before.Redacted || change.After.Redacted
|
||||||
|
}
|
||||||
|
if !maskedHistory {
|
||||||
|
t.Fatal("historical sensitivity was not retained")
|
||||||
|
}
|
||||||
|
differences, err := configkit.Diff(left, right)
|
||||||
|
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/items" {
|
||||||
|
t.Fatalf("sequence difference = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
difference := differences[0]
|
||||||
|
if !difference.Left.Present || !difference.Right.Present || difference.Left.Redacted || difference.Right.Redacted || difference.HiddenChanged {
|
||||||
|
t.Fatalf("public sequence change was concealed: %#v", difference)
|
||||||
|
}
|
||||||
|
if difference.Left.YAML != "- public scalar\n- 1\n" || difference.Right.YAML != "- public scalar\n- 2\n" {
|
||||||
|
t.Fatalf("public sequence snapshots = %#v", difference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) {
|
||||||
|
type configuration struct {
|
||||||
|
Dynamic map[string]any `yaml:"dynamic"`
|
||||||
|
}
|
||||||
|
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||||
|
Secret: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" },
|
||||||
|
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||||
|
delete(current.Dynamic, "entry")
|
||||||
|
resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
absent, err := configkit.Load(configkit.Bytes("removed-entry.yml", []byte("dynamic: {entry: {token: disposable-canary}}\n")), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
options.Definition.Resolve = nil
|
||||||
|
present, err := configkit.Load(configkit.Bytes("public-entry.yml", []byte("dynamic: {entry: public}\n")), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
left, right *configkit.Result[configuration]
|
||||||
|
leftPresent bool
|
||||||
|
}{
|
||||||
|
{name: "addition", left: absent, right: present},
|
||||||
|
{name: "removal", left: present, right: absent, leftPresent: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
differences, err := configkit.Diff(test.left, test.right)
|
||||||
|
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/dynamic/entry" {
|
||||||
|
t.Fatalf("entry difference = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
difference := differences[0]
|
||||||
|
if difference.Left.Present != test.leftPresent || difference.Right.Present == test.leftPresent ||
|
||||||
|
difference.Left.Redacted || difference.Right.Redacted || difference.HiddenChanged {
|
||||||
|
t.Fatalf("public presence change was concealed: %#v", difference)
|
||||||
|
}
|
||||||
|
wantLeft, wantRight := "", "public\n"
|
||||||
|
if test.leftPresent {
|
||||||
|
wantLeft, wantRight = wantRight, wantLeft
|
||||||
|
}
|
||||||
|
if difference.Left.YAML != wantLeft || difference.Right.YAML != wantRight {
|
||||||
|
t.Fatalf("entry snapshots = %#v", difference)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDiffReturnsDetachedCanonicalRecords(t *testing.T) {
|
||||||
|
left, err := configkit.Load[facadeDiffConfig](configkit.Bytes("left.yml", []byte("value: before\ncount: 0\nitems: [one, two]\ndynamic: {}\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load left: %v", err)
|
||||||
|
}
|
||||||
|
right, err := configkit.Load[facadeDiffConfig](configkit.Bytes("right.yml", []byte("value: after\ncount: 0\nitems: [one, three]\ndynamic:\n added: null\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
differences, err := configkit.Diff(left, right)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantPaths := []string{"/dynamic/added", "/items", "/value"}
|
||||||
|
if len(differences) != len(wantPaths) {
|
||||||
|
t.Fatalf("differences = %#v", differences)
|
||||||
|
}
|
||||||
|
for index, difference := range differences {
|
||||||
|
if got := difference.Path.String(); got != wantPaths[index] {
|
||||||
|
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if differences[0].Left.Present || !differences[0].Right.Present || differences[0].Right.YAML != "null\n" {
|
||||||
|
t.Errorf("null/absence snapshots = %#v / %#v", differences[0].Left, differences[0].Right)
|
||||||
|
}
|
||||||
|
if differences[1].Left.YAML != "- one\n- two\n" || differences[1].Right.YAML != "- one\n- three\n" {
|
||||||
|
t.Errorf("sequence snapshots = %#v / %#v", differences[1].Left, differences[1].Right)
|
||||||
|
}
|
||||||
|
|
||||||
|
left.Config.Value = "caller mutation"
|
||||||
|
left.Config.Items[0] = "caller mutation"
|
||||||
|
right.Config.Dynamic["added"] = "caller mutation"
|
||||||
|
differences[2].Left.YAML = "record mutation"
|
||||||
|
differences[2].Path = configkit.Keys("rewritten")
|
||||||
|
|
||||||
|
again, err := configkit.Diff(left, right)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again[2].Path.String() != "/value" || again[2].Left.YAML != "before\n" || again[1].Left.YAML != "- one\n- two\n" {
|
||||||
|
t.Fatalf("repeat diff changed = %#v", again)
|
||||||
|
}
|
||||||
|
explanation, ok := left.Explain(configkit.Keys("value"))
|
||||||
|
if !ok || explanation.Effective.YAML != "before\n" {
|
||||||
|
t.Fatalf("frozen explanation = %#v, %v", explanation, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDiffRejectsUnavailableResults(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
loaded, err := configkit.Load[config](configkit.Bytes("valid.yml", []byte("value: valid\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var nilResult *configkit.Result[config]
|
||||||
|
zeroResult := &configkit.Result[config]{Config: config{Value: "must not be inspected"}}
|
||||||
|
for name, left := range map[string]*configkit.Result[config]{"nil": nilResult, "zero": zeroResult} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if records, err := configkit.Diff(left, loaded); err == nil || records != nil {
|
||||||
|
t.Fatalf("Diff = %#v, %v; want nil records and error", records, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if records, err := configkit.Diff(loaded, nilResult); err == nil || records != nil {
|
||||||
|
t.Fatalf("right nil Diff = %#v, %v", records, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
}
|
||||||
|
defaults := configkit.Options[config]{Definition: configkit.Definition[config]{
|
||||||
|
Defaults: func() config { return config{Count: 7} },
|
||||||
|
}}
|
||||||
|
defaulted, err := configkit.Load[config](configkit.NoSource(), defaults)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicit, err := configkit.Load[config](configkit.Bytes("explicit.yml", []byte("count: 7\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if differences, err := configkit.Diff(defaulted, explicit); err != nil || len(differences) != 0 {
|
||||||
|
t.Fatalf("default/source difference = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zeroDefault, err := configkit.Load[config](configkit.NoSource(), configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config { return config{} }}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicitZero, err := configkit.Load[config](configkit.Bytes("explicit-zero.yml", []byte("count: 0\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if differences, err := configkit.Diff(zeroDefault, explicitZero); err != nil || len(differences) != 0 {
|
||||||
|
t.Fatalf("explicit/defaulted zero difference = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve := func(value string) configkit.Options[config] {
|
||||||
|
return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error {
|
||||||
|
current.Count = len(value) + 8
|
||||||
|
resolution.Record(configkit.Keys("count"), configkit.ChangeNormalization, "set effective count")
|
||||||
|
return nil
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
resolvedA, err := configkit.Load[config](configkit.Bytes("a.yml", []byte("{}\n")), resolve("a"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resolvedB, err := configkit.Load[config](configkit.Bytes("b.yml", []byte("{}\n")), resolve("b"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if differences, err := configkit.Diff(resolvedA, resolvedB); err != nil || len(differences) != 0 {
|
||||||
|
t.Fatalf("equal resolved values = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDiffUsesFrozenFilesAndRunsCallbacksOnlyDuringLoad(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
options := configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config {
|
||||||
|
calls++
|
||||||
|
return config{}
|
||||||
|
}}}
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := []string{filepath.Join(dir, "left.yml"), filepath.Join(dir, "right.yml")}
|
||||||
|
for index, contents := range []string{"value: left\n", "value: right\n"} {
|
||||||
|
if err := os.WriteFile(paths[index], []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
left, err := configkit.Load[config](configkit.File(paths[0]), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
right, err := configkit.Load[config](configkit.File(paths[1]), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("defaults calls after loads = %d", calls)
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
differences, err := configkit.Diff(left, right)
|
||||||
|
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/value" {
|
||||||
|
t.Fatalf("Diff after removing files = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("defaults reran during Diff: %d calls", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) {
|
||||||
|
t.Setenv("FACADE_DIFF_SECRET", "credential-one")
|
||||||
|
secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Secret: func(path configkit.Path) bool {
|
||||||
|
return path.String() == "/secret"
|
||||||
|
}}}
|
||||||
|
left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("FACADE_DIFF_SECRET", "credential-two")
|
||||||
|
right, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("right.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if differences, err := configkit.Diff(left, right); err != nil || len(differences) != 0 {
|
||||||
|
t.Fatalf("credential rotation = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
t.Setenv("OTHER_FACADE_DIFF_SECRET", "other-credential")
|
||||||
|
otherReference, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("other.yml", []byte("secret: OTHER_FACADE_DIFF_SECRET\n")), secretOptions)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
differences, err := configkit.Diff(left, otherReference)
|
||||||
|
if err != nil || len(differences) != 1 || !differences[0].HiddenChanged || !differences[0].Left.Redacted || !differences[0].Right.Redacted {
|
||||||
|
t.Fatalf("reference change = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "credential") {
|
||||||
|
t.Fatalf("credential leaked: %#v", differences)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidLeft, leftErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-left.yml", []byte("unknown: true\n")))
|
||||||
|
if invalidLeft != nil || leftErr == nil {
|
||||||
|
t.Fatalf("invalid left load = %#v, %v", invalidLeft, leftErr)
|
||||||
|
}
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(leftErr, &loadError) {
|
||||||
|
t.Fatalf("left load error = %T, want *configkit.LoadError", leftErr)
|
||||||
|
}
|
||||||
|
invalidRight, rightErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-right.yml", []byte("unknown: true\n")))
|
||||||
|
if invalidRight != nil || rightErr == nil {
|
||||||
|
t.Fatalf("invalid right load = %#v, %v", invalidRight, rightErr)
|
||||||
|
}
|
||||||
|
if !errors.As(rightErr, &loadError) {
|
||||||
|
t.Fatalf("right load error = %T, want *configkit.LoadError", rightErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
164
facade_equivalence_test.go
Normal file
164
facade_equivalence_test.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type equivalentConfig struct {
|
||||||
|
Version uint64 `yaml:"version"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Items []string `yaml:"items"`
|
||||||
|
Optional *string `yaml:"optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
||||||
|
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
|
||||||
|
hostPath := filepath.Join(t.TempDir(), "main.yml")
|
||||||
|
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte(yaml)}})
|
||||||
|
context := &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &namespace}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
source configkit.Source
|
||||||
|
context *configkit.FilesystemContext
|
||||||
|
kind configkit.SourceKind
|
||||||
|
}{
|
||||||
|
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
|
||||||
|
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
|
||||||
|
{"bytes", configkit.Bytes("memory", []byte(yaml)), context, configkit.SourceKindBytes},
|
||||||
|
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
|
||||||
|
{"mapping", configkit.Mapping("programmatic", map[string]any{
|
||||||
|
"version": uint64(1), "name": "retained", "enabled": false, "count": 0,
|
||||||
|
"items": []any{}, "optional": nil,
|
||||||
|
}), context, configkit.SourceKindMapping},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
checks := 0
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: test.source, Filesystem: test.context}, configkit.LayeredOptions[equivalentConfig]{
|
||||||
|
Definition: configkit.Definition[equivalentConfig]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"), Check: func(version configkit.Version, validation *configkit.Validation) error {
|
||||||
|
checks++
|
||||||
|
if !version.Present || version.Text != "1" {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := equivalentConfig{Version: 1, Name: "retained", Items: []string{}}
|
||||||
|
if !reflect.DeepEqual(result.Config, want) || checks != 1 {
|
||||||
|
t.Fatalf("config = %#v, checks %d", result.Config, checks)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} {
|
||||||
|
info, ok := result.Metadata().Lookup(configkit.Keys(field))
|
||||||
|
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerBase || info.Null != (field == "optional") {
|
||||||
|
t.Errorf("%s input info = %#v, %v", field, info, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Kind != test.kind || sources[0].ID != "source-1" || sources[0].UnderlayIndex != 0 {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
resource, ok := result.SourceContext("source-1")
|
||||||
|
if !ok || resource.BaseDir != "." || resource.FS == nil {
|
||||||
|
t.Fatalf("source context = %#v, %v", resource, ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
|
||||||
|
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"left.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: shared.yml}]}\n")},
|
||||||
|
"right.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: shared.yml}]}\n")},
|
||||||
|
"shared.yml": &fstest.MapFile{Data: []byte("name: retained\n")},
|
||||||
|
})
|
||||||
|
type config struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
left := configkit.Input{Source: namespace.File("left.yml"), Imports: true}
|
||||||
|
right := configkit.Input{Source: namespace.File("right.yml"), Imports: true}
|
||||||
|
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{left, right}}},
|
||||||
|
})
|
||||||
|
if got := layeredErrorCode(t, err); got != configkit.CodeImportRepeated {
|
||||||
|
t.Fatalf("same-tier repeated import = %s", got)
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{left}}, {Inputs: []configkit.Input{right}}},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Name != "retained" || len(result.Sources()) != 4 {
|
||||||
|
t.Fatalf("cross-tier repeated import = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
reader := configkit.Reader("single-use", strings.NewReader("name: retained\n"))
|
||||||
|
_, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: reader}}}, {Inputs: []configkit.Input{{Source: reader}}}},
|
||||||
|
})
|
||||||
|
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
||||||
|
t.Fatalf("reader re-consumption = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Secret string `yaml:"secret"`
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}},
|
||||||
|
Definition: configkit.Definition[config]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Secret != "new-secret-canary" {
|
||||||
|
t.Fatalf("secret load = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
var effective, history bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := result.WriteExplanation(&history, configkit.Keys("secret")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(effective.String()+history.String(), "old-secret-canary") || strings.Contains(effective.String()+history.String(), "new-secret-canary") {
|
||||||
|
t.Fatalf("secret output leaked: %q %q", effective.String(), history.String())
|
||||||
|
}
|
||||||
|
_, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}},
|
||||||
|
Definition: configkit.Definition[config]{Validate: func(config *config, validation *configkit.Validation) {
|
||||||
|
validation.Error(configkit.Keys("secret"), "invalid secret")
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("validation error = %#v", err)
|
||||||
|
}
|
||||||
|
var diagnostics bytes.Buffer
|
||||||
|
if err := configkit.WriteDiagnostics(&diagnostics, loadError.Diagnostics()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(diagnostics.String(), "secret-canary") {
|
||||||
|
t.Fatalf("diagnostic leaked secret: %q", diagnostics.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func layeredErrorCode(t *testing.T, err error) configkit.Code {
|
||||||
|
t.Helper()
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 {
|
||||||
|
t.Fatalf("load error = %#v", err)
|
||||||
|
}
|
||||||
|
return loadError.Diagnostics()[0].Code
|
||||||
|
}
|
||||||
142
facade_filesystem_test.go
Normal file
142
facade_filesystem_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed testdata/filesystem
|
||||||
|
var filesystemFixtures embed.FS
|
||||||
|
|
||||||
|
type filesystemFacadeConfig struct {
|
||||||
|
Items map[string]string `yaml:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeFilesystemSourceImportsOverlayAndReuse(t *testing.T) {
|
||||||
|
fixture, err := fs.Sub(filesystemFixtures, "testdata/filesystem")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
namespace := configkit.NewFilesystem("embedded", fixture)
|
||||||
|
copyOfNamespace := namespace
|
||||||
|
source := namespace.File("config/main.yml")
|
||||||
|
for range 2 {
|
||||||
|
result, err := configkit.Load(source, configkit.Options[filesystemFacadeConfig]{
|
||||||
|
Imports: true, Overlay: "testing", Filesystem: &configkit.FilesystemContext{FS: ©OfNamespace},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := (map[string]string{"main": "selected", "shared": "default"}); !reflect.DeepEqual(result.Config.Items, want) {
|
||||||
|
t.Fatalf("items = %#v, want %#v", result.Config.Items, want)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
for index, want := range []string{"config/main.yml", "shared.yml", "overlays/testing.yml"} {
|
||||||
|
if sources[index].ID != "source-"+strconv.Itoa(index+1) || sources[index].Path != want || sources[index].Kind != configkit.SourceKindFilesystem {
|
||||||
|
t.Errorf("source %d = %#v", index, sources[index])
|
||||||
|
}
|
||||||
|
if sources[index].Label != "embedded/"+want {
|
||||||
|
t.Errorf("source label = %q", sources[index].Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sources[1].ImportedFrom == nil || sources[1].ImportedFrom.SourceID != sources[0].ID || sources[1].ImportedFrom.Line == 0 {
|
||||||
|
t.Fatalf("import location = %#v", sources[1].ImportedFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeFilesystemContextsAndIdentity(t *testing.T) {
|
||||||
|
first := configkit.NewFilesystem("same", fstest.MapFS{
|
||||||
|
"root/config/main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\n")},
|
||||||
|
"root/shared.yml": &fstest.MapFile{Data: []byte("items: {value: first}\n")},
|
||||||
|
"root/overlays/testing.yml": &fstest.MapFile{Data: []byte("items: {overlay: first}\n")},
|
||||||
|
})
|
||||||
|
second := configkit.NewFilesystem("same", fstest.MapFS{
|
||||||
|
"root/config/main.yml": &fstest.MapFile{Data: []byte("items: {value: second}\n")},
|
||||||
|
})
|
||||||
|
context := &configkit.FilesystemContext{Root: "root", FS: &first}
|
||||||
|
result, err := configkit.Load(first.File("root/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Imports: true, Overlay: "testing", Filesystem: context})
|
||||||
|
if err != nil || result.Config.Items["value"] != "first" || result.Config.Items["overlay"] != "first" {
|
||||||
|
t.Fatalf("contained load = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
result, err = configkit.Load[filesystemFacadeConfig](second.File("root/config/main.yml"))
|
||||||
|
if err != nil || result.Config.Items["value"] != "second" {
|
||||||
|
t.Fatalf("same-label independent namespace = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
checkCode(t, second.File("root/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Filesystem: context}, configkit.CodeOptionsInvalid)
|
||||||
|
checkCode(t, first.File("root/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Filesystem: &configkit.FilesystemContext{Root: "root/config", FS: &first}, Imports: true}, configkit.CodeImportOutsideRoot)
|
||||||
|
checkCode(t, configkit.File("testdata/filesystem/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Filesystem: context}, configkit.CodeOptionsInvalid)
|
||||||
|
|
||||||
|
for _, input := range []struct {
|
||||||
|
name string
|
||||||
|
source configkit.Source
|
||||||
|
kind configkit.SourceKind
|
||||||
|
}{
|
||||||
|
{"bytes", configkit.Bytes("memory", []byte("configkit: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindBytes},
|
||||||
|
{"reader", configkit.Reader("stream", strings.NewReader("configkit: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindReader},
|
||||||
|
} {
|
||||||
|
t.Run(input.name, func(t *testing.T) {
|
||||||
|
result, err := configkit.Load(input.source, configkit.Options[filesystemFacadeConfig]{Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &first}})
|
||||||
|
if err != nil || result.Config.Items["value"] != "first" {
|
||||||
|
t.Fatalf("synthetic import = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if got := result.Sources(); len(got) != 2 || got[0].Kind != input.kind || got[0].Path != "" || got[1].Kind != configkit.SourceKindFilesystem {
|
||||||
|
t.Fatalf("sources = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
checkCode(t, configkit.Bytes("memory", []byte("items: {}\n")), configkit.Options[filesystemFacadeConfig]{Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: ".", FS: &first}}, configkit.CodeImportOutsideRoot)
|
||||||
|
result, err = configkit.Load(configkit.Bytes("memory", []byte("items: {value: main}\n")), configkit.Options[filesystemFacadeConfig]{
|
||||||
|
Overlay: "testing", Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &first},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Items["value"] != "main" || result.Config.Items["overlay"] != "first" {
|
||||||
|
t.Fatalf("synthetic selected overlay = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceKindsIncludeHostAndCLI(t *testing.T) {
|
||||||
|
result, err := configkit.Load(configkit.File("testdata/filesystem/shared.yml"), configkit.Options[filesystemFacadeConfig]{
|
||||||
|
Overrides: []configkit.Override{{Path: configkit.Keys("items", "shared"), Value: "changed", Flag: "--shared"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Kind != configkit.SourceKindFile || sources[0].Path == "" || sources[1].Kind != configkit.SourceKindCLI || sources[1].Path != "" {
|
||||||
|
t.Fatalf("host and CLI sources = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeFilesystemConstructorDiagnostics(t *testing.T) {
|
||||||
|
var nilFilesystem fs.FS
|
||||||
|
var zero configkit.Filesystem
|
||||||
|
for _, source := range []configkit.Source{
|
||||||
|
zero.File("main.yml"),
|
||||||
|
configkit.NewFilesystem("", fstest.MapFS{}).File("main.yml"),
|
||||||
|
configkit.NewFilesystem("bad\nlabel", fstest.MapFS{}).File("main.yml"),
|
||||||
|
configkit.NewFilesystem("good", nilFilesystem).File("main.yml"),
|
||||||
|
configkit.NewFilesystem("good", fstest.MapFS{}).File("."),
|
||||||
|
configkit.NewFilesystem("good", fstest.MapFS{}).File("../outside.yml"),
|
||||||
|
} {
|
||||||
|
checkCode(t, source, configkit.Options[filesystemFacadeConfig]{}, configkit.CodeSourceInvalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkCode(t *testing.T, source configkit.Source, options configkit.Options[filesystemFacadeConfig], want configkit.Code) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := configkit.Load(source, options)
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != want {
|
||||||
|
t.Fatalf("error = %#v, want %s", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
284
facade_inheritance_test.go
Normal file
284
facade_inheritance_test.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/fs"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inheritedEntry struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
Settings map[string]map[string]string `yaml:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inheritedConfiguration struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Entries map[string]inheritedEntry `yaml:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
|
||||||
|
input := []byte(`entries:
|
||||||
|
parent:
|
||||||
|
enabled: false
|
||||||
|
count: 0
|
||||||
|
settings:
|
||||||
|
primary: {value: original}
|
||||||
|
secondary: {extends: primary}
|
||||||
|
child:
|
||||||
|
extends: parent
|
||||||
|
settings:
|
||||||
|
primary: {value: custom}
|
||||||
|
`)
|
||||||
|
defaultRan := false
|
||||||
|
result, err := configkit.Load(configkit.Bytes("config.yml", input), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
|
||||||
|
Resolve: func(config *inheritedConfiguration, resolution *configkit.Resolution) error {
|
||||||
|
if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) {
|
||||||
|
t.Error("inherited false is not explicit")
|
||||||
|
}
|
||||||
|
info, found := resolution.InputInfo(configkit.Keys("entries", "child", "enabled"))
|
||||||
|
if !found || len(info.Origin.Inheritance) != 1 {
|
||||||
|
t.Errorf("resolver inherited origin = %#v, %v", info, found)
|
||||||
|
}
|
||||||
|
child := config.Entries["child"]
|
||||||
|
configkit.Default(resolution, configkit.Keys("entries", "child", "enabled"), &child.Enabled, true, "use fallback")
|
||||||
|
defaultRan = child.Enabled
|
||||||
|
config.Entries["child"] = child
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
child := result.Config.Entries["child"]
|
||||||
|
if defaultRan || child.Enabled || child.Count != 0 || child.Settings["secondary"]["value"] != "custom" {
|
||||||
|
t.Fatalf("inherited values = %#v, default ran %v", child, defaultRan)
|
||||||
|
}
|
||||||
|
path := configkit.Keys("entries", "child", "settings", "secondary", "value")
|
||||||
|
info, found := result.Metadata().Lookup(path)
|
||||||
|
if !found || !info.Explicit || len(info.Origin.Inheritance) != 1 ||
|
||||||
|
info.Origin.Inheritance[0].From.String() != "/entries/child/settings/primary/value" ||
|
||||||
|
info.Origin.Inheritance[0].To.String() != path.String() {
|
||||||
|
t.Fatalf("nested destination origin = %#v, %v", info, found)
|
||||||
|
}
|
||||||
|
info.Origin.Inheritance[0].Locations[0].SourceID = "mutated"
|
||||||
|
info, _ = result.Metadata().Lookup(path)
|
||||||
|
if info.Origin.Inheritance[0].Locations[0].SourceID == "mutated" {
|
||||||
|
t.Fatal("public inheritance record aliases inspection state")
|
||||||
|
}
|
||||||
|
if _, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "extends")); found {
|
||||||
|
t.Fatal("consumed directive remained effective")
|
||||||
|
}
|
||||||
|
control, found := result.Explain(configkit.Keys("entries", "child", "extends"))
|
||||||
|
if !found || control.Effective.Present || len(control.History) < 2 ||
|
||||||
|
control.History[len(control.History)-1].Origin.Operation != configkit.ChangeInheritance {
|
||||||
|
t.Fatalf("consumed directive = %#v, %v", control, found)
|
||||||
|
}
|
||||||
|
explanation, found := result.Explain(path)
|
||||||
|
if !found || explanation.Effective.YAML != "custom\n" {
|
||||||
|
t.Fatalf("nested explanation = %#v, %v", explanation, found)
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Operation == configkit.ChangeDecoding {
|
||||||
|
t.Fatalf("inherited value attributed to decoding: %#v", explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
|
||||||
|
"shared/parent.yml": &fstest.MapFile{Data: []byte("entries: {parent: {resource: icon.txt, count: 1}}\n")},
|
||||||
|
"shared/flags.yml": &fstest.MapFile{Data: []byte("entries: {parent: {enabled: false}}\n")},
|
||||||
|
"shared/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
|
||||||
|
"config/overlays/test.yml": &fstest.MapFile{Data: []byte("entries: {parent: {count: 2}}\n")},
|
||||||
|
})
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: filesystem.File("config/main.yml")}, configkit.LayeredOptions[inheritedConfiguration]{
|
||||||
|
Underlays: []configkit.Underlay{
|
||||||
|
{Inputs: []configkit.Input{{Source: filesystem.File("shared/parent.yml")}}},
|
||||||
|
{Inputs: []configkit.Input{{Source: filesystem.File("shared/flags.yml")}}},
|
||||||
|
},
|
||||||
|
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem},
|
||||||
|
Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "count"), Value: 3, Flag: "--count"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
child := result.Config.Entries["child"]
|
||||||
|
if child.Count != 3 || child.Resource != "icon.txt" {
|
||||||
|
t.Fatalf("layered inheritance = %#v", child)
|
||||||
|
}
|
||||||
|
info, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "resource"))
|
||||||
|
if !found || len(info.Origin.Inheritance) != 1 || len(info.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("resource origin = %#v, %v", info, found)
|
||||||
|
}
|
||||||
|
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !found || context.BaseDir != "shared" || context.FS == nil {
|
||||||
|
t.Fatalf("source context = %#v, %v", context, found)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource)
|
||||||
|
if err != nil || string(data) != "parent-resource" {
|
||||||
|
t.Fatalf("source-relative resource = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
count, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "count"))
|
||||||
|
if !found || count.Origin.Layer != configkit.LayerCLI || len(count.Origin.Inheritance) != 0 {
|
||||||
|
t.Fatalf("CLI replacement origin = %#v, %v", count, found)
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), "extends:") {
|
||||||
|
t.Fatalf("effective rendering = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceMappingMatchesBytes(t *testing.T) {
|
||||||
|
values := map[string]any{"entries": map[string]any{
|
||||||
|
"parent": map[string]any{"enabled": false, "count": 0},
|
||||||
|
"child": map[string]any{"extends": "parent"},
|
||||||
|
}}
|
||||||
|
mapped, err := configkit.Load[inheritedConfiguration](configkit.Mapping("mapping", values))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fromYAML, err := configkit.Load[inheritedConfiguration](configkit.Bytes("config.yml", []byte("entries: {parent: {enabled: false, count: 0}, child: {extends: parent}}\n")))
|
||||||
|
if err != nil || !reflect.DeepEqual(mapped.Config, fromYAML.Config) {
|
||||||
|
t.Fatalf("mapping and bytes differ: %#v, %#v, %v", mapped, fromYAML, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceProtectsVersionOwnership(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, input string
|
||||||
|
path configkit.Path
|
||||||
|
wantFailure bool
|
||||||
|
}{
|
||||||
|
{"inherited version", "entries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("entries", "child", "count"), true},
|
||||||
|
{"overridden version", "entries: {parent: {count: 1}, child: {extends: parent, count: 1}}\n", configkit.Keys("entries", "child", "count"), true},
|
||||||
|
{"direct unchanged version", "entries: {parent: {enabled: false}, child: {extends: parent, count: 1}}\n", configkit.Keys("entries", "child", "count"), false},
|
||||||
|
{"consumed checked directive", "entries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("entries", "child", "extends"), true},
|
||||||
|
{"independent top-level version", "version: v1\nentries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("version"), false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
checks := struct{ version, defaults, resolution, validation int }{}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("version.yml", []byte(test.input)), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
|
||||||
|
Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error {
|
||||||
|
checks.version++
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
Defaults: func() inheritedConfiguration { checks.defaults++; return inheritedConfiguration{} },
|
||||||
|
Resolve: func(*inheritedConfiguration, *configkit.Resolution) error { checks.resolution++; return nil },
|
||||||
|
Validate: func(*inheritedConfiguration, *configkit.Validation) { checks.validation++ },
|
||||||
|
}})
|
||||||
|
if test.wantFailure {
|
||||||
|
if result != nil || err == nil {
|
||||||
|
t.Fatalf("protected version = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeInheritanceVersion ||
|
||||||
|
checks != (struct{ version, defaults, resolution, validation int }{version: 1}) {
|
||||||
|
t.Fatalf("version failure = %v, callbacks %#v", err, checks)
|
||||||
|
}
|
||||||
|
} else if err != nil || result == nil || checks != (struct{ version, defaults, resolution, validation int }{1, 1, 1, 1}) {
|
||||||
|
t.Fatalf("unchanged version = %#v, %v, callbacks %#v", result, err, checks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) {
|
||||||
|
checks := struct{ defaults, resolution, validation int }{}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("invalid.yml", []byte("entries: {child: {extends: PRIVATE-REFERENCE-CANARY}}\n")), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
|
||||||
|
Defaults: func() inheritedConfiguration { checks.defaults++; return inheritedConfiguration{} },
|
||||||
|
Resolve: func(*inheritedConfiguration, *configkit.Resolution) error { checks.resolution++; return nil },
|
||||||
|
Validate: func(*inheritedConfiguration, *configkit.Validation) { checks.validation++ },
|
||||||
|
}})
|
||||||
|
if result != nil || err == nil || checks != (struct{ defaults, resolution, validation int }{}) || strings.Contains(err.Error(), "PRIVATE-REFERENCE-CANARY") {
|
||||||
|
t.Fatalf("invalid inheritance = %#v, %v, callbacks %#v", result, err, checks)
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeInheritanceMissing {
|
||||||
|
t.Fatalf("missing reference diagnostics = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceResolvesAfterImportsAndCLIReferenceChange(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
|
||||||
|
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
|
||||||
|
})
|
||||||
|
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||||
|
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Entries["child"].Count != 2 {
|
||||||
|
t.Fatalf("import and CLI reference = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
|
||||||
|
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
|
||||||
|
t.Fatalf("superseded reference history = %#v", history)
|
||||||
|
}
|
||||||
|
conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")},
|
||||||
|
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\n")},
|
||||||
|
})
|
||||||
|
_, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true})
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||||
|
t.Fatalf("strict conflict before expansion = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceSelectedOverlayCancelsRelationship(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("entries: {parent: {count: 7}, child: {extends: parent, enabled: false}}\n")},
|
||||||
|
"overlays/test.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: null}}\n")},
|
||||||
|
})
|
||||||
|
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||||
|
Overlay: "test", Filesystem: &configkit.FilesystemContext{Root: ".", FS: &filesystem},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Entries["child"].Count != 0 || result.Config.Entries["child"].Enabled {
|
||||||
|
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||||
|
t.Fatalf("cancelled relationship = %#v, %v, diagnostics %#v", result, err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("cancelled relationship = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
|
||||||
|
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
|
||||||
|
t.Fatalf("cancellation history = %#v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritedSecretsStayRedactedAfterLookup(t *testing.T) {
|
||||||
|
const canary = "INHERITED-RELEASE-TEST-CREDENTIAL"
|
||||||
|
t.Setenv("CONFIGKIT_INHERITED_TEST_SECRET", canary)
|
||||||
|
type entry struct {
|
||||||
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
|
Plain string `yaml:"plain"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Entries map[string]entry `yaml:"entries"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[configuration](configkit.Bytes("secret.yml", []byte("entries: {parent: {credential: CONFIGKIT_INHERITED_TEST_SECRET, plain: visible}, child: {extends: parent}}\n")), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||||
|
Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
|
||||||
|
}})
|
||||||
|
if err != nil || result.Config.Entries["child"].Credential.Value() != canary {
|
||||||
|
t.Fatalf("inherited credential = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
path := configkit.Keys("entries", "child", "credential")
|
||||||
|
explanation, found := result.Explain(path)
|
||||||
|
if !found || !explanation.Effective.Redacted || strings.Contains(explanation.Effective.YAML, canary) {
|
||||||
|
t.Fatalf("credential explanation = %#v, %v", explanation, found)
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || !strings.Contains(rendered.String(), "visible") {
|
||||||
|
t.Fatalf("effective secret rendering = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
rendered.Reset()
|
||||||
|
if err := result.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), canary) {
|
||||||
|
t.Fatalf("historical secret rendering = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
243
facade_layered_test.go
Normal file
243
facade_layered_test.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type layeredFacadeConfig struct {
|
||||||
|
Settings map[string]string `yaml:"settings"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
List []string `yaml:"list"`
|
||||||
|
Optional *string `yaml:"optional"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
|
||||||
|
mainPath := filepath.Join(t.TempDir(), "main.yml")
|
||||||
|
if err := os.WriteFile(mainPath, []byte("settings: {shared: main, secret: new-canary}\ncount: 0\nenabled: false\nlist: []\noptional: null\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lower := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: defaults.yml}]}\nsettings: {shared: lower, native: retained, secret: old-canary}\nlist: [lower]\n")},
|
||||||
|
"defaults.yml": &fstest.MapFile{Data: []byte("settings: {imported: retained}\n")},
|
||||||
|
"overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {from_lower_namespace: retained}\n")},
|
||||||
|
})
|
||||||
|
selected := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {shared: overlay}\n")},
|
||||||
|
})
|
||||||
|
checks := struct{ defaults, resolution, validation int }{}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[layeredFacadeConfig]{
|
||||||
|
Underlays: []configkit.Underlay{
|
||||||
|
{Inputs: []configkit.Input{
|
||||||
|
{Source: lower.File("main.yml"), Imports: true},
|
||||||
|
{Source: configkit.Mapping("programmatic-low", map[string]any{"settings": map[string]any{"mapping": "retained"}, "count": 7, "enabled": true})},
|
||||||
|
{Source: lower.File("overlays/testing.yml")},
|
||||||
|
}},
|
||||||
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic-high", map[string]any{
|
||||||
|
"settings": map[string]any{"shared": "middle", "second": "retained"}, "list": []any{"middle"},
|
||||||
|
})}}},
|
||||||
|
},
|
||||||
|
Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &selected},
|
||||||
|
Overrides: []configkit.Override{{Path: configkit.Keys("settings", "shared"), Value: "cli", Flag: "--shared"}},
|
||||||
|
Definition: configkit.Definition[layeredFacadeConfig]{
|
||||||
|
Defaults: func() layeredFacadeConfig { checks.defaults++; return layeredFacadeConfig{} },
|
||||||
|
Resolve: func(config *layeredFacadeConfig, resolution *configkit.Resolution) error {
|
||||||
|
checks.resolution++
|
||||||
|
if source, ok := resolution.Source("source-2"); !ok || source.Layer != configkit.LayerUnderlay || source.UnderlayIndex != 1 || source.Kind != configkit.SourceKindFilesystem {
|
||||||
|
t.Errorf("resolver source = %#v, %v", source, ok)
|
||||||
|
}
|
||||||
|
configkit.Default(resolution, configkit.Keys("derived"), &config.Derived, config.Settings["native"], "derive from native", configkit.Keys("settings", "native"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ },
|
||||||
|
Secret: func(path configkit.Path) bool { return path.String() == "/settings/secret" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("layered load: %v, diagnostics %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
wantSettings := map[string]string{
|
||||||
|
"shared": "cli", "secret": "new-canary", "native": "retained", "imported": "retained",
|
||||||
|
"mapping": "retained", "from_lower_namespace": "retained", "second": "retained",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(result.Config.Settings, wantSettings) || result.Config.Count != 0 || result.Config.Enabled || result.Config.List == nil || len(result.Config.List) != 0 || result.Config.Optional != nil || result.Config.Derived != "retained" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
if checks != (struct{ defaults, resolution, validation int }{1, 1, 1}) {
|
||||||
|
t.Fatalf("lifecycle calls = %#v", checks)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 8 {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
wantLayers := []configkit.Layer{configkit.LayerBase, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI}
|
||||||
|
wantIndexes := []int{0, 1, 1, 1, 1, 2, 0, 0}
|
||||||
|
wantKinds := []configkit.SourceKind{configkit.SourceKindFile, configkit.SourceKindFilesystem, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindCLI}
|
||||||
|
for index, source := range sources {
|
||||||
|
if source.ID != "source-"+strconv.Itoa(index+1) || source.Layer != wantLayers[index] || source.UnderlayIndex != wantIndexes[index] || source.Kind != wantKinds[index] {
|
||||||
|
t.Errorf("source %d = %#v", index, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sources[4].Path != sources[6].Path || sources[4].Label != sources[6].Label {
|
||||||
|
t.Fatalf("namespace-collision fixture changed: %#v, %#v", sources[4], sources[6])
|
||||||
|
}
|
||||||
|
sources[1].Label = "changed"
|
||||||
|
if result.Sources()[1].Label != "bundle/main.yml" {
|
||||||
|
t.Fatal("source records alias retained metadata")
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
path string
|
||||||
|
want configkit.Layer
|
||||||
|
}{
|
||||||
|
{"/settings/native", configkit.LayerUnderlay},
|
||||||
|
{"/settings/shared", configkit.LayerCLI},
|
||||||
|
{"/derived", configkit.LayerResolution},
|
||||||
|
} {
|
||||||
|
path, err := configkit.ParsePath(test.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, ok := result.Metadata().Lookup(path)
|
||||||
|
if !ok || info.Origin.Layer != test.want {
|
||||||
|
t.Errorf("%s metadata = %#v, %v", test.path, info, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container, ok := result.Metadata().Lookup(configkit.Keys("settings"))
|
||||||
|
if !ok || len(container.Origin.Locations) < 4 {
|
||||||
|
t.Fatalf("composite mapping origin = %#v, %v", container, ok)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(configkit.Keys("settings", "shared"))
|
||||||
|
if !ok || len(explanation.History) < 5 || explanation.Origin.Layer != configkit.LayerCLI {
|
||||||
|
t.Fatalf("shared history = %#v, %v", explanation, ok)
|
||||||
|
}
|
||||||
|
var effective, rendered bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := result.WriteExplanation(&rendered, configkit.Keys("settings", "secret")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(effective.String()+rendered.String(), "old-canary") || strings.Contains(effective.String()+rendered.String(), "new-canary") || !strings.Contains(rendered.String(), "redacted") {
|
||||||
|
t.Fatalf("secret rendering leaked: effective %q, explanation %q", effective.String(), rendered.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeLayeredDisplacedDescendantRemainsInspectable(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Choice string `yaml:"choice"`
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{"choice": "flat"})}, configkit.LayeredOptions[config]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{
|
||||||
|
"choice": map[string]any{"nested": "historical"},
|
||||||
|
})}}}},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Choice != "flat" {
|
||||||
|
t.Fatalf("replacement load = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
path := configkit.Keys("choice", "nested")
|
||||||
|
explanation, ok := result.Explain(path)
|
||||||
|
if !ok || explanation.Effective.Present || len(explanation.History) == 0 || explanation.History[0].Origin.Layer != configkit.LayerUnderlay {
|
||||||
|
t.Fatalf("deleted descendant = %#v, %v", explanation, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeLayeredZeroUnderlaysMatchesOrdinaryLoad(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"overlays/testing.yml": &fstest.MapFile{Data: []byte("value: selected\n")},
|
||||||
|
})
|
||||||
|
context := &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}
|
||||||
|
source := configkit.Bytes("main", []byte("value: base\n"))
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
ordinary, err := configkit.Load(source, configkit.Options[config]{Overlay: "testing", Filesystem: context})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
layered, err := configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[config]{Overlay: "testing", OverlayFilesystem: context})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ordinary.Config != layered.Config || !reflect.DeepEqual(ordinary.Sources(), layered.Sources()) {
|
||||||
|
t.Fatalf("ordinary = %#v, %#v; layered = %#v, %#v", ordinary.Config, ordinary.Sources(), layered.Config, layered.Sources())
|
||||||
|
}
|
||||||
|
var first, second bytes.Buffer
|
||||||
|
if err := ordinary.WriteEffective(&first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := layered.WriteEffective(&second); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.String() != second.String() {
|
||||||
|
t.Fatalf("effective ordinary %q, layered %q", first.String(), second.String())
|
||||||
|
}
|
||||||
|
if ordinaryExplanation, ok := ordinary.Explain(configkit.Keys("value")); !ok {
|
||||||
|
t.Fatal("ordinary explanation missing")
|
||||||
|
} else if layeredExplanation, ok := layered.Explain(configkit.Keys("value")); !ok || !reflect.DeepEqual(ordinaryExplanation, layeredExplanation) {
|
||||||
|
t.Fatalf("explanations differ: %#v, %#v", ordinaryExplanation, layeredExplanation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeLayeredOptionAndVersionFailures(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"overlays/testing.yml": &fstest.MapFile{Data: []byte("{}\n")},
|
||||||
|
})
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
main configkit.Input
|
||||||
|
options []configkit.LayeredOptions[config]
|
||||||
|
want configkit.Code
|
||||||
|
}{
|
||||||
|
{"zero source", configkit.Input{}, nil, configkit.CodeSourceInvalid},
|
||||||
|
{"multiple options", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{}, {}}, configkit.CodeOptionsInvalid},
|
||||||
|
{"missing overlay context", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{Overlay: "testing"}}, configkit.CodeOptionsInvalid},
|
||||||
|
{"selected file already acquired", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{
|
||||||
|
Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &namespace},
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: namespace.File("overlays/testing.yml")}}}},
|
||||||
|
}}, configkit.CodeOverlayRepeated},
|
||||||
|
{"underlay version", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{
|
||||||
|
Definition: configkit.Definition[config]{Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}},
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"version": "forbidden"})}}}},
|
||||||
|
}}, configkit.CodeUnderlayVersion},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := configkit.LoadLayered(test.main, test.options...)
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.want {
|
||||||
|
t.Fatalf("error = %#v, want %s", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeLayeredExplicitHostOverlayWithoutMain(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "overlays"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "overlays", "testing.yml"), []byte("value: selected\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
|
||||||
|
Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: root, BaseDir: root},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Value != "selected" || len(result.Sources()) != 1 || result.Sources()[0].Kind != configkit.SourceKindFile {
|
||||||
|
t.Fatalf("explicit host overlay = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
facade_mapping_test.go
Normal file
133
facade_mapping_test.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mappingFacadeConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Count uint64 `yaml:"count"`
|
||||||
|
Optional *string `yaml:"optional"`
|
||||||
|
Items []string `yaml:"items"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeMappingSnapshotsAndMatchesYAML(t *testing.T) {
|
||||||
|
items := []any{"one", "two"}
|
||||||
|
values := map[string]any{
|
||||||
|
"name": "original", "enabled": false, "count": ^uint64(0),
|
||||||
|
"optional": nil, "items": items,
|
||||||
|
}
|
||||||
|
source := configkit.Mapping("programmatic", values)
|
||||||
|
values["name"] = "mutated"
|
||||||
|
items[0] = "mutated"
|
||||||
|
for range 2 {
|
||||||
|
result, err := configkit.Load[mappingFacadeConfig](source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := mappingFacadeConfig{Count: ^uint64(0), Items: []string{"one", "two"}, Name: "original"}
|
||||||
|
if !reflect.DeepEqual(result.Config, want) {
|
||||||
|
t.Fatalf("mapping config = %#v, want %#v", result.Config, want)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerBase || sources[0].Path != "" {
|
||||||
|
t.Fatalf("mapping sources = %#v", sources)
|
||||||
|
}
|
||||||
|
info, ok := result.Metadata().Lookup(configkit.Keys("name"))
|
||||||
|
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != "source-1" || info.Origin.Locations[0].Line != 0 {
|
||||||
|
t.Fatalf("mapping name origin = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
fromYAML, err := configkit.Load[mappingFacadeConfig](configkit.Bytes("equivalent", []byte("enabled: false\ncount: 18446744073709551615\noptional: null\nitems: [one, two]\nname: original\n")))
|
||||||
|
if err != nil || !reflect.DeepEqual(result.Config, fromYAML.Config) {
|
||||||
|
t.Fatalf("equivalent YAML = %#v, %v", fromYAML, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeMappingNullEmptyAndVersion(t *testing.T) {
|
||||||
|
result, err := configkit.Load[mappingFacadeConfig](configkit.Mapping("empty", map[string]any{}))
|
||||||
|
if err != nil || len(result.Sources()) != 1 {
|
||||||
|
t.Fatalf("empty mapping = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
var nilSequence []any
|
||||||
|
var nilMap map[string]any
|
||||||
|
var checked string
|
||||||
|
result, err = configkit.Load(configkit.Mapping("nullable", map[string]any{
|
||||||
|
"items": nilSequence, "optional": nilMap, "enabled": false, "count": uint64(0),
|
||||||
|
}), configkit.Options[mappingFacadeConfig]{Definition: configkit.Definition[mappingFacadeConfig]{
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("count"), Check: func(version configkit.Version, validation *configkit.Validation) error {
|
||||||
|
checked = version.Text
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
}})
|
||||||
|
if err != nil || checked != "0" || result.Config.Items != nil || result.Config.Optional != nil {
|
||||||
|
t.Fatalf("null and zero mapping = %#v, checked %q, %v", result, checked, err)
|
||||||
|
}
|
||||||
|
info, ok := result.Metadata().Lookup(configkit.Keys("items"))
|
||||||
|
if !ok || !info.Null || !info.Explicit {
|
||||||
|
t.Fatalf("null metadata = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeMappingImportsUseExplicitContext(t *testing.T) {
|
||||||
|
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"root/shared.yml": &fstest.MapFile{Data: []byte("name: imported\n")},
|
||||||
|
})
|
||||||
|
source := configkit.Mapping("programmatic", map[string]any{
|
||||||
|
"configkit": map[string]any{"imports": []any{map[string]any{"path": "shared.yml"}}},
|
||||||
|
"enabled": true,
|
||||||
|
})
|
||||||
|
result, err := configkit.Load(source, configkit.Options[mappingFacadeConfig]{
|
||||||
|
Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &namespace},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Name != "imported" || !result.Config.Enabled {
|
||||||
|
t.Fatalf("mapping import = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerBase {
|
||||||
|
t.Fatalf("mapping import sources = %#v", sources)
|
||||||
|
}
|
||||||
|
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid)
|
||||||
|
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{}, configkit.CodeImportDisabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeMappingDeferredValueDiagnostics(t *testing.T) {
|
||||||
|
cycle := map[string]any{}
|
||||||
|
cycle["secret"] = cycle
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
source configkit.Source
|
||||||
|
want configkit.Code
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{"nil root", configkit.Mapping("nil", nil), configkit.CodeSourceInvalid, ""},
|
||||||
|
{"bad label", configkit.Mapping("bad\nlabel", map[string]any{}), configkit.CodeSourceInvalid, ""},
|
||||||
|
{"unsupported", configkit.Mapping("bad", map[string]any{"secret": struct{ Value string }{"canary-value"}}), configkit.CodeSourceInvalid, "/secret"},
|
||||||
|
{"nonfinite", configkit.Mapping("bad", map[string]any{"count": math.NaN()}), configkit.CodeSourceInvalid, "/count"},
|
||||||
|
{"cycle", configkit.Mapping("bad", cycle), configkit.CodeSourceInvalid, "/secret"},
|
||||||
|
{"too many bytes", configkit.Mapping("bad", map[string]any{"name": strings.Repeat("x", 8<<20+1)}), configkit.CodeSourceLimitExceeded, "/name"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
loadError := checkMappingCode(t, test.source, configkit.Options[mappingFacadeConfig]{}, test.want)
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Path.String() != test.path || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || strings.Contains(diagnostic.Message, "canary-value") {
|
||||||
|
t.Fatalf("diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMappingCode(t *testing.T, source configkit.Source, options configkit.Options[mappingFacadeConfig], want configkit.Code) *configkit.LoadError {
|
||||||
|
t.Helper()
|
||||||
|
_, err := configkit.Load(source, options)
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != want {
|
||||||
|
t.Fatalf("error = %#v, want %s", err, want)
|
||||||
|
}
|
||||||
|
return loadError
|
||||||
|
}
|
||||||
221
facade_resource_context_test.go
Normal file
221
facade_resource_context_test.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type observedResourceFS struct {
|
||||||
|
files fstest.MapFS
|
||||||
|
opens int
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *observedResourceFS) Open(name string) (fs.File, error) {
|
||||||
|
f.opens++
|
||||||
|
if f.closed {
|
||||||
|
return nil, fs.ErrClosed
|
||||||
|
}
|
||||||
|
return f.files.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
type resourceFacadeConfig struct {
|
||||||
|
Native string `yaml:"native"`
|
||||||
|
Shared string `yaml:"shared"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Selected string `yaml:"selected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceContextsFollowOriginsWithoutQueryIO(t *testing.T) {
|
||||||
|
host := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(host, "main.yml"), []byte("host: host.txt\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(host, "host.txt"), []byte("host-v1"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
borrowed := &observedResourceFS{files: fstest.MapFS{
|
||||||
|
"bundle/config/defaults.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\nnative: icon.txt\n")},
|
||||||
|
"bundle/shared.yml": &fstest.MapFile{Data: []byte("shared: shared.txt\n")},
|
||||||
|
"bundle/overlays/test.yml": &fstest.MapFile{Data: []byte("selected: overlay.txt\n")},
|
||||||
|
"bundle/config/icon.txt": &fstest.MapFile{Data: []byte("icon-v1")},
|
||||||
|
"bundle/shared.txt": &fstest.MapFile{Data: []byte("shared-v1")},
|
||||||
|
"bundle/overlays/overlay.txt": &fstest.MapFile{Data: []byte("overlay-v1")},
|
||||||
|
}}
|
||||||
|
namespace := configkit.NewFilesystem("bundle", borrowed)
|
||||||
|
var resolverContext configkit.ResourceContext
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(host, "main.yml"))}, configkit.LayeredOptions[resourceFacadeConfig]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: namespace.File("bundle/config/defaults.yml"), Imports: true,
|
||||||
|
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
|
||||||
|
}}}},
|
||||||
|
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: "bundle", BaseDir: "bundle", FS: &namespace},
|
||||||
|
Definition: configkit.Definition[resourceFacadeConfig]{Resolve: func(config *resourceFacadeConfig, resolution *configkit.Resolution) error {
|
||||||
|
before := borrowed.opens
|
||||||
|
var ok bool
|
||||||
|
resolverContext, ok = resolution.SourceContext("source-2")
|
||||||
|
if !ok || resolverContext.BaseDir != "config" || resolverContext.FS == nil {
|
||||||
|
t.Errorf("resolver context = %#v, %v", resolverContext, ok)
|
||||||
|
}
|
||||||
|
if _, ok := resolution.SourceContext("missing"); ok || borrowed.opens != before {
|
||||||
|
t.Error("resolution context query performed I/O or guessed an unknown source")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v, diagnostics %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
queriesBefore := borrowed.opens
|
||||||
|
for _, test := range []struct {
|
||||||
|
field, wantBase, wantData string
|
||||||
|
}{
|
||||||
|
{"native", "config", "icon-v1"},
|
||||||
|
{"shared", ".", "shared-v1"},
|
||||||
|
{"selected", "overlays", "overlay-v1"},
|
||||||
|
{"host", ".", "host-v1"},
|
||||||
|
} {
|
||||||
|
origin, ok := result.Metadata().Lookup(configkit.Keys(test.field))
|
||||||
|
if !ok || len(origin.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("%s origin = %#v, %v", test.field, origin, ok)
|
||||||
|
}
|
||||||
|
context, ok := result.SourceContext(origin.Origin.Locations[0].SourceID)
|
||||||
|
if !ok || context.FS == nil || context.BaseDir != test.wantBase {
|
||||||
|
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
|
||||||
|
}
|
||||||
|
if borrowed.opens != queriesBefore {
|
||||||
|
t.Fatal("result SourceContext query performed filesystem I/O")
|
||||||
|
}
|
||||||
|
resourceName := path.Join(context.BaseDir, map[string]string{
|
||||||
|
"native": result.Config.Native, "shared": result.Config.Shared,
|
||||||
|
"selected": result.Config.Selected, "host": result.Config.Host,
|
||||||
|
}[test.field])
|
||||||
|
data, err := fs.ReadFile(context.FS, resourceName)
|
||||||
|
if err != nil || string(data) != test.wantData {
|
||||||
|
t.Fatalf("%s resource = %q, %v", test.field, data, err)
|
||||||
|
}
|
||||||
|
queriesBefore = borrowed.opens
|
||||||
|
}
|
||||||
|
if _, ok := result.SourceContext(""); ok {
|
||||||
|
t.Fatal("empty source ID returned a context")
|
||||||
|
}
|
||||||
|
var nilResult *configkit.Result[resourceFacadeConfig]
|
||||||
|
if _, ok := nilResult.SourceContext("source-2"); ok {
|
||||||
|
t.Fatal("nil result returned a context")
|
||||||
|
}
|
||||||
|
if _, err := fs.ReadFile(resolverContext.FS, "../shared.txt"); !errors.Is(err, fs.ErrInvalid) {
|
||||||
|
t.Fatalf("native root escape = %v", err)
|
||||||
|
}
|
||||||
|
borrowed.files["bundle/config/icon.txt"].Data = []byte("icon-v2")
|
||||||
|
data, err := fs.ReadFile(resolverContext.FS, "config/icon.txt")
|
||||||
|
if err != nil || string(data) != "icon-v2" {
|
||||||
|
t.Fatalf("updated native resource = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
borrowed.closed = true
|
||||||
|
if result.Config.Native != "icon.txt" {
|
||||||
|
t.Fatal("closing caller filesystem altered loaded configuration")
|
||||||
|
}
|
||||||
|
if _, err := fs.ReadFile(resolverContext.FS, "config/icon.txt"); !errors.Is(err, fs.ErrClosed) {
|
||||||
|
t.Fatalf("closed caller filesystem error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeHostContextSurvivesConfigurationDeletion(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
main := filepath.Join(root, "main.yml")
|
||||||
|
resource := filepath.Join(root, "asset.txt")
|
||||||
|
if err := os.WriteFile(main, []byte("native: asset.txt\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(resource, []byte("first"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[resourceFacadeConfig](configkit.File(main))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
context, ok := result.SourceContext("source-1")
|
||||||
|
if !ok || context.BaseDir != "." {
|
||||||
|
t.Fatalf("host context = %#v, %v", context, ok)
|
||||||
|
}
|
||||||
|
if err := os.Remove(main); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(resource, []byte("second"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Native))
|
||||||
|
if err != nil || string(data) != "second" || result.Config.Native != "asset.txt" {
|
||||||
|
t.Fatalf("resource after source deletion = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
if _, err := fs.ReadFile(context.FS, "../asset.txt"); !errors.Is(err, fs.ErrInvalid) {
|
||||||
|
t.Fatalf("invalid host path = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(resource); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := fs.ReadFile(context.FS, "asset.txt"); !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Fatalf("missing later resource = %v", err)
|
||||||
|
}
|
||||||
|
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||||
|
if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outside, filepath.Join(root, "escape")); err == nil {
|
||||||
|
if _, err := fs.ReadFile(context.FS, "escape"); !errors.Is(err, fs.ErrPermission) {
|
||||||
|
t.Fatalf("host symlink escape = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
|
||||||
|
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"bundle/config/icon.txt": &fstest.MapFile{Data: []byte("icon")},
|
||||||
|
})
|
||||||
|
context := &configkit.FilesystemContext{Root: "bundle", BaseDir: "bundle/config", FS: &namespace}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
source configkit.Source
|
||||||
|
}{
|
||||||
|
{"bytes", configkit.Bytes("memory", []byte("native: icon.txt\n"))},
|
||||||
|
{"reader", configkit.Reader("stream", strings.NewReader("native: icon.txt\n"))},
|
||||||
|
{"mapping", configkit.Mapping("values", map[string]any{"native": "icon.txt"})},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := configkit.Load(test.source, configkit.Options[resourceFacadeConfig]{Filesystem: context})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resource, ok := result.SourceContext("source-1")
|
||||||
|
if !ok || resource.BaseDir != "config" {
|
||||||
|
t.Fatalf("synthetic context = %#v, %v", resource, ok)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, result.Config.Native))
|
||||||
|
if err != nil || string(data) != "icon" {
|
||||||
|
t.Fatalf("synthetic resource = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[resourceFacadeConfig](configkit.Bytes("plain", []byte("native: none\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := result.SourceContext("source-1"); ok {
|
||||||
|
t.Fatal("contextless byte source returned a context")
|
||||||
|
}
|
||||||
|
result, err = configkit.Load(configkit.NoSource(), configkit.Options[resourceFacadeConfig]{Overrides: []configkit.Override{{Path: configkit.Keys("native"), Value: "from-cli", Flag: "--native"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := result.SourceContext("source-1"); ok {
|
||||||
|
t.Fatal("CLI source returned a context")
|
||||||
|
}
|
||||||
|
}
|
||||||
107
facade_underlay_origins_test.go
Normal file
107
facade_underlay_origins_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
|
||||||
|
for _, later := range []bool{false, true} {
|
||||||
|
name := "first tier"
|
||||||
|
if later {
|
||||||
|
name = "later tier"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
bundle := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: items.yml}]}\n")},
|
||||||
|
"items.yml": &fstest.MapFile{Data: []byte("items: [{name: first, nested: [false]}]\n")},
|
||||||
|
})
|
||||||
|
tiers := []configkit.Underlay{}
|
||||||
|
if later {
|
||||||
|
tiers = append(tiers, configkit.Underlay{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"other": true})}}})
|
||||||
|
}
|
||||||
|
tiers = append(tiers, configkit.Underlay{Inputs: []configkit.Input{{Source: bundle.File("main.yml"), Imports: true}}})
|
||||||
|
paths := []string{"/items", "/items/0", "/items/0/name", "/items/0/nested", "/items/0/nested/0"}
|
||||||
|
entry := map[string]configkit.Origin{}
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{"main": true})}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Underlays: tiers,
|
||||||
|
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
for _, pointer := range paths {
|
||||||
|
path, _ := configkit.ParsePath(pointer)
|
||||||
|
info, ok := resolution.InputInfo(path)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("missing resolver input %s", pointer)
|
||||||
|
}
|
||||||
|
entry[pointer] = info.Origin
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sourceID string
|
||||||
|
for _, source := range result.Sources() {
|
||||||
|
if source.Path == "items.yml" {
|
||||||
|
sourceID = source.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sourceID == "" {
|
||||||
|
t.Fatal("missing imported source")
|
||||||
|
}
|
||||||
|
for _, pointer := range paths {
|
||||||
|
path, _ := configkit.ParsePath(pointer)
|
||||||
|
info, ok := result.Metadata().Lookup(path)
|
||||||
|
if !ok || info.Origin.Layer != configkit.LayerUnderlay || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != sourceID {
|
||||||
|
t.Errorf("%s origin = %+v", pointer, info.Origin)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(entry[pointer], info.Origin) {
|
||||||
|
t.Errorf("%s resolver origin differs", pointer)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(path)
|
||||||
|
if !ok || !reflect.DeepEqual(explanation.Origin, info.Origin) {
|
||||||
|
t.Errorf("%s explanation origin differs", pointer)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(path)
|
||||||
|
if len(history) == 0 || !reflect.DeepEqual(history[len(history)-1].Origin, info.Origin) {
|
||||||
|
t.Errorf("%s history = %+v", pointer, history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnderlayHistoryRetainsLaterMappingContributors(t *testing.T) {
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{
|
||||||
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("one", map[string]any{"x": 1})}}},
|
||||||
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("two", map[string]any{"x": map[string]any{}})}}},
|
||||||
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("three", map[string]any{"x": map[string]any{"y": 2}})}}},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := configkit.Keys("x")
|
||||||
|
info, _ := result.Metadata().Lookup(path)
|
||||||
|
wantOrigin := configkit.Origin{Layer: configkit.LayerUnderlay, Locations: []configkit.Location{{SourceID: "source-2"}, {SourceID: "source-3"}}}
|
||||||
|
if !reflect.DeepEqual(info.Origin, wantOrigin) {
|
||||||
|
t.Fatalf("effective origin = %+v, want %+v", info.Origin, wantOrigin)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(path)
|
||||||
|
if len(history) != 3 || !reflect.DeepEqual(history[len(history)-1].Origin, info.Origin) {
|
||||||
|
t.Fatalf("history = %+v, effective origin = %+v", history, info.Origin)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(path)
|
||||||
|
if !ok || !reflect.DeepEqual(explanation.Origin, info.Origin) {
|
||||||
|
t.Fatal("explanation and metadata disagree")
|
||||||
|
}
|
||||||
|
if len(explanation.History) != 3 {
|
||||||
|
t.Fatalf("explanation history = %+v", explanation.History)
|
||||||
|
}
|
||||||
|
last := explanation.History[len(explanation.History)-1].Origin
|
||||||
|
if last.Layer != configkit.LayerUnderlay || len(last.Locations) != 1 || last.Locations[0].SourceID != "source-3" {
|
||||||
|
t.Fatalf("last explanation origin = %+v", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
go.mod
10
go.mod
@@ -1,5 +1,13 @@
|
|||||||
module gitea.maximumdirect.net/eric/ConfigKit
|
module gitea.maximumdirect.net/eric/ConfigKit
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
||||||
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -1,2 +1,8 @@
|
|||||||
|
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
||||||
|
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||||
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
|
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ type Explanation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Explain returns safe effective and chronological values for a known final or
|
// Explain returns safe effective and chronological values for a known final or
|
||||||
// historical path. Unknown and malformed paths return false.
|
// historical path. Structural inheritance appears between source applications
|
||||||
|
// and typed reconciliation. Unknown and malformed paths return false.
|
||||||
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return Explanation{}, false
|
return Explanation{}, false
|
||||||
|
|||||||
119
internal/engine/assembly_bench_test.go
Normal file
119
internal/engine/assembly_bench_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BenchmarkComposeWideMappings(b *testing.B) {
|
||||||
|
for _, width := range []int{128, 256, 512} {
|
||||||
|
b.Run(strconv.Itoa(width), func(b *testing.B) {
|
||||||
|
left := benchmarkCatalog("source-1", 0, width)
|
||||||
|
right := benchmarkCatalog("source-2", width, width)
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for range b.N {
|
||||||
|
if _, diagnostics := composeDocuments(left, right); len(diagnostics) != 0 {
|
||||||
|
b.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkOverlayRecursiveMappings(b *testing.B) {
|
||||||
|
for _, width := range []int{128, 256, 512} {
|
||||||
|
b.Run(strconv.Itoa(width), func(b *testing.B) {
|
||||||
|
base, diagnostics := composeDocuments(benchmarkCatalog("source-1", 0, width))
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
b.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
overlay := benchmarkCatalog("source-2", width/2, width)
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for range b.N {
|
||||||
|
if _, diagnostics := overlayDocuments(base, overlay); len(diagnostics) != 0 {
|
||||||
|
b.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkOverlayManyApplications(b *testing.B) {
|
||||||
|
for _, count := range []int{32, 64, 128} {
|
||||||
|
inputs := make([]*document.Node, count)
|
||||||
|
for index := range inputs {
|
||||||
|
inputs[index] = benchmarkCatalog("source-2", index, 1)
|
||||||
|
}
|
||||||
|
benchmarkOverlayApplications(b, count, inputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkOverlayRepeatedReplacement(b *testing.B) {
|
||||||
|
for _, count := range []int{32, 64, 128} {
|
||||||
|
inputs := make([]*document.Node, count)
|
||||||
|
for index := range inputs {
|
||||||
|
location := document.Location{SourceID: "source-2"}
|
||||||
|
value := &document.Node{Kind: document.String, Text: "replacement", Location: location}
|
||||||
|
if index%2 == 0 {
|
||||||
|
value = &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
|
||||||
|
Key: "enabled", Value: &document.Node{Kind: document.Boolean, Text: "false", Location: location},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
inputs[index] = &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
|
||||||
|
Key: "catalog", Value: &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
|
||||||
|
Key: "item-0", Value: value,
|
||||||
|
}}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
benchmarkOverlayApplications(b, count, inputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchmarkOverlayApplications(b *testing.B, count int, inputs []*document.Node) {
|
||||||
|
base, diagnostics := composeDocuments(benchmarkCatalog("source-1", 0, 1))
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
b.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
for _, mode := range []string{"owned", "copy-preserving"} {
|
||||||
|
b.Run(mode+"/"+strconv.Itoa(count), func(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for range b.N {
|
||||||
|
if mode == "owned" {
|
||||||
|
assembly := newAssemblyAccumulator(cloneComposedDocument(base))
|
||||||
|
for _, input := range inputs {
|
||||||
|
if diagnostics := assembly.applyOverlay(input, nil, nil, LayerOverlay); len(diagnostics) != 0 {
|
||||||
|
b.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current := base
|
||||||
|
for _, input := range inputs {
|
||||||
|
current, diagnostics = overlayDocuments(current, input)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
b.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchmarkCatalog(source string, first, width int) *document.Node {
|
||||||
|
location := document.Location{SourceID: source}
|
||||||
|
entries := make([]document.Entry, width)
|
||||||
|
for index := range entries {
|
||||||
|
entries[index] = document.Entry{
|
||||||
|
Key: "item-" + strconv.Itoa(first+index),
|
||||||
|
Value: &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{Key: "enabled", Value: &document.Node{Kind: document.Boolean, Text: "true", Location: location}}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
|
||||||
|
Key: "catalog", Value: &document.Node{Kind: document.Mapping, Location: location, Mapping: entries},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
@@ -15,6 +15,17 @@ type composedDocument struct {
|
|||||||
inputNodes int
|
inputNodes int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assemblyAccumulator owns one mutable working document. Acquired trees held by
|
||||||
|
// history applications are separate from its root and remain unchanged.
|
||||||
|
type assemblyAccumulator struct {
|
||||||
|
current *composedDocument
|
||||||
|
index mappingIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
|
||||||
|
return &assemblyAccumulator{current: owned}
|
||||||
|
}
|
||||||
|
|
||||||
// cloneComposedDocument detaches mutable composition and provenance state;
|
// cloneComposedDocument detaches mutable composition and provenance state;
|
||||||
// history application trees are immutable and retain their existing sharing.
|
// history application trees are immutable and retain their existing sharing.
|
||||||
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||||
@@ -58,8 +69,14 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
|||||||
collected.append(input, Path{})
|
collected.append(input, Path{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checked := make(map[string]struct{}, len(collected.byPath))
|
||||||
for _, claim := range collected.ordered {
|
for _, claim := range collected.ordered {
|
||||||
claims := collected.byPath[claim.path.String()]
|
pointer := claim.path.String()
|
||||||
|
if _, seen := checked[pointer]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
checked[pointer] = struct{}{}
|
||||||
|
claims := collected.byPath[pointer]
|
||||||
if len(claims) < 2 || allMappingClaims(claims) {
|
if len(claims) < 2 || allMappingClaims(claims) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -67,21 +84,43 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
|||||||
}
|
}
|
||||||
|
|
||||||
root := document.Clone(inputs[0])
|
root := document.Clone(inputs[0])
|
||||||
|
index := new(mappingIndex)
|
||||||
for _, input := range inputs[1:] {
|
for _, input := range inputs[1:] {
|
||||||
mergeDisjointMappings(root, input)
|
mergeDisjointMappings(root, input, index)
|
||||||
}
|
}
|
||||||
applications := make([]historyApplication, 0, len(inputs))
|
applications := make([]historyApplication, 0, len(inputs))
|
||||||
for _, input := range inputs {
|
for _, input := range inputs {
|
||||||
applications = append(applications, historyApplication{layer: LayerBase, value: input})
|
applications = append(applications, historyApplication{layer: LayerBase, value: input})
|
||||||
}
|
}
|
||||||
|
origins := compositionOrigins(collected.ordered)
|
||||||
|
completeCompositionOrigins(root, Path{}, origins)
|
||||||
return &composedDocument{
|
return &composedDocument{
|
||||||
root: root,
|
root: root,
|
||||||
claims: cloneCompositionClaims(collected.ordered),
|
claims: cloneCompositionClaims(collected.ordered),
|
||||||
origins: compositionOrigins(collected.ordered),
|
origins: origins,
|
||||||
applications: applications,
|
applications: applications,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conflict claims treat sequences atomically, but inspection needs origins for
|
||||||
|
// every descendant. Preserve composite mapping claims while filling those gaps.
|
||||||
|
func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) {
|
||||||
|
pointer := path.String()
|
||||||
|
if _, present := origins[pointer]; !present {
|
||||||
|
origins[pointer] = Origin{Layer: LayerBase, Locations: []Location{publicLocation(node.Location)}}
|
||||||
|
}
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
completeCompositionOrigins(entry.Value, path.Key(entry.Key), origins)
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
completeCompositionOrigins(child, path.Index(index), origins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *compositionClaims) append(node *document.Node, path Path) {
|
func (c *compositionClaims) append(node *document.Node, path Path) {
|
||||||
claim := compositionClaim{
|
claim := compositionClaim{
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
@@ -109,18 +148,18 @@ func allMappingClaims(claims []compositionClaim) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeDisjointMappings(target, incoming *document.Node) {
|
func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
|
||||||
for _, incomingEntry := range incoming.Mapping {
|
for _, incomingEntry := range incoming.Mapping {
|
||||||
index := documentEntryIndex(target.Mapping, incomingEntry.Key)
|
position := index.find(target, incomingEntry.Key)
|
||||||
if index < 0 {
|
if position < 0 {
|
||||||
cloned := incomingEntry
|
cloned := incomingEntry
|
||||||
cloned.Value = document.Clone(incomingEntry.Value)
|
cloned.Value = document.Clone(incomingEntry.Value)
|
||||||
target.Mapping = append(target.Mapping, cloned)
|
index.append(target, cloned)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Claim validation guarantees that the only remaining overlap is a
|
// Claim validation guarantees that the only remaining overlap is a
|
||||||
// mapping pair. Empty mappings naturally contribute no child entries.
|
// mapping pair. Empty mappings naturally contribute no child entries.
|
||||||
mergeDisjointMappings(target.Mapping[index].Value, incomingEntry.Value)
|
mergeDisjointMappings(target.Mapping[position].Value, incomingEntry.Value, index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,31 @@ func TestComposeDocumentsReportsAllClaimantsAtExactLocations(t *testing.T) {
|
|||||||
assertCompositionConflict(t, diagnostics, "/group", []string{"source-1", "source-2", "source-3"})
|
assertCompositionConflict(t, diagnostics, "/group", []string{"source-1", "source-2", "source-3"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsKeepsFirstConflictAndAppendedEntryOrder(t *testing.T) {
|
||||||
|
first := parseCompositionDocument(t, "source-1", "catalog:\n first: 1\n shared: one\n later: one\n")
|
||||||
|
second := parseCompositionDocument(t, "source-2", "catalog:\n second: 2\n shared: two\n later: two\n")
|
||||||
|
third := parseCompositionDocument(t, "source-3", "catalog:\n third: 3\n shared: three\n")
|
||||||
|
_, diagnostics := composeDocuments(first, second, third)
|
||||||
|
assertCompositionConflict(t, diagnostics, "/catalog/shared", []string{"source-1", "source-2", "source-3"})
|
||||||
|
|
||||||
|
composed, diagnostics := composeDocuments(
|
||||||
|
parseCompositionDocument(t, "source-1", "catalog:\n first: 1\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "catalog:\n second: 2\n"),
|
||||||
|
parseCompositionDocument(t, "source-3", "catalog:\n third: 3\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("composeDocuments() diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
entries := composed.root.Mapping[0].Value.Mapping
|
||||||
|
got := make([]string, len(entries))
|
||||||
|
for index, entry := range entries {
|
||||||
|
got[index] = entry.Key
|
||||||
|
}
|
||||||
|
if want := []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("catalog keys = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestComposeDocumentsTreatsEmptyMappingsAsChildlessContributions(t *testing.T) {
|
func TestComposeDocumentsTreatsEmptyMappingsAsChildlessContributions(t *testing.T) {
|
||||||
empty, diagnostics := composeDocuments(
|
empty, diagnostics := composeDocuments(
|
||||||
parseCompositionDocument(t, "source-1", "{}\n"),
|
parseCompositionDocument(t, "source-1", "{}\n"),
|
||||||
|
|||||||
127
internal/engine/destination.go
Normal file
127
internal/engine/destination.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidDestination = errors.New("configkit: destination must contain only non-reserved mapping keys")
|
||||||
|
errDestinationLimit = errors.New("configkit: destination exceeds document limits")
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeDestination(path Path) (Path, error) {
|
||||||
|
if !path.valid() {
|
||||||
|
return Path{}, errInvalidDestination
|
||||||
|
}
|
||||||
|
|
||||||
|
segments := make([]pathSegment, 0, len(path.segments))
|
||||||
|
for _, segment := range path.segments {
|
||||||
|
if segment.kind != pathSegmentKey && segment.kind != pathSegmentParsed {
|
||||||
|
return Path{}, errInvalidDestination
|
||||||
|
}
|
||||||
|
if segment.key == "configkit" || segment.key == "extends" {
|
||||||
|
return Path{}, errInvalidDestination
|
||||||
|
}
|
||||||
|
segments = append(segments, pathSegment{kind: pathSegmentKey, key: segment.key})
|
||||||
|
}
|
||||||
|
return Path{segments: segments}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
||||||
|
return Diagnostic{
|
||||||
|
Code: CodeOptionsInvalid, Severity: SeverityError,
|
||||||
|
Path: destination.clone(),
|
||||||
|
Message: "input destination must contain only non-reserved mapping keys",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSourceDestinations(sources []SourceInfo, destination Path) {
|
||||||
|
for index := range sources {
|
||||||
|
sources[index].Destination = destination.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeDocument checks all per-document bounds before allocating destination
|
||||||
|
// wrappers. Aggregate accounting remains the caller's responsibility.
|
||||||
|
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||||
|
normalized, err := normalizeDestination(destination)
|
||||||
|
if err != nil {
|
||||||
|
return Path{}, nil, err
|
||||||
|
}
|
||||||
|
if body == nil {
|
||||||
|
return normalized, nil, nil
|
||||||
|
}
|
||||||
|
if !destinationWithinLimits(body, normalized, maxDepth, maxNodes) {
|
||||||
|
return normalized, nil, errDestinationLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
placed := body
|
||||||
|
for index := len(normalized.segments) - 1; index >= 0; index-- {
|
||||||
|
key := normalized.segments[index].key
|
||||||
|
placed = &document.Node{
|
||||||
|
Kind: document.Mapping,
|
||||||
|
Location: sourceRoot,
|
||||||
|
Mapping: []document.Entry{{
|
||||||
|
Key: key,
|
||||||
|
KeyLocation: sourceRoot,
|
||||||
|
ValueLocation: sourceRoot,
|
||||||
|
Value: placed,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized, placed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// destinationWithinLimits expects a normalized mapping-key destination.
|
||||||
|
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
|
||||||
|
if body == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if maxDepth < 0 || maxNodes < 0 || !destination.valid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
segments := len(destination.segments)
|
||||||
|
if segments > maxDepth || segments > maxNodes/2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
wrapperNodes := destinationWrapperNodeCount(destination)
|
||||||
|
for _, segment := range destination.segments {
|
||||||
|
if segment.kind != pathSegmentKey {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if documentDepth(body) > maxDepth-segments {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return countInputNodes(body) <= maxNodes-wrapperNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
func destinationWrapperNodeCount(destination Path) int {
|
||||||
|
return 2 * len(destination.segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentDepth(node *document.Node) int {
|
||||||
|
if node == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
depth := 0
|
||||||
|
var visit func(*document.Node, int)
|
||||||
|
visit = func(current *document.Node, currentDepth int) {
|
||||||
|
if current == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if currentDepth > depth {
|
||||||
|
depth = currentDepth
|
||||||
|
}
|
||||||
|
for _, entry := range current.Mapping {
|
||||||
|
visit(entry.Value, currentDepth+1)
|
||||||
|
}
|
||||||
|
for _, child := range current.Sequence {
|
||||||
|
visit(child, currentDepth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(node, 0)
|
||||||
|
return depth
|
||||||
|
}
|
||||||
176
internal/engine/destination_accounting_test.go
Normal file
176
internal/engine/destination_accounting_test.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := defaultImportLimits()
|
||||||
|
destination := Keys("collections", "entry")
|
||||||
|
empty := Mapping("empty", map[string]any{})
|
||||||
|
parsed, diagnostics := parseSource(empty, "fixture", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
perInput := countInputNodes(parsed.document) + destinationWrapperNodeCount(destination)
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
delta int
|
||||||
|
wantError bool
|
||||||
|
wantCharge int
|
||||||
|
}{
|
||||||
|
{name: "below aggregate bound", delta: -1, wantCharge: perInput},
|
||||||
|
{name: "at aggregate bound", delta: 0, wantCharge: perInput},
|
||||||
|
{name: "above aggregate bound", delta: 1, wantError: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
|
||||||
|
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||||
|
source: empty, destination: destination,
|
||||||
|
}}}, 1, nil, accounting)
|
||||||
|
if test.wantError {
|
||||||
|
if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||||
|
t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge {
|
||||||
|
t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := defaultImportLimits()
|
||||||
|
inputs := []loadInput{
|
||||||
|
{source: Mapping("one", map[string]any{}), destination: Keys("collection", "one")},
|
||||||
|
{source: Mapping("two", map[string]any{}), destination: Keys("collection", "two")},
|
||||||
|
{source: Mapping("three", map[string]any{}), destination: Keys("collection", "three")},
|
||||||
|
}
|
||||||
|
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
|
||||||
|
charge := len(inputs) * perInput
|
||||||
|
accounting := &importAccounting{nodes: limits.nodes - charge}
|
||||||
|
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
||||||
|
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge {
|
||||||
|
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
|
||||||
|
"fragment.yml": &fstest.MapFile{Data: []byte("{}\n")},
|
||||||
|
})
|
||||||
|
mainSource := filesystem.File("main.yml")
|
||||||
|
mainParsed, diagnostics := parseSource(mainSource, "fixture", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
fragmentParsed, diagnostics := parseSource(Bytes("fragment", []byte("{}\n")), "fixture", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
rawNodes := countInputNodes(mainParsed.document) + countInputNodes(fragmentParsed.document)
|
||||||
|
destination := Keys("collection", "empty")
|
||||||
|
placementNodes := 2 * destinationWrapperNodeCount(destination)
|
||||||
|
limits := defaultImportLimits()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
delta int
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "below aggregate bound", delta: -1},
|
||||||
|
{name: "at aggregate bound", delta: 0},
|
||||||
|
{name: "above aggregate bound", delta: 1, wantError: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
|
||||||
|
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||||
|
source: mainSource, imports: true, destination: destination,
|
||||||
|
}}}, 1, nil, accounting)
|
||||||
|
if test.wantError {
|
||||||
|
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
|
t.Fatalf("over-budget imports = %#v, %#v", composition, diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 || composition == nil || len(sources) != 2 {
|
||||||
|
t.Fatalf("bounded imports = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
|
||||||
|
}
|
||||||
|
if composition.inputNodes != rawNodes+placementNodes || accounting.nodes != limits.nodes+test.delta {
|
||||||
|
t.Fatalf("import node charge = %d, accounting %d; want %d and %d", composition.inputNodes, accounting.nodes, rawNodes+placementNodes, limits.nodes+test.delta)
|
||||||
|
}
|
||||||
|
if node, present := documentAtPath(composition.root, Keys("collection", "empty")); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||||
|
t.Fatalf("placed empty import body = %#v, present %v", node, present)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
accounting := &importAccounting{}
|
||||||
|
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
||||||
|
{source: NoSource(), destination: Keys("absent")},
|
||||||
|
{source: Mapping("root", map[string]any{})},
|
||||||
|
}}, 1, nil, accounting)
|
||||||
|
if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 {
|
||||||
|
t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := defaultImportLimits()
|
||||||
|
baseNodes := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("mounted"))
|
||||||
|
|
||||||
|
t.Run("selected overlay", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"overlays/test.yml": &fstest.MapFile{Data: []byte("{}\n")},
|
||||||
|
})
|
||||||
|
request := loadRequest[map[string]any]{
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}},
|
||||||
|
overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||||
|
}
|
||||||
|
accounting := &importAccounting{nodes: limits.nodes - baseNodes}
|
||||||
|
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded {
|
||||||
|
t.Fatalf("overlay diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CLI override", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
override := Override{Path: Keys("mounted", "value"), Value: "x", Flag: "--value"}
|
||||||
|
builder := valueBuilder{sourceID: "fixture", active: make(map[valueVisit]bool)}
|
||||||
|
if _, issue := builder.build(override.Value, override.Path, len(override.Path.segments)); issue != nil {
|
||||||
|
t.Fatal(issue)
|
||||||
|
}
|
||||||
|
overrideNodes := builder.nodes + destinationWrapperNodeCount(override.Path)
|
||||||
|
request := loadRequest[map[string]any]{
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}},
|
||||||
|
overrides: []Override{override},
|
||||||
|
}
|
||||||
|
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
|
||||||
|
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded {
|
||||||
|
t.Fatalf("override diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
142
internal/engine/destination_diagnostic_test.go
Normal file
142
internal/engine/destination_diagnostic_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLayeredDestinationDiagnosticsUseEffectivePathsAndAuthoredLocations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("strict conflict retains both source locations", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: NoSource()},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{
|
||||||
|
{Source: Bytes("left", []byte("section:\n value: left\n")), Destination: Keys("application")},
|
||||||
|
{Source: Bytes("right", []byte("section:\n value: right\n")), Destination: Keys("application")},
|
||||||
|
}}}},
|
||||||
|
)
|
||||||
|
if loaded != nil {
|
||||||
|
t.Fatalf("conflicting load returned a result: %#v", loaded)
|
||||||
|
}
|
||||||
|
diagnostic := firstDestinationDiagnostic(t, err)
|
||||||
|
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/application/section/value" {
|
||||||
|
t.Fatalf("conflict diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 2 {
|
||||||
|
t.Fatalf("primary location = %#v", diagnostic.Primary)
|
||||||
|
}
|
||||||
|
if len(diagnostic.Related) != 1 || diagnostic.Related[0].SourceID != "source-2" || diagnostic.Related[0].Line != 2 {
|
||||||
|
t.Fatalf("related locations = %#v", diagnostic.Related)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("decode failure keeps effective path and content line", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type section struct {
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Application struct {
|
||||||
|
Section section `yaml:"section"`
|
||||||
|
} `yaml:"application"`
|
||||||
|
}
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("main", []byte("section:\n count: invalid\n")), Destination: Keys("application")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[config]{},
|
||||||
|
)
|
||||||
|
if loaded != nil {
|
||||||
|
t.Fatalf("invalid load returned a result: %#v", loaded)
|
||||||
|
}
|
||||||
|
diagnostic := firstDestinationDiagnostic(t, err)
|
||||||
|
if diagnostic.Code != CodeDecodeType || diagnostic.Path.String() != "/application/section/count" {
|
||||||
|
t.Fatalf("decode diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 2 {
|
||||||
|
t.Fatalf("decode location = %#v", diagnostic.Primary)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("imported version failure uses effective path and import source", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
directory := t.TempDir()
|
||||||
|
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
|
||||||
|
writeImportFile(t, directory, "fragment.yml", "version: forbidden\n")
|
||||||
|
rule := &VersionRule{Path: Keys("application", "version"), Check: func(Version, *Validation) error { return nil }}
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: NoSource()},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{
|
||||||
|
Definition: Definition[map[string]any]{Version: rule},
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: Keys("application"),
|
||||||
|
}}}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if loaded != nil {
|
||||||
|
t.Fatalf("invalid import returned a result: %#v", loaded)
|
||||||
|
}
|
||||||
|
diagnostic := firstDestinationDiagnostic(t, err)
|
||||||
|
if diagnostic.Code != CodeImportVersion || diagnostic.Path.String() != "/application/version" {
|
||||||
|
t.Fatalf("import version diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || diagnostic.Primary.Line != 1 {
|
||||||
|
t.Fatalf("import version location = %#v", diagnostic.Primary)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid destination has no fictional source location", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("main", []byte("value: present\n")), Destination: Keys("configkit")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{},
|
||||||
|
)
|
||||||
|
if loaded != nil {
|
||||||
|
t.Fatalf("invalid destination returned a result: %#v", loaded)
|
||||||
|
}
|
||||||
|
diagnostic := firstDestinationDiagnostic(t, err)
|
||||||
|
if diagnostic.Code != CodeOptionsInvalid || diagnostic.Path.String() != "/configkit" || diagnostic.Primary != nil {
|
||||||
|
t.Fatalf("invalid destination diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("malformed source directive keeps its local path and location", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [true]}\nvalue: present\n")},
|
||||||
|
})
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: filesystem.File("main.yml"), Imports: true, Destination: Keys("application")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{},
|
||||||
|
)
|
||||||
|
if loaded != nil {
|
||||||
|
t.Fatalf("malformed directive returned a result: %#v", loaded)
|
||||||
|
}
|
||||||
|
diagnostic := firstDestinationDiagnostic(t, err)
|
||||||
|
if diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("malformed directive diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 1 {
|
||||||
|
t.Fatalf("malformed directive location = %#v", diagnostic.Primary)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstDestinationDiagnostic(t *testing.T, err error) Diagnostic {
|
||||||
|
t.Helper()
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("load error = %#v", err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
t.Fatal("load error has no diagnostics")
|
||||||
|
}
|
||||||
|
return diagnostics[0]
|
||||||
|
}
|
||||||
208
internal/engine/destination_import_test.go
Normal file
208
internal/engine/destination_import_test.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
type importDestinationConfig struct {
|
||||||
|
Mounted map[string]string `yaml:"mounted"`
|
||||||
|
Collections map[string]map[string]string `yaml:"collections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredHostImportsInheritInputDestinationTransitively(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: first.yml
|
||||||
|
- path: second.yml
|
||||||
|
root: main
|
||||||
|
`)
|
||||||
|
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: nested/third.yml
|
||||||
|
first: one
|
||||||
|
`)
|
||||||
|
thirdPath := writeImportFile(t, directory, "nested/third.yml", "nested: three\n")
|
||||||
|
secondPath := writeImportFile(t, directory, "second.yml", "second: two\n")
|
||||||
|
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: File(mainPath), Imports: true, Destination: Keys("mounted")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[importDestinationConfig]{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if loadError, ok := err.(*LoadError); ok {
|
||||||
|
t.Fatalf("LoadLayered diagnostics: %#v", loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
want := map[string]string{"root": "main", "first": "one", "nested": "three", "second": "two"}
|
||||||
|
if len(result.Config.Mounted) != len(want) {
|
||||||
|
t.Fatalf("mounted values = %#v", result.Config.Mounted)
|
||||||
|
}
|
||||||
|
for key, value := range want {
|
||||||
|
if result.Config.Mounted[key] != value {
|
||||||
|
t.Errorf("mounted[%q] = %q, want %q", key, result.Config.Mounted[key], value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata().Lookup(Keys("mounted", "configkit")); ok {
|
||||||
|
t.Fatal("source-root import directive leaked into application content")
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, firstPath), canonicalTestPath(t, thirdPath), canonicalTestPath(t, secondPath)}
|
||||||
|
if len(sources) != len(wantPaths) {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
for index, source := range sources {
|
||||||
|
if source.Destination.String() != "/mounted" {
|
||||||
|
t.Errorf("source %d destination = %q", index, source.Destination.String())
|
||||||
|
}
|
||||||
|
if source.Path != wantPaths[index] {
|
||||||
|
t.Errorf("source %d path = %q, want %q", index, source.Path, wantPaths[index])
|
||||||
|
}
|
||||||
|
if index > 0 && source.ImportedFrom == nil {
|
||||||
|
t.Errorf("imported source %d has no physical import location", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredNativeFilesystemImportsInheritInputDestination(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
native := fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: first.yml\nroot: main\n")},
|
||||||
|
"first.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: nested.yml}]}\nfirst: imported\n")},
|
||||||
|
"nested.yml": &fstest.MapFile{Data: []byte("nested: imported\n")},
|
||||||
|
}
|
||||||
|
namespace := newNativeNamespace("bundle", native)
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: filesystemFile(namespace, "main.yml"), Imports: true, Destination: Keys("mounted")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[importDestinationConfig]{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if loadError, ok := err.(*LoadError); ok {
|
||||||
|
t.Fatalf("LoadLayered diagnostics: %#v", loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
if result.Config.Mounted["root"] != "main" || result.Config.Mounted["first"] != "imported" || result.Config.Mounted["nested"] != "imported" {
|
||||||
|
t.Fatalf("mounted native filesystem values = %#v", result.Config.Mounted)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 || sources[0].Destination.String() != "/mounted" || sources[1].Destination.String() != "/mounted" || sources[2].Destination.String() != "/mounted" {
|
||||||
|
t.Fatalf("native source destinations = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredImportOnlyInputMaterializesDestination(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: optional.yml
|
||||||
|
optional: true
|
||||||
|
`)
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: File(mainPath), Imports: true, Destination: Keys("collections", "empty")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[importDestinationConfig]{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
info, ok := result.Metadata().Lookup(Keys("collections", "empty"))
|
||||||
|
if !ok || !info.Explicit {
|
||||||
|
t.Fatalf("import-only body destination = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Destination.String() != "/collections/empty" {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredImportedVersionClaimsUseEffectiveDestination(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
rulePath Path
|
||||||
|
destination Path
|
||||||
|
wantCode Code
|
||||||
|
}{
|
||||||
|
{name: "protected effective path", rulePath: Keys("mounted", "version"), destination: Keys("mounted"), wantCode: CodeImportVersion},
|
||||||
|
{name: "ordinary nested domain version", rulePath: Keys("version"), destination: Keys("collections", "entry")},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
directory := t.TempDir()
|
||||||
|
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
|
||||||
|
writeImportFile(t, directory, "fragment.yml", "version: v2\n")
|
||||||
|
rule := &VersionRule{Path: test.rulePath, Check: func(Version, *Validation) error { return nil }}
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: NoSource()},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[importDestinationConfig]{
|
||||||
|
Definition: Definition[importDestinationConfig]{Version: rule},
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: test.destination,
|
||||||
|
}}}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if test.wantCode != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("protected imported version claim was accepted")
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.wantCode {
|
||||||
|
t.Fatalf("load error = %#v", err)
|
||||||
|
}
|
||||||
|
if test.name == "protected effective path" {
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != "/mounted" || sources[1].Destination.String() != "/mounted" {
|
||||||
|
t.Fatalf("error source destinations = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ordinary nested version was rejected: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
if got := result.Config.Collections["entry"]["version"]; got != "v2" {
|
||||||
|
t.Fatalf("nested version = %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
writeImportFile(t, directory, "shared.yml", "value: shared\n")
|
||||||
|
importerPath := writeImportFile(t, directory, "importer.yml", "configkit: {imports: [{path: shared.yml}]}\n")
|
||||||
|
_, err := LoadLayered(
|
||||||
|
Input{Source: NoSource()},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[importDestinationConfig]{Underlays: []Underlay{{Inputs: []Input{
|
||||||
|
{Source: File(importerPath), Imports: true, Destination: Keys("imported")},
|
||||||
|
{Source: File(filepath.Join(directory, "shared.yml")), Destination: Keys("direct")},
|
||||||
|
}}}},
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the same physical source was accepted at two destinations in one group")
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportRepeated {
|
||||||
|
t.Fatalf("load error = %#v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
463
internal/engine/destination_integration_test.go
Normal file
463
internal/engine/destination_integration_test.go
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLayeredDestinationInheritanceUsesFinalSiblingScopes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("main", []byte("parent:\n label: final\n settings:\n primary: {height: 4}\ncancelled:\n extends: null\n own: retained\n")), Destination: Keys("catalog", "entries")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{
|
||||||
|
{Source: Bytes("parent", []byte("parent:\n label: lower\n settings:\n primary: {width: '10'}\n secondary: {extends: primary}\ncancelled:\n extends: parent\n own: retained\n")), Destination: Keys("catalog", "entries")},
|
||||||
|
{Source: Bytes("child", []byte("child:\n extends: parent\n settings:\n primary: {width: '20'}\n secondary: {extends: primary}\n")), Destination: Keys("catalog", "entries")},
|
||||||
|
}}}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
entries := layeredMap(t, layeredMap(t, result.Config, "catalog"), "entries")
|
||||||
|
parent := layeredMap(t, entries, "parent")
|
||||||
|
child := layeredMap(t, entries, "child")
|
||||||
|
parentSettings := layeredMap(t, parent, "settings")
|
||||||
|
childSettings := layeredMap(t, child, "settings")
|
||||||
|
if parent["label"] != "final" || child["label"] != "final" {
|
||||||
|
t.Fatalf("higher-tier parent was not inherited after precedence: parent=%#v child=%#v", parent, child)
|
||||||
|
}
|
||||||
|
if layeredMapValue(t, parentSettings, "secondary", "width") != "10" ||
|
||||||
|
fmt.Sprint(layeredMapValue(t, parentSettings, "secondary", "height")) != "4" ||
|
||||||
|
layeredMapValue(t, childSettings, "secondary", "width") != "20" ||
|
||||||
|
fmt.Sprint(layeredMapValue(t, childSettings, "secondary", "height")) != "4" {
|
||||||
|
t.Fatalf("nested sibling scopes were not resolved at their destinations: parent=%#v child=%#v", parentSettings, childSettings)
|
||||||
|
}
|
||||||
|
cancelled := layeredMap(t, entries, "cancelled")
|
||||||
|
if len(cancelled) != 1 || cancelled["own"] != "retained" {
|
||||||
|
t.Fatalf("higher-tier cancellation did not suppress inheritance: %#v", cancelled)
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata().Lookup(Keys("catalog", "entries", "child", inheritanceKey)); ok {
|
||||||
|
t.Fatal("effective child retained its extends directive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredDestinationInheritanceDoesNotSearchOtherCollections(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("main", []byte("parent: {value: foreign}\n")), Destination: Keys("catalog", "other")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: Bytes("child", []byte("child: {extends: parent}\n")),
|
||||||
|
Destination: Keys("catalog", "entries"),
|
||||||
|
}}}}},
|
||||||
|
)
|
||||||
|
if got := groupedErrorCode(t, err); got != CodeInheritanceMissing {
|
||||||
|
t.Fatalf("cross-collection parent lookup error = %s, want %s", got, CodeInheritanceMissing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
rulePath Path
|
||||||
|
}{
|
||||||
|
{name: "inherited protected value", body: "parent: {version: v2}\nchild: {extends: parent}\n", rulePath: Keys("catalog", "entries", "child", "version")},
|
||||||
|
{name: "inherited protected ancestor", body: "parent: {schema: null}\nchild: {extends: parent}\n", rulePath: Keys("catalog", "entries", "child", "schema", "version")},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, err := LoadLayered(
|
||||||
|
Input{Source: NoSource()}, testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{
|
||||||
|
Definition: Definition[map[string]any]{Version: &VersionRule{Path: test.rulePath, Check: func(Version, *Validation) error { return nil }}},
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{Source: Bytes("underlay", []byte(test.body)), Destination: Keys("catalog", "entries")}}}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if got := groupedErrorCode(t, err); got != CodeInheritanceVersion {
|
||||||
|
t.Fatalf("inheritance version protection code = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("inheritance consumes a protected directive", func(t *testing.T) {
|
||||||
|
_, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("main", []byte("parent: {value: inherited}\nchild: {extends: parent}\n")), Destination: Keys("catalog", "entries")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{
|
||||||
|
Path: Keys("catalog", "entries", "child", "extends"),
|
||||||
|
Check: func(version Version, _ *Validation) error {
|
||||||
|
if !version.Present || version.Text != "parent" {
|
||||||
|
t.Errorf("mounted protected directive value = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}},
|
||||||
|
)
|
||||||
|
if got := groupedErrorCode(t, err); got != CodeInheritanceVersion {
|
||||||
|
t.Fatalf("consumed directive protection code = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("inherited mapping ancestor without a version claim", func(t *testing.T) {
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: NoSource()}, testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{
|
||||||
|
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "entries", "child", "schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: Bytes("underlay", []byte("parent: {schema: {name: ordinary}}\nchild: {extends: parent}\n")), Destination: Keys("catalog", "entries"),
|
||||||
|
}}}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nonclaiming inherited mapping ancestor was rejected: %v", err)
|
||||||
|
}
|
||||||
|
if got := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries", "child", "schema", "name"); got != "ordinary" {
|
||||||
|
t.Fatalf("inherited non-version value = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{name: "overlay protected value", body: "catalog: {schema: {version: v2}}\n"},
|
||||||
|
{name: "overlay protected ancestor", body: "catalog: {schema: null}\n"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
filesystem := NewFilesystem("overlay", fstest.MapFS{"overlays/test.yml": &fstest.MapFile{Data: []byte(test.body)}})
|
||||||
|
_, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("main", []byte("schema: {version: v1}\n")), Destination: Keys("catalog")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{
|
||||||
|
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(version Version, _ *Validation) error {
|
||||||
|
if !version.Present || version.Text != "v1" {
|
||||||
|
t.Errorf("mounted main version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}},
|
||||||
|
Overlay: "test", OverlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if got := groupedErrorCode(t, err); got != CodeOverlayVersion {
|
||||||
|
t.Fatalf("overlay version protection code = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
path Path
|
||||||
|
value any
|
||||||
|
}{
|
||||||
|
{name: "override protected value", path: Keys("catalog", "schema", "version"), value: "v2"},
|
||||||
|
{name: "override protected ancestor", path: Keys("catalog", "schema"), value: map[string]any{"version": "v2"}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("main", []byte("schema: {version: v1}\n")), Destination: Keys("catalog")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{
|
||||||
|
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
||||||
|
Overrides: []Override{{Path: test.path, Value: test.value, Flag: "--config"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if got := groupedErrorCode(t, err); got != CodeOverrideVersion {
|
||||||
|
t.Fatalf("override version protection code = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("unrelated inheritance under the protected mapping ancestor", func(t *testing.T) {
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("main", []byte("schema: {version: v1}\nentries:\n parent: {value: inherited}\n child: {extends: parent}\n")), Destination: Keys("catalog")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(Version, *Validation) error { return nil }}}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unrelated mounted inheritance was rejected: %v", err)
|
||||||
|
}
|
||||||
|
child := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries", "child", "value")
|
||||||
|
if child != "inherited" {
|
||||||
|
t.Fatalf("unrelated inheritance value = %#v", child)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type destinationDecodeProbe string
|
||||||
|
|
||||||
|
var destinationDecodeProbeValues []string
|
||||||
|
|
||||||
|
func (value *destinationDecodeProbe) UnmarshalText(text []byte) error {
|
||||||
|
destinationDecodeProbeValues = append(destinationDecodeProbeValues, string(text))
|
||||||
|
*value = destinationDecodeProbe(text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value destinationDecodeProbe) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type destinationLifecycleConfig struct {
|
||||||
|
Defaulted string `yaml:"defaulted"`
|
||||||
|
Tenant struct {
|
||||||
|
Tagged string `yaml:"tagged" configkit:"secret"`
|
||||||
|
Dynamic string `yaml:"dynamic"`
|
||||||
|
Credential Secret `yaml:"credential"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
Decoded destinationDecodeProbe `yaml:"decoded"`
|
||||||
|
} `yaml:"tenant"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape(t *testing.T) {
|
||||||
|
const (
|
||||||
|
oldTagged = "SUPERSEDED-TAGGED-CANARY"
|
||||||
|
newTagged = "CURRENT-TAGGED-CANARY"
|
||||||
|
oldDynamic = "SUPERSEDED-DYNAMIC-CANARY"
|
||||||
|
newDynamic = "CURRENT-DYNAMIC-CANARY"
|
||||||
|
credential = "RESOLVED-CREDENTIAL-CANARY"
|
||||||
|
secretName = "DESTINATION_SECRET_NAME"
|
||||||
|
decodeValue = "decoded-once"
|
||||||
|
)
|
||||||
|
type calls struct{ defaults, resolve, lookup, validate int }
|
||||||
|
newLoad := func(mounted bool, observed *calls) (*testResult[destinationLifecycleConfig], error) {
|
||||||
|
mainBody := "tagged: " + newTagged + "\ndynamic: " + newDynamic + "\ncredential: " + secretName + "\ndecoded: " + decodeValue + "\n"
|
||||||
|
underlayBody := "tagged: " + oldTagged + "\ndynamic: " + oldDynamic + "\ncredential: OLD_DESTINATION_SECRET_NAME\ndecoded: old\n"
|
||||||
|
if !mounted {
|
||||||
|
mainBody = "tenant:\n" + indentDestinationBody(mainBody)
|
||||||
|
underlayBody = "tenant:\n" + indentDestinationBody(underlayBody)
|
||||||
|
}
|
||||||
|
main := loadInput{source: Bytes("main", []byte(mainBody))}
|
||||||
|
underlay := loadInput{source: Bytes("underlay", []byte(underlayBody))}
|
||||||
|
if mounted {
|
||||||
|
main.destination = Keys("tenant")
|
||||||
|
underlay.destination = Keys("tenant")
|
||||||
|
}
|
||||||
|
definition := Definition[destinationLifecycleConfig]{
|
||||||
|
Defaults: func() destinationLifecycleConfig {
|
||||||
|
observed.defaults++
|
||||||
|
return destinationLifecycleConfig{Defaulted: "from-default"}
|
||||||
|
},
|
||||||
|
Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error {
|
||||||
|
observed.resolve++
|
||||||
|
configuration.Tenant.Derived = configuration.Tenant.Dynamic
|
||||||
|
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from mounted input", Keys("tenant", "dynamic"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(configuration *destinationLifecycleConfig, _ *Validation) {
|
||||||
|
observed.validate++
|
||||||
|
if configuration.Tenant.Decoded != destinationDecodeProbe(decodeValue) || configuration.Tenant.Dynamic != newDynamic {
|
||||||
|
t.Errorf("validation saw a non-final mounted configuration: %#v", configuration.Tenant)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/tenant/dynamic" },
|
||||||
|
}
|
||||||
|
loaded, err := loadGroupedWithSecretLookup(loadRequest[destinationLifecycleConfig]{
|
||||||
|
main: main, underlays: []loadGroup{{inputs: []loadInput{underlay}}}, definition: definition,
|
||||||
|
}, testSecretBinding(), func(name string) (string, bool) {
|
||||||
|
observed.lookup++
|
||||||
|
if name != secretName {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return credential, true
|
||||||
|
})
|
||||||
|
return testLoaded(loaded), err
|
||||||
|
}
|
||||||
|
|
||||||
|
var observed calls
|
||||||
|
destinationDecodeProbeValues = nil
|
||||||
|
mounted, err := newLoad(true, &observed)
|
||||||
|
if err != nil {
|
||||||
|
if loadError, ok := err.(*LoadError); ok {
|
||||||
|
t.Fatalf("mounted load: %#v", loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("mounted load: %v", err)
|
||||||
|
}
|
||||||
|
if observed != (calls{defaults: 1, resolve: 1, lookup: 1, validate: 1}) {
|
||||||
|
t.Fatalf("mounted lifecycle counts = %#v", observed)
|
||||||
|
}
|
||||||
|
sawFinalDecode := false
|
||||||
|
for _, value := range destinationDecodeProbeValues {
|
||||||
|
if value == decodeValue {
|
||||||
|
sawFinalDecode = true
|
||||||
|
} else if value != "" {
|
||||||
|
t.Fatalf("decoder observed a non-final mounted value %q: %#v", value, destinationDecodeProbeValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawFinalDecode {
|
||||||
|
t.Fatal("final mounted value was not decoded")
|
||||||
|
}
|
||||||
|
if mounted.Config.Defaulted != "from-default" || mounted.Config.Tenant.Tagged != newTagged || mounted.Config.Tenant.Dynamic != newDynamic ||
|
||||||
|
mounted.Config.Tenant.Derived != newDynamic || mounted.Config.Tenant.Decoded != destinationDecodeProbe(decodeValue) ||
|
||||||
|
mounted.Config.Tenant.Credential.Value() != credential {
|
||||||
|
t.Fatalf("mounted final configuration = %#v", mounted.Config)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic"), Keys("tenant", "derived")} {
|
||||||
|
if !mounted.Inspection.secrets[path.String()] {
|
||||||
|
t.Errorf("effective sensitivity missing at %s: %#v", path, mounted.Inspection.secrets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic"), Keys("tenant", "derived")} {
|
||||||
|
explanation, ok := mounted.Explain(path)
|
||||||
|
if !ok || !explanation.Effective.Redacted {
|
||||||
|
t.Errorf("effective explanation at %s = %#v, %v", path, explanation, ok)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML+change.After.YAML, oldTagged) ||
|
||||||
|
strings.Contains(change.Before.YAML+change.After.YAML, newTagged) ||
|
||||||
|
strings.Contains(change.Before.YAML+change.After.YAML, oldDynamic) ||
|
||||||
|
strings.Contains(change.Before.YAML+change.After.YAML, newDynamic) {
|
||||||
|
t.Errorf("classified history leaked a canary at %s: %#v", path, change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := mounted.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), "CANARY") {
|
||||||
|
t.Errorf("safe explanation at %s = %q, %v", path, rendered.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var effective bytes.Buffer
|
||||||
|
if err := mounted.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, canary := range []string{oldTagged, newTagged, oldDynamic, newDynamic, credential} {
|
||||||
|
if strings.Contains(effective.String(), canary) {
|
||||||
|
t.Fatalf("effective rendering exposed %q: %s", canary, effective.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if serializedConfig := fmt.Sprintf("%#v", mounted.Config); strings.Contains(serializedConfig, credential) {
|
||||||
|
t.Fatalf("configuration formatting exposed the resolved credential: %s", serializedConfig)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic")} {
|
||||||
|
if history := fmt.Sprintf("%#v", mounted.Metadata().History(path)); strings.Contains(history, "CANARY") {
|
||||||
|
t.Fatalf("metadata history at %s exposed a canary: %s", path, history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var authoredCalls calls
|
||||||
|
destinationDecodeProbeValues = nil
|
||||||
|
authored, err := newLoad(false, &authoredCalls)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("explicitly wrapped load: %v", err)
|
||||||
|
}
|
||||||
|
if authoredCalls != (calls{defaults: 1, resolve: 1, lookup: 1, validate: 1}) {
|
||||||
|
t.Fatalf("authored lifecycle counts = %#v", authoredCalls)
|
||||||
|
}
|
||||||
|
sawFinalDecode = false
|
||||||
|
for _, value := range destinationDecodeProbeValues {
|
||||||
|
if value == decodeValue {
|
||||||
|
sawFinalDecode = true
|
||||||
|
} else if value != "" {
|
||||||
|
t.Fatalf("authored decoder observed a non-final value %q: %#v", value, destinationDecodeProbeValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawFinalDecode {
|
||||||
|
t.Fatal("final authored value was not decoded")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(mounted.Config, authored.Config) {
|
||||||
|
t.Fatalf("mounted config %#v differs from explicitly wrapped config %#v", mounted.Config, authored.Config)
|
||||||
|
}
|
||||||
|
differences, err := Diff(mounted.Inspection, authored.Inspection)
|
||||||
|
if err != nil || len(differences) != 0 {
|
||||||
|
t.Fatalf("mounted versus authored diff = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredDestinationDiffUsesBilateralSensitivity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Tenant struct {
|
||||||
|
Dynamic string `yaml:"dynamic"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
} `yaml:"tenant"`
|
||||||
|
}
|
||||||
|
load := func(value string, classify bool) *testResult[config] {
|
||||||
|
t.Helper()
|
||||||
|
loaded, err := loadGroupedWithSecretLookup(loadRequest[config]{
|
||||||
|
main: loadInput{source: Bytes("input", []byte("dynamic: "+value+"\n")), destination: Keys("tenant")},
|
||||||
|
definition: Definition[config]{
|
||||||
|
Resolve: func(configuration *config, resolution *Resolution) error {
|
||||||
|
configuration.Tenant.Derived = configuration.Tenant.Dynamic
|
||||||
|
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from dynamic input", Keys("tenant", "dynamic"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" },
|
||||||
|
},
|
||||||
|
}, testSecretBinding(), func(string) (string, bool) { return "", false })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
return testLoaded(loaded)
|
||||||
|
}
|
||||||
|
left := load("LEFT-DIFF-CANARY", true)
|
||||||
|
right := load("RIGHT-DIFF-CANARY", false)
|
||||||
|
differences, err := Diff(left.Inspection, right.Inspection)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(differences) != 2 {
|
||||||
|
t.Fatalf("differences = %#v", differences)
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, difference := range differences {
|
||||||
|
path := difference.Path.String()
|
||||||
|
seen[path] = true
|
||||||
|
if !difference.Left.Redacted || !difference.Right.Redacted || !difference.HiddenChanged ||
|
||||||
|
strings.Contains(difference.Left.YAML+difference.Right.YAML, "DIFF-CANARY") {
|
||||||
|
t.Errorf("bilaterally classified difference at %s = %#v", path, difference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/tenant/dynamic", "/tenant/derived"} {
|
||||||
|
if !seen[path] || !left.Inspection.secrets[path] || right.Inspection.secrets[path] {
|
||||||
|
t.Errorf("classification/difference at %s: seen=%v left=%v right=%v", path, seen[path], left.Inspection.secrets[path], right.Inspection.secrets[path])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Tenant struct {
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
} `yaml:"tenant"`
|
||||||
|
}
|
||||||
|
calls := struct{ defaults, resolve, lookup, validate int }{}
|
||||||
|
loaded, err := loadGroupedWithSecretLookup(loadRequest[config]{
|
||||||
|
main: loadInput{source: Bytes("invalid", []byte("count: not-an-integer\n")), destination: Keys("tenant")},
|
||||||
|
definition: Definition[config]{
|
||||||
|
Defaults: func() config { calls.defaults++; return config{} },
|
||||||
|
Resolve: func(*config, *Resolution) error { calls.resolve++; return nil },
|
||||||
|
Validate: func(*config, *Validation) { calls.validate++ },
|
||||||
|
},
|
||||||
|
}, testSecretBinding(), func(string) (string, bool) { calls.lookup++; return "", false })
|
||||||
|
if loaded != nil || err == nil {
|
||||||
|
t.Fatalf("mounted decode failure returned a usable result: %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
if calls.defaults != 1 || calls.resolve != 0 || calls.lookup != 0 || calls.validate != 0 {
|
||||||
|
t.Fatalf("callbacks after mounted decode failure = %#v", calls)
|
||||||
|
}
|
||||||
|
if got := groupedErrorCode(t, err); got != CodeDecodeType {
|
||||||
|
t.Fatalf("mounted decode failure code = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indentDestinationBody(body string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, line := range strings.Split(strings.TrimSuffix(body, "\n"), "\n") {
|
||||||
|
result.WriteString(" ")
|
||||||
|
result.WriteString(line)
|
||||||
|
result.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
118
internal/engine/destination_load_test.go
Normal file
118
internal/engine/destination_load_test.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type destinationLoadConfig struct {
|
||||||
|
Main map[string]string `yaml:"main"`
|
||||||
|
Defaults map[string]string `yaml:"defaults"`
|
||||||
|
Collections map[string]map[string]string `yaml:"collections"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredLoadPlacesNonImportMainAndUnderlayInputs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
destination := Keys("main")
|
||||||
|
main := Input{Source: Bytes("main", []byte("value: primary\n")), Destination: destination}
|
||||||
|
underlay := Input{Source: Bytes("defaults", []byte("value: fallback\n")), Destination: Keys("defaults")}
|
||||||
|
loaded, err := LoadLayered(main, testSecretBinding(), LayeredOptions[destinationLoadConfig]{
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{underlay}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
if result.Config.Main["value"] != "primary" || result.Config.Defaults["value"] != "fallback" {
|
||||||
|
t.Fatalf("mounted configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != "/main" || sources[1].Destination.String() != "/defaults" {
|
||||||
|
t.Fatalf("source destinations = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
destination.segments[0].key = "caller mutation"
|
||||||
|
sources[0].Destination.segments[0].key = "record mutation"
|
||||||
|
if got := result.Sources()[0].Destination.String(); got != "/main" {
|
||||||
|
t.Fatalf("source destination was not independently owned: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredLoadOmittedAndEmptyDestinationsKeepRootPlacement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
inputs := []Input{
|
||||||
|
{Source: Bytes("omitted", []byte("value: same\n"))},
|
||||||
|
{Source: Bytes("empty", []byte("value: same\n")), Destination: Keys()},
|
||||||
|
}
|
||||||
|
for _, input := range inputs {
|
||||||
|
loaded, err := LoadLayered(input, testSecretBinding(), LayeredOptions[destinationLoadConfig]{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
if result.Config.Value != "same" {
|
||||||
|
t.Fatalf("root value = %q", result.Config.Value)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Destination.String() != "" {
|
||||||
|
t.Fatalf("root source destination = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredLoadDistinguishesEmptyBodyFromNoSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: NoSource(), Destination: Keys("absent")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[destinationLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: Bytes("empty", []byte("{}\n")), Destination: Keys("collections", "one"),
|
||||||
|
}}}}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
info, ok := result.Metadata().Lookup(Keys("collections", "one"))
|
||||||
|
if !ok || !info.Explicit || info.Origin.Layer != LayerUnderlay {
|
||||||
|
t.Fatalf("empty mounted mapping = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata().Lookup(Keys("absent")); ok {
|
||||||
|
t.Fatal("NoSource materialized its destination")
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Label != "empty" || sources[0].Destination.String() != "/collections/one" {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredLoadPreflightsDestinationsBeforeReadingSources(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
reader := bytes.NewBufferString("value: unused\n")
|
||||||
|
_, err := LoadLayered(
|
||||||
|
Input{Source: NoSource()},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[destinationLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: Reader("reader", reader), Destination: Keys("configkit"),
|
||||||
|
}}}}},
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("reserved destination was accepted")
|
||||||
|
}
|
||||||
|
if reader.Len() == 0 {
|
||||||
|
t.Fatal("source reader was consumed before destination preflight")
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) != 1 {
|
||||||
|
t.Fatalf("error = %#v", err)
|
||||||
|
}
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Code != CodeOptionsInvalid || diagnostic.Path.String() != "/configkit" || diagnostic.Primary != nil {
|
||||||
|
t.Fatalf("destination diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
}
|
||||||
181
internal/engine/destination_provenance_test.go
Normal file
181
internal/engine/destination_provenance_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
sharedPath := writeImportFile(t, directory, "shared.yml", "section:\n shared: lower\n branch:\n child: old\n kept: retained\n")
|
||||||
|
shared := File(sharedPath)
|
||||||
|
load := func() (*Loaded[map[string]any], error) {
|
||||||
|
return LoadLayered(
|
||||||
|
Input{Source: NoSource()}, testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{Underlays: []Underlay{
|
||||||
|
{Inputs: []Input{
|
||||||
|
{Source: shared, Destination: Keys("catalog")},
|
||||||
|
{Source: Bytes("peer", []byte("section:\n peer: present\n")), Destination: Keys("catalog")},
|
||||||
|
}},
|
||||||
|
{Inputs: []Input{{Source: shared, Destination: Keys("backup")}}},
|
||||||
|
{Inputs: []Input{{Source: Bytes("replacement", []byte("section:\n branch: replaced\n")), Destination: Keys("catalog")}}},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loaded, err := load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, err.(*LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
metadata := result.Metadata()
|
||||||
|
|
||||||
|
container, ok := metadata.Lookup(Keys("catalog"))
|
||||||
|
if !ok || !container.Explicit || len(container.Origin.Locations) < 3 {
|
||||||
|
t.Fatalf("mounted container origin = %#v, %v", container, ok)
|
||||||
|
}
|
||||||
|
containerSources := make(map[string]bool)
|
||||||
|
for _, location := range container.Origin.Locations {
|
||||||
|
containerSources[location.SourceID] = true
|
||||||
|
if location.Line < 1 {
|
||||||
|
t.Fatalf("introduced container lacks its source-root fallback: %#v", location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sourceID := range []string{"source-1", "source-2", "source-4"} {
|
||||||
|
if !containerSources[sourceID] {
|
||||||
|
t.Fatalf("container contributors = %#v, missing %s", container.Origin.Locations, sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedField, ok := metadata.Lookup(Keys("catalog", "section", "shared"))
|
||||||
|
if !ok || len(sharedField.Origin.Locations) != 1 || sharedField.Origin.Locations[0].SourceID != "source-1" || sharedField.Origin.Locations[0].Line != 2 {
|
||||||
|
t.Fatalf("authored leaf origin = %#v, %v", sharedField, ok)
|
||||||
|
}
|
||||||
|
peerField, ok := metadata.Lookup(Keys("catalog", "section", "peer"))
|
||||||
|
if !ok || len(peerField.Origin.Locations) != 1 || peerField.Origin.Locations[0].SourceID != "source-2" {
|
||||||
|
t.Fatalf("peer leaf origin = %#v, %v", peerField, ok)
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(Keys("catalog", "section", "branch", "child")); ok {
|
||||||
|
t.Fatal("replaced descendant remains effective")
|
||||||
|
}
|
||||||
|
removed := metadata.History(Keys("catalog", "section", "branch", "child"))
|
||||||
|
if len(removed) < 2 || removed[0].Origin.Locations[0].SourceID != "source-1" {
|
||||||
|
t.Fatalf("removed descendant history = %#v", removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
backup, ok := metadata.Lookup(Keys("backup", "section", "shared"))
|
||||||
|
if !ok || len(backup.Origin.Locations) != 1 || backup.Origin.Locations[0].SourceID != "source-3" {
|
||||||
|
t.Fatalf("second-tier leaf origin = %#v, %v", backup, ok)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 4 || sources[0].Path != sources[2].Path || sources[0].Destination.String() != "/catalog" || sources[2].Destination.String() != "/backup" {
|
||||||
|
t.Fatalf("same source in separate tiers = %#v", sources)
|
||||||
|
}
|
||||||
|
if sources[0].Layer != LayerUnderlay || sources[0].UnderlayIndex != 1 || sources[2].Layer != LayerUnderlay || sources[2].UnderlayIndex != 2 {
|
||||||
|
t.Fatalf("tier source records = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
other, err := load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("independent load: %v", err)
|
||||||
|
}
|
||||||
|
differences, err := Diff(loaded.Inspection, other.Inspection)
|
||||||
|
if err != nil || len(differences) != 0 {
|
||||||
|
t.Fatalf("equivalent inspection diff = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sources[0].Destination.segments[0].key = "mutated"
|
||||||
|
sharedField.Origin.Locations[0].SourceID = "mutated"
|
||||||
|
removed[0].Path.segments[0].key = "mutated"
|
||||||
|
if got := result.Sources()[0].Destination.String(); got != "/catalog" {
|
||||||
|
t.Fatalf("source record copy changed after inspection: %q", got)
|
||||||
|
}
|
||||||
|
fresh, ok := result.Metadata().Lookup(Keys("catalog", "section", "shared"))
|
||||||
|
if !ok || fresh.Origin.Locations[0].SourceID != "source-1" {
|
||||||
|
t.Fatalf("effective origin changed after mutating returned records: %#v, %v", fresh, ok)
|
||||||
|
}
|
||||||
|
if got := result.Metadata().History(Keys("catalog", "section", "branch", "child")); len(got) < 2 || got[0].Path.String() != "/catalog/section/branch/child" {
|
||||||
|
t.Fatalf("historical path changed after mutating returned records: %#v", got)
|
||||||
|
}
|
||||||
|
if got := other.Inspection.sources[0].Destination.String(); got != "/catalog" {
|
||||||
|
t.Fatalf("independent load destination changed: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredImportedInheritedValuesRetainResourceContexts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
Extends string `yaml:"extends"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Catalog struct {
|
||||||
|
Entries map[string]entry `yaml:"entries"`
|
||||||
|
} `yaml:"catalog"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "config/main.yml", "child:\n extends: parent\n")
|
||||||
|
inputPath := writeImportFile(t, directory, "shared/input.yml", "configkit: {imports: [{path: parent.yml}]}\n")
|
||||||
|
writeImportFile(t, directory, "shared/parent.yml", "parent:\n resource: icon.txt\n count: 1\n")
|
||||||
|
writeImportFile(t, directory, "shared/icon.txt", "parent-resource")
|
||||||
|
var resolutionContext ResourceContext
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: File(mainPath), Destination: Keys("catalog", "entries")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[config]{
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: File(inputPath), Imports: true, Destination: Keys("catalog", "entries"),
|
||||||
|
}}}},
|
||||||
|
Definition: Definition[config]{Resolve: func(configuration *config, resolution *Resolution) error {
|
||||||
|
info, ok := resolution.InputInfo(Keys("catalog", "entries", "child", "resource"))
|
||||||
|
if !ok || len(info.Origin.Locations) != 1 || len(info.Origin.Inheritance) != 1 {
|
||||||
|
return errors.New("inherited resource lacks its source origin")
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
resolutionContext, found = resolution.state.input.sourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !found || resolutionContext.BaseDir != "." || resolutionContext.FS == nil {
|
||||||
|
return errors.New("resolver could not find the declaring resource context")
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(resolutionContext.FS, path.Join(resolutionContext.BaseDir, configuration.Catalog.Entries["child"].Resource))
|
||||||
|
if err != nil || string(data) != "parent-resource" {
|
||||||
|
return errors.New("resolver could not read the inherited relative resource")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, err.(*LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
child := result.Config.Catalog.Entries["child"]
|
||||||
|
if child.Resource != "icon.txt" || child.Count != 1 || child.Extends != "" {
|
||||||
|
t.Fatalf("inherited child = %#v", child)
|
||||||
|
}
|
||||||
|
field, ok := result.Metadata().Lookup(Keys("catalog", "entries", "child", "resource"))
|
||||||
|
if !ok || len(field.Origin.Locations) != 1 || len(field.Origin.Inheritance) != 1 {
|
||||||
|
t.Fatalf("inherited resource origin = %#v, %v", field, ok)
|
||||||
|
}
|
||||||
|
context, ok := result.Inspection.SourceContext(field.Origin.Locations[0].SourceID)
|
||||||
|
if !ok || context.BaseDir != "." || context.FS == nil || resolutionContext.BaseDir != context.BaseDir {
|
||||||
|
t.Fatalf("completed resource contexts = %#v, %#v, %v", resolutionContext, context, ok)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, child.Resource))
|
||||||
|
if err != nil || string(data) != "parent-resource" {
|
||||||
|
t.Fatalf("completed source-relative resource = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(result.Config.Catalog.Entries["parent"], entry{Resource: "icon.txt", Count: 1}) {
|
||||||
|
t.Fatalf("surviving lower-tier parent = %#v", result.Config.Catalog.Entries["parent"])
|
||||||
|
}
|
||||||
|
for _, source := range result.Sources() {
|
||||||
|
if source.Layer == LayerUnderlay && source.Destination.String() != "/catalog/entries" {
|
||||||
|
t.Errorf("underlay destination = %#v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
internal/engine/destination_test.go
Normal file
178
internal/engine/destination_test.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parsed, err := ParsePath("/a~1b/0/~0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
normalized, err := normalizeDestination(parsed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := normalized.String(), "/a~1b/0/~0"; got != want {
|
||||||
|
t.Fatalf("normalized path = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
for index, want := range []string{"a/b", "0", "~"} {
|
||||||
|
segment := normalized.segments[index]
|
||||||
|
if segment.kind != pathSegmentKey || segment.key != want {
|
||||||
|
t.Fatalf("segment %d = %#v, want key %q", index, segment, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
original := Keys("owned", "path")
|
||||||
|
owned, err := normalizeDestination(original)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
owned.segments[0].key = "changed"
|
||||||
|
if got := original.String(); got != "/owned/path" {
|
||||||
|
t.Fatalf("normalizing shared path storage mutated its input: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDestinationRejectsIndicesAndReservedKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for name, path := range map[string]Path{
|
||||||
|
"explicit index": Keys("items").Index(0),
|
||||||
|
"negative index": Keys("items").Index(-1),
|
||||||
|
"invalid path": Path{invalid: true},
|
||||||
|
"configkit": Keys("configkit"),
|
||||||
|
"extends": Keys("extends"),
|
||||||
|
"nested configkit": Keys("outer", "configkit"),
|
||||||
|
"nested extends": Keys("outer", "extends"),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if _, err := normalizeDestination(path); !errors.Is(err, errInvalidDestination) {
|
||||||
|
t.Fatalf("normalizeDestination(%q) error = %v, want invalid destination", path.String(), err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{"myextends", "configkit2", "", ".", "..", "../x", "a.b", "a/b", "~"} {
|
||||||
|
t.Run("ordinary key "+key, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if _, err := normalizeDestination(Keys(key)); err != nil {
|
||||||
|
t.Fatalf("normalizeDestination(%q): %v", key, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaceDocumentWrapsEmptyMappingAndPreservesSourceCoordinates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rootLocation := document.Location{SourceID: "source-a", Line: 1, Column: 1}
|
||||||
|
body := &document.Node{
|
||||||
|
Kind: document.Mapping,
|
||||||
|
Location: document.Location{SourceID: "source-a", Line: 4, Column: 1},
|
||||||
|
}
|
||||||
|
path, placed, err := placeDocument(body, Keys("a", "b"), rootLocation, 2, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := path.String(), "/a/b"; got != want {
|
||||||
|
t.Fatalf("normalized destination = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if placed == body || len(placed.Mapping) != 1 || placed.Mapping[0].Key != "a" {
|
||||||
|
t.Fatalf("outer destination mapping was not created: %#v", placed)
|
||||||
|
}
|
||||||
|
inner := placed.Mapping[0].Value
|
||||||
|
if inner.Kind != document.Mapping || len(inner.Mapping) != 1 || inner.Mapping[0].Key != "b" {
|
||||||
|
t.Fatalf("inner destination mapping was not created: %#v", inner)
|
||||||
|
}
|
||||||
|
if inner.Mapping[0].Value != body || len(body.Mapping) != 0 {
|
||||||
|
t.Fatal("placement mutated or copied the authored body")
|
||||||
|
}
|
||||||
|
if placed.Location != rootLocation || inner.Location != rootLocation || placed.Mapping[0].KeyLocation != rootLocation || placed.Mapping[0].ValueLocation != rootLocation {
|
||||||
|
t.Fatal("introduced structure did not use the source-root coordinate fallback")
|
||||||
|
}
|
||||||
|
if body.Location != (document.Location{SourceID: "source-a", Line: 4, Column: 1}) {
|
||||||
|
t.Fatalf("authored body location changed: %#v", body.Location)
|
||||||
|
}
|
||||||
|
if got, want := countInputNodes(placed), countInputNodes(body)+4; got != want {
|
||||||
|
t.Fatalf("placed node count = %d, want body count plus four synthetic nodes (%d)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaceDocumentPreservesAuthoredEntryCoordinates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rootLocation := document.Location{SourceID: "source-b", Line: 1, Column: 1}
|
||||||
|
bodyLocation := document.Location{SourceID: "source-b", Line: 3, Column: 1}
|
||||||
|
keyLocation := document.Location{SourceID: "source-b", Line: 3, Column: 3}
|
||||||
|
valueLocation := document.Location{SourceID: "source-b", Line: 4, Column: 5}
|
||||||
|
value := &document.Node{Kind: document.String, Location: valueLocation, Text: "value"}
|
||||||
|
body := &document.Node{
|
||||||
|
Kind: document.Mapping,
|
||||||
|
Location: bodyLocation,
|
||||||
|
Mapping: []document.Entry{{
|
||||||
|
Key: "leaf",
|
||||||
|
KeyLocation: keyLocation,
|
||||||
|
ValueLocation: valueLocation,
|
||||||
|
Value: value,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
_, placed, err := placeDocument(body, Keys("mounted"), rootLocation, 2, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if placed.Mapping[0].KeyLocation != rootLocation || placed.Mapping[0].ValueLocation != rootLocation {
|
||||||
|
t.Fatal("introduced destination entry did not use the source-root fallback")
|
||||||
|
}
|
||||||
|
if placed.Mapping[0].Value != body || body.Location != bodyLocation {
|
||||||
|
t.Fatal("placement changed the authored body node or location")
|
||||||
|
}
|
||||||
|
entry := body.Mapping[0]
|
||||||
|
if entry.KeyLocation != keyLocation || entry.ValueLocation != valueLocation || entry.Value != value || value.Location != valueLocation {
|
||||||
|
t.Fatal("placement changed authored entry or value coordinates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaceDocumentRootAndBounds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := &document.Node{
|
||||||
|
Kind: document.Mapping,
|
||||||
|
Mapping: []document.Entry{{
|
||||||
|
Key: "leaf",
|
||||||
|
Value: &document.Node{Kind: document.String, Text: "value"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
path, placed, err := placeDocument(body, Path{}, body.Location, 1, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if path.String() != "" || placed != body {
|
||||||
|
t.Fatal("root placement must preserve the existing body")
|
||||||
|
}
|
||||||
|
|
||||||
|
destination := Keys("a", "b")
|
||||||
|
if !destinationWithinLimits(body, destination, 3, 7) {
|
||||||
|
t.Fatal("exact depth and node limits should admit the placed document")
|
||||||
|
}
|
||||||
|
if destinationWithinLimits(body, destination, 2, 7) {
|
||||||
|
t.Fatal("placement exceeding the depth limit was accepted")
|
||||||
|
}
|
||||||
|
if destinationWithinLimits(body, destination, 3, 6) {
|
||||||
|
t.Fatal("placement exceeding the node limit was accepted")
|
||||||
|
}
|
||||||
|
_, placed, err = placeDocument(body, destination, body.Location, 3, 6)
|
||||||
|
if !errors.Is(err, errDestinationLimit) || placed != nil {
|
||||||
|
t.Fatalf("bounded placement returned node %p and error %v", placed, err)
|
||||||
|
}
|
||||||
|
_, absent, err := placeDocument(nil, destination, document.Location{}, 0, 0)
|
||||||
|
if err != nil || absent != nil {
|
||||||
|
t.Fatalf("absent body should not consume placement limits: node %p, error %v", absent, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
347
internal/engine/destination_tiers_test.go
Normal file
347
internal/engine/destination_tiers_test.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLayeredDestinationStrictCompositionBoundaries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputs []Input
|
||||||
|
wantPath []string
|
||||||
|
want any
|
||||||
|
alsoPath []string
|
||||||
|
alsoWant any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disjoint peers at one destination",
|
||||||
|
inputs: []Input{
|
||||||
|
{Source: Bytes("left", []byte("left: one\n")), Destination: Keys("application")},
|
||||||
|
{Source: Bytes("right", []byte("right: two\n")), Destination: Keys("application")},
|
||||||
|
},
|
||||||
|
wantPath: []string{"application", "right"},
|
||||||
|
want: "two",
|
||||||
|
alsoPath: []string{"application", "left"},
|
||||||
|
alsoWant: "one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different destinations",
|
||||||
|
inputs: []Input{
|
||||||
|
{Source: Bytes("left", []byte("value: shared\n")), Destination: Keys("left")},
|
||||||
|
{Source: Bytes("right", []byte("value: shared\n")), Destination: Keys("right")},
|
||||||
|
},
|
||||||
|
wantPath: []string{"right", "value"},
|
||||||
|
want: "shared",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ancestor and descendant destinations",
|
||||||
|
inputs: []Input{
|
||||||
|
{Source: Bytes("parent", []byte("branch: {base: one}\n")), Destination: Keys("application")},
|
||||||
|
{Source: Bytes("child", []byte("leaf: two\n")), Destination: Keys("application", "branch")},
|
||||||
|
},
|
||||||
|
wantPath: []string{"application", "branch", "leaf"},
|
||||||
|
want: "two",
|
||||||
|
alsoPath: []string{"application", "branch", "base"},
|
||||||
|
alsoWant: "one",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
|
||||||
|
Underlays: []Underlay{{Inputs: test.inputs}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
if got := layeredMapValue(t, result.Config, test.wantPath...); got != test.want {
|
||||||
|
t.Fatalf("value at %v = %#v, want %#v", test.wantPath, got, test.want)
|
||||||
|
}
|
||||||
|
if len(test.alsoPath) != 0 {
|
||||||
|
if got := layeredMapValue(t, result.Config, test.alsoPath...); got != test.alsoWant {
|
||||||
|
t.Fatalf("value at %v = %#v, want %#v", test.alsoPath, got, test.alsoWant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
left string
|
||||||
|
right string
|
||||||
|
}{
|
||||||
|
{name: "equal values conflict", left: "value: same\n", right: "value: same\n"},
|
||||||
|
{name: "unequal values conflict", left: "value: left\n", right: "value: right\n"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
inputs := []Input{
|
||||||
|
{Source: Bytes("left", []byte(test.left)), Destination: Keys("application")},
|
||||||
|
{Source: Bytes("right", []byte(test.right)), Destination: Keys("application")},
|
||||||
|
}
|
||||||
|
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
|
||||||
|
Underlays: []Underlay{{Inputs: inputs}},
|
||||||
|
})
|
||||||
|
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||||
|
t.Fatalf("strict composition result = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("strict conflict is not hidden by an upper override", func(t *testing.T) {
|
||||||
|
inputs := []Input{
|
||||||
|
{Source: Bytes("left", []byte("value: left\n")), Destination: Keys("application")},
|
||||||
|
{Source: Bytes("right", []byte("value: right\n")), Destination: Keys("application")},
|
||||||
|
}
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("main", []byte("value: upper\n")), Destination: Keys("application")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: inputs}}},
|
||||||
|
)
|
||||||
|
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||||
|
t.Fatalf("strict composition result = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredDestinationMatchesExplicitlyWrappedInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
load := func(input Input) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{input}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
return testLoaded(loaded).Config
|
||||||
|
}
|
||||||
|
|
||||||
|
placed := load(Input{Source: Bytes("placed", []byte("field: value\n")), Destination: Keys("application")})
|
||||||
|
authored := load(Input{Source: Bytes("authored", []byte("application: {field: value}\n"))})
|
||||||
|
if !reflect.DeepEqual(placed, authored) {
|
||||||
|
t.Fatalf("placed configuration %#v differs from explicitly wrapped %#v", placed, authored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyMountedMappingRetainsLowerChildren(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
destination := Keys("collections", "weather")
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("empty-main", []byte("{}\n")), Destination: destination},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: Bytes("lower", []byte("prompt: {title: retained}\n")), Destination: destination,
|
||||||
|
}}}}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
if got := layeredMapValue(t, testLoaded(loaded).Config, "collections", "weather", "prompt", "title"); got != "retained" {
|
||||||
|
t.Fatalf("lower child beneath empty mounted mapping = %#v, want retained", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredDestinationOverlayRetainsOrdinaryValueSemantics(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"overlays/test.yml": &fstest.MapFile{Data: []byte("settings:\n metadata:\n version: overlay\n")},
|
||||||
|
})
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("main", []byte("mapping: {shared: main}\nsequence: [9]\ncleared: null\nenabled: false\nmetadata: {version: main}\n")), Destination: Keys("settings")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: Bytes("underlay", []byte("mapping: {retained: lower, shared: lower}\nsequence: [1, 2]\ncleared: {old: true}\nenabled: true\ncount: 7\nmetadata: {version: lower, label: retained}\n")),
|
||||||
|
Destination: Keys("settings"),
|
||||||
|
}}}},
|
||||||
|
Overlay: "test",
|
||||||
|
OverlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||||
|
Overrides: []Override{{Path: Keys("settings", "count"), Value: 0, Flag: "--count"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
settings := layeredMap(t, result.Config, "settings")
|
||||||
|
mapping := layeredMap(t, settings, "mapping")
|
||||||
|
if mapping["retained"] != "lower" || mapping["shared"] != "main" {
|
||||||
|
t.Fatalf("recursive mapping overlay = %#v", mapping)
|
||||||
|
}
|
||||||
|
sequence, ok := settings["sequence"].([]any)
|
||||||
|
if !ok || len(sequence) != 1 || sequence[0] != int64(9) {
|
||||||
|
t.Fatalf("sequence overlay = %#v", settings["sequence"])
|
||||||
|
}
|
||||||
|
if value, present := settings["cleared"]; !present || value != nil {
|
||||||
|
t.Fatalf("explicit null = %#v, present %v", value, present)
|
||||||
|
}
|
||||||
|
if value, ok := settings["enabled"].(bool); !ok || value {
|
||||||
|
t.Fatalf("explicit false = %#v", settings["enabled"])
|
||||||
|
}
|
||||||
|
if value, ok := settings["count"].(int64); !ok || value != 0 {
|
||||||
|
t.Fatalf("explicit zero override = %#v", settings["count"])
|
||||||
|
}
|
||||||
|
metadata := layeredMap(t, settings, "metadata")
|
||||||
|
if metadata["version"] != "overlay" || metadata["label"] != "retained" {
|
||||||
|
t.Fatalf("version-only overlay = %#v", metadata)
|
||||||
|
}
|
||||||
|
if _, present := settings["settings"]; present {
|
||||||
|
t.Fatalf("upper tiers were incorrectly nested under the main destination: %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredDestinationChecksVersionOnceOnComposedMain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type appConfig struct {
|
||||||
|
Version int8 `yaml:"version"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Main string `yaml:"main"`
|
||||||
|
Imported string `yaml:"imported"`
|
||||||
|
Lower string `yaml:"lower"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
App appConfig `yaml:"app"`
|
||||||
|
}
|
||||||
|
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\nversion: 6\ncount: 4\nmain: main\n")},
|
||||||
|
"fragment.yml": &fstest.MapFile{Data: []byte("imported: included\n")},
|
||||||
|
})
|
||||||
|
checks := 0
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: filesystem.File("main.yml"), Imports: true, Destination: Keys("app")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[config]{
|
||||||
|
Definition: Definition[config]{Version: &VersionRule{Path: Keys("app", "version"), Check: func(version Version, _ *Validation) error {
|
||||||
|
checks++
|
||||||
|
if !version.Present || version.Text != "6" {
|
||||||
|
t.Errorf("nested main version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}},
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: Bytes("lower", []byte("lower: underlay\n")), Destination: Keys("app"),
|
||||||
|
}}}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
if checks != 1 {
|
||||||
|
t.Fatalf("version hook calls = %d, want 1", checks)
|
||||||
|
}
|
||||||
|
if result.Config.App.Version != 6 || result.Config.App.Count != 4 || result.Config.App.Main != "main" || result.Config.App.Imported != "included" || result.Config.App.Lower != "underlay" {
|
||||||
|
t.Fatalf("nested app = %#v", result.Config.App)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredDestinationVersionClaimsUsePlacedApplicationPaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rule := &VersionRule{Path: Keys("app", "schema", "version"), Check: func(Version, *Validation) error { return nil }}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantCode Code
|
||||||
|
wantValue any
|
||||||
|
}{
|
||||||
|
{name: "underlay direct claim", body: "schema: {version: v2}\n", wantCode: CodeUnderlayVersion},
|
||||||
|
{name: "underlay ancestor claim", body: "schema: null\n", wantCode: CodeUnderlayVersion},
|
||||||
|
{name: "underlay mapping ancestor without claim", body: "schema: {name: ordinary}\n", wantValue: "ordinary"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: NoSource()},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{
|
||||||
|
Definition: Definition[map[string]any]{Version: rule},
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: Bytes("underlay", []byte(test.body)), Destination: Keys("app"),
|
||||||
|
}}}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if test.wantCode != "" {
|
||||||
|
if loaded != nil || groupedErrorCode(t, err) != test.wantCode {
|
||||||
|
t.Fatalf("version claim result = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nonclaiming mapping ancestor was rejected: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
if got := layeredMapValue(t, result.Config, "app", "schema", "name"); got != test.wantValue {
|
||||||
|
t.Fatalf("nested ordinary value = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantCode Code
|
||||||
|
}{
|
||||||
|
{name: "imported direct claim", body: "schema: {version: v2}\n", wantCode: CodeImportVersion},
|
||||||
|
{name: "imported ancestor claim", body: "schema: null\n", wantCode: CodeImportVersion},
|
||||||
|
{name: "imported mapping ancestor without claim", body: "schema: {name: ordinary}\n"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
directory := t.TempDir()
|
||||||
|
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
|
||||||
|
writeImportFile(t, directory, "fragment.yml", test.body)
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: NoSource()},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{
|
||||||
|
Definition: Definition[map[string]any]{Version: rule},
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: Keys("app"),
|
||||||
|
}}}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if test.wantCode != "" {
|
||||||
|
if loaded != nil || groupedErrorCode(t, err) != test.wantCode {
|
||||||
|
t.Fatalf("version claim result = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nonclaiming imported mapping ancestor was rejected: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
if got := layeredMapValue(t, result.Config, "app", "schema", "name"); got != "ordinary" {
|
||||||
|
t.Fatalf("nested ordinary value = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func layeredMap(t *testing.T, value map[string]any, key string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
result, ok := value[key].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%q = %#v, want mapping", key, value[key])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func layeredMapValue(t *testing.T, value map[string]any, keys ...string) any {
|
||||||
|
t.Helper()
|
||||||
|
for _, key := range keys[:len(keys)-1] {
|
||||||
|
value = layeredMap(t, value, key)
|
||||||
|
}
|
||||||
|
return value[keys[len(keys)-1]]
|
||||||
|
}
|
||||||
@@ -85,15 +85,28 @@ func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
|||||||
for i := range canonical {
|
for i := range canonical {
|
||||||
slices.SortFunc(canonical[i].Related, compareLocation)
|
slices.SortFunc(canonical[i].Related, compareLocation)
|
||||||
}
|
}
|
||||||
slices.SortFunc(canonical, compareDiagnostic)
|
type orderedDiagnostic struct {
|
||||||
|
diagnostic Diagnostic
|
||||||
|
pointer string
|
||||||
|
}
|
||||||
|
ordered := make([]orderedDiagnostic, len(canonical))
|
||||||
|
for i, diagnostic := range canonical {
|
||||||
|
ordered[i] = orderedDiagnostic{diagnostic: diagnostic, pointer: diagnostic.Path.String()}
|
||||||
|
}
|
||||||
|
slices.SortFunc(ordered, func(left, right orderedDiagnostic) int {
|
||||||
|
return compareDiagnosticWithPaths(left.diagnostic, right.diagnostic, left.pointer, right.pointer)
|
||||||
|
})
|
||||||
|
for i, item := range ordered {
|
||||||
|
canonical[i] = item.diagnostic
|
||||||
|
}
|
||||||
return canonical
|
return canonical
|
||||||
}
|
}
|
||||||
|
|
||||||
func compareDiagnostic(left, right Diagnostic) int {
|
func compareDiagnosticWithPaths(left, right Diagnostic, leftPath, rightPath string) int {
|
||||||
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
|
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
|
||||||
return order
|
return order
|
||||||
}
|
}
|
||||||
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
|
if order := cmp.Compare(leftPath, rightPath); order != 0 {
|
||||||
return order
|
return order
|
||||||
}
|
}
|
||||||
if order := cmp.Compare(left.Code, right.Code); order != 0 {
|
if order := cmp.Compare(left.Code, right.Code); order != 0 {
|
||||||
|
|||||||
@@ -90,6 +90,26 @@ func TestDiagnosticsAreCanonicalAndDetached(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanonicalDiagnosticsSortEscapedPaths(t *testing.T) {
|
||||||
|
primary := &Location{SourceID: "source-1", Line: 1, Column: 1}
|
||||||
|
diagnostics := []Diagnostic{
|
||||||
|
{Code: "same", Severity: SeverityError, Path: Keys("a/b"), Message: "same", Primary: primary},
|
||||||
|
{Code: "same", Severity: SeverityError, Path: Keys("a~b"), Message: "same", Primary: primary},
|
||||||
|
{Code: "z", Severity: SeverityError, Path: Keys("same"), Message: "same", Primary: primary},
|
||||||
|
{Code: "a", Severity: SeverityError, Path: Keys("same"), Message: "same", Primary: primary},
|
||||||
|
}
|
||||||
|
canonical := canonicalDiagnostics(diagnostics)
|
||||||
|
want := []string{"/a~0b", "/a~1b", "/same", "/same"}
|
||||||
|
for i, diagnostic := range canonical {
|
||||||
|
if got := diagnostic.Path.String(); got != want[i] {
|
||||||
|
t.Fatalf("diagnostic[%d] path = %q, want %q", i, got, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if canonical[2].Code != "a" || canonical[3].Code != "z" {
|
||||||
|
t.Fatalf("same-path code order = %q, %q", canonical[2].Code, canonical[3].Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteDiagnosticsEscapesControls(t *testing.T) {
|
func TestWriteDiagnosticsEscapesControls(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
98
internal/engine/diff.go
Normal file
98
internal/engine/diff.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errDiffInspectionUnavailable = errors.New("configkit: diff inspection unavailable")
|
||||||
|
var errDiffSnapshotUnavailable = errors.New("configkit: diff snapshot unavailable")
|
||||||
|
|
||||||
|
// Difference describes one canonical changed region with safe snapshots.
|
||||||
|
type Difference struct {
|
||||||
|
Path Path
|
||||||
|
Left ValueSnapshot
|
||||||
|
Right ValueSnapshot
|
||||||
|
HiddenChanged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type diffRegion struct {
|
||||||
|
path Path
|
||||||
|
left *document.Node
|
||||||
|
right *document.Node
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareDiffRegions(left, right *Inspection) ([]diffRegion, error) {
|
||||||
|
if left == nil || left.final == nil || right == nil || right.final == nil {
|
||||||
|
return nil, errDiffInspectionUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
deltas := compareConfigurationValues(left.final, right.final)
|
||||||
|
regions := make([]diffRegion, 0, len(deltas))
|
||||||
|
for _, delta := range deltas {
|
||||||
|
leftNode, leftPresent := documentAtPathWithLookup(left.final, delta.path, left.immutableLookup)
|
||||||
|
if !leftPresent {
|
||||||
|
leftNode = nil
|
||||||
|
}
|
||||||
|
rightNode, rightPresent := documentAtPathWithLookup(right.final, delta.path, right.immutableLookup)
|
||||||
|
if !rightPresent {
|
||||||
|
rightNode = nil
|
||||||
|
}
|
||||||
|
regions = append(regions, diffRegion{path: delta.path, left: leftNode, right: rightNode})
|
||||||
|
}
|
||||||
|
return regions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff compares the final frozen values retained by two successful inspections.
|
||||||
|
// Values are serialized only after applying the combined sensitivity of both loads.
|
||||||
|
func Diff(left, right *Inspection) ([]Difference, error) {
|
||||||
|
regions, err := prepareDiffRegions(left, right)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sensitivity := newDiffSensitivity(left.secrets, right.secrets)
|
||||||
|
differences := make([]Difference, 0, len(regions))
|
||||||
|
for _, region := range regions {
|
||||||
|
paired, supported := preparePairedDiffSnapshot(region.left, region.right, region.path, sensitivity)
|
||||||
|
if !supported {
|
||||||
|
return nil, errDiffSnapshotUnavailable
|
||||||
|
}
|
||||||
|
leftSnapshot, err := serializeDiffSnapshot(paired.left, paired.leftRedacted)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errDiffSnapshotUnavailable
|
||||||
|
}
|
||||||
|
rightSnapshot, err := serializeDiffSnapshot(paired.right, paired.rightRedacted)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errDiffSnapshotUnavailable
|
||||||
|
}
|
||||||
|
differences = append(differences, Difference{
|
||||||
|
Path: region.path,
|
||||||
|
Left: leftSnapshot,
|
||||||
|
Right: rightSnapshot,
|
||||||
|
HiddenChanged: paired.hiddenChanged,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return differences, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serializeDiffSnapshot(node *document.Node, redacted bool) (ValueSnapshot, error) {
|
||||||
|
if node == nil {
|
||||||
|
return ValueSnapshot{}, nil
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := writeDiffDocument(&output, node); err != nil {
|
||||||
|
return ValueSnapshot{}, errDiffSnapshotUnavailable
|
||||||
|
}
|
||||||
|
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDiffDocument(output io.Writer, node *document.Node) error {
|
||||||
|
if err := yamladapter.Write(output, node); err != nil {
|
||||||
|
return errDiffSnapshotUnavailable
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
130
internal/engine/diff_hidden_test.go
Normal file
130
internal/engine/diff_hidden_test.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotReportsWhollyMaskedChanges(t *testing.T) {
|
||||||
|
left := &document.Node{Kind: document.String, Text: "private-before"}
|
||||||
|
right := &document.Node{Kind: document.String, Text: "private-after"}
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
left, right, Keys("credential"), newDiffSensitivity(nil, map[string]bool{"/credential": true}),
|
||||||
|
)
|
||||||
|
if !supported || !paired.hiddenChanged || !paired.leftRedacted || !paired.rightRedacted {
|
||||||
|
t.Fatalf("masked change = %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, paired.left, "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, paired.right, "[REDACTED]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotDetectsRawChangesBehindIdenticalMarkers(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", "token: old-canary\n")
|
||||||
|
right := parseCompositionDocument(t, "right", "token: new-canary\n")
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
mappingValue(t, left, "token"), mappingValue(t, right, "token"), Keys("token"),
|
||||||
|
newDiffSensitivity(map[string]bool{"/token": true}, nil),
|
||||||
|
)
|
||||||
|
if !supported || !paired.hiddenChanged || paired.left.Text != paired.right.Text {
|
||||||
|
t.Fatalf("identically rendered masked values lost raw change: %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotCombinesPublicAndHiddenChanges(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", "public: before\ntoken: old\n")
|
||||||
|
right := parseCompositionDocument(t, "right", "public: after\ntoken: new\n")
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
left, right, Path{}, newDiffSensitivity(nil, map[string]bool{"/token": true}),
|
||||||
|
)
|
||||||
|
if !supported || !paired.hiddenChanged {
|
||||||
|
t.Fatalf("mixed public and hidden change = %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.left, "public"), "before")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.right, "public"), "after")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.left, "token"), "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.right, "token"), "[REDACTED]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotDoesNotFlagUnchangedMaskedValues(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", "public: before\ntoken: unchanged\n")
|
||||||
|
right := parseCompositionDocument(t, "right", "public: after\ntoken: unchanged\n")
|
||||||
|
regions, err := prepareDiffRegions(diffTestInspection(left), diffTestInspection(right))
|
||||||
|
if err != nil || len(regions) != 1 || regions[0].path.String() != "/public" {
|
||||||
|
t.Fatalf("changed regions = %#v, err = %v; want only /public", regions, err)
|
||||||
|
}
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
left, right, Path{}, newDiffSensitivity(map[string]bool{"/token": true}, nil),
|
||||||
|
)
|
||||||
|
if !supported || !paired.leftRedacted || !paired.rightRedacted || paired.hiddenChanged {
|
||||||
|
t.Fatalf("unchanged masked values affected hidden-change state: %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
|
||||||
|
leftSequence := parseCompositionDocument(t, "left-sequence", "items: [unchanged, before]\n")
|
||||||
|
rightSequence := parseCompositionDocument(t, "right-sequence", "items: [unchanged, after]\n")
|
||||||
|
sequence, supported := preparePairedDiffSnapshot(
|
||||||
|
mappingValue(t, leftSequence, "items"), mappingValue(t, rightSequence, "items"), Keys("items"),
|
||||||
|
newDiffSensitivity(nil, map[string]bool{"/items/0": true}),
|
||||||
|
)
|
||||||
|
if !supported || !sequence.leftRedacted || !sequence.rightRedacted || sequence.hiddenChanged {
|
||||||
|
t.Fatalf("unchanged masked sequence member affected hidden-change state: %#v, supported=%v", sequence, supported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotReportsMaskedAdditionAndRemoval(t *testing.T) {
|
||||||
|
value := &document.Node{Kind: document.String, Text: "private"}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
left, right *document.Node
|
||||||
|
leftHidden bool
|
||||||
|
rightHidden bool
|
||||||
|
}{
|
||||||
|
{name: "addition", right: value, rightHidden: true},
|
||||||
|
{name: "removal", left: value, leftHidden: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
test.left, test.right, Keys("token"), newDiffSensitivity(nil, map[string]bool{"/token": true}),
|
||||||
|
)
|
||||||
|
if !supported || !paired.hiddenChanged || paired.leftRedacted != test.leftHidden || paired.rightRedacted != test.rightHidden {
|
||||||
|
t.Fatalf("masked presence change = %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
if test.left == nil && paired.left != nil || test.right == nil && paired.right != nil {
|
||||||
|
t.Fatalf("absence became a value: %#v", paired)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotReportsHiddenSequenceLengthChange(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", "items: [public]\n")
|
||||||
|
right := parseCompositionDocument(t, "right", "items: [public, private]\n")
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
mappingValue(t, left, "items"), mappingValue(t, right, "items"), Keys("items"),
|
||||||
|
newDiffSensitivity(nil, map[string]bool{"/items/1": true}),
|
||||||
|
)
|
||||||
|
if !supported || !paired.hiddenChanged || paired.leftRedacted || !paired.rightRedacted {
|
||||||
|
t.Fatalf("masked sequence length change = %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
if len(paired.left.Sequence) != 1 || len(paired.right.Sequence) != 2 {
|
||||||
|
t.Fatalf("safe sequence lengths = %d, %d; want 1, 2", len(paired.left.Sequence), len(paired.right.Sequence))
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, paired.right.Sequence[1], "[REDACTED]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotClassificationAloneDoesNotCreateHiddenChange(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", "token: same\n")
|
||||||
|
right := parseCompositionDocument(t, "right", "token: same\n")
|
||||||
|
regions, err := prepareDiffRegions(diffTestInspection(left), diffTestInspection(right))
|
||||||
|
if err != nil || len(regions) != 0 {
|
||||||
|
t.Fatalf("classification-independent changed regions = %#v, err = %v", regions, err)
|
||||||
|
}
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
mappingValue(t, left, "token"), mappingValue(t, right, "token"), Keys("token"),
|
||||||
|
newDiffSensitivity(map[string]bool{"/token": true}, nil),
|
||||||
|
)
|
||||||
|
if !supported || !paired.leftRedacted || !paired.rightRedacted || paired.hiddenChanged {
|
||||||
|
t.Fatalf("classification alone reported hidden change: %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
}
|
||||||
282
internal/engine/diff_operation_test.go
Normal file
282
internal/engine/diff_operation_test.go
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type diffIntegrationConfig struct {
|
||||||
|
Defaulted string `yaml:"defaulted"`
|
||||||
|
Dynamic map[string]any `yaml:"dynamic"`
|
||||||
|
Credential Secret `yaml:"credential" configkit:"secret"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type diffOptionalSecretConfig struct {
|
||||||
|
Optional *Secret `yaml:"optional" configkit:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffUsesFinalValuesAndDependencyDerivedSensitivity(t *testing.T) {
|
||||||
|
var resolveCalls int
|
||||||
|
options := Options[diffIntegrationConfig]{Definition: Definition[diffIntegrationConfig]{
|
||||||
|
Defaults: func() diffIntegrationConfig {
|
||||||
|
return diffIntegrationConfig{Defaulted: "from-default", Dynamic: map[string]any{"defaulted": "retained"}}
|
||||||
|
},
|
||||||
|
Resolve: func(configuration *diffIntegrationConfig, resolution *Resolution) error {
|
||||||
|
resolveCalls++
|
||||||
|
configuration.Derived = configuration.Dynamic["visible"].(string)
|
||||||
|
resolution.Record(Keys("derived"), ChangeNormalization, "derive from effective values", Keys("credential"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
input := func(value string) Source {
|
||||||
|
return Bytes("effective.yml", []byte("dynamic: {visible: "+value+"}\ncredential: DIFF_ENGINE_SECRET\n"))
|
||||||
|
}
|
||||||
|
t.Setenv("DIFF_ENGINE_SECRET", "credential-left-canary")
|
||||||
|
left, err := testLoad(input("before"), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("DIFF_ENGINE_SECRET", "credential-right-canary")
|
||||||
|
right, err := testLoad(input("after"), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if left.Config.Defaulted != "from-default" || right.Config.Defaulted != "from-default" {
|
||||||
|
t.Fatalf("defaulted values = %q, %q", left.Config.Defaulted, right.Config.Defaulted)
|
||||||
|
}
|
||||||
|
if !left.Inspection.secrets["/credential"] || !left.Inspection.secrets["/derived"] ||
|
||||||
|
!right.Inspection.secrets["/credential"] || !right.Inspection.secrets["/derived"] {
|
||||||
|
t.Fatalf("typed or dependency-derived sensitivity was not retained: left=%#v right=%#v", left.Inspection.secrets, right.Inspection.secrets)
|
||||||
|
}
|
||||||
|
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("derived"), left.Inspection.immutableLookup); !present || node.Text != "before" {
|
||||||
|
t.Fatalf("left frozen derived value = %#v, present=%v", node, present)
|
||||||
|
}
|
||||||
|
|
||||||
|
differences, err := Diff(left.Inspection, right.Inspection)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(differences) != 2 || differences[0].Path.String() != "/derived" || differences[1].Path.String() != "/dynamic/visible" {
|
||||||
|
t.Fatalf("differences = %#v", differences)
|
||||||
|
}
|
||||||
|
if !differences[0].Left.Redacted || !differences[0].Right.Redacted || !differences[0].HiddenChanged ||
|
||||||
|
differences[0].Left.YAML != "'[REDACTED]'\n" || differences[0].Right.YAML != "'[REDACTED]'\n" {
|
||||||
|
t.Fatalf("dependency-derived difference = %#v", differences[0])
|
||||||
|
}
|
||||||
|
if differences[1].Left.Redacted || differences[1].Right.Redacted || differences[1].HiddenChanged ||
|
||||||
|
differences[1].Left.YAML != "before\n" || differences[1].Right.YAML != "after\n" {
|
||||||
|
t.Fatalf("public dynamic difference = %#v", differences[1])
|
||||||
|
}
|
||||||
|
for _, value := range differences {
|
||||||
|
if strings.Contains(value.Left.YAML+value.Right.YAML, "credential-") {
|
||||||
|
t.Fatal("diff snapshot exposed a resolved credential")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var effective bytes.Buffer
|
||||||
|
if err := left.Inspection.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(effective.String(), "credential-left-canary") {
|
||||||
|
t.Fatal("standalone effective rendering exposed a credential after diff")
|
||||||
|
}
|
||||||
|
explanation, ok := left.Inspection.Explain(Keys("derived"))
|
||||||
|
if !ok || !explanation.Effective.Redacted || explanation.Effective.YAML != "'[REDACTED]'\n" {
|
||||||
|
t.Fatalf("explanation changed after diff: %#v, ok=%v", explanation, ok)
|
||||||
|
}
|
||||||
|
if _, ok := left.Inspection.Metadata().Lookup(Keys("dynamic", "visible")); !ok {
|
||||||
|
t.Fatal("metadata lookup failed after diff")
|
||||||
|
}
|
||||||
|
if resolveCalls != 2 {
|
||||||
|
t.Fatalf("resolution callbacks ran during diff: %d calls", resolveCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returned records can be modified without affecting a later comparison.
|
||||||
|
differences[0].Path.segments[0].key = "changed-by-caller"
|
||||||
|
differences[0].Left.YAML = "changed-by-caller"
|
||||||
|
t.Setenv("DIFF_ENGINE_SECRET", "credential-after-load-canary")
|
||||||
|
repeated, err := Diff(left.Inspection, right.Inspection)
|
||||||
|
if err != nil || len(repeated) != 2 || repeated[0].Path.String() != "/derived" || repeated[0].Left.YAML != "'[REDACTED]'\n" {
|
||||||
|
t.Fatalf("repeated diff = %#v, err = %v", repeated, err)
|
||||||
|
}
|
||||||
|
if resolveCalls != 2 {
|
||||||
|
t.Fatalf("repeated diff reran resolution: %d calls", resolveCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
independent, err := testLoad(input("before"), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolveCalls != 3 {
|
||||||
|
t.Fatalf("independent load did not retain normal resolution behavior: %d calls", resolveCalls)
|
||||||
|
}
|
||||||
|
if _, err := Diff(left.Inspection, independent.Inspection); err != nil {
|
||||||
|
t.Fatalf("independent load comparison = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffUsesReferenceNamesAndFrozenSecretState(t *testing.T) {
|
||||||
|
loadReference := func(name string) *testResult[diffOptionalSecretConfig] {
|
||||||
|
t.Helper()
|
||||||
|
result, err := testLoad[diffOptionalSecretConfig](Bytes("secret-reference.yml", []byte("optional: "+name+"\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
t.Setenv("DIFF_ROTATING_SECRET", "first-credential-canary")
|
||||||
|
left := loadReference("DIFF_ROTATING_SECRET")
|
||||||
|
t.Setenv("DIFF_ROTATING_SECRET", "second-credential-canary")
|
||||||
|
right := loadReference("DIFF_ROTATING_SECRET")
|
||||||
|
t.Setenv("DIFF_ROTATING_SECRET", "changed-after-load-canary")
|
||||||
|
differences, err := Diff(left.Inspection, right.Inspection)
|
||||||
|
if err != nil || len(differences) != 0 {
|
||||||
|
t.Fatalf("credential rotation behind one reference = %#v, err = %v", differences, err)
|
||||||
|
}
|
||||||
|
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("optional"), left.Inspection.immutableLookup); !present || node.Text != "DIFF_ROTATING_SECRET" {
|
||||||
|
t.Fatalf("frozen reference = %#v, present=%v", node, present)
|
||||||
|
}
|
||||||
|
if node, present := documentAtPathWithLookup(right.Inspection.final, Keys("optional"), right.Inspection.immutableLookup); !present || node.Text != "DIFF_ROTATING_SECRET" {
|
||||||
|
t.Fatalf("right frozen reference = %#v, present=%v", node, present)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("DIFF_OTHER_SECRET", "other-credential-canary")
|
||||||
|
other := loadReference("DIFF_OTHER_SECRET")
|
||||||
|
differences, err = Diff(left.Inspection, other.Inspection)
|
||||||
|
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/optional" {
|
||||||
|
t.Fatalf("reference-name change = %#v, err = %v", differences, err)
|
||||||
|
}
|
||||||
|
if !differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Left.Redacted ||
|
||||||
|
!differences[0].Right.Redacted || !differences[0].HiddenChanged ||
|
||||||
|
differences[0].Left.YAML != "'[REDACTED]'\n" || differences[0].Right.YAML != "'[REDACTED]'\n" {
|
||||||
|
t.Fatalf("classified reference-name diff = %#v", differences[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
absent, err := testLoad[diffOptionalSecretConfig](Bytes("absent-secret.yml", []byte("{}\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if absent.Config.Optional != nil {
|
||||||
|
t.Fatalf("missing optional reference decoded as %#v", absent.Config.Optional)
|
||||||
|
}
|
||||||
|
if node, present := documentAtPathWithLookup(absent.Inspection.final, Keys("optional"), absent.Inspection.immutableLookup); !present || node.Kind != document.Null {
|
||||||
|
t.Fatalf("optional nil projection = %#v, present=%v", node, present)
|
||||||
|
}
|
||||||
|
differences, err = Diff(absent.Inspection, left.Inspection)
|
||||||
|
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/optional" {
|
||||||
|
t.Fatalf("optional absence diff = %#v, err = %v", differences, err)
|
||||||
|
}
|
||||||
|
if !differences[0].Left.Present || !differences[0].Left.Redacted ||
|
||||||
|
!differences[0].Right.Present || !differences[0].Right.Redacted || !differences[0].HiddenChanged {
|
||||||
|
t.Fatalf("optional reference change snapshots = %#v", differences[0])
|
||||||
|
}
|
||||||
|
for _, value := range differences {
|
||||||
|
if strings.Contains(value.Left.YAML+value.Right.YAML, "credential-canary") {
|
||||||
|
t.Fatal("reference diff exposed a resolved credential")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffProtectsHistoricalSensitiveRemovalAndReplacement(t *testing.T) {
|
||||||
|
type configuration struct {
|
||||||
|
Dynamic map[string]any `yaml:"dynamic"`
|
||||||
|
}
|
||||||
|
secretPath := Keys("dynamic", "token")
|
||||||
|
secretSelector := func(path Path) bool { return path.String() == secretPath.String() }
|
||||||
|
load := func(input string, resolve func(*configuration, *Resolution) error) *testResult[configuration] {
|
||||||
|
t.Helper()
|
||||||
|
result, err := testLoad(Bytes("historical-secret.yml", []byte(input)), Options[configuration]{Definition: Definition[configuration]{
|
||||||
|
Secret: secretSelector,
|
||||||
|
Resolve: resolve,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("removed historical value", func(t *testing.T) {
|
||||||
|
left := load("dynamic: {token: removed-canary}\n", func(configuration *configuration, resolution *Resolution) error {
|
||||||
|
delete(configuration.Dynamic, "token")
|
||||||
|
resolution.Record(secretPath, ChangeNormalization, "remove token")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
right := load("dynamic: {token: current-canary}\n", nil)
|
||||||
|
if !left.Inspection.secrets[secretPath.String()] {
|
||||||
|
t.Fatal("removed historical selector was not retained")
|
||||||
|
}
|
||||||
|
if _, present := documentAtPathWithLookup(left.Inspection.final, secretPath, left.Inspection.immutableLookup); present {
|
||||||
|
t.Fatal("removed token remains in the effective left tree")
|
||||||
|
}
|
||||||
|
differences, err := Diff(left.Inspection, right.Inspection)
|
||||||
|
if err != nil || len(differences) != 1 || differences[0].Path.String() != secretPath.String() {
|
||||||
|
t.Fatalf("historical removal diff = %#v, err = %v", differences, err)
|
||||||
|
}
|
||||||
|
if differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Right.Redacted || !differences[0].HiddenChanged {
|
||||||
|
t.Fatalf("historical removal snapshots = %#v", differences[0])
|
||||||
|
}
|
||||||
|
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "canary") {
|
||||||
|
t.Fatal("historical removal diff exposed a sensitive value")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replaced historical value", func(t *testing.T) {
|
||||||
|
left := load("dynamic: {token: original-canary}\n", func(configuration *configuration, resolution *Resolution) error {
|
||||||
|
configuration.Dynamic["token"] = "replacement-canary"
|
||||||
|
resolution.Record(secretPath, ChangeNormalization, "replace token")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
right := load("dynamic: {token: other-canary}\n", nil)
|
||||||
|
if !left.Inspection.secrets[secretPath.String()] {
|
||||||
|
t.Fatal("replaced historical selector was not retained")
|
||||||
|
}
|
||||||
|
differences, err := Diff(left.Inspection, right.Inspection)
|
||||||
|
if err != nil || len(differences) != 1 || differences[0].Path.String() != secretPath.String() {
|
||||||
|
t.Fatalf("historical replacement diff = %#v, err = %v", differences, err)
|
||||||
|
}
|
||||||
|
if !differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Left.Redacted ||
|
||||||
|
!differences[0].Right.Redacted || !differences[0].HiddenChanged {
|
||||||
|
t.Fatalf("historical replacement snapshots = %#v", differences[0])
|
||||||
|
}
|
||||||
|
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "canary") {
|
||||||
|
t.Fatal("historical replacement diff exposed a sensitive value")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffReturnsSafeErrorsWithoutRecords(t *testing.T) {
|
||||||
|
valid := diffTestInspection(&document.Node{Kind: document.String, Text: "safe"})
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
left, right *Inspection
|
||||||
|
}{
|
||||||
|
{name: "nil left", right: valid},
|
||||||
|
{name: "nil right", left: valid},
|
||||||
|
{name: "missing final tree", left: &Inspection{}, right: valid},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
differences, err := Diff(test.left, test.right)
|
||||||
|
if differences != nil || err == nil || err != errDiffInspectionUnavailable || strings.Contains(err.Error(), "safe") {
|
||||||
|
t.Fatalf("differences, err = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSerializationErrorDoesNotExposeWriterDetails(t *testing.T) {
|
||||||
|
err := writeDiffDocument(diffErrorWriter{}, &document.Node{Kind: document.String, Text: "safe"})
|
||||||
|
if err != errDiffSnapshotUnavailable || strings.Contains(err.Error(), "CANARY") {
|
||||||
|
t.Fatalf("serialization error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type diffErrorWriter struct{}
|
||||||
|
|
||||||
|
func (diffErrorWriter) Write([]byte) (int, error) {
|
||||||
|
return 0, errors.New("CANARY_WRITER_ERROR")
|
||||||
|
}
|
||||||
48
internal/engine/diff_sensitivity.go
Normal file
48
internal/engine/diff_sensitivity.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
type diffSensitivity struct {
|
||||||
|
prefixes pathPrefixIndex
|
||||||
|
pointers []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDiffSensitivity(left, right map[string]bool) *diffSensitivity {
|
||||||
|
sensitivity := &diffSensitivity{}
|
||||||
|
seen := make(map[string]struct{}, len(left)+len(right))
|
||||||
|
for _, classifications := range []map[string]bool{left, right} {
|
||||||
|
for pointer, classified := range classifications {
|
||||||
|
if !classified {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, found := seen[pointer]; found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[pointer] = struct{}{}
|
||||||
|
id := len(sensitivity.pointers)
|
||||||
|
sensitivity.pointers = append(sensitivity.pointers, pointer)
|
||||||
|
sensitivity.prefixes.insert(pointer, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sensitivity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sensitivity *diffSensitivity) isSensitiveAtOrAbove(pointer string) bool {
|
||||||
|
if sensitivity == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
sensitivity.prefixes.visitAncestors(pointer, func(int) { found = true })
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sensitivity *diffSensitivity) hasStrictDescendant(pointer string) bool {
|
||||||
|
if sensitivity == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
sensitivity.prefixes.visitDescendants(pointer, func(id int) {
|
||||||
|
if sensitivity.pointers[id] != pointer {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
90
internal/engine/diff_sensitivity_test.go
Normal file
90
internal/engine/diff_sensitivity_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiffSensitivityCombinesClassificationsWithoutMutation(t *testing.T) {
|
||||||
|
left := map[string]bool{
|
||||||
|
"/a": true,
|
||||||
|
"/a/b": true,
|
||||||
|
"/historical/removed": true,
|
||||||
|
"/cross~1key": true,
|
||||||
|
"/literal~01slash": true,
|
||||||
|
"/items/01": true,
|
||||||
|
"/items/+1": false,
|
||||||
|
"/false-left": false,
|
||||||
|
}
|
||||||
|
right := map[string]bool{
|
||||||
|
"/a/b": true,
|
||||||
|
"/right-only": true,
|
||||||
|
"/false-right": false,
|
||||||
|
}
|
||||||
|
leftBefore, rightBefore := maps.Clone(left), maps.Clone(right)
|
||||||
|
sensitivity := newDiffSensitivity(left, right)
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
pointer string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{pointer: "/a", want: true},
|
||||||
|
{pointer: "/a/b", want: true},
|
||||||
|
{pointer: "/a/b/c", want: true},
|
||||||
|
{pointer: "/ab", want: false},
|
||||||
|
{pointer: "/ab/c", want: false},
|
||||||
|
{pointer: "/historical/removed", want: true},
|
||||||
|
{pointer: "/historical/removed/child", want: true},
|
||||||
|
{pointer: "/historical", want: false},
|
||||||
|
{pointer: "/right-only", want: true},
|
||||||
|
{pointer: "/false-left", want: false},
|
||||||
|
{pointer: "/false-right", want: false},
|
||||||
|
{pointer: "/cross~1key", want: true},
|
||||||
|
{pointer: "/cross/key", want: false},
|
||||||
|
{pointer: "/literal~01slash", want: true},
|
||||||
|
{pointer: "/literal~1slash", want: false},
|
||||||
|
{pointer: "/items/01", want: true},
|
||||||
|
{pointer: "/items/1", want: false},
|
||||||
|
{pointer: "/items/+1", want: false},
|
||||||
|
} {
|
||||||
|
if got := sensitivity.isSensitiveAtOrAbove(test.pointer); got != test.want {
|
||||||
|
t.Errorf("isSensitiveAtOrAbove(%q) = %v, want %v", test.pointer, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
pointer string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{pointer: "", want: true},
|
||||||
|
{pointer: "/a", want: true},
|
||||||
|
{pointer: "/a/b", want: false},
|
||||||
|
{pointer: "/ab", want: false},
|
||||||
|
{pointer: "/historical", want: true},
|
||||||
|
{pointer: "/right-only", want: false},
|
||||||
|
{pointer: "/cross~1key", want: false},
|
||||||
|
} {
|
||||||
|
if got := sensitivity.hasStrictDescendant(test.pointer); got != test.want {
|
||||||
|
t.Errorf("hasStrictDescendant(%q) = %v, want %v", test.pointer, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !maps.Equal(left, leftBefore) || !maps.Equal(right, rightBefore) {
|
||||||
|
t.Fatal("classification maps were modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSensitivityDistinguishesRootAndEmptyKey(t *testing.T) {
|
||||||
|
root := newDiffSensitivity(map[string]bool{"": true}, nil)
|
||||||
|
if !root.isSensitiveAtOrAbove("") || !root.isSensitiveAtOrAbove("/empty") ||
|
||||||
|
root.hasStrictDescendant("") {
|
||||||
|
t.Fatal("root classification did not remain distinct from strict descendants")
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyKey := newDiffSensitivity(map[string]bool{"/": true}, nil)
|
||||||
|
if emptyKey.isSensitiveAtOrAbove("") || !emptyKey.isSensitiveAtOrAbove("/") ||
|
||||||
|
emptyKey.isSensitiveAtOrAbove("/other") || !emptyKey.hasStrictDescendant("") ||
|
||||||
|
emptyKey.hasStrictDescendant("/") {
|
||||||
|
t.Fatal("empty-key classification was treated as the root or an unrelated path")
|
||||||
|
}
|
||||||
|
}
|
||||||
195
internal/engine/diff_snapshot.go
Normal file
195
internal/engine/diff_snapshot.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pairedDiffSnapshot struct {
|
||||||
|
left *document.Node
|
||||||
|
right *document.Node
|
||||||
|
leftRedacted bool
|
||||||
|
rightRedacted bool
|
||||||
|
requiresFallback bool
|
||||||
|
hiddenChanged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparePairedDiffSnapshot(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) {
|
||||||
|
paired, supported := preparePairedDiffNodes(left, right, path, sensitivity)
|
||||||
|
if !supported || !paired.requiresFallback {
|
||||||
|
return paired, supported
|
||||||
|
}
|
||||||
|
return pairedDiffSnapshot{
|
||||||
|
left: redactedDiffNode(left),
|
||||||
|
right: redactedDiffNode(right),
|
||||||
|
leftRedacted: left != nil,
|
||||||
|
rightRedacted: right != nil,
|
||||||
|
hiddenChanged: true,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparePairedDiffNodes(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) {
|
||||||
|
if left == nil && right == nil {
|
||||||
|
return pairedDiffSnapshot{}, true
|
||||||
|
}
|
||||||
|
if sensitivity.isSensitiveAtOrAbove(path.String()) {
|
||||||
|
return pairedDiffSnapshot{
|
||||||
|
left: redactedDiffNode(left),
|
||||||
|
right: redactedDiffNode(right),
|
||||||
|
leftRedacted: left != nil,
|
||||||
|
rightRedacted: right != nil,
|
||||||
|
hiddenChanged: !configurationValuesEqual(left, right),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
if left != nil && right != nil && left.Kind != right.Kind {
|
||||||
|
if sensitivity.hasStrictDescendant(path.String()) {
|
||||||
|
return pairedDiffSnapshot{requiresFallback: true}, true
|
||||||
|
}
|
||||||
|
return pairedDiffSnapshot{left: left, right: right}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := document.Null
|
||||||
|
if left != nil {
|
||||||
|
kind = left.Kind
|
||||||
|
} else if right != nil {
|
||||||
|
kind = right.Kind
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case document.Mapping:
|
||||||
|
return preparePairedDiffMapping(left, right, path, sensitivity)
|
||||||
|
case document.Sequence:
|
||||||
|
return preparePairedDiffSequence(left, right, path, sensitivity)
|
||||||
|
default:
|
||||||
|
return pairedDiffSnapshot{left: left, right: right}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparePairedDiffMapping(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) {
|
||||||
|
leftEntries := diffMappingEntries(left)
|
||||||
|
rightEntries := diffMappingEntries(right)
|
||||||
|
keys := make([]string, 0, len(leftEntries)+len(rightEntries))
|
||||||
|
seen := make(map[string]struct{}, len(leftEntries)+len(rightEntries))
|
||||||
|
for key := range leftEntries {
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
for key := range rightEntries {
|
||||||
|
if _, found := seen[key]; found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
var leftCopy, rightCopy *document.Node
|
||||||
|
if left != nil {
|
||||||
|
copy := *left
|
||||||
|
copy.Mapping = make([]document.Entry, 0, len(left.Mapping))
|
||||||
|
leftCopy = ©
|
||||||
|
}
|
||||||
|
if right != nil {
|
||||||
|
copy := *right
|
||||||
|
copy.Mapping = make([]document.Entry, 0, len(right.Mapping))
|
||||||
|
rightCopy = ©
|
||||||
|
}
|
||||||
|
redacted := pairedDiffSnapshot{}
|
||||||
|
for _, key := range keys {
|
||||||
|
leftEntry, leftPresent := leftEntries[key]
|
||||||
|
rightEntry, rightPresent := rightEntries[key]
|
||||||
|
var leftChild, rightChild *document.Node
|
||||||
|
if leftPresent {
|
||||||
|
leftChild = leftEntry.Value
|
||||||
|
}
|
||||||
|
if rightPresent {
|
||||||
|
rightChild = rightEntry.Value
|
||||||
|
}
|
||||||
|
child, supported := preparePairedDiffNodes(leftChild, rightChild, path.Key(key), sensitivity)
|
||||||
|
if !supported {
|
||||||
|
return pairedDiffSnapshot{}, false
|
||||||
|
}
|
||||||
|
if child.requiresFallback {
|
||||||
|
return pairedDiffSnapshot{requiresFallback: true}, true
|
||||||
|
}
|
||||||
|
if leftPresent {
|
||||||
|
leftEntry.Value = child.left
|
||||||
|
leftCopy.Mapping = append(leftCopy.Mapping, leftEntry)
|
||||||
|
}
|
||||||
|
if rightPresent {
|
||||||
|
rightEntry.Value = child.right
|
||||||
|
rightCopy.Mapping = append(rightCopy.Mapping, rightEntry)
|
||||||
|
}
|
||||||
|
redacted.leftRedacted = redacted.leftRedacted || child.leftRedacted
|
||||||
|
redacted.rightRedacted = redacted.rightRedacted || child.rightRedacted
|
||||||
|
redacted.hiddenChanged = redacted.hiddenChanged || child.hiddenChanged
|
||||||
|
}
|
||||||
|
redacted.left = leftCopy
|
||||||
|
redacted.right = rightCopy
|
||||||
|
return redacted, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparePairedDiffSequence(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) {
|
||||||
|
var leftCopy, rightCopy *document.Node
|
||||||
|
if left != nil {
|
||||||
|
copy := *left
|
||||||
|
copy.Sequence = make([]*document.Node, len(left.Sequence))
|
||||||
|
leftCopy = ©
|
||||||
|
}
|
||||||
|
if right != nil {
|
||||||
|
copy := *right
|
||||||
|
copy.Sequence = make([]*document.Node, len(right.Sequence))
|
||||||
|
rightCopy = ©
|
||||||
|
}
|
||||||
|
length := 0
|
||||||
|
if left != nil {
|
||||||
|
length = len(left.Sequence)
|
||||||
|
}
|
||||||
|
if right != nil && len(right.Sequence) > length {
|
||||||
|
length = len(right.Sequence)
|
||||||
|
}
|
||||||
|
result := pairedDiffSnapshot{left: leftCopy, right: rightCopy}
|
||||||
|
for index := 0; index < length; index++ {
|
||||||
|
var leftChild, rightChild *document.Node
|
||||||
|
if left != nil && index < len(left.Sequence) {
|
||||||
|
leftChild = left.Sequence[index]
|
||||||
|
}
|
||||||
|
if right != nil && index < len(right.Sequence) {
|
||||||
|
rightChild = right.Sequence[index]
|
||||||
|
}
|
||||||
|
child, supported := preparePairedDiffNodes(leftChild, rightChild, path.Index(index), sensitivity)
|
||||||
|
if !supported {
|
||||||
|
return pairedDiffSnapshot{}, false
|
||||||
|
}
|
||||||
|
if child.requiresFallback {
|
||||||
|
return pairedDiffSnapshot{requiresFallback: true}, true
|
||||||
|
}
|
||||||
|
if left != nil && index < len(left.Sequence) {
|
||||||
|
leftCopy.Sequence[index] = child.left
|
||||||
|
}
|
||||||
|
if right != nil && index < len(right.Sequence) {
|
||||||
|
rightCopy.Sequence[index] = child.right
|
||||||
|
}
|
||||||
|
result.leftRedacted = result.leftRedacted || child.leftRedacted
|
||||||
|
result.rightRedacted = result.rightRedacted || child.rightRedacted
|
||||||
|
result.hiddenChanged = result.hiddenChanged || child.hiddenChanged
|
||||||
|
}
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffMappingEntries(node *document.Node) map[string]document.Entry {
|
||||||
|
entries := make(map[string]document.Entry)
|
||||||
|
if node == nil {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
entries[entry.Key] = entry
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactedDiffNode(node *document.Node) *document.Node {
|
||||||
|
if node == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &document.Node{Kind: document.String, Text: "[REDACTED]"}
|
||||||
|
}
|
||||||
351
internal/engine/diff_snapshot_test.go
Normal file
351
internal/engine/diff_snapshot_test.go
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotRedactsAlignedMappingFromEitherSide(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", "credential: alpha\npublic: before\n")
|
||||||
|
right := parseCompositionDocument(t, "right", "credential: bravo\npublic: after\n")
|
||||||
|
sensitivity := newDiffSensitivity(map[string]bool{"/credential": true}, nil)
|
||||||
|
|
||||||
|
paired, supported := preparePairedDiffSnapshot(left, right, Path{}, sensitivity)
|
||||||
|
if !supported {
|
||||||
|
t.Fatal("compatible mappings were not supported")
|
||||||
|
}
|
||||||
|
if !paired.leftRedacted || !paired.rightRedacted {
|
||||||
|
t.Fatalf("redacted flags = %v, %v; want true on both sides", paired.leftRedacted, paired.rightRedacted)
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.left, "credential"), "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.right, "credential"), "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.left, "public"), "before")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.right, "public"), "after")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, left, "credential"), "alpha")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, right, "credential"), "bravo")
|
||||||
|
if paired.left == left || paired.right == right || &paired.left.Mapping[0] == &left.Mapping[0] {
|
||||||
|
t.Fatal("safe mapping containers or entries share mutable storage with retained trees")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotPreservesSequencePositionsAndLengths(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", "items: [left-public, hidden, old-tail]\n")
|
||||||
|
right := parseCompositionDocument(t, "right", "items: [right-public, hidden, old-tail, extra]\n")
|
||||||
|
sensitivity := newDiffSensitivity(nil, map[string]bool{"/items/1": true})
|
||||||
|
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
mappingValue(t, left, "items"), mappingValue(t, right, "items"), Keys("items"), sensitivity,
|
||||||
|
)
|
||||||
|
if !supported {
|
||||||
|
t.Fatal("compatible sequences were not supported")
|
||||||
|
}
|
||||||
|
if !paired.leftRedacted || !paired.rightRedacted {
|
||||||
|
t.Fatalf("redacted flags = %v, %v; want true on both sides", paired.leftRedacted, paired.rightRedacted)
|
||||||
|
}
|
||||||
|
if len(paired.left.Sequence) != 3 || len(paired.right.Sequence) != 4 {
|
||||||
|
t.Fatalf("sequence lengths = %d, %d; want 3, 4", len(paired.left.Sequence), len(paired.right.Sequence))
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, paired.left.Sequence[0], "left-public")
|
||||||
|
assertDiffStringNode(t, paired.right.Sequence[0], "right-public")
|
||||||
|
assertDiffStringNode(t, paired.left.Sequence[1], "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, paired.right.Sequence[1], "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, paired.right.Sequence[3], "extra")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, left, "items").Sequence[1], "hidden")
|
||||||
|
if &paired.left.Sequence[0] == &mappingValue(t, left, "items").Sequence[0] {
|
||||||
|
t.Fatal("safe sequence shares mutable storage with retained tree")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotMasksSensitiveDescendantsOfAbsentSubtrees(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
left, right string
|
||||||
|
absentSide string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "removed subtree",
|
||||||
|
left: "removed: {token: old-secret, public: retained}\n",
|
||||||
|
right: "other: value\n",
|
||||||
|
absentSide: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "added subtree",
|
||||||
|
left: "other: value\n",
|
||||||
|
right: "added: {token: new-secret, public: retained}\n",
|
||||||
|
absentSide: "left",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", test.left)
|
||||||
|
right := parseCompositionDocument(t, "right", test.right)
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
mappingValueIfPresent(left, "removed", "added"),
|
||||||
|
mappingValueIfPresent(right, "removed", "added"),
|
||||||
|
Keys("subtree"),
|
||||||
|
newDiffSensitivity(map[string]bool{"/subtree/token": true}, nil),
|
||||||
|
)
|
||||||
|
if !supported {
|
||||||
|
t.Fatal("compatible subtree and absence were not supported")
|
||||||
|
}
|
||||||
|
if test.absentSide == "left" {
|
||||||
|
if paired.left != nil || paired.leftRedacted || !paired.rightRedacted {
|
||||||
|
t.Fatalf("paired absence/redaction = %#v", paired)
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.right, "token"), "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.right, "public"), "retained")
|
||||||
|
} else {
|
||||||
|
if paired.right != nil || paired.rightRedacted || !paired.leftRedacted {
|
||||||
|
t.Fatalf("paired absence/redaction = %#v", paired)
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.left, "token"), "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.left, "public"), "retained")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotMasksSelectedRootAndContainer(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
left, right *document.Node
|
||||||
|
path Path
|
||||||
|
selector string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "root",
|
||||||
|
left: parseCompositionDocument(t, "left", "token: old\n"),
|
||||||
|
right: parseCompositionDocument(t, "right", "token: new\n"),
|
||||||
|
path: Path{},
|
||||||
|
selector: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "container",
|
||||||
|
left: parseCompositionDocument(t, "left", "group: {public: old}\n"),
|
||||||
|
right: parseCompositionDocument(t, "right", "group: {public: new}\n"),
|
||||||
|
path: Keys("group"),
|
||||||
|
selector: "/group",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
test.left, test.right, test.path,
|
||||||
|
newDiffSensitivity(map[string]bool{test.selector: true}, nil),
|
||||||
|
)
|
||||||
|
if !supported {
|
||||||
|
t.Fatal("selected nodes were not supported")
|
||||||
|
}
|
||||||
|
if !paired.leftRedacted || !paired.rightRedacted {
|
||||||
|
t.Fatalf("redacted flags = %v, %v; want true on both sides", paired.leftRedacted, paired.rightRedacted)
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, paired.left, "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, paired.right, "[REDACTED]")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotDoesNotInterpretMarkerLookingInput(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", "label: '[REDACTED]'\n")
|
||||||
|
right := parseCompositionDocument(t, "right", "label: '[REDACTED]'\n")
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
mappingValue(t, left, "label"), mappingValue(t, right, "label"), Keys("label"),
|
||||||
|
newDiffSensitivity(nil, nil),
|
||||||
|
)
|
||||||
|
if !supported {
|
||||||
|
t.Fatal("ordinary scalar values were not supported")
|
||||||
|
}
|
||||||
|
if paired.leftRedacted || paired.rightRedacted {
|
||||||
|
t.Fatalf("redacted flags = %v, %v; want false", paired.leftRedacted, paired.rightRedacted)
|
||||||
|
}
|
||||||
|
if paired.left != mappingValue(t, left, "label") || paired.right != mappingValue(t, right, "label") {
|
||||||
|
t.Fatal("unselected scalar nodes should remain borrowed immutable nodes")
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, paired.left, "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, paired.right, "[REDACTED]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotMasksSensitiveStructuralReplacements(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
left, right string
|
||||||
|
path Path
|
||||||
|
selector string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "mapping to scalar",
|
||||||
|
left: "changed: {token: old-secret, public: old}\n",
|
||||||
|
right: "changed: new-scalar\n",
|
||||||
|
path: Keys("changed"),
|
||||||
|
selector: "/changed/token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mapping to scalar with historical-only descendant",
|
||||||
|
left: "changed: {public: old}\n",
|
||||||
|
right: "changed: new-scalar\n",
|
||||||
|
path: Keys("changed"),
|
||||||
|
selector: "/changed/removed/secret",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scalar to mapping",
|
||||||
|
left: "changed: old-scalar\n",
|
||||||
|
right: "changed: {token: new-secret, public: new}\n",
|
||||||
|
path: Keys("changed"),
|
||||||
|
selector: "/changed/token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mapping to sequence with numeric paths",
|
||||||
|
left: "changed: {'0': {token: old-secret}}\n",
|
||||||
|
right: "changed: [{token: new-secret}]\n",
|
||||||
|
path: Keys("changed"),
|
||||||
|
selector: "/changed/0/token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sequence to mapping with numeric paths",
|
||||||
|
left: "changed: [{token: old-secret}]\n",
|
||||||
|
right: "changed: {'0': {token: new-secret}}\n",
|
||||||
|
path: Keys("changed"),
|
||||||
|
selector: "/changed/0/token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null to container",
|
||||||
|
left: "changed: null\n",
|
||||||
|
right: "changed: {token: new-secret}\n",
|
||||||
|
path: Keys("changed"),
|
||||||
|
selector: "/changed/token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "container to null",
|
||||||
|
left: "changed: {token: old-secret}\n",
|
||||||
|
right: "changed: null\n",
|
||||||
|
path: Keys("changed"),
|
||||||
|
selector: "/changed/token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
leftRoot := parseCompositionDocument(t, "left", test.left)
|
||||||
|
rightRoot := parseCompositionDocument(t, "right", test.right)
|
||||||
|
left := mappingValue(t, leftRoot, test.path.segments[0].key)
|
||||||
|
right := mappingValue(t, rightRoot, test.path.segments[0].key)
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
left, right, test.path, newDiffSensitivity(nil, map[string]bool{test.selector: true}),
|
||||||
|
)
|
||||||
|
assertWholeRegionFallback(t, paired, supported, left, right)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotMasksOriginalRegionForNestedSequenceMismatch(t *testing.T) {
|
||||||
|
leftRoot := parseCompositionDocument(t, "left", "items: [{branch: {token: old-secret, public: old}}]\n")
|
||||||
|
rightRoot := parseCompositionDocument(t, "right", "items: [{branch: new-scalar}]\n")
|
||||||
|
left := mappingValue(t, leftRoot, "items")
|
||||||
|
right := mappingValue(t, rightRoot, "items")
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
left, right, Keys("items"), newDiffSensitivity(nil, map[string]bool{"/items/0/branch/token": true}),
|
||||||
|
)
|
||||||
|
assertWholeRegionFallback(t, paired, supported, left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotKeepsPublicScalarsWithHistoricalDescendants(t *testing.T) {
|
||||||
|
leftRoot := parseCompositionDocument(t, "left", "changed: before\n")
|
||||||
|
rightRoot := parseCompositionDocument(t, "right", "changed: after\n")
|
||||||
|
left := mappingValue(t, leftRoot, "changed")
|
||||||
|
right := mappingValue(t, rightRoot, "changed")
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
left, right, Keys("changed"), newDiffSensitivity(map[string]bool{"/changed/removed/secret": true}, nil),
|
||||||
|
)
|
||||||
|
if !supported || paired.leftRedacted || paired.rightRedacted || paired.hiddenChanged {
|
||||||
|
t.Fatalf("public scalar replacement = %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, paired.left, "before")
|
||||||
|
assertDiffStringNode(t, paired.right, "after")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotKeepsPublicContentAcrossAbsence(t *testing.T) {
|
||||||
|
right := parseCompositionDocument(t, "right", "branch: scalar\n")
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
nil, right, Keys("added"), newDiffSensitivity(nil, map[string]bool{"/added/branch/secret": true}),
|
||||||
|
)
|
||||||
|
if !supported || paired.left != nil || paired.leftRedacted || paired.rightRedacted || paired.hiddenChanged {
|
||||||
|
t.Fatalf("public addition presence/redaction = %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.right, "branch"), "scalar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotPreservesPublicReplacementBesideSensitiveSibling(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", "child: before\n")
|
||||||
|
right := &document.Node{Kind: document.String, Text: "after"}
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
mappingValue(t, left, "child"), right, Keys("public"),
|
||||||
|
newDiffSensitivity(nil, map[string]bool{"/private/token": true}),
|
||||||
|
)
|
||||||
|
if !supported || paired.left != mappingValue(t, left, "child") || paired.right != right || paired.leftRedacted || paired.rightRedacted {
|
||||||
|
t.Fatalf("unrelated classification changed public replacement: %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePairedDiffSnapshotTargetsRedactionAcrossAbsence(t *testing.T) {
|
||||||
|
left := parseCompositionDocument(t, "left", "items: []\n")
|
||||||
|
right := parseCompositionDocument(t, "right", "items: [{token: new-secret, public: visible}]\n")
|
||||||
|
leftItems := mappingValue(t, left, "items")
|
||||||
|
rightItems := mappingValue(t, right, "items")
|
||||||
|
paired, supported := preparePairedDiffSnapshot(
|
||||||
|
leftItems, rightItems, Keys("items"), newDiffSensitivity(nil, map[string]bool{"/items/0/token": true}),
|
||||||
|
)
|
||||||
|
if !supported || paired.left == nil || paired.right == nil || paired.leftRedacted || !paired.rightRedacted {
|
||||||
|
t.Fatalf("targeted sequence redaction = %#v, supported=%v", paired, supported)
|
||||||
|
}
|
||||||
|
if len(paired.left.Sequence) != 0 || len(paired.right.Sequence) != 1 {
|
||||||
|
t.Fatalf("sequence lengths = %d, %d; want 0, 1", len(paired.left.Sequence), len(paired.right.Sequence))
|
||||||
|
}
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.right.Sequence[0], "token"), "[REDACTED]")
|
||||||
|
assertDiffStringNode(t, mappingValue(t, paired.right.Sequence[0], "public"), "visible")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertWholeRegionFallback(t *testing.T, paired pairedDiffSnapshot, supported bool, left, right *document.Node) {
|
||||||
|
t.Helper()
|
||||||
|
if !supported {
|
||||||
|
t.Fatal("sensitive incompatible shape was not handled")
|
||||||
|
}
|
||||||
|
if !paired.hiddenChanged {
|
||||||
|
t.Fatal("fallback for a changed reported region did not mark hidden change")
|
||||||
|
}
|
||||||
|
if (left == nil) != (paired.left == nil) || (right == nil) != (paired.right == nil) {
|
||||||
|
t.Fatalf("fallback changed root presence: %#v", paired)
|
||||||
|
}
|
||||||
|
if left != nil {
|
||||||
|
assertDiffStringNode(t, paired.left, "[REDACTED]")
|
||||||
|
if !paired.leftRedacted {
|
||||||
|
t.Fatal("present left region was not marked redacted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if right != nil {
|
||||||
|
assertDiffStringNode(t, paired.right, "[REDACTED]")
|
||||||
|
if !paired.rightRedacted {
|
||||||
|
t.Fatal("present right region was not marked redacted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mappingValueIfPresent(node *document.Node, keys ...string) *document.Node {
|
||||||
|
if node == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
if entry.Key == key {
|
||||||
|
return entry.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertDiffStringNode(t *testing.T, node *document.Node, want string) {
|
||||||
|
t.Helper()
|
||||||
|
if node == nil || node.Kind != document.String || node.Text != want {
|
||||||
|
t.Fatalf("node = %#v, want string %q", node, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
144
internal/engine/diff_test.go
Normal file
144
internal/engine/diff_test.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareDiffRegionsUsesCanonicalConfigurationComparison(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
left string
|
||||||
|
right string
|
||||||
|
want []expectedDiffRegion
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "mapping order is ignored for equal values",
|
||||||
|
left: "first: 1\nsecond: true\n",
|
||||||
|
right: "second: true\nfirst: 1\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "addition removal null and empty values",
|
||||||
|
left: "removed: null\n",
|
||||||
|
right: "added: null\nempty_map: {}\nempty_sequence: []\n",
|
||||||
|
want: []expectedDiffRegion{
|
||||||
|
{path: "/added", rightKind: document.Null, rightPresent: true},
|
||||||
|
{path: "/empty_map", rightKind: document.Mapping, rightPresent: true},
|
||||||
|
{path: "/empty_sequence", rightKind: document.Sequence, rightPresent: true},
|
||||||
|
{path: "/removed", leftKind: document.Null, leftPresent: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scalar kinds and atomic sequences",
|
||||||
|
left: "scalar: 1\nitems: [one, two]\n",
|
||||||
|
right: "scalar: '1'\nitems: [one, changed]\n",
|
||||||
|
want: []expectedDiffRegion{
|
||||||
|
{path: "/items", leftKind: document.Sequence, rightKind: document.Sequence, leftPresent: true, rightPresent: true},
|
||||||
|
{path: "/scalar", leftKind: document.Number, rightKind: document.String, leftPresent: true, rightPresent: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped empty and numeric mapping keys sort by pointer",
|
||||||
|
left: `"": old` + "\n'0': old\n'01': old\n'a/b': old\n'a~b': old\n",
|
||||||
|
right: `"": new` + "\n'0': new\n'01': new\n'a/b': new\n'a~b': new\n",
|
||||||
|
want: []expectedDiffRegion{
|
||||||
|
{path: "/", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
|
||||||
|
{path: "/0", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
|
||||||
|
{path: "/01", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
|
||||||
|
{path: "/a~0b", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
|
||||||
|
{path: "/a~1b", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
leftRoot := parseCompositionDocument(t, "left", test.left)
|
||||||
|
rightRoot := parseCompositionDocument(t, "right", test.right)
|
||||||
|
regions, err := prepareDiffRegions(diffTestInspection(leftRoot), diffTestInspection(rightRoot))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(regions) != len(test.want) {
|
||||||
|
t.Fatalf("regions = %#v, want %#v", regions, test.want)
|
||||||
|
}
|
||||||
|
for index, want := range test.want {
|
||||||
|
region := regions[index]
|
||||||
|
if region.path.String() != want.path {
|
||||||
|
t.Fatalf("region[%d].path = %q, want %q", index, region.path.String(), want.path)
|
||||||
|
}
|
||||||
|
assertDiffRegionNode(t, "left", region.left, want.leftPresent, want.leftKind)
|
||||||
|
assertDiffRegionNode(t, "right", region.right, want.rightPresent, want.rightKind)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareDiffRegionsHandlesRootReplacementAndBorrowsNodes(t *testing.T) {
|
||||||
|
leftRoot := &document.Node{Kind: document.String, Text: "before"}
|
||||||
|
rightRoot := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||||
|
Key: "value", Value: &document.Node{Kind: document.Number, Text: "1"},
|
||||||
|
}}}
|
||||||
|
regions, err := prepareDiffRegions(diffTestInspection(leftRoot), diffTestInspection(rightRoot))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(regions) != 1 || regions[0].path.String() != "" || regions[0].left != leftRoot || regions[0].right != rightRoot {
|
||||||
|
t.Fatalf("root region = %#v", regions)
|
||||||
|
}
|
||||||
|
|
||||||
|
leftRoot = parseCompositionDocument(t, "left-child", "value: before\n")
|
||||||
|
rightRoot = parseCompositionDocument(t, "right-child", "value: after\n")
|
||||||
|
regions, err = prepareDiffRegions(diffTestInspection(leftRoot), diffTestInspection(rightRoot))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(regions) != 1 || regions[0].left != mappingValue(t, leftRoot, "value") || regions[0].right != mappingValue(t, rightRoot, "value") {
|
||||||
|
t.Fatalf("child nodes were not borrowed from the frozen trees: %#v", regions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareDiffRegionsRejectsUnavailableInspections(t *testing.T) {
|
||||||
|
valid := diffTestInspection(&document.Node{Kind: document.String, Text: "safe"})
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
left, right *Inspection
|
||||||
|
}{
|
||||||
|
{name: "nil left", left: nil, right: valid},
|
||||||
|
{name: "nil right", left: valid, right: nil},
|
||||||
|
{name: "missing left tree", left: &Inspection{}, right: valid},
|
||||||
|
{name: "missing right tree", left: valid, right: &Inspection{}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
regions, err := prepareDiffRegions(test.left, test.right)
|
||||||
|
if err == nil || regions != nil {
|
||||||
|
t.Fatalf("regions, err = %#v, %v", regions, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type expectedDiffRegion struct {
|
||||||
|
path string
|
||||||
|
leftKind, rightKind document.Kind
|
||||||
|
leftPresent, rightPresent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffTestInspection(root *document.Node) *Inspection {
|
||||||
|
return &Inspection{final: root, immutableLookup: buildDocumentLookup(root)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertDiffRegionNode(t *testing.T, side string, node *document.Node, wantPresent bool, wantKind document.Kind) {
|
||||||
|
t.Helper()
|
||||||
|
if !wantPresent {
|
||||||
|
if node != nil {
|
||||||
|
t.Fatalf("%s node = %#v, want absence", side, node)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if node == nil || node.Kind != wantKind {
|
||||||
|
t.Fatalf("%s node = %#v, want kind %v", side, node, wantKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,23 @@ type documentDelta struct {
|
|||||||
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
||||||
var deltas []documentDelta
|
var deltas []documentDelta
|
||||||
compareConfigurationValue(before, after, Path{}, &deltas)
|
compareConfigurationValue(before, after, Path{}, &deltas)
|
||||||
slices.SortFunc(deltas, func(left, right documentDelta) int {
|
type orderedDelta struct {
|
||||||
if order := cmp.Compare(left.path.String(), right.path.String()); order != 0 {
|
delta documentDelta
|
||||||
|
pointer string
|
||||||
|
}
|
||||||
|
ordered := make([]orderedDelta, len(deltas))
|
||||||
|
for i, delta := range deltas {
|
||||||
|
ordered[i] = orderedDelta{delta: delta, pointer: delta.path.String()}
|
||||||
|
}
|
||||||
|
slices.SortFunc(ordered, func(left, right orderedDelta) int {
|
||||||
|
if order := cmp.Compare(left.pointer, right.pointer); order != 0 {
|
||||||
return order
|
return order
|
||||||
}
|
}
|
||||||
return int(left.kind) - int(right.kind)
|
return int(left.delta.kind) - int(right.delta.kind)
|
||||||
})
|
})
|
||||||
|
for i, item := range ordered {
|
||||||
|
deltas[i] = item.delta
|
||||||
|
}
|
||||||
return deltas
|
return deltas
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
internal/engine/document_lookup.go
Normal file
54
internal/engine/document_lookup.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
// documentLookup contains read-only mapping positions for a completed document.
|
||||||
|
type documentLookup struct {
|
||||||
|
byNode map[*document.Node]map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDocumentLookup(roots ...*document.Node) *documentLookup {
|
||||||
|
lookup := &documentLookup{byNode: make(map[*document.Node]map[string]int)}
|
||||||
|
visited := make(map[*document.Node]struct{})
|
||||||
|
var visit func(*document.Node)
|
||||||
|
visit = func(node *document.Node) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := visited[node]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visited[node] = struct{}{}
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
entries := make(map[string]int, len(node.Mapping))
|
||||||
|
for index, entry := range node.Mapping {
|
||||||
|
if _, exists := entries[entry.Key]; !exists {
|
||||||
|
entries[entry.Key] = index
|
||||||
|
}
|
||||||
|
visit(entry.Value)
|
||||||
|
}
|
||||||
|
lookup.byNode[node] = entries
|
||||||
|
case document.Sequence:
|
||||||
|
for _, child := range node.Sequence {
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, root := range roots {
|
||||||
|
visit(root)
|
||||||
|
}
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup *documentLookup) find(node *document.Node, key string) int {
|
||||||
|
if lookup == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if entries := lookup.byNode[node]; entries != nil {
|
||||||
|
if index, ok := entries[key]; ok {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ func indexDocument(node *document.Node, path Path, index map[string]*document.No
|
|||||||
}
|
}
|
||||||
|
|
||||||
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
|
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
|
||||||
|
return documentAtPathWithLookup(root, path, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentAtPathWithLookup(root *document.Node, path Path, lookup *documentLookup) (*document.Node, bool) {
|
||||||
if root == nil || !path.valid() {
|
if root == nil || !path.valid() {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -41,17 +45,21 @@ func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
|
|||||||
if segment.kind == pathSegmentIndex {
|
if segment.kind == pathSegmentIndex {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
found := false
|
entryIndex := -1
|
||||||
for _, entry := range current.Mapping {
|
if lookup != nil {
|
||||||
if entry.Key == segment.key {
|
entryIndex = lookup.find(current, segment.key)
|
||||||
current = entry.Value
|
} else {
|
||||||
found = true
|
for index, entry := range current.Mapping {
|
||||||
break
|
if entry.Key == segment.key {
|
||||||
|
entryIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if entryIndex < 0 || entryIndex >= len(current.Mapping) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
current = current.Mapping[entryIndex].Value
|
||||||
case document.Sequence:
|
case document.Sequence:
|
||||||
index, ok := schemaIndex(segment)
|
index, ok := schemaIndex(segment)
|
||||||
if !ok || index >= len(current.Sequence) {
|
if !ok || index >= len(current.Sequence) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
|
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
|
||||||
return Explanation{}, false
|
return Explanation{}, false
|
||||||
}
|
}
|
||||||
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
|
explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, r.immutableLookup)}
|
||||||
if info, ok := r.metadata.Lookup(path); ok {
|
if info, ok := r.metadata.Lookup(path); ok {
|
||||||
explanation.Origin = cloneOrigin(info.Origin)
|
explanation.Origin = cloneOrigin(info.Origin)
|
||||||
}
|
}
|
||||||
@@ -51,13 +51,14 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
|
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
replayIndex := new(mappingIndex)
|
||||||
for _, application := range r.applications {
|
for _, application := range r.applications {
|
||||||
operationPath, relevant := applicationOperationPath(application, path)
|
operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup)
|
||||||
var before ValueSnapshot
|
var before ValueSnapshot
|
||||||
if relevant {
|
if relevant {
|
||||||
before = valueSnapshot(working, path, r.secrets)
|
before = valueSnapshot(working, path, r.secrets)
|
||||||
}
|
}
|
||||||
applyHistoryApplication(&working, application)
|
applyHistoryApplication(&working, application, replayIndex)
|
||||||
if !relevant {
|
if !relevant {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -68,28 +69,29 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
Before: before,
|
Before: before,
|
||||||
After: after,
|
After: after,
|
||||||
Origin: historyApplicationOrigin(application, operationPath),
|
Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
appendInheritanceExplanation(&explanation, &working, path, r.inheritance, r.secrets)
|
||||||
|
|
||||||
// Intermediate input replacements do not permanently suppress defaults:
|
// Intermediate input replacements do not permanently suppress defaults:
|
||||||
// decoding applies them to the final input tree. Show that reconciliation
|
// decoding applies them to the final input tree. Show that reconciliation
|
||||||
// explicitly, including canonical scalar conversions, before domain changes.
|
// explicitly, including canonical scalar conversions, before domain changes.
|
||||||
replayed, _ := documentAtPath(working, path)
|
replayed, _ := documentAtPath(working, path)
|
||||||
decoded, _ := documentAtPath(r.input, path)
|
decoded, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup)
|
||||||
if !configurationValuesEqual(replayed, decoded) {
|
if !configurationValuesEqual(replayed, decoded) {
|
||||||
origin := cloneOrigin(r.inputOrigins[path.String()])
|
origin := cloneOrigin(r.inputOrigins[path.String()])
|
||||||
origin.Operation = ChangeDecoding
|
origin.Operation = ChangeDecoding
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
Before: valueSnapshot(working, path, r.secrets),
|
Before: valueSnapshot(working, path, r.secrets),
|
||||||
After: valueSnapshot(r.input, path, r.secrets),
|
After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup),
|
||||||
Origin: origin,
|
Origin: origin,
|
||||||
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
working = document.Clone(r.input)
|
working = document.Clone(r.input)
|
||||||
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
|
for _, snapshot := range r.resolutionReplay {
|
||||||
relevant := pathsIntersect(snapshot.change.Path, path)
|
relevant := pathsIntersect(snapshot.change.Path, path)
|
||||||
var before ValueSnapshot
|
var before ValueSnapshot
|
||||||
if relevant {
|
if relevant {
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
|
|||||||
return errExplanationUnknownPath
|
return errExplanationUnknownPath
|
||||||
}
|
}
|
||||||
var rendered strings.Builder
|
var rendered strings.Builder
|
||||||
|
labels := make(map[string]string, len(r.sources))
|
||||||
|
for _, source := range r.sources {
|
||||||
|
labels[source.ID] = source.Label
|
||||||
|
}
|
||||||
rendered.WriteString("path: ")
|
rendered.WriteString("path: ")
|
||||||
rendered.WriteString(strconv.Quote(explanation.Path.String()))
|
rendered.WriteString(strconv.Quote(explanation.Path.String()))
|
||||||
rendered.WriteByte('\n')
|
rendered.WriteByte('\n')
|
||||||
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources)
|
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, labels)
|
||||||
writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0)
|
writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0)
|
||||||
if explanation.SecretReference != nil {
|
if explanation.SecretReference != nil {
|
||||||
rendered.WriteString("secret reference: ")
|
rendered.WriteString("secret reference: ")
|
||||||
@@ -38,12 +42,14 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
|
|||||||
rendered.WriteString(" - representation: ")
|
rendered.WriteString(" - representation: ")
|
||||||
if change.Origin.Operation == ChangeDecoding {
|
if change.Origin.Operation == ChangeDecoding {
|
||||||
rendered.WriteString("input to canonical typed\n")
|
rendered.WriteString("input to canonical typed\n")
|
||||||
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI {
|
} else if change.Origin.Operation == ChangeInheritance {
|
||||||
|
rendered.WriteString("structural inheritance\n")
|
||||||
|
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerUnderlay || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI {
|
||||||
rendered.WriteString("policy-validated input\n")
|
rendered.WriteString("policy-validated input\n")
|
||||||
} else {
|
} else {
|
||||||
rendered.WriteString("canonical typed\n")
|
rendered.WriteString("canonical typed\n")
|
||||||
}
|
}
|
||||||
writeExplanationOrigin(&rendered, " source", change.Origin, r.sources)
|
writeExplanationOrigin(&rendered, " source", change.Origin, labels)
|
||||||
writeExplanationSnapshot(&rendered, " before", change.Before, 4)
|
writeExplanationSnapshot(&rendered, " before", change.Before, 4)
|
||||||
writeExplanationSnapshot(&rendered, " after", change.After, 4)
|
writeExplanationSnapshot(&rendered, " after", change.After, 4)
|
||||||
for _, reason := range change.Reasons {
|
for _, reason := range change.Reasons {
|
||||||
@@ -65,7 +71,7 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) {
|
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, labels map[string]string) {
|
||||||
output.WriteString(label)
|
output.WriteString(label)
|
||||||
output.WriteString(": layer=")
|
output.WriteString(": layer=")
|
||||||
output.WriteString(strconv.Quote(string(origin.Layer)))
|
output.WriteString(strconv.Quote(string(origin.Layer)))
|
||||||
@@ -77,7 +83,7 @@ func writeExplanationOrigin(output *strings.Builder, label string, origin Origin
|
|||||||
for _, location := range origin.Locations {
|
for _, location := range origin.Locations {
|
||||||
output.WriteString(" location: source=")
|
output.WriteString(" location: source=")
|
||||||
output.WriteString(strconv.Quote(location.SourceID))
|
output.WriteString(strconv.Quote(location.SourceID))
|
||||||
if label := sourceLabel(sources, location.SourceID); label != "" {
|
if label := labels[location.SourceID]; label != "" {
|
||||||
output.WriteString(" label=")
|
output.WriteString(" label=")
|
||||||
output.WriteString(strconv.Quote(label))
|
output.WriteString(strconv.Quote(label))
|
||||||
}
|
}
|
||||||
@@ -87,15 +93,15 @@ func writeExplanationOrigin(output *strings.Builder, label string, origin Origin
|
|||||||
output.WriteString(strconv.Itoa(location.Column))
|
output.WriteString(strconv.Itoa(location.Column))
|
||||||
output.WriteByte('\n')
|
output.WriteByte('\n')
|
||||||
}
|
}
|
||||||
}
|
for _, step := range origin.Inheritance {
|
||||||
|
output.WriteString(" inherited: from=")
|
||||||
func sourceLabel(sources []SourceInfo, sourceID string) string {
|
output.WriteString(strconv.Quote(step.From.String()))
|
||||||
for _, source := range sources {
|
output.WriteString(" to=")
|
||||||
if source.ID == sourceID {
|
output.WriteString(strconv.Quote(step.To.String()))
|
||||||
return source.Label
|
output.WriteString(" reference=")
|
||||||
}
|
output.WriteString(strconv.Quote(step.Reference.String()))
|
||||||
|
output.WriteByte('\n')
|
||||||
}
|
}
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) {
|
func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
@@ -520,3 +521,95 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExplanationReplayDescriptorsRemainDetachedAcrossQueries(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
}
|
||||||
|
result, err := testLoad(Bytes("resolution-explanation.yml", []byte("count: 1\n")), Options[config]{
|
||||||
|
Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
|
||||||
|
value.Count = 2
|
||||||
|
resolution.Record(Keys("count"), ChangeNormalization, "normalized", Keys("count"))
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, ok := result.Explain(Keys("count"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
var firstResolution *ValueChange
|
||||||
|
for index := range first.History {
|
||||||
|
if first.History[index].Origin.Layer == LayerResolution {
|
||||||
|
firstResolution = &first.History[index]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstResolution == nil || len(firstResolution.Reasons) != 1 || len(firstResolution.Dependencies) != 1 {
|
||||||
|
t.Fatalf("resolution history = %#v", first.History)
|
||||||
|
}
|
||||||
|
firstResolution.Reasons[0] = "mutated"
|
||||||
|
firstResolution.Dependencies[0].segments[0].key = "mutated"
|
||||||
|
|
||||||
|
second, ok := result.Explain(Keys("count"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing repeated explanation")
|
||||||
|
}
|
||||||
|
for _, change := range second.History {
|
||||||
|
if change.Origin.Layer == LayerResolution {
|
||||||
|
if !reflect.DeepEqual(change.Reasons, []string{"normalized"}) || len(change.Dependencies) != 1 || change.Dependencies[0].String() != "/count" {
|
||||||
|
t.Fatalf("repeated explanation observed caller mutation: %#v", change)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("resolution change missing from repeated explanation: %#v", second.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentReadOnlyInspectionQueries(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Group struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
} `yaml:"group"`
|
||||||
|
}
|
||||||
|
result, err := testLoad(Bytes("concurrent-inspection.yml", []byte("group: {value: input}\n")), Options[config]{
|
||||||
|
Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
|
||||||
|
value.Group.Value = "resolved"
|
||||||
|
resolution.Record(Keys("group", "value"), ChangeNormalization, "normalized", Keys("group", "value"))
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
paths := []Path{Keys("group"), Keys("group", "value")}
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
errors := make(chan string, 8)
|
||||||
|
for worker := 0; worker < cap(errors); worker++ {
|
||||||
|
wait.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
for iteration := 0; iteration < 8; iteration++ {
|
||||||
|
for _, path := range paths {
|
||||||
|
explanation, found := result.Explain(path)
|
||||||
|
if !found || !explanation.Effective.Present {
|
||||||
|
errors <- "missing effective explanation for " + path.String()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
if _, found := metadata.Lookup(path); !found || len(metadata.History(path)) == 0 {
|
||||||
|
errors <- "missing metadata for " + path.String()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wait.Wait()
|
||||||
|
close(errors)
|
||||||
|
for message := range errors {
|
||||||
|
t.Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
||||||
|
if nativeContext(source, context) {
|
||||||
|
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
|
return validateNativeDirectories(access, basePath, mainPath)
|
||||||
|
}
|
||||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return diagnostic
|
return diagnostic
|
||||||
@@ -29,20 +38,87 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di
|
|||||||
case sourceNone:
|
case sourceNone:
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
|
||||||
case sourceFile:
|
case sourceFile:
|
||||||
|
if context.FS != nil {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
|
||||||
|
}
|
||||||
if context.BaseDir != "" {
|
if context.BaseDir != "" {
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
|
||||||
}
|
}
|
||||||
case sourceBytes, sourceReader:
|
case sourceFilesystem:
|
||||||
|
if context.FS == nil || context.FS.namespace == nil || source.namespace == nil || context.FS.namespace.token != source.namespace.token {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem file context must use the source namespace")}
|
||||||
|
}
|
||||||
|
if context.BaseDir != "" {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
|
||||||
|
}
|
||||||
|
if context.Root != "" && !fs.ValidPath(context.Root) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem root must be a slash-relative valid path")}
|
||||||
|
}
|
||||||
|
case sourceBytes, sourceReader, sourceMapping:
|
||||||
if context.BaseDir == "" || context.Root == "" {
|
if context.BaseDir == "" || context.Root == "" {
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")}
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")}
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
if context.FS != nil {
|
||||||
|
if context.FS.namespace == nil || !fs.ValidPath(context.BaseDir) || !fs.ValidPath(context.Root) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem base and root must be slash-relative valid paths")}
|
||||||
|
}
|
||||||
|
} else if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")}
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nativeContext(source Source, context *FilesystemContext) bool {
|
||||||
|
return source.kind == sourceFilesystem || context != nil && context.FS != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nativeFilesystemAccess resolves namespace context without opening caller
|
||||||
|
// resources. File inputs always derive their base from the selected file.
|
||||||
|
func nativeFilesystemAccess(source Source, context *FilesystemContext) (*filesystemAccess, string, string, *Diagnostic) {
|
||||||
|
var namespace *nativeNamespace
|
||||||
|
rootPath := "."
|
||||||
|
basePath := "."
|
||||||
|
mainPath := ""
|
||||||
|
if source.kind == sourceFilesystem {
|
||||||
|
namespace = source.namespace
|
||||||
|
mainPath = source.path
|
||||||
|
basePath = path.Dir(source.path)
|
||||||
|
if context != nil && context.Root != "" {
|
||||||
|
rootPath = context.Root
|
||||||
|
}
|
||||||
|
} else if context != nil && context.FS != nil {
|
||||||
|
namespace = context.FS.namespace
|
||||||
|
rootPath, basePath = context.Root, context.BaseDir
|
||||||
|
}
|
||||||
|
if namespace == nil || nilFilesystem(namespace.filesystem) || !fs.ValidPath(rootPath) || !fs.ValidPath(basePath) {
|
||||||
|
value := optionDiagnostic(CodeOptionsInvalid, "filesystem context is invalid")
|
||||||
|
return nil, "", "", &value
|
||||||
|
}
|
||||||
|
access := newNativeAccess(namespace.filesystem, rootPath, namespace.token)
|
||||||
|
access.label = namespace.label
|
||||||
|
if !access.contains(basePath) || mainPath != "" && !access.contains(mainPath) {
|
||||||
|
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||||
|
return nil, "", "", &value
|
||||||
|
}
|
||||||
|
return access, basePath, mainPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateNativeDirectories(access *filesystemAccess, basePath, mainPath string) *Diagnostic {
|
||||||
|
if !access.contains(basePath) || mainPath != "" && !access.contains(mainPath) {
|
||||||
|
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
for _, directory := range []string{access.rootPath, basePath} {
|
||||||
|
info, err := access.stat(directory)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
|
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
|
||||||
if main.source == nil || main.document == nil {
|
if main.source == nil || main.document == nil {
|
||||||
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
|
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
|
||||||
|
|||||||
185
internal/engine/filesystem_access.go
Normal file
185
internal/engine/filesystem_access.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// namespaceToken identifies one caller-selected filesystem without comparing
|
||||||
|
// arbitrary fs.FS values. A token can be shared by copies of a source wrapper.
|
||||||
|
type namespaceToken struct{ marker byte }
|
||||||
|
|
||||||
|
type nativeNamespace struct {
|
||||||
|
filesystem fs.FS
|
||||||
|
label string
|
||||||
|
token *namespaceToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNativeNamespace(label string, filesystem fs.FS) *nativeNamespace {
|
||||||
|
return &nativeNamespace{filesystem: filesystem, label: label, token: &namespaceToken{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type resourceIdentity struct {
|
||||||
|
namespace *namespaceToken
|
||||||
|
path string
|
||||||
|
occurrence *namespaceToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// filesystemAccess is a load-scoped view of either a contained host root or a
|
||||||
|
// borrowed native filesystem. Names are canonical host paths or fs.FS paths.
|
||||||
|
type filesystemAccess struct {
|
||||||
|
rootPath string
|
||||||
|
root *os.Root
|
||||||
|
native fs.FS
|
||||||
|
token *namespaceToken
|
||||||
|
label string
|
||||||
|
}
|
||||||
|
|
||||||
|
func openHostAccess(rootPath string) (*filesystemAccess, error) {
|
||||||
|
root, err := os.OpenRoot(rootPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &filesystemAccess{rootPath: rootPath, root: root, token: &namespaceToken{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNativeAccess(filesystem fs.FS, root string, token *namespaceToken) *filesystemAccess {
|
||||||
|
return &filesystemAccess{rootPath: root, native: filesystem, token: token}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) close() error {
|
||||||
|
if a.root != nil {
|
||||||
|
return a.root.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) identity(name string) resourceIdentity {
|
||||||
|
return resourceIdentity{namespace: a.token, path: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) syntheticIdentity() resourceIdentity {
|
||||||
|
return resourceIdentity{namespace: a.token, occurrence: &namespaceToken{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) join(base, name string) string {
|
||||||
|
if a.native != nil {
|
||||||
|
return path.Join(base, name)
|
||||||
|
}
|
||||||
|
return filepath.Join(base, filepath.FromSlash(path.Clean(name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) dir(name string) string {
|
||||||
|
if a.native != nil {
|
||||||
|
return path.Dir(name)
|
||||||
|
}
|
||||||
|
return filepath.Dir(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) contains(name string) bool {
|
||||||
|
if a.native == nil {
|
||||||
|
return containedPath(a.rootPath, filepath.Clean(name))
|
||||||
|
}
|
||||||
|
if !fs.ValidPath(name) || !fs.ValidPath(a.rootPath) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return a.rootPath == "." || name == a.rootPath || strings.HasPrefix(name, a.rootPath+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) canonical(name string) (string, error) {
|
||||||
|
if a.native == nil {
|
||||||
|
return canonicalExistingPath(name)
|
||||||
|
}
|
||||||
|
if !fs.ValidPath(name) {
|
||||||
|
return "", fs.ErrInvalid
|
||||||
|
}
|
||||||
|
_, err := fs.Stat(a.native, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) relative(name string) (string, error) {
|
||||||
|
if a.native != nil {
|
||||||
|
if !a.contains(name) {
|
||||||
|
return "", fs.ErrInvalid
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(a.rootPath, name)
|
||||||
|
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||||
|
return "", fs.ErrInvalid
|
||||||
|
}
|
||||||
|
return relative, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) stat(name string) (fs.FileInfo, error) {
|
||||||
|
if a.native != nil {
|
||||||
|
return fs.Stat(a.native, name)
|
||||||
|
}
|
||||||
|
relative, err := a.relative(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return a.root.Stat(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) open(name string) (fs.File, error) {
|
||||||
|
if a.native != nil {
|
||||||
|
return a.native.Open(name)
|
||||||
|
}
|
||||||
|
relative, err := a.relative(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return a.root.Open(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error {
|
||||||
|
file, err := a.open(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reader, ok := file.(fs.ReadDirFile); ok {
|
||||||
|
for {
|
||||||
|
entries, readErr := reader.ReadDir(globReadBatch)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !visit(entry) {
|
||||||
|
return file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errors.Is(readErr, io.EOF) {
|
||||||
|
return file.Close()
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
_ = file.Close()
|
||||||
|
return io.ErrNoProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// fs.ReadDir provides the standard fallback for implementations whose opened
|
||||||
|
// files do not implement fs.ReadDirFile. It may allocate all entries itself.
|
||||||
|
entries, err := fs.ReadDir(a.native, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !visit(entry) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -2,11 +2,8 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -42,29 +39,29 @@ func (s *importTraversal) expandImportGlob(basePath string, declaration importDe
|
|||||||
names = []string{literal}
|
names = []string{literal}
|
||||||
}
|
}
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
absolute := filepath.Join(candidate.absolute, filepath.FromSlash(name))
|
absolute := s.access.join(candidate.absolute, name)
|
||||||
relative := path.Join(candidate.relative, name)
|
relative := path.Join(candidate.relative, name)
|
||||||
if !containedPath(s.rootPath, filepath.Clean(absolute)) {
|
if !s.access.contains(absolute) {
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
|
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
canonical, err := canonicalExistingPath(absolute)
|
canonical, err := s.access.canonical(absolute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) && !patterned {
|
if errors.Is(err, fs.ErrNotExist) && !patterned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
|
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
|
||||||
}
|
}
|
||||||
if !containedPath(s.rootPath, canonical) {
|
if !s.access.contains(canonical) {
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
|
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
if last {
|
if last {
|
||||||
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
||||||
if len(s.sources)+len(next) > s.limits.sources {
|
if s.accounting.sources+len(next) > s.limits.sources {
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
|
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
info, err := os.Stat(canonical)
|
info, err := s.access.stat(canonical)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
|
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
|
||||||
}
|
}
|
||||||
@@ -88,56 +85,47 @@ func (s *importTraversal) expandImportGlob(basePath string, declaration importDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
|
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
|
||||||
canonical, err := canonicalExistingPath(directory)
|
canonical, err := s.access.canonical(directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
|
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
|
||||||
}
|
}
|
||||||
if !containedPath(s.rootPath, canonical) {
|
if !s.access.contains(canonical) {
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
relative, err := filepath.Rel(s.rootPath, canonical)
|
if _, err := s.access.relative(canonical); err != nil {
|
||||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
directoryFile, err := s.root.Open(relative)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be opened", location)}
|
|
||||||
}
|
|
||||||
|
|
||||||
var matches []string
|
var matches []string
|
||||||
for {
|
var problem *Diagnostic
|
||||||
entries, readErr := directoryFile.ReadDir(globReadBatch)
|
err = s.access.readDirectory(canonical, func(entry fs.DirEntry) bool {
|
||||||
for _, entry := range entries {
|
s.accounting.entries++
|
||||||
s.usedEntries++
|
if s.accounting.entries > s.limits.entries {
|
||||||
if s.usedEntries > s.limits.entries {
|
value := importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)
|
||||||
_ = directoryFile.Close()
|
problem = &value
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
return false
|
||||||
}
|
|
||||||
matched, matchErr := path.Match(pattern, entry.Name())
|
|
||||||
if matchErr != nil {
|
|
||||||
_ = directoryFile.Close()
|
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)}
|
|
||||||
}
|
|
||||||
if matched {
|
|
||||||
matches = append(matches, entry.Name())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if errors.Is(readErr, io.EOF) {
|
matched, matchErr := path.Match(pattern, entry.Name())
|
||||||
break
|
if matchErr != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)
|
||||||
|
problem = &value
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
if readErr != nil {
|
if matched {
|
||||||
_ = directoryFile.Close()
|
matches = append(matches, entry.Name())
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(readErr, "glob directory could not be read", location)}
|
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if problem != nil {
|
||||||
|
return nil, []Diagnostic{*problem}
|
||||||
}
|
}
|
||||||
if err := directoryFile.Close(); err != nil {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be closed", location)}
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be traversed", location)}
|
||||||
}
|
}
|
||||||
slices.Sort(matches)
|
slices.Sort(matches)
|
||||||
return matches, nil
|
return matches, nil
|
||||||
|
|||||||
@@ -6,6 +6,40 @@ import (
|
|||||||
|
|
||||||
const historyNodeLimit = 4_000_000
|
const historyNodeLimit = 4_000_000
|
||||||
|
|
||||||
|
type retainedNodeCounter struct {
|
||||||
|
seen map[*document.Node]bool
|
||||||
|
count int
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRetainedNodeCounter(limit int) *retainedNodeCounter {
|
||||||
|
return &retainedNodeCounter{seen: make(map[*document.Node]bool), limit: limit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *retainedNodeCounter) add(root *document.Node) bool {
|
||||||
|
if c == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stack := []*document.Node{root}
|
||||||
|
for len(stack) != 0 {
|
||||||
|
node := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
if node == nil || c.seen[node] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.seen[node] = true
|
||||||
|
c.count++
|
||||||
|
if c.limit >= 0 && c.count > c.limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
stack = append(stack, entry.Value)
|
||||||
|
}
|
||||||
|
stack = append(stack, node.Sequence...)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// History applications borrow immutable policy-validated input trees. Metadata
|
// History applications borrow immutable policy-validated input trees. Metadata
|
||||||
// records answer presence and origin queries; replay reconstructs value changes.
|
// records answer presence and origin queries; replay reconstructs value changes.
|
||||||
// Replay reconciles with canonical decoding before domain resolution so both
|
// Replay reconciles with canonical decoding before domain resolution so both
|
||||||
@@ -31,7 +65,7 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
|
func applicationOperationPath(application historyApplication, query Path, lookup *documentLookup) (Path, bool) {
|
||||||
if !application.path.valid() || application.value == nil {
|
if !application.path.valid() || application.value == nil {
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
@@ -46,7 +80,7 @@ func applicationOperationPath(application historyApplication, query Path) (Path,
|
|||||||
}
|
}
|
||||||
for length := 1; length <= len(query.segments); length++ {
|
for length := 1; length <= len(query.segments); length++ {
|
||||||
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
||||||
node, present := documentAtPath(application.value, prefix)
|
node, present := documentAtPathWithLookup(application.value, prefix, lookup)
|
||||||
if !present {
|
if !present {
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
@@ -57,7 +91,7 @@ func applicationOperationPath(application historyApplication, query Path) (Path,
|
|||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
|
func historyApplicationOrigin(application historyApplication, operationPath Path, lookup *documentLookup) Origin {
|
||||||
if application.origin.Layer != "" {
|
if application.origin.Layer != "" {
|
||||||
return cloneOrigin(application.origin)
|
return cloneOrigin(application.origin)
|
||||||
}
|
}
|
||||||
@@ -66,7 +100,7 @@ func historyApplicationOrigin(application historyApplication, operationPath Path
|
|||||||
if !ok {
|
if !ok {
|
||||||
return origin
|
return origin
|
||||||
}
|
}
|
||||||
if node, present := documentAtPath(application.value, relative); present {
|
if node, present := documentAtPathWithLookup(application.value, relative, lookup); present {
|
||||||
origin.Locations = []Location{publicLocation(node.Location)}
|
origin.Locations = []Location{publicLocation(node.Location)}
|
||||||
}
|
}
|
||||||
return origin
|
return origin
|
||||||
@@ -83,15 +117,15 @@ func pathsIntersect(left, right Path) bool {
|
|||||||
return pathContains(left, right) || pathContains(right, left)
|
return pathContains(left, right) || pathContains(right, left)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyHistoryApplication(root **document.Node, application historyApplication) {
|
func applyHistoryApplication(root **document.Node, application historyApplication, index *mappingIndex) {
|
||||||
if application.path.String() == "" {
|
if application.path.String() == "" {
|
||||||
mergeHistoryDocument(root, application.value)
|
mergeHistoryDocument(root, application.value, index)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mergeHistoryAtPath(root, application.path, application.value)
|
mergeHistoryAtPath(root, application.path, application.value, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
|
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
|
||||||
if incoming == nil {
|
if incoming == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,39 +134,39 @@ func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, entry := range incoming.Mapping {
|
for _, entry := range incoming.Mapping {
|
||||||
position := documentEntryIndex((*target).Mapping, entry.Key)
|
position := index.find(*target, entry.Key)
|
||||||
if position < 0 {
|
if position < 0 {
|
||||||
cloned := entry
|
cloned := entry
|
||||||
cloned.Value = document.Clone(entry.Value)
|
cloned.Value = document.Clone(entry.Value)
|
||||||
(*target).Mapping = append((*target).Mapping, cloned)
|
index.append(*target, cloned)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
|
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value, index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
|
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex) {
|
||||||
if root == nil || *root == nil || len(path.segments) == 0 {
|
if root == nil || *root == nil || len(path.segments) == 0 {
|
||||||
mergeHistoryDocument(root, incoming)
|
mergeHistoryDocument(root, incoming, index)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
current := *root
|
current := *root
|
||||||
for index, segment := range path.segments {
|
for segmentIndex, segment := range path.segments {
|
||||||
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
|
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
position := documentEntryIndex(current.Mapping, segment.key)
|
position := index.find(current, segment.key)
|
||||||
if index == len(path.segments)-1 {
|
if segmentIndex == len(path.segments)-1 {
|
||||||
if position < 0 {
|
if position < 0 {
|
||||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
|
index.append(current, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
|
||||||
} else {
|
} else {
|
||||||
mergeHistoryDocument(¤t.Mapping[position].Value, incoming)
|
mergeHistoryDocument(¤t.Mapping[position].Value, incoming, index)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if position < 0 {
|
if position < 0 {
|
||||||
child := &document.Node{Kind: document.Mapping}
|
child := &document.Node{Kind: document.Mapping}
|
||||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
index.append(current, document.Entry{Key: segment.key, Value: child})
|
||||||
current = child
|
current = child
|
||||||
} else {
|
} else {
|
||||||
current = current.Mapping[position].Value
|
current = current.Mapping[position].Value
|
||||||
@@ -145,28 +179,50 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
|
|||||||
// intent into that event rather than replay a child assignment a second time.
|
// intent into that event rather than replay a child assignment a second time.
|
||||||
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
||||||
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
||||||
|
reasons := make([]map[string]struct{}, 0, len(snapshots))
|
||||||
|
dependencies := make([]map[string]struct{}, 0, len(snapshots))
|
||||||
|
var coverage pathPrefixIndex
|
||||||
for _, snapshot := range snapshots {
|
for _, snapshot := range snapshots {
|
||||||
merged := false
|
pointer := snapshot.change.Path.String()
|
||||||
for i := range groups {
|
groupIndex := -1
|
||||||
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
|
coverage.visitAncestors(pointer, func(id int) {
|
||||||
continue
|
if groupIndex < 0 || id < groupIndex {
|
||||||
|
groupIndex = id
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
if groupIndex >= 0 {
|
||||||
|
group := &groups[groupIndex]
|
||||||
for _, reason := range snapshot.change.Reasons {
|
for _, reason := range snapshot.change.Reasons {
|
||||||
groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason)
|
if _, found := reasons[groupIndex][reason]; !found {
|
||||||
|
reasons[groupIndex][reason] = struct{}{}
|
||||||
|
group.change.Reasons = append(group.change.Reasons, reason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, dependency := range snapshot.change.Dependencies {
|
for _, dependency := range snapshot.change.Dependencies {
|
||||||
groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency)
|
dependencyPointer := dependency.String()
|
||||||
|
if _, found := dependencies[groupIndex][dependencyPointer]; !found {
|
||||||
|
dependencies[groupIndex][dependencyPointer] = struct{}{}
|
||||||
|
group.change.Dependencies = append(group.change.Dependencies, dependency.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if snapshot.change.Origin.Operation == ChangeNormalization {
|
if snapshot.change.Origin.Operation == ChangeNormalization {
|
||||||
groups[i].change.Origin.Operation = ChangeNormalization
|
group.change.Origin.Operation = ChangeNormalization
|
||||||
}
|
}
|
||||||
merged = true
|
continue
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if !merged {
|
snapshot.change = cloneChange(snapshot.change)
|
||||||
snapshot.change = cloneChange(snapshot.change)
|
groups = append(groups, snapshot)
|
||||||
groups = append(groups, snapshot)
|
reasonSet := make(map[string]struct{}, len(snapshot.change.Reasons))
|
||||||
|
for _, reason := range snapshot.change.Reasons {
|
||||||
|
reasonSet[reason] = struct{}{}
|
||||||
}
|
}
|
||||||
|
reasons = append(reasons, reasonSet)
|
||||||
|
dependencySet := make(map[string]struct{}, len(snapshot.change.Dependencies))
|
||||||
|
for _, dependency := range snapshot.change.Dependencies {
|
||||||
|
dependencySet[dependency.String()] = struct{}{}
|
||||||
|
}
|
||||||
|
dependencies = append(dependencies, dependencySet)
|
||||||
|
coverage.insert(pointer, len(groups)-1)
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
@@ -230,11 +286,11 @@ func replaceHistoryPath(root **document.Node, path Path, replacement *document.N
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
|
||||||
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions)
|
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions, inherited...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
|
||||||
seen := make(map[*document.Node]bool)
|
seen := make(map[*document.Node]bool)
|
||||||
count := 0
|
count := 0
|
||||||
var visit func(*document.Node) bool
|
var visit func(*document.Node) bool
|
||||||
@@ -272,5 +328,23 @@ func historyNodesWithin(limit int, baseline, input, final *document.Node, applic
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, expansion := range inherited {
|
||||||
|
if expansion == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !visit(expansion.root) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, operation := range expansion.operations {
|
||||||
|
if !visit(operation.parent) || !visit(operation.effective) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, replacement := range expansion.replacements {
|
||||||
|
if !visit(replacement.before) || !visit(replacement.after) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -40,47 +37,99 @@ type importRoute struct {
|
|||||||
locations []Location
|
locations []Location
|
||||||
}
|
}
|
||||||
|
|
||||||
type importTraversal struct {
|
type importAccounting struct {
|
||||||
rootPath string
|
sources int
|
||||||
root *os.Root
|
bytes int64
|
||||||
limits importLimits
|
nodes int
|
||||||
rule *VersionRule
|
entries int
|
||||||
|
}
|
||||||
|
|
||||||
documents []*document.Node
|
type importTraversal struct {
|
||||||
sources []SourceInfo
|
access *filesystemAccess
|
||||||
active map[string]int
|
accounting *importAccounting
|
||||||
visited map[string]importRoute
|
limits importLimits
|
||||||
stack []importRoute
|
rule *VersionRule
|
||||||
usedBytes int64
|
destination Path
|
||||||
usedNodes int
|
|
||||||
usedEntries int
|
documents []*document.Node
|
||||||
|
sources []SourceInfo
|
||||||
|
active map[resourceIdentity]int
|
||||||
|
visited map[resourceIdentity]importRoute
|
||||||
|
stack []importRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
return loadImportedDocumentsTracked(source, context, main, Path{}, rule, limits, &importAccounting{}, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedDocumentsTracked(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
if nativeContext(source, context) {
|
||||||
|
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited)
|
||||||
|
}
|
||||||
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
root, err := os.OpenRoot(rootPath)
|
access, err := openHostAccess(rootPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
diagnostic.Primary = nil
|
diagnostic.Primary = nil
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
|
return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
|
||||||
}
|
}
|
||||||
|
if hostToken != nil {
|
||||||
|
access.token = hostToken
|
||||||
|
}
|
||||||
|
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
return loadImportedWithAccessTracked(access, basePath, mainPath, main, Path{}, rule, limits, accounting, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
|
||||||
|
_ = access.close()
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
|
||||||
|
}
|
||||||
|
for _, directory := range []string{access.rootPath, basePath} {
|
||||||
|
info, err := access.stat(directory)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
_ = access.close()
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)}
|
||||||
|
}
|
||||||
|
}
|
||||||
mainInfo := cloneSourceInfo(*main.source)
|
mainInfo := cloneSourceInfo(*main.source)
|
||||||
|
mainInfo.Destination = destination.clone()
|
||||||
if mainPath != "" {
|
if mainPath != "" {
|
||||||
mainInfo.Path = mainPath
|
mainInfo.Path = mainPath
|
||||||
}
|
}
|
||||||
|
mainKey := access.identity(mainPath)
|
||||||
|
if mainPath == "" {
|
||||||
|
mainKey = access.syntheticIdentity()
|
||||||
|
}
|
||||||
|
if previous, repeated := visited[mainKey]; repeated {
|
||||||
|
_ = access.close()
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
||||||
|
}
|
||||||
|
accounting.sources++
|
||||||
|
accounting.bytes += main.bytes
|
||||||
|
accounting.nodes += countInputNodes(main.document)
|
||||||
state := importTraversal{
|
state := importTraversal{
|
||||||
rootPath: rootPath, root: root, limits: limits, rule: rule,
|
access: access, accounting: accounting, limits: limits, rule: rule,
|
||||||
documents: make([]*document.Node, 0, 1),
|
destination: destination.clone(),
|
||||||
sources: []SourceInfo{mainInfo},
|
documents: make([]*document.Node, 0, 1),
|
||||||
active: make(map[string]int), visited: make(map[string]importRoute),
|
sources: []SourceInfo{mainInfo},
|
||||||
usedBytes: main.bytes, usedNodes: countInputNodes(main.document),
|
active: make(map[resourceIdentity]int), visited: visited,
|
||||||
|
}
|
||||||
|
if state.visited == nil {
|
||||||
|
state.visited = make(map[resourceIdentity]importRoute)
|
||||||
}
|
}
|
||||||
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
||||||
_ = root.Close()
|
_ = access.close()
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,16 +138,25 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
|
|||||||
mainIdentity = "<selected-source>"
|
mainIdentity = "<selected-source>"
|
||||||
}
|
}
|
||||||
mainRoute := importRoute{canonical: mainIdentity}
|
mainRoute := importRoute{canonical: mainIdentity}
|
||||||
state.active[mainIdentity] = 0
|
state.active[mainKey] = 0
|
||||||
state.visited[mainIdentity] = mainRoute
|
state.visited[mainKey] = mainRoute
|
||||||
state.stack = append(state.stack, mainRoute)
|
state.stack = append(state.stack, mainRoute)
|
||||||
body, declarations, diagnostics := splitImportDirectives(main.document)
|
body, declarations, diagnostics := splitImportDirectives(main.document)
|
||||||
if len(diagnostics) == 0 {
|
if len(diagnostics) == 0 {
|
||||||
state.documents = append(state.documents, body)
|
_, placed, err := placeDocument(body, state.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
placementNodes := destinationWrapperNodeCount(state.destination)
|
||||||
|
if err != nil {
|
||||||
|
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||||
|
} else if accounting.nodes+placementNodes > limits.nodes {
|
||||||
|
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
|
||||||
|
} else {
|
||||||
|
accounting.nodes += placementNodes
|
||||||
|
state.documents = append(state.documents, placed)
|
||||||
|
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeErr := root.Close()
|
closeErr := access.close()
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, cloneSourceInfos(state.sources), diagnostics
|
return nil, cloneSourceInfos(state.sources), diagnostics
|
||||||
}
|
}
|
||||||
@@ -107,22 +165,22 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
|
|||||||
}
|
}
|
||||||
composed, diagnostics := composeDocuments(state.documents...)
|
composed, diagnostics := composeDocuments(state.documents...)
|
||||||
if composed != nil {
|
if composed != nil {
|
||||||
composed.inputBytes = state.usedBytes
|
composed.inputBytes = accounting.bytes
|
||||||
composed.inputNodes = state.usedNodes
|
composed.inputNodes = accounting.nodes
|
||||||
}
|
}
|
||||||
return composed, cloneSourceInfos(state.sources), diagnostics
|
return composed, cloneSourceInfos(state.sources), diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||||
if s.limits.sources < 1 {
|
if s.accounting.sources > s.limits.sources {
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
if s.usedBytes > s.limits.bytes {
|
if s.accounting.bytes > s.limits.bytes {
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
if s.usedNodes > s.limits.nodes {
|
if s.accounting.nodes > s.limits.nodes {
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
@@ -169,11 +227,11 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate := filepath.Join(basePath, filepath.FromSlash(path.Clean(declaration.path)))
|
candidate := s.access.join(basePath, declaration.path)
|
||||||
if !containedPath(s.rootPath, filepath.Clean(candidate)) {
|
if !s.access.contains(candidate) {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
canonical, err := canonicalExistingPath(candidate)
|
canonical, err := s.access.canonical(candidate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
|
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil
|
return nil
|
||||||
@@ -187,30 +245,39 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
}
|
}
|
||||||
return []Diagnostic{importDiagnostic(code, message, &location)}
|
return []Diagnostic{importDiagnostic(code, message, &location)}
|
||||||
}
|
}
|
||||||
if !containedPath(s.rootPath, canonical) {
|
if !s.access.contains(canonical) {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentRoute := s.currentRoute(location)
|
currentRoute := s.currentRoute(location)
|
||||||
if activeIndex, ok := s.active[canonical]; ok {
|
identity := s.access.identity(canonical)
|
||||||
|
if activeIndex, ok := s.active[identity]; ok {
|
||||||
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
||||||
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
||||||
}
|
}
|
||||||
if previous, ok := s.visited[canonical]; ok {
|
if previous, ok := s.visited[identity]; ok {
|
||||||
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
||||||
}
|
}
|
||||||
if len(s.sources)+1 > s.limits.sources {
|
if s.accounting.sources+1 > s.limits.sources {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
|
||||||
}
|
}
|
||||||
|
|
||||||
relative, err := filepath.Rel(s.rootPath, canonical)
|
if _, err := s.access.relative(canonical); err != nil {
|
||||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
sourceID := "source-" + strconv.Itoa(len(s.sources)+1)
|
sourceID := "source-" + strconv.Itoa(s.accounting.sources+1)
|
||||||
info := SourceInfo{ID: sourceID, Label: declaration.path, Path: canonical, Layer: LayerBase, ImportedFrom: &location}
|
kind := SourceKindFile
|
||||||
|
label := declaration.path
|
||||||
|
if s.access.native != nil {
|
||||||
|
kind = SourceKindFilesystem
|
||||||
|
label = s.access.label + "/" + canonical
|
||||||
|
}
|
||||||
|
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.destination.clone(), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
||||||
|
if kind == SourceKindFilesystem {
|
||||||
|
info.namespace = s.access.token
|
||||||
|
}
|
||||||
s.sources = append(s.sources, info)
|
s.sources = append(s.sources, info)
|
||||||
data, skipped, diagnostic := s.readImport(relative, sourceID, declaration.optional)
|
data, skipped, diagnostic := s.readImport(canonical, sourceID, declaration.optional)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return []Diagnostic{*diagnostic}
|
return []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
@@ -218,6 +285,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
s.sources = s.sources[:len(s.sources)-1]
|
s.sources = s.sources[:len(s.sources)-1]
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
s.accounting.sources++
|
||||||
|
|
||||||
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
|
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
|
||||||
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
||||||
@@ -226,16 +294,25 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
return parserDiagnostics(sourceID, issues)
|
return parserDiagnostics(sourceID, issues)
|
||||||
}
|
}
|
||||||
nodeCount := countInputNodes(root)
|
nodeCount := countInputNodes(root)
|
||||||
if s.usedNodes+nodeCount > s.limits.nodes {
|
if s.accounting.nodes+nodeCount > s.limits.nodes {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
||||||
}
|
}
|
||||||
s.usedNodes += nodeCount
|
s.accounting.nodes += nodeCount
|
||||||
|
|
||||||
body, declarations, diagnostics := splitImportDirectives(root)
|
body, declarations, diagnostics := splitImportDirectives(root)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
if versionNode := fragmentVersionClaim(body, s.rule); versionNode != nil {
|
_, placed, err := placeDocument(body, s.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
|
if err != nil {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)}
|
||||||
|
}
|
||||||
|
placementNodes := destinationWrapperNodeCount(s.destination)
|
||||||
|
if s.accounting.nodes+placementNodes > s.limits.nodes {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
||||||
|
}
|
||||||
|
s.accounting.nodes += placementNodes
|
||||||
|
if versionNode := fragmentVersionClaim(placed, s.rule); versionNode != nil {
|
||||||
versionLocation := publicLocation(versionNode.Location)
|
versionLocation := publicLocation(versionNode.Location)
|
||||||
return []Diagnostic{{
|
return []Diagnostic{{
|
||||||
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
|
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
|
||||||
@@ -244,23 +321,23 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
}
|
}
|
||||||
|
|
||||||
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
||||||
s.documents = append(s.documents, body)
|
s.documents = append(s.documents, placed)
|
||||||
s.active[canonical] = len(s.stack)
|
s.active[identity] = len(s.stack)
|
||||||
s.visited[canonical] = route
|
s.visited[identity] = route
|
||||||
s.stack = append(s.stack, route)
|
s.stack = append(s.stack, route)
|
||||||
diagnostics = s.walkDeclarations(filepath.Dir(canonical), declarations, depth)
|
diagnostics = s.walkDeclarations(s.access.dir(canonical), declarations, depth)
|
||||||
delete(s.active, canonical)
|
delete(s.active, identity)
|
||||||
s.stack = s.stack[:len(s.stack)-1]
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) readImport(relative, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
|
func (s *importTraversal) readImport(name, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
|
||||||
remaining := s.limits.bytes - s.usedBytes
|
remaining := s.limits.bytes - s.accounting.bytes
|
||||||
limit := min(int64(maxSourceBytes), remaining)
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
|
data, failure := readRegularFile(name, s.access.stat, s.access.open, limit)
|
||||||
if failure != nil {
|
if failure != nil {
|
||||||
if optional && failure.missingTarget {
|
if optional && failure.missingTarget {
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
@@ -268,7 +345,7 @@ func (s *importTraversal) readImport(relative, sourceID string, optional bool) (
|
|||||||
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
||||||
return nil, false, &value
|
return nil, false, &value
|
||||||
}
|
}
|
||||||
s.usedBytes += int64(len(data))
|
s.accounting.bytes += int64(len(data))
|
||||||
return data, false, nil
|
return data, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
313
internal/engine/inheritance.go
Normal file
313
internal/engine/inheritance.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
const inheritanceKey = "extends"
|
||||||
|
|
||||||
|
// These private records keep structural evidence separate from acquired source
|
||||||
|
// records. Later inspection can project a transfer onto individual descendants.
|
||||||
|
type inheritanceTransfer struct {
|
||||||
|
from Path
|
||||||
|
to Path
|
||||||
|
reference Path
|
||||||
|
locations []Location
|
||||||
|
}
|
||||||
|
|
||||||
|
type inheritanceControl struct {
|
||||||
|
path Path
|
||||||
|
locations []Location
|
||||||
|
cancelled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type inheritanceScope struct {
|
||||||
|
root *document.Node
|
||||||
|
origins map[string]Origin
|
||||||
|
transfers []inheritanceTransfer
|
||||||
|
controls []inheritanceControl
|
||||||
|
replacements []inheritanceReplacement
|
||||||
|
operations []inheritanceOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
type inheritanceOperation struct {
|
||||||
|
control *inheritanceControl
|
||||||
|
transfer *inheritanceTransfer
|
||||||
|
controlOrigin Origin
|
||||||
|
parent *document.Node
|
||||||
|
effective *document.Node
|
||||||
|
parentOrigins map[string]Origin
|
||||||
|
effectiveOrigins map[string]Origin
|
||||||
|
}
|
||||||
|
|
||||||
|
// A replacement retains the displaced source/derivation record while the
|
||||||
|
// effective origin belongs to the child. Value trees are borrowed immutable
|
||||||
|
// inputs or detached templates and are budgeted with retained history later.
|
||||||
|
type inheritanceReplacement struct {
|
||||||
|
path Path
|
||||||
|
before *document.Node
|
||||||
|
after *document.Node
|
||||||
|
previous []displacedDefinition
|
||||||
|
effective Origin
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link represents one shared relationship edge. Each contributor retains
|
||||||
|
// its original semantic path independently of source line/column coordinates.
|
||||||
|
type inheritanceTrace struct {
|
||||||
|
previous *inheritanceTrace
|
||||||
|
transfer *inheritanceTransfer
|
||||||
|
}
|
||||||
|
|
||||||
|
type inheritanceContributor struct {
|
||||||
|
source Path
|
||||||
|
location Location
|
||||||
|
trace *inheritanceTrace
|
||||||
|
}
|
||||||
|
|
||||||
|
type siblingFrame struct {
|
||||||
|
name string
|
||||||
|
parent string
|
||||||
|
hasParent bool
|
||||||
|
entered bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandSiblingDefinitions resolves only the mappings directly in scope.
|
||||||
|
// Nested instructions remain in the returned templates for destination-local
|
||||||
|
// resolution. This private entry point does not participate in public loading.
|
||||||
|
func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanceScope, []Diagnostic) {
|
||||||
|
return expandSiblingDefinitionsWithLimits(scope, scopePath, defaultProjectionLimits())
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandSiblingDefinitionsWithLimits(scope *document.Node, scopePath Path, limits projectionLimits) (*inheritanceScope, []Diagnostic) {
|
||||||
|
return expandSiblingDefinitionsWithOrigins(scope, scopePath, limits, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) {
|
||||||
|
if scope == nil || scope.Kind != document.Mapping {
|
||||||
|
return nil, []Diagnostic{{
|
||||||
|
Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath,
|
||||||
|
Message: "inheritance scope must be a mapping",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
byName := make(map[string]document.Entry, len(scope.Mapping))
|
||||||
|
names := make([]string, 0, len(scope.Mapping))
|
||||||
|
for _, entry := range scope.Mapping {
|
||||||
|
byName[entry.Key] = entry
|
||||||
|
names = append(names, entry.Key)
|
||||||
|
}
|
||||||
|
slices.Sort(names)
|
||||||
|
|
||||||
|
resolved := make(map[string]*document.Node, len(byName))
|
||||||
|
resolvedOrigins := make(map[string]map[string]Origin, len(byName))
|
||||||
|
state := make(map[string]uint8, len(byName))
|
||||||
|
result := &inheritanceScope{}
|
||||||
|
counter := newRetainedNodeCounter(historyNodeLimit)
|
||||||
|
if len(budget) != 0 {
|
||||||
|
counter = budget[0]
|
||||||
|
}
|
||||||
|
retained := 1 // scope mapping node
|
||||||
|
for _, entry := range scope.Mapping {
|
||||||
|
if entry.Value.Kind == document.Mapping {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remaining := limits.nodes - retained - 1
|
||||||
|
if limits.nodes >= 0 && remaining < 0 {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath.Key(entry.Key), entry.Value.Location)}
|
||||||
|
}
|
||||||
|
count, within := inheritanceLowerBoundCount(entry.Value, remaining)
|
||||||
|
if !within {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath.Key(entry.Key), entry.Value.Location)}
|
||||||
|
}
|
||||||
|
retained += 1 + count // key and value subtree
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
if byName[name].Value.Kind != document.Mapping || state[name] == 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stack := []siblingFrame{{name: name}}
|
||||||
|
for len(stack) != 0 {
|
||||||
|
frame := &stack[len(stack)-1]
|
||||||
|
entry := byName[frame.name]
|
||||||
|
path := scopePath.Key(frame.name)
|
||||||
|
if !frame.entered {
|
||||||
|
state[frame.name] = 1
|
||||||
|
frame.entered = true
|
||||||
|
reference, found := inheritanceDirective(entry.Value)
|
||||||
|
if found {
|
||||||
|
referencePath := path.Key(inheritanceKey)
|
||||||
|
location := publicLocation(reference.Value.Location)
|
||||||
|
result.controls = append(result.controls, inheritanceControl{
|
||||||
|
path: referencePath, locations: []Location{location},
|
||||||
|
cancelled: reference.Value.Kind == document.Null,
|
||||||
|
})
|
||||||
|
switch reference.Value.Kind {
|
||||||
|
case document.Null:
|
||||||
|
case document.String:
|
||||||
|
frame.parent, frame.hasParent = reference.Value.Text, true
|
||||||
|
parent, exists := byName[frame.parent]
|
||||||
|
if !exists {
|
||||||
|
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceMissing, referencePath, location, nil,
|
||||||
|
"referenced sibling does not exist")}
|
||||||
|
}
|
||||||
|
if parent.Value.Kind != document.Mapping {
|
||||||
|
related := []Location{publicLocation(parent.Value.Location)}
|
||||||
|
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, related,
|
||||||
|
"referenced sibling is not a mapping")}
|
||||||
|
}
|
||||||
|
if state[frame.parent] == 1 {
|
||||||
|
related := []Location{publicLocation(parent.Value.Location)}
|
||||||
|
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceCycle, referencePath, location, related,
|
||||||
|
"sibling inheritance contains a cycle")}
|
||||||
|
}
|
||||||
|
if state[frame.parent] == 0 {
|
||||||
|
stack = append(stack, siblingFrame{name: frame.parent})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, nil,
|
||||||
|
"inheritance reference must be a sibling name or null")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := withoutInheritanceDirective(entry.Value)
|
||||||
|
operationStart, replacementStart := len(result.operations), len(result.replacements)
|
||||||
|
childOrigins := directInheritanceOrigins(payload, path, inputOrigins)
|
||||||
|
if reference, found := inheritanceDirective(entry.Value); found {
|
||||||
|
control := inheritanceControl{
|
||||||
|
path: path.Key(inheritanceKey),
|
||||||
|
locations: []Location{publicLocation(reference.Value.Location)},
|
||||||
|
cancelled: reference.Value.Kind == document.Null,
|
||||||
|
}
|
||||||
|
controlOrigins := directInheritanceOrigins(reference.Value, control.path, inputOrigins)
|
||||||
|
result.operations = append(result.operations, inheritanceOperation{
|
||||||
|
control: &control, controlOrigin: controlOrigins[control.path.String()],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if limits.nodes >= 0 {
|
||||||
|
remaining := limits.nodes - retained - 1 // the sibling key
|
||||||
|
within := false
|
||||||
|
if frame.hasParent {
|
||||||
|
within = inheritanceOverlayLowerBoundWithin(resolved[frame.parent], payload, remaining)
|
||||||
|
} else {
|
||||||
|
within = inheritanceLowerBoundWithin(payload, remaining)
|
||||||
|
}
|
||||||
|
if !within {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(path, entry.Value.Location)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if frame.hasParent {
|
||||||
|
parent := resolved[frame.parent]
|
||||||
|
merged := document.Clone(parent)
|
||||||
|
control := inheritanceDirectiveLocation(entry.Value)
|
||||||
|
transfer := inheritanceTransfer{
|
||||||
|
from: scopePath.Key(frame.parent), to: path,
|
||||||
|
reference: path.Key(inheritanceKey), locations: []Location{control},
|
||||||
|
}
|
||||||
|
mergedOrigins := transferredInheritanceOrigins(resolvedOrigins[frame.parent], transfer.from, path, transfer)
|
||||||
|
mergeInheritanceOrigin(mergedOrigins, path, childOrigins[path.String()])
|
||||||
|
mergeRecursiveMappings(merged, payload, path, new(mappingIndex), recursiveMergeHooks{
|
||||||
|
added: func(childPath Path, incoming *document.Node) {
|
||||||
|
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
|
||||||
|
},
|
||||||
|
combined: func(childPath Path, _ *document.Node) {
|
||||||
|
mergeInheritanceOrigin(mergedOrigins, childPath, childOrigins[childPath.String()])
|
||||||
|
},
|
||||||
|
replaced: func(childPath Path, existing, incoming *document.Node) {
|
||||||
|
previous := displacedOrigins(existing, mergedOrigins, childPath)
|
||||||
|
result.replacements = append(result.replacements, inheritanceReplacement{
|
||||||
|
path: childPath.clone(), before: existing, after: incoming,
|
||||||
|
previous: previous, effective: cloneOrigin(childOrigins[childPath.String()]),
|
||||||
|
})
|
||||||
|
deleteOriginSubtree(mergedOrigins, childPath, existing)
|
||||||
|
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
merged.Location = entry.Value.Location
|
||||||
|
resolved[frame.name] = merged
|
||||||
|
resolvedOrigins[frame.name] = mergedOrigins
|
||||||
|
result.transfers = append(result.transfers, transfer)
|
||||||
|
result.operations = append(result.operations, inheritanceOperation{
|
||||||
|
transfer: &transfer, parent: parent, effective: merged,
|
||||||
|
parentOrigins: resolvedOrigins[frame.parent], effectiveOrigins: mergedOrigins,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolved[frame.name] = document.Clone(payload)
|
||||||
|
resolvedOrigins[frame.name] = childOrigins
|
||||||
|
}
|
||||||
|
count, _ := inheritanceLowerBoundCount(resolved[frame.name], limits.nodes)
|
||||||
|
retained += 1 + count
|
||||||
|
for _, operation := range result.operations[operationStart:] {
|
||||||
|
if !counter.add(operation.parent) || !counter.add(operation.effective) {
|
||||||
|
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(path, entry.Value.Location)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, replacement := range result.replacements[replacementStart:] {
|
||||||
|
if !counter.add(replacement.before) || !counter.add(replacement.after) {
|
||||||
|
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(path, entry.Value.Location)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state[frame.name] = 2
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root := *scope
|
||||||
|
root.Mapping = make([]document.Entry, len(scope.Mapping))
|
||||||
|
rootOrigin := directInheritanceOrigins(&document.Node{Kind: document.Mapping, Location: scope.Location}, scopePath, inputOrigins)
|
||||||
|
result.origins = rootOrigin
|
||||||
|
for index, entry := range scope.Mapping {
|
||||||
|
root.Mapping[index] = entry
|
||||||
|
if resolved[entry.Key] != nil {
|
||||||
|
root.Mapping[index].Value = resolved[entry.Key]
|
||||||
|
for pointer, origin := range resolvedOrigins[entry.Key] {
|
||||||
|
result.origins[pointer] = origin
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
root.Mapping[index].Value = document.Clone(entry.Value)
|
||||||
|
for pointer, origin := range directInheritanceOrigins(entry.Value, scopePath.Key(entry.Key), inputOrigins) {
|
||||||
|
result.origins[pointer] = origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.root = &root
|
||||||
|
if !inheritanceLowerBoundWithin(result.root, limits.nodes) {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath, scope.Location)}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceDirective(node *document.Node) (document.Entry, bool) {
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
if entry.Key == inheritanceKey {
|
||||||
|
return entry, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return document.Entry{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceDirectiveLocation(node *document.Node) Location {
|
||||||
|
entry, _ := inheritanceDirective(node)
|
||||||
|
return publicLocation(entry.Value.Location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withoutInheritanceDirective(node *document.Node) *document.Node {
|
||||||
|
payload := *node
|
||||||
|
payload.Mapping = make([]document.Entry, 0, len(node.Mapping))
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
if entry.Key != inheritanceKey {
|
||||||
|
payload.Mapping = append(payload.Mapping, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceDiagnostic(code Code, path Path, primary Location, related []Location, message string) Diagnostic {
|
||||||
|
return Diagnostic{
|
||||||
|
Code: code, Severity: SeverityError, Path: path, Message: message,
|
||||||
|
Primary: &primary, Related: related,
|
||||||
|
}
|
||||||
|
}
|
||||||
110
internal/engine/inheritance_bench_test.go
Normal file
110
internal/engine/inheritance_bench_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BenchmarkInheritanceExpansion(b *testing.B) {
|
||||||
|
for _, shape := range []string{"chain", "wide", "nested", "replacement"} {
|
||||||
|
for _, size := range []int{16, 32, 64} {
|
||||||
|
b.Run(shape+"/"+strconv.Itoa(size), func(b *testing.B) {
|
||||||
|
input := inheritanceBenchmarkInput(shape, size)
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for range b.N {
|
||||||
|
if _, diagnostics := expandInheritanceWithOrigins(input, nil, defaultProjectionLimits()); len(diagnostics) != 0 {
|
||||||
|
b.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkInheritanceInspectedLoad(b *testing.B) {
|
||||||
|
type entry struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Entries map[string]entry `yaml:"entries"`
|
||||||
|
}
|
||||||
|
for _, size := range []int{16, 32, 64} {
|
||||||
|
b.Run(strconv.Itoa(size), func(b *testing.B) {
|
||||||
|
var input strings.Builder
|
||||||
|
input.WriteString("entries:\n item-0: {value: base}\n")
|
||||||
|
for index := 1; index <= size; index++ {
|
||||||
|
input.WriteString(" item-")
|
||||||
|
input.WriteString(strconv.Itoa(index))
|
||||||
|
input.WriteString(": {extends: item-0}\n")
|
||||||
|
}
|
||||||
|
source := Bytes("inheritance-benchmark", []byte(input.String()))
|
||||||
|
query := Keys("entries", "item-"+strconv.Itoa(size), "value")
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for range b.N {
|
||||||
|
loaded, err := Load[configuration](source, testSecretBinding())
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, found := loaded.Inspection.Explain(query); !found {
|
||||||
|
b.Fatal("inherited value is not explainable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each scope has constant-sized output and evidence. This distinguishes budget
|
||||||
|
// accounting overhead from legitimate growth in expanded parent payloads.
|
||||||
|
func BenchmarkInheritanceIndependentScopes(b *testing.B) {
|
||||||
|
for _, size := range []int{256, 512, 1024} {
|
||||||
|
b.Run(strconv.Itoa(size), func(b *testing.B) {
|
||||||
|
input := &document.Node{Kind: document.Mapping}
|
||||||
|
for index := 0; index < size; index++ {
|
||||||
|
input.Mapping = append(input.Mapping, document.Entry{
|
||||||
|
Key: strconv.Itoa(index), Value: inheritanceBenchmarkInput("wide", 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for range b.N {
|
||||||
|
if _, diagnostics := expandInheritanceTree(input, defaultProjectionLimits()); len(diagnostics) != 0 {
|
||||||
|
b.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceBenchmarkInput(shape string, size int) *document.Node {
|
||||||
|
value := func(text string) *document.Node { return &document.Node{Kind: document.String, Text: text} }
|
||||||
|
mapping := func(entries ...document.Entry) *document.Node {
|
||||||
|
return &document.Node{Kind: document.Mapping, Mapping: entries}
|
||||||
|
}
|
||||||
|
entry := func(key string, node *document.Node) document.Entry { return document.Entry{Key: key, Value: node} }
|
||||||
|
base := mapping(entry("value", value("base")), entry("settings", mapping(entry("enabled", &document.Node{Kind: document.Boolean, Text: "false"}))))
|
||||||
|
if shape == "nested" {
|
||||||
|
base = mapping(entry("settings", mapping(entry("first", mapping(entry("value", value("base")))), entry("second", mapping(entry("extends", value("first")))))))
|
||||||
|
}
|
||||||
|
definitions := make([]document.Entry, 0, size+1)
|
||||||
|
definitions = append(definitions, entry("item-0", base))
|
||||||
|
for index := 1; index <= size; index++ {
|
||||||
|
parent := "item-0"
|
||||||
|
if shape == "chain" {
|
||||||
|
parent = "item-" + strconv.Itoa(index-1)
|
||||||
|
}
|
||||||
|
payload := []document.Entry{entry("extends", value(parent))}
|
||||||
|
if shape == "nested" {
|
||||||
|
payload = append(payload, entry("settings", mapping(entry("first", mapping(entry("value", value("custom")))))))
|
||||||
|
}
|
||||||
|
if shape == "replacement" {
|
||||||
|
payload = append(payload, entry("value", value("replacement")))
|
||||||
|
}
|
||||||
|
definitions = append(definitions, entry("item-"+strconv.Itoa(index), mapping(payload...)))
|
||||||
|
}
|
||||||
|
return mapping(entry("entries", mapping(definitions...)))
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user