Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tools/tailor/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion tools/tailor/crates/tailor-config/src/fragment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use serde_yaml_ng::Value;

use crate::{
error::ConfigError,
schema::{AxisValues, ExtraParam},
schema::{AxisValues, ExtraParam, SigningRef},
types::ParamValue,
};

Expand All @@ -45,6 +45,9 @@ pub(crate) struct Fragment {
/// (base → most-specific), mirroring `rpmSources`.
#[serde(default)]
pub(crate) extra_params: Vec<ExtraParam>,
/// Opt a matched cell into a workspace signing profile.
#[serde(default)]
pub(crate) signing: Option<SigningRef>,
/// Drop cells this fragment applies to from bulk selection unless the run pins the fragment's
/// value or names the cell (`meta/docs/2026-07-22-fragment-skip.md`). Mergeable last-wins; a
/// more-specific fragment may set `false` to un-skip.
Expand Down
120 changes: 115 additions & 5 deletions tools/tailor/crates/tailor-config/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
include, interpolate, matrix,
matrix::AxisTuple,
merge,
schema::{BaseSource, ExtraParam, ImageDefinition, OutputSpec},
schema::{BaseSource, ExtraParam, ImageDefinition, OutputSpec, SigningRef},
types::{OutputFormat, ParamValue},
};

Expand Down Expand Up @@ -65,6 +65,8 @@
pub rpm_sources: Vec<PathBuf>,
/// Extra IC command-line flags appended verbatim, concatenated across matched fragments.
pub extra_params: Vec<ExtraParam>,
/// The resolved per-cell `signing:` opt-in after fragment merging.
pub signing: Option<SigningRef>,
/// Resolved `skip` for this cell (merged from fragment `skip:` fields, last-wins). When `true`,
/// the cell is dropped from bulk selection unless specifically requested
/// (`meta/docs/2026-07-22-fragment-skip.md`).
Expand Down Expand Up @@ -190,8 +192,8 @@
let mut ic_config = Value::Mapping(config);
interpolate::interpolate_tree(&mut ic_config, &context)?;

let base = resolve_base(image, &tuple, &matched, &context)?;
let outputs = resolve_outputs(image, &tuple, &matched)?;
let base = resolve_base(image, image_dir, &tuple, &matched, &context)?;
let outputs = resolve_outputs(image, &tuple, &matched, &context)?;
for output in &outputs {
validate_output_compression(image, &tuple, output)?;
}
Expand All @@ -203,6 +205,7 @@
.iter()
.flat_map(|f| f.doc.extra_params.clone())
.collect();
let signing = resolve_signing_ref(image, &tuple, &matched)?;

// Resolve `skip` last-wins over the matched fragments (base → most-specific). When the winning
// value is `true`, remember that fragment's predicate coordinates as the pins that can override
Expand All @@ -223,6 +226,7 @@
outputs,
rpm_sources,
extra_params,
signing,
skip,
skip_pins,
})
Expand Down Expand Up @@ -252,13 +256,17 @@

fn resolve_base(
image: &ImageDefinition,
image_dir: &Path,
tuple: &AxisTuple,
matched: &[&LoadedFragment],
context: &interpolate::Context,
) -> Result<BaseSource, ConfigError> {
let mut base: Option<Value> = None;
for fragment in matched {
if let Some(value) = fragment.doc.base.clone() {
if let Some(mut value) = fragment.doc.base.clone() {
// Mirror config:'s $include handling (resolved before merge, see render_cell above) so a
// base: fragment can splice a shared snippet too, instead of only config: being able to.
include::resolve_includes(&mut value, image_dir)?;
base = Some(merge::merge_field(
base,
value,
Expand Down Expand Up @@ -305,6 +313,7 @@
image: &ImageDefinition,
tuple: &AxisTuple,
matched: &[&LoadedFragment],
context: &interpolate::Context,
) -> Result<Vec<OutputSpec>, ConfigError> {
let mut outputs: Option<Value> = None;
for fragment in matched {
Expand All @@ -318,11 +327,28 @@
}
}
match outputs {
Some(value) => deserialize_field(value, image, tuple, OUTPUTS_FIELD),
Some(mut value) => {
interpolate::interpolate_tree(&mut value, context)?;
deserialize_field(value, image, tuple, OUTPUTS_FIELD)
}
None => Ok(Vec::new()),
}
}

fn resolve_signing_ref(
_image: &ImageDefinition,
_tuple: &AxisTuple,
matched: &[&LoadedFragment],
) -> Result<Option<SigningRef>, ConfigError> {
let mut signing: Option<SigningRef> = None;
for fragment in matched {
if let Some(value) = fragment.doc.signing.clone() {
signing = Some(value);
}
}
Ok(signing)
}

/// Reject `compression:` on formats where it makes no sense: `cosi` (IC already compresses it),
/// `iso` (compressing the image breaks bootability), and the `pxe-*` outputs (a directory / an
/// already-gzipped tar). Compression is a tailor post-step over a single raw disk image.
Expand Down Expand Up @@ -482,6 +508,34 @@
.collect()
}

#[test]
fn output_name_parses_static_and_interpolated_values() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"image.yaml",
indoc! {"
name: named
matrix:
arch: [amd64]
outputs:
- format: cosi
name: legacy-static
- format: raw
name: '${arch}-artifact'
base:
path: ./b.img
config:
os: { hostname: named }
"},
);
let image = load_image(tmp.path().join("image.yaml")).unwrap();
let cells = render_image(&image, tmp.path()).unwrap();
assert_eq!(cells[0].outputs.len(), 2);
assert_eq!(cells[0].outputs[0].name.as_deref(), Some("legacy-static"));
assert_eq!(cells[0].outputs[1].name.as_deref(), Some("amd64-artifact"));
}

#[test]
fn compression_parses_and_is_validated_against_the_format() {
// A raw disk-image format accepts compression; the parsed OutputSpec carries it.
Expand Down Expand Up @@ -695,6 +749,62 @@
assert_eq!(install(pro), ["boot-pro"]);
}

#[test]
fn base_include_splices_a_shared_snippet_before_merge() {
// A by-arch fragment can $include a shared base snippet instead of inlining it, exactly
// like config: already could - regression test for the "must be resolved before merge"
// bug where base: never ran $include resolution.
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"image.yaml",
indoc! {"
name: shared-base
matrix:
arch: [amd64, arm64]
outputs:
- format: cosi
config: {}
"},
);
write(
tmp.path(),
"by-arch/amd64.yaml",
"base:
$include: common/base-amd64.yaml
",
);
write(
tmp.path(),
"by-arch/arm64.yaml",
"base:
$include: common/base-arm64.yaml

Check warning on line 781 in tools/tailor/crates/tailor-config/src/render.rs

View workflow job for this annotation

GitHub Actions / fmt, clippy, unit tests

Diff in /home/runner/work/trident/trident/tools/tailor/crates/tailor-config/src/render.rs
",
);
write(tmp.path(), "common/base-amd64.yaml", "path: ./amd64.img
");
write(tmp.path(), "common/base-arm64.yaml", "path: ./arm64.img
");

let image = load_image(tmp.path().join("image.yaml")).unwrap();
let cells = render_image(&image, tmp.path()).unwrap();
let by_arch = |arch: &str| -> &RenderedCell {
cells
.iter()
.find(|c| c.tuple.get("arch") == Some(arch))
.unwrap_or_else(|| panic!("no {arch} cell"))
};

match &by_arch("amd64").base {
BaseSource::Path { path, .. } => assert_eq!(path, Path::new("./amd64.img")),
other => panic!("expected a path base, got {other:?}"),
}
match &by_arch("arm64").base {
BaseSource::Path { path, .. } => assert_eq!(path, Path::new("./arm64.img")),
other => panic!("expected a path base, got {other:?}"),
}
}

#[test]
fn an_image_with_no_base_is_an_error() {
let tmp = TempDir::new().unwrap();
Expand Down
4 changes: 4 additions & 0 deletions tools/tailor/crates/tailor-config/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,10 @@ pub struct AzureLinuxBase {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct OutputSpec {
pub format: OutputFormat,
/// Optional published artifact basename override. When omitted, tailor publishes `<slug>.<ext>`;
/// when set, it publishes `<name>.<ext>` (and still suffixes clones with `_cloneN`).
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub cosi_compression_level: Option<u8>,
/// Post-build compression tailor applies to the artifact (e.g. `zstd` ⇒ `img.vhd.zst`). Invalid
Expand Down
4 changes: 3 additions & 1 deletion tools/tailor/crates/tailor-core/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{collections::BTreeMap, fmt, path::PathBuf, sync::Arc};
use serde_yaml_ng::Value;
use tailor_config::{
Arch, BaseImageCatalogue, BaseSource, ExtraParam, ImageDefinition, OutputArtifactsPolicy,
OutputSpec, ToolsDirSource, ToolsDirSourceInline,
OutputSpec, SigningRef, ToolsDirSource, ToolsDirSourceInline,
};

/// A resolved image — the catalogue/authoring unit, after config load and defaults are applied.
Expand Down Expand Up @@ -62,6 +62,8 @@ pub struct Cell {
pub rpm_sources: Vec<PathBuf>,
/// Extra IC command-line flags appended verbatim after every tailor-managed flag.
pub extra_params: Vec<ExtraParam>,
/// The resolved per-cell `signing:` opt-in after fragment merging.
pub signing: Option<SigningRef>,
/// Resolved `${inputs.<name>}` producer artifact paths substituted into `ic_config`, in declared
/// order. Content-hashed into the fingerprint (like `extraDependencies`) so a producer rebuild
/// invalidates this cell (`meta/docs/2026-09-09-inter-image-dependencies.md`). Live in the (already
Expand Down
19 changes: 19 additions & 0 deletions tools/tailor/crates/tailor-core/src/fingerprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
pub compression: Option<Compression>,
/// COSI compression level (`--cosi-compression-level`); a change rebuilds the COSI.
pub cosi_compression_level: Option<u8>,
/// Published artifact basename override from `outputs[].name`; a change rebuilds the cell.
pub output_name: Option<&'a str>,
}

/// Compute the canonical fingerprint. Each field is domain-separated and length-prefixed so distinct
Expand Down Expand Up @@ -100,6 +102,9 @@
if let Some(level) = inputs.cosi_compression_level {
field(&mut hasher, b"cosi-compression-level", &[level]);
}
if let Some(name) = inputs.output_name {
field(&mut hasher, b"output.name", name.as_bytes());
}

Fingerprint(hasher.finalize().into())
}
Expand Down Expand Up @@ -154,6 +159,7 @@
extra_params: &[],
compression: None,
cosi_compression_level: None,
output_name: None,
}
}

Expand Down Expand Up @@ -236,6 +242,19 @@
assert_ne!(fingerprint(&level_one), fingerprint(&level_two));
}

#[test]

Check warning on line 245 in tools/tailor/crates/tailor-core/src/fingerprint.rs

View workflow job for this annotation

GitHub Actions / fmt, clippy, unit tests

Diff in /home/runner/work/trident/trident/tools/tailor/crates/tailor-core/src/fingerprint.rs
fn output_name_changes_fingerprint() {
let base = base();
let cfg: Value = serde_yaml_ng::from_str("os:
hostname: a
").unwrap();
let mut a = inputs("cell", &cfg, &base);
a.output_name = Some("legacy-name");
let mut b = inputs("cell", &cfg, &base);
b.output_name = Some("other-name");
assert_ne!(fingerprint(&a), fingerprint(&b));
}

#[test]
fn input_dep_hashes_change_the_fingerprint() {
let base = base();
Expand Down
1 change: 1 addition & 0 deletions tools/tailor/crates/tailor-core/src/imagedep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ mod tests {
dir,
default_outputs: vec![OutputSpec {
format: OutputFormat::Cosi,
name: None,
cosi_compression_level: None,
compression: None,
}],
Expand Down
2 changes: 1 addition & 1 deletion tools/tailor/crates/tailor-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ pub use hashcache::{FileHash, hash_file_cached};
pub use lockfile::{LockedBase, LockedContainer, LockedRuntime, Lockfile};
pub use orchestrator::{
BuildOptions, BuildProgress, BuildSelection, Orchestrator, ResolvedToolchain,
ResolvedToolsDirSource, artifact_name, cells, cells_selected, output_slug,
ResolvedToolsDirSource, artifact_name, cells, cells_selected, output_name, output_slug,
published_artifact_name, runtime_config, select_node_cells, toolchain_for, toolchain_key,
tools_dir_key,
};
Expand Down
Loading
Loading