|
| 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. |
0 commit comments