Skip to content

Commit 13cefaa

Browse files
committed
Add specs for artifact composition
1 parent f84bbd2 commit 13cefaa

File tree

3 files changed

+193
-0
lines changed

3 files changed

+193
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Specification Quality Checklist: Artifact Content Composition via Jinja2 Filters
2+
3+
**Purpose**: Validate specification completeness and quality before proceeding to planning
4+
**Created**: 2026-02-18
5+
**Feature**: [spec.md](../spec.md)
6+
7+
## Content Quality
8+
9+
- [x] No implementation details (languages, frameworks, APIs)
10+
- [x] Focused on user value and business needs
11+
- [x] Written for non-technical stakeholders
12+
- [x] All mandatory sections completed
13+
14+
## Requirement Completeness
15+
16+
- [x] No [NEEDS CLARIFICATION] markers remain
17+
- [x] Requirements are testable and unambiguous
18+
- [x] Success criteria are measurable
19+
- [x] Success criteria are technology-agnostic (no implementation details)
20+
- [x] All acceptance scenarios are defined
21+
- [x] Edge cases are identified
22+
- [x] Scope is clearly bounded
23+
- [x] Dependencies and assumptions identified
24+
25+
## Feature Readiness
26+
27+
- [x] All functional requirements have clear acceptance criteria
28+
- [x] User scenarios cover primary flows
29+
- [x] Feature meets measurable outcomes defined in Success Criteria
30+
- [x] No implementation details leak into specification
31+
32+
## Notes
33+
34+
- One open question remains intentionally: whether to add a Python transform convenience SDK method (FR scope question flagged in Open Questions section, documented for planning phase).
35+
- Ordering guarantee is explicitly out of scope and documented as a known limitation.
36+
- `from_json`/`from_yaml` existence in the current filter set is flagged as an assumption to verify during planning.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Feature specification: Artifact content composition via Jinja2 filters
2+
3+
**Feature Branch**: `001-artifact-composition`
4+
**Created**: 2026-02-18
5+
**Status**: Draft
6+
**Jira**: INFP-504 (part of INFP-304 Artifact of Artifacts initiative)
7+
8+
## Overview
9+
10+
Enable customers building modular configuration pipelines to compose larger artifacts from smaller sub-artifacts by referencing and inlining rendered artifact content directly inside a Jinja2 transform, without duplicating template logic or GraphQL query fields.
11+
12+
## Clarifications
13+
14+
### Session 2026-02-18
15+
16+
- Q: Are `artifact_content` and `file_object_content` identical at the storage API level, or do they use different API paths / metadata handling? → A: Different implementations — `file_object_content` uses a different API path or carries different metadata handling than `artifact_content`.
17+
- Q: Where are these filters permitted to execute, and what mechanism enforces the boundary? → A: Blocked in computed attributes (executed locally in the Infrahub API server, which uses `validate(restricted=True)`); permitted on Prefect workers, which have access to an `InfrahubClient`. The `trusted=False` registration enforces this boundary via the existing restricted-mode validation.
18+
- Q: What exception class should filter-level errors (no client, retrieval failure) raise? → A: A new `JinjaFilterError` class that is a child of the existing `JinjaTemplateError` base class.
19+
- Q: Should the SDK expose a convenience method for artifact content retrieval in Python transforms? → A: No new method — document `client.object_store.get(identifier=storage_id)` directly.
20+
- Q: What should `from_json`/`from_yaml` do on malformed input? → A: Deferred — behavior on malformed content to be decided during planning/implementation.
21+
22+
## User scenarios & testing *(mandatory)*
23+
24+
### User story 1 - inline artifact content in a composite template (Priority: P1)
25+
26+
A network engineer maintains separate section-level artifacts for routing policy, interfaces, and base config. They want a composite "startup config" artifact whose Jinja2 template pulls in each section's rendered content via a `storage_id` already present in the GraphQL query result — without copy-pasting template logic.
27+
28+
The template uses `artifact.node.storage_id.value | artifact_content` and the rendered output assembles all sections automatically.
29+
30+
**Why this priority**: This is the primary use case that delivers the modular pipeline capability. Everything else in this feature supports or extends it.
31+
32+
**Independent Test**: A Jinja2 template calling `artifact_content` with a valid storage_id can be rendered against a real or mocked Infrahub instance and the output matches the expected concatenated artifact contents.
33+
34+
**Acceptance Scenarios**:
35+
36+
1. **Given** a `Jinja2Template` constructed with a valid `InfrahubClient` and a template calling `storage_id | artifact_content`, **When** the template is rendered with a data dict containing a valid storage_id string, **Then** the output contains the raw string content fetched from the object store.
37+
2. **Given** the same setup but the storage_id is null or the object store cannot retrieve the content, **When** rendered, **Then** the filter raises a descriptive error indicating the retrieval failure.
38+
3. **Given** a `Jinja2Template` constructed *without* an `InfrahubClient` and a template calling `artifact_content`, **When** rendered, **Then** an error is raised with a message clearly stating that an `InfrahubClient` is required for this filter.
39+
4. **Given** a template using `artifact_content` and `validate(restricted=True)` is called, **Then** a `JinjaTemplateOperationViolationError` is raised, confirming the filter is blocked in local restricted mode.
40+
41+
---
42+
43+
### User story 2 - inline file object content in a composite template (Priority: P2)
44+
45+
A template author needs to embed the content of a stored file object (as distinct from an artifact) into a Jinja2 template. They use `storage_id | file_object_content` and the same injection and error-handling behaviour applies.
46+
47+
**Why this priority**: Mirrors `artifact_content` for the file-object use case; same implementation pattern, lower novelty.
48+
49+
**Independent Test**: A template calling `file_object_content` renders correctly with a valid storage_id, and raises a descriptive error for null or unresolvable storage_ids.
50+
51+
**Acceptance Scenarios**:
52+
53+
1. **Given** a `Jinja2Template` with a client and a valid file-object storage_id, **When** rendered, **Then** the raw file content string is returned.
54+
2. **Given** a null or missing storage_id value, **When** the filter is invoked, **Then** an error is raised with a descriptive message about the retrieval failure.
55+
3. **Given** no client provided to `Jinja2Template`, **When** the filter is invoked, **Then** an error is raised.
56+
57+
---
58+
59+
### User story 3 - parse structured artifact content in a template (Priority: P3)
60+
61+
A template author retrieves a JSON-formatted artifact and needs to traverse its structure as a dict within the template. They chain `storage_id | artifact_content | from_json` to obtain a parsed object, then access fields normally.
62+
63+
**Why this priority**: Unlocks structured composition use cases; depends on `artifact_content` (P1) being in place. `from_json`/`from_yaml` are useful in isolation too.
64+
65+
**Independent Test**: A template chaining `artifact_content | from_json` renders correctly and the output reflects values from parsed JSON fields.
66+
67+
**Acceptance Scenarios**:
68+
69+
1. **Given** a template using `storage_id | artifact_content | from_json`, **When** rendered with a storage_id pointing to valid JSON content, **Then** the template can access keys of the parsed object.
70+
2. **Given** `storage_id | artifact_content | from_yaml`, **When** rendered with YAML content, **Then** the template can access keys of the parsed mapping.
71+
3. **Given** `from_json` or `from_yaml` applied to an empty string (e.g. a template variable that is explicitly empty), **When** rendered, **Then** the filter returns an empty dict or appropriate empty value without raising.
72+
73+
---
74+
75+
### User story 4 - security gate blocks filters in computed attributes context (Priority: P1)
76+
77+
The Infrahub API server executes computed attributes locally using `validate(restricted=True)`. The new `artifact_content` and `file_object_content` filters require an SDK client (only available on Prefect workers) and MUST be blocked in the computed attributes context. They are permitted to run only on Prefect workers, where an `InfrahubClient` is available.
78+
79+
**Why this priority**: Preventing these filters from running in the computed attributes context is a hard requirement. Executing them there would fail silently or cause runtime errors because no SDK client is available. Shares P1 priority with User Story 1.
80+
81+
**Independent Test**: `Jinja2Template.validate(restricted=True)` raises `JinjaTemplateOperationViolationError` for any template using the new filters, confirming they are blocked in the computed attributes execution path.
82+
83+
**Acceptance Scenarios**:
84+
85+
1. **Given** a template referencing `artifact_content`, **When** `validate(restricted=True)` is called (as in the computed attributes path), **Then** `JinjaTemplateOperationViolationError` is raised.
86+
2. **Given** the same template on a Prefect worker context, **When** `validate(restricted=False)` is called with a client-initialised `Jinja2Template`, **Then** validation passes and the template may be rendered.
87+
88+
---
89+
90+
### Edge cases
91+
92+
- What happens if a storage_id value is `None` (Python None) rather than a missing string? Both cases must raise a descriptive error.
93+
- What if the object store raises a network or authentication error mid-render? All error conditions (null storage_id, not-found, auth failure, network failure) raise exceptions — there is no silent fallback.
94+
- What if `from_json` or `from_yaml` already exists in the netutils filter set? De-duplicate rather than shadow.
95+
- What if the same filter name is registered twice (e.g., user-supplied filter shadows `artifact_content`)? Existing override behaviour should be preserved.
96+
- File-based templates use a regular `Environment` (not sandboxed); the new filters must be injected correctly in both cases.
97+
98+
## Requirements *(mandatory)*
99+
100+
### Functional requirements
101+
102+
- **FR-001**: `Jinja2Template.__init__` MUST accept an optional `client` parameter of type `InfrahubClient | InfrahubClientSync | None` (default `None`).
103+
- **FR-002**: A dedicated class (e.g., `InfrahubFilters`) MUST be introduced to hold the client reference and expose the Infrahub-specific filter callable methods. `Jinja2Template` instantiates this class when a client is provided and registers its filters into the Jinja2 environment.
104+
- **FR-003**: The system MUST provide an `artifact_content` Jinja2 filter that accepts a `storage_id` string and returns the raw string content of the referenced artifact, using the artifact-specific API path.
105+
- **FR-004**: The system MUST provide a `file_object_content` Jinja2 filter that accepts a `storage_id` string and returns the raw string content of the referenced file object, using the file-object-specific API path or metadata handling — this implementation is distinct from `artifact_content`.
106+
- **FR-005**: Both `artifact_content` and `file_object_content` MUST raise `JinjaFilterError` when the input `storage_id` is null or empty, or when the object store cannot retrieve the content for any reason (not found, network failure, auth failure).
107+
- **FR-006**: Both `artifact_content` and `file_object_content` MUST raise `JinjaFilterError` when invoked and no `InfrahubClient` was supplied to `Jinja2Template` at construction time. The error message MUST name the filter and explain that an `InfrahubClient` is required.
108+
- **FR-007**: Both `artifact_content` and `file_object_content` MUST be registered with `trusted=False` in the `FilterDefinition` registry so that `validate(restricted=True)` blocks them in the computed attributes execution context (Infrahub API server). They are only permitted to execute on Prefect workers, where an `InfrahubClient` is available.
109+
- **FR-008**: The system MUST provide `from_json` and `from_yaml` Jinja2 filters (adding them only if not already present in the environment) that parse a string into a Python dict/list. Applying them to an empty string MUST return an empty dict without raising.
110+
- **FR-009**: `from_json` and `from_yaml` MUST be registered as trusted filters (`trusted=True`) since they perform no external I/O.
111+
- **FR-010**: All new filters MUST work correctly in both async (`InfrahubClient`) and sync (`InfrahubClientSync`) execution contexts.
112+
- **FR-011**: All `JinjaFilterError` instances MUST carry an actionable error message that identifies the filter name, the cause of failure, and any remediation hint (e.g., "artifact_content requires an InfrahubClient — pass one via Jinja2Template(client=...)").
113+
- **FR-012**: A new `JinjaFilterError` exception class MUST be added to `infrahub_sdk/template/exceptions.py` as a subclass of `JinjaTemplateError`.
114+
- **FR-013**: Documentation MUST include a Python transform example demonstrating artifact content retrieval via `client.object_store.get(identifier=storage_id)`. No new SDK convenience method will be added.
115+
116+
### Key entities
117+
118+
- **`Jinja2Template`**: Gains an optional `client` constructor parameter; delegates client-bound filter registration to `InfrahubFilters`.
119+
- **`InfrahubFilters`**: New class that holds an `InfrahubClient` reference and exposes `artifact_content`, `file_object_content`, and any other client-dependent filter methods. Registered into the Jinja2 filter map when a client is provided.
120+
- **`FilterDefinition`**: Existing dataclass used to declare filter `name`, `trusted` flag, and `source`. New entries are added here for all new filters.
121+
- **`ObjectStore` / `ObjectStoreSync`**: Existing async/sync storage clients used by `InfrahubFilters` to perform `get(identifier=storage_id)` calls.
122+
- **`JinjaFilterError`**: New exception class, subclass of `JinjaTemplateError`, raised by `InfrahubFilters` methods on all filter-level failures (no client, null/empty storage_id, retrieval error).
123+
124+
## Success criteria *(mandatory)*
125+
126+
### Measurable outcomes
127+
128+
- **SC-001**: A composite Jinja2 artifact template using `artifact_content` renders successfully end-to-end (integration test), with output containing all expected sub-artifact content.
129+
- **SC-002**: `validate(restricted=True)` on any template referencing `artifact_content` or `file_object_content` always raises a security violation — zero false negatives across the test suite.
130+
- **SC-003**: All filter error conditions (no client, null/empty storage_id, retrieval failure) produce a descriptive, actionable error message — no silent failures, no raw tracebacks as the primary user-facing message.
131+
- **SC-004**: Both async and sync execution paths are covered by unit tests with no regressions to existing filter behaviour.
132+
- **SC-005**: The full unit test suite (`uv run pytest tests/unit/`) passes without modification after the feature is added.
133+
- **SC-006**: A template chaining `artifact_content | from_json` or `artifact_content | from_yaml` can access parsed fields from a structured artifact in a rendered output.
134+
135+
## Assumptions
136+
137+
- The `artifact_content` and `file_object_content` filters receive a `storage_id` string directly from the template variable context (i.e., extracted from the GraphQL query result by the template author). The filter does not resolve artifact names — it operates on storage IDs only.
138+
- Ordering of artifact generation is a known limitation: artifacts may be generated in parallel. This is a documented constraint, not something this feature enforces. Future event-driven pipeline work (INFP-227) will address ordering.
139+
- `from_json` and `from_yaml` are not currently present in the builtin or netutils filter sets; they will be added as part of this feature. If they already exist, the implementation de-duplicates rather than overrides.
140+
- All failure modes from the filters (null storage_id, empty storage_id, object not found, network error, auth error) raise exceptions. There is no silent fallback to an empty string.
141+
- The permitted execution context for `artifact_content` and `file_object_content` is Prefect workers only. The computed attributes path in the Infrahub API server always runs `validate(restricted=True)`, which blocks these filters before rendering begins.
142+
- The `InfrahubFilters` class provides synchronous callables to Jinja2's filter map regardless of whether the underlying client is async or sync. Async I/O calls are handled consistently with the SDK's existing pattern for sync wrapping.
143+
144+
## Dependencies & constraints
145+
146+
- Depends on `ObjectStore.get(identifier)` / `ObjectStoreSync.get(identifier)` in `infrahub_sdk/object_store.py`.
147+
- Depends on the existing `FilterDefinition` dataclass and `trusted` flag mechanism in `infrahub_sdk/template/filters.py`.
148+
- Depends on the existing `validate(restricted=True)` security mechanism in `Jinja2Template`.
149+
- Must not break any existing filter behaviour or the `validate()` contract.
150+
- No new external Python dependencies may be introduced without approval.
151+
- Related: INFP-304 (Artifact of Artifacts), INFP-496 (Modular GraphQL queries), INFP-227 (Modular generators / event-driven pipeline).
152+
153+
## Open questions
154+
155+
- **Filter naming**: `artifact_content` is the working name. Alternatives are open.
156+
- **Sandboxed environment injection**: The `render_jinja2_template` method in `integrator.py` has access to `self.sdk`; the exact threading path to pass the client into `Jinja2Template` needs investigation during planning.

specs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dev/specs

0 commit comments

Comments
 (0)