Feature Branch: infp-504-artifact-composition
Created: 2026-02-18
Status: Draft
Jira: INFP-504 (part of INFP-304 Artifact of Artifacts initiative)
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.
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.
The template uses artifact.node.storage_id.value | artifact_content and the rendered output assembles all sections automatically.
Why this priority: This is the primary use case that delivers the modular pipeline capability. Everything else in this feature supports or extends it.
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.
Acceptance Scenarios:
- Given a
Jinja2Templateconstructed with a validInfrahubClientand a template callingstorage_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. - 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.
- Given a
Jinja2Templateconstructed without anInfrahubClientand a template callingartifact_content, When rendered, Then an error is raised with a message clearly stating that anInfrahubClientis required for this filter. - Given a template using
artifact_contentandvalidate(restricted=True)is called, Then aJinjaTemplateOperationViolationErroris raised, confirming the filter is blocked in local restricted mode.
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.
Why this priority: Mirrors artifact_content for the file-object use case; same implementation pattern, lower novelty.
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.
Acceptance Scenarios:
- Given a
Jinja2Templatewith a client and a valid file-object storage_id, When rendered, Then the raw file content string is returned. - 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.
- Given no client provided to
Jinja2Template, When the filter is invoked, Then an error is raised.
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.
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.
Independent Test: A template chaining artifact_content | from_json renders correctly and the output reflects values from parsed JSON fields.
Acceptance Scenarios:
- 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. - Given
storage_id | artifact_content | from_yaml, When rendered with YAML content, Then the template can access keys of the parsed mapping. - Given
from_jsonorfrom_yamlapplied to an empty string (for example, a template variable that is explicitly empty), When rendered, Then the filter returns an empty dict or appropriate empty value without raising.
The Infrahub API server executes computed attributes locally and must block artifact_content and file_object_content because no network calls should be made within that context. Prefect workers run inside Infrahub with a client and must be able to use these filters. Other currently-untrusted Jinja2 filters (for example, safe, attr) must remain subject to their existing restriction rules — this feature must not inadvertently widen their permissions.
The existing single restricted: bool parameter on validate() is insufficient: flipping it to False to permit Infrahub filters would also permit all other untrusted filters. The validation mechanism must be extended to express at least three distinct execution contexts.
Why this priority: Preventing these filters from running in the computed attributes context is a hard requirement. Shares P1 priority with User Story 1.
Independent Test: Validation in the computed-attributes context raises JinjaTemplateOperationViolationError for templates using artifact_content or file_object_content. Validation in the Prefect-worker context passes for the same templates. Neither context changes the restriction behaviour of other currently-untrusted filters.
Acceptance Scenarios:
- Given a template referencing
artifact_content, When validated in the computed-attributes context, ThenJinjaTemplateOperationViolationErroris raised. - Given the same template, When validated in the Prefect-worker context with a client-initialised
Jinja2Template, Then validation passes. - Given a template using an existing untrusted filter (for example,
safe), When validated in the Prefect-worker context, ThenJinjaTemplateOperationViolationErroris still raised — the Prefect-worker context does not unlock other untrusted filters.
- What happens if a storage_id value is
None(Python None) rather than a missing string? Both cases must raise a descriptive error. - 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.
- What if
from_jsonorfrom_yamlalready exists in the netutils filter set? De-duplicate rather than shadow. - What happens when
from_jsonorfrom_yamlreceives malformed content (invalid JSON/YAML syntax)?JinjaFilterErroris raised — no silent fallback. - What if the same filter name is registered twice (for example, a user-supplied filter that shadows
artifact_content)? Existing override behaviour should be preserved. - File-based templates use a regular
Environment(not sandboxed); the new filters must be injected correctly in both cases.
- FR-001:
Jinja2Template.__init__MUST accept an optionalclientparameter of typeInfrahubClient | None(defaultNone). Additionally,Jinja2TemplateMUST expose aset_client(client)method for deferred client injection, allowing the template to be created first and the client added later.InfrahubClientSyncis not supported. - FR-002: A dedicated class (for example,
InfrahubFilters) MUST be introduced to hold the client reference and expose the Infrahub-specific filter callable methods.Jinja2Templateinstantiates this class when a client is provided (via__init__orset_client()) and registers its filters into the Jinja2 environment. - FR-003: The system MUST provide an
artifact_contentJinja2 filter that accepts astorage_idstring and returns the raw string content of the referenced artifact, using the artifact-specific API path. - FR-004: The system MUST provide a
file_object_contentJinja2 filter that accepts astorage_idstring 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 fromartifact_content. - FR-005: Both
artifact_contentandfile_object_contentMUST raiseJinjaFilterErrorwhen the inputstorage_idis null or empty, or when the object store cannot retrieve the content for any reason (not found, network failure, auth failure). Additionally,file_object_contentMUST raiseJinjaFilterErrorwhen the retrieved content has a non-text content type (i.e., nottext/*,application/json, orapplication/yaml). - FR-006: Both
artifact_contentandfile_object_contentMUST raiseJinjaFilterErrorwhen invoked and noInfrahubClientwas supplied toJinja2Templateat construction time. The error message MUST name the filter and explain that anInfrahubClientis required. - FR-007: Both
artifact_contentandfile_object_contentMUST be registered withallowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCALin theFilterDefinitionregistry. Thevalidate()method accepts anExecutionContextflag; these filters are blocked in theCOREcontext (API server computed attributes) and permitted in theWORKERcontext (Prefect workers) andLOCALcontext (CLI/unrestricted rendering). Within Infrahub, any Jinja2-based computed attributes that use these new filters should cause a schema violation when loading the schema. - FR-008: The system MUST provide
from_jsonandfrom_yamlJinja2 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. Applying them to malformed content MUST raiseJinjaFilterError. - FR-009:
from_jsonandfrom_yamlMUST be registered as trusted filters (trusted=True) since they perform no external I/O. - FR-010: All new filters MUST work correctly with
InfrahubClient(async).InfrahubClientSyncis not a supported client type forJinja2Template. Both the sandboxed environment (string-based templates) and the file-based environment MUST haveenable_async=Trueto support async filter callables via Jinja2'sauto_await. - FR-011: All
JinjaFilterErrorinstances MUST carry an actionable error message that identifies the filter name, the cause of failure, and any remediation hint (for example: "artifact_content requires an InfrahubClient — pass one via Jinja2Template(client=...)"). - FR-012: A new
JinjaFilterErrorexception class MUST be added toinfrahub_sdk/template/exceptions.pyas a subclass ofJinjaTemplateError. - 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. - FR-014: If the current user isn't allowed due to a permission denied error to query for the artifact or object file the filter should catch such permission error and raise a Jinja2 error specifically related to the permission issue.
Jinja2Template: Gains an optionalclientconstructor parameter; delegates client-bound filter registration toInfrahubFilters.InfrahubFilters: New class that holds anInfrahubClientreference and exposesartifact_content,file_object_content, and any other client-dependent filter methods. Registered into the Jinja2 filter map when a client is provided.FilterDefinition: Existing dataclass used to declare filtername,trustedflag, andsource. New entries are added here for all new filters.ObjectStore: Existing async storage client used byInfrahubFiltersto performget(identifier=storage_id)calls. (ObjectStoreSyncis not used;InfrahubClientSyncis explicitly out of scope — see FR-001, FR-010.)JinjaFilterError: New exception class, subclass ofJinjaTemplateError, raised byInfrahubFiltersmethods on all filter-level failures (no client, null/empty storage_id, retrieval error).
- SC-001: A composite Jinja2 artifact template using
artifact_contentrenders successfully end-to-end (integration test), with output containing all expected sub-artifact content. - SC-002:
validate(restricted=True)on any template referencingartifact_contentorfile_object_contentalways raises a security violation — zero false negatives across the test suite. - 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.
- SC-004: The async execution path (
InfrahubClient) is covered by unit tests with no regressions to existing filter behaviour. - SC-005: The full unit test suite (
uv run pytest tests/unit/) passes without modification after the feature is added. - SC-006: A template chaining
artifact_content | from_jsonorartifact_content | from_yamlcan access parsed fields from a structured artifact in a rendered output.
- The
artifact_contentandfile_object_contentfilters receive astorage_idstring directly from the template variable context — extracted from the GraphQL query result by the template author. The filter does not resolve artifact names — it operates on storage IDs only. - 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.
from_jsonandfrom_yamlare 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.- 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.
- The permitted execution context for
artifact_contentandfile_object_contentis Prefect workers only. The computed attributes path in the Infrahub API server always runsvalidate(restricted=True), which blocks these filters before rendering begins. - The
InfrahubFiltersclass providesasync defcallables to Jinja2's filter map; the underlying client is alwaysInfrahubClient(async). Jinja2'sauto_awaitmechanism (enabled viaenable_async=Trueon the environment) automatically awaits filter return values duringrender_async(), so no explicit sync-to-async bridging is needed.
- Depends on
ObjectStore.get(identifier)ininfrahub_sdk/object_store.py. - Depends on the existing
FilterDefinitiondataclass andtrustedflag mechanism ininfrahub_sdk/template/filters.py. - Depends on the existing
validate(restricted=True)security mechanism inJinja2Template. - Must not break any existing filter behaviour or the
validate()contract. - No new external Python dependencies may be introduced without approval.
- Related: INFP-304 (Artifact of Artifacts), INFP-496 (Modular GraphQL queries), INFP-227 (Modular generators / event-driven pipeline).
- Filter naming:
artifact_contentis the working name. Alternatives are open. Same withfile_object_contentas one option is to use the "/api/storage/files/by-storage-id" endpoint, we will want to support "by-hfid" and node as well. - Sandboxed environment injection: The
render_jinja2_templatemethod inintegrator.pyhas access toself.sdk; the exact threading path to pass the client intoJinja2Templateneeds investigation during planning. - Validation level model: The current
validate(restricted: bool)parameter is too coarse to express the three distinct execution contexts this feature requires. A natural evolution would be to replace the boolean with an enum (for example:corefor the Infrahub API server,workerfor Prefect background workers,untrustedfor fully restricted local execution). Filters tagged asworker-only would be blocked in thecorecontext but permitted in theworkercontext, whiletrustedfilters remain available in all contexts. The exact enum design and migration of existing call sites is a technical decision for the implementation plan, but the interface change should be considered up front to avoid needing to revisitvalidate()again later.
- Q: Are
artifact_contentandfile_object_contentidentical at the storage API level, or do they use different API paths / metadata handling? → A: Different implementations —file_object_contentuses a different API path or carries different metadata handling thanartifact_content. - 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 anInfrahubClient. Thetrusted=Falseregistration enforces this boundary via the existing restricted-mode validation. - Q: What exception class should filter-level errors (no client, retrieval failure) raise? → A: A new
JinjaFilterErrorclass that is a child of the existingJinjaTemplateErrorbase class. - 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. - Q: What should
from_json/from_yamldo on malformed input? → A: RaiseJinjaFilterErroron malformed JSON or YAML input.