Add selected profile overlays
This commit is contained in:
@@ -5,11 +5,11 @@ An opinionated Go library for applications that use YAML configuration files.
|
|||||||
ConfigKit is in early implementation. Its current loader accepts one strict YAML
|
ConfigKit is in early implementation. Its current loader accepts one strict YAML
|
||||||
source (or explicit defaults-only input), optionally traverses explicit path and glob
|
source (or explicit defaults-only input), optionally traverses explicit path and glob
|
||||||
imports, strictly composes their disjoint definitions, applies typed defaults, and
|
imports, strictly composes their disjoint definitions, applies typed defaults, and
|
||||||
can check an application schema version. It returns detached metadata and safe
|
can overlay one selected fixed profile and check an application schema version. It
|
||||||
diagnostics.
|
returns detached metadata and safe diagnostics.
|
||||||
Applications may perform audited defaulting and normalization through a resolver,
|
Applications may perform audited defaulting and normalization through a resolver,
|
||||||
then report domain errors and warnings through read-only semantic validation.
|
then report domain errors and warnings through read-only semantic validation.
|
||||||
Profiles, overrides, secrets, and rendering are still under development.
|
Overrides, secrets, and rendering are still under development.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ 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
|
||||||
|
inputBytes int64
|
||||||
|
inputNodes int
|
||||||
}
|
}
|
||||||
|
|
||||||
type compositionClaim struct {
|
type compositionClaim struct {
|
||||||
|
|||||||
@@ -20,28 +20,29 @@ The zero `Source` is invalid. Constructor argument failures are retained inside
|
|||||||
opaque source and become safe structured diagnostics when the source is consumed.
|
opaque source and become safe structured diagnostics when the source is consumed.
|
||||||
File sources can observe file changes between consumptions; byte sources cannot.
|
File sources can observe file changes between consumptions; byte sources cannot.
|
||||||
|
|
||||||
Each source is limited to 8 MiB. An imported load is limited to 64 MiB across all
|
Each source is limited to 8 MiB. A load using imports or an active profile is limited
|
||||||
accepted sources, 1,024 accepted sources, 64 active import edges, and 1,000,000
|
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
||||||
input nodes in aggregate. Glob expansion separately permits 1,000,000 visited
|
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
||||||
directory entries, including nonmatches. Reads request at most the remaining
|
1,000,000 visited directory entries, including nonmatches. Reads request at most the
|
||||||
allowance plus one byte so excess data is rejected rather than silently truncated.
|
remaining allowance plus one byte so excess data is rejected rather than silently
|
||||||
Data returned together with a read error is not accepted as partial input.
|
truncated. Data returned together with a read error is not accepted as partial input.
|
||||||
|
|
||||||
## Single-source loading
|
## Single-source loading
|
||||||
|
|
||||||
`Load[T](source, options...)` accepts zero or one `Options[T]`. The configuration
|
`Load[T](source, options...)` accepts zero or one `Options[T]`. The configuration
|
||||||
root `T` must satisfy the application schema contract below. With no options it
|
root `T` must satisfy the application schema contract below. With no options it
|
||||||
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
|
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
|
||||||
runs exactly once after successful source parsing, schema construction, and optional
|
runs exactly once after successful source parsing, schema construction, optional
|
||||||
schema-version checking. Its result is detached before input is applied.
|
schema-version checking, and selected-profile loading. Its result is detached before
|
||||||
|
input is applied.
|
||||||
|
|
||||||
The current lifecycle is:
|
The current lifecycle is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
validate options → parse the selected source → traverse/compose enabled imports
|
validate options → parse the selected source → traverse/compose enabled imports
|
||||||
→ build schema → check the optional application version → create and clone defaults
|
→ build schema → check the optional application version → apply a selected profile
|
||||||
→ strictly decode → recorded application resolution → semantic validation
|
→ create and clone defaults → strictly decode → recorded application resolution
|
||||||
→ snapshot configuration and metadata
|
→ semantic validation → snapshot configuration and metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
`File`, `Bytes`, and `Reader` each contribute the first base source with deterministic
|
`File`, `Bytes`, and `Reader` each contribute the first base source with deterministic
|
||||||
@@ -51,13 +52,12 @@ contributes no source, and continues through defaults, decode, and projection. A
|
|||||||
missing file remains an error; it is never interpreted as no input.
|
missing file remains an error; it is never interpreted as no input.
|
||||||
|
|
||||||
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
||||||
semantic validator, explicit path and glob imports, and filesystem context. Active
|
semantic validator, explicit path and glob imports, one selected profile, and
|
||||||
profiles, overrides, and dynamic secret classification return `options.unavailable`
|
filesystem context. Overrides and dynamic secret classification return
|
||||||
because their lifecycle stages are not implemented. The reserved profile spelling
|
`options.unavailable` because their lifecycle stages are not implemented. `NoSource`
|
||||||
`default` selects no active profile. `NoSource`
|
rejects imports, active profiles, and filesystem context as invalid combinations.
|
||||||
additionally rejects imports, active profiles, and filesystem context as invalid
|
Input containing a reserved `_configkit` mapping key is rejected while imports are
|
||||||
combinations. Input containing a reserved `_configkit` mapping key is rejected while
|
disabled.
|
||||||
imports are disabled.
|
|
||||||
|
|
||||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||||
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
||||||
@@ -79,13 +79,16 @@ error without reporting one, ConfigKit emits a generic `version.hook_failed`
|
|||||||
diagnostic and suppresses the returned error text. Warnings alone are retained on a
|
diagnostic and suppresses the returned error text. Warnings alone are retained on a
|
||||||
successful result. Callback panics remain programming failures.
|
successful result. Callback panics remain programming failures.
|
||||||
|
|
||||||
### Base/default metadata
|
### Layered input/default metadata
|
||||||
|
|
||||||
`Result.Metadata` describes every final canonical path. Exact surviving input paths
|
`Result.Metadata` describes every final canonical path. Exact surviving input paths
|
||||||
are explicit and have a base origin; omitted paths have a default origin. An explicit
|
are explicit and have a base or profile origin; omitted paths have a default origin.
|
||||||
parent mapping does not make omitted children explicit. Null state and available
|
An explicit parent mapping does not make omitted children explicit. Null state and
|
||||||
source coordinates are retained. `History` records the Go-zero or application
|
available source coordinates are retained. `History` records the Go-zero or application
|
||||||
default baseline followed by exact base definitions, including equal-valued input.
|
default baseline followed by exact base or profile definitions, including
|
||||||
|
equal-valued input. Profile replacement removes displaced descendants from effective
|
||||||
|
metadata while retaining their base definition and the parent replacement operation
|
||||||
|
in history.
|
||||||
All metadata queries and returned nested records are detached.
|
All metadata queries and returned nested records are detached.
|
||||||
|
|
||||||
### Recorded application resolution
|
### Recorded application resolution
|
||||||
@@ -181,6 +184,43 @@ traversal order. Traversal order never authorizes replacement. Composition clone
|
|||||||
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
||||||
input document.
|
input document.
|
||||||
|
|
||||||
|
## Selected profiles
|
||||||
|
|
||||||
|
`Options.Profile` selects at most one overlay by name. Names must match
|
||||||
|
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
|
||||||
|
`default` select no profile, and `profiles/default.yml` is never loaded implicitly.
|
||||||
|
Every other name resolves only to `<configuration-root>/profiles/<name>.yml`, with
|
||||||
|
no extension fallback, case normalization, inheritance, or profile stack. A selected
|
||||||
|
missing profile is an error.
|
||||||
|
|
||||||
|
The profile is a separately policy-validated YAML mapping. It is loaded after the
|
||||||
|
base import graph is composed and its schema version is checked, but before defaults
|
||||||
|
and typed decoding. Profile bytes, nodes, and source records count toward the same
|
||||||
|
aggregate limits as base inputs. Its canonical path must remain within the configured
|
||||||
|
root and must not duplicate a file already present in the base graph.
|
||||||
|
|
||||||
|
Overlay is intentionally distinct from strict composition:
|
||||||
|
|
||||||
|
| Existing lower node | Incoming profile node | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Absent | Value | Retain the profile value |
|
||||||
|
| Value | Absent | Retain the lower value |
|
||||||
|
| Mapping | Mapping | Recurse by mapping key |
|
||||||
|
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
|
||||||
|
|
||||||
|
Scalars, sequences, and null are atomic. Thus a profile sequence replaces rather
|
||||||
|
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
|
||||||
|
cross-kind replacement is allowed when the final application schema accepts it.
|
||||||
|
Replacing a subtree removes all displaced descendants from effective presence and
|
||||||
|
origin data; history retains the displaced definitions and identifies the parent
|
||||||
|
replacement. Surviving profile paths have profile origins, while recursively shared
|
||||||
|
mapping containers retain their contributing locations.
|
||||||
|
|
||||||
|
A profile may not contain `_configkit` anywhere, contribute the configured schema
|
||||||
|
version, or replace an ancestor of that version with a scalar or null. These rules
|
||||||
|
apply even if the supplied version text would be unchanged. Profiles cannot mask a
|
||||||
|
base parse, composition, or version failure because those checks run first.
|
||||||
|
|
||||||
## Explicit imports
|
## Explicit imports
|
||||||
|
|
||||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
This is the contributor entry point for ConfigKit. Read the task-specific owners
|
This is the contributor entry point for ConfigKit. Read the task-specific owners
|
||||||
below before making changes. The repository is in early implementation: its public
|
below before making changes. The repository is in early implementation: its public
|
||||||
loader currently covers strict selected sources, explicit path/glob import graphs,
|
loader currently covers strict selected sources, explicit path/glob import graphs,
|
||||||
composition, typed defaults, schema-version checks, recorded application resolution,
|
composition, selected profile overlays, typed defaults, schema-version checks,
|
||||||
|
recorded application resolution,
|
||||||
semantic validation, and metadata. The
|
semantic validation, and metadata. The
|
||||||
[component overview](internal/overview.md) owns the implementation inventory.
|
[component overview](internal/overview.md) owns the implementation inventory.
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,17 @@ Assigned to selected-profile overlay:
|
|||||||
declared defaults, inheritance, arbitrary profile paths, or validating every
|
declared defaults, inheritance, arbitrary profile paths, or validating every
|
||||||
unselected overlay.
|
unselected overlay.
|
||||||
|
|
||||||
|
Implementation record (2026-09-06): `overlayDocuments`, `mergeOverlayMappings`,
|
||||||
|
`loadProfileDocument`, and `profileDirectiveDiagnostics` adapt the assigned merge,
|
||||||
|
selection, loading, and ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
|
||||||
|
`TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults`, profile selection/error,
|
||||||
|
directive/version, containment, and budget cases adapt the assigned tests. ConfigKit
|
||||||
|
uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement,
|
||||||
|
retains displaced descendant history, rejects any profile directive or version-path
|
||||||
|
contribution, and counts the profile in aggregate budgets. Narratio's declared
|
||||||
|
default, multi-overlay validation, inheritance-like application conventions, and
|
||||||
|
null/kind restrictions were not retained.
|
||||||
|
|
||||||
### Effective inspection and provenance
|
### Effective inspection and provenance
|
||||||
|
|
||||||
Assigned to layered metadata, redaction, rendering, and explanation:
|
Assigned to layered metadata, redaction, rendering, and explanation:
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ module and package documentation boundary. Its implemented public primitives are
|
|||||||
- a private strict composer in `compose.go` that joins only disjoint document-tree
|
- a private strict composer in `compose.go` that joins only disjoint document-tree
|
||||||
definitions, retains ordered claims and composite mapping origins, reports every
|
definitions, retains ordered claims and composite mapping origins, reports every
|
||||||
conflicting location, and never aliases an input tree;
|
conflicting location, and never aliases an input tree;
|
||||||
|
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
|
||||||
|
with displacement records in `overlay.go`;
|
||||||
- a per-operation private reflection descriptor graph in `schema.go` for supported
|
- a per-operation private reflection descriptor graph in `schema.go` for supported
|
||||||
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
|
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
|
||||||
- descriptor-driven canonical projection, detached typed cloning, cycle/limit
|
- descriptor-driven canonical projection, detached typed cloning, cycle/limit
|
||||||
@@ -31,7 +33,7 @@ module and package documentation boundary. Its implemented public primitives are
|
|||||||
`validation.go`, completing the public callback lifecycle after composed input.
|
`validation.go`, completing the public callback lifecycle after composed input.
|
||||||
|
|
||||||
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, version checking, recorded resolution,
|
loading lifecycle, path imports, composition, profile overlay, version checking, recorded resolution,
|
||||||
semantic validation, metadata, and ownership behavior.
|
semantic validation, metadata, and ownership behavior.
|
||||||
|
|
||||||
The source-extraction inventory records the application foundations assigned to
|
The source-extraction inventory records the application foundations assigned to
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status and ownership
|
## Status and ownership
|
||||||
|
|
||||||
Status: implementation in progress; Stages 1–9 are complete and later stages are
|
Status: implementation in progress; Stages 1–13 are complete and later stages are
|
||||||
pending.
|
pending.
|
||||||
|
|
||||||
Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in
|
Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in
|
||||||
@@ -982,7 +982,31 @@ Acceptance:
|
|||||||
|
|
||||||
## Stage 13 — Implement profile overlays
|
## Stage 13 — Implement profile overlays
|
||||||
|
|
||||||
Status: pending.
|
Status: complete.
|
||||||
|
|
||||||
|
Completion evidence (2026-09-06):
|
||||||
|
|
||||||
|
- Added a private non-aliasing recursive overlay that merges mappings by key and
|
||||||
|
atomically replaces scalars, sequences, empty sequences, nulls, and cross-kind
|
||||||
|
subtrees. Replacement deletes stale descendant origins and records every displaced
|
||||||
|
definition plus the parent operation for later history.
|
||||||
|
- Added exact selected-profile loading from `profiles/<name>.yml`, with constrained
|
||||||
|
names, inactive empty/`default` selection, canonical root containment, base-graph
|
||||||
|
duplicate rejection, deterministic profile source ownership, and shared aggregate
|
||||||
|
source/byte/node limits. No path selection, extension fallback, inheritance, or
|
||||||
|
implicit default file is supported.
|
||||||
|
- Applied profiles only after base import composition and version checking and before
|
||||||
|
defaults and strict decoding. Profiles reject `_configkit` at any depth, direct
|
||||||
|
version contributions, and scalar/null replacements of version ancestors; base
|
||||||
|
parse and version failures therefore cannot be masked.
|
||||||
|
- Tests cover recursive siblings, scalar/full/empty-sequence/null/cross-kind
|
||||||
|
replacement, lower defaults, profile source and origin attribution, removed-child
|
||||||
|
and stale-index history, selection errors, fixed extension, directives, version
|
||||||
|
placement, base aliases, parse ordering, filesystem context, symlink escape, input
|
||||||
|
budgets, and non-aliasing. The
|
||||||
|
[current profile contract](../config.md#selected-profiles) owns the behavior.
|
||||||
|
Verified `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`,
|
||||||
|
and `git diff --check` with Go 1.26.7 on linux/amd64.
|
||||||
|
|
||||||
Read: feature [profiles](initial.md#profiles), composition/overlay table.
|
Read: feature [profiles](initial.md#profiles), composition/overlay table.
|
||||||
|
|
||||||
|
|||||||
@@ -450,28 +450,9 @@ origin is a resolution operation, with input origins retained separately.
|
|||||||
|
|
||||||
## Composition and overlay
|
## Composition and overlay
|
||||||
|
|
||||||
Strict base composition is implemented and now owned by the
|
Strict base composition and selected-profile overlay are implemented and owned by
|
||||||
[current configuration contract](../config.md#strict-document-composition).
|
the [current composition contract](../config.md#strict-document-composition) and
|
||||||
Intentional profile overlay remains target behavior. Its exhaustive operation table
|
[profile contract](../config.md#selected-profiles).
|
||||||
is below; “value” includes scalar, sequence, mapping, and null, while absent means no
|
|
||||||
node rather than null.
|
|
||||||
|
|
||||||
| Existing/lower node | Incoming/higher node | Overlay |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Absent | Absent | Absent |
|
|
||||||
| Absent | Value | Incoming |
|
|
||||||
| Value | Absent | Existing |
|
|
||||||
| Mapping | Mapping | Recurse by key |
|
|
||||||
| Any other pair of present nodes | Any other pair of present nodes | Replace entire existing node |
|
|
||||||
|
|
||||||
A mapping can replace a lower scalar, sequence, or null during overlay, subject to
|
|
||||||
the final schema. Sequences are atomic and the higher sequence replaces the entire
|
|
||||||
lower sequence in an overlay. `[]` clears a sequence. There is no append, union,
|
|
||||||
index merge, or mapping-clear operator.
|
|
||||||
|
|
||||||
Replacing a parent must not leave effective metadata for removed children.
|
|
||||||
Sequence histories describe whole-sequence replacement; index origins refer to
|
|
||||||
the surviving sequence, not an attempted correspondence with previous indices.
|
|
||||||
|
|
||||||
## Explicit imports and source identity
|
## Explicit imports and source identity
|
||||||
|
|
||||||
@@ -481,18 +462,9 @@ filesystem context, and budgets are implemented and now owned by the
|
|||||||
|
|
||||||
## Profiles
|
## Profiles
|
||||||
|
|
||||||
Load only the composed base unless the application selects a profile. The API
|
Selected profiles are implemented and owned by the
|
||||||
accepts a name matching `[a-zA-Z0-9_-]+`, not an arbitrary path. The empty name
|
[current profile contract](../config.md#selected-profiles). Applications continue
|
||||||
and reserved lowercase name `default` both mean no overlay; a literal
|
to own the flags and commands that choose a profile name.
|
||||||
`profiles/default.yml` is never loaded. Other names resolve exactly to
|
|
||||||
`<configuration-root>/profiles/<name>.yml`, without extension fallback or case
|
|
||||||
normalization. A selected missing profile is an error.
|
|
||||||
|
|
||||||
A profile is one policy-validated YAML mapping applied using the overlay table.
|
|
||||||
It must not contain `_configkit`, introduce imports, or alter the application's
|
|
||||||
declared schema-version path. Reject these directives even if their values
|
|
||||||
would be unchanged. There is no profile inheritance or multiple-profile stack.
|
|
||||||
Applications own flags and commands that select this behavior.
|
|
||||||
|
|
||||||
## Static defaults and null
|
## Static defaults and null
|
||||||
|
|
||||||
@@ -597,8 +569,8 @@ Automatic migration is deferred.
|
|||||||
|
|
||||||
## Consumer API design
|
## Consumer API design
|
||||||
|
|
||||||
The core declarations, callback lifecycle, strict composition, and explicit path
|
The core declarations, callback lifecycle, strict composition, explicit imports,
|
||||||
imports are implemented; their current contract is owned by
|
and selected profiles are implemented; their current contract is owned by
|
||||||
[configuration input](../config.md) and Go reference comments. Remaining declarations
|
[configuration input](../config.md) and Go reference comments. Remaining declarations
|
||||||
and behavior in this section are target design, not implemented Go reference
|
and behavior in this section are target design, not implemented Go reference
|
||||||
documentation or a frozen naming commitment. Declarations omit method bodies and
|
documentation or a frozen naming commitment. Declarations omit method bodies and
|
||||||
@@ -614,7 +586,7 @@ and verification against consumer models.
|
|||||||
`Load`, `Definition`, `Options`, `FilesystemContext`, `Override`, `VersionRule`,
|
`Load`, `Definition`, `Options`, `FilesystemContext`, `Override`, `VersionRule`,
|
||||||
`Version`, `Validation`, and `Result` are declared. The current loader supports the
|
`Version`, `Validation`, and `Result` are declared. The current loader supports the
|
||||||
callback and path-import subset described by the [current contract](../config.md);
|
callback and path-import subset described by the [current contract](../config.md);
|
||||||
later profile, override, secret, and rendering behavior below remains target work and
|
later override, secret, and rendering behavior below remains target work and
|
||||||
unavailable options fail explicitly rather than being ignored.
|
unavailable options fail explicitly rather than being ignored.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -660,8 +632,8 @@ ConfigKit does not synchronize application-owned captured state.
|
|||||||
`Source`, its constructors, and import filesystem context are implemented;
|
`Source`, its constructors, and import filesystem context are implemented;
|
||||||
[`source.go`](../../source.go) and the [configuration input contract](../config.md#sources)
|
[`source.go`](../../source.go) and the [configuration input contract](../config.md#sources)
|
||||||
own their current behavior. Applications must use non-secret source labels because
|
own their current behavior. Applications must use non-secret source labels because
|
||||||
labels appear in diagnostics and source reports. Profile-related filesystem behavior
|
labels appear in diagnostics and source reports. Filesystem options do not change
|
||||||
remains target work. Filesystem options do not change application field path meanings.
|
application field path meanings.
|
||||||
|
|
||||||
`NoSource()` explicitly selects defaults-only loading. It contributes no document
|
`NoSource()` explicitly selects defaults-only loading. It contributes no document
|
||||||
or file source entry and marks no fields explicit by itself; CLI overrides still
|
or file source entry and marks no fields explicit by itself; CLI overrides still
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
|
|||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
||||||
}
|
}
|
||||||
composed, diagnostics := composeDocuments(state.documents...)
|
composed, diagnostics := composeDocuments(state.documents...)
|
||||||
|
if composed != nil {
|
||||||
|
composed.inputBytes = state.usedBytes
|
||||||
|
composed.inputNodes = state.usedNodes
|
||||||
|
}
|
||||||
return composed, cloneSourceInfos(state.sources), diagnostics
|
return composed, cloneSourceInfos(state.sources), diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
load.go
42
load.go
@@ -17,8 +17,8 @@ type Definition[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Options contains the application definition and per-load input selections.
|
// Options contains the application definition and per-load input selections.
|
||||||
// Imports enables explicit path and glob imports; later precedence layers remain
|
// Imports enables explicit path and glob imports; Profile selects one fixed profile
|
||||||
// unavailable until their owning features are implemented.
|
// overlay. Explicit overrides remain unavailable until implemented.
|
||||||
type Options[T any] struct {
|
type Options[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
Imports bool
|
Imports bool
|
||||||
@@ -94,9 +94,9 @@ func (r *Result[T]) Diagnostics() []Diagnostic {
|
|||||||
return cloneDiagnostics(r.diagnostics)
|
return cloneDiagnostics(r.diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load parses and strictly decodes zero or one selected source plus explicitly
|
// Load parses and strictly decodes zero or one selected source, explicitly enabled
|
||||||
// enabled imports. It accepts at most one Options value and returns no result when
|
// imports, and one selected profile. It accepts at most one Options value and
|
||||||
// any error is reported.
|
// returns no result when any error is reported.
|
||||||
func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
|
func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
|
||||||
accumulator := diagnosticAccumulator{}
|
accumulator := diagnosticAccumulator{}
|
||||||
options, diagnostics := selectOptions(source, supplied)
|
options, diagnostics := selectOptions(source, supplied)
|
||||||
@@ -127,7 +127,7 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
|
|||||||
if parsed.source != nil {
|
if parsed.source != nil {
|
||||||
accumulator.sources = append(accumulator.sources, *parsed.source)
|
accumulator.sources = append(accumulator.sources, *parsed.source)
|
||||||
}
|
}
|
||||||
if options.Filesystem != nil {
|
if options.Filesystem != nil && !activeProfile(options.Profile) {
|
||||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||||
accumulator.add(*diagnostic)
|
accumulator.add(*diagnostic)
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
@@ -153,6 +153,26 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
|
|||||||
if hasErrors(versionDiagnostics) {
|
if hasErrors(versionDiagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
|
if activeProfile(options.Profile) {
|
||||||
|
if composition == nil {
|
||||||
|
composition, diagnostics = composeDocuments(inputDocument)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
composition.inputBytes = parsed.bytes
|
||||||
|
composition.inputNodes = countInputNodes(parsed.document)
|
||||||
|
}
|
||||||
|
composition, accumulator.sources, diagnostics = loadProfileDocument(
|
||||||
|
source, options.Filesystem, parsed, composition, accumulator.sources,
|
||||||
|
options.Profile, options.Definition.Version, defaultImportLimits(),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
inputDocument = composition.root
|
||||||
|
}
|
||||||
|
|
||||||
defaults := reflect.Zero(reflect.TypeFor[T]())
|
defaults := reflect.Zero(reflect.TypeFor[T]())
|
||||||
if options.Definition.Defaults != nil {
|
if options.Definition.Defaults != nil {
|
||||||
@@ -171,6 +191,9 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
|
|||||||
|
|
||||||
configuration := decoded.value.Interface().(T)
|
configuration := decoded.value.Interface().(T)
|
||||||
metadata := metadataFromDecoded(decoded)
|
metadata := metadataFromDecoded(decoded)
|
||||||
|
if composition != nil {
|
||||||
|
applyOverlayMetadata(&metadata, composition.overlays)
|
||||||
|
}
|
||||||
final := decoded.final
|
final := decoded.final
|
||||||
var resolutionSnapshots []resolutionSnapshot
|
var resolutionSnapshots []resolutionSnapshot
|
||||||
if options.Definition.Resolve != nil {
|
if options.Definition.Resolve != nil {
|
||||||
@@ -213,8 +236,8 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
if definition.Secret != nil {
|
if definition.Secret != nil {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "dynamic secret classification is not available in this release boundary"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "dynamic secret classification is not available in this release boundary"))
|
||||||
}
|
}
|
||||||
if options.Profile != "" && options.Profile != "default" {
|
if !validProfileName(options.Profile) {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "configuration profiles are not available in this release boundary"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen"))
|
||||||
}
|
}
|
||||||
if options.Filesystem != nil {
|
if options.Filesystem != nil {
|
||||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
||||||
@@ -228,6 +251,9 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" {
|
if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile"))
|
||||||
}
|
}
|
||||||
|
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context"))
|
||||||
|
}
|
||||||
if definition.Version != nil {
|
if definition.Version != nil {
|
||||||
if !definition.Version.Path.valid() {
|
if !definition.Version.Path.valid() {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
|
||||||
|
|||||||
@@ -34,6 +34,43 @@ func metadataFromDecoded(decoded decodedConfiguration) Metadata {
|
|||||||
return metadata
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyOverlayMetadata(metadata *Metadata, records []overlayRecord) {
|
||||||
|
for _, record := range records {
|
||||||
|
for _, displaced := range record.displaced {
|
||||||
|
pointer := displaced.path.String()
|
||||||
|
history := metadata.history[pointer]
|
||||||
|
profileIndex := -1
|
||||||
|
for index := len(history) - 1; index >= 0; index-- {
|
||||||
|
if history[index].Origin.Layer == LayerProfile {
|
||||||
|
profileIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseChange := Change{Path: displaced.path.clone(), Origin: cloneOrigin(displaced.origin)}
|
||||||
|
if len(history) != 0 && history[len(history)-1].Origin.Layer == LayerDefault {
|
||||||
|
baseChange.Previous = []Origin{cloneOrigin(history[len(history)-1].Origin)}
|
||||||
|
}
|
||||||
|
if profileIndex >= 0 {
|
||||||
|
history = append(history[:profileIndex], append([]Change{baseChange}, history[profileIndex:]...)...)
|
||||||
|
} else {
|
||||||
|
history = append(history, baseChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
change := cloneChange(record.change)
|
||||||
|
change.Previous = []Origin{cloneOrigin(displaced.origin)}
|
||||||
|
if profileIndex >= 0 {
|
||||||
|
// The effective-input projection created a leaf-shaped profile
|
||||||
|
// record. Replace it with the actual parent operation.
|
||||||
|
profileIndex++
|
||||||
|
history[profileIndex] = change
|
||||||
|
} else {
|
||||||
|
history = append(history, change)
|
||||||
|
}
|
||||||
|
metadata.history[pointer] = history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func collectFieldInfo(node *document.Node, path Path, decoded decodedConfiguration, fields map[string]FieldInfo) {
|
func collectFieldInfo(node *document.Node, path Path, decoded decodedConfiguration, fields map[string]FieldInfo) {
|
||||||
pointer := path.String()
|
pointer := path.String()
|
||||||
_, explicit := decoded.presence[pointer]
|
_, explicit := decoded.presence[pointer]
|
||||||
|
|||||||
@@ -224,7 +224,6 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
|||||||
{name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid},
|
{name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid},
|
||||||
{name: "secret classifier", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Secret: func(Path) bool { return false }}}}, code: CodeOptionsUnavailable},
|
{name: "secret classifier", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Secret: func(Path) bool { return false }}}}, code: CodeOptionsUnavailable},
|
||||||
{name: "imports without filesystem context", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsInvalid},
|
{name: "imports without filesystem context", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsInvalid},
|
||||||
{name: "profile", source: validSource(), options: []Options[config]{{Profile: "testing"}}, code: CodeOptionsUnavailable},
|
|
||||||
{name: "incomplete filesystem", source: validSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid},
|
{name: "incomplete filesystem", source: validSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid},
|
||||||
{name: "overrides", source: validSource(), options: []Options[config]{{Overrides: []Override{{Path: Keys("name"), Value: "other", Flag: "--name"}}}}, code: CodeOptionsUnavailable},
|
{name: "overrides", source: validSource(), options: []Options[config]{{Overrides: []Override{{Path: Keys("name"), Value: "other", Flag: "--name"}}}}, code: CodeOptionsUnavailable},
|
||||||
{name: "missing version checker", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version")}}}}, code: CodeOptionsInvalid},
|
{name: "missing version checker", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version")}}}}, code: CodeOptionsInvalid},
|
||||||
|
|||||||
165
overlay.go
Normal file
165
overlay.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type overlayRecord struct {
|
||||||
|
change Change
|
||||||
|
displaced []displacedDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
type displacedDefinition struct {
|
||||||
|
path Path
|
||||||
|
origin Origin
|
||||||
|
}
|
||||||
|
|
||||||
|
func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) {
|
||||||
|
if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
|
||||||
|
return nil, []Diagnostic{{
|
||||||
|
Code: CodeProfileInvalid, Severity: SeverityError,
|
||||||
|
Message: "profile overlay requires mapping document roots",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
mergeOverlayOrigin(result.origins, Path{}, overlay.Location)
|
||||||
|
mergeOverlayMappings(result, result.root, overlay, Path{})
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeOverlayMappings(result *composedDocument, target, overlay *document.Node, current Path) {
|
||||||
|
for _, incoming := range overlay.Mapping {
|
||||||
|
path := current.Key(incoming.Key)
|
||||||
|
index := documentEntryIndex(target.Mapping, incoming.Key)
|
||||||
|
if index < 0 {
|
||||||
|
cloned := incoming
|
||||||
|
cloned.Value = document.Clone(incoming.Value)
|
||||||
|
target.Mapping = append(target.Mapping, cloned)
|
||||||
|
collectOverlayOrigins(result.origins, incoming.Value, path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
existing := target.Mapping[index].Value
|
||||||
|
if existing.Kind == document.Mapping && incoming.Value.Kind == document.Mapping {
|
||||||
|
mergeOverlayOrigin(result.origins, path, incoming.Value.Location)
|
||||||
|
mergeOverlayMappings(result, existing, incoming.Value, path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
displaced := displacedOrigins(existing, result.origins, path)
|
||||||
|
previous := cloneOrigin(result.origins[path.String()])
|
||||||
|
deleteOriginSubtree(result.origins, path)
|
||||||
|
target.Mapping[index] = incoming
|
||||||
|
target.Mapping[index].Value = document.Clone(incoming.Value)
|
||||||
|
collectOverlayOrigins(result.origins, incoming.Value, path)
|
||||||
|
result.overlays = append(result.overlays, overlayRecord{
|
||||||
|
change: Change{
|
||||||
|
Path: path.clone(), Origin: profileOrigin(incoming.Value.Location),
|
||||||
|
Previous: previousOrigins(previous),
|
||||||
|
},
|
||||||
|
displaced: displaced,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path) {
|
||||||
|
origins[path.String()] = profileOrigin(node.Location)
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
collectOverlayOrigins(origins, entry.Value, path.Key(entry.Key))
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
collectOverlayOrigins(origins, child, path.Index(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeOverlayOrigin(origins map[string]Origin, path Path, location document.Location) {
|
||||||
|
pointer := path.String()
|
||||||
|
origin := cloneOrigin(origins[pointer])
|
||||||
|
origin.Layer = LayerProfile
|
||||||
|
origin.Locations = append(origin.Locations, publicLocation(location))
|
||||||
|
origins[pointer] = origin
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileOrigin(location document.Location) Origin {
|
||||||
|
return Origin{Layer: LayerProfile, Locations: []Location{publicLocation(location)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func previousOrigins(origin Origin) []Origin {
|
||||||
|
if origin.Layer == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []Origin{origin}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displacedOrigins(node *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
|
||||||
|
var displaced []displacedDefinition
|
||||||
|
collectDisplacedOrigins(&displaced, node, origins, path, Origin{})
|
||||||
|
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
|
||||||
|
return strings.Compare(left.path.String(), right.path.String())
|
||||||
|
})
|
||||||
|
return displaced
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectDisplacedOrigins(displaced *[]displacedDefinition, node *document.Node, origins map[string]Origin, path Path, inherited Origin) {
|
||||||
|
origin, ok := origins[path.String()]
|
||||||
|
if !ok {
|
||||||
|
origin = cloneOrigin(inherited)
|
||||||
|
origin.Locations = []Location{publicLocation(node.Location)}
|
||||||
|
}
|
||||||
|
*displaced = append(*displaced, displacedDefinition{path: path.clone(), origin: cloneOrigin(origin)})
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
collectDisplacedOrigins(displaced, entry.Value, origins, path.Key(entry.Key), origin)
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
collectDisplacedOrigins(displaced, child, origins, path.Index(index), origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteOriginSubtree(origins map[string]Origin, path Path) {
|
||||||
|
prefix := path.String()
|
||||||
|
for pointer := range origins {
|
||||||
|
if pointer == prefix || strings.HasPrefix(pointer, prefix+"/") {
|
||||||
|
delete(origins, pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneOriginIndex(origins map[string]Origin) map[string]Origin {
|
||||||
|
cloned := make(map[string]Origin, len(origins))
|
||||||
|
for pointer, origin := range origins {
|
||||||
|
cloned[pointer] = cloneOrigin(origin)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneOverlayRecords(records []overlayRecord) []overlayRecord {
|
||||||
|
cloned := make([]overlayRecord, len(records))
|
||||||
|
for index, record := range records {
|
||||||
|
cloned[index].change = cloneChange(record.change)
|
||||||
|
cloned[index].displaced = make([]displacedDefinition, len(record.displaced))
|
||||||
|
for displacedIndex, displaced := range record.displaced {
|
||||||
|
cloned[index].displaced[displacedIndex] = displacedDefinition{
|
||||||
|
path: displaced.path.clone(), origin: cloneOrigin(displaced.origin),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
78
overlay_test.go
Normal file
78
overlay_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees(t *testing.T) {
|
||||||
|
baseRoot := parseCompositionDocument(t, "source-1", `shared:
|
||||||
|
kept: base
|
||||||
|
changed: old
|
||||||
|
sequence: [one, two]
|
||||||
|
nullable: value
|
||||||
|
to_map: scalar
|
||||||
|
to_scalar: {child: old}
|
||||||
|
`)
|
||||||
|
base, diagnostics := composeDocuments(baseRoot)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
overlay := parseCompositionDocument(t, "source-2", `shared:
|
||||||
|
changed: new
|
||||||
|
added: profile
|
||||||
|
sequence: []
|
||||||
|
nullable: null
|
||||||
|
to_map: {child: new}
|
||||||
|
to_scalar: scalar
|
||||||
|
`)
|
||||||
|
baseBefore := document.Clone(base.root)
|
||||||
|
overlayBefore := document.Clone(overlay)
|
||||||
|
|
||||||
|
result, diagnostics := overlayDocuments(base, overlay)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("overlayDocuments() diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
checks := []struct {
|
||||||
|
path Path
|
||||||
|
kind document.Kind
|
||||||
|
text string
|
||||||
|
}{
|
||||||
|
{path: Keys("shared", "kept"), kind: document.String, text: "base"},
|
||||||
|
{path: Keys("shared", "changed"), kind: document.String, text: "new"},
|
||||||
|
{path: Keys("shared", "added"), kind: document.String, text: "profile"},
|
||||||
|
{path: Keys("sequence"), kind: document.Sequence},
|
||||||
|
{path: Keys("nullable"), kind: document.Null},
|
||||||
|
{path: Keys("to_map"), kind: document.Mapping},
|
||||||
|
{path: Keys("to_map", "child"), kind: document.String, text: "new"},
|
||||||
|
{path: Keys("to_scalar"), kind: document.String, text: "scalar"},
|
||||||
|
}
|
||||||
|
for _, check := range checks {
|
||||||
|
node, ok := documentAtPath(result.root, check.path)
|
||||||
|
if !ok || node.Kind != check.kind || node.Text != check.text {
|
||||||
|
t.Errorf("node %s = %#v, %v", check.path.String(), node, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, exists := result.origins["/sequence/0"]; exists {
|
||||||
|
t.Fatal("replaced empty sequence retained a stale index origin")
|
||||||
|
}
|
||||||
|
if _, exists := result.origins["/to_scalar/child"]; exists {
|
||||||
|
t.Fatal("parent replacement retained a stale child origin")
|
||||||
|
}
|
||||||
|
shared := result.origins["/shared"]
|
||||||
|
if shared.Layer != LayerProfile || len(shared.Locations) != 2 {
|
||||||
|
t.Fatalf("shared mapping origin = %#v", shared)
|
||||||
|
}
|
||||||
|
if len(result.overlays) != 5 {
|
||||||
|
t.Fatalf("overlay records = %#v, want five replacements", result.overlays)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(base.root, baseBefore) || !reflect.DeepEqual(overlay, overlayBefore) {
|
||||||
|
t.Fatal("overlay mutated an input document")
|
||||||
|
}
|
||||||
|
result.root.Mapping[0].Value.Mapping[0].Value.Text = "changed"
|
||||||
|
if !reflect.DeepEqual(base.root, baseBefore) || !reflect.DeepEqual(overlay, overlayBefore) {
|
||||||
|
t.Fatal("overlay result aliases an input document")
|
||||||
|
}
|
||||||
|
}
|
||||||
179
profile.go
Normal file
179
profile.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
func activeProfile(name string) bool {
|
||||||
|
return name != "" && name != "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
func validProfileName(name string) bool {
|
||||||
|
if !activeProfile(name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, character := range name {
|
||||||
|
if (character >= 'a' && character <= 'z') ||
|
||||||
|
(character >= 'A' && character <= 'Z') ||
|
||||||
|
(character >= '0' && character <= '9') || character == '_' || character == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProfileDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return nil, cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
profilePath := filepath.Join(rootPath, "profiles", name+".yml")
|
||||||
|
canonical, err := canonicalExistingPath(profilePath)
|
||||||
|
if err != nil {
|
||||||
|
code := CodeProfileInvalid
|
||||||
|
message := "selected profile could not be resolved"
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
code, message = CodeProfileMissing, "selected profile does not exist"
|
||||||
|
} else if errors.Is(err, fs.ErrPermission) {
|
||||||
|
code, message = CodeImportPermission, "selected profile cannot be accessed"
|
||||||
|
}
|
||||||
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(code, message, nil)}
|
||||||
|
}
|
||||||
|
if !containedPath(rootPath, canonical) {
|
||||||
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile resolves outside the configuration root", nil)}
|
||||||
|
}
|
||||||
|
for _, existing := range sources {
|
||||||
|
if existing.Path != "" && existing.Path == canonical {
|
||||||
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileRepeated, "selected profile is already present in the base source graph", nil)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sources)+1 > limits.sources {
|
||||||
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the source-count limit", nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := os.OpenRoot(rootPath)
|
||||||
|
if err != nil {
|
||||||
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected profile", Location{})
|
||||||
|
value.Primary = nil
|
||||||
|
return nil, cloneSourceInfos(sources), []Diagnostic{value}
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(rootPath, canonical)
|
||||||
|
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||||
|
_ = root.Close()
|
||||||
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := "source-" + strconv.Itoa(len(sources)+1)
|
||||||
|
profileLocation := &Location{SourceID: sourceID}
|
||||||
|
profileSource := SourceInfo{ID: sourceID, Label: filepath.ToSlash(filepath.Join("profiles", name+".yml")), Path: canonical, Layer: LayerProfile}
|
||||||
|
sources = append(cloneSourceInfos(sources), profileSource)
|
||||||
|
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
|
||||||
|
if diagnostic != nil {
|
||||||
|
_ = root.Close()
|
||||||
|
return nil, sources, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
|
||||||
|
if len(issues) != 0 {
|
||||||
|
_ = root.Close()
|
||||||
|
return nil, sources, importParseDiagnostics(sourceID, issues)
|
||||||
|
}
|
||||||
|
nodeCount := countInputNodes(profileRoot)
|
||||||
|
if base.inputNodes+nodeCount > limits.nodes {
|
||||||
|
_ = root.Close()
|
||||||
|
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
|
||||||
|
}
|
||||||
|
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
|
||||||
|
_ = root.Close()
|
||||||
|
return nil, sources, diagnostics
|
||||||
|
}
|
||||||
|
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
|
||||||
|
location := publicLocation(versionNode.Location)
|
||||||
|
_ = root.Close()
|
||||||
|
return nil, sources, []Diagnostic{{
|
||||||
|
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
|
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if err := root.Close(); err != nil {
|
||||||
|
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
profiled, diagnostics := overlayDocuments(base, profileRoot)
|
||||||
|
if profiled != nil {
|
||||||
|
profiled.inputBytes = base.inputBytes + int64(len(data))
|
||||||
|
profiled.inputNodes = base.inputNodes + nodeCount
|
||||||
|
}
|
||||||
|
return profiled, sources, diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
func readProfileFile(root *os.Root, relative, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
||||||
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
|
if limit < 0 {
|
||||||
|
limit = 0
|
||||||
|
}
|
||||||
|
file, err := root.Open(relative)
|
||||||
|
if err != nil {
|
||||||
|
value := classifySourceError(err, sourceID)
|
||||||
|
return nil, &value
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
value := classifySourceError(err, sourceID)
|
||||||
|
return nil, &value
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
||||||
|
return nil, &value
|
||||||
|
}
|
||||||
|
if info.Size() > limit {
|
||||||
|
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
||||||
|
return nil, &value
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||||
|
if err != nil {
|
||||||
|
value := classifySourceError(err, sourceID)
|
||||||
|
return nil, &value
|
||||||
|
}
|
||||||
|
if int64(len(data)) > limit {
|
||||||
|
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
||||||
|
return nil, &value
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||||
|
if node.Kind == document.Mapping {
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
childPath := current.Key(entry.Key)
|
||||||
|
if entry.Key == "_configkit" {
|
||||||
|
location := publicLocation(entry.KeyLocation)
|
||||||
|
return []Diagnostic{{
|
||||||
|
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
|
||||||
|
Message: "profiles cannot contain configuration directives", Primary: &location,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if node.Kind == document.Sequence {
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
267
profile_test.go
Normal file
267
profile_test.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type profileTestConfig struct {
|
||||||
|
Settings struct {
|
||||||
|
Kept string `yaml:"kept"`
|
||||||
|
Changed string `yaml:"changed"`
|
||||||
|
Added string `yaml:"added"`
|
||||||
|
} `yaml:"settings"`
|
||||||
|
Values []string `yaml:"values"`
|
||||||
|
Nullable *string `yaml:"nullable"`
|
||||||
|
Value any `yaml:"value"`
|
||||||
|
Schema map[string]string `yaml:"schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
||||||
|
imports: [{path: base.yml}]
|
||||||
|
settings:
|
||||||
|
kept: main
|
||||||
|
changed: base
|
||||||
|
values: [base, stale]
|
||||||
|
nullable: base
|
||||||
|
value: {child: base}
|
||||||
|
`)
|
||||||
|
basePath := writeImportFile(t, directory, "base.yml", "settings: {added: imported}\n")
|
||||||
|
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `settings:
|
||||||
|
changed: profile
|
||||||
|
values: [profile]
|
||||||
|
nullable: null
|
||||||
|
value: scalar
|
||||||
|
`)
|
||||||
|
|
||||||
|
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{
|
||||||
|
Imports: true,
|
||||||
|
Profile: "testing",
|
||||||
|
Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig {
|
||||||
|
defaultValue := "default"
|
||||||
|
return profileTestConfig{
|
||||||
|
Values: []string{"default"}, Nullable: &defaultValue,
|
||||||
|
Settings: struct {
|
||||||
|
Kept string `yaml:"kept"`
|
||||||
|
Changed string `yaml:"changed"`
|
||||||
|
Added string `yaml:"added"`
|
||||||
|
}{Kept: "default", Changed: "default", Added: "default"},
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Settings.Kept != "main" || result.Config.Settings.Changed != "profile" || result.Config.Settings.Added != "imported" {
|
||||||
|
t.Fatalf("settings = %#v", result.Config.Settings)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(result.Config.Values, []string{"profile"}) || result.Config.Nullable != nil || result.Config.Value != "scalar" {
|
||||||
|
t.Fatalf("profile values = values %#v nullable %#v value %#v", result.Config.Values, result.Config.Nullable, result.Config.Value)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, basePath), canonicalTestPath(t, profilePath)}
|
||||||
|
gotPaths := make([]string, len(sources))
|
||||||
|
for index, source := range sources {
|
||||||
|
gotPaths[index] = source.Path
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(gotPaths, wantPaths) || sources[2].Layer != LayerProfile {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
changed, _ := result.Metadata().Lookup(Keys("settings", "changed"))
|
||||||
|
kept, _ := result.Metadata().Lookup(Keys("settings", "kept"))
|
||||||
|
if changed.Origin.Layer != LayerProfile || changed.Origin.Locations[0].SourceID != "source-3" || kept.Origin.Layer != LayerBase {
|
||||||
|
t.Fatalf("origins = changed %#v kept %#v", changed, kept)
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata().Lookup(Keys("value", "child")); ok {
|
||||||
|
t.Fatal("removed child remains effective")
|
||||||
|
}
|
||||||
|
childHistory := result.Metadata().History(Keys("value", "child"))
|
||||||
|
if len(childHistory) < 2 || childHistory[len(childHistory)-1].Path.String() != "/value" || childHistory[len(childHistory)-1].Origin.Layer != LayerProfile {
|
||||||
|
t.Fatalf("removed child history = %#v", childHistory)
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
|
||||||
|
t.Fatal("removed sequence index remains effective")
|
||||||
|
}
|
||||||
|
indexHistory := result.Metadata().History(Keys("values").Index(1))
|
||||||
|
if len(indexHistory) < 2 || indexHistory[len(indexHistory)-1].Path.String() != "/values" {
|
||||||
|
t.Fatalf("removed sequence history = %#v", indexHistory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n")
|
||||||
|
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
|
||||||
|
writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n")
|
||||||
|
|
||||||
|
for _, name := range []string{"", "default"} {
|
||||||
|
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name})
|
||||||
|
if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 {
|
||||||
|
t.Fatalf("inactive profile %q = %#v, %v", name, result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selected, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"})
|
||||||
|
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 {
|
||||||
|
t.Fatalf("selected profile = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"../testing", "testing.yml", "one,two", "with space", "é", "bad\x00name"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: name}, CodeOptionsInvalid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "missing"}, CodeProfileMissing)
|
||||||
|
writeImportFile(t, directory, "profiles/fallback.yaml", "settings: {changed: fallback}\n")
|
||||||
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "fallback"}, CodeProfileMissing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||||
|
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
|
||||||
|
if !version.Present || version.Text != "v1" {
|
||||||
|
return errors.New("unexpected version")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
profile string
|
||||||
|
code Code
|
||||||
|
}{
|
||||||
|
{name: "root directive", profile: "_configkit: {}\n", code: CodeProfileDirective},
|
||||||
|
{name: "nested directive", profile: "settings: {_configkit: {}}\n", code: CodeProfileDirective},
|
||||||
|
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
|
||||||
|
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
|
||||||
|
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
|
||||||
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("mapping sibling is allowed", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
|
||||||
|
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
|
||||||
|
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
|
||||||
|
if err != nil || result.Config.Schema["other"] != "allowed" {
|
||||||
|
t.Fatalf("mapping sibling = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("profile aliases imported base", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: profiles/testing.yml}]}\n")
|
||||||
|
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
|
||||||
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
|
||||||
|
t.Run("base parse precedes profile", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "settings: [\n")
|
||||||
|
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: profile}\n")
|
||||||
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("profile parse", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
|
||||||
|
writeImportFile(t, directory, "profiles/testing.yml", "settings: [\n")
|
||||||
|
loadError := requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
|
||||||
|
if loadError.Diagnostics()[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 {
|
||||||
|
t.Fatalf("profile parse attribution = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
|
||||||
|
t.Run("symlink escape", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
outsideProfile := writeImportFile(t, outside, "testing.yml", "settings: {changed: outside}\n")
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "profiles"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outsideProfile, filepath.Join(root, "profiles/testing.yml")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
mainPath := writeImportFile(t, root, "main.yml", "{}\n")
|
||||||
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeProfileOutsideRoot)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("byte source context", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
|
||||||
|
result, err := Load[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{
|
||||||
|
Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Settings.Changed != "selected" {
|
||||||
|
t.Fatalf("byte profile = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
requireProfileLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[profileTestConfig]{Profile: "testing"}, CodeOptionsInvalid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
limits func(parsedSource, int) importLimits
|
||||||
|
}{
|
||||||
|
{name: "source count", limits: func(_ parsedSource, _ int) importLimits {
|
||||||
|
return importLimits{sources: 1, bytes: maxTotalBytes, nodes: maxDocumentNodes}
|
||||||
|
}},
|
||||||
|
{name: "aggregate bytes", limits: func(main parsedSource, profileBytes int) importLimits {
|
||||||
|
return importLimits{sources: 2, bytes: main.bytes + int64(profileBytes) - 1, nodes: maxDocumentNodes}
|
||||||
|
}},
|
||||||
|
{name: "aggregate nodes", limits: func(main parsedSource, _ int) importLimits {
|
||||||
|
return importLimits{sources: 2, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
|
||||||
|
profileData := "settings: {changed: selected}\n"
|
||||||
|
writeImportFile(t, directory, "profiles/testing.yml", profileData)
|
||||||
|
main, diagnostics := parseSource(File(mainPath), "source-1", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
base, diagnostics := composeDocuments(main.document)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
base.inputBytes = main.bytes
|
||||||
|
base.inputNodes = countInputNodes(main.document)
|
||||||
|
_, _, diagnostics = loadProfileDocument(File(mainPath), nil, main, base, sourceInfos(main.source), "testing", nil, test.limits(main, len(profileData)))
|
||||||
|
if len(diagnostics) == 0 || diagnostics[0].Code != CodeProfileLimitExceeded {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError {
|
||||||
|
t.Helper()
|
||||||
|
result, err := Load[profileTestConfig](source, options)
|
||||||
|
if result != nil || err == nil {
|
||||||
|
t.Fatalf("result, error = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("error type = %T", err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) == 0 || diagnostics[0].Code != code {
|
||||||
|
t.Fatalf("diagnostics = %#v, want %s", diagnostics, code)
|
||||||
|
}
|
||||||
|
return loadError
|
||||||
|
}
|
||||||
@@ -87,6 +87,14 @@ const (
|
|||||||
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
||||||
CodeImportVersion Code = "import.version"
|
CodeImportVersion Code = "import.version"
|
||||||
|
|
||||||
|
CodeProfileInvalid Code = "profile.invalid"
|
||||||
|
CodeProfileMissing Code = "profile.missing"
|
||||||
|
CodeProfileOutsideRoot Code = "profile.outside_root"
|
||||||
|
CodeProfileRepeated Code = "profile.repeated"
|
||||||
|
CodeProfileDirective Code = "profile.directive"
|
||||||
|
CodeProfileVersion Code = "profile.version"
|
||||||
|
CodeProfileLimitExceeded Code = "profile.limit_exceeded"
|
||||||
|
|
||||||
CodeComposeInvalid Code = "compose.invalid"
|
CodeComposeInvalid Code = "compose.invalid"
|
||||||
CodeComposeConflict Code = "compose.conflict"
|
CodeComposeConflict Code = "compose.conflict"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user