Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0adb94f59e | |||
| ee2bcbeed5 | |||
| 3883a5042d | |||
| c034b7e709 | |||
| a803799502 | |||
| 01b437e6ed | |||
| e6ced0d3d1 | |||
| 73ce6e9e31 | |||
| ae91f57bbe | |||
| 66e080ed34 | |||
| d8d9dbfe7f | |||
| aead20fae4 | |||
| c5a2ddee7a | |||
| 418fefdeef | |||
| 21ae2159f7 | |||
| b9425c9ae2 | |||
| fbe7b61c79 |
3
.codebase-memory/.gitattributes
vendored
3
.codebase-memory/.gitattributes
vendored
@@ -1,3 +0,0 @@
|
|||||||
# Auto-generated by codebase-memory-mcp
|
|
||||||
# Prevent merge conflicts on compressed artifact
|
|
||||||
graph.db.zst merge=ours binary
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 2,
|
|
||||||
"commit": "88e86de03ebd87038b0c98b164865f2c3b82a94c",
|
|
||||||
"indexed_at": "2026-09-12T18:30:58Z",
|
|
||||||
"project": "home-eric-Workspace-ConfigKit",
|
|
||||||
"nodes": 1641,
|
|
||||||
"edges": 7961,
|
|
||||||
"original_size": 8126464,
|
|
||||||
"compressed_size": 1350795,
|
|
||||||
"compression_level": 3
|
|
||||||
}
|
|
||||||
Binary file not shown.
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,6 +25,9 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Local codebase-memory tooling artifacts
|
||||||
|
/.codebase-memory/
|
||||||
|
|
||||||
# ---> Linux
|
# ---> Linux
|
||||||
*~
|
*~
|
||||||
|
|
||||||
@@ -81,4 +84,3 @@ Temporary Items
|
|||||||
.history/
|
.history/
|
||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
|
||||||
|
|||||||
34
AGENTS.md
34
AGENTS.md
@@ -1,35 +1,5 @@
|
|||||||
Please review `docs/development.md` for initial orientation in this repository
|
Please review `docs/development.md` for initial orientation in this repository
|
||||||
and follow its task-specific reading guide.
|
and follow its task-specific reading guide.
|
||||||
|
|
||||||
## Implementation Discipline
|
Review and follow `docs/policy/agents.md` before recommending, planning, or
|
||||||
|
implementing any changes to this repository.
|
||||||
Simplify the mechanism, not the user's choices.
|
|
||||||
|
|
||||||
**Keep implementation machinery proportionate.** Use straightforward,
|
|
||||||
idiomatic code and introduce only the machinery needed to support the requested
|
|
||||||
behavior and flexibility. Optimize for conceptual simplicity and
|
|
||||||
maintainability, not for the fewest lines, fewest options, or narrowest product
|
|
||||||
surface.
|
|
||||||
|
|
||||||
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
|
|
||||||
harden, or otherwise narrow behavior unless required by the user, an explicit
|
|
||||||
repository contract, or an unavoidable correctness constraint. Preserve
|
|
||||||
operator and caller choice where policy has not been established. If an
|
|
||||||
additional restriction appears advisable, present it as a decision rather than
|
|
||||||
silently implementing it.
|
|
||||||
|
|
||||||
A correctness constraint must follow from the requested behavior or an
|
|
||||||
established invariant, not from the convenience of the chosen implementation.
|
|
||||||
Explain the specific failure it prevents and why a less restrictive
|
|
||||||
implementation cannot satisfy the contract.
|
|
||||||
|
|
||||||
**An implementation detail is not repository policy.** Tests and documentation
|
|
||||||
must not elevate incidental behavior into a durable guarantee or restriction
|
|
||||||
unless that behavior was expressly selected as part of the contract. Existing
|
|
||||||
explicit contracts remain authoritative; changing or weakening one requires a
|
|
||||||
deliberate decision rather than being inferred from this principle.
|
|
||||||
|
|
||||||
Agent-authored plans, tests, documentation, and prior implementations are not
|
|
||||||
evidence that an additional restriction was deliberately approved. Identify the
|
|
||||||
authorizing requirement or decision; when that authority is unclear, surface
|
|
||||||
the ambiguity rather than treating repetition as approval.
|
|
||||||
|
|||||||
20
COPYRIGHT
Normal file
20
COPYRIGHT
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
ConfigKit
|
||||||
|
|
||||||
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
|
This library is free software: you can redistribute it and/or modify it
|
||||||
|
under the terms of the GNU Lesser General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
The GNU Lesser General Public License version 3 and its incorporated
|
||||||
|
GNU General Public License version 3 text are provided in LICENSE.
|
||||||
|
|
||||||
|
This library is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Lesser General Public License for more details.
|
||||||
|
|
||||||
|
Third-party code and assets are subject to their respective license terms.
|
||||||
@@ -8,14 +8,14 @@ Linux is release-validated; macOS support is best-effort. See the
|
|||||||
ConfigKit's initial loader accepts one strict YAML
|
ConfigKit's initial 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 overlay one selected fixed profile and check an application schema version. It
|
can overlay one selected fixed overlay and check an application schema version. It
|
||||||
returns detached metadata and safe diagnostics.
|
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. Secret
|
then report domain errors and warnings through read-only semantic validation. Secret
|
||||||
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
||||||
references resolve environment names before validation, and explicit
|
references resolve environment names before validation, and explicit
|
||||||
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
||||||
CLI override records apply above profiles. Value explanation is available through
|
CLI override records apply above overlays. Value explanation is available through
|
||||||
detached structured records and deterministic redacted output.
|
detached structured records and deterministic redacted output.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -40,3 +40,7 @@ The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. Se
|
|||||||
[development guide](docs/development.md) for repository orientation and the
|
[development guide](docs/development.md) for repository orientation and the
|
||||||
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
||||||
steps.
|
steps.
|
||||||
|
|
||||||
|
ConfigKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||||
|
|
||||||
|
[Open Source attribution notices](docs/oss/attribution.md) are available.
|
||||||
|
|||||||
2
doc.go
2
doc.go
@@ -2,7 +2,7 @@
|
|||||||
// loading for Go programs.
|
// loading for Go programs.
|
||||||
//
|
//
|
||||||
// Load supports file, byte, reader, and explicit no-input sources with strict YAML,
|
// Load supports file, byte, reader, and explicit no-input sources with strict YAML,
|
||||||
// typed defaults, imports, profiles, CLI overrides, schema-version checks, detached
|
// typed defaults, imports, overlays, CLI overrides, schema-version checks, detached
|
||||||
// metadata, recorded application resolution, typed secret references, safe effective
|
// metadata, recorded application resolution, typed secret references, safe effective
|
||||||
// rendering, read-only semantic validation, and structured load errors. The
|
// rendering, read-only semantic validation, and structured load errors. The
|
||||||
// configuration contract is documented in docs/config.md.
|
// configuration contract is documented in docs/config.md.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ The zero `Source` is invalid. Constructor argument failures are retained inside
|
|||||||
opaque source and become safe structured diagnostics when the source is consumed.
|
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. A load using imports or an active profile is limited
|
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
|
||||||
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
||||||
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
||||||
1,000,000 visited directory entries, including nonmatches. Reads request at most the
|
1,000,000 visited directory entries, including nonmatches. Reads request at most the
|
||||||
@@ -33,7 +33,7 @@ truncated. Data returned together with a read error is not accepted as partial i
|
|||||||
root `T` must satisfy the application schema contract below. With no options it
|
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, optional
|
runs exactly once after successful source parsing, schema construction, optional
|
||||||
schema-version checking, and selected-profile loading. Its result is detached before
|
schema-version checking, and selected-overlay loading. Its result is detached before
|
||||||
input is applied. Resolver-provided mutable values are detached before secret lookup
|
input is applied. Resolver-provided mutable values are detached before secret lookup
|
||||||
and validation, and a final detached copy prevents equal-valued aliases introduced
|
and validation, and a final detached copy prevents equal-valued aliases introduced
|
||||||
by validators from escaping into the returned configuration. The publication copy
|
by validators from escaping into the returned configuration. The publication copy
|
||||||
@@ -43,7 +43,7 @@ The current lifecycle is:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
validate options → parse the selected source → traverse/compose enabled imports
|
validate options → parse the selected source → traverse/compose enabled imports
|
||||||
→ build schema → check the optional application version → apply a selected profile
|
→ build schema → check the optional application version → apply a selected overlay
|
||||||
→ apply explicit CLI overrides → create and clone defaults → strictly decode
|
→ apply explicit CLI overrides → create and clone defaults → strictly decode
|
||||||
→ recorded application resolution
|
→ recorded application resolution
|
||||||
→ resolve typed secret references → semantic validation → classify secrets
|
→ resolve typed secret references → semantic validation → classify secrets
|
||||||
@@ -57,9 +57,9 @@ contributes no source, and continues through defaults, decode, and projection. A
|
|||||||
missing file remains an error; it is never interpreted as no input.
|
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, one selected profile, and
|
semantic validator, explicit path and glob imports, one selected overlay, and
|
||||||
filesystem context. It also accepts explicit CLI override records, static secret
|
filesystem context. It also accepts explicit CLI override records, static secret
|
||||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
|
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays,
|
||||||
and filesystem context as invalid combinations but accepts overrides. Input
|
and filesystem context as invalid combinations but accepts overrides. Input
|
||||||
containing a reserved `configkit` mapping key is rejected while imports are
|
containing a reserved `configkit` mapping key is rejected while imports are
|
||||||
disabled.
|
disabled.
|
||||||
@@ -93,7 +93,7 @@ This is a load-local exception: siblings and unrelated fields, dynamic values,
|
|||||||
integer and float destinations, durations, `Secret`, and text codecs retain ordinary
|
integer and float destinations, durations, `Secret`, and text codecs retain ordinary
|
||||||
strict decoding. The retained source and history stay numeric while typed projection
|
strict decoding. The retained source and history stay numeric while typed projection
|
||||||
and effective YAML contain the resulting string; explanation records this through
|
and effective YAML contain the resulting string; explanation records this through
|
||||||
the normal decoding reconciliation event. Fragment, profile, and CLI ownership
|
the normal decoding reconciliation event. Fragment, overlay, and CLI ownership
|
||||||
protections apply before this conversion.
|
protections apply before this conversion.
|
||||||
|
|
||||||
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
||||||
@@ -109,11 +109,11 @@ successful result. Callback panics remain programming failures.
|
|||||||
key/index distinctions using actual effective and historical container shapes;
|
key/index distinctions using actual effective and historical container shapes;
|
||||||
only parsed numeric tokens are resolved contextually. A descendant that never
|
only parsed numeric tokens are resolved contextually. A descendant that never
|
||||||
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
|
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
|
||||||
are explicit and have a base or profile origin; omitted paths have a default origin.
|
are explicit and have a base or overlay origin; omitted paths have a default origin.
|
||||||
An explicit parent mapping does not make omitted children explicit. Null state and
|
An explicit parent mapping does not make omitted children explicit. Null state and
|
||||||
available source coordinates are retained. `History` records the Go-zero or application
|
available source coordinates are retained. `History` records the Go-zero or application
|
||||||
default baseline followed by exact base or profile definitions, including
|
default baseline followed by exact base or overlay definitions, including
|
||||||
equal-valued input. Profile replacement removes displaced descendants from effective
|
equal-valued input. Overlay replacement removes displaced descendants from effective
|
||||||
metadata while retaining their base definition and the parent replacement operation
|
metadata while retaining their base definition and the parent replacement operation
|
||||||
in history. All metadata queries and returned nested records are detached.
|
in history. All metadata queries and returned nested records are detached.
|
||||||
|
|
||||||
@@ -125,21 +125,21 @@ documentation in [metadata.go](../metadata.go) defines enumeration and path-orde
|
|||||||
details.
|
details.
|
||||||
|
|
||||||
History is chronological: a path can have a synthetic default record, its exact base
|
History is chronological: a path can have a synthetic default record, its exact base
|
||||||
definition, a profile replacement, and a recorded resolution operation. A descendant
|
definition, an overlay replacement, and a recorded resolution operation. A descendant
|
||||||
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
||||||
the descendant can therefore retain history after `Lookup` stops reporting it as
|
the descendant can therefore retain history after `Lookup` stops reporting it as
|
||||||
effective. Equal-valued input is still an explicit base definition, while a derived
|
effective. Equal-valued input is still an explicit base definition, while a derived
|
||||||
value for absent input remains non-explicit.
|
value for absent input remains non-explicit.
|
||||||
|
|
||||||
A mapping assembled from the selected file and imports has one composite base origin
|
A mapping assembled from the selected file and imports has one composite base origin
|
||||||
whose locations retain source traversal order. A mapping also visited by a profile
|
whose locations retain source traversal order. A mapping also visited by an overlay
|
||||||
has a profile-layer composite origin with the lower contributing locations followed
|
has an overlay-layer composite origin with the lower contributing locations followed
|
||||||
by the profile location; an empty mapping is still an explicit container even though
|
by the overlay location; an empty mapping is still an explicit container even though
|
||||||
it contributes no children. Resolution origins are synthetic: they have layer
|
it contributes no children. Resolution origins are synthetic: they have layer
|
||||||
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
||||||
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
||||||
removed. Source records remain ordered as selected source, depth-first imports, the
|
removed. Source records remain ordered as selected source, depth-first imports, the
|
||||||
selected profile, then synthetic CLI records in supplied order.
|
selected overlay, then synthetic CLI records in supplied order.
|
||||||
|
|
||||||
### Secret classification and effective rendering
|
### Secret classification and effective rendering
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ historical path. Its effective value and each chronological before/after value a
|
|||||||
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
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
|
null is present and renders as `null`. The history starts with the canonical typed
|
||||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||||
traversal order, the selected profile, supplied CLI records, and audited net
|
traversal order, the selected overlay, supplied CLI records, and audited net
|
||||||
resolution operations. Overlapping parent/child resolution records are rendered
|
resolution operations. Overlapping parent/child resolution records are rendered
|
||||||
as one net subtree event with combined reasons and dependencies, rather than
|
as one net subtree event with combined reasons and dependencies, rather than
|
||||||
pretending that the recorded assignments ran in lexical order. Explicit equal
|
pretending that the recorded assignments ran in lexical order. Explicit equal
|
||||||
@@ -199,7 +199,7 @@ before domain resolution. Its origin retains the decoded path's source/default
|
|||||||
attribution; its reason identifies static-default retention and canonical decoding.
|
attribution; its reason identifies static-default retention and canonical decoding.
|
||||||
This built-in inspection operation is not accepted by `Resolution.Record`.
|
This built-in inspection operation is not accepted by `Resolution.Record`.
|
||||||
|
|
||||||
Base, profile, and CLI history preserves the policy-validated input representation,
|
Base, overlay, and CLI history preserves the policy-validated input representation,
|
||||||
including a superseded value that could not have decoded into the final Go field.
|
including a superseded value that could not have decoded into the final Go field.
|
||||||
Baseline, effective, and resolution values use canonical typed projection. A
|
Baseline, effective, and resolution values use canonical typed projection. A
|
||||||
decoding event bridges replayed input and canonical typed values, so intermediate
|
decoding event bridges replayed input and canonical typed values, so intermediate
|
||||||
@@ -293,10 +293,13 @@ operation once before starting concurrent work. The selected path may itself be
|
|||||||
symlink; ConfigKit resolves it to an existing canonical directory before examining
|
symlink; ConfigKit resolves it to an existing canonical directory before examining
|
||||||
its immediate entries.
|
its immediate entries.
|
||||||
|
|
||||||
The bootstrap enumerates at most 1,024 entries and sorts them by filename. Every
|
The bootstrap enumerates at most 1,024 entries, including dot-prefixed entries.
|
||||||
entry must have an ASCII environment-variable name and be a regular, nonsymlink
|
Entries whose names begin with `.` (files, directories, or symlinks, such as `.git`
|
||||||
file. Metadata for all entries is validated before file content is read or process
|
and `.gitignore`) are ignored before name or metadata validation, are never opened,
|
||||||
state is changed. Existing environment variables win, including variables whose
|
and are omitted from the report. Remaining entries are sorted by filename; each
|
||||||
|
must have an ASCII environment-variable name and be a regular, nonsymlink
|
||||||
|
file. Metadata for all remaining entries is validated before file content is read
|
||||||
|
or process state is changed. Existing environment variables win, including variables whose
|
||||||
value is empty; their files are not opened. Each eligible file is limited to 1 MiB,
|
value is empty; their files are not opened. Each eligible file is limited to 1 MiB,
|
||||||
and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one
|
and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one
|
||||||
terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and
|
terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and
|
||||||
@@ -335,7 +338,7 @@ found entry path. Resolution changes, additions, removals, and moves do not alte
|
|||||||
this entry view.
|
this entry view.
|
||||||
|
|
||||||
`Resolution.Source(id)` returns a detached record for an already acquired source in
|
`Resolution.Source(id)` returns a detached record for an already acquired source in
|
||||||
the same load: the main source, imports, selected profile, and supplied CLI records
|
the same load: the main source, imports, selected overlay, and supplied CLI records
|
||||||
where applicable. Empty and unknown IDs return false. It never reloads input, reads
|
where applicable. Empty and unknown IDs return false. It never reloads input, reads
|
||||||
the filesystem, resolves symlinks, or looks up credentials. A source path exists
|
the filesystem, resolves symlinks, or looks up credentials. A source path exists
|
||||||
only when the acquired source has one; defaults and byte, reader, and CLI sources do
|
only when the acquired source has one; defaults and byte, reader, and CLI sources do
|
||||||
@@ -433,41 +436,41 @@ traversal order. Traversal order never authorizes replacement. Composition clone
|
|||||||
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
||||||
input document.
|
input document.
|
||||||
|
|
||||||
## Selected profiles
|
## Selected overlays
|
||||||
|
|
||||||
`Options.Profile` selects at most one overlay by name. Names must match
|
`Options.Overlay` selects at most one overlay by name. Names must match
|
||||||
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
|
`[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.
|
`default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
|
||||||
Every other name resolves only to `<configuration-root>/profiles/<name>.yml`, with
|
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
|
||||||
no extension fallback, case normalization, inheritance, or profile stack. A selected
|
no extension fallback, case normalization, inheritance, or overlay stack. A selected
|
||||||
missing profile is an error.
|
missing overlay is an error.
|
||||||
|
|
||||||
The profile is a separately policy-validated YAML mapping. It is loaded after the
|
The overlay is a separately policy-validated YAML mapping. It is loaded after the
|
||||||
base import graph is composed and its schema version is checked, but before defaults
|
base import graph is composed and its schema version is checked, but before defaults
|
||||||
and typed decoding. Profile bytes, nodes, and source records count toward the same
|
and typed decoding. Overlay bytes, nodes, and source records count toward the same
|
||||||
aggregate limits as base inputs. Its canonical path must remain within the configured
|
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.
|
root and must not duplicate a file already present in the base graph.
|
||||||
|
|
||||||
Overlay is intentionally distinct from strict composition:
|
Overlay is intentionally distinct from strict composition:
|
||||||
|
|
||||||
| Existing lower node | Incoming profile node | Result |
|
| Existing lower node | Incoming overlay node | Result |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Absent | Value | Retain the profile value |
|
| Absent | Value | Retain the overlay value |
|
||||||
| Value | Absent | Retain the lower value |
|
| Value | Absent | Retain the lower value |
|
||||||
| Mapping | Mapping | Recurse by mapping key |
|
| Mapping | Mapping | Recurse by mapping key |
|
||||||
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
|
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
|
||||||
|
|
||||||
Scalars, sequences, and null are atomic. Thus a profile sequence replaces rather
|
Scalars, sequences, and null are atomic. Thus an overlay sequence replaces rather
|
||||||
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
|
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.
|
cross-kind replacement is allowed when the final application schema accepts it.
|
||||||
Replacing a subtree removes all displaced descendants from effective presence and
|
Replacing a subtree removes all displaced descendants from effective presence and
|
||||||
origin data; history retains the displaced definitions and identifies the parent
|
origin data; history retains the displaced definitions and identifies the parent
|
||||||
replacement. Surviving profile paths have profile origins, while recursively shared
|
replacement. Surviving overlay paths have overlay origins, while recursively shared
|
||||||
mapping containers retain their contributing locations.
|
mapping containers retain their contributing locations.
|
||||||
|
|
||||||
A profile may not contain `configkit` anywhere, contribute the configured schema
|
An overlay may not contain `configkit` anywhere, contribute the configured schema
|
||||||
version, or replace an ancestor of that version with a scalar or null. These rules
|
version, or replace an ancestor of that version with a scalar or null. These rules
|
||||||
apply even if the supplied version text would be unchanged. Profiles cannot mask a
|
apply even if the supplied version text would be unchanged. Overlays cannot mask a
|
||||||
base parse, composition, or version failure because those checks run first.
|
base parse, composition, or version failure because those checks run first.
|
||||||
|
|
||||||
## Explicit CLI overrides
|
## Explicit CLI overrides
|
||||||
@@ -480,8 +483,8 @@ must never contain the raw argument or configured value. General environment
|
|||||||
variable overlays are an explicit non-goal: environment access is reserved for
|
variable overlays are an explicit non-goal: environment access is reserved for
|
||||||
typed secret references.
|
typed secret references.
|
||||||
|
|
||||||
Overrides apply after the selected profile and before typed defaults/decoding, so
|
Overrides apply after the selected overlay and before typed defaults/decoding, so
|
||||||
the input precedence is application defaults < composed base < selected profile <
|
the input precedence is application defaults < composed base < selected overlay <
|
||||||
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
|
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
|
||||||
effective override tree. Overrides also work with `NoSource`.
|
effective override tree. Overrides also work with `NoSource`.
|
||||||
|
|
||||||
@@ -503,7 +506,7 @@ configured schema-version path. Unknown fields and effective type mismatches rem
|
|||||||
ordinary strict-decoder errors.
|
ordinary strict-decoder errors.
|
||||||
|
|
||||||
At most 1,024 records are accepted, and each record creates one synthetic CLI
|
At most 1,024 records are accepted, and each record creates one synthetic CLI
|
||||||
source after file/profile sources in supplied order. Override sources share the
|
source after file/overlay sources in supplied order. Override sources share the
|
||||||
1,024-source ceiling. A record has an 8 MiB string-byte limit; override strings and
|
1,024-source ceiling. A record has an 8 MiB string-byte limit; override strings and
|
||||||
source bytes share the 64 MiB aggregate limit. Synthetic mapping keys and values
|
source bytes share the 64 MiB aggregate limit. Synthetic mapping keys and values
|
||||||
count toward the existing 1,000,000-node and depth-256 ceilings. Cycles, named or
|
count toward the existing 1,000,000-node and depth-256 ceilings. Cycles, named or
|
||||||
@@ -511,7 +514,7 @@ unsupported types, nonfinite floats, and limit excesses fail with safe `override
|
|||||||
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
|
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
|
||||||
arguments or values.
|
arguments or values.
|
||||||
|
|
||||||
Override origins are explicit CLI origins. History retains replaced base/profile
|
Override origins are explicit CLI origins. History retains replaced base/overlay
|
||||||
definitions and records equal-value replacements as well as structural changes.
|
definitions and records equal-value replacements as well as structural changes.
|
||||||
Caller-owned maps and slices are snapshotted during loading and must not be mutated
|
Caller-owned maps and slices are snapshotted during loading and must not be mutated
|
||||||
concurrently with the call. The executable `ExampleOverride` shows translating a
|
concurrently with the call. The executable `ExampleOverride` shows translating a
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Consumer Adoption Guide
|
# Consumer Adoption Guide
|
||||||
|
|
||||||
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and
|
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and
|
||||||
profile traversal, precedence, provenance, secret lookup, and safe inspection from
|
selected-overlay traversal, precedence, provenance, secret lookup, and safe inspection from
|
||||||
applications. Consumers still own their configuration structs, defaults, semantic
|
applications. Consumers still own their configuration structs, defaults, semantic
|
||||||
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
||||||
resource checks, remote acquisition, and service initialization.
|
resource checks, remote acquisition, and service initialization.
|
||||||
@@ -34,7 +34,7 @@ features appear only when the application needs them:
|
|||||||
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{
|
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{
|
||||||
Definition: rules,
|
Definition: rules,
|
||||||
Imports: useDeclaredImports,
|
Imports: useDeclaredImports,
|
||||||
Profile: selectedConfigProfile,
|
Overlay: selectedConfigOverlay,
|
||||||
Overrides: explicitlySetFlags,
|
Overrides: explicitlySetFlags,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,8 +43,8 @@ if err != nil {
|
|||||||
return start(result.Config)
|
return start(result.Config)
|
||||||
```
|
```
|
||||||
|
|
||||||
Simple consumers use neither imports nor profiles. A complex consumer can opt into
|
Simple consumers use neither imports nor selected overlays. A complex consumer can opt into
|
||||||
both without redefining their syntax. ConfigKit profiles are configuration overlays;
|
both without redefining their syntax. ConfigKit overlays are configuration overlays;
|
||||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||||
execution profile concepts.
|
execution profile concepts.
|
||||||
|
|
||||||
@@ -100,6 +100,16 @@ The examples preserve domain meaning while adopting one configuration convention
|
|||||||
blocked empty variables remain blocked, and ordinary `Load` never changes the
|
blocked empty variables remain blocked, and ordinary `Load` never changes the
|
||||||
process environment.
|
process environment.
|
||||||
|
|
||||||
|
### Selected-overlay migration
|
||||||
|
|
||||||
|
To adopt the current selected-overlay API, rename `Options.Profile`,
|
||||||
|
`LayerProfile`, and `CodeProfile*` references to their `Overlay` equivalents; move
|
||||||
|
selected files from `profiles/<name>.yml` to `overlays/<name>.yml`; update any
|
||||||
|
handling of the `overlay` layer and `overlay.*` diagnostic strings; then rebuild.
|
||||||
|
The [configuration contract](../config.md#selected-overlays) is the canonical
|
||||||
|
behavioral reference. Application-owned profile concepts, such as `model_profile`,
|
||||||
|
are unchanged, and any CLI flag rename remains the application's decision.
|
||||||
|
|
||||||
These examples are migration evidence, not completed changes to Narratio, Notarius,
|
These examples are migration evidence, not completed changes to Narratio, Notarius,
|
||||||
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
|
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
|
||||||
wiring, runtime catalogs, and operational documentation remains separate downstream
|
wiring, runtime catalogs, and operational documentation remains separate downstream
|
||||||
@@ -113,7 +123,7 @@ focused behavioral owner:
|
|||||||
| Roadmap surface | Maintained owner |
|
| Roadmap surface | Maintained owner |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
|
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
|
||||||
| Source/import/profile pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
|
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
|
||||||
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
||||||
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
||||||
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
|
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ public behavior.
|
|||||||
## Initial orientation
|
## Initial orientation
|
||||||
|
|
||||||
1. Inspect the working tree and preserve unrelated changes.
|
1. Inspect the working tree and preserve unrelated changes.
|
||||||
|
Agents must read and follow the [agent policy](policy/agents.md) before
|
||||||
|
recommending, planning, or implementing changes.
|
||||||
2. Read the architecture policy for code or design work.
|
2. Read the architecture policy for code or design work.
|
||||||
3. Read the policy, contract, and internal documents applicable to the task.
|
3. Read the policy, contract, and internal documents applicable to the task.
|
||||||
4. Inspect the relevant implementation and behavioral tests before changing them.
|
4. Inspect the relevant implementation and behavioral tests before changing them.
|
||||||
@@ -18,6 +20,7 @@ public behavior.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Repository orientation or component responsibility | [Architecture policy](policy/architecture.md) and [component overview](internal/overview.md) |
|
| Repository orientation or component responsibility | [Architecture policy](policy/architecture.md) and [component overview](internal/overview.md) |
|
||||||
| Documentation changes | [Documentation policy](policy/documentation.md) and the owning document |
|
| Documentation changes | [Documentation policy](policy/documentation.md) and the owning document |
|
||||||
|
| Licensing, dependencies, attribution, or release-document changes | [OSS policy](policy/oss.md), [project grant](../COPYRIGHT), and [attribution](oss/attribution.md); release changes also follow the release policy and procedure |
|
||||||
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [deferred scope](roadmap/deferred.md), and relevant ADRs listed below |
|
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [deferred scope](roadmap/deferred.md), and relevant ADRs listed below |
|
||||||
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and the owning contract and tests from the map below |
|
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and the owning contract and tests from the map below |
|
||||||
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
||||||
@@ -56,6 +59,11 @@ and current facade ownership belongs to the [component overview](internal/overvi
|
|||||||
|
|
||||||
## Baseline validation
|
## Baseline validation
|
||||||
|
|
||||||
|
The completed overlay rename plan and release-workflow simplification record are
|
||||||
|
preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
|
||||||
|
behavior belongs to the [configuration contract](config.md#selected-overlays),
|
||||||
|
and release operations belong to the [release procedure](release.md).
|
||||||
|
|
||||||
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
||||||
dependencies explicitly before entering an offline development or validation
|
dependencies explicitly before entering an offline development or validation
|
||||||
environment:
|
environment:
|
||||||
@@ -88,8 +96,10 @@ Use focused checks while iterating, with validation proportionate to the change
|
|||||||
under the testing policy. Documentation-only work normally needs the local-link
|
under the testing policy. Documentation-only work normally needs the local-link
|
||||||
check below and whitespace review; behavior-bearing examples and commands also
|
check below and whitespace review; behavior-bearing examples and commands also
|
||||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
||||||
The separate [release checker](release.md#4-validate-the-source-candidate) adds
|
The [release checker](release.md#4-validate-the-source-candidate) performs one
|
||||||
two-toolchain, module-hygiene, race, and cross-build gates.
|
minimum-toolchain race-suite run, vet, module hygiene, and Linux cross-builds.
|
||||||
|
It already covers examples, links, and package boundaries; do not additionally
|
||||||
|
run the everyday checker just to repeat those checks. CI never gates a release.
|
||||||
|
|
||||||
## Task-to-test map
|
## Task-to-test map
|
||||||
|
|
||||||
@@ -101,7 +111,7 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
||||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||||
| Composition, profiles, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [profiles](../internal/engine/profile_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Profile\|Override'` |
|
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||||
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||||
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||||
|
|||||||
@@ -137,9 +137,11 @@ permission failures, explicit `conf.d` use, and entry/candidate budgets. This wo
|
|||||||
has no Narratio production equivalent; it uses the selected standard-library
|
has no Narratio production equivalent; it uses the selected standard-library
|
||||||
`path.Match` and bounded `File.ReadDir` adaptation rather than its flat loader.
|
`path.Match` and bounded `File.ReadDir` adaptation rather than its flat loader.
|
||||||
|
|
||||||
### Profiles and overlay
|
### Upstream profiles and ConfigKit selected overlays
|
||||||
|
|
||||||
Assigned to selected-profile overlay:
|
The following upstream symbols and case names retain their original profile
|
||||||
|
terminology. ConfigKit adapts their selected-overlay behavior without renaming the
|
||||||
|
upstream evidence:
|
||||||
|
|
||||||
- Production: `mergeOverlayComposition`, `mergeOverlayNodes`,
|
- Production: `mergeOverlayComposition`, `mergeOverlayNodes`,
|
||||||
`selectPipelineProfile`, `loadPipelineProfileOverlays`, and
|
`selectPipelineProfile`, `loadPipelineProfileOverlays`, and
|
||||||
@@ -153,19 +155,21 @@ Assigned to selected-profile overlay:
|
|||||||
`TestLoadPipelineProfilePairResolvesIndependentEffectivePipelines`.
|
`TestLoadPipelineProfilePairResolvesIndependentEffectivePipelines`.
|
||||||
- Preserve: recursive mapping overlay, atomic scalar/sequence replacement,
|
- Preserve: recursive mapping overlay, atomic scalar/sequence replacement,
|
||||||
deterministic selection failures, source ownership, and independent results.
|
deterministic selection failures, source ownership, and independent results.
|
||||||
- Adapt: load only the selected fixed profile path; lowercase `default` means no
|
- Adapt: load only the selected fixed overlay path; lowercase `default` means no
|
||||||
overlay. ConfigKit permits null and cross-kind replacement and does not support
|
selection. ConfigKit permits null and cross-kind replacement and does not support
|
||||||
declared defaults, inheritance, arbitrary profile paths, or validating every
|
declared defaults, inheritance, arbitrary overlay paths, or validating every
|
||||||
unselected overlay.
|
unselected overlay.
|
||||||
|
|
||||||
Implementation record (2026-09-06): `overlayDocuments`, `mergeOverlayMappings`,
|
Implementation record (2026-09-06, updated 2026-09-23): `overlayDocuments`,
|
||||||
`loadProfileDocument`, and `profileDirectiveDiagnostics` adapt the assigned merge,
|
`mergeOverlayMappings`, `loadSelectedOverlayDocument`, and
|
||||||
selection, loading, and ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
|
`overlayDirectiveDiagnostics` adapt the assigned merge, selection, loading, and
|
||||||
`TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults`, profile selection/error,
|
ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
|
||||||
directive/version, containment, and budget cases adapt the assigned tests. ConfigKit
|
`TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults`, selected-overlay
|
||||||
|
selection/error, directive/version, containment, and budget cases adapt the assigned
|
||||||
|
tests. ConfigKit
|
||||||
uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement,
|
uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement,
|
||||||
retains displaced descendant history, rejects any profile directive or version-path
|
retains displaced descendant history, rejects any overlay directive or version-path
|
||||||
contribution, and counts the profile in aggregate budgets. Narratio's declared
|
contribution, and counts the overlay in aggregate budgets. Narratio's declared
|
||||||
default, multi-overlay validation, inheritance-like application conventions, and
|
default, multi-overlay validation, inheritance-like application conventions, and
|
||||||
null/kind restrictions were not retained.
|
null/kind restrictions were not retained.
|
||||||
|
|
||||||
@@ -196,7 +200,7 @@ previously extracted source claims, overlay displacement records, typed baseline
|
|||||||
and audited resolution snapshots in `load_metadata.go`. The representative
|
and audited resolution snapshots in `load_metadata.go`. The representative
|
||||||
`TestLayeredProvenanceAgreesWithResolvedSnapshot` adapts Narratio's stable ownership,
|
`TestLayeredProvenanceAgreesWithResolvedSnapshot` adapts Narratio's stable ownership,
|
||||||
profile ownership, deterministic effective projection, and side-effect-free
|
profile ownership, deterministic effective projection, and side-effect-free
|
||||||
inspection cases across one default/import/profile/resolution workflow. ConfigKit
|
inspection cases across one default/import/overlay/resolution workflow. ConfigKit
|
||||||
adds chronological descendant history for parent and whole-sequence operations,
|
adds chronological descendant history for parent and whole-sequence operations,
|
||||||
composite empty-mapping origins, canonical resolution reasons/dependencies, private
|
composite empty-mapping origins, canonical resolution reasons/dependencies, private
|
||||||
snapshot agreement, defensive nested copies, and concurrent load isolation. Value
|
snapshot agreement, defensive nested copies, and concurrent load isolation. Value
|
||||||
|
|||||||
28
docs/oss/attribution.md
Normal file
28
docs/oss/attribution.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Open Source Attribution
|
||||||
|
|
||||||
|
Component notices follow the [OSS policy](../policy/oss.md). ConfigKit's own
|
||||||
|
grant is in [COPYRIGHT](../../COPYRIGHT); dependency notices do not change it.
|
||||||
|
|
||||||
|
## go-yaml v4
|
||||||
|
|
||||||
|
Version: `go.yaml.in/yaml/v4 v4.0.0-rc.6`.
|
||||||
|
|
||||||
|
Copyright 2011-2019 Canonical Ltd
|
||||||
|
|
||||||
|
Copyright 2025 The go-yaml Project Contributors
|
||||||
|
|
||||||
|
Copyright 2006-2010 Kirill Simonov
|
||||||
|
|
||||||
|
SPDX-License-Identifier: Apache-2.0 AND MIT
|
||||||
|
|
||||||
|
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||||
|
[MIT](licenses/MIT.txt) |
|
||||||
|
[upstream NOTICE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/NOTICE) |
|
||||||
|
[upstream LICENSE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/LICENSE)
|
||||||
|
|
||||||
|
Source: [go-yaml](https://github.com/yaml/go-yaml/tree/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125).
|
||||||
|
|
||||||
|
The upstream NOTICE identifies the libyaml-derived portion as MIT-licensed and
|
||||||
|
the remaining project files as Apache-2.0-licensed; these are not alternative
|
||||||
|
licenses for the entire module. The statements above preserve the NOTICE's
|
||||||
|
wording. The module declares no additional Go module requirements.
|
||||||
202
docs/oss/licenses/Apache-2.0.txt
Normal file
202
docs/oss/licenses/Apache-2.0.txt
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
18
docs/oss/licenses/MIT.txt
Normal file
18
docs/oss/licenses/MIT.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) <year> <copyright holders>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||||
|
portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||||
|
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||||
|
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
91
docs/policy/agents.md
Normal file
91
docs/policy/agents.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Agent Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.1
|
||||||
|
|
||||||
|
**Last Updated**: 2026-09-23
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Agents working in this repository must read and adhere to the following house rules.
|
||||||
|
|
||||||
|
## Implementation Discipline
|
||||||
|
|
||||||
|
**KEY PRINCIPLES:** Write the simplest idiomatic code that satisfies the user's
|
||||||
|
request. Do not implicitly strengthen requirements, restrictions, or guarantees
|
||||||
|
beyond what the user actually requested.
|
||||||
|
|
||||||
|
Simplify the mechanism, not the user's choices.
|
||||||
|
|
||||||
|
**Keep implementation machinery proportionate.** Use straightforward,
|
||||||
|
idiomatic code and introduce only the machinery needed to support the requested
|
||||||
|
behavior and flexibility. Optimize for conceptual simplicity and
|
||||||
|
maintainability, not for the fewest lines, fewest options, or narrowest product
|
||||||
|
surface.
|
||||||
|
|
||||||
|
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
|
||||||
|
harden, or otherwise narrow behavior unless required by the user, an explicit
|
||||||
|
repository contract, or an unavoidable correctness constraint. Preserve
|
||||||
|
operator and caller choice where policy has not been established. If an
|
||||||
|
additional restriction appears advisable, present it as a decision rather than
|
||||||
|
silently implementing it.
|
||||||
|
|
||||||
|
A correctness constraint must follow from the requested behavior or an
|
||||||
|
established invariant, not from the convenience of the chosen implementation.
|
||||||
|
Explain the specific failure it prevents and why a less restrictive
|
||||||
|
implementation cannot satisfy the contract.
|
||||||
|
|
||||||
|
**Do not invent guarantees.** Do not strengthen public contracts, guarantees,
|
||||||
|
or invariants beyond those expressly requested by the user.
|
||||||
|
|
||||||
|
**Use the standard library.** For example: in almost every case, the best
|
||||||
|
approach to copying files is to use the standard library. Writing custom code to
|
||||||
|
implement power-loss protection at the application level, or to guard against
|
||||||
|
bad actors with privileged and concurrent access to the host's filesystem,
|
||||||
|
is wasteful, unnecessary, and not idiomatic. Stick to the simple, standard, and
|
||||||
|
idiomatic approach unless another path is expressly requested by the user.
|
||||||
|
|
||||||
|
**An implementation detail is not repository policy.** Tests and documentation
|
||||||
|
must not elevate incidental behavior into a durable guarantee or restriction
|
||||||
|
unless that behavior was expressly selected as part of the contract. Existing
|
||||||
|
explicit contracts remain authoritative; changing or weakening one requires a
|
||||||
|
deliberate decision rather than being inferred from this principle.
|
||||||
|
|
||||||
|
Agent-authored plans, tests, documentation, and prior implementations are not
|
||||||
|
evidence that an additional restriction was deliberately approved. Identify the
|
||||||
|
authorizing requirement or decision; when that authority is unclear, surface
|
||||||
|
the ambiguity rather than treating repetition as approval.
|
||||||
|
|
||||||
|
## Git Commit Policy
|
||||||
|
|
||||||
|
By default, create a commit after completing each coherent unit of work, using a
|
||||||
|
concise, plain-English message describing what changed. This policy is standing
|
||||||
|
authorization to create those local commits; a separate user request is not
|
||||||
|
required. An explicit user instruction to leave work uncommitted takes precedence.
|
||||||
|
|
||||||
|
A unit of work is a focused, reviewable outcome, including its relevant tests and
|
||||||
|
documentation. Do not commit after every intermediate edit or create empty commits.
|
||||||
|
Complete the appropriate validation before committing. If work is incomplete or
|
||||||
|
validation is blocked, report that state rather than presenting it as completed.
|
||||||
|
|
||||||
|
Before committing:
|
||||||
|
|
||||||
|
1. Run `git status --short` and review the relevant diff, including any staged
|
||||||
|
changes. Identify changes that predate the current unit of work.
|
||||||
|
2. Exclude secrets, local environment files, generated junk, and unrelated changes.
|
||||||
|
3. Stage only the files or hunks belonging to the completed unit. Use `git add .`
|
||||||
|
only when every included change belongs to that unit. Do not disturb unrelated
|
||||||
|
staged or unstaged work.
|
||||||
|
4. Review `git diff --staged` and run appropriate checks for the change. Do not
|
||||||
|
repeat checks already passed for unchanged content merely to create a commit.
|
||||||
|
5. Review `git log --oneline -n 10` and use
|
||||||
|
`git commit -m "<concise plain-English commit message>"`.
|
||||||
|
6. Report the commit identifier and any remaining uncommitted work.
|
||||||
|
|
||||||
|
If the unit cannot be safely separated from pre-existing work, leave it uncommitted
|
||||||
|
and explain the overlap rather than silently including that work. If a commit
|
||||||
|
fails, preserve the changes and report the failure; do not bypass hooks.
|
||||||
|
|
||||||
|
Default commit authorization does not authorize pushing, publishing, amending,
|
||||||
|
resetting, reverting, or rewriting history. Those actions require a separate user
|
||||||
|
instruction. Do not use `git commit -a` or `git commit --no-verify` to bypass the
|
||||||
|
staging and validation discipline above.
|
||||||
@@ -71,7 +71,7 @@ network provider into the library.
|
|||||||
## Public API and dependency direction
|
## Public API and dependency direction
|
||||||
|
|
||||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||||
single-document consumers must not need import, profile, or provider machinery.
|
single-document consumers must not need import, overlay, or provider machinery.
|
||||||
Do not expose parser-specific nodes, errors, or extension hooks through the
|
Do not expose parser-specific nodes, errors, or extension hooks through the
|
||||||
public API. Parser integration belongs behind a private boundary, as recorded
|
public API. Parser integration belongs behind a private boundary, as recorded
|
||||||
in [ADR 0002](../adr/0002-own-yaml-semantics.md).
|
in [ADR 0002](../adr/0002-own-yaml-semantics.md).
|
||||||
@@ -106,7 +106,7 @@ same inputs and options. Invalid input must not produce a successful usable
|
|||||||
configuration. Do not run application hooks on invalid typed configuration.
|
configuration. Do not run application hooks on invalid typed configuration.
|
||||||
|
|
||||||
User-controlled precedence is application defaults, composed base, selected
|
User-controlled precedence is application defaults, composed base, selected
|
||||||
profile, then explicitly supplied CLI overrides. Applications own flag parsing;
|
overlay, then explicitly supplied CLI overrides. Applications own flag parsing;
|
||||||
ConfigKit owns overlay semantics and provenance. Domain resolution follows these
|
ConfigKit owns overlay semantics and provenance. Domain resolution follows these
|
||||||
inputs and must remain recorded, not become a hidden override channel.
|
inputs and must remain recorded, not become a hidden override channel.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
# Documentation Policy
|
# Documentation Policy
|
||||||
|
|
||||||
**Base Policy Version:** 1.0
|
**Base Policy Version:** 1.1
|
||||||
|
|
||||||
**Last Updated:** 2026-09-06
|
**Last Updated:** 2026-09-26
|
||||||
|
|
||||||
|
Licensing ownership incorporates the upstream documentation policy v1.5;
|
||||||
|
unrelated policy revisions remain outside this adoption.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -59,9 +62,15 @@ handling, not secret values.
|
|||||||
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example |
|
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example |
|
||||||
| Contributor entry point | `docs/development.md` | Reading guide and actual baseline validation commands |
|
| Contributor entry point | `docs/development.md` | Reading guide and actual baseline validation commands |
|
||||||
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
|
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
|
||||||
|
| Agent rules | `docs/policy/agents.md` | Implementation discipline, proportionality, rules against inventing contracts, and local commit authorization; not application contracts or architecture |
|
||||||
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
|
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
|
||||||
| Testing rules | `docs/policy/testing.md` | Risk-based sufficiency, test boundaries, and maintenance principles |
|
| Testing rules | `docs/policy/testing.md` | Risk-based sufficiency, test boundaries, and maintenance principles |
|
||||||
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
|
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
|
||||||
|
| Licensing and attribution rules | `docs/policy/oss.md` | Project licensing conventions, attribution format, notice links, discoverability, and maintenance |
|
||||||
|
| Project grant | `COPYRIGHT` | Project identity, holders and years, SPDX expression, grant, disclaimer, and third-party statement |
|
||||||
|
| Project license texts | `LICENSE` | Complete selected license and incorporated companion text |
|
||||||
|
| Upstream attribution | `docs/oss/attribution.md` | Component names, copyright statements, license identification, and source/license/notice links |
|
||||||
|
| Standard dependency license texts | `docs/oss/licenses/` | Complete verbatim texts, not project-specific upstream notices |
|
||||||
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
|
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
|
||||||
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
|
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
|
||||||
| YAML and resolution semantics | `docs/config.md` | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
|
| YAML and resolution semantics | `docs/config.md` | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
|
||||||
@@ -85,6 +94,12 @@ interface. Do not create those reference documents for consuming applications.
|
|||||||
|
|
||||||
## Boundary rules
|
## Boundary rules
|
||||||
|
|
||||||
|
The [OSS policy](oss.md) owns licensing and attribution conventions. COPYRIGHT
|
||||||
|
owns the project grant, LICENSE owns its complete texts, and attribution entries
|
||||||
|
own component-specific information. README and new release documents provide
|
||||||
|
navigation; the release procedure owns concrete release URLs and publication
|
||||||
|
mechanics. Do not duplicate notice lists in those documents.
|
||||||
|
|
||||||
Architecture owns governing constraints; ADRs explain why; contracts describe
|
Architecture owns governing constraints; ADRs explain why; contracts describe
|
||||||
implemented semantics; internal documentation explains how. A roadmap may link
|
implemented semantics; internal documentation explains how. A roadmap may link
|
||||||
to all of them but must not become a second current reference.
|
to all of them but must not become a second current reference.
|
||||||
|
|||||||
170
docs/policy/oss.md
Normal file
170
docs/policy/oss.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# Open Source Licensing And Attribution Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.2
|
||||||
|
|
||||||
|
**Last Updated**: 2026-09-26
|
||||||
|
|
||||||
|
## ConfigKit applicability
|
||||||
|
|
||||||
|
ConfigKit's selected grant is LGPL-3.0-or-later, recorded in
|
||||||
|
[COPYRIGHT](../../COPYRIGHT), with the complete LGPLv3 and incorporated GPLv3
|
||||||
|
texts in [LICENSE](../../LICENSE). Adoption does not change that grant.
|
||||||
|
The [release procedure](../release.md) owns the Gitea source host, source-only
|
||||||
|
Go module distribution, and annotated-tag publication mechanism. No separate
|
||||||
|
hosted download page or license asset is required for this source-only channel.
|
||||||
|
|
||||||
|
## Purpose And Scope
|
||||||
|
|
||||||
|
This policy defines project licensing conventions and a repeatable House Format
|
||||||
|
for upstream attribution and license notices. It covers the project's own code
|
||||||
|
and upstream components incorporated into or distributed with the project,
|
||||||
|
including relevant transitive dependencies and
|
||||||
|
copied code or assets. Treatment follows the component's terms and actual use,
|
||||||
|
rather than its classification as a direct dependency.
|
||||||
|
|
||||||
|
The baseline is a publicly available source repository with releases published
|
||||||
|
alongside it. The format is independent of language, forge, and build system.
|
||||||
|
It does not require attribution for every development tool, establish license
|
||||||
|
compatibility, or resolve every obligation arising from use or distribution.
|
||||||
|
|
||||||
|
## Canonical Locations
|
||||||
|
|
||||||
|
The [documentation policy](documentation.md) assigns ownership:
|
||||||
|
|
||||||
|
- `COPYRIGHT` contains the project name, copyright statements, SPDX expression,
|
||||||
|
license grant, warranty disclaimer, and statement about third-party material.
|
||||||
|
- `LICENSE` contains the complete, unmodified project license text, including
|
||||||
|
incorporated companion text. Dependency attribution does not relicense the project.
|
||||||
|
- `docs/oss/attribution.md` contains the canonical component entries.
|
||||||
|
- `docs/oss/licenses/` contains complete, verbatim standard license texts,
|
||||||
|
including companion texts where applicable.
|
||||||
|
|
||||||
|
Use recognizable license filenames, such as `Apache-2.0.txt` or `LGPL-3.0.txt`.
|
||||||
|
A shared standard text may serve multiple entries. Keep explanations outside
|
||||||
|
license texts and preserve distinct versions and exceptions.
|
||||||
|
|
||||||
|
## Project Licensing
|
||||||
|
|
||||||
|
Unless the author specifies otherwise, use **GPL version 3 or later**
|
||||||
|
(`GPL-3.0-or-later`) for applications and **LGPL version 3 or later**
|
||||||
|
(`LGPL-3.0-or-later`) for libraries. An author's explicit choice takes precedence:
|
||||||
|
for example, a one-off script may use MIT. Such a choice requires no exception
|
||||||
|
rationale, additional approval, or compensating controls under this policy.
|
||||||
|
|
||||||
|
These defaults guide new license selection. Adopting this policy does not
|
||||||
|
relicense existing code or override third-party terms. Preserve an existing
|
||||||
|
license unless the author deliberately changes it. For mixed-purpose projects,
|
||||||
|
follow the author's selected scope rather than inferring different licenses for
|
||||||
|
individual packages.
|
||||||
|
|
||||||
|
Keep project name, years, and holders accurate in COPYRIGHT; do not assume the
|
||||||
|
maintainer owns every contribution or advance years automatically. For another
|
||||||
|
selected license, adapt the grant, SPDX expression, disclaimer, and text
|
||||||
|
references together.
|
||||||
|
|
||||||
|
For GPLv3 projects, copy the complete GPLv3 text into `LICENSE`. For LGPLv3
|
||||||
|
projects, place the complete LGPLv3 text followed by the complete GPLv3 text in
|
||||||
|
`LICENSE`; the LGPL incorporates the GPL terms. Keep the texts unmodified and
|
||||||
|
place project-specific statements in `COPYRIGHT`. For the default grants, the
|
||||||
|
grant and SPDX expression in `COPYRIGHT` must expressly identify `or-later`;
|
||||||
|
follow the author’s choice if a different grant is selected.
|
||||||
|
|
||||||
|
For other licenses, provide their complete text and any required project-specific
|
||||||
|
fields; the standard reference collection remains unchanged. Keep `COPYRIGHT`,
|
||||||
|
`LICENSE`, SPDX expressions, and README descriptions consistent with the actual
|
||||||
|
grant. This convention introduces no blanket requirement for per-file headers;
|
||||||
|
preserve notices or add them where the applicable terms require them.
|
||||||
|
|
||||||
|
## Upstream Attribution House Format
|
||||||
|
|
||||||
|
Each component entry has five fields:
|
||||||
|
|
||||||
|
1. **Project name**, normally as the entry heading.
|
||||||
|
2. **Upstream copyright statement or statements**, preserving their wording.
|
||||||
|
3. **SPDX license identifier or expression**, when applicable.
|
||||||
|
4. **License and notice links**, covering the applicable standard texts and
|
||||||
|
upstream project-specific material.
|
||||||
|
5. **Source repository link**, identifying the source corresponding to the
|
||||||
|
imported version.
|
||||||
|
|
||||||
|
The [go-yaml entry](../oss/attribution.md#go-yaml-v4) demonstrates this format
|
||||||
|
with the pinned module's actual statements and links.
|
||||||
|
|
||||||
|
These are five fields, not a line limit. Include multiple statements and links
|
||||||
|
where needed. Omit the SPDX field when no applicable identifier exists; identify
|
||||||
|
nonstandard terms accurately through the linked text. Preserve licensing
|
||||||
|
alternatives and exceptions, and record a selected option when relevant. Do not
|
||||||
|
invent an identifier or silently resolve an unsettled licensing choice.
|
||||||
|
|
||||||
|
## License Texts And Upstream Links
|
||||||
|
|
||||||
|
Link standard license texts to their local copies. Link project-specific
|
||||||
|
notices to the upstream copies instead of storing them locally. Every upstream
|
||||||
|
repository or notice link must identify the exact commit corresponding to the
|
||||||
|
imported version, including the selected source revision for copied material.
|
||||||
|
Do not use moving branches or version tags as substitutes for that commit.
|
||||||
|
|
||||||
|
Classify material by its contents, not its filename. An upstream `LICENSE`,
|
||||||
|
`COPYING`, or similar file may contain project-specific notices or exceptions
|
||||||
|
in addition to standard wording. Link both the local standard text and the
|
||||||
|
commit-specific upstream file when needed. Make companion license texts directly
|
||||||
|
reachable from the entry rather than leaving readers to infer their location.
|
||||||
|
|
||||||
|
For project-specific license and attribution material, this policy accepts
|
||||||
|
direct links to the applicable upstream texts as satisfying reproduction,
|
||||||
|
inclusion, or copy requirements. This is the adopted house interpretation.
|
||||||
|
Agents must apply it without adding local-copy requirements or seeking repeated
|
||||||
|
approval solely because a license uses those terms.
|
||||||
|
|
||||||
|
This interpretation concerns notice reproduction. Concrete obligations such as
|
||||||
|
marking modified files or supplying corresponding source receive their own
|
||||||
|
applicable treatment. Preserve required notices already present in incorporated
|
||||||
|
source files; the central attribution entry does not authorize stripping them.
|
||||||
|
|
||||||
|
## Discoverability
|
||||||
|
|
||||||
|
End the project's README with two friendly statements linking to its actual
|
||||||
|
grant in `COPYRIGHT` and the attribution document. ConfigKit's [README](../../README.md)
|
||||||
|
uses "ConfigKit is licensed under the LGPL v3.0 or later" and "Open Source
|
||||||
|
attribution notices are available", linking the license name to COPYRIGHT and
|
||||||
|
the attribution phrase to `docs/oss/attribution.md` relative to the repository root.
|
||||||
|
|
||||||
|
Each new checked-in release document and its hosted download page must end
|
||||||
|
with links whose visible text is `COPYRIGHT | LICENSE | ATTRIBUTION`. Use absolute
|
||||||
|
URLs to those files at the release's immutable tag or corresponding immutable source
|
||||||
|
revision so the links work in both locations. This release navigation is distinct
|
||||||
|
from upstream component links, which must use the imported commit.
|
||||||
|
|
||||||
|
Reuse the checked-in release document as the hosted release body where supported.
|
||||||
|
The release procedure owns concrete URLs and publication mechanics for delivering
|
||||||
|
the links through the selected channel. Do not maintain duplicate notice lists.
|
||||||
|
These conventions do not by themselves require separate downloadable license
|
||||||
|
assets, source bundles, or a different binary package format.
|
||||||
|
|
||||||
|
## Maintenance And Exceptions
|
||||||
|
|
||||||
|
Update project licensing statements together when the author changes the grant
|
||||||
|
or its scope. Update upstream notices in the change that adds, removes, upgrades,
|
||||||
|
copies, or changes the distribution of a relevant component. Check the selected source's license
|
||||||
|
and notices rather than assuming they are unchanged. Update affected entries,
|
||||||
|
commit links, and texts; retain historical versions through Git.
|
||||||
|
|
||||||
|
Check affected statements, faithful standard-text copies, and link destinations
|
||||||
|
as part of ordinary change review. For a future release tag, check intended file
|
||||||
|
paths against the candidate source; the tag need not resolve remotely before
|
||||||
|
publication. Do not add publication-time network checks, CI polling, recurring
|
||||||
|
legal sign-offs, mandatory scanners, or a full software bill of materials merely
|
||||||
|
to implement this policy.
|
||||||
|
|
||||||
|
Use the House Format routinely. Raise an exception only for a concrete obligation
|
||||||
|
or unresolved rights question it does not address, including any arising from a
|
||||||
|
particular distribution channel. Record the issue and selected treatment in the
|
||||||
|
adopted policy or affected attribution entry as appropriate. Resolve the issue
|
||||||
|
before the affected use or distribution without reopening settled choices.
|
||||||
|
Common ownership of projects is neither required nor a blanket exception.
|
||||||
|
|
||||||
|
Apply the formatting convention prospectively. Do not rewrite published tags,
|
||||||
|
notes, binaries, or checksums to retrofit it. Address actual omissions deliberately
|
||||||
|
under the applicable terms and the [release correction policy](release.md#immutability-and-correction).
|
||||||
|
Ordinary attribution maintenance and release links are preparation work, not new
|
||||||
|
release approval gates.
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
# Release Policy
|
# Release Policy
|
||||||
|
|
||||||
**Base Policy Version:** 0.1.0
|
**Base Policy Version:** 1.0
|
||||||
|
|
||||||
**Last Updated:** 2026-09-12
|
**Last Updated:** 2026-09-26
|
||||||
|
|
||||||
|
The licensing-footer requirement incorporates upstream release policy v1.2;
|
||||||
|
other release-workflow changes from that version are not adopted here.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -21,7 +24,8 @@ ConfigKit adopts a source-only Go module release model. The source tag and
|
|||||||
version-matched release note are the declared deliverables. Standalone binaries,
|
version-matched release note are the declared deliverables. Standalone binaries,
|
||||||
container images, and separate binary/package-registry publication are not
|
container images, and separate binary/package-registry publication are not
|
||||||
required. This does not waive source compilation, module hygiene, testing, or
|
required. This does not waive source compilation, module hygiene, testing, or
|
||||||
consumer-facing module retrieval and build verification.
|
local target-build validation. Post-publication consumer retrieval is optional,
|
||||||
|
as documented under Policy Exceptions.
|
||||||
|
|
||||||
The release procedure must define the module path, authoritative remote,
|
The release procedure must define the module path, authoritative remote,
|
||||||
supported Go versions and operating-system/architecture targets, prerequisite
|
supported Go versions and operating-system/architecture targets, prerequisite
|
||||||
@@ -74,6 +78,10 @@ responsibility is documented. Automation must not silently change the source
|
|||||||
candidate, move a published tag, replace a published deliverable, or repair a
|
candidate, move a published tag, replace a published deliverable, or repair a
|
||||||
release in place.
|
release in place.
|
||||||
|
|
||||||
|
A successful push of the intended annotated tag completes the source-only release.
|
||||||
|
Never wait for CI or use its status as a readiness or completion gate. Optional
|
||||||
|
post-publication checks must not delay reporting success.
|
||||||
|
|
||||||
## Versioning And Compatibility
|
## Versioning And Compatibility
|
||||||
|
|
||||||
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
|
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
|
||||||
@@ -130,40 +138,36 @@ canonical documentation for exact behavior. Every affected canonical document
|
|||||||
must be updated in the same candidate; a release note must not substitute for
|
must be updated in the same candidate; a release note must not substitute for
|
||||||
current documentation or defer required documentation changes.
|
current documentation or defer required documentation changes.
|
||||||
|
|
||||||
|
Each new release note must end with the release navigation defined by the
|
||||||
|
[OSS policy](oss.md#discoverability). Preserve those links on a hosted download
|
||||||
|
page if one is used. The release procedure owns URL construction and channel
|
||||||
|
treatment. This is ordinary preparation, not a new network check or approval
|
||||||
|
gate; historical release notes are unchanged.
|
||||||
|
|
||||||
## Candidate Preparation And Validation
|
## Candidate Preparation And Validation
|
||||||
|
|
||||||
Each repository must provide one documented release-check command or script
|
ConfigKit provides one local release checker. Run it once against the final
|
||||||
that exercises the complete set of automated source-candidate gates. The
|
committed candidate using one maintained patch of the minimum supported Go line
|
||||||
repository-specific procedure must state all additional manual gates.
|
on Linux. It checks module hygiene, formatting and whitespace, the full offline
|
||||||
|
suite with the race detector, static analysis, and CGO-disabled Linux amd64/arm64
|
||||||
|
builds. Examples, documentation links, consumer fixtures, and package boundaries
|
||||||
|
are covered by that suite, not separate repeated runs. Native package compilation
|
||||||
|
is covered by the test run. A second toolchain is optional and non-gating.
|
||||||
|
|
||||||
The required gates are:
|
Commit the release note and associated changes before release validation. Record
|
||||||
|
the exact validated commit; revalidate if the candidate changes. Do not require
|
||||||
|
both pre-commit and post-commit full checker runs for unchanged content.
|
||||||
|
Do not publish uncommitted or unvalidated content. Local check failures still
|
||||||
|
block publication. Skipped test cases are reported, not separate human sign-off
|
||||||
|
requirements.
|
||||||
|
|
||||||
- dependency or module hygiene appropriate to the language and ecosystem;
|
Compatibility review, release-note accuracy, removal of completed roadmaps, and
|
||||||
- the complete deterministic, offline default test suite;
|
review for unintended files, credentials, private data, and unrelated changes
|
||||||
- race, concurrency, or equivalent safety checks where the implementation can
|
are ordinary preparation. The executing agent may perform them without routine
|
||||||
be meaningfully checked for those risks;
|
human approval or separate manual evidence records. Ask the maintainer only for
|
||||||
- static analysis and linting used by the repository;
|
genuine unresolved decisions or missing authority. A clean existing checkout is
|
||||||
- production builds for every supported target that can be built in the
|
sufficient; a dedicated clone is optional. Dependency and toolchain update
|
||||||
release environment;
|
research belongs to maintenance or relevant changes, not every release.
|
||||||
- formatting and whitespace checks;
|
|
||||||
- validation of maintained examples, configuration, schemas, generated
|
|
||||||
sources, and documentation commands where applicable;
|
|
||||||
- review of changed local documentation links;
|
|
||||||
- removal of obsolete roadmap documents under `docs/roadmap/` that describe
|
|
||||||
completed work included in the release;
|
|
||||||
- review for unintended files, generated output, credentials, private data,
|
|
||||||
and unrelated changes; and
|
|
||||||
- any additional security, compatibility, migration, packaging, or platform
|
|
||||||
checks required by the product.
|
|
||||||
|
|
||||||
Unavailable tooling or an inapplicable check must be addressed explicitly in
|
|
||||||
the adopted policy or release procedure; it must not disappear silently from
|
|
||||||
the candidate gates.
|
|
||||||
|
|
||||||
Run the complete checker while preparing the candidate. Commit the release
|
|
||||||
note and all associated code, tests, documentation, and configuration, then
|
|
||||||
run the complete checker again against the exact commit intended for release.
|
|
||||||
Do not publish a release from uncommitted or unvalidated content.
|
|
||||||
|
|
||||||
## Publication Guards
|
## Publication Guards
|
||||||
|
|
||||||
@@ -178,7 +182,8 @@ verify all of the following:
|
|||||||
- the authoritative remote branch points to that same commit;
|
- the authoritative remote branch points to that same commit;
|
||||||
- the matching release note exists in that commit and has the required title
|
- the matching release note exists in that commit and has the required title
|
||||||
and sections;
|
and sections;
|
||||||
- all required approvals and automated gates succeeded for that commit; and
|
- release publication is authorized and the local automated gates succeeded for
|
||||||
|
that commit; and
|
||||||
- no local or remote tag already uses the selected version.
|
- no local or remote tag already uses the selected version.
|
||||||
|
|
||||||
Create the version tag against the guarded commit and verify its target before
|
Create the version tag against the guarded commit and verify its target before
|
||||||
@@ -219,21 +224,36 @@ order:
|
|||||||
1. maintainer prerequisites and required access;
|
1. maintainer prerequisites and required access;
|
||||||
2. version selection and compatibility review;
|
2. version selection and compatibility review;
|
||||||
3. release-note creation;
|
3. release-note creation;
|
||||||
4. candidate preparation and the complete validation command;
|
4. candidate preparation and commit;
|
||||||
5. candidate commit, push, and exact commit recording;
|
5. one local validation pass, exact candidate recording, and push;
|
||||||
6. publication guards;
|
6. publication guards;
|
||||||
7. exact tag and deliverable publication commands;
|
7. exact tag and deliverable publication commands;
|
||||||
8. authoritative remote and consumer-facing verification; and
|
8. completion on successful tag push and optional post-publication checks; and
|
||||||
9. failure handling and correction.
|
9. failure handling and correction.
|
||||||
|
|
||||||
The procedure should use copyable, fail-fast commands where practical. It must
|
The procedure should use copyable, fail-fast commands where practical. It must
|
||||||
name the primary branch, authoritative remote, tag form, published
|
name the primary branch, authoritative remote, tag form, published
|
||||||
deliverables, supported build targets, release automation responsibilities,
|
deliverables, supported build targets, release automation responsibilities,
|
||||||
and consumer verification path. It must link to this policy instead of
|
and optional consumer verification path. It must link to this policy instead of
|
||||||
restating or redefining its requirements.
|
restating or redefining its requirements.
|
||||||
|
|
||||||
## Policy Exceptions
|
## Policy Exceptions
|
||||||
|
|
||||||
|
Any exception to this policy must be explicitly documented in this section,
|
||||||
|
including the repository constraint requiring it, its rationale, scope, and
|
||||||
|
compensating controls. Customizing release mechanics must not silently weaken
|
||||||
|
the policy's integrity, validation, documentation, verification, or immutability
|
||||||
|
requirements.
|
||||||
|
|
||||||
|
For ConfigKit's source-only releases, the maintainer accepts successful publication
|
||||||
|
of the intended tag as sufficient completion evidence. This is an explicit
|
||||||
|
exception to the shared standard's mandatory post-publication verification:
|
||||||
|
authoritative tag read-back and fresh consumer retrieval are optional and
|
||||||
|
non-gating. CI status and downstream retrieval availability must not delay release
|
||||||
|
completion. Compensating controls are local validation of the exact candidate,
|
||||||
|
automated pre-publication guards, matching remote main, verification of the local
|
||||||
|
annotated tag target, explicit ref pushes, and immutable published versions.
|
||||||
|
|
||||||
For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to
|
For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to
|
||||||
`configkit` as an error-correcting point release because there are no current
|
`configkit` as an error-correcting point release because there are no current
|
||||||
consumers. This intentionally changes the v0.1.0 configuration contract without a
|
consumers. This intentionally changes the v0.1.0 configuration contract without a
|
||||||
@@ -247,5 +267,5 @@ only. The final candidate still requires the complete Linux release checker.
|
|||||||
This exception waives only repeating the macOS check after those documentation
|
This exception waives only repeating the macOS check after those documentation
|
||||||
changes; it does not waive other publication or verification gates.
|
changes; it does not waive other publication or verification gates.
|
||||||
|
|
||||||
Candidate selection, publication, and post-publication verification remain
|
Candidate selection and guarded publication remain mandatory; post-publication
|
||||||
mandatory steps in the [release procedure](../release.md).
|
verification is optional in the [release procedure](../release.md).
|
||||||
|
|||||||
188
docs/release.md
188
docs/release.md
@@ -12,21 +12,24 @@ binary, container image, generated package, or separate registry publication.
|
|||||||
- Authoritative remote: `origin`, expected to fetch from
|
- Authoritative remote: `origin`, expected to fetch from
|
||||||
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
||||||
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported
|
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported
|
||||||
- Minimum Go language/toolchain line: Go 1.25, tested with a supported patched
|
- Minimum Go language/toolchain line: Go 1.25, release-validated with one maintained
|
||||||
Go 1.25 toolchain and one newer supported toolchain
|
patch of that line; newer-toolchain testing is optional and non-gating
|
||||||
- Release-gated build targets: Linux on amd64 and arm64
|
- Release-gated build targets: Linux on amd64 and arm64
|
||||||
- Best-effort targets: macOS on amd64 and arm64, under the
|
- Best-effort targets: macOS on amd64 and arm64, under the
|
||||||
[platform support policy](policy/release.md#platform-support)
|
[platform support policy](policy/release.md#platform-support)
|
||||||
|
|
||||||
The automated checker cross-builds the Linux targets. Before release, the maintainer
|
The local Linux checker includes native race tests and CGO-disabled cross-builds.
|
||||||
must also obtain native test, race, symlink, and permission-fixture evidence on Linux;
|
Its output reports skipped filesystem cases; no separate evidence record or
|
||||||
cross-compilation is not native execution evidence. Native macOS checks and Darwin
|
human sign-off is required. Native macOS checks and Darwin cross-builds are
|
||||||
cross-builds are optional and do not gate publication.
|
optional and do not gate publication.
|
||||||
|
|
||||||
|
CI is never a release gate. Do not wait for it, poll its status, or delay reporting
|
||||||
|
completion because it is pending, unavailable, or failed.
|
||||||
|
|
||||||
## 1. Prepare access and tools
|
## 1. Prepare access and tools
|
||||||
|
|
||||||
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash 3.2 or newer,
|
The release maintainer owns this procedure. Use a clean checkout (a dedicated clone is optional) with Git, Bash 3.2 or newer,
|
||||||
and two supported Go toolchains available. The checker supports the stock macOS
|
a maintained Go 1.25 patch, and the compiler needed for native race tests. The checker supports the stock macOS
|
||||||
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module
|
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module
|
||||||
access through the normal credential manager before starting. Keep tokens, passwords,
|
access through the normal credential manager before starting. Keep tokens, passwords,
|
||||||
SSH material, and environment dumps outside the repository and command transcript.
|
SSH material, and environment dumps outside the repository and command transcript.
|
||||||
@@ -37,23 +40,24 @@ git remote get-url origin
|
|||||||
git ls-remote origin HEAD
|
git ls-remote origin HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
The remote URL must equal the authoritative URL above. Select the latest supported
|
The remote URL must equal the authoritative URL above. Acquire the selected
|
||||||
patch in the Go 1.25 line and a newer supported toolchain. The values below are the
|
toolchain and dependencies explicitly. For example, with the maintained patch
|
||||||
toolchains used while this runbook was introduced; update them to supported patch
|
used when this procedure was simplified:
|
||||||
releases during candidate preparation and record the actual versions in the release
|
|
||||||
note or validation record.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.5
|
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14
|
||||||
export CONFIGKIT_NEWER_TOOLCHAIN=go1.26.7
|
|
||||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
|
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
|
||||||
GOTOOLCHAIN="$CONFIGKIT_NEWER_TOOLCHAIN" go version
|
|
||||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
|
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
|
||||||
GOTOOLCHAIN="$CONFIGKIT_NEWER_TOOLCHAIN" go mod download
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Those commands explicitly acquire toolchains and dependencies. The checker itself
|
Keep toolchains maintained, but do not require latest-version discovery for each
|
||||||
sets `GOPROXY=off` and fails if its required cache is incomplete.
|
release. The checker sets `GOPROXY=off` and fails if the cache is incomplete.
|
||||||
|
Toolchain and YAML-dependency update research belongs to periodic maintenance or
|
||||||
|
relevant dependency changes. No renewed approval of an unchanged pin is required.
|
||||||
|
|
||||||
|
Use Bash with `set -euo pipefail` for the command blocks below so any failed gate
|
||||||
|
stops the sequence. Publication requires user authorization; the agent policy's
|
||||||
|
local-commit authorization is not permission to push or release.
|
||||||
|
|
||||||
## 2. Select the version and review compatibility
|
## 2. Select the version and review compatibility
|
||||||
|
|
||||||
@@ -63,7 +67,7 @@ For the first release, review the whole public surface and current contracts.
|
|||||||
```sh
|
```sh
|
||||||
release_version=v0.1.0
|
release_version=v0.1.0
|
||||||
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||||
git show-ref --verify --quiet "refs/tags/$release_version" && exit 1 || :
|
if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi
|
||||||
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
||||||
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -79,16 +83,9 @@ intentional incompatible minor-release change must be explained with consumer ac
|
|||||||
patch releases must remain compatible within their minor line. Review the maintained
|
patch releases must remain compatible within their minor line. Review the maintained
|
||||||
consumer examples and the migration guide even when signatures did not change.
|
consumer examples and the migration guide even when signatures did not change.
|
||||||
|
|
||||||
Reassess `go.yaml.in/yaml/v4` rather than using `@latest` implicitly:
|
When dependencies change, review parser behavior and migration impact as part of
|
||||||
|
that change. Keep Compatibility accurate about the dependency pin, but do not
|
||||||
```sh
|
perform an external version survey for every patch release.
|
||||||
go list -m -versions go.yaml.in/yaml/v4
|
|
||||||
go list -m -json go.yaml.in/yaml/v4@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
If changing the explicit pin, make that a reviewed dependency change and rerun the
|
|
||||||
complete checker, especially parser policy, limits, diagnostics, and rendering tests.
|
|
||||||
If retaining a prerelease pin, record that decision in Compatibility.
|
|
||||||
|
|
||||||
## 3. Write the release note
|
## 3. Write the release note
|
||||||
|
|
||||||
@@ -98,57 +95,63 @@ begin with `# ConfigKit $release_version` and contain `Summary`, `Compatibility`
|
|||||||
to current canonical contracts instead of copying their definitions. Do not create a
|
to current canonical contracts instead of copying their definitions. Do not create a
|
||||||
release note until there is a real candidate and reviewed content.
|
release note until there is a real candidate and reviewed content.
|
||||||
|
|
||||||
|
End each new release note with the footer required by the
|
||||||
|
[OSS policy](policy/oss.md#discoverability). For the selected version, use these
|
||||||
|
absolute Gitea URLs (replace every `VERSION` with the complete intended tag):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the three target files exist in the candidate and the version is correct
|
||||||
|
during ordinary note review. The future tag need not resolve remotely yet; do
|
||||||
|
not add publication-time network checks. ConfigKit publishes source tags with
|
||||||
|
checked-in notes, not separate hosted download pages. If a hosted release page
|
||||||
|
is used later, reuse the checked-in note as its body and retain this footer.
|
||||||
|
Do not retrofit published notes or add separate downloadable license assets.
|
||||||
|
|
||||||
## 4. Validate the source candidate
|
## 4. Validate the source candidate
|
||||||
|
|
||||||
Run the complete checker on Linux from the repository root after dependencies have been
|
Before release validation, finish compatibility review, inspect the diff and file
|
||||||
acquired. It validates module manifests in a disposable copy, both supported Go
|
list for unintended content or credentials, retire completed roadmaps, repair
|
||||||
toolchains, offline default tests, the native race detector, vet, native builds,
|
links, and review the release note. These are ordinary preparation tasks the
|
||||||
CGO-disabled Linux cross-builds, formatting and whitespace (including untracked files),
|
executing agent may perform, not separate human approvals or evidence records.
|
||||||
examples, package isolation, and local documentation links.
|
Ask the maintainer only for an unresolved product decision or missing authority.
|
||||||
|
|
||||||
|
Commit the candidate, staging only its intended changes, then run the checker
|
||||||
|
once against that clean commit. Do not run both everyday and release checkers to
|
||||||
|
repeat the same gates.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/release-check.sh
|
git status --short
|
||||||
```
|
# Stage the specific files belonging to the candidate, then review:
|
||||||
|
git diff --staged
|
||||||
`go vet` is the repository's static-analysis baseline. No additional linter is
|
|
||||||
claimed. Links to current ConfigKit files and headings are strict. Recorded links to
|
|
||||||
sibling consumer source are checked when those checkouts exist and are otherwise
|
|
||||||
inapplicable in an isolated clone; their immutable revisions remain recorded in the
|
|
||||||
extraction inventory.
|
|
||||||
|
|
||||||
Complete these manual gates and record their evidence with the candidate:
|
|
||||||
|
|
||||||
- The maintainer reviews compatibility and downstream migration impact against
|
|
||||||
`docs/config.md`, Go API comments, and the consumer guide.
|
|
||||||
- The maintainer inspects `git status --short`, `git diff`, and the candidate file
|
|
||||||
list for credentials, private data, generated output, unintended files, and
|
|
||||||
unrelated changes.
|
|
||||||
- The maintainer records a successful native checker run on Linux, including
|
|
||||||
filesystem capability tests. Missing required Linux evidence blocks release;
|
|
||||||
no macOS run, sign-off, or cross-build result is required.
|
|
||||||
- The maintainer removes completed, obsolete documents under `docs/roadmap/`, repairs
|
|
||||||
inbound links, and reruns the checker. ADR rationale remains historical.
|
|
||||||
- The maintainer reviews the exact version-matched release note for accuracy and
|
|
||||||
confirms it describes only deliverables that will actually be published.
|
|
||||||
|
|
||||||
## 5. Commit, push, and record the candidate
|
|
||||||
|
|
||||||
Commit all candidate source, contract, example, roadmap-retirement, and release-note
|
|
||||||
changes, rerun the checker against that clean commit, then push only `main` and record
|
|
||||||
the exact commit in Git metadata local to the clone:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git add .
|
|
||||||
git commit --message "Prepare ConfigKit $release_version"
|
git commit --message "Prepare ConfigKit $release_version"
|
||||||
./scripts/release-check.sh
|
|
||||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||||
git push origin HEAD:refs/heads/main
|
candidate_commit=$(git rev-parse HEAD)
|
||||||
git rev-parse HEAD | tee .git/configkit-release-candidate
|
./scripts/release-check.sh
|
||||||
candidate_commit=$(cat .git/configkit-release-candidate)
|
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||||
|
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||||
|
printf '%s\n' "$candidate_commit" > "$(git rev-parse --git-path configkit-release-candidate)"
|
||||||
```
|
```
|
||||||
|
|
||||||
If review changes the source after this point, create and validate a new candidate;
|
If the final candidate is already committed, omit the staging and commit commands;
|
||||||
do not reuse the recorded commit.
|
do not create an empty commit. The checker validates module manifests in a
|
||||||
|
disposable copy, formatting, whitespace, the full offline suite once with the race
|
||||||
|
detector and verbose skip reporting, vet once, and CGO-disabled Linux amd64/arm64
|
||||||
|
builds. The suite includes examples, consumer fixtures, documentation links, and
|
||||||
|
package boundaries; no separate reruns are required. No additional linter is
|
||||||
|
claimed. Sibling-consumer links are checked when those checkouts exist and are
|
||||||
|
otherwise inapplicable in an isolated checkout.
|
||||||
|
|
||||||
|
A changed candidate requires validation again. Do not reuse a recorded candidate
|
||||||
|
from an older revision. The record is written only after successful local checks.
|
||||||
|
|
||||||
|
## 5. Prepare publication
|
||||||
|
|
||||||
|
The following guarded sequence pushes only `main` and the selected tag. Local
|
||||||
|
validation failures block it; CI does not. Keep the selected `release_version`
|
||||||
|
from step 2 in the same shell. If starting a new shell, set it explicitly again.
|
||||||
|
|
||||||
## 6. Run publication guards
|
## 6. Run publication guards
|
||||||
|
|
||||||
@@ -156,14 +159,12 @@ Immediately before tagging, fetch the authoritative branch and tags, restore the
|
|||||||
recorded variables if this is a new shell, and execute every guard:
|
recorded variables if this is a new shell, and execute every guard:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_version=v0.1.0
|
candidate_commit=$(cat "$(git rev-parse --git-path configkit-release-candidate)")
|
||||||
candidate_commit=$(cat .git/configkit-release-candidate)
|
|
||||||
git fetch --prune origin main
|
git fetch --prune origin main
|
||||||
git fetch --tags origin
|
git fetch --tags origin
|
||||||
test "$(git branch --show-current)" = main
|
test "$(git branch --show-current)" = main
|
||||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||||
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||||
test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit"
|
|
||||||
test "$(git remote get-url origin)" = 'https://gitea.maximumdirect.net/eric/ConfigKit.git'
|
test "$(git remote get-url origin)" = 'https://gitea.maximumdirect.net/eric/ConfigKit.git'
|
||||||
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||||
test -f "docs/releases/$release_version.md"
|
test -f "docs/releases/$release_version.md"
|
||||||
@@ -171,17 +172,22 @@ test "$(sed -n '1p' "docs/releases/$release_version.md")" = "# ConfigKit $releas
|
|||||||
for heading in Summary Compatibility Upgrade Changes; do
|
for heading in Summary Compatibility Upgrade Changes; do
|
||||||
grep -Fxq "## $heading" "docs/releases/$release_version.md"
|
grep -Fxq "## $heading" "docs/releases/$release_version.md"
|
||||||
done
|
done
|
||||||
git show-ref --verify --quiet "refs/tags/$release_version" && exit 1 || :
|
if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi
|
||||||
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
||||||
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
test $? -eq 2
|
test $? -eq 2
|
||||||
fi
|
fi
|
||||||
|
git push origin HEAD:refs/heads/main
|
||||||
|
git fetch origin main
|
||||||
|
test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit"
|
||||||
|
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||||
|
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Confirm the recorded automated and manual approvals apply to exactly
|
The recorded local validation must apply to exactly `$candidate_commit`. No
|
||||||
`$candidate_commit`. A successful older run is not evidence for a changed commit.
|
separate human sign-off, CI status, or manual evidence document is required.
|
||||||
|
|
||||||
## 7. Tag and publish the exact ref
|
## 7. Tag and publish the exact ref
|
||||||
|
|
||||||
@@ -194,15 +200,17 @@ test "$(git rev-list -n 1 "$release_version")" = "$candidate_commit"
|
|||||||
git push origin "refs/tags/$release_version:refs/tags/$release_version"
|
git push origin "refs/tags/$release_version:refs/tags/$release_version"
|
||||||
```
|
```
|
||||||
|
|
||||||
The pushed tag completes the declared source-only publication. No Gitea release
|
A successful push of the intended tag completes the release. Report success now;
|
||||||
object or binary upload is required.
|
do not wait for CI, module propagation, or any post-publication check. No Gitea
|
||||||
|
release object or binary upload is required.
|
||||||
|
|
||||||
## 8. Verify publication and clean consumer retrieval
|
## 8. Optional post-publication checks
|
||||||
|
|
||||||
Verify the authoritative tag and then retrieve that exact version into a new module
|
Only when useful or requested, read back the remote tag or retrieve the exact
|
||||||
without a local `replace` directive. Private-host credentials must already be
|
version into a new module without a local `replace` directive. These checks are
|
||||||
configured. Use direct origin retrieval unless an explicitly approved internal Go
|
optional and non-gating; their failure or unavailability does not delay release
|
||||||
proxy is the publication channel.
|
completion. Do not poll CI or wait for module propagation. Private-host credentials
|
||||||
|
must already be configured. Example direct-origin verification:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
remote_tag_commit=$(git ls-remote origin "refs/tags/$release_version^{}" | awk '{print $1}')
|
remote_tag_commit=$(git ls-remote origin "refs/tags/$release_version^{}" | awk '{print $1}')
|
||||||
@@ -229,13 +237,13 @@ EOF
|
|||||||
rm -rf -- "$consumer_dir"
|
rm -rf -- "$consumer_dir"
|
||||||
```
|
```
|
||||||
|
|
||||||
The final `go list` output must show the exact module path and `$release_version`.
|
If performed, the final `go list` output should show the exact module path and
|
||||||
Record the result with the release evidence.
|
`$release_version`. Report optional verification separately from publication success.
|
||||||
|
|
||||||
## 9. Handle failures without replacement
|
## 9. Handle failures without replacement
|
||||||
|
|
||||||
Before publication, fix the candidate, rerun every gate, commit, push, and record a
|
Before publication, fix and commit the candidate, validate it, record the new
|
||||||
new exact candidate. An unpublished local tag may be deleted only after verifying it
|
exact candidate, and repeat the guarded publication sequence. An unpublished local tag may be deleted only after verifying it
|
||||||
was never pushed or used by another release channel.
|
was never pushed or used by another release channel.
|
||||||
|
|
||||||
After publication, never move, delete, or recreate the tag and never replace its
|
After publication, never move, delete, or recreate the tag and never replace its
|
||||||
|
|||||||
43
docs/releases/v0.3.1.md
Normal file
43
docs/releases/v0.3.1.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ConfigKit v0.3.1
|
||||||
|
|
||||||
|
This release fixes secrets-directory bootstrap when the directory contains Git
|
||||||
|
metadata or other dot-prefixed entries.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`LoadSecretsDir` now ignores dot-prefixed files, directories, and symlinks,
|
||||||
|
including `.git`, `.gitignore`, and `.DS_Store`. These entries no longer prevent
|
||||||
|
otherwise valid secrets from loading and are omitted from the bootstrap report.
|
||||||
|
See the [bootstrap contract](../config.md#explicit-secrets-directory-bootstrap).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible bug fix for previously rejected directory contents.
|
||||||
|
There are no Go API signature changes or migrations for previously accepted
|
||||||
|
inputs. Validation of non-hidden entries, existing-environment precedence, and
|
||||||
|
the existing enumeration and content limits remain unchanged. Hidden entries
|
||||||
|
still count toward the directory enumeration limit.
|
||||||
|
|
||||||
|
The minimum Go version remains 1.25. Linux remains release-validated and macOS
|
||||||
|
remains best-effort. The explicitly pinned YAML dependency remains
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`; reassessment found no newer published version.
|
||||||
|
Retaining this prerelease pin avoids unrelated parser changes in this patch.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency and rebuild the consuming application:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.3.1
|
||||||
|
```
|
||||||
|
|
||||||
|
No configuration migration is required. A secrets directory can retain its
|
||||||
|
dot-prefixed metadata without treating those entries as secret candidates.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Filter hidden entries before filename and metadata validation and reporting.
|
||||||
|
- Cover hidden-only and mixed directories, including Git metadata and a dangling
|
||||||
|
hidden symlink, with regression tests.
|
||||||
|
- Update public API comments and the canonical bootstrap contract.
|
||||||
|
- Stop tracking local codebase-memory tooling artifacts and ignore their directory.
|
||||||
56
docs/releases/v0.4.0.md
Normal file
56
docs/releases/v0.4.0.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# ConfigKit v0.4.0
|
||||||
|
|
||||||
|
This release renames selected configuration profiles to overlays, intentionally
|
||||||
|
breaking the previous naming contract without compatibility aliases or fallback.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Selected configuration now uses `Options.Overlay` and `overlays/<name>.yml`.
|
||||||
|
Selection, merge rules, input precedence, and lifecycle behavior are unchanged.
|
||||||
|
See the [selected-overlay contract](../config.md#selected-overlays).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a breaking minor release before v1.0.0. Four surfaces change:
|
||||||
|
|
||||||
|
- Go API: `Options.Profile` becomes `Options.Overlay`, `LayerProfile` becomes
|
||||||
|
`LayerOverlay`, and all seven `CodeProfile*` constants become `CodeOverlay*`.
|
||||||
|
- Selected files: `profiles/<name>.yml` becomes `overlays/<name>.yml`; there is
|
||||||
|
no legacy-directory fallback.
|
||||||
|
- Provenance: the layer string changes from `profile` to `overlay`.
|
||||||
|
- Structured diagnostics: the `profile.*` code prefix becomes `overlay.*`.
|
||||||
|
|
||||||
|
The old Go identifiers are removed. Application-owned concepts such as
|
||||||
|
`ModelProfile` and `model_profile` are unchanged. Ordinary explicit imports from
|
||||||
|
a directory named `profiles` remain valid base inputs.
|
||||||
|
|
||||||
|
The minimum Go version remains 1.25. Linux remains release-validated and macOS
|
||||||
|
remains best-effort. The YAML dependency remains pinned to
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`; this release does not change parser dependencies.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the dependency:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.4.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Rename the affected Go identifiers, move selected files into `overlays/`, update
|
||||||
|
any handling of provenance-layer and diagnostic-code strings, and rebuild.
|
||||||
|
The maintained example helper is now `LoadNarratioOverlay` rather than
|
||||||
|
`LoadNarratioProfile`. Application CLI flag names remain application-owned;
|
||||||
|
ConfigKit does not require renaming domain-specific profile concepts.
|
||||||
|
See the [migration guide](../consumers/README.md#selected-overlay-migration).
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Rename the selected feature consistently across the public facade, internal
|
||||||
|
engine, diagnostics, source labels, examples, and current documentation.
|
||||||
|
- Add regression coverage for the new directory, absence of legacy fallback,
|
||||||
|
public values, explanations, and preservation of application profile fields.
|
||||||
|
- Align contributor policies with the current shared standard.
|
||||||
|
- Simplify release validation to one local minimum-toolchain check. CI is not a
|
||||||
|
release gate; successful publication of the intended tag completes the
|
||||||
|
source-only release. See the [release procedure](../release.md).
|
||||||
|
- Retire completed overlay and release-workflow planning documents to Git history.
|
||||||
42
docs/releases/v0.4.1.md
Normal file
42
docs/releases/v0.4.1.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ConfigKit v0.4.1
|
||||||
|
|
||||||
|
This release formalizes ConfigKit's licensing and upstream attribution
|
||||||
|
documentation without changing library behavior.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ConfigKit now includes an explicit LGPL-3.0-or-later grant in COPYRIGHT,
|
||||||
|
complete LGPLv3 and incorporated GPLv3 license texts, and centralized upstream
|
||||||
|
attribution. README and release guidance provide navigation to those materials.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
No Go code, public API, configuration semantics, diagnostic behavior, or
|
||||||
|
dependencies changed from v0.4.0. The minimum Go version remains 1.25, Linux
|
||||||
|
remains release-validated, and macOS remains best-effort. The YAML dependency
|
||||||
|
remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`.
|
||||||
|
|
||||||
|
The project grant is explicitly LGPL-3.0-or-later. Third-party material remains
|
||||||
|
subject to its respective terms, recorded in the attribution document below.
|
||||||
|
Published earlier releases are unchanged.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency and rebuild to select this release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.4.1
|
||||||
|
```
|
||||||
|
|
||||||
|
No application-code or configuration migration is required from v0.4.0.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Add the project COPYRIGHT statement and complete standard project license texts.
|
||||||
|
- Document go-yaml attribution with exact upstream revision links and local
|
||||||
|
Apache-2.0 and MIT license texts.
|
||||||
|
- Adopt the house OSS policy and connect licensing ownership and contributor guidance.
|
||||||
|
- Add license and attribution navigation to README and future release documents,
|
||||||
|
without adding release gates or changing publication mechanics.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.4.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.4.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.4.1/docs/oss/attribution.md)
|
||||||
@@ -15,7 +15,7 @@ The initial release deliberately excludes:
|
|||||||
|
|
||||||
- automatic `conf.d` discovery;
|
- automatic `conf.d` discovery;
|
||||||
- remote imports, arbitrary providers, and additional configuration formats;
|
- remote imports, arbitrary providers, and additional configuration formats;
|
||||||
- custom merge strategies, sequence merging, reset operators, and profile inheritance;
|
- custom merge strategies, sequence merging, reset operators, and overlay inheritance;
|
||||||
- parser-specific or custom structural-decoding extension APIs;
|
- parser-specific or custom structural-decoding extension APIs;
|
||||||
- generic dynamic-subtree conversion and module-registry APIs;
|
- generic dynamic-subtree conversion and module-registry APIs;
|
||||||
- automatic schema migrations;
|
- automatic schema migrations;
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ type NarratioConfig struct {
|
|||||||
|
|
||||||
// LoadNarratio loads the maintained import fixture with Narratio-shaped rules.
|
// LoadNarratio loads the maintained import fixture with Narratio-shaped rules.
|
||||||
func LoadNarratio(path string) (*configkit.Result[NarratioConfig], error) {
|
func LoadNarratio(path string) (*configkit.Result[NarratioConfig], error) {
|
||||||
return LoadNarratioProfile(path, "")
|
return LoadNarratioOverlay(path, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadNarratioProfile selects a ConfigKit configuration overlay independently
|
// LoadNarratioOverlay selects a ConfigKit configuration overlay independently
|
||||||
// from the application's ModelProfile field.
|
// from the application's ModelProfile field.
|
||||||
func LoadNarratioProfile(path, profile string) (*configkit.Result[NarratioConfig], error) {
|
func LoadNarratioOverlay(path, overlay string) (*configkit.Result[NarratioConfig], error) {
|
||||||
return configkit.Load(configkit.File(path), configkit.Options[NarratioConfig]{
|
return configkit.Load(configkit.File(path), configkit.Options[NarratioConfig]{
|
||||||
Imports: true, Profile: profile,
|
Imports: true, Overlay: overlay,
|
||||||
Definition: configkit.Definition[NarratioConfig]{
|
Definition: configkit.Definition[NarratioConfig]{
|
||||||
Defaults: func() NarratioConfig { return NarratioConfig{PollInterval: time.Minute} },
|
Defaults: func() NarratioConfig { return NarratioConfig{PollInterval: time.Minute} },
|
||||||
Resolve: resolveNarratio,
|
Resolve: resolveNarratio,
|
||||||
|
|||||||
@@ -92,10 +92,13 @@ func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
|
|||||||
if origin, ok := narratio.Metadata().Lookup(configkit.Keys("artifacts", "transcript", "output")); !ok || origin.Origin.Layer != configkit.LayerResolution {
|
if origin, ok := narratio.Metadata().Lookup(configkit.Keys("artifacts", "transcript", "output")); !ok || origin.Origin.Layer != configkit.LayerResolution {
|
||||||
t.Fatalf("artifact origin = %#v, %v", origin, ok)
|
t.Fatalf("artifact origin = %#v, %v", origin, ok)
|
||||||
}
|
}
|
||||||
profiled, err := consumers.LoadNarratioProfile(fixture("narratio", "pipeline.yml"), "testing")
|
overlaid, err := consumers.LoadNarratioOverlay(fixture("narratio", "pipeline.yml"), "testing")
|
||||||
if err != nil || profiled.Config.PollInterval.String() != "2m0s" ||
|
if err != nil || overlaid.Config.PollInterval.String() != "2m0s" ||
|
||||||
profiled.Config.ModelProfile != "concise" || len(profiled.Sources()) != 3 {
|
overlaid.Config.ModelProfile != "concise" || len(overlaid.Sources()) != 3 {
|
||||||
t.Fatalf("configuration/application profiles = %#v, %v", profiled, err)
|
t.Fatalf("configuration overlay and application model profile = %#v, %v", overlaid, err)
|
||||||
|
}
|
||||||
|
if source := overlaid.Sources()[2]; source.Label != "overlays/testing.yml" || source.Layer != configkit.LayerOverlay {
|
||||||
|
t.Fatalf("Narratio overlay source = %#v", source)
|
||||||
}
|
}
|
||||||
|
|
||||||
notarius, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
notarius, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
||||||
@@ -200,7 +203,7 @@ func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
|
|||||||
if len(diagnostics) == 0 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path {
|
if len(diagnostics) == 0 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path {
|
||||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
if strings.Contains(diagnostics[0].Message, "wrong-before-profile") {
|
if strings.Contains(diagnostics[0].Message, "wrong-before-overlay") {
|
||||||
t.Fatalf("diagnostic exposed raw parser input: %#v", diagnostics[0])
|
t.Fatalf("diagnostic exposed raw parser input: %#v", diagnostics[0])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -181,24 +181,24 @@ func nodeImportDiagnostic(code Code, path Path, message string, node *document.N
|
|||||||
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
|
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
|
||||||
}
|
}
|
||||||
|
|
||||||
func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
func overlayDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||||
if node.Kind == document.Mapping {
|
if node.Kind == document.Mapping {
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
childPath := current.Key(entry.Key)
|
childPath := current.Key(entry.Key)
|
||||||
if entry.Key == directiveKey {
|
if entry.Key == directiveKey {
|
||||||
location := publicLocation(entry.KeyLocation)
|
location := publicLocation(entry.KeyLocation)
|
||||||
return []Diagnostic{{
|
return []Diagnostic{{
|
||||||
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
|
Code: CodeOverlayDirective, Severity: SeverityError, Path: childPath,
|
||||||
Message: "profiles cannot contain configuration directives", Primary: &location,
|
Message: "overlays cannot contain configuration directives", Primary: &location,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if node.Kind == document.Sequence {
|
} else if node.Kind == document.Sequence {
|
||||||
for index, child := range node.Sequence {
|
for index, child := range node.Sequence {
|
||||||
if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
if diagnostics := overlayDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
|
|||||||
rendered.WriteString(" - representation: ")
|
rendered.WriteString(" - representation: ")
|
||||||
if change.Origin.Operation == ChangeDecoding {
|
if change.Origin.Operation == ChangeDecoding {
|
||||||
rendered.WriteString("input to canonical typed\n")
|
rendered.WriteString("input to canonical typed\n")
|
||||||
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
|
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI {
|
||||||
rendered.WriteString("policy-validated input\n")
|
rendered.WriteString("policy-validated input\n")
|
||||||
} else {
|
} else {
|
||||||
rendered.WriteString("canonical typed\n")
|
rendered.WriteString("canonical typed\n")
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ type explanationConcurrency struct {
|
|||||||
} `yaml:"transcription"`
|
} `yaml:"transcription"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
func TestExplainShowsDefaultBaseAndEffectiveOverlayValue(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
|
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, "transcription.yml", "transcription:\n concurrency: 8\n")
|
||||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
|
writeImportFile(t, directory, filepath.Join("overlays", "testing.yml"), "transcription:\n concurrency: 1\n")
|
||||||
result, err := testLoad[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
result, err := testLoad[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
||||||
Imports: true, Profile: "testing",
|
Imports: true, Overlay: "testing",
|
||||||
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
|
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
|
||||||
value := explanationConcurrency{}
|
value := explanationConcurrency{}
|
||||||
value.Transcription.Concurrency = 4
|
value.Transcription.Concurrency = 4
|
||||||
@@ -44,11 +44,17 @@ func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
|||||||
if got := explanationValues(explanation); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n"}) {
|
if got := explanationValues(explanation); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n"}) {
|
||||||
t.Fatalf("history values = %#v", got)
|
t.Fatalf("history values = %#v", got)
|
||||||
}
|
}
|
||||||
if explanation.Effective.YAML != "1\n" || explanation.Origin.Layer != LayerProfile ||
|
if explanation.Effective.YAML != "1\n" || explanation.Origin.Layer != LayerOverlay ||
|
||||||
len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != "source-3" ||
|
len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != "source-3" ||
|
||||||
explanation.Origin.Locations[0].Line != 2 {
|
explanation.Origin.Locations[0].Line != 2 {
|
||||||
t.Fatalf("effective explanation = %#v", explanation)
|
t.Fatalf("effective explanation = %#v", explanation)
|
||||||
}
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := result.WriteExplanation(&rendered, Keys("transcription", "concurrency")); err != nil ||
|
||||||
|
!strings.Contains(rendered.String(), `layer="overlay"`) ||
|
||||||
|
!strings.Contains(rendered.String(), `label="overlays/testing.yml"`) {
|
||||||
|
t.Fatalf("rendered explanation = %v\n%s", err, rendered.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type explanationConfig struct {
|
type explanationConfig struct {
|
||||||
@@ -70,18 +76,18 @@ func TestExplainReplaysCLIResolutionContainersAndSafeHistoricalValues(t *testing
|
|||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "config.yml", `concurrency: 8
|
mainPath := writeImportFile(t, directory, "config.yml", `concurrency: 8
|
||||||
equal: same
|
equal: same
|
||||||
limit: wrong-before-profile
|
limit: wrong-before-overlay
|
||||||
items: [base, removed]
|
items: [base, removed]
|
||||||
labels: {base: retained}
|
labels: {base: retained}
|
||||||
dynamic: {retired: HISTORICAL_EXPLAIN_CANARY, public: visible}
|
dynamic: {retired: HISTORICAL_EXPLAIN_CANARY, public: visible}
|
||||||
credential: APP_SECRET
|
credential: APP_SECRET
|
||||||
classified: APP_SECRET
|
classified: APP_SECRET
|
||||||
`)
|
`)
|
||||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `concurrency: 1
|
writeImportFile(t, directory, filepath.Join("overlays", "testing.yml"), `concurrency: 1
|
||||||
equal: same
|
equal: same
|
||||||
limit: 7
|
limit: 7
|
||||||
items: [profile]
|
items: [overlay]
|
||||||
labels: {profile: retained}
|
labels: {overlay: retained}
|
||||||
dynamic: selected
|
dynamic: selected
|
||||||
`)
|
`)
|
||||||
result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
|
result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
|
||||||
@@ -90,7 +96,7 @@ dynamic: selected
|
|||||||
}
|
}
|
||||||
return "RESOLVED_EXPLAIN_CANARY", true
|
return "RESOLVED_EXPLAIN_CANARY", true
|
||||||
}, Options[explanationConfig]{
|
}, Options[explanationConfig]{
|
||||||
Profile: "testing",
|
Overlay: "testing",
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{Path: Keys("concurrency"), Value: 2, Flag: "--concurrency"},
|
{Path: Keys("concurrency"), Value: 2, Flag: "--concurrency"},
|
||||||
{Path: Keys("equal"), Value: "same", Flag: "--equal"},
|
{Path: Keys("equal"), Value: "same", Flag: "--equal"},
|
||||||
@@ -130,7 +136,7 @@ dynamic: selected
|
|||||||
if got := explanationValues(concurrency); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n", "2\n", "3\n"}) {
|
if got := explanationValues(concurrency); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n", "2\n", "3\n"}) {
|
||||||
t.Fatalf("concurrency history = %#v", got)
|
t.Fatalf("concurrency history = %#v", got)
|
||||||
}
|
}
|
||||||
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}) {
|
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerOverlay, LayerCLI, LayerResolution}) {
|
||||||
t.Fatalf("concurrency layers = %#v", got)
|
t.Fatalf("concurrency layers = %#v", got)
|
||||||
}
|
}
|
||||||
if concurrency.History[4].Before.YAML != "2\n" || concurrency.History[4].Reasons[0] != "normalized\nconcurrency" ||
|
if concurrency.History[4].Before.YAML != "2\n" || concurrency.History[4].Reasons[0] != "normalized\nconcurrency" ||
|
||||||
@@ -149,7 +155,7 @@ dynamic: selected
|
|||||||
}
|
}
|
||||||
|
|
||||||
limit, _ := result.Explain(Keys("limit"))
|
limit, _ := result.Explain(Keys("limit"))
|
||||||
if got := explanationValues(limit); !reflect.DeepEqual(got, []string{"0\n", "wrong-before-profile\n", "7\n"}) {
|
if got := explanationValues(limit); !reflect.DeepEqual(got, []string{"0\n", "wrong-before-overlay\n", "7\n"}) {
|
||||||
t.Fatalf("overridden invalid input was not retained: %#v", got)
|
t.Fatalf("overridden invalid input was not retained: %#v", got)
|
||||||
}
|
}
|
||||||
removed, ok := result.Explain(Keys("dynamic", "retired"))
|
removed, ok := result.Explain(Keys("dynamic", "retired"))
|
||||||
@@ -182,7 +188,7 @@ dynamic: selected
|
|||||||
}
|
}
|
||||||
labels, _ := result.Explain(Keys("labels"))
|
labels, _ := result.Explain(Keys("labels"))
|
||||||
if len(labels.History) != 4 || !strings.Contains(labels.Effective.YAML, "default: retained") ||
|
if len(labels.History) != 4 || !strings.Contains(labels.Effective.YAML, "default: retained") ||
|
||||||
!strings.Contains(labels.Effective.YAML, "profile: retained") || !strings.Contains(labels.Effective.YAML, "cli: retained") {
|
!strings.Contains(labels.Effective.YAML, "overlay: retained") || !strings.Contains(labels.Effective.YAML, "cli: retained") {
|
||||||
t.Fatalf("composite mapping history = %#v", labels)
|
t.Fatalf("composite mapping history = %#v", labels)
|
||||||
}
|
}
|
||||||
if labels.Origin.Layer != LayerCLI || len(labels.Origin.Locations) != 3 {
|
if labels.Origin.Layer != LayerCLI || len(labels.Origin.Locations) != 3 {
|
||||||
@@ -258,7 +264,7 @@ dynamic: selected
|
|||||||
var invalidInput bytes.Buffer
|
var invalidInput bytes.Buffer
|
||||||
if err := result.WriteExplanation(&invalidInput, Keys("limit")); err != nil ||
|
if err := result.WriteExplanation(&invalidInput, Keys("limit")); err != nil ||
|
||||||
!strings.Contains(invalidInput.String(), "policy-validated input") ||
|
!strings.Contains(invalidInput.String(), "policy-validated input") ||
|
||||||
strings.Contains(invalidInput.String(), "wrong-before-profile\n source: layer=\"base\"\n before (canonical typed)") {
|
strings.Contains(invalidInput.String(), "wrong-before-overlay\n source: layer=\"base\"\n before (canonical typed)") {
|
||||||
t.Fatalf("historical representation rendering = %v\n%s", err, invalidInput.String())
|
t.Fatalf("historical representation rendering = %v\n%s", err, invalidInput.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,8 +346,8 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
|||||||
if cli {
|
if cli {
|
||||||
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
|
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
|
||||||
} else {
|
} else {
|
||||||
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
|
writeImportFile(t, root, "overlays/p.yml", "m: {b: 2}\n")
|
||||||
opts.Profile = "p"
|
opts.Overlay = "p"
|
||||||
}
|
}
|
||||||
result, err := testLoad(File(main), opts)
|
result, err := testLoad(File(main), opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -229,11 +229,11 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) {
|
func TestFilesystemContextIsValidatedWithoutImportsOrActiveOverlay(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n")
|
||||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
|
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||||
Profile: "default", Filesystem: &FilesystemContext{Root: directory},
|
Overlay: "default", Filesystem: &FilesystemContext{Root: directory},
|
||||||
})
|
})
|
||||||
if err != nil || result.Config.Items["root"] != "root" {
|
if err != nil || result.Config.Items["root"] != "root" {
|
||||||
t.Fatalf("file filesystem context = %#v, %v", result, err)
|
t.Fatalf("file filesystem context = %#v, %v", result, err)
|
||||||
@@ -247,9 +247,9 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T)
|
|||||||
t.Fatalf("byte filesystem context = %#v, %v", result, err)
|
t.Fatalf("byte filesystem context = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
requireLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory}}, CodeOptionsInvalid)
|
requireLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory}}, CodeOptionsInvalid)
|
||||||
defaultsOnly, err := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{Profile: "default"})
|
defaultsOnly, err := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{Overlay: "default"})
|
||||||
if err != nil || defaultsOnly == nil {
|
if err != nil || defaultsOnly == nil {
|
||||||
t.Fatalf("default profile with NoSource = %#v, %v", defaultsOnly, err)
|
t.Fatalf("default overlay with NoSource = %#v, %v", defaultsOnly, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
|
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ settings:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadImportsFromProfilesDirectoryAsBaseInput(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/shared.yml}]}\n")
|
||||||
|
writeImportFile(t, directory, "profiles/shared.yml", "settings: {root: imported}\n")
|
||||||
|
|
||||||
|
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
|
if err != nil || result.Config.Settings.Root != "imported" || len(result.Sources()) != 2 ||
|
||||||
|
result.Sources()[1].Layer != LayerBase {
|
||||||
|
t.Fatalf("profiles import = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
||||||
t.Run("all duplicate claimants", func(t *testing.T) {
|
t.Run("all duplicate claimants", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
|
|||||||
@@ -19,17 +19,17 @@ 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; Profile selects one fixed profile
|
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
||||||
// overlay; Overrides contains only values explicitly supplied by an application CLI.
|
// Overrides contains only values explicitly supplied by an application CLI.
|
||||||
type Options[T any] struct {
|
type Options[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
Imports bool
|
Imports bool
|
||||||
Profile string
|
Overlay string
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemContext supplies filesystem roots for imports and profiles.
|
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||||
type FilesystemContext struct {
|
type FilesystemContext struct {
|
||||||
BaseDir string
|
BaseDir string
|
||||||
Root string
|
Root string
|
||||||
@@ -113,7 +113,7 @@ func (r *Inspection) Diagnostics() []Diagnostic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load parses and strictly decodes zero or one selected source, explicitly enabled
|
// Load parses and strictly decodes zero or one selected source, explicitly enabled
|
||||||
// imports, and one selected profile. It accepts at most one Options value and
|
// imports, and one selected overlay. It accepts at most one Options value and
|
||||||
// returns no result when any error is reported.
|
// returns no result when any error is reported.
|
||||||
func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (*Loaded[T], error) {
|
func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (*Loaded[T], error) {
|
||||||
return loadWithSecretLookup(source, secrets, os.LookupEnv, supplied...)
|
return loadWithSecretLookup(source, secrets, os.LookupEnv, supplied...)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput
|
|||||||
input.document = input.composition.root
|
input.document = input.composition.root
|
||||||
} else {
|
} else {
|
||||||
input.sources = sourceInfos(parsed.source)
|
input.sources = sourceInfos(parsed.source)
|
||||||
if options.Filesystem != nil && !activeProfile(options.Profile) {
|
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
||||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||||
return input, []Diagnostic{*diagnostic}
|
return input, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
@@ -40,17 +40,17 @@ func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) {
|
func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) {
|
||||||
if !activeProfile(options.Profile) && len(options.Overrides) == 0 {
|
if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 {
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
composition, diagnostics := compositionForInput(input)
|
composition, diagnostics := compositionForInput(input)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
if activeProfile(options.Profile) {
|
if activeOverlay(options.Overlay) {
|
||||||
composition, input.sources, diagnostics = loadProfileDocument(
|
composition, input.sources, diagnostics = loadSelectedOverlayDocument(
|
||||||
source, options.Filesystem, input.parsed, composition, input.sources,
|
source, options.Filesystem, input.parsed, composition, input.sources,
|
||||||
options.Profile, options.Definition.Version, defaultImportLimits(),
|
options.Overlay, options.Definition.Version, defaultImportLimits(),
|
||||||
)
|
)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
@@ -69,7 +69,7 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
|
|||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// compositionForInput initializes one handoff for profiles and CLI, including
|
// compositionForInput initializes one handoff for overlays and CLI, including
|
||||||
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
|
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
|
||||||
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
|
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
|
||||||
if input.composition != nil {
|
if input.composition != nil {
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *
|
|||||||
root := writeImportFile(t, dir, "main.yml", "configkit:\n imports: [{path: first.yml}, {path: second.yml}]\ncredential: CONFIGKIT_HISTORY_UNUSED_PROD_KEY\n")
|
root := writeImportFile(t, dir, "main.yml", "configkit:\n imports: [{path: first.yml}, {path: second.yml}]\ncredential: CONFIGKIT_HISTORY_UNUSED_PROD_KEY\n")
|
||||||
writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n")
|
writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n")
|
||||||
writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n")
|
writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n")
|
||||||
writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n")
|
writeImportFile(t, dir, "overlays/testing.yml", "definitions: null\ncredential: "+credential+"\n")
|
||||||
result, err := testLoad[config](File(root), Options[config]{Imports: true, Profile: "testing"})
|
result, err := testLoad[config](File(root), Options[config]{Imports: true, Overlay: "testing"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
}
|
}
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
definition := options.Definition
|
definition := options.Definition
|
||||||
if !validProfileName(options.Profile) {
|
if !validOverlayName(options.Overlay) {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay 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)...)
|
||||||
@@ -19,11 +19,11 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
if source.kind == sourceNone && options.Imports {
|
if source.kind == sourceNone && options.Imports {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
|
||||||
}
|
}
|
||||||
if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" {
|
if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay"))
|
||||||
}
|
}
|
||||||
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) {
|
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeOverlay(options.Overlay)) {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or overlays require filesystem context"))
|
||||||
}
|
}
|
||||||
if definition.Version != nil {
|
if definition.Version != nil {
|
||||||
if !definition.Version.Path.valid() {
|
if !definition.Version.Path.valid() {
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ type displacedDefinition struct {
|
|||||||
func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) {
|
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 {
|
if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
|
||||||
return nil, []Diagnostic{{
|
return nil, []Diagnostic{{
|
||||||
Code: CodeProfileInvalid, Severity: SeverityError,
|
Code: CodeOverlayInvalid, Severity: SeverityError,
|
||||||
Message: "profile overlay requires mapping document roots",
|
Message: "overlay requires mapping document roots",
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
result := cloneComposedDocument(base)
|
result := cloneComposedDocument(base)
|
||||||
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerOverlay)
|
||||||
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerOverlay, true)
|
||||||
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
|
result.applications = append(result.applications, historyApplication{layer: LayerOverlay, value: overlay})
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ to_scalar: {child: old}
|
|||||||
}
|
}
|
||||||
overlay := parseCompositionDocument(t, "source-2", `shared:
|
overlay := parseCompositionDocument(t, "source-2", `shared:
|
||||||
changed: new
|
changed: new
|
||||||
added: profile
|
added: overlay
|
||||||
sequence: []
|
sequence: []
|
||||||
nullable: null
|
nullable: null
|
||||||
to_map: {child: new}
|
to_map: {child: new}
|
||||||
@@ -42,7 +42,7 @@ to_scalar: scalar
|
|||||||
}{
|
}{
|
||||||
{path: Keys("shared", "kept"), kind: document.String, text: "base"},
|
{path: Keys("shared", "kept"), kind: document.String, text: "base"},
|
||||||
{path: Keys("shared", "changed"), kind: document.String, text: "new"},
|
{path: Keys("shared", "changed"), kind: document.String, text: "new"},
|
||||||
{path: Keys("shared", "added"), kind: document.String, text: "profile"},
|
{path: Keys("shared", "added"), kind: document.String, text: "overlay"},
|
||||||
{path: Keys("sequence"), kind: document.Sequence},
|
{path: Keys("sequence"), kind: document.Sequence},
|
||||||
{path: Keys("nullable"), kind: document.Null},
|
{path: Keys("nullable"), kind: document.Null},
|
||||||
{path: Keys("to_map"), kind: document.Mapping},
|
{path: Keys("to_map"), kind: document.Mapping},
|
||||||
@@ -62,7 +62,7 @@ to_scalar: scalar
|
|||||||
t.Fatal("parent replacement retained a stale child origin")
|
t.Fatal("parent replacement retained a stale child origin")
|
||||||
}
|
}
|
||||||
shared := result.origins["/shared"]
|
shared := result.origins["/shared"]
|
||||||
if shared.Layer != LayerProfile || len(shared.Locations) != 2 {
|
if shared.Layer != LayerOverlay || len(shared.Locations) != 2 {
|
||||||
t.Fatalf("shared mapping origin = %#v", shared)
|
t.Fatalf("shared mapping origin = %#v", shared)
|
||||||
}
|
}
|
||||||
if len(result.overlays) != 5 {
|
if len(result.overlays) != 5 {
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
|||||||
labels: {base: retained}
|
labels: {base: retained}
|
||||||
api_key: BASE_SECRET
|
api_key: BASE_SECRET
|
||||||
`)
|
`)
|
||||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `settings:
|
writeImportFile(t, directory, filepath.Join("overlays", "testing.yml"), `settings:
|
||||||
target: profile
|
target: overlay
|
||||||
labels: {profile: retained}
|
labels: {overlay: retained}
|
||||||
`)
|
`)
|
||||||
validated := false
|
validated := false
|
||||||
overrides := []Override{
|
overrides := []Override{
|
||||||
@@ -62,7 +62,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
|||||||
}
|
}
|
||||||
return "credential-value", true
|
return "credential-value", true
|
||||||
}, Options[overrideLifecycleConfig]{
|
}, Options[overrideLifecycleConfig]{
|
||||||
Imports: true, Profile: "testing", Overrides: overrides,
|
Imports: true, Overlay: "testing", Overrides: overrides,
|
||||||
Definition: Definition[overrideLifecycleConfig]{
|
Definition: Definition[overrideLifecycleConfig]{
|
||||||
Defaults: func() overrideLifecycleConfig {
|
Defaults: func() overrideLifecycleConfig {
|
||||||
return overrideLifecycleConfig{Settings: overrideLifecycleSettings{Target: "default", Count: 9, Text: "default", Items: []string{"default"}}}
|
return overrideLifecycleConfig{Settings: overrideLifecycleSettings{Target: "default", Count: 9, Text: "default", Items: []string{"default"}}}
|
||||||
@@ -86,7 +86,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
|||||||
t.Fatalf("testLoadWithSecretLookup() error = %v", err)
|
t.Fatalf("testLoadWithSecretLookup() error = %v", err)
|
||||||
}
|
}
|
||||||
want := overrideLifecycleSettings{
|
want := overrideLifecycleSettings{
|
||||||
Target: "cli", Labels: map[string]string{"base": "retained", "profile": "retained", "cli": "selected"},
|
Target: "cli", Labels: map[string]string{"base": "retained", "overlay": "retained", "cli": "selected"},
|
||||||
APIKey: result.Config.Settings.APIKey,
|
APIKey: result.Config.Settings.APIKey,
|
||||||
}
|
}
|
||||||
if !validated || result.Config.Settings.Count != 0 || result.Config.Settings.Enabled || result.Config.Settings.Text != "" ||
|
if !validated || result.Config.Settings.Count != 0 || result.Config.Settings.Enabled || result.Config.Settings.Text != "" ||
|
||||||
@@ -95,7 +95,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
|||||||
t.Fatalf("effective settings = %#v", result.Config.Settings)
|
t.Fatalf("effective settings = %#v", result.Config.Settings)
|
||||||
}
|
}
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
if len(sources) != 3+len(overrides) || sources[0].Layer != LayerBase || sources[1].Layer != LayerBase || sources[2].Layer != LayerProfile {
|
if len(sources) != 3+len(overrides) || sources[0].Layer != LayerBase || sources[1].Layer != LayerBase || sources[2].Layer != LayerOverlay {
|
||||||
t.Fatalf("sources = %#v", sources)
|
t.Fatalf("sources = %#v", sources)
|
||||||
}
|
}
|
||||||
for index, override := range overrides {
|
for index, override := range overrides {
|
||||||
@@ -104,7 +104,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
history := result.Metadata().History(Keys("settings", "target"))
|
history := result.Metadata().History(Keys("settings", "target"))
|
||||||
wantLayers := []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}
|
wantLayers := []Layer{LayerDefault, LayerBase, LayerOverlay, LayerCLI, LayerResolution}
|
||||||
if got := historyLayers(history); !reflect.DeepEqual(got, wantLayers) {
|
if got := historyLayers(history); !reflect.DeepEqual(got, wantLayers) {
|
||||||
t.Fatalf("target history layers = %#v, want %#v; history %#v", got, wantLayers, history)
|
t.Fatalf("target history layers = %#v, want %#v; history %#v", got, wantLayers, history)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type layeredConfig struct {
|
|||||||
Shared struct {
|
Shared struct {
|
||||||
Equal string `yaml:"equal"`
|
Equal string `yaml:"equal"`
|
||||||
Imported string `yaml:"imported"`
|
Imported string `yaml:"imported"`
|
||||||
Profile string `yaml:"profile"`
|
Overlay string `yaml:"overlay"`
|
||||||
} `yaml:"shared"`
|
} `yaml:"shared"`
|
||||||
Values []int `yaml:"values"`
|
Values []int `yaml:"values"`
|
||||||
Items map[string]layeredItem `yaml:"items"`
|
Items map[string]layeredItem `yaml:"items"`
|
||||||
@@ -40,7 +40,7 @@ nullable: base
|
|||||||
empty: {}
|
empty: {}
|
||||||
`)
|
`)
|
||||||
importPath := writeImportFile(t, directory, "imported.yml", "shared: {imported: base}\n")
|
importPath := writeImportFile(t, directory, "imported.yml", "shared: {imported: base}\n")
|
||||||
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `shared: {profile: selected}
|
overlayPath := writeImportFile(t, directory, "overlays/testing.yml", `shared: {overlay: selected}
|
||||||
values: [3, 4]
|
values: [3, 4]
|
||||||
value: selected
|
value: selected
|
||||||
nullable: null
|
nullable: null
|
||||||
@@ -71,23 +71,23 @@ empty: {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := testLoad[layeredConfig](File(mainPath), Options[layeredConfig]{
|
result, err := testLoad[layeredConfig](File(mainPath), Options[layeredConfig]{
|
||||||
Imports: true, Profile: "testing", Definition: definition,
|
Imports: true, Overlay: "testing", Definition: definition,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var loadError *LoadError
|
var loadError *LoadError
|
||||||
_ = errors.As(err, &loadError)
|
_ = errors.As(err, &loadError)
|
||||||
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
|
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
|
||||||
}
|
}
|
||||||
wantSources := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, importPath), canonicalTestPath(t, profilePath)}
|
wantSources := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, importPath), canonicalTestPath(t, overlayPath)}
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
gotSources := make([]string, len(sources))
|
gotSources := make([]string, len(sources))
|
||||||
for index, source := range sources {
|
for index, source := range sources {
|
||||||
gotSources[index] = source.Path
|
gotSources[index] = source.Path
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(gotSources, wantSources) || sources[2].Layer != LayerProfile {
|
if !reflect.DeepEqual(gotSources, wantSources) || sources[2].Layer != LayerOverlay {
|
||||||
t.Fatalf("sources = %#v", sources)
|
t.Fatalf("sources = %#v", sources)
|
||||||
}
|
}
|
||||||
if result.Config.Shared.Equal != "same" || result.Config.Shared.Imported != "base" || result.Config.Shared.Profile != "selected" ||
|
if result.Config.Shared.Equal != "same" || result.Config.Shared.Imported != "base" || result.Config.Shared.Overlay != "selected" ||
|
||||||
!reflect.DeepEqual(result.Config.Values, []int{5}) || result.Config.Nullable != nil || result.Config.Derived != 7 ||
|
!reflect.DeepEqual(result.Config.Values, []int{5}) || result.Config.Nullable != nil || result.Config.Derived != 7 ||
|
||||||
result.Config.Empty["retained"] != "default" {
|
result.Config.Empty["retained"] != "default" {
|
||||||
t.Fatalf("configuration = %#v", result.Config)
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
@@ -100,13 +100,13 @@ empty: {}
|
|||||||
empty, _ := metadata.Lookup(Keys("empty"))
|
empty, _ := metadata.Lookup(Keys("empty"))
|
||||||
firstValue, _ := metadata.Lookup(Keys("values").Index(0))
|
firstValue, _ := metadata.Lookup(Keys("values").Index(0))
|
||||||
if !equal.Explicit || equal.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault ||
|
if !equal.Explicit || equal.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault ||
|
||||||
!nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerProfile ||
|
!nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerOverlay ||
|
||||||
!empty.Explicit || empty.Origin.Layer != LayerProfile || len(empty.Origin.Locations) != 2 ||
|
!empty.Explicit || empty.Origin.Layer != LayerOverlay || len(empty.Origin.Locations) != 2 ||
|
||||||
!firstValue.Explicit || firstValue.Origin.Operation != ChangeNormalization {
|
!firstValue.Explicit || firstValue.Origin.Operation != ChangeNormalization {
|
||||||
t.Fatalf("metadata = equal %#v derived %#v nullable %#v empty %#v value %#v", equal, derived, nullable, empty, firstValue)
|
t.Fatalf("metadata = equal %#v derived %#v nullable %#v empty %#v value %#v", equal, derived, nullable, empty, firstValue)
|
||||||
}
|
}
|
||||||
if _, ok := metadata.Lookup(Keys("value", "child")); ok {
|
if _, ok := metadata.Lookup(Keys("value", "child")); ok {
|
||||||
t.Fatal("profile-removed child remains effective")
|
t.Fatal("overlay-removed child remains effective")
|
||||||
}
|
}
|
||||||
if _, ok := metadata.Lookup(Keys("values").Index(1)); ok {
|
if _, ok := metadata.Lookup(Keys("values").Index(1)); ok {
|
||||||
t.Fatal("resolution-removed sequence index remains effective")
|
t.Fatal("resolution-removed sequence index remains effective")
|
||||||
@@ -116,10 +116,10 @@ empty: {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertHistoryLayersAndPaths(t, metadata.History(Keys("values").Index(1)),
|
assertHistoryLayersAndPaths(t, metadata.History(Keys("values").Index(1)),
|
||||||
[]Layer{LayerDefault, LayerBase, LayerProfile, LayerResolution},
|
[]Layer{LayerDefault, LayerBase, LayerOverlay, LayerResolution},
|
||||||
[]string{"/values/1", "/values/1", "/values", "/values"})
|
[]string{"/values/1", "/values/1", "/values", "/values"})
|
||||||
assertHistoryLayersAndPaths(t, metadata.History(Keys("value", "child")),
|
assertHistoryLayersAndPaths(t, metadata.History(Keys("value", "child")),
|
||||||
[]Layer{LayerDefault, LayerBase, LayerProfile},
|
[]Layer{LayerDefault, LayerBase, LayerOverlay},
|
||||||
[]string{"/value/child", "/value/child", "/value"})
|
[]string{"/value/child", "/value/child", "/value"})
|
||||||
removedHistory := metadata.History(Keys("items", "remove", "value"))
|
removedHistory := metadata.History(Keys("items", "remove", "value"))
|
||||||
assertHistoryLayersAndPaths(t, removedHistory,
|
assertHistoryLayersAndPaths(t, removedHistory,
|
||||||
@@ -159,11 +159,11 @@ empty: {}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) {
|
func TestSelectedOverlayLoadsRemainIndependentConcurrently(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "values: [1, 2]\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "values: [1, 2]\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "values: [3, 4]\n")
|
writeImportFile(t, directory, "overlays/testing.yml", "values: [3, 4]\n")
|
||||||
options := Options[layeredConfig]{Profile: "testing"}
|
options := Options[layeredConfig]{Overlay: "testing"}
|
||||||
|
|
||||||
const loads = 16
|
const loads = 16
|
||||||
results := make(chan *testResult[layeredConfig], loads)
|
results := make(chan *testResult[layeredConfig], loads)
|
||||||
@@ -200,7 +200,7 @@ func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(result.Config.Values, []int{3, 4}) {
|
if !reflect.DeepEqual(result.Config.Values, []int{3, 4}) {
|
||||||
t.Fatalf("concurrent result contaminated = %#v", result.Config.Values)
|
t.Fatalf("concurrent result contaminated = %#v", result.Config.Values)
|
||||||
}
|
}
|
||||||
if info, ok := result.Metadata().Lookup(Keys("values")); !ok || info.Origin.Layer != LayerProfile {
|
if info, ok := result.Metadata().Lookup(Keys("values")); !ok || info.Origin.Layer != LayerOverlay {
|
||||||
t.Fatalf("concurrent metadata contaminated = %#v, %v", info, ok)
|
t.Fatalf("concurrent metadata contaminated = %#v, %v", info, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +220,7 @@ sequence: [first, second]
|
|||||||
composite: {first: first}
|
composite: {first: first}
|
||||||
`)
|
`)
|
||||||
secondPath := writeImportFile(t, directory, "second.yml", "composite: {second: second}\n")
|
secondPath := writeImportFile(t, directory, "second.yml", "composite: {second: second}\n")
|
||||||
profilePath := writeImportFile(t, directory, "profiles/testing.yml", "equal: same\nprofile: profile\n")
|
overlayPath := writeImportFile(t, directory, "overlays/testing.yml", "equal: same\noverlay: overlay\n")
|
||||||
compositeSources := map[string]bool{
|
compositeSources := map[string]bool{
|
||||||
canonicalTestPath(t, mainPath): true,
|
canonicalTestPath(t, mainPath): true,
|
||||||
canonicalTestPath(t, firstPath): true,
|
canonicalTestPath(t, firstPath): true,
|
||||||
@@ -232,12 +232,12 @@ composite: {first: first}
|
|||||||
Sequence []string `yaml:"sequence"`
|
Sequence []string `yaml:"sequence"`
|
||||||
Composite map[string]string `yaml:"composite"`
|
Composite map[string]string `yaml:"composite"`
|
||||||
Equal string `yaml:"equal"`
|
Equal string `yaml:"equal"`
|
||||||
Profile string `yaml:"profile"`
|
Overlay string `yaml:"overlay"`
|
||||||
CLIEqual string `yaml:"cli_equal"`
|
CLIEqual string `yaml:"cli_equal"`
|
||||||
}
|
}
|
||||||
var callbackErr error
|
var callbackErr error
|
||||||
result, err := testLoad[config](File(mainPath), Options[config]{
|
result, err := testLoad[config](File(mainPath), Options[config]{
|
||||||
Imports: true, Profile: "testing", Overrides: []Override{{Path: Keys("cli_equal"), Value: "same", Flag: "--cli-equal"}},
|
Imports: true, Overlay: "testing", Overrides: []Override{{Path: Keys("cli_equal"), Value: "same", Flag: "--cli-equal"}},
|
||||||
Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
check := func(ok bool, message string) {
|
check := func(ok bool, message string) {
|
||||||
if !ok && callbackErr == nil {
|
if !ok && callbackErr == nil {
|
||||||
@@ -257,9 +257,9 @@ composite: {first: first}
|
|||||||
fileOwner(Keys("main"), canonicalTestPath(t, mainPath))
|
fileOwner(Keys("main"), canonicalTestPath(t, mainPath))
|
||||||
fileOwner(Keys("imported"), canonicalTestPath(t, firstPath))
|
fileOwner(Keys("imported"), canonicalTestPath(t, firstPath))
|
||||||
fileOwner(Keys("sequence").Index(1), canonicalTestPath(t, firstPath))
|
fileOwner(Keys("sequence").Index(1), canonicalTestPath(t, firstPath))
|
||||||
profile := fileOwner(Keys("equal"), canonicalTestPath(t, profilePath))
|
overlay := fileOwner(Keys("equal"), canonicalTestPath(t, overlayPath))
|
||||||
fileOwner(Keys("profile"), canonicalTestPath(t, profilePath))
|
fileOwner(Keys("overlay"), canonicalTestPath(t, overlayPath))
|
||||||
check(profile.Origin.Layer == LayerProfile, "profile replacement retained base ownership")
|
check(overlay.Origin.Layer == LayerOverlay, "overlay replacement retained base ownership")
|
||||||
cli, found := resolution.InputInfo(Keys("cli_equal"))
|
cli, found := resolution.InputInfo(Keys("cli_equal"))
|
||||||
check(found && cli.Origin.Layer == LayerCLI && len(cli.Origin.Locations) == 1, "CLI replacement lost ownership")
|
check(found && cli.Origin.Layer == LayerCLI && len(cli.Origin.Locations) == 1, "CLI replacement lost ownership")
|
||||||
if found && len(cli.Origin.Locations) == 1 {
|
if found && len(cli.Origin.Locations) == 1 {
|
||||||
@@ -298,7 +298,7 @@ composite: {first: first}
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Config.Main != "main" || result.Config.Imported != "imported" || !reflect.DeepEqual(result.Config.Sequence, []string{"first", "second"}) ||
|
if result.Config.Main != "main" || result.Config.Imported != "imported" || !reflect.DeepEqual(result.Config.Sequence, []string{"first", "second"}) ||
|
||||||
result.Config.Equal != "same" || result.Config.Profile != "profile" || result.Config.CLIEqual != "same" {
|
result.Config.Equal != "same" || result.Config.Overlay != "overlay" || result.Config.CLIEqual != "same" {
|
||||||
t.Fatalf("configuration = %#v", result.Config)
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type Layer string
|
|||||||
const (
|
const (
|
||||||
LayerDefault Layer = "default"
|
LayerDefault Layer = "default"
|
||||||
LayerBase Layer = "base"
|
LayerBase Layer = "base"
|
||||||
LayerProfile Layer = "profile"
|
LayerOverlay Layer = "overlay"
|
||||||
LayerCLI Layer = "cli"
|
LayerCLI Layer = "cli"
|
||||||
LayerResolution Layer = "resolution"
|
LayerResolution Layer = "resolution"
|
||||||
)
|
)
|
||||||
@@ -90,13 +90,13 @@ const (
|
|||||||
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
||||||
CodeImportVersion Code = "import.version"
|
CodeImportVersion Code = "import.version"
|
||||||
|
|
||||||
CodeProfileInvalid Code = "profile.invalid"
|
CodeOverlayInvalid Code = "overlay.invalid"
|
||||||
CodeProfileMissing Code = "profile.missing"
|
CodeOverlayMissing Code = "overlay.missing"
|
||||||
CodeProfileOutsideRoot Code = "profile.outside_root"
|
CodeOverlayOutsideRoot Code = "overlay.outside_root"
|
||||||
CodeProfileRepeated Code = "profile.repeated"
|
CodeOverlayRepeated Code = "overlay.repeated"
|
||||||
CodeProfileDirective Code = "profile.directive"
|
CodeOverlayDirective Code = "overlay.directive"
|
||||||
CodeProfileVersion Code = "profile.version"
|
CodeOverlayVersion Code = "overlay.version"
|
||||||
CodeProfileLimitExceeded Code = "profile.limit_exceeded"
|
CodeOverlayLimitExceeded Code = "overlay.limit_exceeded"
|
||||||
|
|
||||||
CodeComposeInvalid Code = "compose.invalid"
|
CodeComposeInvalid Code = "compose.invalid"
|
||||||
CodeComposeConflict Code = "compose.conflict"
|
CodeComposeConflict Code = "compose.conflict"
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ empty_sequence: []
|
|||||||
labels: {hidden: DYNAMIC_CANARY_6a12, public: visible}
|
labels: {hidden: DYNAMIC_CANARY_6a12, public: visible}
|
||||||
sequence: [second, first]
|
sequence: [second, first]
|
||||||
`)
|
`)
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "dynamic: selected\n")
|
writeImportFile(t, directory, "overlays/testing.yml", "dynamic: selected\n")
|
||||||
definition := Definition[renderConfig]{
|
definition := Definition[renderConfig]{
|
||||||
Secret: func(path Path) bool {
|
Secret: func(path Path) bool {
|
||||||
return path.String() == "/labels/hidden" || path.String() == "/dynamic/retired"
|
return path.String() == "/labels/hidden" || path.String() == "/dynamic/retired"
|
||||||
@@ -85,7 +85,7 @@ sequence: [second, first]
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
result, err := testLoad[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Profile: "testing"})
|
result, err := testLoad[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Overlay: "testing"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ type stagedSecret struct {
|
|||||||
// LoadSecretsDir explicitly loads immediate regular files from directory into
|
// LoadSecretsDir explicitly loads immediate regular files from directory into
|
||||||
// previously unset process environment variables. Applications should call it
|
// previously unset process environment variables. Applications should call it
|
||||||
// once during startup, before concurrent work; Load never calls it.
|
// once during startup, before concurrent work; Load never calls it.
|
||||||
|
// Dot-prefixed entries are ignored and omitted from the report.
|
||||||
func LoadSecretsDir(directory string) (*SecretsReport, error) {
|
func LoadSecretsDir(directory string) (*SecretsReport, error) {
|
||||||
return loadSecretsDir(directory, secretsEnvironment{lookup: os.LookupEnv, set: os.Setenv}, secretsAccess{
|
return loadSecretsDir(directory, secretsEnvironment{lookup: os.LookupEnv, set: os.Setenv}, secretsAccess{
|
||||||
openDirectory: os.Open,
|
openDirectory: os.Open,
|
||||||
@@ -168,13 +169,17 @@ func loadSecretsDir(directory string, environment secretsEnvironment, access sec
|
|||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return report, secretsFailure(CodeSecretsRead, "secrets directory could not be closed: "+strconv.Quote(canonical))
|
return report, secretsFailure(CodeSecretsRead, "secrets directory could not be closed: "+strconv.Quote(canonical))
|
||||||
}
|
}
|
||||||
|
entryLimitExceeded := len(entries) > secretsEntryLimit
|
||||||
|
entries = slices.DeleteFunc(entries, func(entry fs.DirEntry) bool {
|
||||||
|
return strings.HasPrefix(entry.Name(), ".")
|
||||||
|
})
|
||||||
slices.SortFunc(entries, func(left, right fs.DirEntry) int { return strings.Compare(left.Name(), right.Name()) })
|
slices.SortFunc(entries, func(left, right fs.DirEntry) int { return strings.Compare(left.Name(), right.Name()) })
|
||||||
report.entries = make([]SecretFileResult, len(entries))
|
report.entries = make([]SecretFileResult, len(entries))
|
||||||
for index, entry := range entries {
|
for index, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
report.entries[index] = SecretFileResult{Name: name, Path: filepath.Join(canonical, name), Status: SecretFileNotApplied}
|
report.entries[index] = SecretFileResult{Name: name, Path: filepath.Join(canonical, name), Status: SecretFileNotApplied}
|
||||||
}
|
}
|
||||||
if len(entries) > secretsEntryLimit {
|
if entryLimitExceeded {
|
||||||
return report, secretsFailure(CodeSecretsLimitExceeded, "secrets directory exceeds the 1024-entry limit: "+strconv.Quote(canonical))
|
return report, secretsFailure(CodeSecretsLimitExceeded, "secrets directory exceeds the 1024-entry limit: "+strconv.Quote(canonical))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,41 @@ func TestLoadSecretsDirLoadsCanonicalSortedValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsDirIgnoresDotEntries(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(directory, ".git"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeSecretTestFile(t, directory, ".gitignore", "CANARY\x00")
|
||||||
|
writeSecretTestFile(t, directory, ".DS_Store", "CANARY\x00")
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
if err := os.Symlink(filepath.Join(directory, "missing"), filepath.Join(directory, ".hidden-link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
access := testSecretsAccess()
|
||||||
|
access.lstat = func(path string) (fs.FileInfo, error) {
|
||||||
|
if strings.HasPrefix(filepath.Base(path), ".") {
|
||||||
|
t.Fatalf("inspected ignored entry %q", path)
|
||||||
|
}
|
||||||
|
return os.Lstat(path)
|
||||||
|
}
|
||||||
|
environment := map[string]string{}
|
||||||
|
report, err := loadSecretsDir(directory, mapSecretsEnvironment(environment), access)
|
||||||
|
if err != nil || len(report.Entries()) != 0 || len(environment) != 0 {
|
||||||
|
t.Fatalf("hidden-only directory: error = %v, report = %#v, environment = %#v", err, report.Entries(), environment)
|
||||||
|
}
|
||||||
|
writeSecretTestFile(t, directory, "SECRET", "value\n")
|
||||||
|
report, err = loadSecretsDir(directory, mapSecretsEnvironment(environment), access)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []SecretFileResult{{Name: "SECRET", Path: filepath.Join(canonicalTestPath(t, directory), "SECRET"), Status: SecretFileLoaded}}
|
||||||
|
if !reflect.DeepEqual(report.Entries(), want) || !reflect.DeepEqual(environment, map[string]string{"SECRET": "value"}) {
|
||||||
|
t.Fatalf("mixed directory: report = %#v, environment = %#v", report.Entries(), environment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadSecretsDirPreservesExistingWithoutReading(t *testing.T) {
|
func TestLoadSecretsDirPreservesExistingWithoutReading(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeSecretTestFile(t, directory, "EMPTY_WINNER", strings.Repeat("CANARY", int(secretFileByteLimit)))
|
writeSecretTestFile(t, directory, "EMPTY_WINNER", strings.Repeat("CANARY", int(secretFileByteLimit)))
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import (
|
|||||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
func activeProfile(name string) bool {
|
func activeOverlay(name string) bool {
|
||||||
return name != "" && name != "default"
|
return name != "" && name != "default"
|
||||||
}
|
}
|
||||||
|
|
||||||
func validProfileName(name string) bool {
|
func validOverlayName(name string) bool {
|
||||||
if !activeProfile(name) {
|
if !activeOverlay(name) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, character := range name {
|
for _, character := range name {
|
||||||
@@ -31,38 +31,38 @@ func validProfileName(name string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadProfileDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func loadSelectedOverlayDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
return nil, cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
profilePath := filepath.Join(rootPath, "profiles", name+".yml")
|
overlayPath := filepath.Join(rootPath, "overlays", name+".yml")
|
||||||
canonical, err := canonicalExistingPath(profilePath)
|
canonical, err := canonicalExistingPath(overlayPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
code := CodeProfileInvalid
|
code := CodeOverlayInvalid
|
||||||
message := "selected profile could not be resolved"
|
message := "selected overlay could not be resolved"
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
code, message = CodeProfileMissing, "selected profile does not exist"
|
code, message = CodeOverlayMissing, "selected overlay does not exist"
|
||||||
} else if errors.Is(err, fs.ErrPermission) {
|
} else if errors.Is(err, fs.ErrPermission) {
|
||||||
code, message = CodeImportPermission, "selected profile cannot be accessed"
|
code, message = CodeImportPermission, "selected overlay cannot be accessed"
|
||||||
}
|
}
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(code, message, nil)}
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(code, message, nil)}
|
||||||
}
|
}
|
||||||
if !containedPath(rootPath, canonical) {
|
if !containedPath(rootPath, canonical) {
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile resolves outside the configuration root", nil)}
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)}
|
||||||
}
|
}
|
||||||
for _, existing := range sources {
|
for _, existing := range sources {
|
||||||
if existing.Path != "" && existing.Path == canonical {
|
if existing.Path != "" && existing.Path == canonical {
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileRepeated, "selected profile is already present in the base source graph", nil)}
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayRepeated, "selected overlay is already present in the base source graph", nil)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(sources)+1 > limits.sources {
|
if len(sources)+1 > limits.sources {
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the source-count limit", nil)}
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the source-count limit", nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
root, err := os.OpenRoot(rootPath)
|
root, err := os.OpenRoot(rootPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected profile", Location{})
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected overlay", Location{})
|
||||||
value.Primary = nil
|
value.Primary = nil
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{value}
|
return nil, cloneSourceInfos(sources), []Diagnostic{value}
|
||||||
}
|
}
|
||||||
@@ -74,57 +74,57 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS
|
|||||||
}()
|
}()
|
||||||
relative, err := filepath.Rel(rootPath, canonical)
|
relative, err := filepath.Rel(rootPath, canonical)
|
||||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)}
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay could not be addressed inside the configuration root", nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceID := "source-" + strconv.Itoa(len(sources)+1)
|
sourceID := "source-" + strconv.Itoa(len(sources)+1)
|
||||||
profileLocation := &Location{SourceID: sourceID}
|
overlayLocation := &Location{SourceID: sourceID}
|
||||||
profileSource := SourceInfo{ID: sourceID, Label: filepath.ToSlash(filepath.Join("profiles", name+".yml")), Path: canonical, Layer: LayerProfile}
|
overlaySource := SourceInfo{ID: sourceID, Label: filepath.ToSlash(filepath.Join("overlays", name+".yml")), Path: canonical, Layer: LayerOverlay}
|
||||||
sources = append(cloneSourceInfos(sources), profileSource)
|
sources = append(cloneSourceInfos(sources), overlaySource)
|
||||||
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
|
data, diagnostic := readOverlayFile(root, relative, sourceID, limits.bytes-base.inputBytes)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return nil, sources, []Diagnostic{*diagnostic}
|
return nil, sources, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
|
overlayRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return nil, sources, parserDiagnostics(sourceID, issues)
|
return nil, sources, parserDiagnostics(sourceID, issues)
|
||||||
}
|
}
|
||||||
nodeCount := countInputNodes(profileRoot)
|
nodeCount := countInputNodes(overlayRoot)
|
||||||
if base.inputNodes+nodeCount > limits.nodes {
|
if base.inputNodes+nodeCount > limits.nodes {
|
||||||
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
|
return nil, sources, []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the aggregate node limit", overlayLocation)}
|
||||||
}
|
}
|
||||||
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
|
if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}); len(diagnostics) != 0 {
|
||||||
return nil, sources, diagnostics
|
return nil, sources, diagnostics
|
||||||
}
|
}
|
||||||
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
|
if versionNode := fragmentVersionClaim(overlayRoot, rule); versionNode != nil {
|
||||||
location := publicLocation(versionNode.Location)
|
location := publicLocation(versionNode.Location)
|
||||||
return nil, sources, []Diagnostic{{
|
return nil, sources, []Diagnostic{{
|
||||||
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
|
Message: "overlays cannot contribute to or replace the schema-version path", Primary: &location,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
closeErr := root.Close()
|
closeErr := root.Close()
|
||||||
rootClosed = true
|
rootClosed = true
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)}
|
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
profiled, diagnostics := overlayDocuments(base, profileRoot)
|
overlayd, diagnostics := overlayDocuments(base, overlayRoot)
|
||||||
if profiled != nil {
|
if overlayd != nil {
|
||||||
profiled.inputBytes = base.inputBytes + int64(len(data))
|
overlayd.inputBytes = base.inputBytes + int64(len(data))
|
||||||
profiled.inputNodes = base.inputNodes + nodeCount
|
overlayd.inputNodes = base.inputNodes + nodeCount
|
||||||
}
|
}
|
||||||
return profiled, sources, diagnostics
|
return overlayd, sources, diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
func readProfileFile(root *os.Root, relative, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
func readOverlayFile(root *os.Root, relative, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
||||||
limit := min(int64(maxSourceBytes), remaining)
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
|
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
|
||||||
if failure != nil {
|
if failure != nil {
|
||||||
value := sourceReadDiagnostic(failure, sourceID, CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit")
|
value := sourceReadDiagnostic(failure, sourceID, CodeOverlayLimitExceeded, "selected overlay exceeds a source or aggregate byte limit")
|
||||||
return nil, &value
|
return nil, &value
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type profileTestConfig struct {
|
type overlayTestConfig struct {
|
||||||
Settings struct {
|
Settings struct {
|
||||||
Kept string `yaml:"kept"`
|
Kept string `yaml:"kept"`
|
||||||
Changed string `yaml:"changed"`
|
Changed string `yaml:"changed"`
|
||||||
@@ -21,7 +21,7 @@ type profileTestConfig struct {
|
|||||||
Schema map[string]string `yaml:"schema"`
|
Schema map[string]string `yaml:"schema"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
|
func TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports: [{path: base.yml}]
|
imports: [{path: base.yml}]
|
||||||
@@ -33,19 +33,19 @@ nullable: base
|
|||||||
value: {child: base}
|
value: {child: base}
|
||||||
`)
|
`)
|
||||||
basePath := writeImportFile(t, directory, "base.yml", "settings: {added: imported}\n")
|
basePath := writeImportFile(t, directory, "base.yml", "settings: {added: imported}\n")
|
||||||
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `settings:
|
overlayPath := writeImportFile(t, directory, "overlays/testing.yml", `settings:
|
||||||
changed: profile
|
changed: overlay
|
||||||
values: [profile]
|
values: [overlay]
|
||||||
nullable: null
|
nullable: null
|
||||||
value: scalar
|
value: scalar
|
||||||
`)
|
`)
|
||||||
|
|
||||||
result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{
|
result, err := testLoad[overlayTestConfig](File(mainPath), Options[overlayTestConfig]{
|
||||||
Imports: true,
|
Imports: true,
|
||||||
Profile: "testing",
|
Overlay: "testing",
|
||||||
Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig {
|
Definition: Definition[overlayTestConfig]{Defaults: func() overlayTestConfig {
|
||||||
defaultValue := "default"
|
defaultValue := "default"
|
||||||
return profileTestConfig{
|
return overlayTestConfig{
|
||||||
Values: []string{"default"}, Nullable: &defaultValue,
|
Values: []string{"default"}, Nullable: &defaultValue,
|
||||||
Settings: struct {
|
Settings: struct {
|
||||||
Kept string `yaml:"kept"`
|
Kept string `yaml:"kept"`
|
||||||
@@ -58,31 +58,31 @@ value: scalar
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Config.Settings.Kept != "main" || result.Config.Settings.Changed != "profile" || result.Config.Settings.Added != "imported" {
|
if result.Config.Settings.Kept != "main" || result.Config.Settings.Changed != "overlay" || result.Config.Settings.Added != "imported" {
|
||||||
t.Fatalf("settings = %#v", result.Config.Settings)
|
t.Fatalf("settings = %#v", result.Config.Settings)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(result.Config.Values, []string{"profile"}) || result.Config.Nullable != nil || result.Config.Value != "scalar" {
|
if !reflect.DeepEqual(result.Config.Values, []string{"overlay"}) || 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)
|
t.Fatalf("overlay values = values %#v nullable %#v value %#v", result.Config.Values, result.Config.Nullable, result.Config.Value)
|
||||||
}
|
}
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, basePath), canonicalTestPath(t, profilePath)}
|
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, basePath), canonicalTestPath(t, overlayPath)}
|
||||||
gotPaths := make([]string, len(sources))
|
gotPaths := make([]string, len(sources))
|
||||||
for index, source := range sources {
|
for index, source := range sources {
|
||||||
gotPaths[index] = source.Path
|
gotPaths[index] = source.Path
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(gotPaths, wantPaths) || sources[2].Layer != LayerProfile {
|
if !reflect.DeepEqual(gotPaths, wantPaths) || sources[2].Layer != LayerOverlay {
|
||||||
t.Fatalf("sources = %#v", sources)
|
t.Fatalf("sources = %#v", sources)
|
||||||
}
|
}
|
||||||
changed, _ := result.Metadata().Lookup(Keys("settings", "changed"))
|
changed, _ := result.Metadata().Lookup(Keys("settings", "changed"))
|
||||||
kept, _ := result.Metadata().Lookup(Keys("settings", "kept"))
|
kept, _ := result.Metadata().Lookup(Keys("settings", "kept"))
|
||||||
if changed.Origin.Layer != LayerProfile || changed.Origin.Locations[0].SourceID != "source-3" || kept.Origin.Layer != LayerBase {
|
if changed.Origin.Layer != LayerOverlay || changed.Origin.Locations[0].SourceID != "source-3" || kept.Origin.Layer != LayerBase {
|
||||||
t.Fatalf("origins = changed %#v kept %#v", changed, kept)
|
t.Fatalf("origins = changed %#v kept %#v", changed, kept)
|
||||||
}
|
}
|
||||||
if _, ok := result.Metadata().Lookup(Keys("value", "child")); ok {
|
if _, ok := result.Metadata().Lookup(Keys("value", "child")); ok {
|
||||||
t.Fatal("removed child remains effective")
|
t.Fatal("removed child remains effective")
|
||||||
}
|
}
|
||||||
childHistory := result.Metadata().History(Keys("value", "child"))
|
childHistory := result.Metadata().History(Keys("value", "child"))
|
||||||
if len(childHistory) < 2 || childHistory[len(childHistory)-1].Path.String() != "/value" || childHistory[len(childHistory)-1].Origin.Layer != LayerProfile {
|
if len(childHistory) < 2 || childHistory[len(childHistory)-1].Path.String() != "/value" || childHistory[len(childHistory)-1].Origin.Layer != LayerOverlay {
|
||||||
t.Fatalf("removed child history = %#v", childHistory)
|
t.Fatalf("removed child history = %#v", childHistory)
|
||||||
}
|
}
|
||||||
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
|
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
|
||||||
@@ -94,34 +94,43 @@ value: scalar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
|
func TestLoadOverlaySelectionUsesOneFixedYAMLFile(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
|
writeImportFile(t, directory, "overlays/testing.yml", "settings: {changed: selected}\n")
|
||||||
writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n")
|
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: legacy}\n")
|
||||||
|
writeImportFile(t, directory, "overlays/default.yml", "settings: {changed: forbidden}\n")
|
||||||
|
|
||||||
for _, name := range []string{"", "default"} {
|
for _, name := range []string{"", "default"} {
|
||||||
result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name})
|
result, err := testLoad[overlayTestConfig](File(mainPath), Options[overlayTestConfig]{Overlay: name})
|
||||||
if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 {
|
if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 {
|
||||||
t.Fatalf("inactive profile %q = %#v, %v", name, result, err)
|
t.Fatalf("inactive overlay %q = %#v, %v", name, result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selected, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"})
|
selected, err := testLoad[overlayTestConfig](File(mainPath), Options[overlayTestConfig]{Overlay: "testing"})
|
||||||
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 {
|
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 ||
|
||||||
t.Fatalf("selected profile = %#v, %v", selected, err)
|
selected.Sources()[1].Label != "overlays/testing.yml" || selected.Sources()[1].Layer != LayerOverlay {
|
||||||
|
t.Fatalf("selected overlay = %#v, %v", selected, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("legacy directory is not selected", func(t *testing.T) {
|
||||||
|
legacyDirectory := t.TempDir()
|
||||||
|
legacyMain := writeImportFile(t, legacyDirectory, "main.yml", "settings: {kept: base}\n")
|
||||||
|
writeImportFile(t, legacyDirectory, "profiles/testing.yml", "settings: {changed: legacy}\n")
|
||||||
|
requireOverlayLoadCode(t, File(legacyMain), Options[overlayTestConfig]{Overlay: "testing"}, CodeOverlayMissing)
|
||||||
|
})
|
||||||
|
|
||||||
for _, name := range []string{"../testing", "testing.yml", "one,two", "with space", "é", "bad\x00name"} {
|
for _, name := range []string{"../testing", "testing.yml", "one,two", "with space", "é", "bad\x00name"} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: name}, CodeOptionsInvalid)
|
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: name}, CodeOptionsInvalid)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "missing"}, CodeProfileMissing)
|
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "missing"}, CodeOverlayMissing)
|
||||||
writeImportFile(t, directory, "profiles/fallback.yaml", "settings: {changed: fallback}\n")
|
writeImportFile(t, directory, "overlays/fallback.yaml", "settings: {changed: fallback}\n")
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "fallback"}, CodeProfileMissing)
|
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "fallback"}, CodeOverlayMissing)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
func TestLoadOverlayRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||||
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
|
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
|
||||||
if !version.Present || version.Text != "4" {
|
if !version.Present || version.Text != "4" {
|
||||||
return errors.New("unexpected version")
|
return errors.New("unexpected version")
|
||||||
@@ -130,55 +139,55 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
profile string
|
overlay string
|
||||||
code Code
|
code Code
|
||||||
}{
|
}{
|
||||||
{name: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
|
{name: "root directive", overlay: "configkit: {}\n", code: CodeOverlayDirective},
|
||||||
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
|
{name: "nested directive", overlay: "settings: {configkit: {}}\n", code: CodeOverlayDirective},
|
||||||
{name: "equal integer version", profile: "schema: {version: 4}\n", code: CodeProfileVersion},
|
{name: "equal integer version", overlay: "schema: {version: 4}\n", code: CodeOverlayVersion},
|
||||||
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
{name: "ancestor scalar", overlay: "schema: null\n", code: CodeOverlayVersion},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
|
writeImportFile(t, directory, "overlays/testing.yml", test.overlay)
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
|
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "testing", Definition: Definition[overlayTestConfig]{Version: versionRule}}, test.code)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("mapping sibling is allowed", func(t *testing.T) {
|
t.Run("mapping sibling is allowed", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
|
writeImportFile(t, directory, "overlays/testing.yml", "schema: {other: allowed}\n")
|
||||||
result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
|
result, err := testLoad[overlayTestConfig](File(mainPath), Options[overlayTestConfig]{Overlay: "testing", Definition: Definition[overlayTestConfig]{Version: versionRule}})
|
||||||
if err != nil || result.Config.Schema["other"] != "allowed" {
|
if err != nil || result.Config.Schema["other"] != "allowed" {
|
||||||
t.Fatalf("mapping sibling = %#v, %v", result, err)
|
t.Fatalf("mapping sibling = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("profile aliases imported base", func(t *testing.T) {
|
t.Run("overlay aliases imported base", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/testing.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: overlays/testing.yml}]}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
|
writeImportFile(t, directory, "overlays/testing.yml", "settings: {changed: value}\n")
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
|
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Imports: true, Overlay: "testing"}, CodeOverlayRepeated)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
|
func TestLoadOverlayKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
|
||||||
t.Run("base parse precedes profile", func(t *testing.T) {
|
t.Run("base parse precedes overlay", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "settings: [\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "settings: [\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: profile}\n")
|
writeImportFile(t, directory, "overlays/testing.yml", "settings: {changed: overlay}\n")
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
|
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "testing"}, CodeYAMLInvalid)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("profile parse", func(t *testing.T) {
|
t.Run("overlay parse", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: [\n")
|
writeImportFile(t, directory, "overlays/testing.yml", "settings: [\n")
|
||||||
loadError := requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
|
loadError := requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "testing"}, CodeYAMLInvalid)
|
||||||
if loadError.Diagnostics()[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 {
|
if loadError.Diagnostics()[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 {
|
||||||
t.Fatalf("profile parse attribution = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
|
t.Fatalf("overlay parse attribution = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -186,32 +195,32 @@ func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
|
|||||||
t.Run("symlink escape", func(t *testing.T) {
|
t.Run("symlink escape", func(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
outside := t.TempDir()
|
outside := t.TempDir()
|
||||||
outsideProfile := writeImportFile(t, outside, "testing.yml", "settings: {changed: outside}\n")
|
outsideOverlay := writeImportFile(t, outside, "testing.yml", "settings: {changed: outside}\n")
|
||||||
if err := os.Mkdir(filepath.Join(root, "profiles"), 0o755); err != nil {
|
if err := os.Mkdir(filepath.Join(root, "overlays"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.Symlink(outsideProfile, filepath.Join(root, "profiles/testing.yml")); err != nil {
|
if err := os.Symlink(outsideOverlay, filepath.Join(root, "overlays/testing.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, root, "main.yml", "{}\n")
|
mainPath := writeImportFile(t, root, "main.yml", "{}\n")
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeProfileOutsideRoot)
|
requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: "testing"}, CodeOverlayOutsideRoot)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("byte source context", func(t *testing.T) {
|
t.Run("byte source context", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
|
writeImportFile(t, directory, "overlays/testing.yml", "settings: {changed: selected}\n")
|
||||||
result, err := testLoad[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{
|
result, err := testLoad[overlayTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[overlayTestConfig]{
|
||||||
Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
|
Overlay: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
|
||||||
})
|
})
|
||||||
if err != nil || result.Config.Settings.Changed != "selected" {
|
if err != nil || result.Config.Settings.Changed != "selected" {
|
||||||
t.Fatalf("byte profile = %#v, %v", result, err)
|
t.Fatalf("byte overlay = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
requireProfileLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[profileTestConfig]{Profile: "testing"}, CodeOptionsInvalid)
|
requireOverlayLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[overlayTestConfig]{Overlay: "testing"}, CodeOptionsInvalid)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
|
func TestLoadOverlayCountsAgainstAggregateBudgets(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
limits func(parsedSource, int) importLimits
|
limits func(parsedSource, int) importLimits
|
||||||
@@ -219,8 +228,8 @@ func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
|
|||||||
{name: "source count", limits: func(_ parsedSource, _ int) importLimits {
|
{name: "source count", limits: func(_ parsedSource, _ int) importLimits {
|
||||||
return importLimits{sources: 1, bytes: maxTotalBytes, nodes: maxDocumentNodes}
|
return importLimits{sources: 1, bytes: maxTotalBytes, nodes: maxDocumentNodes}
|
||||||
}},
|
}},
|
||||||
{name: "aggregate bytes", limits: func(main parsedSource, profileBytes int) importLimits {
|
{name: "aggregate bytes", limits: func(main parsedSource, overlayBytes int) importLimits {
|
||||||
return importLimits{sources: 2, bytes: main.bytes + int64(profileBytes) - 1, nodes: maxDocumentNodes}
|
return importLimits{sources: 2, bytes: main.bytes + int64(overlayBytes) - 1, nodes: maxDocumentNodes}
|
||||||
}},
|
}},
|
||||||
{name: "aggregate nodes", limits: func(main parsedSource, _ int) importLimits {
|
{name: "aggregate nodes", limits: func(main parsedSource, _ int) importLimits {
|
||||||
return importLimits{sources: 2, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}
|
return importLimits{sources: 2, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}
|
||||||
@@ -229,8 +238,8 @@ func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
|
||||||
profileData := "settings: {changed: selected}\n"
|
overlayData := "settings: {changed: selected}\n"
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", profileData)
|
writeImportFile(t, directory, "overlays/testing.yml", overlayData)
|
||||||
main, diagnostics := parseSource(File(mainPath), "source-1", defaultParseLimits())
|
main, diagnostics := parseSource(File(mainPath), "source-1", defaultParseLimits())
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
@@ -241,17 +250,17 @@ func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
base.inputBytes = main.bytes
|
base.inputBytes = main.bytes
|
||||||
base.inputNodes = countInputNodes(main.document)
|
base.inputNodes = countInputNodes(main.document)
|
||||||
_, _, diagnostics = loadProfileDocument(File(mainPath), nil, main, base, sourceInfos(main.source), "testing", nil, test.limits(main, len(profileData)))
|
_, _, diagnostics = loadSelectedOverlayDocument(File(mainPath), nil, main, base, sourceInfos(main.source), "testing", nil, test.limits(main, len(overlayData)))
|
||||||
if len(diagnostics) == 0 || diagnostics[0].Code != CodeProfileLimitExceeded {
|
if len(diagnostics) == 0 || diagnostics[0].Code != CodeOverlayLimitExceeded {
|
||||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError {
|
func requireOverlayLoadCode(t *testing.T, source Source, options Options[overlayTestConfig], code Code) *LoadError {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
result, err := testLoad[profileTestConfig](source, options)
|
result, err := testLoad[overlayTestConfig](source, options)
|
||||||
if result != nil || err == nil {
|
if result != nil || err == nil {
|
||||||
t.Fatalf("result, error = %#v, %v", result, err)
|
t.Fatalf("result, error = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
@@ -23,8 +23,8 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
|
|||||||
source = File(filepath.Join(root, "pipe.yml"))
|
source = File(filepath.Join(root, "pipe.yml"))
|
||||||
case "import":
|
case "import":
|
||||||
opts.Imports = true
|
opts.Imports = true
|
||||||
case "profile":
|
case "overlay":
|
||||||
opts.Profile = "fifo"
|
opts.Overlay = "fifo"
|
||||||
}
|
}
|
||||||
_, err := testLoad(source, opts)
|
_, err := testLoad(source, opts)
|
||||||
var loadErr *LoadError
|
var loadErr *LoadError
|
||||||
@@ -38,7 +38,7 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Fatalf("expected nonregular classification, got %v", loadErr.Diagnostics())
|
t.Fatalf("expected nonregular classification, got %v", loadErr.Diagnostics())
|
||||||
}
|
}
|
||||||
for _, mode := range []string{"file", "import", "profile"} {
|
for _, mode := range []string{"file", "import", "overlay"} {
|
||||||
t.Run(mode, func(t *testing.T) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
input := "value: 1\n"
|
input := "value: 1\n"
|
||||||
@@ -48,12 +48,12 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
|
|||||||
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte(input), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte(input), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.Mkdir(filepath.Join(root, "profiles"), 0700); err != nil {
|
if err := os.Mkdir(filepath.Join(root, "overlays"), 0700); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
fifo := filepath.Join(root, "pipe.yml")
|
fifo := filepath.Join(root, "pipe.yml")
|
||||||
if mode == "profile" {
|
if mode == "overlay" {
|
||||||
fifo = filepath.Join(root, "profiles", "fifo.yml")
|
fifo = filepath.Join(root, "overlays", "fifo.yml")
|
||||||
}
|
}
|
||||||
if err := syscall.Mkfifo(fifo, 0600); err != nil {
|
if err := syscall.Mkfifo(fifo, 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
12
lifecycle.go
12
lifecycle.go
@@ -18,17 +18,17 @@ 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; Profile selects one fixed profile
|
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
||||||
// overlay; Overrides contains only values explicitly supplied by an application CLI.
|
// Overrides contains only values explicitly supplied by an application CLI.
|
||||||
type Options[T any] struct {
|
type Options[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
Imports bool
|
Imports bool
|
||||||
Profile string
|
Overlay string
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemContext supplies filesystem roots for imports and profiles.
|
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||||
type FilesystemContext struct {
|
type FilesystemContext struct {
|
||||||
BaseDir string
|
BaseDir string
|
||||||
Root string
|
Root string
|
||||||
@@ -75,7 +75,7 @@ type Result[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load parses and strictly decodes zero or one selected source, explicitly enabled
|
// Load parses and strictly decodes zero or one selected source, explicitly enabled
|
||||||
// imports, and one selected profile. It accepts at most one Options value and
|
// imports, and one selected overlay. It accepts at most one Options value and
|
||||||
// returns no result when any error is reported.
|
// 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) {
|
||||||
options := make([]engine.Options[T], len(supplied))
|
options := make([]engine.Options[T], len(supplied))
|
||||||
@@ -219,7 +219,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] {
|
|||||||
result := engine.Options[T]{
|
result := engine.Options[T]{
|
||||||
Definition: toEngineDefinition(options.Definition),
|
Definition: toEngineDefinition(options.Definition),
|
||||||
Imports: options.Imports,
|
Imports: options.Imports,
|
||||||
Profile: options.Profile,
|
Overlay: options.Overlay,
|
||||||
}
|
}
|
||||||
if options.Filesystem != nil {
|
if options.Filesystem != nil {
|
||||||
result.Filesystem = &engine.FilesystemContext{BaseDir: options.Filesystem.BaseDir, Root: options.Filesystem.Root}
|
result.Filesystem = &engine.FilesystemContext{BaseDir: options.Filesystem.BaseDir, Root: options.Filesystem.Root}
|
||||||
|
|||||||
16
records.go
16
records.go
@@ -6,7 +6,7 @@ type Layer string
|
|||||||
const (
|
const (
|
||||||
LayerDefault Layer = "default"
|
LayerDefault Layer = "default"
|
||||||
LayerBase Layer = "base"
|
LayerBase Layer = "base"
|
||||||
LayerProfile Layer = "profile"
|
LayerOverlay Layer = "overlay"
|
||||||
LayerCLI Layer = "cli"
|
LayerCLI Layer = "cli"
|
||||||
LayerResolution Layer = "resolution"
|
LayerResolution Layer = "resolution"
|
||||||
)
|
)
|
||||||
@@ -90,13 +90,13 @@ const (
|
|||||||
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
||||||
CodeImportVersion Code = "import.version"
|
CodeImportVersion Code = "import.version"
|
||||||
|
|
||||||
CodeProfileInvalid Code = "profile.invalid"
|
CodeOverlayInvalid Code = "overlay.invalid"
|
||||||
CodeProfileMissing Code = "profile.missing"
|
CodeOverlayMissing Code = "overlay.missing"
|
||||||
CodeProfileOutsideRoot Code = "profile.outside_root"
|
CodeOverlayOutsideRoot Code = "overlay.outside_root"
|
||||||
CodeProfileRepeated Code = "profile.repeated"
|
CodeOverlayRepeated Code = "overlay.repeated"
|
||||||
CodeProfileDirective Code = "profile.directive"
|
CodeOverlayDirective Code = "overlay.directive"
|
||||||
CodeProfileVersion Code = "profile.version"
|
CodeOverlayVersion Code = "overlay.version"
|
||||||
CodeProfileLimitExceeded Code = "profile.limit_exceeded"
|
CodeOverlayLimitExceeded Code = "overlay.limit_exceeded"
|
||||||
|
|
||||||
CodeComposeInvalid Code = "compose.invalid"
|
CodeComposeInvalid Code = "compose.invalid"
|
||||||
CodeComposeConflict Code = "compose.conflict"
|
CodeComposeConflict Code = "compose.conflict"
|
||||||
|
|||||||
22
records_test.go
Normal file
22
records_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestOverlayPublicValues(t *testing.T) {
|
||||||
|
if LayerOverlay != "overlay" {
|
||||||
|
t.Fatalf("LayerOverlay = %q", LayerOverlay)
|
||||||
|
}
|
||||||
|
for code, value := range map[Code]string{
|
||||||
|
CodeOverlayInvalid: "overlay.invalid",
|
||||||
|
CodeOverlayMissing: "overlay.missing",
|
||||||
|
CodeOverlayOutsideRoot: "overlay.outside_root",
|
||||||
|
CodeOverlayRepeated: "overlay.repeated",
|
||||||
|
CodeOverlayDirective: "overlay.directive",
|
||||||
|
CodeOverlayVersion: "overlay.version",
|
||||||
|
CodeOverlayLimitExceeded: "overlay.limit_exceeded",
|
||||||
|
} {
|
||||||
|
if string(code) != value {
|
||||||
|
t.Errorf("code = %q, want %q", code, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,120 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Exercise the copyable runbook against disposable Git repositories, not a
|
||||||
|
// publication service. The checker itself is covered separately.
|
||||||
|
func TestReleaseRunbookGuards(t *testing.T) {
|
||||||
|
document, err := os.ReadFile("docs/release.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block := func(heading string) string {
|
||||||
|
t.Helper()
|
||||||
|
_, section, ok := strings.Cut(string(document), heading)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing section %q", heading)
|
||||||
|
}
|
||||||
|
_, section, ok = strings.Cut(section, "```sh\n")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing commands in %q", heading)
|
||||||
|
}
|
||||||
|
commands, _, ok := strings.Cut(section, "```")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unterminated commands in %q", heading)
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
prepare := block("## 4. Validate the source candidate")
|
||||||
|
publish := block("## 6. Run publication guards") + block("## 7. Tag and publish the exact ref")
|
||||||
|
for _, scenario := range []string{"success", "dirty", "changed-candidate", "remote-tag-exists", "failed-validation"} {
|
||||||
|
t.Run(scenario, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||||
|
run := func(program string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command(program, args...)
|
||||||
|
cmd.Dir = root
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %v: %v\n%s", program, args, err, out)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
run("git", "init", "-q", "-b", "main")
|
||||||
|
run("git", "init", "-q", "--bare", remote)
|
||||||
|
run("git", "config", "user.name", "Test")
|
||||||
|
run("git", "config", "user.email", "test@example.invalid")
|
||||||
|
run("git", "config", "commit.gpgsign", "false")
|
||||||
|
run("git", "config", "tag.gpgsign", "false")
|
||||||
|
const origin = "https://gitea.maximumdirect.net/eric/ConfigKit.git"
|
||||||
|
run("git", "remote", "add", "origin", remote)
|
||||||
|
// Substitute only the fixture's authoritative URL; execute the guards
|
||||||
|
// themselves unchanged. Disallow network transports as a backstop.
|
||||||
|
localPublish := strings.ReplaceAll(publish, origin, remote)
|
||||||
|
for _, dir := range []string{"docs/releases", "scripts"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, dir), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write := func(path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checker := "#!/bin/sh\nexit 0\n"
|
||||||
|
if scenario == "failed-validation" {
|
||||||
|
checker = "#!/bin/sh\nexit 37\n"
|
||||||
|
}
|
||||||
|
write("scripts/release-check.sh", checker)
|
||||||
|
write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.0\n\n## Summary\n\n## Compatibility\n\n## Upgrade\n\n## Changes\n")
|
||||||
|
run("git", "add", ".")
|
||||||
|
bash := func(commands string) ([]byte, error) {
|
||||||
|
cmd := exec.Command("bash", "-c", "set -euo pipefail\nrelease_version=v0.9.0\n"+commands)
|
||||||
|
cmd.Dir = root
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file")
|
||||||
|
return cmd.CombinedOutput()
|
||||||
|
}
|
||||||
|
out, err := bash(prepare)
|
||||||
|
if scenario == "failed-validation" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("failed validation accepted")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, ".git/configkit-release-candidate")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("failed validation recorded a candidate")
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
t.Fatalf("prepare: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
switch scenario {
|
||||||
|
case "dirty":
|
||||||
|
write("unexpected.txt", "uncommitted\n")
|
||||||
|
case "changed-candidate":
|
||||||
|
run("git", "commit", "--allow-empty", "-qm", "Changed candidate")
|
||||||
|
case "remote-tag-exists":
|
||||||
|
run("git", "tag", "v0.9.0")
|
||||||
|
run("git", "push", remote, "refs/tags/v0.9.0")
|
||||||
|
run("git", "tag", "-d", "v0.9.0")
|
||||||
|
}
|
||||||
|
// Seed the remote primary branch so fetch works before first publication.
|
||||||
|
run("git", "push", remote, "HEAD:refs/heads/main")
|
||||||
|
out, err = bash(localPublish)
|
||||||
|
if scenario == "success" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("publish: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if got := run("git", "--git-dir="+remote, "rev-parse", "v0.9.0^{}"); got != run("git", "rev-parse", "HEAD") {
|
||||||
|
t.Fatal("published wrong commit")
|
||||||
|
}
|
||||||
|
} else if err == nil {
|
||||||
|
t.Fatalf("publication accepted %s", scenario)
|
||||||
|
} else if scenario != "remote-tag-exists" && run("git", "--git-dir="+remote, "tag", "--list") != "" {
|
||||||
|
t.Fatal("failed guard published a tag")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckScriptsHandleFilenamesAndStopOnFailure(t *testing.T) {
|
func TestCheckScriptsHandleFilenamesAndStopOnFailure(t *testing.T) {
|
||||||
for _, scriptName := range []string{"release-check.sh", "check.sh"} {
|
for _, scriptName := range []string{"release-check.sh", "check.sh"} {
|
||||||
t.Run(scriptName, func(t *testing.T) {
|
t.Run(scriptName, func(t *testing.T) {
|
||||||
@@ -66,7 +180,7 @@ func TestCheckScriptsHandleFilenamesAndStopOnFailure(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName))
|
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName))
|
||||||
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", "CONFIGKIT_NEWER_TOOLCHAIN=test-newer", "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
|
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", "CONFIGKIT_NEWER_TOOLCHAIN=", "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
|
||||||
// Log only targets selected by the script, not inherited host settings.
|
// Log only targets selected by the script, not inherited host settings.
|
||||||
cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=")
|
cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=")
|
||||||
if name == "failed-go-check" {
|
if name == "failed-go-check" {
|
||||||
@@ -104,6 +218,12 @@ func TestCheckScriptsHandleFilenamesAndStopOnFailure(t *testing.T) {
|
|||||||
t.Fatal("checker continued after failed test")
|
t.Fatal("checker continued after failed test")
|
||||||
}
|
}
|
||||||
if scriptName == "release-check.sh" && !fail {
|
if scriptName == "release-check.sh" && !fail {
|
||||||
|
if strings.Count(string(invoked), " test ") != 1 || !strings.Contains(string(invoked), " test -race -v ./...\n") {
|
||||||
|
t.Fatalf("want one complete race-test run, got:\n%s", invoked)
|
||||||
|
}
|
||||||
|
if strings.Count(string(invoked), " vet ./...") != 1 || strings.Count(string(invoked), " build ./...") != 2 {
|
||||||
|
t.Fatalf("want one vet run and only two cross-builds, got:\n%s", invoked)
|
||||||
|
}
|
||||||
// These are selected release gates, not incidental loop details.
|
// These are selected release gates, not incidental loop details.
|
||||||
for _, target := range []string{"linux/amd64", "linux/arm64"} {
|
for _, target := range []string{"linux/amd64", "linux/arm64"} {
|
||||||
if !strings.Contains(string(invoked), target+" build ./...") {
|
if !strings.Contains(string(invoked), target+" build ./...") {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
|||||||
cd "$repository_dir"
|
cd "$repository_dir"
|
||||||
|
|
||||||
baseline_toolchain=${CONFIGKIT_BASELINE_TOOLCHAIN:?set CONFIGKIT_BASELINE_TOOLCHAIN to a cached supported Go 1.25 patch release}
|
baseline_toolchain=${CONFIGKIT_BASELINE_TOOLCHAIN:?set CONFIGKIT_BASELINE_TOOLCHAIN to a cached supported Go 1.25 patch release}
|
||||||
newer_toolchain=${CONFIGKIT_NEWER_TOOLCHAIN:?set CONFIGKIT_NEWER_TOOLCHAIN to a cached newer supported Go toolchain}
|
|
||||||
|
|
||||||
run_go() {
|
run_go() {
|
||||||
local toolchain=$1
|
local toolchain=$1
|
||||||
@@ -20,7 +19,6 @@ check_toolchain() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_toolchain "$baseline_toolchain"
|
check_toolchain "$baseline_toolchain"
|
||||||
check_toolchain "$newer_toolchain"
|
|
||||||
bash -n scripts/release-check.sh
|
bash -n scripts/release-check.sh
|
||||||
|
|
||||||
temporary_dir=$(mktemp -d)
|
temporary_dir=$(mktemp -d)
|
||||||
@@ -69,21 +67,13 @@ while IFS= read -r -d '' candidate_file; do
|
|||||||
esac
|
esac
|
||||||
done < <(git ls-files --cached --others --exclude-standard -z)
|
done < <(git ls-files --cached --others --exclude-standard -z)
|
||||||
|
|
||||||
for toolchain in "$baseline_toolchain" "$newer_toolchain"; do
|
# Verbose output reports skipped cases without a separate evidence run.
|
||||||
run_go "$toolchain" test ./...
|
run_go "$baseline_toolchain" test -race -v ./...
|
||||||
run_go "$toolchain" vet ./...
|
run_go "$baseline_toolchain" vet ./...
|
||||||
run_go "$toolchain" build ./...
|
|
||||||
done
|
|
||||||
|
|
||||||
run_go "$newer_toolchain" test -race ./...
|
|
||||||
run_go "$newer_toolchain" test . -run '^Example'
|
|
||||||
run_go "$newer_toolchain" test ./examples/consumers
|
|
||||||
run_go "$newer_toolchain" test . -run '^TestLocalDocumentationLinks$'
|
|
||||||
run_go "$newer_toolchain" test . -run '^TestDependencyImportBoundary$'
|
|
||||||
|
|
||||||
# Only release-validated targets gate publication; macOS is best-effort.
|
# Only release-validated targets gate publication; macOS is best-effort.
|
||||||
for target in linux/amd64 linux/arm64; do
|
for target in linux/amd64 linux/arm64; do
|
||||||
target_os=${target%/*}
|
target_os=${target%/*}
|
||||||
target_arch=${target#*/}
|
target_arch=${target#*/}
|
||||||
GOTOOLCHAIN="$newer_toolchain" GOPROXY=off CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" go build ./...
|
GOTOOLCHAIN="$baseline_toolchain" GOPROXY=off CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" go build ./...
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ func (e *SecretsError) Diagnostics() []Diagnostic {
|
|||||||
// LoadSecretsDir explicitly loads immediate regular files from directory into
|
// LoadSecretsDir explicitly loads immediate regular files from directory into
|
||||||
// previously unset process environment variables. Applications should call it
|
// previously unset process environment variables. Applications should call it
|
||||||
// once during startup, before concurrent work; Load never calls it.
|
// once during startup, before concurrent work; Load never calls it.
|
||||||
|
// Dot-prefixed entries are ignored and omitted from the report.
|
||||||
func LoadSecretsDir(directory string) (*SecretsReport, error) {
|
func LoadSecretsDir(directory string) (*SecretsReport, error) {
|
||||||
report, err := engine.LoadSecretsDir(directory)
|
report, err := engine.LoadSecretsDir(directory)
|
||||||
result := &SecretsReport{value: report}
|
result := &SecretsReport{value: report}
|
||||||
|
|||||||
Reference in New Issue
Block a user