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
|
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 profiles. Value explanation is still under
|
CLI override records apply above profiles. Value explanation is available through
|
||||||
development.
|
detached structured records and deterministic redacted output.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Config struct {
|
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;
|
// precedence layers. Claims remain ordered by source and declaration traversal;
|
||||||
// origins retain every source that contributes to a composite mapping.
|
// origins retain every source that contributes to a composite mapping.
|
||||||
type composedDocument struct {
|
type composedDocument struct {
|
||||||
root *document.Node
|
root *document.Node
|
||||||
claims []compositionClaim
|
claims []compositionClaim
|
||||||
origins map[string]Origin
|
origins map[string]Origin
|
||||||
overlays []overlayRecord
|
overlays []overlayRecord
|
||||||
inputBytes int64
|
applications []historyApplication
|
||||||
inputNodes int
|
inputBytes int64
|
||||||
|
inputNodes int
|
||||||
}
|
}
|
||||||
|
|
||||||
type compositionClaim struct {
|
type compositionClaim struct {
|
||||||
@@ -58,10 +59,15 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
|||||||
for _, input := range inputs[1:] {
|
for _, input := range inputs[1:] {
|
||||||
mergeDisjointMappings(root, input)
|
mergeDisjointMappings(root, input)
|
||||||
}
|
}
|
||||||
|
applications := make([]historyApplication, 0, len(inputs))
|
||||||
|
for _, input := range inputs {
|
||||||
|
applications = append(applications, historyApplication{layer: LayerBase, value: input})
|
||||||
|
}
|
||||||
return &composedDocument{
|
return &composedDocument{
|
||||||
root: root,
|
root: root,
|
||||||
claims: cloneCompositionClaims(collected.ordered),
|
claims: cloneCompositionClaims(collected.ordered),
|
||||||
origins: compositionOrigins(collected.ordered),
|
origins: compositionOrigins(collected.ordered),
|
||||||
|
applications: applications,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,10 +141,60 @@ translated to a fixed safe error. There is no unredacted rendering option and
|
|||||||
ConfigKit performs no implicit logging.
|
ConfigKit performs no implicit logging.
|
||||||
|
|
||||||
`Result.Config` is the explicit trusted boundary and contains ordinary typed values,
|
`Result.Config` is the explicit trusted boundary and contains ordinary typed values,
|
||||||
including secrets. Metadata, histories, diagnostics, and load errors contain no raw
|
including secrets. Metadata records, diagnostics, and load errors contain no
|
||||||
configuration values. Applications must still keep source labels, resolution
|
configuration values. Explanation snapshots deliberately expose non-secret
|
||||||
reasons, and diagnostic messages value-free because ConfigKit cannot infer and
|
configuration history after applying the redaction contract below. Applications
|
||||||
sanitize secrets embedded in arbitrary application prose.
|
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
|
## Typed secret references
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ composition, selected profile overlays, typed defaults, schema-version checks,
|
|||||||
explicit CLI overrides, recorded application resolution, semantic validation,
|
explicit CLI overrides, recorded application resolution, semantic validation,
|
||||||
layered metadata, secret
|
layered metadata, secret
|
||||||
classification, typed environment references, explicit secrets bootstrap, and
|
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.
|
[component overview](internal/overview.md) owns the implementation inventory.
|
||||||
|
|
||||||
## Initial orientation
|
## 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
|
- explicit bounded startup environment bootstrap and safe partial reporting in
|
||||||
`secrets.go`;
|
`secrets.go`;
|
||||||
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`; and
|
- 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`.
|
only upstream dumper integration isolated in `internal/yaml/write.go`.
|
||||||
|
|
||||||
The [configuration contract](../config.md) owns implemented input, schema, decoding,
|
The [configuration contract](../config.md) owns implemented input, schema, decoding,
|
||||||
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
|
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
|
||||||
recorded resolution, typed secret references/bootstrap, semantic validation,
|
recorded resolution, typed secret references/bootstrap, semantic validation,
|
||||||
metadata, secret classification, redacted effective rendering, and ownership
|
metadata, secret classification, redacted effective rendering, historical
|
||||||
behavior.
|
explanation, and ownership behavior.
|
||||||
|
|
||||||
The source-extraction inventory records the application foundations assigned to
|
The source-extraction inventory records the application foundations assigned to
|
||||||
future implementation work:
|
future implementation work:
|
||||||
|
|||||||
@@ -1365,7 +1365,43 @@ Acceptance:
|
|||||||
|
|
||||||
## Stage 19 — Expose safe historical-value explanation
|
## 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
|
Read: [explanation contract](initial.md#historical-value-explanation), redaction
|
||||||
rules, and history bounds above.
|
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
|
`Result.Config`, `Metadata`, `Sources`, `Diagnostics`, and `WriteEffective`, together
|
||||||
with `LoadError` and `WriteDiagnostics`, are implemented at the current loading boundary.
|
with `LoadError` and `WriteDiagnostics`, are implemented at the current loading boundary.
|
||||||
The [current loading contract](../config.md#single-source-loading) and Go reference
|
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
|
```go
|
||||||
type Result[T any] struct {
|
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()`
|
Applications can use `Metadata().Lookup`, `Metadata().History`, and `Sources()`
|
||||||
for structured inspection without implementing value rendering or importing a
|
for structured inspection without implementing value rendering or importing a
|
||||||
parser. The explanation API below supplies safe values and formatting for application-owned
|
parser. The implemented explanation API supplies safe values and formatting for
|
||||||
`config explain` commands.
|
application-owned `config explain` commands.
|
||||||
|
|
||||||
### Historical-value explanation
|
### Historical-value explanation
|
||||||
|
|
||||||
```go
|
Historical explanation is implemented. The
|
||||||
func (r *Result[T]) Explain(path Path) (Explanation, bool)
|
[current explanation contract](../config.md#historical-value-explanation) owns its
|
||||||
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error
|
public records, replay ordering, redaction, rendering, and allocation bounds.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Module-specific options
|
### Module-specific options
|
||||||
|
|
||||||
|
|||||||
@@ -134,3 +134,17 @@ func ExampleOverride() {
|
|||||||
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
||||||
// Output: 0 --workers <nil>
|
// 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 {
|
type Result[T any] struct {
|
||||||
Config T
|
Config T
|
||||||
|
|
||||||
metadata Metadata
|
metadata Metadata
|
||||||
sources []SourceInfo
|
sources []SourceInfo
|
||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
final *document.Node
|
baseline *document.Node
|
||||||
resolution []resolutionSnapshot
|
input *document.Node
|
||||||
overlays []overlayRecord
|
final *document.Node
|
||||||
secrets map[string]bool
|
resolution []resolutionSnapshot
|
||||||
|
applications []historyApplication
|
||||||
|
secrets map[string]bool
|
||||||
|
secretReferences map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metadata returns detached effective presence, origin, and history records.
|
// 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
|
resolutionSnapshots = resolved.snapshots
|
||||||
applyResolutionMetadata(&metadata, resolved)
|
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)
|
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, inputDocument, lookup)
|
||||||
accumulator.add(secretResolution.diagnostics...)
|
accumulator.add(secretResolution.diagnostics...)
|
||||||
if hasErrors(secretResolution.diagnostics) {
|
if hasErrors(secretResolution.diagnostics) {
|
||||||
@@ -251,20 +264,19 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
|
|||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final)
|
secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final, applications)
|
||||||
var overlaySnapshots []overlayRecord
|
|
||||||
if composition != nil {
|
|
||||||
overlaySnapshots = cloneOverlayRecords(composition.overlays)
|
|
||||||
}
|
|
||||||
return &Result[T]{
|
return &Result[T]{
|
||||||
Config: configuration,
|
Config: configuration,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
sources: cloneSourceInfos(accumulator.sources),
|
sources: cloneSourceInfos(accumulator.sources),
|
||||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||||
final: document.Clone(final),
|
baseline: decoded.baseline,
|
||||||
resolution: cloneResolutionSnapshots(resolutionSnapshots),
|
input: decoded.final,
|
||||||
overlays: overlaySnapshots,
|
final: final,
|
||||||
secrets: secrets,
|
resolution: resolutionSnapshots,
|
||||||
|
applications: applications,
|
||||||
|
secrets: secrets,
|
||||||
|
secretReferences: cloneSecretReferences(secretResolution.references),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
14
overlay.go
14
overlay.go
@@ -27,15 +27,17 @@ func overlayDocuments(base *composedDocument, overlay *document.Node) (*composed
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
result := &composedDocument{
|
result := &composedDocument{
|
||||||
root: document.Clone(base.root),
|
root: document.Clone(base.root),
|
||||||
claims: cloneCompositionClaims(base.claims),
|
claims: cloneCompositionClaims(base.claims),
|
||||||
origins: cloneOriginIndex(base.origins),
|
origins: cloneOriginIndex(base.origins),
|
||||||
overlays: cloneOverlayRecords(base.overlays),
|
overlays: cloneOverlayRecords(base.overlays),
|
||||||
inputBytes: base.inputBytes,
|
applications: cloneHistoryApplications(base.applications),
|
||||||
inputNodes: base.inputNodes,
|
inputBytes: base.inputBytes,
|
||||||
|
inputNodes: base.inputNodes,
|
||||||
}
|
}
|
||||||
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
||||||
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
||||||
|
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -378,7 +378,8 @@ func cloneComposedDocument(base *composedDocument) *composedDocument {
|
|||||||
return &composedDocument{
|
return &composedDocument{
|
||||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
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{
|
result.overlays = append(result.overlays, overlayRecord{change: Change{
|
||||||
Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
|
Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
|
||||||
}, displaced: displaced, before: before, after: document.Clone(current.Mapping[position].Value)})
|
}, 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"
|
CodeOverrideDirective Code = "override.directive"
|
||||||
CodeOverrideVersion Code = "override.version"
|
CodeOverrideVersion Code = "override.version"
|
||||||
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
|
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
|
||||||
|
|
||||||
|
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Location identifies a position in a configuration source. Zero line and column
|
// 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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"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 {
|
func redactedDocument(node *document.Node, secret map[string]bool, path Path) *document.Node {
|
||||||
if node == nil {
|
result, _ := safeSnapshotDocument(node, secret, path)
|
||||||
return nil
|
return result
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathIsSecret(secret map[string]bool, path Path) bool {
|
func pathIsSecret(secret map[string]bool, path Path) bool {
|
||||||
|
|||||||
@@ -7,13 +7,20 @@ type secretDependency struct {
|
|||||||
dependency Path
|
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)
|
paths := make(map[string]Path)
|
||||||
finalPaths := make(map[string]*document.Node)
|
finalPaths := make(map[string]*document.Node)
|
||||||
indexDocument(final, Path{}, finalPaths)
|
indexDocument(final, Path{}, finalPaths)
|
||||||
for pointer := range finalPaths {
|
for pointer := range finalPaths {
|
||||||
addSecretPath(paths, pointer)
|
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
|
var dependencies []secretDependency
|
||||||
for pointer, history := range metadata.history {
|
for pointer, history := range metadata.history {
|
||||||
addSecretPath(paths, pointer)
|
addSecretPath(paths, pointer)
|
||||||
|
|||||||
@@ -64,12 +64,13 @@ type secretLookupResult struct {
|
|||||||
|
|
||||||
type secretResolutionResult struct {
|
type secretResolutionResult struct {
|
||||||
lookups map[string]secretLookupResult
|
lookups map[string]secretLookupResult
|
||||||
|
references map[string]string
|
||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input *document.Node, lookup secretLookup) secretResolutionResult {
|
func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input *document.Node, lookup secretLookup) secretResolutionResult {
|
||||||
result := 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 {
|
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"}}
|
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
|
return
|
||||||
}
|
}
|
||||||
value.Set(reflect.ValueOf(Secret{name: name, value: found.value, specified: true, resolved: true}))
|
value.Set(reflect.ValueOf(Secret{name: name, value: found.value, specified: true, resolved: true}))
|
||||||
|
result.references[path.String()] = name
|
||||||
case descriptorStruct:
|
case descriptorStruct:
|
||||||
for _, member := range descriptor.members {
|
for _, member := range descriptor.members {
|
||||||
memberPath := path
|
memberPath := path
|
||||||
|
|||||||
Reference in New Issue
Block a user