Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1c7e87f2c | |||
| 99098d2974 | |||
| 3e50388b73 | |||
| 22603f0e5b | |||
| 5f001cf597 | |||
| 47547bc63e | |||
| ce6005ca2d | |||
| ef420c943b | |||
| f0198f865d | |||
| 367fe4120f | |||
| 37ea670747 | |||
| 68de431bb9 | |||
| 720df98a5a | |||
| 81c27bb9cd | |||
| 9a08438769 | |||
| a05bb94a21 | |||
| d2608e8d56 | |||
| 18af2d2130 | |||
| bc3fc91296 | |||
| 08ea01921b | |||
| 761dd0a4e0 | |||
| b93cbb0639 | |||
| e47960d377 | |||
| f0a4bf9bb2 | |||
| 0a136063da | |||
| ae0c94d3c6 | |||
| c51bea9b4f | |||
| 132b9ec659 | |||
| c0bc5e2b73 | |||
| daa0cf9e88 | |||
| 2ba61d5c7c | |||
| e36740313d | |||
| 1960304f6f | |||
| 26ea24958c |
47
docs/adr/0022-main-relative-context.md
Normal file
47
docs/adr/0022-main-relative-context.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# ADR 0022: Bind Companion Contexts After Main Selection
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-11-09
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications choose a main-file search and may also declare companion imports
|
||||||
|
and a selected overlay. The main directory is only known after discovery, so
|
||||||
|
supplying every dependent context before loading duplicates selection or requires
|
||||||
|
a preliminary load. ADR 0019 keeps main discovery explicit and inside loading.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Extend layered loading to bind application-declared non-file underlays to the
|
||||||
|
resolved main context after acquisition. An explicit underlay context overrides
|
||||||
|
the binding, and an explicit fallback or require-base option controls absence.
|
||||||
|
Selected layered overlays choose an explicit context, the resolved main context,
|
||||||
|
or a fallback in that order. Derived and fallback lookup use the context base;
|
||||||
|
the inherited root continues to bound containment. Ordinary and explicit
|
||||||
|
overlay lookup retain their established root-relative behavior.
|
||||||
|
|
||||||
|
Applications remain responsible for choosing searches, import declarations,
|
||||||
|
and overlay names. Selection failures are terminal; no second search or
|
||||||
|
application context callback is introduced.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- Require applications to discover the main file separately. This repeats
|
||||||
|
discovery and may run a complete load more than once.
|
||||||
|
- Implicitly scan conventional companion directories. This would impose
|
||||||
|
application-specific conventions without an explicit declaration.
|
||||||
|
- Add only a relative file constructor. This would not cover mapping or bytes
|
||||||
|
sources with existing import, placement, and composition rules.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The load owns the resolved binding and keeps namespace, containment, source
|
||||||
|
identity, and handle lifetime in the existing acquisition lifecycle. A missing
|
||||||
|
base skips optional imports while preserving the declaring payload; required
|
||||||
|
imports and require-base retain explicit failures. The configuration contract
|
||||||
|
and public declarations describe the observable rules.
|
||||||
@@ -75,6 +75,8 @@ or the supported directory presets, then pass it to `SearchConfig`. Search
|
|||||||
descriptions are immutable and reusable; each load resolves dynamic directories
|
descriptions are immutable and reusable; each load resolves dynamic directories
|
||||||
and checks current candidate files again. `Searches` is ordered concatenation.
|
and checks current candidate files again. `Searches` is ordered concatenation.
|
||||||
It preserves the supplied description order and does not deduplicate candidates.
|
It preserves the supplied description order and does not deduplicate candidates.
|
||||||
|
Layered companion inputs can use the selected main's context without a second
|
||||||
|
search, as described under [layered loading](#layered-loading).
|
||||||
|
|
||||||
`NamedFilenames` selects `<application>.yml`, then `<application>.yaml`.
|
`NamedFilenames` selects `<application>.yml`, then `<application>.yaml`.
|
||||||
`GenericFilenames` selects `config.yml`, `config.yaml`, `main.yml`, then
|
`GenericFilenames` selects `config.yml`, `config.yaml`, `main.yml`, then
|
||||||
@@ -316,13 +318,33 @@ typed configuration before application resolution. Syntax, strict-composition,
|
|||||||
inheritance, and decoding errors cannot be masked by a CLI override. Resolution,
|
inheritance, and decoding errors cannot be masked by a CLI override. Resolution,
|
||||||
secret lookup, validation, and publication run once on the resulting values.
|
secret lookup, validation, and publication run once on the resulting values.
|
||||||
|
|
||||||
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
|
An active layered overlay selects `OverlayFilesystem` first, then the resolved
|
||||||
independent of the main source's namespace. Its name and root-relative lookup
|
main context, then `OverlayFallback`. An invalid selected context fails without
|
||||||
follow the selected-overlay rules below; inactive overlays ignore the context.
|
trying another. An inactive overlay ignores both supplied contexts.
|
||||||
The same path in a different filesystem namespace is not a repeated file.
|
The same path in a different filesystem namespace is not a repeated file.
|
||||||
Ordinary `Load` infers overlay context from sources that provide one; with
|
Ordinary `Load` infers overlay context from sources that provide one; with
|
||||||
`NoSource`, it requires the explicit `Options.Filesystem` context.
|
`NoSource`, it requires the explicit `Options.Filesystem` context.
|
||||||
|
|
||||||
|
An underlay `Input.MainRelative` requests the resolved main context for a
|
||||||
|
non-file source. An explicit `Input.Filesystem` overrides it entirely. File,
|
||||||
|
native file, and search underlays cannot use an active binding, nor can the
|
||||||
|
main input bind to itself. `MainRelative()` creates a fresh default description.
|
||||||
|
The binding does not enable imports. If the main has no usable context, its
|
||||||
|
`Fallback` is selected if supplied; otherwise optional imports are skipped and
|
||||||
|
the input's own payload remains. Required named targets, directory and glob
|
||||||
|
selections fail without a base, while an empty `path: []` remains a no-op.
|
||||||
|
`RequireBase` requires a usable main or fallback context even without imports.
|
||||||
|
Invalid declarations still fail, and skipped imports create no search evidence.
|
||||||
|
Selected main failures never trigger fallback or a second discovery attempt.
|
||||||
|
|
||||||
|
Derived contexts preserve the main namespace and containment root, while lookup
|
||||||
|
starts at its resolved base. A host file's base is its canonical target directory,
|
||||||
|
including when the selected filename is a symlink. A wider root permits contained
|
||||||
|
parent-relative imports without moving the lookup base. Explicit non-file and
|
||||||
|
absent-main contexts can provide a base; an absent host input may use its root as
|
||||||
|
base. An unused fallback is not opened or checked for existence. See the
|
||||||
|
[consumer example](../examples/consumers/main_relative.go).
|
||||||
|
|
||||||
Source IDs follow acquisition order—main and its depth-first imports, then each
|
Source IDs follow acquisition order—main and its depth-first imports, then each
|
||||||
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
||||||
history follows precedence order. Default main-version checking happens after
|
history follows precedence order. Default main-version checking happens after
|
||||||
@@ -567,7 +589,7 @@ one context for a composite origin or an application-derived value.
|
|||||||
`ResourceContext.FS` is rooted at the source's configured root, and `BaseDir`
|
`ResourceContext.FS` is rooted at the source's configured root, and `BaseDir`
|
||||||
is its slash-relative directory inside that root. This includes imported
|
is its slash-relative directory inside that root. This includes imported
|
||||||
sources, selected overlays, host files even without enabled imports, native
|
sources, selected overlays, host files even without enabled imports, native
|
||||||
files, and byte, reader, or mapping inputs with an explicit context. CLI and
|
files, and byte, reader, or mapping inputs with an explicit or main-derived context. CLI and
|
||||||
contextless synthetic inputs have no resource context. To open a relative
|
contextless synthetic inputs have no resource context. To open a relative
|
||||||
resource, join `BaseDir` with the application resource name using slash-path
|
resource, join `BaseDir` with the application resource name using slash-path
|
||||||
operations and open that path through `FS`.
|
operations and open that path through `FS`.
|
||||||
@@ -1489,12 +1511,15 @@ claim records rather than mutating or aliasing an input document.
|
|||||||
`Options.Overlay` 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 overlay, and `overlays/default.yml` is never loaded implicitly.
|
`default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
|
||||||
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
|
For ordinary loads and explicit layered overlay contexts, every other name resolves
|
||||||
|
to `<configuration-root>/overlays/<name>.yml`, with
|
||||||
no extension fallback, case normalization, inheritance between overlay files,
|
no extension fallback, case normalization, inheritance between overlay files,
|
||||||
or overlay stack. A selected missing overlay is an error.
|
or overlay stack. A selected missing overlay is an error.
|
||||||
|
For a layered main-derived or fallback context, lookup instead starts at the
|
||||||
|
resolved base directory; the configured root still bounds containment.
|
||||||
|
|
||||||
With `NoSource`, the main input stays absent and the explicit filesystem context
|
With ordinary `Load` and `NoSource`, the main input stays absent and the explicit
|
||||||
supplies the overlay root. The overlay is recorded as the first acquired source
|
filesystem context supplies the overlay root. The overlay is recorded as the first acquired source
|
||||||
when no other source exists. Host contexts may use the root-only form described
|
when no other source exists. Host contexts may use the root-only form described
|
||||||
under [single-source loading](#single-source-loading); native contexts retain
|
under [single-source loading](#single-source-loading); native contexts retain
|
||||||
their complete namespace, base, and root requirements.
|
their complete namespace, base, and root requirements.
|
||||||
@@ -1504,6 +1529,10 @@ base import graph is composed and its schema version is checked, but before defa
|
|||||||
and typed decoding. Overlay 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.
|
||||||
|
For native filesystems that implement `fs.ReadLinkFS`, these checks resolve
|
||||||
|
link targets in the selected namespace. For other native filesystems, the
|
||||||
|
checks use namespace-relative paths and reads follow that filesystem's
|
||||||
|
ordinary behavior.
|
||||||
|
|
||||||
Collection destinations follow the
|
Collection destinations follow the
|
||||||
[collection operation and presence rules](#strict-document-composition) above.
|
[collection operation and presence rules](#strict-document-composition) above.
|
||||||
@@ -1629,6 +1658,8 @@ config:
|
|||||||
Each selected document follows the same strict combination rules. A missing
|
Each selected document follows the same strict combination rules. A missing
|
||||||
optional input can be absent; invalid declarations, unreadable selected files,
|
optional input can be absent; invalid declarations, unreadable selected files,
|
||||||
and conflicting settings still fail loading.
|
and conflicting settings still fail loading.
|
||||||
|
Main-relative underlays follow the same import rules once a context is selected;
|
||||||
|
their no-base behavior is described under [layered loading](#layered-loading).
|
||||||
|
|
||||||
| Option | `path` | `dir` | `glob` |
|
| Option | `path` | `dir` | `glob` |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ Start with one file, then choose the tasks your application needs:
|
|||||||
- Use `LoadLayered` when several inputs should supply lower-priority defaults
|
- Use `LoadLayered` when several inputs should supply lower-priority defaults
|
||||||
beneath the main source. Each underlay group combines its inputs strictly.
|
beneath the main source. Each underlay group combines its inputs strictly.
|
||||||
Each [`Input`](../../layered.go) owns its source, filesystem, destination, and
|
Each [`Input`](../../layered.go) owns its source, filesystem, destination, and
|
||||||
import choice. The overlay filesystem context is selected independently in
|
import choice. Set `Input.MainRelative` on a companion underlay to use the
|
||||||
|
main input's filesystem context. Overlay context selection belongs to
|
||||||
[`LayeredOptions`](../../layered.go).
|
[`LayeredOptions`](../../layered.go).
|
||||||
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
||||||
[`LayeredOptions`](../../layered.go): the application definition (including
|
[`LayeredOptions`](../../layered.go): the application definition (including
|
||||||
@@ -53,6 +54,8 @@ file and layered cases. For import-focused adoption, see the
|
|||||||
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
|
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
|
||||||
discovery composed with sequences, see the
|
discovery composed with sequences, see the
|
||||||
[sequence catalog](../../examples/consumers/sequence_catalog.go).
|
[sequence catalog](../../examples/consumers/sequence_catalog.go).
|
||||||
|
For discovered main files with companion imports and an overlay, see the
|
||||||
|
[main-relative example](../../examples/consumers/main_relative.go).
|
||||||
|
|
||||||
## Happy path
|
## Happy path
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ Accepted decisions:
|
|||||||
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
||||||
- [Scope configuration controls to document roots](adr/0020-document-root-controls.md)
|
- [Scope configuration controls to document roots](adr/0020-document-root-controls.md)
|
||||||
- [Match registered records during resolution inspection](adr/0021-sequence-resolution-correspondence.md)
|
- [Match registered records during resolution inspection](adr/0021-sequence-resolution-correspondence.md)
|
||||||
|
- [Bind companion contexts after main selection](adr/0022-main-relative-context.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
@@ -93,6 +94,12 @@ and current facade ownership belongs to the [component overview](internal/overvi
|
|||||||
|
|
||||||
## Baseline validation
|
## Baseline validation
|
||||||
|
|
||||||
|
The completed main-relative companion roadmap, Audita request, implementation
|
||||||
|
plan, and remediation report are preserved in Git history at `47547bc` under
|
||||||
|
`docs/roadmap/`. The [layered loading contract](config.md#layered-loading)
|
||||||
|
describes current behavior, and [ADR 0022](adr/0022-main-relative-context.md)
|
||||||
|
records the context binding decision.
|
||||||
|
|
||||||
The completed field-level sequence resolution roadmap, implementation plan, and
|
The completed field-level sequence resolution roadmap, implementation plan, and
|
||||||
resolved audit findings are preserved in Git history at `7e53863` under
|
resolved audit findings are preserved in Git history at `7e53863` under
|
||||||
`docs/roadmap/`. For example:
|
`docs/roadmap/`. For example:
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ patterns to discovered names, and form a set union of matching candidates across
|
|||||||
overlapping roots. Exclusions are applied as set difference after discovery and
|
overlapping roots. Exclusions are applied as set difference after discovery and
|
||||||
candidate normalization. They do not suppress discovery or resource errors.
|
candidate normalization. They do not suppress discovery or resource errors.
|
||||||
|
|
||||||
|
Layered assembly passes the resolved main context to underlay acquisition.
|
||||||
|
Derived inputs retain the main's filesystem location and namespace. The
|
||||||
|
receiving tier supplies its own host token and tracks repeated sources.
|
||||||
|
Contextless bindings prepare declaration policies and assemble the declaring
|
||||||
|
payload without opening filesystem access or recording selection evidence.
|
||||||
|
Selected contexts use the existing acquisition and handle lifecycle.
|
||||||
|
|
||||||
Directory traversal tracks canonical directories on the active traversal path.
|
Directory traversal tracks canonical directories on the active traversal path.
|
||||||
It adds an identity when descending and removes it when the frame is popped, so
|
It adds an identity when descending and removes it when the frame is popped, so
|
||||||
only a back-edge to an active directory is a traversal cycle; a later alias is
|
only a back-edge to an active directory is a traversal cycle; a later alias is
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Implemented component owners:
|
|||||||
explicitly selected configuration discovery. A search resolves once during
|
explicitly selected configuration discovery. A search resolves once during
|
||||||
shared acquisition; the selected concrete file or absent-input state then
|
shared acquisition; the selected concrete file or absent-input state then
|
||||||
follows the existing composition, metadata, and resource-context lifecycle.
|
follows the existing composition, metadata, and resource-context lifecycle.
|
||||||
It also owns grouped strict composition, recursive precedence assembly,
|
It also owns main-relative context selection, grouped strict composition, recursive precedence assembly,
|
||||||
per-input mapping and sequence-item placement, YAML collection declarations
|
per-input mapping and sequence-item placement, YAML collection declarations
|
||||||
and operations for identified sequences, and application identity registrations for strict
|
and operations for identified sequences, and application identity registrations for strict
|
||||||
composition, inheritance, and final validation. It applies the selected
|
composition, inheritance, and final validation. It applies the selected
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## Standard Repository Policies
|
## Standard Repository Policies
|
||||||
|
|
||||||
Material: `docs/policy/release.md`, adapted from
|
Material: `docs/policy/documentation.md`, `docs/policy/oss.md`, and
|
||||||
`e833faddf120c789a9b45348edaf75720de16348`; `docs/policy/meta.md`, `docs/policy/oss.md`,
|
`docs/policy/release.md`, adapted from
|
||||||
`docs/policy/documentation.md`, `docs/policy/agents.md`,
|
`b339ad0427a50e43a68b932a6a9f4669cc0f6252`; `docs/policy/meta.md`, `docs/policy/agents.md`,
|
||||||
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
|
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
|
||||||
Policy Reading Guide in `docs/development.md`, adapted from
|
Policy Reading Guide in `docs/development.md`, adapted from
|
||||||
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
|
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
|
||||||
@@ -16,12 +16,12 @@ SPDX-License-Identifier: CC-BY-SA-4.0
|
|||||||
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252).
|
||||||
|
|
||||||
## go-yaml v4
|
## go-yaml v4
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ initialization, and resource checks. See the [configuration discovery
|
|||||||
contract](../config.md#configuration-discovery) and [ADR
|
contract](../config.md#configuration-discovery) and [ADR
|
||||||
0019](../adr/0019-explicit-configuration-discovery.md).
|
0019](../adr/0019-explicit-configuration-discovery.md).
|
||||||
|
|
||||||
|
The layered loader may bind application-selected dependent locations after main
|
||||||
|
selection. Applications still choose search conventions and must explicitly
|
||||||
|
select an overlay. [ADR 0022](../adr/0022-main-relative-context.md) records
|
||||||
|
the deferred context decision.
|
||||||
|
|
||||||
The engine applies each input's mapping destination within the existing source
|
The engine applies each input's mapping destination within the existing source
|
||||||
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
||||||
retains each effective placement with source identity and inspection records;
|
retains each effective placement with source identity and inspection records;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Documentation Policy
|
# Documentation Policy
|
||||||
|
|
||||||
**Base Policy Version:** 1.11
|
**Base Policy Version:** 1.12
|
||||||
|
|
||||||
**Last Updated**: 2026-10-16
|
**Last Updated**: 2026-11-09
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/docs/policy/documentation.md)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ secret values.
|
|||||||
| Consumer guidance for the public Go API | `docs/consumers/` | Task-oriented public Go API usage, minimal examples, and consumer responsibilities. | Go symbol contracts, configuration semantics, and internal implementation detail. |
|
| Consumer guidance for the public Go API | `docs/consumers/` | Task-oriented public Go API usage, minimal examples, and consumer responsibilities. | Go symbol contracts, configuration semantics, and internal implementation detail. |
|
||||||
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
||||||
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
||||||
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
| Architectural decision records | `docs/adr/` | Records of significant architectural decisions and supporting rationale. | Ordinary design choices, current behavior reference, implementation status, task sequencing. |
|
||||||
| Potential future features | `docs/roadmap/future.md` | Candidate enhancements and open questions for future consideration. | Rejected proposals, architectural decisions, task execution, and current behavior. |
|
| Potential future features | `docs/roadmap/future.md` | Candidate enhancements and open questions for future consideration. | Rejected proposals, architectural decisions, task execution, and current behavior. |
|
||||||
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, sequencing, acceptance, and progress; completed plans retire during release preparation. | Implemented behavior and ADR rationale. |
|
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, sequencing, acceptance, and progress; completed plans retire during release preparation. | Implemented behavior and ADR rationale. |
|
||||||
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
||||||
@@ -157,10 +157,16 @@ Use sequentially numbered ADR filenames such as
|
|||||||
|
|
||||||
Treat the decision content of an accepted ADR as immutable. When a decision
|
Treat the decision content of an accepted ADR as immutable. When a decision
|
||||||
changes, create a new ADR and update the earlier ADR's status to superseded.
|
changes, create a new ADR and update the earlier ADR's status to superseded.
|
||||||
|
|
||||||
|
ADRs should be reserved for major architectural decisions, such as ownership
|
||||||
|
boundaries, large-scale architectural features, or invariants that rise to
|
||||||
|
the level of architectural significance. Routine choices, implementation
|
||||||
|
decisions, and rejected suggestions do not warrant an ADR unless they
|
||||||
|
otherwise represent a significant architectural decision.
|
||||||
|
|
||||||
Rejected architectural alternatives and significant architectural exclusions
|
Rejected architectural alternatives and significant architectural exclusions
|
||||||
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
||||||
ideas. Routine choices and rejected suggestions need no ADR unless they
|
ideas.
|
||||||
represent a significant architectural decision.
|
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Open Source Licensing And Attribution Policy
|
# Open Source Licensing And Attribution Policy
|
||||||
|
|
||||||
**Base Policy Version:** 1.8
|
**Base Policy Version:** 1.9
|
||||||
|
|
||||||
**Last Updated**: 2026-09-28
|
**Last Updated**: 2026-11-09
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/docs/policy/oss.md)
|
||||||
|
|
||||||
## ConfigKit Applicability
|
## ConfigKit Applicability
|
||||||
|
|
||||||
@@ -74,8 +74,9 @@ individual packages.
|
|||||||
|
|
||||||
Use the upstream [application COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/application/COPYRIGHT)
|
Use the upstream [application COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/application/COPYRIGHT)
|
||||||
or [library COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/library/COPYRIGHT)
|
or [library COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/library/COPYRIGHT)
|
||||||
to create the project’s root `COPYRIGHT` file.
|
to create the project’s root `COPYRIGHT` file. Use the examples as provided,
|
||||||
Use verified project names, contribution years, and copyright holders.
|
without discretionary modification, unless otherwise instructed. Substitute
|
||||||
|
verified project names, contribution years, and copyright holders.
|
||||||
For another selected license, adapt the grant, SPDX expression, disclaimer, and
|
For another selected license, adapt the grant, SPDX expression, disclaimer, and
|
||||||
text references together to reflect the author's selected grant.
|
text references together to reflect the author's selected grant.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Release Policy
|
# Release Policy
|
||||||
|
|
||||||
**Base Policy Version:** 1.6
|
**Base Policy Version:** 1.7
|
||||||
|
|
||||||
**Last Updated**: 2026-10-09
|
**Last Updated**: 2026-11-09
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/docs/policy/release.md)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -110,6 +110,10 @@ For releases at or after `v1.0.0`:
|
|||||||
minimum as described below; and
|
minimum as described below; and
|
||||||
- a patch release may make backward-compatible fixes.
|
- a patch release may make backward-compatible fixes.
|
||||||
|
|
||||||
|
In this context, "backward-compatible" means only that pre-existing, valid
|
||||||
|
configurations will continue to work following the release. Behavior need not
|
||||||
|
remain strictly identical.
|
||||||
|
|
||||||
Before `v1.0.0`, a minor release may change a documented public, operational,
|
Before `v1.0.0`, a minor release may change a documented public, operational,
|
||||||
configuration, data, integration, or compatibility contract when the release
|
configuration, data, integration, or compatibility contract when the release
|
||||||
note explains the impact and required consumer action. A patch release must
|
note explains the impact and required consumer action. A patch release must
|
||||||
|
|||||||
54
docs/releases/v1.2.0.md
Normal file
54
docs/releases/v1.2.0.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# ConfigKit v1.2.0
|
||||||
|
|
||||||
|
`LoadLayered` can now use the resolved main configuration's filesystem context
|
||||||
|
to find companion imports and the selected overlay in a single load. For example,
|
||||||
|
an application that discovers `/srv/campaign/config.yml` can import fragments
|
||||||
|
from `/srv/campaign/prompts/` and apply
|
||||||
|
`/srv/campaign/overlays/production.yml`, regardless of its working directory.
|
||||||
|
|
||||||
|
## Main-relative companion inputs
|
||||||
|
|
||||||
|
Set `Input.MainRelative` to `MainRelative()` on a non-file underlay to use the
|
||||||
|
main input's resolved context. Enable imports separately and declare the files
|
||||||
|
or directories to load. A concrete `Input.Filesystem` takes precedence over the
|
||||||
|
binding. The derived context keeps the main input's filesystem namespace and
|
||||||
|
containment root, with lookup starting at its resolved base directory.
|
||||||
|
|
||||||
|
When the main supplies no usable context, the companion can use a
|
||||||
|
`MainRelativeContext.Fallback`. Otherwise, optional imports are skipped and
|
||||||
|
the companion's own configuration still contributes. Required imports that
|
||||||
|
need targets fail, and `RequireBase` requires a usable context even when imports
|
||||||
|
are disabled. Skipped imports produce no filesystem search evidence.
|
||||||
|
|
||||||
|
The consumer example combines embedded defaults, operator imports, and a selected
|
||||||
|
overlay. [Example](../../examples/consumers/main_relative.go) ·
|
||||||
|
[Layered loading contract](../config.md#layered-loading)
|
||||||
|
|
||||||
|
## Overlay lookup and filesystem checks
|
||||||
|
|
||||||
|
`LoadLayered` now looks for a selected overlay using `OverlayFilesystem`, the
|
||||||
|
resolved main context, or `OverlayFallback`, in that order. Derived and fallback
|
||||||
|
lookups start at the context's base directory. Explicit overlay contexts and
|
||||||
|
ordinary `Load` use their existing root-relative lookup. A selected overlay
|
||||||
|
must exist in the chosen context.
|
||||||
|
|
||||||
|
Native overlays resolve symlink targets when the filesystem exposes link
|
||||||
|
inspection. This catches targets outside the configured root and aliases of
|
||||||
|
already acquired files in the same namespace. Derived standalone host contexts
|
||||||
|
now check root access the same way explicit contexts do. [Overlay contract](../config.md#selected-overlays)
|
||||||
|
|
||||||
|
## Upgrade notes
|
||||||
|
|
||||||
|
Use keyed Go literals when constructing `Input` and `LayeredOptions`. Both
|
||||||
|
types gained fields, so existing positional literals need updating. The minimum
|
||||||
|
consumer Go version is 1.27.0.
|
||||||
|
|
||||||
|
Internally, imports with and without a filesystem base share root placement
|
||||||
|
and accounting. Directory import policies reuse default patterns, and companions
|
||||||
|
in the same tier share one derived context.
|
||||||
|
|
||||||
|
The repository also adopted upstream documentation, licensing, and release
|
||||||
|
policy updates, including clearer guidance on when to write an ADR and what
|
||||||
|
compatibility means for existing configurations. [Repository policies](../policy/meta.md)
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/oss/attribution.md)
|
||||||
@@ -102,3 +102,29 @@ string equality must not be presented as semantic equality.
|
|||||||
Keeping `Path` solely as an opaque query/building value remains an alternative.
|
Keeping `Path` solely as an opaque query/building value remains an alternative.
|
||||||
No current contract changes or implementation commitment follow from this entry;
|
No current contract changes or implementation commitment follow from this entry;
|
||||||
a comparison operation can be added compatibly later.
|
a comparison operation can be added compatibly later.
|
||||||
|
|
||||||
|
## 5. Single-file named overlays
|
||||||
|
|
||||||
|
Status: deferred. Consider supporting a single `overlays.yml` containing named
|
||||||
|
overlay bodies alongside the existing
|
||||||
|
[selected-overlay directory convention](../config.md#selected-overlays).
|
||||||
|
This would let small applications keep their overlays in one file. Planning was
|
||||||
|
tabled because the added semantics outweighed the immediate convenience; the
|
||||||
|
draft is preserved at `3e50388:docs/roadmap/overlays.md`.
|
||||||
|
|
||||||
|
The draft selected a direct name-to-body mapping, strict peer composition when
|
||||||
|
both storage forms contribute to the selected name, and file-level version
|
||||||
|
checks using the application's existing rule and mode. These provide a starting
|
||||||
|
point for reconsideration, not an implementation commitment.
|
||||||
|
|
||||||
|
### Questions to resolve before implementation
|
||||||
|
|
||||||
|
- Where should collection controls live: within each named body or at catalog
|
||||||
|
level, and how should that interact with document-root control ownership?
|
||||||
|
- How should configured version metadata paths coexist with overlay names,
|
||||||
|
including nested paths and modes that do not check overlay versions?
|
||||||
|
- Which semantic checks should apply to unselected bodies?
|
||||||
|
- Should `.yaml` also be recognized, and how should multiple matching files
|
||||||
|
interact?
|
||||||
|
- How should catalog lookup be enabled without disrupting successful loads that
|
||||||
|
previously ignored an adjacent `overlays.yml`?
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
configkit "go.maximumdirect.net/configkit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version
|
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version testdata/main-relative-bundle
|
||||||
var bundledFiles embed.FS
|
var bundledFiles embed.FS
|
||||||
|
|
||||||
// BundledConfig is a small configuration loaded directly from an embedded file.
|
// BundledConfig is a small configuration loaded directly from an embedded file.
|
||||||
|
|||||||
47
examples/consumers/main_relative.go
Normal file
47
examples/consumers/main_relative.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package consumers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompanionConfig demonstrates independently bundled and operator prompt tiers.
|
||||||
|
type CompanionConfig struct {
|
||||||
|
Mode string `yaml:"mode"`
|
||||||
|
Prompts []CompanionPrompt `yaml:"prompts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompanionPrompt struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Text string `yaml:"text"`
|
||||||
|
Note string `yaml:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCompanionConfig discovers a main file and binds operator imports and the
|
||||||
|
// selected overlay to its directory while retaining the embedded catalog tier.
|
||||||
|
func LoadCompanionConfig(mainPath, overlay string) (*configkit.Result[CompanionConfig], error) {
|
||||||
|
files, err := fs.Sub(bundledFiles, "testdata/main-relative-bundle")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bundle := configkit.NewFilesystem("bundled catalog", files)
|
||||||
|
absolute, err := filepath.Abs(mainPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
declarations := map[string]any{"config": map[string]any{"imports": []any{
|
||||||
|
map[string]any{"path": "prompts.yml", "destination": []any{"prompts"}, "placement": "sequence-item"},
|
||||||
|
map[string]any{"dir": "operator", "recursive": true, "pattern": []any{"*.yml", "*.yaml"}, "destination": []any{"prompts"}, "placement": "sequence-item"},
|
||||||
|
}}}
|
||||||
|
main := configkit.SearchConfig("", configkit.Searches(configkit.SearchPaths(absolute)), configkit.SearchOptions{Required: true})
|
||||||
|
return configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[CompanionConfig]{
|
||||||
|
Underlays: []configkit.Underlay{
|
||||||
|
{Inputs: []configkit.Input{{Source: bundle.File("catalog.yml")}}},
|
||||||
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator imports", declarations), Imports: true, MainRelative: configkit.MainRelative()}}},
|
||||||
|
},
|
||||||
|
Overlay: overlay,
|
||||||
|
Definition: configkit.Definition[CompanionConfig]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id")}},
|
||||||
|
})
|
||||||
|
}
|
||||||
86
examples/consumers/main_relative_test.go
Normal file
86
examples/consumers/main_relative_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/configkit/examples/consumers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example_mainRelativeCompanions() {
|
||||||
|
result, err := consumers.LoadCompanionConfig(fixture("main-relative", "main.yml"), "live")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(result.Config.Mode, result.Config.Prompts)
|
||||||
|
// Output: overlay [{shared operator replacement } {bundled bundled text } {local local text transitive detail}]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeStrictPeers(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, contents := range map[string]string{
|
||||||
|
"main.yml": "mode: main\n", "prompts.yml": "id: duplicate\ntext: first\n", "operator/second.yml": "id: duplicate\ntext: second\n",
|
||||||
|
} {
|
||||||
|
file := filepath.Join(root, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file, []byte(contents), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := consumers.LoadCompanionConfig(filepath.Join(root, "main.yml"), "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("duplicate peer identities succeeded")
|
||||||
|
}
|
||||||
|
for _, d := range err.(*configkit.LoadError).Diagnostics() {
|
||||||
|
if d.Code == configkit.CodeComposeConflict {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeOptionalResources(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
file, directory, empty bool
|
||||||
|
count int
|
||||||
|
}{
|
||||||
|
{"neither", false, false, false, 2}, {"file", true, false, false, 3},
|
||||||
|
{"directory", false, true, false, 3}, {"both", true, true, false, 4}, {"empty directory", false, false, true, 2},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "main.yml"), []byte("mode: main\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tc.file {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "prompts.yml"), []byte("id: file\ntext: file\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.directory || tc.empty {
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "operator"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.directory {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "operator", "item.yaml"), []byte("id: directory\ntext: directory\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := consumers.LoadCompanionConfig(filepath.Join(root, "main.yml"), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if len(result.Config.Prompts) != tc.count {
|
||||||
|
t.Fatalf("prompts = %#v", result.Config.Prompts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
7
examples/consumers/testdata/main-relative-bundle/catalog.yml
vendored
Normal file
7
examples/consumers/testdata/main-relative-bundle/catalog.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mode: embedded
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
text: embedded text
|
||||||
|
note: replaced
|
||||||
|
- id: bundled
|
||||||
|
text: bundled text
|
||||||
1
examples/consumers/testdata/main-relative/main.yml
vendored
Normal file
1
examples/consumers/testdata/main-relative/main.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mode: main
|
||||||
5
examples/consumers/testdata/main-relative/operator/nested/one.yaml
vendored
Normal file
5
examples/consumers/testdata/main-relative/operator/nested/one.yaml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
config:
|
||||||
|
imports:
|
||||||
|
- path: ../../shared/extra.yml
|
||||||
|
id: local
|
||||||
|
text: local text
|
||||||
1
examples/consumers/testdata/main-relative/overlays/live.yml
vendored
Normal file
1
examples/consumers/testdata/main-relative/overlays/live.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mode: overlay
|
||||||
2
examples/consumers/testdata/main-relative/prompts.yml
vendored
Normal file
2
examples/consumers/testdata/main-relative/prompts.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
id: shared
|
||||||
|
text: operator replacement
|
||||||
1
examples/consumers/testdata/main-relative/shared/extra.yml
vendored
Normal file
1
examples/consumers/testdata/main-relative/shared/extra.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
note: transitive detail
|
||||||
253
facade_main_relative_integration_test.go
Normal file
253
facade_main_relative_integration_test.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainRelativeStandaloneRejectsInaccessibleHostRoot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mainPath := filepath.Join(root, "main.yml")
|
||||||
|
if err := os.WriteFile(mainPath, []byte("count: 1\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(root, 0100); err != nil {
|
||||||
|
t.Skipf("cannot set search-only permissions: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chmod(root, 0700); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
handle, err := os.OpenRoot(root)
|
||||||
|
if err == nil {
|
||||||
|
_ = handle.Close()
|
||||||
|
t.Skip("host allows opening this search-only directory")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, os.ErrPermission) {
|
||||||
|
t.Fatalf("open root = %v, want permission denied", err)
|
||||||
|
}
|
||||||
|
if _, err := os.ReadFile(mainPath); err != nil {
|
||||||
|
t.Skipf("main must remain readable by name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
main := configkit.Input{Source: configkit.File(mainPath)}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
filesystem *configkit.FilesystemContext
|
||||||
|
}{
|
||||||
|
{name: "derived"},
|
||||||
|
{name: "explicit", filesystem: &configkit.FilesystemContext{Root: root, BaseDir: root}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
underlay := configkit.Input{
|
||||||
|
Source: configkit.Bytes("companion", []byte("{}\n")),
|
||||||
|
MainRelative: &configkit.MainRelativeContext{RequireBase: true},
|
||||||
|
Filesystem: tc.filesystem,
|
||||||
|
}
|
||||||
|
_, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
|
||||||
|
})
|
||||||
|
var failure *configkit.LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("error = %v, want a load error", err)
|
||||||
|
}
|
||||||
|
diagnostics := failure.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportPermission {
|
||||||
|
t.Fatalf("diagnostics = %#v, want import.permission", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeDiscoveryAndEvidence(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
first, second := filepath.Join(root, "first"), filepath.Join(root, "second")
|
||||||
|
for _, dir := range []string{first, second} {
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "main.yml"), []byte("count: 1\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "fragment.yml"), []byte("settings: {selected: "+filepath.Base(dir)+"}\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding := configkit.MainRelative()
|
||||||
|
underlay := configkit.Input{Source: configkit.Bytes("declaration", []byte("config: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: binding}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
paths []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"first", []string{filepath.Join(first, "main.yml"), filepath.Join(second, "main.yml")}, "first"},
|
||||||
|
{"later", []string{filepath.Join(root, "missing.yml"), filepath.Join(second, "main.yml")}, "second"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var callbacks int
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.SearchConfig("", configkit.SearchPaths(test.paths...))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, Definition: configkit.Definition[layeredFacadeConfig]{Validate: func(*layeredFacadeConfig, *configkit.Validation) { callbacks++ }}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Settings["selected"] != test.want || callbacks != 1 {
|
||||||
|
t.Fatalf("config = %#v, callbacks = %d", result.Config, callbacks)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 || sources[0].Layer != configkit.LayerMain || sources[1].Layer != configkit.LayerUnderlay || sources[2].Layer != configkit.LayerUnderlay || sources[2].UnderlayIndex != 1 || filepath.Base(sources[2].Path) != "fragment.yml" || sources[1].Path != "" {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
if _, ok := result.SourceContext(sources[1].ID); !ok {
|
||||||
|
t.Fatal("bound synthetic source lacks context")
|
||||||
|
}
|
||||||
|
if _, ok := result.SourceContext(sources[2].ID); !ok {
|
||||||
|
t.Fatal("import source lacks context")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeSyntheticMainContexts(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "fragment.yml"), []byte("settings: {selected: synthetic}\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := &configkit.FilesystemContext{Root: root, BaseDir: root}
|
||||||
|
mainSources := []configkit.Source{configkit.Bytes("main bytes", []byte("count: 1\n")), configkit.Reader("main reader", strings.NewReader("count: 1\n")), configkit.Mapping("main mapping", map[string]any{"count": 1})}
|
||||||
|
for _, source := range mainSources {
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("underlay", []byte("config: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: configkit.MainRelative()}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: source, Filesystem: ctx}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Settings["selected"] != "synthetic" {
|
||||||
|
t.Fatalf("config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeSelectedMainFailureIsTerminal(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
bad, good := filepath.Join(root, "bad.yml"), filepath.Join(root, "good.yml")
|
||||||
|
if err := os.WriteFile(bad, []byte("count: [\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(good, []byte("count: 1\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("underlay", []byte("count: 3\n")), MainRelative: &configkit.MainRelativeContext{Fallback: &configkit.FilesystemContext{Root: root, BaseDir: root}}}
|
||||||
|
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.SearchConfig("", configkit.SearchPaths(bad, good))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}, Definition: configkit.Definition[layeredFacadeConfig]{Validate: func(*layeredFacadeConfig, *configkit.Validation) { called = true }}})
|
||||||
|
if err == nil || called {
|
||||||
|
t.Fatalf("error = %v, callback = %v", err, called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeSourceVersionEvidence(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "main.yml"), []byte("version: 1\ncount: 1\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "fragment.yml"), []byte("version: 2\nsettings: {loaded: fragment}\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("companion", []byte("version: 3\nconfig: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: configkit.MainRelative()}
|
||||||
|
type versioned struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Settings map[string]string `yaml:"settings"`
|
||||||
|
}
|
||||||
|
definition := configkit.Definition[versioned]{Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(root, "main.yml"))}, configkit.LayeredOptions[versioned]{Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
for i, want := range []string{"1", "3", "2"} {
|
||||||
|
evidence := sources[i].VersionEvidence
|
||||||
|
if evidence == nil || evidence.Version.Text != want || evidence.Location == nil || evidence.Location.SourceID != sources[i].ID {
|
||||||
|
t.Fatalf("version evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeOptionalSearchExhaustion(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "overlays"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, content := range map[string]string{
|
||||||
|
"fragment.yml": "settings: {companion: loaded}\n",
|
||||||
|
"overlays/live.yml": "count: 9\n",
|
||||||
|
} {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
search := configkit.SearchConfig("", configkit.SearchPaths(filepath.Join(root, "absent.yml")))
|
||||||
|
missing := filepath.Join(root, "missing-fallback")
|
||||||
|
fallback := &configkit.FilesystemContext{Root: missing, BaseDir: missing}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
mainContext *configkit.FilesystemContext
|
||||||
|
bindingFallback *configkit.FilesystemContext
|
||||||
|
overlayFallback *configkit.FilesystemContext
|
||||||
|
overlay string
|
||||||
|
wantCount int
|
||||||
|
wantSources int
|
||||||
|
}{
|
||||||
|
{name: "skip without context", wantCount: 3, wantSources: 1},
|
||||||
|
{
|
||||||
|
name: "root-only main context wins", mainContext: &configkit.FilesystemContext{Root: root},
|
||||||
|
bindingFallback: fallback, overlayFallback: fallback, overlay: "live", wantCount: 9, wantSources: 3,
|
||||||
|
},
|
||||||
|
{name: "selected missing companion fallback", bindingFallback: fallback},
|
||||||
|
{name: "selected missing overlay fallback", overlay: "live", overlayFallback: fallback},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
underlay := configkit.Input{
|
||||||
|
Source: configkit.Bytes("companion", []byte("count: 3\nconfig: {imports: [{path: fragment.yml}]}\n")),
|
||||||
|
Imports: true, MainRelative: &configkit.MainRelativeContext{Fallback: tc.bindingFallback},
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered(
|
||||||
|
configkit.Input{Source: search, Filesystem: tc.mainContext},
|
||||||
|
configkit.LayeredOptions[layeredFacadeConfig]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
|
||||||
|
Overlay: tc.overlay, OverlayFallback: tc.overlayFallback,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if tc.wantSources == 0 {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("missing selected fallback succeeded")
|
||||||
|
}
|
||||||
|
diagnostics := err.(*configkit.LoadError).Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportInvalid {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if result.Config.Count != tc.wantCount || len(sources) != tc.wantSources {
|
||||||
|
t.Fatalf("config = %#v, sources = %#v", result.Config, sources)
|
||||||
|
}
|
||||||
|
if sources[0].Layer != configkit.LayerUnderlay || len(sources[0].EmptyImportSelections) != 0 {
|
||||||
|
t.Fatalf("declaring source = %#v", sources[0])
|
||||||
|
}
|
||||||
|
if tc.mainContext != nil && (result.Config.Settings["companion"] != "loaded" || sources[2].Layer != configkit.LayerOverlay) {
|
||||||
|
t.Fatalf("config = %#v, sources = %#v", result.Config, sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
45
facade_main_relative_options_test.go
Normal file
45
facade_main_relative_options_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainRelativeOptions(t *testing.T) {
|
||||||
|
if configkit.MainRelative() == configkit.MainRelative() {
|
||||||
|
t.Fatal("binding descriptions must be distinct")
|
||||||
|
}
|
||||||
|
invalid := &configkit.FilesystemContext{BaseDir: "relative", Root: "relative"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
main configkit.Input
|
||||||
|
underlay configkit.Input
|
||||||
|
overlay string
|
||||||
|
overlayContext, fallback *configkit.FilesystemContext
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "binding on main", main: configkit.Input{Source: configkit.NoSource(), MainRelative: configkit.MainRelative()}, wantError: true},
|
||||||
|
{name: "binding on file", underlay: configkit.Input{Source: configkit.File("missing"), MainRelative: configkit.MainRelative()}, wantError: true},
|
||||||
|
{name: "invalid fallback", underlay: configkit.Input{Source: configkit.NoSource(), MainRelative: &configkit.MainRelativeContext{Fallback: invalid}}, wantError: true},
|
||||||
|
{name: "shadowed binding", underlay: configkit.Input{Source: configkit.NoSource(), Filesystem: &configkit.FilesystemContext{Root: t.TempDir()}, MainRelative: &configkit.MainRelativeContext{Fallback: invalid}}},
|
||||||
|
{name: "inactive overlay fallback", fallback: invalid},
|
||||||
|
{name: "invalid explicit overlay", overlay: "test", overlayContext: invalid, fallback: &configkit.FilesystemContext{Root: t.TempDir(), BaseDir: t.TempDir()}, wantError: true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
main := tc.main
|
||||||
|
if tc.name != "binding on main" {
|
||||||
|
main.Source = configkit.NoSource()
|
||||||
|
}
|
||||||
|
opts := configkit.LayeredOptions[layeredFacadeConfig]{Overlay: tc.overlay, OverlayFilesystem: tc.overlayContext, OverlayFallback: tc.fallback}
|
||||||
|
if tc.name == "binding on file" || tc.name == "invalid fallback" || tc.name == "shadowed binding" {
|
||||||
|
opts.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{tc.underlay}}}
|
||||||
|
}
|
||||||
|
_, err := configkit.LoadLayered(main, opts)
|
||||||
|
if (err != nil) != tc.wantError {
|
||||||
|
t.Fatalf("error = %v, want error %v", err, tc.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
260
facade_main_relative_test.go
Normal file
260
facade_main_relative_test.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainRelativeCompanion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "main.yml"), []byte("count: 3\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "extra.yml"), []byte("settings: {companion: host}\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
binding := configkit.MainRelative()
|
||||||
|
companion := configkit.Input{Source: configkit.Bytes("companions", []byte("config: {imports: [{path: extra.yml}]}\n")), Imports: true, MainRelative: binding}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(dir, "main.yml"))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 3 || result.Config.Settings["companion"] != "host" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
native := configkit.NewFilesystem("bundle", fstest.MapFS{"dir/main.yml": &fstest.MapFile{Data: []byte("count: 4\n")}, "dir/extra.yml": &fstest.MapFile{Data: []byte("settings: {companion: native}\n")}})
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: native.File("dir/main.yml")}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 4 || result.Config.Settings["companion"] != "native" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeContextSelection(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
base := filepath.Join(root, "main")
|
||||||
|
if err := os.Mkdir(base, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for path, value := range map[string]string{"main/main.yml": "count: 1\n", "main/extra.yml": "settings: {value: derived}\n", "extra.yml": "settings: {value: fallback}\n"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, path), []byte(value), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
link := filepath.Join(root, "link.yml")
|
||||||
|
if err := os.Symlink(filepath.Join(base, "main.yml"), link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
companion := configkit.Input{Source: configkit.Bytes("companion", []byte("config: {imports: [{path: extra.yml}]}\n")), Imports: true, MainRelative: &configkit.MainRelativeContext{Fallback: &configkit.FilesystemContext{Root: root, BaseDir: root}}}
|
||||||
|
opts := configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(link), Filesystem: &configkit.FilesystemContext{Root: root}}, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if got := result.Config.Settings["value"]; got != "derived" {
|
||||||
|
t.Fatalf("derived value = %q", got)
|
||||||
|
}
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if got := result.Config.Settings["value"]; got != "fallback" {
|
||||||
|
t.Fatalf("fallback value = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeWithoutBase(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, source string
|
||||||
|
requireBase bool
|
||||||
|
wantCode configkit.Code
|
||||||
|
}{
|
||||||
|
{"optional", "config: {imports: [{path: missing.yml}]}\ncount: 7\n", false, ""},
|
||||||
|
{"required", "config: {imports: [{path: missing.yml, required: true}]}\n", false, configkit.CodeImportMissing},
|
||||||
|
{"required directory", "config: {imports: [{dir: missing, required: true}]}\n", false, configkit.CodeImportMissing},
|
||||||
|
{"required glob", "config: {imports: [{glob: '*.yml', required: true}]}\n", false, configkit.CodeImportMissing},
|
||||||
|
{"empty required", "config: {imports: [{path: [], required: true}]}\ncount: 7\n", false, ""},
|
||||||
|
{"base required", "count: 7\n", true, configkit.CodeOptionsInvalid},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("companion", []byte(test.source)), Imports: true, MainRelative: &configkit.MainRelativeContext{RequireBase: test.requireBase}}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||||
|
if test.wantCode != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected failure")
|
||||||
|
}
|
||||||
|
for _, d := range err.(*configkit.LoadError).Diagnostics() {
|
||||||
|
if d.Code == test.wantCode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics: %#v", err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 7 {
|
||||||
|
t.Fatalf("config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 1 || len(sources[0].EmptyImportSelections) != 0 {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeWithoutBaseSequenceItem(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("item", []byte("config: {imports: [{path: missing.yml}]}\nname: example\n")), Imports: true, MainRelative: configkit.MainRelative(), Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if len(result.Config.Items) != 1 || result.Config.Items[0].Name != "example" {
|
||||||
|
t.Fatalf("config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeWithoutBaseImportDefaults(t *testing.T) {
|
||||||
|
required := true
|
||||||
|
tests := []struct {
|
||||||
|
name, source string
|
||||||
|
imports bool
|
||||||
|
requireBase bool
|
||||||
|
code configkit.Code
|
||||||
|
}{
|
||||||
|
{"default required", "config: {imports: [{path: missing.yml}]}\n", true, false, configkit.CodeImportMissing},
|
||||||
|
{"malformed", "config: {imports: [{path: 42}]}\n", true, false, configkit.CodeImportInvalid},
|
||||||
|
{"imports disabled", "config: {imports: [{path: missing.yml}]}\n", false, false, configkit.CodeImportDisabled},
|
||||||
|
{"base required without imports", "count: 1\n", false, true, configkit.CodeOptionsInvalid},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("companion", []byte(test.source)), Imports: test.imports, MainRelative: &configkit.MainRelativeContext{RequireBase: test.requireBase}}
|
||||||
|
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{ImportDefaults: configkit.ImportDefaults{Required: &required}, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected failure")
|
||||||
|
}
|
||||||
|
for _, diagnostic := range err.(*configkit.LoadError).Diagnostics() {
|
||||||
|
if diagnostic.Code == test.code {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredOverlayContextSelection(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
base := filepath.Join(root, "base")
|
||||||
|
for _, dir := range []string{filepath.Join(root, "overlays"), filepath.Join(base, "overlays")} {
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path, content := range map[string]string{"base/main.yml": "count: 1\n", "base/overlays/test.yml": "count: 2\n", "overlays/test.yml": "count: 3\n"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main := configkit.Input{Source: configkit.File(filepath.Join(base, "main.yml")), Filesystem: &configkit.FilesystemContext{Root: root}}
|
||||||
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 2 {
|
||||||
|
t.Fatalf("derived overlay = %d", result.Config.Count)
|
||||||
|
}
|
||||||
|
result, err = configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: root, BaseDir: base}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 3 {
|
||||||
|
t.Fatalf("explicit overlay = %d", result.Config.Count)
|
||||||
|
}
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test", OverlayFallback: &configkit.FilesystemContext{Root: root, BaseDir: base}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 2 {
|
||||||
|
t.Fatalf("fallback overlay = %d", result.Config.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredOverlayNativeContext(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"dir/main.yml": &fstest.MapFile{Data: []byte("count: 1\n")},
|
||||||
|
"dir/overlays/test.yml": &fstest.MapFile{Data: []byte("count: 9\n")},
|
||||||
|
})
|
||||||
|
main := configkit.Input{Source: filesystem.File("dir/main.yml"), Filesystem: &configkit.FilesystemContext{Root: ".", FS: &filesystem}}
|
||||||
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 9 {
|
||||||
|
t.Fatalf("config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
_, err = configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "missing", OverlayFallback: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("missing selected overlay succeeded")
|
||||||
|
}
|
||||||
|
for _, diagnostic := range err.(*configkit.LoadError).Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeOverlayMissing {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredOverlayNativeLinks(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, mainPath, target string
|
||||||
|
want configkit.Code
|
||||||
|
}{
|
||||||
|
{"outside root", "scope/main.yml", "../../outside.yml", configkit.CodeOverlayOutsideRoot},
|
||||||
|
{"overlay aliases main", "scope/main.yml", "../main.yml", configkit.CodeOverlayRepeated},
|
||||||
|
{"main aliases overlay target", "scope/alias.yml", "../target.yml", configkit.CodeOverlayRepeated},
|
||||||
|
{"contained overlay", "scope/main.yml", "../target.yml", ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
native := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"scope/main.yml": {Data: []byte("count: 1\n")},
|
||||||
|
"scope/alias.yml": {Data: []byte("target.yml"), Mode: os.ModeSymlink},
|
||||||
|
"scope/target.yml": {Data: []byte("count: 7\n")},
|
||||||
|
"scope/overlays/test.yml": {Data: []byte(tc.target), Mode: os.ModeSymlink},
|
||||||
|
"outside.yml": {Data: []byte("count: 9\n")},
|
||||||
|
})
|
||||||
|
main := configkit.Input{Source: native.File(tc.mainPath), Filesystem: &configkit.FilesystemContext{Root: "scope", FS: &native}}
|
||||||
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"})
|
||||||
|
if tc.want != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("overlay link unexpectedly loaded")
|
||||||
|
}
|
||||||
|
diagnostics := err.(*configkit.LoadError).Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != tc.want {
|
||||||
|
t.Fatalf("diagnostics = %#v, want %v", diagnostics, tc.want)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Count != 7 || result.Sources()[1].Path != "scope/target.yml" {
|
||||||
|
t.Fatalf("config = %#v, sources = %#v", result.Config, result.Sources())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ type tierAcquisitionState struct {
|
|||||||
|
|
||||||
type inputAcquisitionRequest struct {
|
type inputAcquisitionRequest struct {
|
||||||
input loadInput
|
input loadInput
|
||||||
|
resolved *resolvedFilesystemContext
|
||||||
|
withoutBase bool
|
||||||
role sourceVersionRole
|
role sourceVersionRole
|
||||||
directiveContext rootDirectiveContext
|
directiveContext rootDirectiveContext
|
||||||
protectionRule *VersionRule
|
protectionRule *VersionRule
|
||||||
@@ -33,6 +35,9 @@ type importAcquisitionRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
||||||
|
if request.withoutBase && request.input.mainRelative.RequireBase {
|
||||||
|
return assembledInput{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "main-relative input requires a filesystem base")}
|
||||||
|
}
|
||||||
source := request.input.source
|
source := request.input.source
|
||||||
destination := request.input.destination
|
destination := request.input.destination
|
||||||
accounting := state.accounting
|
accounting := state.accounting
|
||||||
@@ -101,7 +106,12 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta
|
|||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
request.input.source = selectedSource
|
request.input.source = selectedSource
|
||||||
if request.input.imports && selectedSource.kind != sourceNone {
|
if request.input.imports && selectedSource.kind != sourceNone && request.withoutBase {
|
||||||
|
input, diagnostics = acquireContextlessInput(input, request, versionProcessing, policy)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
} else if request.input.imports && selectedSource.kind != sourceNone {
|
||||||
importRequest := importAcquisitionRequest{input: request, main: parsed, processing: versionProcessing}
|
importRequest := importAcquisitionRequest{input: request, main: parsed, processing: versionProcessing}
|
||||||
input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier)
|
input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -126,6 +136,12 @@ func acquireStandaloneInput(input assembledInput, request inputAcquisitionReques
|
|||||||
if processing != nil {
|
if processing != nil {
|
||||||
input.document = processing.payload
|
input.document = processing.payload
|
||||||
}
|
}
|
||||||
|
if request.resolved != nil {
|
||||||
|
if diagnostic := validateResolvedFilesystemContext(*request.resolved); diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
input.resolvedFilesystem = request.resolved
|
||||||
|
}
|
||||||
if request.input.filesystem != nil && (input.source.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
|
if request.input.filesystem != nil && (input.source.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
|
||||||
resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken)
|
resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
|
|||||||
72
internal/engine/contextless_imports.go
Normal file
72
internal/engine/contextless_imports.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "go.maximumdirect.net/configkit/internal/document"
|
||||||
|
|
||||||
|
func acquireContextlessInput(input assembledInput, request inputAcquisitionRequest, processing *sourceVersionProcessingResult, policy acquisitionPolicy) (assembledInput, []Diagnostic) {
|
||||||
|
parsed := input.parsed
|
||||||
|
var body *document.Node
|
||||||
|
var declarations []importDeclarationSpec
|
||||||
|
var sequences []sequenceDeclarationSpec
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
if processing != nil {
|
||||||
|
body, declarations = processing.payload, processing.imports
|
||||||
|
sequences = cloneSequenceDeclarations(processing.sequences)
|
||||||
|
} else {
|
||||||
|
body, declarations, diagnostics = splitImportDirectives(parsed.document)
|
||||||
|
}
|
||||||
|
destination := request.input.destination
|
||||||
|
localDestination := destination
|
||||||
|
if request.input.placementMode == PlacementModeSequenceItem {
|
||||||
|
localDestination = Path{}
|
||||||
|
}
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
var policies []resolvedImportPolicy
|
||||||
|
policies, diagnostics = prepareImportDeclarations(declarations, policy.importDefaults, localDestination)
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
for i, spec := range declarations {
|
||||||
|
if !policies[i].required || spec.selector == importSelectorPath && len(spec.paths) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
site := spec.fieldSites["path"]
|
||||||
|
if spec.selector == importSelectorPath && len(spec.selectorSites) != 0 {
|
||||||
|
site = spec.selectorSites[0]
|
||||||
|
}
|
||||||
|
if spec.selector == importSelectorDirectory {
|
||||||
|
site = spec.fieldSites["dir"]
|
||||||
|
}
|
||||||
|
if spec.selector == importSelectorGlob {
|
||||||
|
site = spec.fieldSites["glob"]
|
||||||
|
}
|
||||||
|
diagnostics = []Diagnostic{{Code: CodeImportMissing, Severity: SeverityError, Path: site.path.clone(), Message: "import cannot be selected because no filesystem base is available", Primary: cloneLocation(&site.location)}}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
if request.input.placementMode == PlacementModeSequenceItem {
|
||||||
|
diagnostics = prependCompositionDiagnosticPaths(diagnostics, destination.Index(0))
|
||||||
|
}
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
accounting, limits := input.accounting, policy.importLimits
|
||||||
|
if diagnostic := chargeDeclaringImport(parsed, accounting, limits); diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
key := importKeyForMainSource(parsed.source)
|
||||||
|
root := acquiredContribution{
|
||||||
|
source: cloneSourceInfo(*parsed.source), payload: body, identity: key,
|
||||||
|
role: importRoleMain, scopeOwner: key, processing: processing, sequenceDeclarations: sequences,
|
||||||
|
}
|
||||||
|
scope, diagnostic := prepareDeclaringImportScope(root, accounting, limits)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
scope, diagnostic = finalizeDeclaringImportScope(scope, root, accounting, limits)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
input.document = body
|
||||||
|
input.composition = &composedDocument{acquisition: []acquisitionScope{scope}, importGraph: newImportInclusionGraph(), inputBytes: accounting.bytes, inputNodes: accounting.nodes}
|
||||||
|
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
@@ -17,23 +17,30 @@ func validateFilesystemContext(source Source, context *FilesystemContext, main p
|
|||||||
}
|
}
|
||||||
return resolvedFilesystemContext{}, diagnostic
|
return resolvedFilesystemContext{}, diagnostic
|
||||||
}
|
}
|
||||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
if diagnostic := validateResolvedFilesystemContext(resolved); diagnostic != nil {
|
||||||
return resolvedFilesystemContext{}, diagnostic
|
return resolvedFilesystemContext{}, diagnostic
|
||||||
}
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResolvedFilesystemContext(resolved resolvedFilesystemContext) *Diagnostic {
|
||||||
|
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
if resolved.native != nil {
|
if resolved.native != nil {
|
||||||
return resolved, nil
|
return nil
|
||||||
}
|
}
|
||||||
root, err := os.OpenRoot(resolved.rootPath)
|
root, err := os.OpenRoot(resolved.rootPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
value.Primary = nil
|
value.Primary = nil
|
||||||
return resolvedFilesystemContext{}, &value
|
return &value
|
||||||
}
|
}
|
||||||
if err := root.Close(); err != nil {
|
if err := root.Close(); err != nil {
|
||||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||||
return resolvedFilesystemContext{}, &value
|
return &value
|
||||||
}
|
}
|
||||||
return resolved, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||||
|
|||||||
@@ -38,26 +38,30 @@ func acquireImportedInput(request importAcquisitionRequest, policy *acquisitionP
|
|||||||
source := request.input.input.source
|
source := request.input.input.source
|
||||||
context := request.input.input.filesystem
|
context := request.input.input.filesystem
|
||||||
main := request.main
|
main := request.main
|
||||||
resolved, diagnostic := resolveFilesystemContext(source, context, main, tier.hostToken)
|
resolved := request.input.resolved
|
||||||
if diagnostic != nil {
|
if resolved == nil {
|
||||||
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
value, diagnostic := resolveFilesystemContext(source, context, main, tier.hostToken)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
resolved = &value
|
||||||
}
|
}
|
||||||
if resolved.native == nil {
|
if resolved.native == nil {
|
||||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
if diagnostic := validateResolvedFilesystemDirectories(*resolved); diagnostic != nil {
|
||||||
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
access, err := openResolvedFilesystemAccess(resolved)
|
access, err := openResolvedFilesystemAccess(*resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
diagnostic.Primary = nil
|
diagnostic.Primary = nil
|
||||||
return nil, sourceInfos(main.source), nil, []Diagnostic{diagnostic}
|
return nil, sourceInfos(main.source), nil, []Diagnostic{diagnostic}
|
||||||
}
|
}
|
||||||
composition, sources, diagnostics := acquireImportedWithAccess(request, policy, state, tier, access, resolved)
|
composition, sources, diagnostics := acquireImportedWithAccess(request, policy, state, tier, access, *resolved)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return composition, sources, nil, diagnostics
|
return composition, sources, nil, diagnostics
|
||||||
}
|
}
|
||||||
return composition, sources, &resolved, nil
|
return composition, sources, resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisitionPolicy, loadState *acquisitionState, tier *tierAcquisitionState, access *filesystemAccess, resolved resolvedFilesystemContext) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisitionPolicy, loadState *acquisitionState, tier *tierAcquisitionState, access *filesystemAccess, resolved resolvedFilesystemContext) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
@@ -107,9 +111,6 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
_ = closeAccess()
|
_ = closeAccess()
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
||||||
}
|
}
|
||||||
accounting.sources++
|
|
||||||
accounting.bytes += main.bytes
|
|
||||||
accounting.nodes += countInputNodes(main.document)
|
|
||||||
directiveContext := request.input.directiveContext
|
directiveContext := request.input.directiveContext
|
||||||
if directiveContext == 0 {
|
if directiveContext == 0 {
|
||||||
directiveContext = rootDirectiveContextImportsOnly
|
directiveContext = rootDirectiveContextImportsOnly
|
||||||
@@ -120,7 +121,6 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
request.input.directiveContext = directiveContext
|
request.input.directiveContext = directiveContext
|
||||||
state := importTraversal{
|
state := importTraversal{
|
||||||
access: access, request: request, policy: policy, state: loadState, tier: tier,
|
access: access, request: request, policy: policy, state: loadState, tier: tier,
|
||||||
scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()},
|
|
||||||
sources: []SourceInfo{mainInfo},
|
sources: []SourceInfo{mainInfo},
|
||||||
active: make(map[resourceIdentity]int),
|
active: make(map[resourceIdentity]int),
|
||||||
importGraph: newImportInclusionGraph(),
|
importGraph: newImportInclusionGraph(),
|
||||||
@@ -129,11 +129,10 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
state.itemScope = true
|
state.itemScope = true
|
||||||
state.itemPrefix = destination.clone()
|
state.itemPrefix = destination.clone()
|
||||||
state.itemFallback = destination.clone()
|
state.itemFallback = destination.clone()
|
||||||
state.scope.destination = Path{}
|
|
||||||
state.sources[0] = cloneSourceInfo(mainInfo)
|
state.sources[0] = cloneSourceInfo(mainInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
if diagnostic := chargeDeclaringImport(main, accounting, limits); diagnostic != nil {
|
||||||
_ = closeAccess()
|
_ = closeAccess()
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
@@ -159,31 +158,18 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
} else {
|
} else {
|
||||||
body, declarations, diagnostics = splitImportDirectives(main.document)
|
body, declarations, diagnostics = splitImportDirectives(main.document)
|
||||||
}
|
}
|
||||||
|
root := acquiredContribution{
|
||||||
|
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
|
||||||
|
role: importRoleMain, scopeOwner: mainNodeKey, processing: processing,
|
||||||
|
sequenceDeclarations: sequenceDeclarations,
|
||||||
|
}
|
||||||
if len(diagnostics) == 0 {
|
if len(diagnostics) == 0 {
|
||||||
if mainIsItem {
|
scope, diagnostic := prepareDeclaringImportScope(root, accounting, limits)
|
||||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
if diagnostic != nil {
|
||||||
source: cloneSourceInfo(mainInfo), identity: mainNodeKey, role: importRoleMain,
|
diagnostics = []Diagnostic{*diagnostic}
|
||||||
scopeOwner: mainNodeKey, payload: body, document: body, processing: processing,
|
|
||||||
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
|
||||||
})
|
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{}, 0)
|
|
||||||
} else {
|
} else {
|
||||||
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
state.scope = scope
|
||||||
placementNodes := destinationWrapperNodeCount(destination)
|
diagnostics = state.walkDeclarations(basePath, declarations, 0, scope.destination, 0)
|
||||||
if err != nil {
|
|
||||||
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
|
||||||
} else if accounting.nodes+placementNodes > limits.nodes {
|
|
||||||
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
|
|
||||||
} else {
|
|
||||||
accounting.nodes += placementNodes
|
|
||||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
|
||||||
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
|
|
||||||
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
|
|
||||||
destination: destination.clone(), processing: processing,
|
|
||||||
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
|
||||||
})
|
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination, 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(diagnostics) == 0 {
|
if len(diagnostics) == 0 {
|
||||||
@@ -200,19 +186,8 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
||||||
}
|
}
|
||||||
acquisition := state.scope
|
acquisition, diagnostic := finalizeDeclaringImportScope(state.scope, root, accounting, limits)
|
||||||
if mainIsItem {
|
if diagnostic != nil {
|
||||||
localScope := state.scope
|
|
||||||
acquisition = acquisitionScope{
|
|
||||||
owner: mainNodeKey, destination: destination.clone(),
|
|
||||||
contributions: []acquiredContribution{{
|
|
||||||
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
|
|
||||||
role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(),
|
|
||||||
processing: processing, sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations), pendingItem: &localScope,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil {
|
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
return &composedDocument{
|
return &composedDocument{
|
||||||
@@ -221,35 +196,27 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
}, cloneSourceInfos(state.sources), nil
|
}, cloneSourceInfos(state.sources), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
func prepareImportDeclarations(declarations []importDeclarationSpec, defaults ImportDefaults, parentDestination Path) ([]resolvedImportPolicy, []Diagnostic) {
|
||||||
if s.state.accounting.sources > s.policy.importLimits.sources {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
if s.state.accounting.bytes > s.policy.importLimits.bytes {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
if s.state.accounting.nodes > s.policy.importLimits.nodes {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path, sourceIndex int) []Diagnostic {
|
|
||||||
policies := make([]resolvedImportPolicy, len(declarations))
|
policies := make([]resolvedImportPolicy, len(declarations))
|
||||||
for index, spec := range declarations {
|
for index, spec := range declarations {
|
||||||
policy, diagnostics := resolveImportPolicy(spec, s.policy.importDefaults)
|
policy, diagnostics := resolveImportPolicy(spec, defaults)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return nil, diagnostics
|
||||||
}
|
}
|
||||||
if policy.placement == PlacementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 {
|
if policy.placement == PlacementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 {
|
||||||
site := spec.fieldSites["placement"]
|
site := spec.fieldSites["placement"]
|
||||||
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: site.path.clone(), Message: "sequence-item placement requires a nonempty destination", Primary: cloneLocation(&site.location)}}
|
return nil, []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: site.path.clone(), Message: "sequence-item placement requires a nonempty destination", Primary: cloneLocation(&site.location)}}
|
||||||
}
|
}
|
||||||
policies[index] = policy
|
policies[index] = policy
|
||||||
}
|
}
|
||||||
|
return policies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path, sourceIndex int) []Diagnostic {
|
||||||
|
policies, diagnostics := prepareImportDeclarations(declarations, s.policy.importDefaults, parentDestination)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
for index, spec := range declarations {
|
for index, spec := range declarations {
|
||||||
policy := policies[index]
|
policy := policies[index]
|
||||||
var selection importSelection
|
var selection importSelection
|
||||||
|
|||||||
@@ -285,3 +285,58 @@ func importedVersionClaim(documents []*document.Node, scope acquisitionScope, co
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The declaring payload contributes once even when there are no admitted imports.
|
||||||
|
func chargeDeclaringImport(parsed parsedSource, accounting *importAccounting, limits importLimits) *Diagnostic {
|
||||||
|
accounting.sources++
|
||||||
|
accounting.bytes += parsed.bytes
|
||||||
|
accounting.nodes += countInputNodes(parsed.document)
|
||||||
|
if accounting.sources > limits.sources {
|
||||||
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
if accounting.bytes > limits.bytes {
|
||||||
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
if accounting.nodes > limits.nodes {
|
||||||
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareDeclaringImportScope(root acquiredContribution, accounting *importAccounting, limits importLimits) (acquisitionScope, *Diagnostic) {
|
||||||
|
scope := acquisitionScope{owner: root.identity}
|
||||||
|
if root.source.placementMode == PlacementModeSequenceItem {
|
||||||
|
root.document = root.payload
|
||||||
|
} else {
|
||||||
|
destination := root.source.Destination
|
||||||
|
_, placed, err := placeDocument(root.payload, destination, root.payload.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
|
if err != nil {
|
||||||
|
diagnostic := sourceDiagnostic(CodeSourceLimitExceeded, root.source.ID, "configuration exceeds the source depth or node limit after destination placement")
|
||||||
|
return acquisitionScope{}, &diagnostic
|
||||||
|
}
|
||||||
|
placementNodes := destinationWrapperNodeCount(destination)
|
||||||
|
if accounting.nodes+placementNodes > limits.nodes {
|
||||||
|
diagnostic := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
||||||
|
return acquisitionScope{}, &diagnostic
|
||||||
|
}
|
||||||
|
accounting.nodes += placementNodes
|
||||||
|
root.document, root.destination = placed, destination.clone()
|
||||||
|
scope.destination = destination.clone()
|
||||||
|
}
|
||||||
|
scope.contributions = []acquiredContribution{root}
|
||||||
|
return scope, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeDeclaringImportScope(scope acquisitionScope, root acquiredContribution, accounting *importAccounting, limits importLimits) (acquisitionScope, *Diagnostic) {
|
||||||
|
if root.source.placementMode == PlacementModeSequenceItem {
|
||||||
|
root.destination = root.source.Destination.clone()
|
||||||
|
localScope := scope
|
||||||
|
root.pendingItem = &localScope
|
||||||
|
scope = acquisitionScope{owner: root.identity, destination: root.destination.clone(), contributions: []acquiredContribution{root}}
|
||||||
|
}
|
||||||
|
diagnostic := reservePendingItemPlacements(&scope, limits, accounting)
|
||||||
|
return scope, diagnostic
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ func resolveImportPolicy(spec importDeclarationSpec, defaults ImportDefaults) (r
|
|||||||
if spec.patternSet {
|
if spec.patternSet {
|
||||||
policy.pattern = cloneStrings(spec.pattern)
|
policy.pattern = cloneStrings(spec.pattern)
|
||||||
} else {
|
} else {
|
||||||
policy.pattern = cloneStrings(defaults.Pattern)
|
// Defaults belong to the load's snapshot. Selectors only read them.
|
||||||
|
policy.pattern = defaults.Pattern
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if spec.depthSet && !policy.recursive {
|
if spec.depthSet && !policy.recursive {
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ type Input struct {
|
|||||||
Source Source
|
Source Source
|
||||||
Imports bool
|
Imports bool
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
|
MainRelative *MainRelativeContext
|
||||||
Destination Path
|
Destination Path
|
||||||
PlacementMode PlacementMode
|
PlacementMode PlacementMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MainRelativeContext struct {
|
||||||
|
Fallback *FilesystemContext
|
||||||
|
RequireBase bool
|
||||||
|
}
|
||||||
|
|
||||||
// Underlay contains strictly composed contributions at one precedence tier.
|
// Underlay contains strictly composed contributions at one precedence tier.
|
||||||
type Underlay struct{ Inputs []Input }
|
type Underlay struct{ Inputs []Input }
|
||||||
|
|
||||||
@@ -22,6 +28,7 @@ type LayeredOptions[T any] struct {
|
|||||||
Underlays []Underlay
|
Underlays []Underlay
|
||||||
Overlay string
|
Overlay string
|
||||||
OverlayFilesystem *FilesystemContext
|
OverlayFilesystem *FilesystemContext
|
||||||
|
OverlayFallback *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +44,14 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
|||||||
}
|
}
|
||||||
request := loadRequest[T]{
|
request := loadRequest[T]{
|
||||||
main: loadInput{
|
main: loadInput{
|
||||||
source: main.Source, imports: main.Imports, filesystem: main.Filesystem,
|
source: main.Source, imports: main.Imports, filesystem: main.Filesystem, mainRelative: main.MainRelative,
|
||||||
destination: main.Destination.clone(), placementMode: main.PlacementMode,
|
destination: main.Destination.clone(), placementMode: main.PlacementMode,
|
||||||
},
|
},
|
||||||
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
|
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
|
||||||
importDefaults: options.ImportDefaults,
|
importDefaults: options.ImportDefaults,
|
||||||
overlay: options.Overlay,
|
overlay: options.Overlay,
|
||||||
overlayFilesystem: options.OverlayFilesystem,
|
overlayFilesystem: options.OverlayFilesystem,
|
||||||
|
overlayFallback: options.OverlayFallback,
|
||||||
overrides: append([]Override(nil), options.Overrides...),
|
overrides: append([]Override(nil), options.Overrides...),
|
||||||
underlays: make([]loadGroup, len(options.Underlays)),
|
underlays: make([]loadGroup, len(options.Underlays)),
|
||||||
}
|
}
|
||||||
@@ -51,7 +59,7 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
|||||||
request.underlays[index].inputs = make([]loadInput, len(underlay.Inputs))
|
request.underlays[index].inputs = make([]loadInput, len(underlay.Inputs))
|
||||||
for inputIndex, input := range underlay.Inputs {
|
for inputIndex, input := range underlay.Inputs {
|
||||||
request.underlays[index].inputs[inputIndex] = loadInput{
|
request.underlays[index].inputs[inputIndex] = loadInput{
|
||||||
source: input.Source, imports: input.Imports, filesystem: input.Filesystem,
|
source: input.Source, imports: input.Imports, filesystem: input.Filesystem, mainRelative: input.MainRelative,
|
||||||
destination: input.Destination.clone(), placementMode: input.PlacementMode,
|
destination: input.Destination.clone(), placementMode: input.PlacementMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type loadRequest[T any] struct {
|
|||||||
effectiveVersion *VersionRule
|
effectiveVersion *VersionRule
|
||||||
overlay string
|
overlay string
|
||||||
overlayFilesystem *FilesystemContext
|
overlayFilesystem *FilesystemContext
|
||||||
|
overlayFallback *FilesystemContext
|
||||||
overrides []Override
|
overrides []Override
|
||||||
ordinary bool
|
ordinary bool
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,7 @@ type loadInput struct {
|
|||||||
source Source
|
source Source
|
||||||
imports bool
|
imports bool
|
||||||
filesystem *FilesystemContext
|
filesystem *FilesystemContext
|
||||||
|
mainRelative *MainRelativeContext
|
||||||
destination Path
|
destination Path
|
||||||
placementMode PlacementMode
|
placementMode PlacementMode
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,9 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec
|
|||||||
request.importDefaults = defaults
|
request.importDefaults = defaults
|
||||||
request.sequencePolicy = policy
|
request.sequencePolicy = policy
|
||||||
request.definition.sequenceIdentityRegistrations = nil
|
request.definition.sequenceIdentityRegistrations = nil
|
||||||
|
if request.main.mainRelative != nil && request.main.filesystem == nil {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "main input cannot use main-relative context"))
|
||||||
|
}
|
||||||
main, mainDiagnostics := normalizeInput(request.main, "")
|
main, mainDiagnostics := normalizeInput(request.main, "")
|
||||||
request.main = main
|
request.main = main
|
||||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||||
@@ -64,6 +69,13 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec
|
|||||||
for groupIndex := range request.underlays {
|
for groupIndex := range request.underlays {
|
||||||
for inputIndex := range request.underlays[groupIndex].inputs {
|
for inputIndex := range request.underlays[groupIndex].inputs {
|
||||||
input := &request.underlays[groupIndex].inputs[inputIndex]
|
input := &request.underlays[groupIndex].inputs[inputIndex]
|
||||||
|
if input.mainRelative != nil && input.filesystem == nil {
|
||||||
|
if input.source.kind == sourceFile || input.source.kind == sourceFilesystem || input.source.kind == sourceSearch {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "file and search underlays cannot use main-relative context"))
|
||||||
|
} else if input.mainRelative.Fallback != nil {
|
||||||
|
diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, input.mainRelative.Fallback)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
normalized, inputDiagnostics := normalizeInput(*input, "")
|
normalized, inputDiagnostics := normalizeInput(*input, "")
|
||||||
*input = normalized
|
*input = normalized
|
||||||
diagnostics = append(diagnostics, inputDiagnostics...)
|
diagnostics = append(diagnostics, inputDiagnostics...)
|
||||||
@@ -71,10 +83,10 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec
|
|||||||
}
|
}
|
||||||
diagnostics = append(diagnostics, overlayOptionDiagnostics(request.overlay)...)
|
diagnostics = append(diagnostics, overlayOptionDiagnostics(request.overlay)...)
|
||||||
if activeOverlay(request.overlay) {
|
if activeOverlay(request.overlay) {
|
||||||
if request.overlayFilesystem == nil {
|
if request.overlayFilesystem != nil {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
|
||||||
} else {
|
|
||||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, request.overlayFilesystem)...)
|
diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, request.overlayFilesystem)...)
|
||||||
|
} else if request.overlayFallback != nil {
|
||||||
|
diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, request.overlayFallback)...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -90,7 +102,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
|
|||||||
projectVersion := sequenceVersionPath(request.protectionVersion())
|
projectVersion := sequenceVersionPath(request.protectionVersion())
|
||||||
for index, group := range request.underlays {
|
for index, group := range request.underlays {
|
||||||
evidenceStart := len(input.versionState.evidenceSince(0))
|
evidenceStart := len(input.versionState.evidenceSince(0))
|
||||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), state, policy)
|
acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), state, policy, input.resolvedFilesystem)
|
||||||
mergeResourceContexts(&input.contexts, acquired.contexts)
|
mergeResourceContexts(&input.contexts, acquired.contexts)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
input.sources = append(input.sources, acquired.sources...)
|
input.sources = append(input.sources, acquired.sources...)
|
||||||
@@ -178,10 +190,20 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
|
|||||||
assembly.current.inputBytes = state.accounting.bytes
|
assembly.current.inputBytes = state.accounting.bytes
|
||||||
assembly.current.inputNodes = state.accounting.nodes
|
assembly.current.inputNodes = state.accounting.nodes
|
||||||
if activeOverlay(request.overlay) {
|
if activeOverlay(request.overlay) {
|
||||||
if request.overlayFilesystem == nil {
|
var resolved resolvedFilesystemContext
|
||||||
return input, append(versionDiagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
var diagnostic *Diagnostic
|
||||||
|
useBase := false
|
||||||
|
if request.overlayFilesystem != nil {
|
||||||
|
resolved, diagnostic = resolveAbsentFilesystemContext(request.overlayFilesystem)
|
||||||
|
} else if input.resolvedFilesystem != nil {
|
||||||
|
resolved = *input.resolvedFilesystem
|
||||||
|
useBase = true
|
||||||
|
} else if request.overlayFallback != nil {
|
||||||
|
resolved, diagnostic = resolveAbsentFilesystemContext(request.overlayFallback)
|
||||||
|
useBase = true
|
||||||
|
} else {
|
||||||
|
return input, append(versionDiagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires a filesystem context"))
|
||||||
}
|
}
|
||||||
resolved, diagnostic := resolveAbsentFilesystemContext(request.overlayFilesystem)
|
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return input, append(versionDiagnostics, *diagnostic)
|
return input, append(versionDiagnostics, *diagnostic)
|
||||||
}
|
}
|
||||||
@@ -192,6 +214,9 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
|
|||||||
name: request.overlay, protectionRule: request.protectionVersion(),
|
name: request.overlay, protectionRule: request.protectionVersion(),
|
||||||
sequencePolicy: policy.sequencePolicy, limits: defaultImportLimits(), versionState: input.versionState,
|
sequencePolicy: policy.sequencePolicy, limits: defaultImportLimits(), versionState: input.versionState,
|
||||||
}
|
}
|
||||||
|
if useBase {
|
||||||
|
overlayRequest.lookupDirectory = resolved.basePath
|
||||||
|
}
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
access, diagnostic := openSelectedOverlayAccess(resolved)
|
access, diagnostic := openSelectedOverlayAccess(resolved)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
@@ -248,12 +273,31 @@ func applyCompletedTier(
|
|||||||
return operations, nil
|
return operations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, state *acquisitionState, policy acquisitionPolicy) (*acquiredUnderlayGroup, []Diagnostic) {
|
func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, state *acquisitionState, policy acquisitionPolicy, mainContext *resolvedFilesystemContext) (*acquiredUnderlayGroup, []Diagnostic) {
|
||||||
initialBytes, initialNodes := state.accounting.bytes, state.accounting.nodes
|
initialBytes, initialNodes := state.accounting.bytes, state.accounting.nodes
|
||||||
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}}
|
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}}
|
||||||
|
var derived *resolvedFilesystemContext
|
||||||
|
bind := func(input loadInput) (loadInput, *resolvedFilesystemContext) {
|
||||||
|
if input.filesystem != nil || input.mainRelative == nil {
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
if mainContext == nil {
|
||||||
|
input.filesystem = input.mainRelative.Fallback
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
if derived == nil {
|
||||||
|
context := *mainContext
|
||||||
|
context.mainPath = ""
|
||||||
|
context.hostToken = tier.hostToken
|
||||||
|
derived = &context
|
||||||
|
}
|
||||||
|
return input, derived
|
||||||
|
}
|
||||||
nativeAnchors := make(map[*namespaceToken]string)
|
nativeAnchors := make(map[*namespaceToken]string)
|
||||||
for inputIndex, contribution := range group.inputs {
|
for inputIndex, contribution := range group.inputs {
|
||||||
if token := nativeTokenForLoadInput(contribution); token != nil {
|
contribution, resolved := bind(contribution)
|
||||||
|
token := nativeTokenForLoadInput(contribution, resolved)
|
||||||
|
if token != nil {
|
||||||
if _, exists := nativeAnchors[token]; !exists {
|
if _, exists := nativeAnchors[token]; !exists {
|
||||||
nativeAnchors[token] = explicitNamespaceAnchor(index, inputIndex+1)
|
nativeAnchors[token] = explicitNamespaceAnchor(index, inputIndex+1)
|
||||||
}
|
}
|
||||||
@@ -262,8 +306,11 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s
|
|||||||
acquiredGroup := &acquiredUnderlayGroup{index: index}
|
acquiredGroup := &acquiredUnderlayGroup{index: index}
|
||||||
inclusionGraph := newImportInclusionGraph()
|
inclusionGraph := newImportInclusionGraph()
|
||||||
for inputIndex, contribution := range group.inputs {
|
for inputIndex, contribution := range group.inputs {
|
||||||
|
contribution, resolved := bind(contribution)
|
||||||
acquired, diagnostics := acquireInput(inputAcquisitionRequest{
|
acquired, diagnostics := acquireInput(inputAcquisitionRequest{
|
||||||
input: contribution, role: sourceVersionContribution,
|
input: contribution, role: sourceVersionContribution,
|
||||||
|
resolved: resolved,
|
||||||
|
withoutBase: contribution.mainRelative != nil && contribution.filesystem == nil && resolved == nil,
|
||||||
directiveContext: rootDirectiveContextLayered, protectionRule: rule,
|
directiveContext: rootDirectiveContextLayered, protectionRule: rule,
|
||||||
}, policy, state, tier)
|
}, policy, state, tier)
|
||||||
for _, source := range acquired.sources {
|
for _, source := range acquired.sources {
|
||||||
@@ -282,7 +329,8 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s
|
|||||||
kind: "host", host: explicitNamespaceAnchor(index, 0),
|
kind: "host", host: explicitNamespaceAnchor(index, 0),
|
||||||
input: explicitNamespaceAnchor(index, inputIndex+1),
|
input: explicitNamespaceAnchor(index, inputIndex+1),
|
||||||
}
|
}
|
||||||
if token := nativeTokenForLoadInput(contribution); token != nil {
|
token := nativeTokenForLoadInput(contribution, resolved)
|
||||||
|
if token != nil {
|
||||||
anchors.kind = "native"
|
anchors.kind = "native"
|
||||||
anchors.native = nativeAnchors[token]
|
anchors.native = nativeAnchors[token]
|
||||||
}
|
}
|
||||||
@@ -365,7 +413,10 @@ func composeUnderlayGroup(acquired *acquiredUnderlayGroup, rule *VersionRule, po
|
|||||||
return composition, nil
|
return composition, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func nativeTokenForLoadInput(input loadInput) *namespaceToken {
|
func nativeTokenForLoadInput(input loadInput, resolved *resolvedFilesystemContext) *namespaceToken {
|
||||||
|
if resolved != nil && resolved.native != nil {
|
||||||
|
return resolved.native.token
|
||||||
|
}
|
||||||
if input.source.kind == sourceFilesystem && input.source.namespace != nil {
|
if input.source.kind == sourceFilesystem && input.source.namespace != nil {
|
||||||
return input.source.namespace.token
|
return input.source.namespace.token
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
|
|||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
group := loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}
|
group := loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}
|
||||||
state := &acquisitionState{accounting: accounting}
|
state := &acquisitionState{accounting: accounting}
|
||||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, 2, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
acquired, diagnostics := acquireUnderlayGroupScopes(group, 2, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("acquire underlay group: %#v", diagnostics)
|
t.Fatalf("acquire underlay group: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
|
|||||||
t.Fatalf("composed group handoff = %#v", composition)
|
t.Fatalf("composed group handoff = %#v", composition)
|
||||||
}
|
}
|
||||||
|
|
||||||
empty, diagnostics := acquireUnderlayGroupScopes(loadGroup{}, 3, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
empty, diagnostics := acquireUnderlayGroupScopes(loadGroup{}, 3, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||||
if len(diagnostics) != 0 || len(empty.scopes) != 0 {
|
if len(diagnostics) != 0 || len(empty.scopes) != 0 {
|
||||||
t.Fatalf("empty group acquisition = %#v, %#v", empty, diagnostics)
|
t.Fatalf("empty group acquisition = %#v, %#v", empty, diagnostics)
|
||||||
}
|
}
|
||||||
@@ -471,7 +471,7 @@ func TestGroupedLoadStrictConflictsRepetitionAndBudgets(t *testing.T) {
|
|||||||
|
|
||||||
func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*acquiredUnderlayGroup, []Diagnostic) {
|
func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*acquiredUnderlayGroup, []Diagnostic) {
|
||||||
state := &acquisitionState{accounting: accounting}
|
state := &acquisitionState{accounting: accounting}
|
||||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||||
return acquired, diagnostics
|
return acquired, diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
internal/engine/main_relative_integration_test.go
Normal file
44
internal/engine/main_relative_integration_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainRelativeAcquisitionClosesHandlesOnFailure(t *testing.T) {
|
||||||
|
for _, fail := range []string{"parse", "read"} {
|
||||||
|
t.Run(fail, func(t *testing.T) {
|
||||||
|
body := "items: [invalid\n"
|
||||||
|
if fail == "read" {
|
||||||
|
body = "items: {key: value}\n"
|
||||||
|
}
|
||||||
|
filesystem := &observedFilesystem{files: fstest.MapFS{"dir/main.yml": {Data: []byte("{}\n")}, "dir/child.yml": {Data: []byte(body)}}}
|
||||||
|
if fail == "read" {
|
||||||
|
filesystem.failRead = "dir/child.yml"
|
||||||
|
}
|
||||||
|
namespace := newNativeNamespace("bundle", filesystem)
|
||||||
|
main := Input{Source: filesystemFile(namespace, "dir/main.yml")}
|
||||||
|
underlay := Input{Source: Bytes("declarations", []byte("config: {imports: [{path: child.yml}]}\n")), Imports: true, MainRelative: &MainRelativeContext{}}
|
||||||
|
_, err := LoadLayered[importTestConfig](main, SecretBinding{}, LayeredOptions[importTestConfig]{Underlays: []Underlay{{Inputs: []Input{underlay}}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("invalid selected child succeeded")
|
||||||
|
}
|
||||||
|
filesystem.assertClosed(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeSharesAcquisitionBudget(t *testing.T) {
|
||||||
|
limits := defaultImportLimits()
|
||||||
|
limits.sources = 1
|
||||||
|
accounting := &importAccounting{}
|
||||||
|
state := &acquisitionState{accounting: accounting}
|
||||||
|
group := loadGroup{inputs: []loadInput{
|
||||||
|
{source: Bytes("one", []byte("items: {one: value}\n")), mainRelative: &MainRelativeContext{}},
|
||||||
|
{source: Bytes("two", []byte("items: {two: value}\n")), imports: true, mainRelative: &MainRelativeContext{}},
|
||||||
|
}}
|
||||||
|
_, diagnostics := acquireUnderlayGroupScopes(group, 1, nil, state, acquisitionPolicy{importLimits: limits}, nil)
|
||||||
|
if len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagn
|
|||||||
if input.source.kind == sourceNone && activeOverlay(ordinaryOverlay) && input.filesystem == nil {
|
if input.source.kind == sourceNone && activeOverlay(ordinaryOverlay) && input.filesystem == nil {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
|
||||||
}
|
}
|
||||||
if input.filesystem == nil && (input.source.kind == sourceBytes || input.source.kind == sourceReader || input.source.kind == sourceMapping) && (input.imports || activeOverlay(ordinaryOverlay)) {
|
if input.filesystem == nil && input.mainRelative == nil && (input.source.kind == sourceBytes || input.source.kind == sourceReader || input.source.kind == sourceMapping) && (input.imports || activeOverlay(ordinaryOverlay)) {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
||||||
}
|
}
|
||||||
return input, canonicalDiagnostics(diagnostics)
|
return input, canonicalDiagnostics(diagnostics)
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ func validOverlayName(name string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type selectedOverlayRequest struct {
|
type selectedOverlayRequest struct {
|
||||||
name string
|
name string
|
||||||
protectionRule *VersionRule
|
lookupDirectory string
|
||||||
sequencePolicy sequencePolicy
|
protectionRule *VersionRule
|
||||||
limits importLimits
|
sequencePolicy sequencePolicy
|
||||||
versionState *sourceVersionState
|
limits importLimits
|
||||||
|
versionState *sourceVersionState
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveOrdinaryOverlayFilesystem(source Source, context *FilesystemContext, main parsedSource, existing *resolvedFilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
|
func resolveOrdinaryOverlayFilesystem(source Source, context *FilesystemContext, main parsedSource, existing *resolvedFilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
|
||||||
@@ -70,13 +71,26 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces
|
|||||||
_ = access.close()
|
_ = access.close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
overlayPath := access.join(access.rootPath, "overlays/"+request.name+".yml")
|
lookupDirectory := request.lookupDirectory
|
||||||
canonical, err := access.canonical(overlayPath)
|
if lookupDirectory == "" {
|
||||||
|
lookupDirectory = access.rootPath
|
||||||
|
}
|
||||||
|
overlayPath := access.join(lookupDirectory, "overlays/"+request.name+".yml")
|
||||||
|
links, _ := access.native.(fs.ReadLinkFS)
|
||||||
|
var canonical string
|
||||||
|
var err error
|
||||||
|
if links != nil {
|
||||||
|
canonical, err = resolveNativeImportPath(access, links, overlayPath)
|
||||||
|
} else {
|
||||||
|
canonical, err = access.canonical(overlayPath)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
code := CodeOverlayInvalid
|
code := CodeOverlayInvalid
|
||||||
message := "selected overlay 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 = CodeOverlayMissing, "selected overlay does not exist"
|
code, message = CodeOverlayMissing, "selected overlay does not exist"
|
||||||
|
} else if errors.Is(err, errImportPathOutsideRoot) {
|
||||||
|
code, message = CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root"
|
||||||
} else if errors.Is(err, fs.ErrPermission) {
|
} else if errors.Is(err, fs.ErrPermission) {
|
||||||
code, message = CodeImportPermission, "selected overlay cannot be accessed"
|
code, message = CodeImportPermission, "selected overlay cannot be accessed"
|
||||||
}
|
}
|
||||||
@@ -85,12 +99,8 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces
|
|||||||
if !access.contains(canonical) {
|
if !access.contains(canonical) {
|
||||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)}
|
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)}
|
||||||
}
|
}
|
||||||
for _, existing := range sources {
|
if overlayRepeatsSource(access, links, canonical, sources) {
|
||||||
if existing.Path != "" && existing.Path == canonical &&
|
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayRepeated, "selected overlay is already present in the base source graph", nil)}
|
||||||
((access.native == nil && existing.Kind == SourceKindFile) ||
|
|
||||||
(access.native != nil && existing.Kind == SourceKindFilesystem && existing.namespace == access.token)) {
|
|
||||||
return 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 cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the source-count limit", nil)}
|
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the source-count limit", nil)}
|
||||||
@@ -169,6 +179,37 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces
|
|||||||
return sources, nil
|
return sources, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func overlayRepeatsSource(access *filesystemAccess, links fs.ReadLinkFS, canonical string, sources []SourceInfo) bool {
|
||||||
|
// Other inputs can have a wider root. Compare their identities in the
|
||||||
|
// shared native namespace while keeping overlay lookup rooted separately.
|
||||||
|
namespaceAccess := *access
|
||||||
|
namespaceAccess.rootPath = "."
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if access.native == nil {
|
||||||
|
if source.Kind == SourceKindFile && source.Path == canonical {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if source.Kind != SourceKindFilesystem || source.namespace != access.token {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if source.Path == canonical {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if links != nil {
|
||||||
|
target, err := resolveNativeImportPath(&namespaceAccess, links, source.Path)
|
||||||
|
if err == nil && target == canonical {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func readOverlayFile(access *filesystemAccess, name, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
func readOverlayFile(access *filesystemAccess, name, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
||||||
limit := min(int64(maxSourceBytes), remaining)
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
|
|||||||
26
layered.go
26
layered.go
@@ -19,15 +19,29 @@ type Input struct {
|
|||||||
PlacementMode PlacementMode
|
PlacementMode PlacementMode
|
||||||
Imports bool
|
Imports bool
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
|
// MainRelative binds a non-file underlay to the main input's context when
|
||||||
|
// Filesystem is nil. It does not enable imports.
|
||||||
|
MainRelative *MainRelativeContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MainRelativeContext selects the main input's resolved context for an underlay.
|
||||||
|
// Fallback is used when the main has no usable context; RequireBase requires one.
|
||||||
|
type MainRelativeContext struct {
|
||||||
|
Fallback *FilesystemContext
|
||||||
|
RequireBase bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MainRelative returns a fresh default main-relative binding description.
|
||||||
|
func MainRelative() *MainRelativeContext { return &MainRelativeContext{} }
|
||||||
|
|
||||||
// Underlay contains inputs that must compose strictly within one tier. Tiers
|
// Underlay contains inputs that must compose strictly within one tier. Tiers
|
||||||
// appear in lowest-to-highest precedence order; an empty tier is a no-op.
|
// appear in lowest-to-highest precedence order; an empty tier is a no-op.
|
||||||
type Underlay struct{ Inputs []Input }
|
type Underlay struct{ Inputs []Input }
|
||||||
|
|
||||||
// LayeredOptions selects lower-priority tiers and final application rules.
|
// LayeredOptions selects lower-priority tiers and final application rules.
|
||||||
// An active Overlay requires an explicit OverlayFilesystem; an inactive overlay
|
// An active Overlay uses OverlayFilesystem, then the main input's resolved
|
||||||
// ignores it. Overrides remain explicit CLI records, not general mappings.
|
// context, then OverlayFallback. An inactive overlay ignores both contexts.
|
||||||
|
// Overrides remain explicit CLI records, not general mappings.
|
||||||
// SourceVersionMode applies one declaration policy to main, underlay, import,
|
// SourceVersionMode applies one declaration policy to main, underlay, import,
|
||||||
// and selected overlay documents. Enabled modes interpret the version path
|
// and selected overlay documents. Enabled modes interpret the version path
|
||||||
// source-locally, consume accepted non-main declarations, and protect main's
|
// source-locally, consume accepted non-main declarations, and protect main's
|
||||||
@@ -42,7 +56,9 @@ type LayeredOptions[T any] struct {
|
|||||||
Underlays []Underlay
|
Underlays []Underlay
|
||||||
Overlay string
|
Overlay string
|
||||||
OverlayFilesystem *FilesystemContext
|
OverlayFilesystem *FilesystemContext
|
||||||
Overrides []Override
|
// OverlayFallback supplies a context if the main has no usable context.
|
||||||
|
OverlayFallback *FilesystemContext
|
||||||
|
Overrides []Override
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadLayered acquires the main input first and strictly composes enabled
|
// LoadLayered acquires the main input first and strictly composes enabled
|
||||||
@@ -68,6 +84,9 @@ func toEngineInput(input Input) engine.Input {
|
|||||||
Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
|
Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
|
||||||
PlacementMode: engine.PlacementMode(input.PlacementMode),
|
PlacementMode: engine.PlacementMode(input.PlacementMode),
|
||||||
}
|
}
|
||||||
|
if input.MainRelative != nil {
|
||||||
|
result.MainRelative = &engine.MainRelativeContext{Fallback: toEngineFilesystemContext(input.MainRelative.Fallback), RequireBase: input.MainRelative.RequireBase}
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +96,7 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti
|
|||||||
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||||
Overlay: options.Overlay,
|
Overlay: options.Overlay,
|
||||||
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
||||||
|
OverlayFallback: toEngineFilesystemContext(options.OverlayFallback),
|
||||||
ImportDefaults: toEngineImportDefaults(options.ImportDefaults),
|
ImportDefaults: toEngineImportDefaults(options.ImportDefaults),
|
||||||
}
|
}
|
||||||
if options.Underlays != nil {
|
if options.Underlays != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user