Add safe historical configuration explanations
This commit is contained in:
@@ -12,8 +12,8 @@ then report domain errors and warnings through read-only semantic validation. Se
|
||||
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
||||
references resolve environment names before validation, and explicit
|
||||
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
||||
CLI override records apply above profiles. Value explanation is still under
|
||||
development.
|
||||
CLI override records apply above profiles. Value explanation is available through
|
||||
detached structured records and deterministic redacted output.
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
|
||||
24
compose.go
24
compose.go
@@ -6,12 +6,13 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
// precedence layers. Claims remain ordered by source and declaration traversal;
|
||||
// origins retain every source that contributes to a composite mapping.
|
||||
type composedDocument struct {
|
||||
root *document.Node
|
||||
claims []compositionClaim
|
||||
origins map[string]Origin
|
||||
overlays []overlayRecord
|
||||
inputBytes int64
|
||||
inputNodes int
|
||||
root *document.Node
|
||||
claims []compositionClaim
|
||||
origins map[string]Origin
|
||||
overlays []overlayRecord
|
||||
applications []historyApplication
|
||||
inputBytes int64
|
||||
inputNodes int
|
||||
}
|
||||
|
||||
type compositionClaim struct {
|
||||
@@ -58,10 +59,15 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
||||
for _, input := range inputs[1:] {
|
||||
mergeDisjointMappings(root, input)
|
||||
}
|
||||
applications := make([]historyApplication, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
applications = append(applications, historyApplication{layer: LayerBase, value: input})
|
||||
}
|
||||
return &composedDocument{
|
||||
root: root,
|
||||
claims: cloneCompositionClaims(collected.ordered),
|
||||
origins: compositionOrigins(collected.ordered),
|
||||
root: root,
|
||||
claims: cloneCompositionClaims(collected.ordered),
|
||||
origins: compositionOrigins(collected.ordered),
|
||||
applications: applications,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -141,10 +141,60 @@ translated to a fixed safe error. There is no unredacted rendering option and
|
||||
ConfigKit performs no implicit logging.
|
||||
|
||||
`Result.Config` is the explicit trusted boundary and contains ordinary typed values,
|
||||
including secrets. Metadata, histories, diagnostics, and load errors contain no raw
|
||||
configuration values. Applications must still keep source labels, resolution
|
||||
reasons, and diagnostic messages value-free because ConfigKit cannot infer and
|
||||
sanitize secrets embedded in arbitrary application prose.
|
||||
including secrets. Metadata records, diagnostics, and load errors contain no
|
||||
configuration values. Explanation snapshots deliberately expose non-secret
|
||||
configuration history after applying the redaction contract below. Applications
|
||||
must still keep source labels, resolution reasons, and diagnostic messages value-free
|
||||
because ConfigKit cannot infer and sanitize secrets embedded in arbitrary application
|
||||
prose.
|
||||
|
||||
### Historical-value explanation
|
||||
|
||||
`Result.Explain(path)` returns a detached `Explanation` for any known effective or
|
||||
historical path. Its effective value and each chronological before/after value are
|
||||
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
|
||||
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
||||
null is present and renders as `null`. The history starts with the canonical typed
|
||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||
traversal order, the selected profile, supplied CLI records, and audited net
|
||||
resolution operations. Explicit equal writes remain visible.
|
||||
|
||||
Base, profile, and CLI history preserves the policy-validated input representation,
|
||||
including a superseded value that could not have decoded into the final Go field.
|
||||
Baseline, effective, and resolution values use canonical typed projection. Mapping
|
||||
queries reconstruct each source or override contribution, while sequences remain
|
||||
atomic: a descendant history identifies its presence before and after the containing
|
||||
sequence replacement without implying element-wise merging. Removed paths remain
|
||||
explainable; unknown and malformed paths return false.
|
||||
|
||||
Every snapshot applies the same historical tag, predicate, and recorded-dependency
|
||||
sensitivity closure as effective rendering before YAML is exposed. A redacted scalar
|
||||
is the quoted marker `'[REDACTED]'`, and a container snapshot has `Redacted=true` if
|
||||
it contains a masked descendant. Typed `Secret` snapshots contain only the configured
|
||||
environment name. `SecretReference` reports that name only for a successfully
|
||||
resolved effective reference, and is omitted when its path is classified. Credential
|
||||
values are never retained in explanation history.
|
||||
|
||||
`Result.WriteExplanation(output, path)` renders the safe structured result with the
|
||||
canonical JSON-pointer path, canonical-versus-input representation labels, effective
|
||||
source, composite locations, chronological values, resolution reasons and
|
||||
dependencies, and successful secret-reference lookup attribution. Control characters
|
||||
in paths, source identifiers and labels, reasons, dependencies, and reference names
|
||||
are escaped. An unknown path returns a fixed safe error before writing anything;
|
||||
writer errors and short writes are returned and may leave partial output. There is no
|
||||
unredacted mode.
|
||||
|
||||
Retained history consists of immutable source applications and audited subtree
|
||||
deltas, rather than a full tree copy per field. Shared nodes count once toward a
|
||||
4,000,000-node retained-history limit; exceeding it fails loading with
|
||||
`explain.limit_exceeded`. Replaying one requested path is performed on demand, and
|
||||
the returned YAML strings and nested records cannot mutate later explanations.
|
||||
|
||||
Application commands should render the separate `SecretsReport` immediately after
|
||||
an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
|
||||
`WriteEffective`, or `WriteExplanation` on the later load result as appropriate.
|
||||
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
|
||||
bundle these independent reports into a command framework.
|
||||
|
||||
## Typed secret references
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ composition, selected profile overlays, typed defaults, schema-version checks,
|
||||
explicit CLI overrides, recorded application resolution, semantic validation,
|
||||
layered metadata, secret
|
||||
classification, typed environment references, explicit secrets bootstrap, and
|
||||
redacted effective rendering. The
|
||||
redacted effective rendering and historical value explanation. The
|
||||
[component overview](internal/overview.md) owns the implementation inventory.
|
||||
|
||||
## Initial orientation
|
||||
|
||||
@@ -39,14 +39,16 @@ module and package documentation boundary. Its implemented public primitives are
|
||||
- explicit bounded startup environment bootstrap and safe partial reporting in
|
||||
`secrets.go`;
|
||||
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`; and
|
||||
- frozen-snapshot redaction and canonical effective output in `render.go`, with the
|
||||
- frozen-snapshot redaction and canonical effective output in `render.go`;
|
||||
- bounded immutable input applications, safe historical replay, detached explanation
|
||||
records, and deterministic human rendering in `explain.go`, with the
|
||||
only upstream dumper integration isolated in `internal/yaml/write.go`.
|
||||
|
||||
The [configuration contract](../config.md) owns implemented input, schema, decoding,
|
||||
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
|
||||
recorded resolution, typed secret references/bootstrap, semantic validation,
|
||||
metadata, secret classification, redacted effective rendering, and ownership
|
||||
behavior.
|
||||
metadata, secret classification, redacted effective rendering, historical
|
||||
explanation, and ownership behavior.
|
||||
|
||||
The source-extraction inventory records the application foundations assigned to
|
||||
future implementation work:
|
||||
|
||||
@@ -1365,7 +1365,43 @@ Acceptance:
|
||||
|
||||
## Stage 19 — Expose safe historical-value explanation
|
||||
|
||||
Status: pending.
|
||||
Status: complete.
|
||||
|
||||
Completion evidence (2026-09-06):
|
||||
|
||||
- Added detached `ValueSnapshot`, `ValueChange`, `SecretReferenceInfo`, and
|
||||
`Explanation` records plus `Result.Explain` and `Result.WriteExplanation` in
|
||||
[`explain.go`](../../explain.go). Effective/default/domain values use canonical
|
||||
typed projection; base/profile/CLI events preserve policy-validated input, with
|
||||
explicit absent, null, equal-write, replacement, and deletion states.
|
||||
- Extended composition, profile, and CLI handling to retain immutable applications
|
||||
in traversal/precedence order. Explanation replays those applications on demand
|
||||
with mapping recursion and atomic sequence replacement, then applies the existing
|
||||
audited resolution subtree deltas. This reconstructs container and descendant
|
||||
histories without a full retained tree per field, including superseded input that
|
||||
could not decode into the final destination type.
|
||||
- Applied tag, predicate, and dependency-cycle sensitivity across final and
|
||||
historical application paths before snapshot projection. Snapshots recursively
|
||||
redact classified descendants, resolved `Secret` metadata retains only successful
|
||||
effective environment names, and classified references omit that annotation.
|
||||
Shared immutable nodes count once toward a fixed 4,000,000-node retained-history
|
||||
budget; excess fails safely with `explain.limit_exceeded`.
|
||||
- Added deterministic human rendering with canonical quoted pointer paths, effective
|
||||
and composite source locations/labels, representation labels, chronological
|
||||
before/after YAML, reasons, dependencies, and secret lookup attribution. Unknown
|
||||
paths write nothing; writer errors and short writes propagate. Added executable
|
||||
`ExampleResult_Explain` and moved normative behavior to the
|
||||
[current explanation contract](../config.md#historical-value-explanation), keeping
|
||||
bootstrap reporting explicitly separate.
|
||||
- Added focused coverage in [`explain_test.go`](../../explain_test.go) for the
|
||||
imported-default/profile sequence 4 → 8 → 1, CLI and normalization, equal writes,
|
||||
removed paths, parent replacement, composite mappings, atomic sequences,
|
||||
superseded invalid input, null/empty values, successful and classified references,
|
||||
historical/dependency-cycle canaries, returned-record mutation, deterministic and
|
||||
failing writers, structural-sharing budgets, and an end-to-end secrets-bootstrap/
|
||||
load/explain flow. Verified `go test ./...`, `go test -race ./...`, `go vet ./...`,
|
||||
`go build ./...`, `go mod tidy -diff`, and `git diff --check` with Go 1.26.7 on
|
||||
linux/amd64. No deviations were required; consumer ergonomics remain later work.
|
||||
|
||||
Read: [explanation contract](initial.md#historical-value-explanation), redaction
|
||||
rules, and history bounds above.
|
||||
|
||||
@@ -916,7 +916,9 @@ the [current redaction contract](../config.md#secret-classification-and-effectiv
|
||||
`Result.Config`, `Metadata`, `Sources`, `Diagnostics`, and `WriteEffective`, together
|
||||
with `LoadError` and `WriteDiagnostics`, are implemented at the current loading boundary.
|
||||
The [current loading contract](../config.md#single-source-loading) and Go reference
|
||||
comments own their behavior. Historical-value explanation below remains target work.
|
||||
comments own their behavior. Historical-value explanation is also implemented and
|
||||
owned by the
|
||||
[current explanation contract](../config.md#historical-value-explanation).
|
||||
|
||||
```go
|
||||
type Result[T any] struct {
|
||||
@@ -976,74 +978,14 @@ func InspectPipeline(path, profile string, output io.Writer) error {
|
||||
|
||||
Applications can use `Metadata().Lookup`, `Metadata().History`, and `Sources()`
|
||||
for structured inspection without implementing value rendering or importing a
|
||||
parser. The explanation API below supplies safe values and formatting for application-owned
|
||||
`config explain` commands.
|
||||
parser. The implemented explanation API supplies safe values and formatting for
|
||||
application-owned `config explain` commands.
|
||||
|
||||
### Historical-value explanation
|
||||
|
||||
```go
|
||||
func (r *Result[T]) Explain(path Path) (Explanation, bool)
|
||||
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error
|
||||
|
||||
type Explanation struct {
|
||||
Path Path
|
||||
Effective ValueSnapshot
|
||||
Origin Origin
|
||||
History []ValueChange
|
||||
SecretReference *SecretReferenceInfo
|
||||
}
|
||||
type ValueSnapshot struct {
|
||||
Present bool
|
||||
Redacted bool
|
||||
YAML string
|
||||
}
|
||||
type ValueChange struct {
|
||||
Before, After ValueSnapshot
|
||||
Origin Origin
|
||||
Reasons []string
|
||||
Dependencies []Path
|
||||
}
|
||||
type SecretReferenceInfo struct { Name string } // successful resolved lookup
|
||||
```
|
||||
|
||||
Snapshots contain detached deterministic YAML text, never `any`, parser objects,
|
||||
or mutable internal trees. `Present=false` is absence/deletion and has empty YAML;
|
||||
null is present with `null` YAML. Redacted snapshots use the quoted marker
|
||||
`[REDACTED]`; `Redacted` also marks containers containing redacted descendants.
|
||||
Apply the same historical sensitivity/dependency closure as effective rendering
|
||||
before exposing any snapshots. A `Secret` projects only its environment name;
|
||||
its resolved value is never captured, even privately as history. For classified
|
||||
reference paths omit `SecretReference` as well as masking the projected name.
|
||||
|
||||
Retain private persistent node snapshots or operation deltas with structural
|
||||
sharing; do not copy the entire tree for every changed leaf. Explain a known final
|
||||
or historical path, including removed paths; unknown paths return false, and the
|
||||
writer returns a safe unknown-path error without output. Malformed query paths
|
||||
also fail safely. Render the canonical JSON-pointer path, effective value/source,
|
||||
and chronological history: initial application-default (or Go-zero baseline),
|
||||
base contributions in traversal order, profile, CLI, and recorded domain changes.
|
||||
Include before/after values for changed or explicitly redefined paths, equal-value
|
||||
writes, removals, reasons/dependencies, and composite origins. Inputs overridden
|
||||
before decoding retain policy-validated node values even when they would not fit
|
||||
the final Go type. Defaults/final/domain snapshots use canonical typed projection;
|
||||
label those representations rather than claiming every historical input was typed.
|
||||
|
||||
Container queries reconstruct the value at each relevant operation; descendant
|
||||
queries include ancestor replacement/removal and whole-sequence replacements,
|
||||
without implying element-wise sequence merging. Omitted fields/default retention
|
||||
must agree with the presence/default contract; do not fabricate a write for every
|
||||
import that leaves a path alone. Resolution history records the audited net change
|
||||
per path, not every intermediate Go assignment. Lookup of a secret reference
|
||||
annotates its effective name as resolved from the process environment; bootstrap
|
||||
file outcomes come from the separate report and are never guessed.
|
||||
|
||||
`WriteExplanation` is a deterministic human renderer over this safe structured
|
||||
result. Escape control characters in paths, labels, names, and reasons, propagate
|
||||
writer errors/short writes, and permit partial output only on writer failure.
|
||||
No unredacted switch or bundled command is provided. For example an application
|
||||
can call it for `/transcription/concurrency` to show default `4`, an explicitly
|
||||
imported file's `8`, then a testing profile's `1`, with file line numbers and the
|
||||
profile as effective source. This does not require automatic `conf.d` discovery.
|
||||
Historical explanation is implemented. The
|
||||
[current explanation contract](../config.md#historical-value-explanation) owns its
|
||||
public records, replay ordering, redaction, rendering, and allocation bounds.
|
||||
|
||||
### Module-specific options
|
||||
|
||||
|
||||
@@ -134,3 +134,17 @@ func ExampleOverride() {
|
||||
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
||||
// Output: 0 --workers <nil>
|
||||
}
|
||||
|
||||
func ExampleResult_Explain() {
|
||||
type Config struct {
|
||||
Workers int `yaml:"workers"`
|
||||
}
|
||||
result, err := configkit.Load(configkit.Bytes("config.yml", []byte("workers: 8\n")), configkit.Options[Config]{
|
||||
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
|
||||
})
|
||||
explanation, ok := result.Explain(configkit.Keys("workers"))
|
||||
fmt.Printf("%s -> %s known=%t error=%v\n",
|
||||
strings.TrimSpace(explanation.History[0].After.YAML),
|
||||
strings.TrimSpace(explanation.Effective.YAML), ok, err)
|
||||
// Output: 4 -> 8 known=true error=<nil>
|
||||
}
|
||||
|
||||
507
explain.go
Normal file
507
explain.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||
)
|
||||
|
||||
const historyNodeLimit = 4_000_000
|
||||
|
||||
var (
|
||||
errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable")
|
||||
errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown")
|
||||
)
|
||||
|
||||
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
||||
// Present distinguishes absence or deletion from an explicit null value.
|
||||
type ValueSnapshot struct {
|
||||
Present bool
|
||||
Redacted bool
|
||||
YAML string
|
||||
}
|
||||
|
||||
// ValueChange describes one explicit definition or audited domain operation.
|
||||
type ValueChange struct {
|
||||
Before ValueSnapshot
|
||||
After ValueSnapshot
|
||||
Origin Origin
|
||||
Reasons []string
|
||||
Dependencies []Path
|
||||
}
|
||||
|
||||
// SecretReferenceInfo describes a successfully resolved effective reference.
|
||||
// Name is the environment-variable name, never its value.
|
||||
type SecretReferenceInfo struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// Explanation is a detached safe account of one effective or historical path.
|
||||
type Explanation struct {
|
||||
Path Path
|
||||
Effective ValueSnapshot
|
||||
Origin Origin
|
||||
History []ValueChange
|
||||
SecretReference *SecretReferenceInfo
|
||||
}
|
||||
|
||||
type historyApplication struct {
|
||||
layer Layer
|
||||
path Path
|
||||
value *document.Node
|
||||
origin Origin
|
||||
}
|
||||
|
||||
func cloneHistoryApplications(values []historyApplication) []historyApplication {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]historyApplication, len(values))
|
||||
for index, value := range values {
|
||||
cloned[index] = historyApplication{
|
||||
layer: value.layer, path: value.path.clone(), value: value.value,
|
||||
origin: cloneOrigin(value.origin),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneSecretReferences(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]string, len(values))
|
||||
for path, name := range values {
|
||||
cloned[path] = name
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// Explain returns safe effective and chronological values for a known final or
|
||||
// historical path. Unknown and malformed paths return false.
|
||||
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||
if r == nil || r.final == nil || r.baseline == nil || !path.valid() {
|
||||
return Explanation{}, false
|
||||
}
|
||||
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
|
||||
if info, ok := r.metadata.Lookup(path); ok {
|
||||
explanation.Origin = cloneOrigin(info.Origin)
|
||||
}
|
||||
|
||||
working := document.Clone(r.baseline)
|
||||
if _, present := documentAtPath(working, path); present {
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
|
||||
})
|
||||
}
|
||||
for _, application := range r.applications {
|
||||
operationPath, relevant := applicationOperationPath(application, path)
|
||||
var before ValueSnapshot
|
||||
if relevant {
|
||||
before = valueSnapshot(working, path, r.secrets)
|
||||
}
|
||||
applyHistoryApplication(&working, application)
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: before,
|
||||
After: valueSnapshot(working, path, r.secrets),
|
||||
Origin: historyApplicationOrigin(application, operationPath),
|
||||
})
|
||||
}
|
||||
|
||||
working = document.Clone(r.input)
|
||||
for _, snapshot := range r.resolution {
|
||||
relevant := pathsIntersect(snapshot.change.Path, path)
|
||||
var before ValueSnapshot
|
||||
if relevant {
|
||||
before = valueSnapshot(working, path, r.secrets)
|
||||
}
|
||||
replaceHistoryPath(&working, snapshot.change.Path, snapshot.after)
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: before,
|
||||
After: valueSnapshot(working, path, r.secrets),
|
||||
Origin: cloneOrigin(snapshot.change.Origin),
|
||||
Reasons: append([]string(nil), snapshot.change.Reasons...),
|
||||
Dependencies: clonePaths(snapshot.change.Dependencies),
|
||||
})
|
||||
}
|
||||
|
||||
if !explanation.Effective.Present && len(explanation.History) == 0 {
|
||||
return Explanation{}, false
|
||||
}
|
||||
if name, ok := r.secretReferences[path.String()]; ok && !pathIsSecret(r.secrets, path) {
|
||||
explanation.SecretReference = &SecretReferenceInfo{Name: name}
|
||||
}
|
||||
return explanation, true
|
||||
}
|
||||
|
||||
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
|
||||
if !application.path.valid() || application.value == nil {
|
||||
return Path{}, false
|
||||
}
|
||||
if application.path.String() != "" {
|
||||
if pathsIntersect(application.path, query) {
|
||||
return application.path.clone(), true
|
||||
}
|
||||
return Path{}, false
|
||||
}
|
||||
if len(query.segments) == 0 {
|
||||
return Path{}, true
|
||||
}
|
||||
for length := 1; length <= len(query.segments); length++ {
|
||||
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
||||
node, present := documentAtPath(application.value, prefix)
|
||||
if !present {
|
||||
return Path{}, false
|
||||
}
|
||||
if length == len(query.segments) || node.Kind != document.Mapping {
|
||||
return prefix, true
|
||||
}
|
||||
}
|
||||
return Path{}, false
|
||||
}
|
||||
|
||||
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
|
||||
if application.origin.Layer != "" {
|
||||
return cloneOrigin(application.origin)
|
||||
}
|
||||
origin := Origin{Layer: application.layer}
|
||||
relative, ok := relativeHistoryPath(application.path, operationPath)
|
||||
if !ok {
|
||||
return origin
|
||||
}
|
||||
if node, present := documentAtPath(application.value, relative); present {
|
||||
origin.Locations = []Location{publicLocation(node.Location)}
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
func relativeHistoryPath(root, path Path) (Path, bool) {
|
||||
if !pathContains(root, path) {
|
||||
return Path{}, false
|
||||
}
|
||||
return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true
|
||||
}
|
||||
|
||||
func pathsIntersect(left, right Path) bool {
|
||||
return pathContains(left, right) || pathContains(right, left)
|
||||
}
|
||||
|
||||
func applyHistoryApplication(root **document.Node, application historyApplication) {
|
||||
if application.path.String() == "" {
|
||||
mergeHistoryDocument(root, application.value)
|
||||
return
|
||||
}
|
||||
mergeHistoryAtPath(root, application.path, application.value)
|
||||
}
|
||||
|
||||
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
|
||||
if incoming == nil {
|
||||
return
|
||||
}
|
||||
if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping {
|
||||
*target = document.Clone(incoming)
|
||||
return
|
||||
}
|
||||
for _, entry := range incoming.Mapping {
|
||||
position := documentEntryIndex((*target).Mapping, entry.Key)
|
||||
if position < 0 {
|
||||
cloned := entry
|
||||
cloned.Value = document.Clone(entry.Value)
|
||||
(*target).Mapping = append((*target).Mapping, cloned)
|
||||
continue
|
||||
}
|
||||
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
|
||||
if root == nil || *root == nil || len(path.segments) == 0 {
|
||||
mergeHistoryDocument(root, incoming)
|
||||
return
|
||||
}
|
||||
current := *root
|
||||
for index, segment := range path.segments {
|
||||
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
|
||||
return
|
||||
}
|
||||
position := documentEntryIndex(current.Mapping, segment.key)
|
||||
if index == len(path.segments)-1 {
|
||||
if position < 0 {
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
|
||||
} else {
|
||||
mergeHistoryDocument(¤t.Mapping[position].Value, incoming)
|
||||
}
|
||||
return
|
||||
}
|
||||
if position < 0 {
|
||||
child := &document.Node{Kind: document.Mapping}
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
||||
current = child
|
||||
} else {
|
||||
current = current.Mapping[position].Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) {
|
||||
if root == nil || *root == nil {
|
||||
return
|
||||
}
|
||||
if len(path.segments) == 0 {
|
||||
*root = document.Clone(replacement)
|
||||
return
|
||||
}
|
||||
current := *root
|
||||
for index, segment := range path.segments {
|
||||
last := index == len(path.segments)-1
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return
|
||||
}
|
||||
position := documentEntryIndex(current.Mapping, segment.key)
|
||||
if position < 0 {
|
||||
return
|
||||
}
|
||||
if last {
|
||||
if replacement == nil {
|
||||
current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...)
|
||||
} else {
|
||||
current.Mapping[position].Value = document.Clone(replacement)
|
||||
}
|
||||
return
|
||||
}
|
||||
current = current.Mapping[position].Value
|
||||
case document.Sequence:
|
||||
position, ok := schemaIndex(segment)
|
||||
if !ok || position >= len(current.Sequence) {
|
||||
return
|
||||
}
|
||||
if last {
|
||||
if replacement != nil {
|
||||
current.Sequence[position] = document.Clone(replacement)
|
||||
}
|
||||
return
|
||||
}
|
||||
current = current.Sequence[position]
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot {
|
||||
node, present := documentAtPath(root, path)
|
||||
if !present {
|
||||
return ValueSnapshot{}
|
||||
}
|
||||
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
||||
var output bytes.Buffer
|
||||
if err := yamladapter.Write(&output, safe); err != nil {
|
||||
return ValueSnapshot{Present: true, Redacted: true, YAML: "'[REDACTED]'\n"}
|
||||
}
|
||||
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}
|
||||
}
|
||||
|
||||
func safeSnapshotDocument(node *document.Node, secrets map[string]bool, path Path) (*document.Node, bool) {
|
||||
if node == nil {
|
||||
return nil, false
|
||||
}
|
||||
if pathIsSecret(secrets, path) {
|
||||
return &document.Node{Kind: document.String, Text: "[REDACTED]"}, true
|
||||
}
|
||||
result := *node
|
||||
redacted := false
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
result.Mapping = make([]document.Entry, len(node.Mapping))
|
||||
copy(result.Mapping, node.Mapping)
|
||||
sortDocumentMapping(result.Mapping)
|
||||
for index := range result.Mapping {
|
||||
entry := &result.Mapping[index]
|
||||
entry.Value, redacted = safeSnapshotChild(entry.Value, secrets, path.Key(entry.Key), redacted)
|
||||
}
|
||||
case document.Sequence:
|
||||
result.Sequence = make([]*document.Node, len(node.Sequence))
|
||||
for index, child := range node.Sequence {
|
||||
result.Sequence[index], redacted = safeSnapshotChild(child, secrets, path.Index(index), redacted)
|
||||
}
|
||||
}
|
||||
return &result, redacted
|
||||
}
|
||||
|
||||
func safeSnapshotChild(node *document.Node, secrets map[string]bool, path Path, prior bool) (*document.Node, bool) {
|
||||
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
||||
return safe, prior || redacted
|
||||
}
|
||||
|
||||
func sortDocumentMapping(entries []document.Entry) {
|
||||
slices.SortFunc(entries, func(left, right document.Entry) int {
|
||||
return strings.Compare(left.Key, right.Key)
|
||||
})
|
||||
}
|
||||
|
||||
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
||||
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions)
|
||||
}
|
||||
|
||||
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
||||
seen := make(map[*document.Node]bool)
|
||||
count := 0
|
||||
var visit func(*document.Node) bool
|
||||
visit = func(node *document.Node) bool {
|
||||
if node == nil || seen[node] {
|
||||
return true
|
||||
}
|
||||
seen[node] = true
|
||||
count++
|
||||
if count > limit {
|
||||
return false
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
if !visit(entry.Value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, child := range node.Sequence {
|
||||
if !visit(child) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if !visit(baseline) || !visit(input) || !visit(final) {
|
||||
return false
|
||||
}
|
||||
for _, application := range applications {
|
||||
if !visit(application.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, resolution := range resolutions {
|
||||
if !visit(resolution.before) || !visit(resolution.after) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// WriteExplanation writes a deterministic human-readable rendering of Explain.
|
||||
// Unknown paths produce no output. Writer failures may leave partial output.
|
||||
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
|
||||
if r == nil || output == nil {
|
||||
return errExplanationUnavailable
|
||||
}
|
||||
explanation, ok := r.Explain(path)
|
||||
if !ok {
|
||||
return errExplanationUnknownPath
|
||||
}
|
||||
var rendered strings.Builder
|
||||
rendered.WriteString("path: ")
|
||||
rendered.WriteString(strconv.Quote(explanation.Path.String()))
|
||||
rendered.WriteByte('\n')
|
||||
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources)
|
||||
writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0)
|
||||
if explanation.SecretReference != nil {
|
||||
rendered.WriteString("secret reference: ")
|
||||
rendered.WriteString(strconv.Quote(explanation.SecretReference.Name))
|
||||
rendered.WriteString(" (resolved from process environment)\n")
|
||||
}
|
||||
rendered.WriteString("history:\n")
|
||||
for _, change := range explanation.History {
|
||||
rendered.WriteString(" - representation: ")
|
||||
if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
|
||||
rendered.WriteString("policy-validated input\n")
|
||||
} else {
|
||||
rendered.WriteString("canonical typed\n")
|
||||
}
|
||||
writeExplanationOrigin(&rendered, " source", change.Origin, r.sources)
|
||||
writeExplanationSnapshot(&rendered, " before", change.Before, 4)
|
||||
writeExplanationSnapshot(&rendered, " after", change.After, 4)
|
||||
for _, reason := range change.Reasons {
|
||||
rendered.WriteString(" reason: ")
|
||||
rendered.WriteString(strconv.Quote(reason))
|
||||
rendered.WriteByte('\n')
|
||||
}
|
||||
for _, dependency := range change.Dependencies {
|
||||
rendered.WriteString(" dependency: ")
|
||||
rendered.WriteString(strconv.Quote(dependency.String()))
|
||||
rendered.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
data := []byte(rendered.String())
|
||||
written, err := output.Write(data)
|
||||
if err == nil && written != len(data) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) {
|
||||
output.WriteString(label)
|
||||
output.WriteString(": layer=")
|
||||
output.WriteString(strconv.Quote(string(origin.Layer)))
|
||||
if origin.Operation != "" {
|
||||
output.WriteString(" operation=")
|
||||
output.WriteString(strconv.Quote(string(origin.Operation)))
|
||||
}
|
||||
output.WriteByte('\n')
|
||||
for _, location := range origin.Locations {
|
||||
output.WriteString(" location: source=")
|
||||
output.WriteString(strconv.Quote(location.SourceID))
|
||||
if label := sourceLabel(sources, location.SourceID); label != "" {
|
||||
output.WriteString(" label=")
|
||||
output.WriteString(strconv.Quote(label))
|
||||
}
|
||||
output.WriteString(" line=")
|
||||
output.WriteString(strconv.Itoa(location.Line))
|
||||
output.WriteString(" column=")
|
||||
output.WriteString(strconv.Itoa(location.Column))
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
func sourceLabel(sources []SourceInfo, sourceID string) string {
|
||||
for _, source := range sources {
|
||||
if source.ID == sourceID {
|
||||
return source.Label
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) {
|
||||
if !snapshot.Present {
|
||||
output.WriteString(label)
|
||||
output.WriteString(": absent\n")
|
||||
return
|
||||
}
|
||||
output.WriteString(label)
|
||||
if snapshot.Redacted {
|
||||
output.WriteString(" (redacted)")
|
||||
}
|
||||
output.WriteString(":\n")
|
||||
prefix := strings.Repeat(" ", indent+2)
|
||||
for _, line := range strings.SplitAfter(snapshot.YAML, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
output.WriteString(prefix)
|
||||
output.WriteString(line)
|
||||
if !strings.HasSuffix(line, "\n") {
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
328
explain_test.go
Normal file
328
explain_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type explanationConcurrency struct {
|
||||
Transcription struct {
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
} `yaml:"transcription"`
|
||||
}
|
||||
|
||||
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "config.yml", "_configkit:\n imports: [{path: transcription.yml}]\n")
|
||||
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
|
||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
|
||||
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
||||
Imports: true, Profile: "testing",
|
||||
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
|
||||
value := explanationConcurrency{}
|
||||
value.Transcription.Concurrency = 4
|
||||
return value
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, ok := result.Explain(Keys("transcription", "concurrency"))
|
||||
if !ok {
|
||||
t.Fatal("known path was not explained")
|
||||
}
|
||||
if got := explanationValues(explanation); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n"}) {
|
||||
t.Fatalf("history values = %#v", got)
|
||||
}
|
||||
if explanation.Effective.YAML != "1\n" || explanation.Origin.Layer != LayerProfile ||
|
||||
len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != "source-3" ||
|
||||
explanation.Origin.Locations[0].Line != 2 {
|
||||
t.Fatalf("effective explanation = %#v", explanation)
|
||||
}
|
||||
}
|
||||
|
||||
type explanationConfig struct {
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
Equal string `yaml:"equal"`
|
||||
Limit int `yaml:"limit"`
|
||||
Items []string `yaml:"items"`
|
||||
Labels map[string]string `yaml:"labels"`
|
||||
Dynamic any `yaml:"dynamic"`
|
||||
Maybe *string `yaml:"maybe"`
|
||||
Empty []string `yaml:"empty"`
|
||||
Credential Secret `yaml:"credential"`
|
||||
Classified Secret `yaml:"classified"`
|
||||
DerivedA string `yaml:"derived_a"`
|
||||
DerivedB string `yaml:"derived_b"`
|
||||
}
|
||||
|
||||
func TestExplainReplaysCLIResolutionContainersAndSafeHistoricalValues(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "config.yml", `concurrency: 8
|
||||
equal: same
|
||||
limit: wrong-before-profile
|
||||
items: [base, removed]
|
||||
labels: {base: retained}
|
||||
dynamic: {retired: HISTORICAL_EXPLAIN_CANARY, public: visible}
|
||||
credential: APP_SECRET
|
||||
classified: APP_SECRET
|
||||
`)
|
||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `concurrency: 1
|
||||
equal: same
|
||||
limit: 7
|
||||
items: [profile]
|
||||
labels: {profile: retained}
|
||||
dynamic: selected
|
||||
`)
|
||||
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
|
||||
if name != "APP_SECRET" {
|
||||
t.Fatalf("secret lookup = %q", name)
|
||||
}
|
||||
return "RESOLVED_EXPLAIN_CANARY", true
|
||||
}, Options[explanationConfig]{
|
||||
Profile: "testing",
|
||||
Overrides: []Override{
|
||||
{Path: Keys("concurrency"), Value: 2, Flag: "--concurrency"},
|
||||
{Path: Keys("equal"), Value: "same", Flag: "--equal"},
|
||||
{Path: Keys("items"), Value: []any{"cli", "restored"}, Flag: "--items"},
|
||||
{Path: Keys("labels"), Value: map[string]any{"cli": "retained"}, Flag: "--label"},
|
||||
},
|
||||
Definition: Definition[explanationConfig]{
|
||||
Defaults: func() explanationConfig {
|
||||
return explanationConfig{
|
||||
Concurrency: 4, Equal: "same", Labels: map[string]string{"default": "retained"},
|
||||
Maybe: nil, Empty: []string{}, Credential: SecretEnv("APP_SECRET"), Classified: SecretEnv("APP_SECRET"),
|
||||
}
|
||||
},
|
||||
Secret: func(path Path) bool {
|
||||
return path.String() == "/dynamic/retired" || path.String() == "/classified"
|
||||
},
|
||||
Resolve: func(configuration *explanationConfig, resolution *Resolution) error {
|
||||
Normalize(resolution, Keys("concurrency"), &configuration.Concurrency, 3,
|
||||
"normalized\nconcurrency", Keys("labels"))
|
||||
Normalize(resolution, Keys("equal"), &configuration.Equal, "same", "record equal value")
|
||||
Normalize(resolution, Keys("derived_a"), &configuration.DerivedA, "DERIVED_EXPLAIN_A",
|
||||
"derived cycle a", Keys("derived_b"))
|
||||
Normalize(resolution, Keys("derived_b"), &configuration.DerivedB, "DERIVED_EXPLAIN_B",
|
||||
"derived cycle b", Keys("derived_a"), Keys("dynamic"))
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
concurrency, ok := result.Explain(Keys("concurrency"))
|
||||
if !ok {
|
||||
t.Fatal("concurrency was not explained")
|
||||
}
|
||||
if got := explanationValues(concurrency); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n", "2\n", "3\n"}) {
|
||||
t.Fatalf("concurrency history = %#v", got)
|
||||
}
|
||||
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}) {
|
||||
t.Fatalf("concurrency layers = %#v", got)
|
||||
}
|
||||
if concurrency.History[4].Before.YAML != "2\n" || concurrency.History[4].Reasons[0] != "normalized\nconcurrency" ||
|
||||
concurrency.History[4].Dependencies[0].String() != "/labels" {
|
||||
t.Fatalf("resolution history = %#v", concurrency.History[4])
|
||||
}
|
||||
|
||||
equal, _ := result.Explain(Keys("equal"))
|
||||
if len(equal.History) != 5 {
|
||||
t.Fatalf("equal writes were lost: %#v", equal.History)
|
||||
}
|
||||
for _, change := range equal.History[1:] {
|
||||
if change.Before.YAML != "same\n" || change.After.YAML != "same\n" {
|
||||
t.Fatalf("equal replacement = %#v", change)
|
||||
}
|
||||
}
|
||||
|
||||
limit, _ := result.Explain(Keys("limit"))
|
||||
if got := explanationValues(limit); !reflect.DeepEqual(got, []string{"0\n", "wrong-before-profile\n", "7\n"}) {
|
||||
t.Fatalf("overridden invalid input was not retained: %#v", got)
|
||||
}
|
||||
removed, ok := result.Explain(Keys("dynamic", "retired"))
|
||||
if !ok || removed.Effective.Present || len(removed.History) < 2 {
|
||||
t.Fatalf("removed path = %#v, %v", removed, ok)
|
||||
}
|
||||
for _, change := range removed.History {
|
||||
if strings.Contains(change.Before.YAML+change.After.YAML, "HISTORICAL_EXPLAIN_CANARY") {
|
||||
t.Fatalf("historical secret escaped redaction: %#v", change)
|
||||
}
|
||||
}
|
||||
if !removed.History[0].After.Redacted || removed.History[len(removed.History)-1].After.Present {
|
||||
t.Fatalf("removed secret history = %#v", removed.History)
|
||||
}
|
||||
dynamic, _ := result.Explain(Keys("dynamic"))
|
||||
if len(dynamic.History) < 3 || !dynamic.History[1].After.Redacted ||
|
||||
strings.Contains(dynamic.History[1].After.YAML, "HISTORICAL_EXPLAIN_CANARY") {
|
||||
t.Fatalf("container redaction history = %#v", dynamic.History)
|
||||
}
|
||||
|
||||
items, _ := result.Explain(Keys("items"))
|
||||
if len(items.History) != 4 || !strings.Contains(items.History[1].After.YAML, "removed") ||
|
||||
strings.Contains(items.History[2].After.YAML, "removed") || !strings.Contains(items.History[3].After.YAML, "restored") {
|
||||
t.Fatalf("atomic sequence history = %#v", items.History)
|
||||
}
|
||||
removedIndex, ok := result.Explain(Keys("items").Index(1))
|
||||
if !ok || !removedIndex.History[1].Before.Present || removedIndex.History[1].After.Present ||
|
||||
!removedIndex.History[2].After.Present {
|
||||
t.Fatalf("sequence descendant history = %#v", removedIndex.History)
|
||||
}
|
||||
labels, _ := result.Explain(Keys("labels"))
|
||||
if len(labels.History) != 4 || !strings.Contains(labels.Effective.YAML, "default: retained") ||
|
||||
!strings.Contains(labels.Effective.YAML, "profile: retained") || !strings.Contains(labels.Effective.YAML, "cli: retained") {
|
||||
t.Fatalf("composite mapping history = %#v", labels)
|
||||
}
|
||||
if labels.Origin.Layer != LayerCLI || len(labels.Origin.Locations) != 3 {
|
||||
t.Fatalf("composite effective origin = %#v", labels.Origin)
|
||||
}
|
||||
|
||||
maybe, _ := result.Explain(Keys("maybe"))
|
||||
empty, _ := result.Explain(Keys("empty"))
|
||||
if !maybe.Effective.Present || maybe.Effective.YAML != "null\n" || !empty.Effective.Present || empty.Effective.YAML != "[]\n" {
|
||||
t.Fatalf("null/empty snapshots = maybe %#v empty %#v", maybe.Effective, empty.Effective)
|
||||
}
|
||||
credential, _ := result.Explain(Keys("credential"))
|
||||
if credential.SecretReference == nil || credential.SecretReference.Name != "APP_SECRET" || credential.Effective.YAML != "APP_SECRET\n" {
|
||||
t.Fatalf("secret reference = %#v", credential)
|
||||
}
|
||||
classified, _ := result.Explain(Keys("classified"))
|
||||
if classified.SecretReference != nil || !classified.Effective.Redacted ||
|
||||
strings.Contains(classified.Effective.YAML, "APP_SECRET") {
|
||||
t.Fatalf("classified secret reference = %#v", classified)
|
||||
}
|
||||
var derivedExplanations []Explanation
|
||||
for _, path := range []Path{Keys("derived_a"), Keys("derived_b")} {
|
||||
derived, _ := result.Explain(path)
|
||||
if !derived.Effective.Redacted || strings.Contains(derived.Effective.YAML, "DERIVED_EXPLAIN") {
|
||||
t.Fatalf("dependency closure at %s = %#v", path.String(), derived)
|
||||
}
|
||||
derivedExplanations = append(derivedExplanations, derived)
|
||||
}
|
||||
|
||||
formatted := fmt.Sprintf("%v %#v %v", removed, credential, derivedExplanations)
|
||||
encoded, marshalErr := json.Marshal(append([]Explanation{removed, credential, classified}, derivedExplanations...))
|
||||
if marshalErr != nil || strings.Contains(formatted+string(encoded), "RESOLVED_EXPLAIN_CANARY") ||
|
||||
strings.Contains(formatted+string(encoded), "HISTORICAL_EXPLAIN_CANARY") ||
|
||||
strings.Contains(formatted+string(encoded), "DERIVED_EXPLAIN") {
|
||||
t.Fatalf("public records disclosed a canary: %s %s", formatted, encoded)
|
||||
}
|
||||
|
||||
concurrency.Path.segments[0].key = "mutated"
|
||||
concurrency.History[4].Reasons[0] = "mutated"
|
||||
concurrency.History[4].Dependencies[0].segments[0].key = "mutated"
|
||||
result.Config.Concurrency = 99
|
||||
again, _ := result.Explain(Keys("concurrency"))
|
||||
if again.Path.String() != "/concurrency" || again.Effective.YAML != "3\n" ||
|
||||
again.History[4].Reasons[0] != "normalized\nconcurrency" ||
|
||||
again.History[4].Dependencies[0].String() != "/labels" {
|
||||
t.Fatalf("returned explanation aliased internal history: %#v", again)
|
||||
}
|
||||
|
||||
var first, second bytes.Buffer
|
||||
if err := result.WriteExplanation(&first, Keys("concurrency")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := result.WriteExplanation(&second, Keys("concurrency")); err != nil || first.String() != second.String() {
|
||||
t.Fatalf("nondeterministic rendering: %v\n%s\n%s", err, first.String(), second.String())
|
||||
}
|
||||
if strings.Contains(first.String(), "\nconcurrency\"") || !strings.Contains(first.String(), `"normalized\nconcurrency"`) {
|
||||
t.Fatalf("reason controls were not escaped: %q", first.String())
|
||||
}
|
||||
unknownOutput := bytes.NewBufferString("unchanged")
|
||||
if err := result.WriteExplanation(unknownOutput, Keys("unknown")); !errors.Is(err, errExplanationUnknownPath) || unknownOutput.String() != "unchanged" {
|
||||
t.Fatalf("unknown writer = %q, %v", unknownOutput.String(), err)
|
||||
}
|
||||
if _, ok := result.Explain(Keys("items").Index(-1)); ok {
|
||||
t.Fatal("malformed path was explained")
|
||||
}
|
||||
wantWriteError := errors.New("write failed")
|
||||
if err := result.WriteExplanation(errorWriter{err: wantWriteError}, Keys("concurrency")); !errors.Is(err, wantWriteError) {
|
||||
t.Fatalf("writer error = %v", err)
|
||||
}
|
||||
if err := result.WriteExplanation(shortWriter{}, Keys("concurrency")); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short writer error = %v", err)
|
||||
}
|
||||
var invalidInput bytes.Buffer
|
||||
if err := result.WriteExplanation(&invalidInput, Keys("limit")); err != nil ||
|
||||
!strings.Contains(invalidInput.String(), "policy-validated input") ||
|
||||
strings.Contains(invalidInput.String(), "wrong-before-profile\n source: layer=\"base\"\n before (canonical typed)") {
|
||||
t.Fatalf("historical representation rendering = %v\n%s", err, invalidInput.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
|
||||
const name = "CONFIGKIT_EXPLAIN_BOOTSTRAP_TEST"
|
||||
previous, present := os.LookupEnv(name)
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if present {
|
||||
_ = os.Setenv(name, previous)
|
||||
} else {
|
||||
_ = os.Unsetenv(name)
|
||||
}
|
||||
})
|
||||
directory := t.TempDir()
|
||||
writeSecretTestFile(t, directory, name, "BOOTSTRAP_EXPLAIN_CANARY\n")
|
||||
report, err := LoadSecretsDir(directory)
|
||||
if err != nil || len(report.Entries()) != 1 || report.Entries()[0].Status != SecretFileLoaded {
|
||||
t.Fatalf("bootstrap = %#v, %v", report, err)
|
||||
}
|
||||
type config struct {
|
||||
Credential Secret `yaml:"credential"`
|
||||
}
|
||||
result, err := Load[config](Bytes("config.yml", []byte("credential: "+name+"\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := result.WriteExplanation(&output, Keys("credential")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(output.String(), name) || strings.Contains(output.String(), "BOOTSTRAP_EXPLAIN_CANARY") {
|
||||
t.Fatalf("explanation mixed bootstrap value into reference report: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) {
|
||||
leaf := &document.Node{Kind: document.String, Text: "value"}
|
||||
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}}
|
||||
applications := []historyApplication{{layer: LayerBase, value: root}}
|
||||
if !historyNodesWithin(2, root, root, root, applications, nil) {
|
||||
t.Fatal("structurally shared history was counted more than once")
|
||||
}
|
||||
other := document.Clone(root)
|
||||
if historyNodesWithin(3, root, other, root, applications, nil) {
|
||||
t.Fatal("distinct retained nodes did not consume the budget")
|
||||
}
|
||||
}
|
||||
|
||||
func explanationValues(explanation Explanation) []string {
|
||||
values := make([]string, len(explanation.History))
|
||||
for index, change := range explanation.History {
|
||||
values[index] = change.After.YAML
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func historyValueLayers(explanation Explanation) []Layer {
|
||||
layers := make([]Layer, len(explanation.History))
|
||||
for index, change := range explanation.History {
|
||||
layers[index] = change.Origin.Layer
|
||||
}
|
||||
return layers
|
||||
}
|
||||
52
load.go
52
load.go
@@ -67,13 +67,16 @@ type Version struct {
|
||||
type Result[T any] struct {
|
||||
Config T
|
||||
|
||||
metadata Metadata
|
||||
sources []SourceInfo
|
||||
diagnostics []Diagnostic
|
||||
final *document.Node
|
||||
resolution []resolutionSnapshot
|
||||
overlays []overlayRecord
|
||||
secrets map[string]bool
|
||||
metadata Metadata
|
||||
sources []SourceInfo
|
||||
diagnostics []Diagnostic
|
||||
baseline *document.Node
|
||||
input *document.Node
|
||||
final *document.Node
|
||||
resolution []resolutionSnapshot
|
||||
applications []historyApplication
|
||||
secrets map[string]bool
|
||||
secretReferences map[string]string
|
||||
}
|
||||
|
||||
// Metadata returns detached effective presence, origin, and history records.
|
||||
@@ -239,6 +242,16 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
|
||||
resolutionSnapshots = resolved.snapshots
|
||||
applyResolutionMetadata(&metadata, resolved)
|
||||
}
|
||||
applications := []historyApplication(nil)
|
||||
if composition != nil {
|
||||
applications = cloneHistoryApplications(composition.applications)
|
||||
} else if inputDocument != nil {
|
||||
applications = []historyApplication{{layer: LayerBase, value: inputDocument}}
|
||||
}
|
||||
if !historyNodesWithinLimit(decoded.baseline, decoded.final, final, applications, resolutionSnapshots) {
|
||||
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, inputDocument, lookup)
|
||||
accumulator.add(secretResolution.diagnostics...)
|
||||
if hasErrors(secretResolution.diagnostics) {
|
||||
@@ -251,20 +264,19 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
}
|
||||
secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final)
|
||||
var overlaySnapshots []overlayRecord
|
||||
if composition != nil {
|
||||
overlaySnapshots = cloneOverlayRecords(composition.overlays)
|
||||
}
|
||||
secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final, applications)
|
||||
return &Result[T]{
|
||||
Config: configuration,
|
||||
metadata: metadata,
|
||||
sources: cloneSourceInfos(accumulator.sources),
|
||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||
final: document.Clone(final),
|
||||
resolution: cloneResolutionSnapshots(resolutionSnapshots),
|
||||
overlays: overlaySnapshots,
|
||||
secrets: secrets,
|
||||
Config: configuration,
|
||||
metadata: metadata,
|
||||
sources: cloneSourceInfos(accumulator.sources),
|
||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||
baseline: decoded.baseline,
|
||||
input: decoded.final,
|
||||
final: final,
|
||||
resolution: resolutionSnapshots,
|
||||
applications: applications,
|
||||
secrets: secrets,
|
||||
secretReferences: cloneSecretReferences(secretResolution.references),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
14
overlay.go
14
overlay.go
@@ -27,15 +27,17 @@ func overlayDocuments(base *composedDocument, overlay *document.Node) (*composed
|
||||
}}
|
||||
}
|
||||
result := &composedDocument{
|
||||
root: document.Clone(base.root),
|
||||
claims: cloneCompositionClaims(base.claims),
|
||||
origins: cloneOriginIndex(base.origins),
|
||||
overlays: cloneOverlayRecords(base.overlays),
|
||||
inputBytes: base.inputBytes,
|
||||
inputNodes: base.inputNodes,
|
||||
root: document.Clone(base.root),
|
||||
claims: cloneCompositionClaims(base.claims),
|
||||
origins: cloneOriginIndex(base.origins),
|
||||
overlays: cloneOverlayRecords(base.overlays),
|
||||
applications: cloneHistoryApplications(base.applications),
|
||||
inputBytes: base.inputBytes,
|
||||
inputNodes: base.inputNodes,
|
||||
}
|
||||
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
||||
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
||||
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -378,7 +378,8 @@ func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||
return &composedDocument{
|
||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
||||
applications: cloneHistoryApplications(base.applications),
|
||||
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +429,10 @@ func applyPreparedOverride(result *composedDocument, override preparedOverride)
|
||||
result.overlays = append(result.overlays, overlayRecord{change: Change{
|
||||
Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
|
||||
}, displaced: displaced, before: before, after: document.Clone(current.Mapping[position].Value)})
|
||||
result.applications = append(result.applications, historyApplication{
|
||||
layer: LayerCLI, path: path.clone(), value: override.value,
|
||||
origin: overlayOrigin(LayerCLI, location),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ const (
|
||||
CodeOverrideDirective Code = "override.directive"
|
||||
CodeOverrideVersion Code = "override.version"
|
||||
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
|
||||
|
||||
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
|
||||
)
|
||||
|
||||
// Location identifies a position in a configuration source. Zero line and column
|
||||
|
||||
28
render.go
28
render.go
@@ -3,7 +3,6 @@ package configkit
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
@@ -24,31 +23,8 @@ func (r *Result[T]) WriteEffective(output io.Writer) error {
|
||||
}
|
||||
|
||||
func redactedDocument(node *document.Node, secret map[string]bool, path Path) *document.Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if pathIsSecret(secret, path) {
|
||||
return &document.Node{Kind: document.String, Text: "[REDACTED]"}
|
||||
}
|
||||
result := *node
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
result.Mapping = make([]document.Entry, len(node.Mapping))
|
||||
copy(result.Mapping, node.Mapping)
|
||||
slices.SortFunc(result.Mapping, func(left, right document.Entry) int {
|
||||
return strings.Compare(left.Key, right.Key)
|
||||
})
|
||||
for index := range result.Mapping {
|
||||
entry := &result.Mapping[index]
|
||||
entry.Value = redactedDocument(entry.Value, secret, path.Key(entry.Key))
|
||||
}
|
||||
case document.Sequence:
|
||||
result.Sequence = make([]*document.Node, len(node.Sequence))
|
||||
for index, child := range node.Sequence {
|
||||
result.Sequence[index] = redactedDocument(child, secret, path.Index(index))
|
||||
}
|
||||
}
|
||||
return &result
|
||||
result, _ := safeSnapshotDocument(node, secret, path)
|
||||
return result
|
||||
}
|
||||
|
||||
func pathIsSecret(secret map[string]bool, path Path) bool {
|
||||
|
||||
@@ -7,13 +7,20 @@ type secretDependency struct {
|
||||
dependency Path
|
||||
}
|
||||
|
||||
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node) map[string]bool {
|
||||
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication) map[string]bool {
|
||||
paths := make(map[string]Path)
|
||||
finalPaths := make(map[string]*document.Node)
|
||||
indexDocument(final, Path{}, finalPaths)
|
||||
for pointer := range finalPaths {
|
||||
addSecretPath(paths, pointer)
|
||||
}
|
||||
for _, application := range applications {
|
||||
applicationPaths := make(map[string]*document.Node)
|
||||
indexDocument(application.value, application.path, applicationPaths)
|
||||
for pointer := range applicationPaths {
|
||||
addSecretPath(paths, pointer)
|
||||
}
|
||||
}
|
||||
var dependencies []secretDependency
|
||||
for pointer, history := range metadata.history {
|
||||
addSecretPath(paths, pointer)
|
||||
|
||||
@@ -64,12 +64,13 @@ type secretLookupResult struct {
|
||||
|
||||
type secretResolutionResult struct {
|
||||
lookups map[string]secretLookupResult
|
||||
references map[string]string
|
||||
diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input *document.Node, lookup secretLookup) secretResolutionResult {
|
||||
result := secretResolutionResult{
|
||||
lookups: make(map[string]secretLookupResult),
|
||||
lookups: make(map[string]secretLookupResult), references: make(map[string]string),
|
||||
}
|
||||
if schema == nil || schema.root == nil || !root.IsValid() || root.Type() != schema.root.typeOf {
|
||||
result.diagnostics = []Diagnostic{{Code: CodeSecretRequired, Severity: SeverityError, Message: "configuration value does not match its secret-reference schema"}}
|
||||
@@ -124,6 +125,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
|
||||
return
|
||||
}
|
||||
value.Set(reflect.ValueOf(Secret{name: name, value: found.value, specified: true, resolved: true}))
|
||||
result.references[path.String()] = name
|
||||
case descriptorStruct:
|
||||
for _, member := range descriptor.members {
|
||||
memberPath := path
|
||||
|
||||
Reference in New Issue
Block a user