ADR: Extending Templates#
Status: Proposed Date: 2026-05-26 Updated: 2026-05-30 Authors: ICT Team Technical Area: Template Configuration / Merge System
Summary#
Add an optional extends field to user templates, enabling linear inheritance chains. Each template may extend at most one parent template. The merge is applied iteratively from the root of the chain through each level, producing a deterministic result. This allows users to maintain minimal delta templates that automatically inherit updates from their parent templates.
Context#
Problem Statement#
Teams often publish reusable templates (e.g., ubuntu24-x86_64-edge-raw.yml) that define a baseline image configuration. Other users need to customize these for specific use cases, typically adding packages or changing the kernel. Today, they must copy the entire template and modify it. When the original template is updated (security patches, new default packages), every copy must be manually re-synchronized.
Current System#
ICT already implements a two-layer merge: OS-level default templates (config/osv/) are merged with the user template at build time, using well-defined per-attribute strategies (additive, override, replace, merge-by-key). This works well for OS-to-user inheritance but does not support template-to-template inheritance.
Industry Precedent#
Docker Compose, ESLint, and Spring Boot all use extends for parent-child template inheritance with field-level overrides, the same semantics proposed here.
Key Design Constraint#
Each template may extend at most one parent (single inheritance). This prevents diamond/multiple-inheritance problems entirely. With this constraint, the extends chain is always a simple linear sequence, and MergeConfigurations() can be applied iteratively as a fold operation: if the merge is deterministic for 2 layers, it is deterministic for N.
Decision#
Add an optional extends field to user templates, supporting linear inheritance chains. Each template may extend at most one parent, and chains may be multiple levels deep.
Template Example#
Root template (ubuntu24-x86_64-edge-raw.yml):
image:
name: ubuntu24-x86_64-edge
version: "1.0.0"
target:
os: ubuntu
dist: ubuntu24
arch: x86_64
imageType: raw
systemConfig:
packages:
- docker-cli
- containerd
- openssh-server
kernel:
version: "6.8.0-49-generic"
Level 1 template (monitoring-edge.yml) extends the root:
extends: "ubuntu24-x86_64-edge-raw.yml"
image:
name: monitoring-edge
systemConfig:
packages:
- prometheus-node-exporter
- grafana-agent
Level 2 template (my-custom-edge.yml) extends level 1:
extends: "monitoring-edge.yml"
image:
name: my-custom-edge
systemConfig:
packages:
- my-custom-app
kernel:
version: "6.8.0-50-generic"
Resolved chain (4 layers): OS defaults -> ubuntu24-x86_64-edge-raw.yml -> monitoring-edge.yml -> my-custom-edge.yml
The final merged template contains packages from all levels: docker-cli, containerd, openssh-server, prometheus-node-exporter, grafana-agent, my-custom-app (deduplicated union). The kernel version is 6.8.0-50-generic (last non-empty wins).
Merge Flow#
flowchart TD
A[Load leaf template] --> B[Walk extends chain to find root]
B --> C{Cycle detected?}
C -- Yes --> ERR1["Reject: circular extends detected"]
C -- No --> D{All targets match?}
D -- No --> ERR2["Reject: target mismatch in chain"]
D -- Yes --> E[Load OS default template]
E --> F["Start: merged = MergeConfigurations(root, default)"]
F --> G{More levels in chain?}
G -- Yes --> H["merged = MergeConfigurations(next child, merged)"]
H --> G
G -- No --> I[Final merged template]
style ERR1 fill:#f44,color:#fff
style ERR2 fill:#f44,color:#fff
style I fill:#4a4,color:#fff
The algorithm walks the extends chain to collect all templates from leaf to root, then iteratively applies MergeConfigurations() starting from the root. This is a simple fold/reduce: fold(MergeConfigurations, default, [root, level1, level2, ..., leaf]). Since MergeConfigurations is deterministic for two inputs, the result is deterministic for any chain length.
Merge Strategies (unchanged)#
Each strategy is applied iteratively at each level of the chain. The behavior is the same regardless of chain depth.
Section |
Strategy |
N-layer behavior |
|---|---|---|
|
Additive (deduplicated) |
Union across all levels |
|
Additive (append) |
Appended in chain order: root first, leaf last |
|
Merge by |
Field-level override; last level in chain wins per field |
|
Full replace |
Last non-empty wins |
|
Field-level override |
Last non-empty field wins |
|
Merge by |
Last codename match wins |
Future Consideration: Partition Merge Granularity#
The current disk strategy replaces the entire disk configuration if any level provides one. An alternative approach is to merge partitions by partition ID (analogous to how packages are deduplicated by name), allowing a child template to override a single partition entry (e.g., resize root) without re-specifying the entire partition table. This is orthogonal to extends and could be addressed separately.
Validation Rules#
Single extends per template: each template may reference at most one parent via
extends(no multiple inheritance, no diamond problem)Cycle detection: maintain a visited set while walking the chain; reject if any template appears twice (e.g., A extends B extends A)
Target match: all templates in the chain must have compatible
target(OS/dist/arch/imageType); mismatch at any level is rejectedDepth warning: emit a warning when the chain exceeds 4 levels (OS default + 4 extends levels) to encourage maintainable hierarchies. No hard cap enforced
Path safety: resolve paths with
filepath.Clean, reject symlinks (existingsecurity.RejectSymlinks), reject resolved paths that escape the child template’s directory (i.e., disallow../../../etc/style traversal after cleaning)Schema:
extendsadded as optional string toUserTemplateschema; stripped from merged result
Changes Required#
Component |
Change |
|---|---|
|
Add |
JSON schema |
Add |
|
Walk extends chain, load all templates, iteratively apply |
|
Resolve full extends chain during validation |
|
No changes needed (already calls |
CLI output |
Log the full resolved chain: |
|
Show the resolved extends chain merge (does not include OS defaults) |
Tests |
Basic extends, multi-level chain, missing parent, cycle detection, target mismatch, depth warning, path traversal |
Documentation |
Template docs, CLI specification, examples |
CLI/UX Changes#
build: Log the full extends chain at info level so users can see the inheritance hierarchy in build outputvalidate: Resolve the fullextendschain and validate the merged resultresolve(new subcommand): When the template usesextends, output the resolved chain-merged template as YAML to stdout (does not include OS defaults). If the template does not useextends, output the template as-is with a message:"No extends used in template, nothing to resolve". The--fullflag includes OS defaults in the output, showing exactly what will be built. Usage:image-composer-tool resolve -t my-template.yml [--full]. Note:resolvealways runs on-demand against current template files; output is never cachedError messages: Provide clear, actionable messages for common errors:
"circular extends detected: A -> B -> A""extends target mismatch at level 2: child targets ubuntu/x86_64 but parent targets azure-linux/x86_64""extends path not found: <resolved-path>""extends chain depth 5 exceeds recommended maximum of 4"(warning, not error)
Consequences#
Benefits#
Users maintain only their delta, avoiding full-template copies that drift
Parent template updates (packages, security fixes) propagate automatically to all descendants
No changes to existing merge logic;
MergeConfigurationsis reused as a fold operationSingle-extends-per-template constraint prevents diamond/multiple-inheritance problems entirely
resolvesubcommand provides full traceability regardless of chain depth
Risks and Mitigations#
Risk |
Mitigation |
|---|---|
Deep chains make debugging harder |
|
Parent template breaking changes affect descendants silently |
Document that parent templates are a contract; recommend versioning templates |
Stale |
|
Path resolution complexity (remote URLs, registries) |
Start with local file paths only; remote support can be added later |
Why Multi-Level Linear Chaining Works#
MergeConfigurations(child, parent)is a pure function of two inputs. Applying it iteratively (fold/reduce) produces deterministic results at any depthWith single extends per template, the chain is always linear. There is no diamond problem, no multiple inheritance, and cycle detection is a simple visited-set check
Order of merge is well-defined: OS defaults -> root -> level 1 -> … -> leaf. For additive sections (
packages,configurations), items accumulate in chain order. For override sections (kernel,disk), last non-empty wins. Both are predictableConcerns around ambiguous ordering, last-writer-wins confusion, and validation complexity apply primarily to multiple inheritance (extending from more than one parent), not to linear chaining
Alternatives Considered#
Multiple inheritance (
extends: [a.yml, b.yml]): Rejected. Introduces diamond problem, ambiguous merge ordering between siblings, and significantly more complex conflict resolutionExternal pre-processing (yq, scripts): Already used by some users but pushes complexity outward and loses ICT validation/traceability