Skip to content

feat(build, stapel): sign files matched by a glob pattern - #471

Merged
reyreavman merged 3 commits into
mainfrom
feat/build/sign-glob
Oct 26, 2026
Merged

reyreavman merged 3 commits into
mainfrom
feat/build/sign-glob

Conversation

@reyreavman

@reyreavman reyreavman commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

sign[].path in werf.yaml may now be an absolute glob pattern. werf expands it against the image filesystem when the signing stage runs and lays a detached PKCS#7 <file>.p7s next to every regular file it matches, with the same host-side signing and the same signature shape as a literal path. This is what signing a set of files known only after the build takes — such as the several hundred kernel modules make modules_install produces, which previously had to be signed in shell with a Vault client inside the build container.

What

werf.yaml

  • A sign[].path containing * is a glob pattern: * matches within one path segment, ** across segments. Every other character — ?, [, { included — stands for itself, so an existing literal path such as /out/file[1].bin keeps its exact previous behaviour and no escaping rule exists. The one thing that cannot be signed is a file with * in its name.
  • A pattern whose literal base is / (/**/*.ko) is rejected: a glob pattern must start with a literal directory below /! — otherwise the whole image filesystem would be streamed out for one signing stage.
  • A pattern that matches no regular file fails the build: `path: /out/**/*.ko` of sign in image "kernel" matches no file.
  • The literal base directory of the pattern is resolved through the image's own symlinks (/lib -> /usr/lib); below it symlinks are neither matched nor followed, so a file is signed under its real path only. A hard link is a regular file under another name and is signed under each name.
  • A file reached through two entries of the same signing position is an error naming both patterns; a matched file that is the .p7s of another file signed at the same position is an error as well. Both are detected at expansion time; the static parser checks stay for literal paths and for an identical pattern string listed twice.
  • The JSON schema and the werf.yaml reference describe the pattern form.

Build

  • Each matched file gets its own Signing <path> log line and its own <path>.p7s; the signature format and the certificate requirements are unchanged.
  • VERIFIED: 470+ files of one pattern under the Docker backend do not overflow the stage container's command line — the install commands now go through a script mounted with the signatures instead of the container's Cmd.
  • The stage digest is unchanged for literal paths: a pattern participates in the digest as written, its expansion does not.
  • The Buildah backend reads all files of a signing stage out of one container, mounted once, instead of a container per file. Unmount and remove are both attempted on close.

Documentation and skill

  • docs/pages_{en,ru}/usage/build/stapel/imports.md describe the pattern form, its error cases and the kernel-module example.
  • The werf-to-delivery-kit skill no longer lists kernel-module signing as a stop condition: it describes the sign: glob plus scripts/sign-file -s in beforeSetup as the migration of a Vault-based module signing step.

Why

A sign entry named exactly one file, so an image whose signed files are produced by the build — a kernel's modules — could not use the directive and kept signing in shell: a Vault client and a short-lived token inside the build container, and a network dependency that build.sbom.enable forbids. Listing every module in werf.yaml is not an alternative: the set changes with the kernel config, and a module missing from the list would ship unsigned and refuse to load under CONFIG_MODULE_SIG_FORCE.

The alternative of signing with an ephemeral key generated by the kernel build (CONFIG_MODULE_SIG_ALL=y) was rejected: it puts a second trust anchor into the builtin keyring on every build and changes the CA-only trust model the consumer relies on.

`sign[].path` used to accept one absolute file path, so a set of files
known only after the build — the kernel modules `make modules_install`
produces, several hundred of them — could not be signed without
listing each one in werf.yaml. Accept an absolute glob pattern (`*`,
`?`, `[...]`, `{a,b}`, `**`) and expand it against the image filesystem
when the signing stage runs: every regular file it matches gets its
own `<file>.p7s`, symlinks are neither matched nor followed, and a
pattern matching nothing fails the build the way a missing literal
path does.

Expansion goes through the ImageReader of the container backend, which
gains a Glob method: Docker streams the pattern's literal base
directory out once with `docker cp`, Buildah walks it inside the
mounted container root with the base resolved against that root so a
symlink there cannot lead to a host path. The Buildah backend now also
opens a real reader — one container created and mounted for all the
files of a stage — instead of a container per file.

Duplicate-file and signature-of-the-same-stage conflicts, which the
config parser checks statically for literal paths, are checked for
patterns at expansion time against the actual file set. Under the
legacy stapel builder the install commands go through a script in the
mounted signatures directory, as the command line has a fixed size
limit a few hundred files would exceed.

Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
@reyreavman

reyreavman commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Verification

  • task test:e2e paths=./test/e2e/build labelFilter=sign-files: the new glob spec passed with the Docker backend on macOS and with the native-rootless Buildah backend on a Linux host; the pre-existing build.sbom.enable spec fails on both before and after this change because the fixture's base image has no SBOM attestation in the registry used there.
  • Mutation: empty-match check removed → stage spec fails when the pattern matches no file; duplicate-file check removed → rejects a file reached through two entries; same-position-signature check removed → rejects a matched file that is the signature of another file; pattern treated as a literal → signs every regular file the pattern matches; Docker collector admitting symlinks → globTarCollector spec; Docker collector ignoring hard links → globTarCollector spec; Buildah walk admitting non-regular entries → Glob spec; Buildah base not resolved against the container root → Glob spec (host-escape symlink); parser pattern validation dropped → with sign malformed glob pattern; parser static conflict check applied to patterns → should handle sign with glob patterns.
  • CI run 39943904562: lint, unit, docs_unit, docs_integration, e2e_complex pass. e2e_simple/e2e_extra/integration_* fail on the runner itself — every Buildah spec and every spec's DeferCleanup die with lstat /run/user/998: no such file or directory (no XDG runtime dir for the runner user); the Docker glob spec's [It] passed there and only its cleanup failed. The same jobs fail identically on main in run 39920117480. docs_check_links fails on two external links in usage/cleanup/cr_cleanup.md that time out, untouched by this PR.
  • Not run: the full task test:unit on the author's host fails in pkg/true_git only, because of a global tag.gpgsign true in that host's git config; it passes with GIT_CONFIG_GLOBAL=/dev/null.

Review focus

  • pkg/build/stage/sign_files.go: the legacy-builder path now writes one install.sh per signing stage and mounts the signatures directory read-only; confirm nothing else in the stage still relies on per-file AddServiceRunCommands.
  • pkg/container_backend/docker_image_reader.go: globTarCollector rebases docker cp entries onto the pattern's base directory; a pattern whose literal prefix is / copies the whole root.

Follow-up

  • Migrate container-base/kernel to sign: [{path: /out/lib/modules/**/*.ko, after: install}] once a release carries this change (the module's MR is prepared against it).

@reyreavman reyreavman left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 9e525b2 against main. Built locally, ran the unit suites for pkg/build/stage, pkg/config, pkg/container_backend, and ran the sign-files e2e label on a Linux host with both backends: Ran 8 of 88 Specs — 8 Passed, including the glob spec on Native Rootless, which CI never reached (every Buildah job here dies on lstat /run/user/998 at backend init — infra, not this diff). Three mutations against the load-bearing tests (hard link to a non-regular entry, symlink in WalkDir, dropping the signature-of-signed check) were all caught.

Major

1. Literal paths containing [, { or ? silently change meaning — pkg/config/sign.go:28

IsPattern() is ContainsAny(Path, "*?[{"). Before this PR path: /out/file[1].bin signed exactly that file. Now it is a pattern, and doublestar.Match("/out/file[1].bin", "/out/file[1].bin") is false while /out/file1.bin matches. If that file does not exist the build fails with matches no file (at least visible); if it does, a different file is signed and the intended one ships unsigned. The PR description's "a path without them keeps its exact previous behaviour" is true but omits that a path with them does not. Either document in imports.md (EN/RU) and in the PR body that these are now metacharacters and a literal is written with \-escaping, or treat a pattern that matches itself as a literal. Documenting is simpler and honest about the grammar change.

Minor

2. Glob with base / on the Docker backend — docker_image_reader.go:163. For /**/*.ko the base is / and docker cp <ctr>:/ - yields entries named /bin/arch, so path.Join(path.Dir("/"), hdr.Name) happens to produce the right path — but the collector's comment ("rebased onto its parent") does not describe what happens there, and streaming the whole rootfs of a kernel image through docker cp is operationally bad. Consider rejecting a / base in validate(), or at least add a unit case with the names Docker actually emits for the root.

3. Buildah Glob resolves a symlink in the base through SecureJoin — buildah_image_reader.go:80. Correct (stays inside the rootfs), but files then surface under the path through the link, which contradicts "signed under its real path only" in the docs and PR body. Suggest "below the base, symlinks are not followed" — the unit test already phrases it that way.

4. Comments — buildah_image_reader.go:122-124 (ReadDir) and sign_files.go:147-151 (expandSigns) restate the error texts; both can be cut to a line.

5. CI is red and the PR is draft — before marking ready, a green run is needed so the Native Rootless glob spec is confirmed by CI, not only by my local run. docs_check_links failure not analysed.

Checked and fine

  • Stage digest for literal paths is unchanged (digest() hashes sign.Path as written) — no cache invalidation.
  • One container/mount per signing stage on both backends; Buildah Close attempts unmount and remove and joins the errors; a failed mount removes the container before returning.
  • Legacy Docker path: install commands go into install.sh via stapel.CreateScript, Cmd receives a single line; host dir is already mounted :ro.
  • Duplicate-file and signature-of-signed detection at expansion time name both patterns; the static parser checks remain for literals.
  • EN/RU docs, werf_yaml.yml, JSON schema and the migration skill are in sync.

…s metacharacters

A pattern whose literal base is `/` (`/**/*.ko`) streams the whole image
filesystem out through `docker cp` for one signing stage; require a
literal directory below `/` instead. `*`, `?`, `[` and `{` in `path`
are now metacharacters, so a literal file name containing one is
written with a backslash — say so in the docs and the skill, and state
that symlinks are not followed below the base rather than "never":
the base itself resolves through the container root.

Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
@reyreavman

Copy link
Copy Markdown
Collaborator Author

Thanks — addressed in 7d3848f:

  1. Metacharacters — documented rather than special-cased: imports.md (EN/RU) and the migration skill now state that *, ?, [, { are metacharacters in path and a literal is written as /out/file\[1\].bin; the PR body carries it as a BREAKING note (the directive shipped in one release so far). Matching a pattern against itself would not have helped here — doublestar.Match("/out/file[1].bin", "/out/file[1].bin") is false — so the escaping rule is the only honest grammar.
  2. Base / — rejected in validate(): a glob pattern must start with a literal directory below /!, with a parser test (/**/*.ko). The collector comment now spells out the out/a.ko ↔ /out rebasing and that / never reaches it.
  3. Wording — "below the base, symlinks are not followed" in EN/RU docs, the skill and the PR body; the base resolving through the image's own symlinks is stated explicitly.
  4. Comments trimmed to one line each.
  5. CI re-triggered by the push; the Buildah jobs are at the mercy of /run/user/998 on the runner. docs_check_links fails on two external links in usage/cleanup/cr_cleanup.md (docs.microsoft.com, golang.org) timing out — not touched by this PR, same on main.

`sign[].path` was a literal path before glob support, so turning `?`,
`[` and `{` into syntax changed the meaning of existing names such as
`/out/file[1].bin` and forced a backslash-escaping rule nobody needs:
signing wants "every .ko below this directory" and nothing more. A
path is a pattern when it contains `*`; every other character stands
for itself, which is done by escaping it before the pattern reaches
doublestar.

Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
@reyreavman

Copy link
Copy Markdown
Collaborator Author

Follow-up on point 1, in 737366f: rather than documenting an escaping rule, the grammar is reduced — * (and **) is the only metacharacter. A path is a pattern when it contains *; ?, [ and { stand for themselves (they are escaped before the pattern reaches doublestar), so /out/file[1].bin behaves exactly as before and there is no BREAKING note anymore. Signing never needed more than "every .ko below this directory", and those three are the characters that actually show up in file names. Stage spec treats every character but * literally covers it on both sides (/out/lib[64]/*.ko does not match /out/lib6/, what?.b* does not match what!.bin); the malformed glob pattern check and its test are gone since a *-only pattern cannot be malformed.

@reyreavman
reyreavman marked this pull request as ready for review October 26, 2026 10:05
@reyreavman
reyreavman merged commit ffda24e into main Oct 26, 2026
4 of 6 checks passed
@reyreavman
reyreavman deleted the feat/build/sign-glob branch October 26, 2026 10:06
reyreavman pushed a commit that referenced this pull request Oct 26, 2026
🤖 I have created a release *beep* *boop*
---


##
[3.12.0-dk.3](v3.12.0-dk.2...v3.12.0-dk.3)
(2026-10-26)


### Features

* **build, stapel:** sign files matched by a glob pattern
([#471](#471))
([ffda24e](ffda24e))
* **sbom:** inventory installed Python, JavaScript and Lua packages
([#472](#472))
([7fde219](7fde219))


### Bug Fixes

* **build, stapel, import:** stop imports failing with sbom enabled
([#470](#470))
([478b751](478b751))
* **sbom:** avoid marking editable Python packages as absent
([#474](#474))
([e0b4b66](e0b4b66))
* **sbom:** retain legacy Python bytecode after source removal
([#476](#476))
([36debcb](36debcb))
* **sbom:** stop marking os-pm packages under /lib64 as not shipped
([#475](#475))
([cd0a029](cd0a029))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant