Skip to content

os: add experimental reproducible Debian/mkosi backend - #816

Merged
kvinwang merged 126 commits into
masterfrom
feat/experimental-mkosi-os
Jul 27, 2026
Merged

os: add experimental reproducible Debian/mkosi backend#816
kvinwang merged 126 commits into
masterfrom
feat/experimental-mkosi-os

Conversation

@kvinwang

@kvinwang kvinwang commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an experimental Debian trixie/mkosi implementation of the dstack guest OS,
selectable through the existing backend contract as
./os/build.sh --backend mkosi. The Yocto backend remains the release path and
is unchanged.

The acceptance target is functional parity with the Yocto image, not merely
release-archive compatibility. os/mkosi/parity.json is the machine-checked
inventory: required rootfs paths per component, required kernel config symbols,
and the dev-only / prod-forbidden path lists.

  • Builds the same dstack services plus the pinned Yocto component set: Linux
    6.18.40, NVIDIA 595.58.03 (open modules, userspace, firmware, Fabric Manager,
    NSCQ), nvattest 2026.06.09 with the OCSP-freshness patch, OpenZFS 2.4.0,
    Sysbox 0.6.7, NVIDIA Container Toolkit, nerdctl, CNI plugins and
    stargz-snapshotter 0.18.2.
  • Tracks the same kernel series as production (PREFERRED_VERSION_linux-yocto
    in meta-dstack), which tests/acceptance.sh enforces. That is what keeps
    the component set patch-free: ZFS 2.4.0 declares Linux-Maximum: 6.18 and the
    NVIDIA open modules build against this series unmodified.
  • Reuses the TDX DMA and ACPI BadAML/SystemMemory hardening patches verbatim
    from meta-dstack; patch fuzz is forbidden.
  • Pins every network input: an immutable Debian snapshot, the kernel tarball,
    the Rust 1.92.0 and Go 1.26.2 archives by SHA-256, mkosi 26, and the OVMF
    revision that matches meta-dstack's dstack-ovmf_git.bb SRCREV — a
    generic OVMF produces TDX measurement material dstack-mr cannot parse.
  • Needs no host Rust, Go, C or C++ toolchain. Components compile inside mkosi's
    build overlay from the same snapshot.
  • Produces the Yocto-compatible bare-metal and UKI layouts, dm-verity rootfs and
    TDX/GCP/AWS measurement material through the common os/image/assemble.sh
    release path, so both packages carry one os_image_hash.

Changes outside os/mkosi/

These are the parts that touch shared code and deserve review attention:

Path Change
os/build.sh Register mkosi as a selectable backend
os/image/kernel-cmdline.sh Define the guest kernel command line once; the UKI and the measured metadata.json must describe the same string
os/image/assemble.sh Drive the shared release path from the new artifact manifest
dstack/crates/build-info Add a DSTACK_BUILD_GIT_REVISION compile-time override, because the build sandbox has no .git. Deliberately distinct from the Yocto backend's DSTACK_GIT_REVISION, which carries a bare SHA for release metadata and would otherwise be picked up here in the wrong format
prek.toml, REUSE.toml Hook exclusions for vendored patches, and licensing for the new tree. os/mkosi/** is intentionally not precedence = "override", so a backported upstream patch keeps its own license

CI

.github/workflows/mkosi-build.yml is the backend's release path, mirroring
guest-os.yml for Yocto:

  • Static contract runs build.sh lint on every change (seconds).
  • Build guest images runs on workflow_dispatch and on a release tag. It
    reports os_image_hash as a log notice, a job-summary table and an
    image-hashes.txt release asset, alongside the TDX MRTD, GCP uki_auth,
    AWS boot_pcr_digest, both tarball digests, the sha256sum.txt preimage and
    metadata.json. Values are read back out of the published tarball, and
    digest.txt is recomputed and cross-checked against the UKI package, so a
    corrupt release fails instead of shipping.
  • Publish release is bound to the tag: pushing mkosi-os-v<version> is the
    only event that creates a GitHub Release, so a dispatch dry run cannot be
    mistaken for one. It rejects a tag whose version disagrees with
    versions.env or whose measured metadata.json records a revision other than
    the tagged commit, which also catches a dirty-worktree build.
  • An opt-in repro_check dispatch input builds twice with differing job counts
    and compares the release tarballs byte for byte.

Validation

  • ./os/mkosi/build.sh lint
  • ./os/mkosi/build.sh repro-check — two legs, differing job counts,
    byte-identical dstack-0.6.0.tar.gz and dstack-0.6.0-uki.tar.gz
  • Clean Linux 6.18.40 kernel build with both dstack patches
  • Pinned Intel TDX OVMF build
  • Complete prod rootfs and release assembly
  • Artifact schema, checksum, dm-verity, GPT label/GUID and tar layout checks
  • QEMU UKI smoke test reached verified-root mount, switch_root, and Debian
    systemd

Notes

This remains experimental, and the release workflow marks its output as a
prerelease to match. Package contents are Debian rather than Yocto, while the
external release contract and the file and partition layout are shared with the
Yocto backend.

Copilot AI review requested due to automatic review settings July 22, 2026 06:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kvinwang

Copy link
Copy Markdown
Collaborator Author

Reproducibility/clean-build follow-up pushed as c83948eb3.

Verified with the exact full two-clean-build gate:

JOBS=16 ./os/mkosi/build.sh repro-check os/mkosi/build-repro-final
...
Yocto functional parity paths accepted       # work-a
Yocto-compatible release format accepted     # work-a
Yocto functional parity paths accepted       # work-b
Yocto-compatible release format accepted     # work-b
reproducibility check passed
Elapsed: 35:28.42, exit status 0

The two dstack-0.6.0.tar.gz outputs are byte-identical. The fixes cover deterministic kernel/in-tree BTF, NVIDIA and ZFS external modules, nvattest Rust/C++, dstack Rust binaries, NVIDIA container tooling, fixed dm-verity UUID/salt, removal of the traversal-dependent ldconfig cache, stable absolute mkosi paths, and clean-build Sysbox/OVMF path handling.

Additional gates rerun successfully:

  • mkosi static acceptance / functional parity
  • full output format and dm-verity artifact checks in both clean builds
  • prek run --all-files
  • exact PR range prek command
  • shellcheck
  • REUSE is clean for the tracked tree; the only local reuse lint complaint remains the user's unrelated untracked docs/security/advisories/luks2_tamper.py, which was not touched or added.

@kvinwang

Copy link
Copy Markdown
Collaborator Author

Post-push QEMU smoke test also passed the intended platform-independent portion on the final reproducible disk.raw: Linux 7.1.4-dstack, dm-verity mapping and verified-root mount, var-volatile.mount, volatile bind setup, and dstack-firstboot.service all succeeded. Boot then stopped at the expected bare-QEMU boundary: no dstack-data partition and no /dev/vdb (dstack-prepare reported exactly that). Log: /tmp/mkosi-qemu-repro-final.log.

@kvinwang

Copy link
Copy Markdown
Collaborator Author

Added the missing SEV-SNP artifact chain in 986e487af (+ shellcheck annotation a158ce33e):

  • builds pinned OvmfPkg/AmdSev/AmdSevX64.dsc with the same Yocto embedded-GRUB removal patch
  • exports ovmf-sev.fd through artifact-manifest schema v1
  • emits metadata bios-sev: ovmf-sev.fd
  • generates and checksums measurement.snp.cbor
  • makes both files mandatory in mkosi output acceptance

Verification:

  • independent work-a/work-b TDX OVMF hashes match byte-for-byte
  • independent work-a/work-b SEV OVMF hashes match byte-for-byte
  • both complete release tarballs including measurement.snp.cbor match byte-for-byte (90fabd5d...)
  • measurement.snp.cbor is 680 bytes, matching the Yocto artifact shape
  • output checks, static acceptance, shellcheck and exact-range prek pass

Rootfs size attribution versus /home/kvin/src/dstack-v060-artifacts/image-store/dstack-0.6.0 is complete; detailed numbers follow in the report.

@kvinwang

Copy link
Copy Markdown
Collaborator Author

Added the explicitly opt-in, development-only mkosi component cache in e9a8ffcf0.

Security/audit boundary:

  • only the new dev-image action enables it;
  • normal image unconditionally unsets the internal enable flag;
  • repro-check also unconditionally disables it, even if a caller injects the flag;
  • Debian rootfs, dm-verity, UKI, measurements and release archives are never cached;
  • corrupt entries fail their SHA-256 check and rebuild;
  • the implementation is one small helper with local tar.zst entries and no remote cache/database.

Cached component outputs: dstack Rust, container stack, Sysbox, nvattest, kernel source/build/stage, NVIDIA, ZFS, and TDX+SNP OVMF.

Measured verification (JOBS=16, prod):

  • cold dev-image, all 8 misses: 20:19 (includes populating 2.0 GiB cache and full output/parity validation)
  • hot dev-image, all 8 hits: 3:06
  • second hot build in a different output directory: 2:52
  • hot output tarballs compared byte-for-byte: identical
  • inherited-cache-flag probes for both image and repro-check: no cache directory created and no cache access
  • unit tests cover hit, input-key invalidation, corrupt archive rejection/rebuild, and disabled bypass
  • static acceptance, shellcheck, and exact PR-range prek: passed

Usage:

DSTACK_DEV_CACHE_DIR="$HOME/.cache/dstack/mkosi-dev" \
  ./os/mkosi/build.sh dev-image "$PWD/os/mkosi/build-dev"

@kvinwang

Copy link
Copy Markdown
Collaborator Author

Follow-up 911b41ad1 makes the development cache key conservative over the complete installed Debian package/version set in addition to compiler/build-tool versions, so host headers and libraries cannot change underneath a cache hit. Revalidated current HEAD: after a transient GitHub OpenSSL submodule timeout, retry reused the seven completed component entries, completed OVMF, and produced a valid image; the subsequent all-eight-hit build completed in 3:02 and its release tarball was byte-identical to the prior build.

@kvinwang

Copy link
Copy Markdown
Collaborator Author

Refactored the cache implementation in b6dfefd1d in response to the cohesion/readability concern.

Structure now has three explicit layers:

  • build.sh: release orchestration only; it passes --dev-cache exclusively for dev-image and otherwise knows nothing about cache internals.
  • scripts/build-components.sh: one cohesive place for the eight non-Debian component build/staging/merge boundaries.
  • scripts/dev-cache.sh: one cohesive place for fingerprinting, package/tool dependency capture, locking, checksum validation, restore and atomic store.

In particular, build.sh lost roughly 90 lines of fingerprint/cache/component detail and now invokes component assembly once. The old output-count/key plumbing is gone; the cache API is now simply dev_cache_init plus dev_cache_run component base outputs... -- command. A subshell owns each lock, so lock cleanup is structural rather than duplicated.

Revalidated refactored HEAD:

  • full cold v2 cache population and complete image/parity/output validation: passed
  • all eight component cache hits: passed
  • hot build: 3:12
  • cold and hot release tarballs from different output trees: byte-identical
  • image and repro-check probes: no cache directory or cache access
  • corrupt cache/input invalidation/disabled-path unit tests: passed
  • exact PR-range prek, shellcheck and static acceptance: passed

@kvinwang

Copy link
Copy Markdown
Collaborator Author

Rootfs size parity is now pushed through 1fdf33b21, developed and verified in the isolated worktree /home/kvin/src/dstack.worktrees/mkosi-rootfs-size so the other agent's main-worktree changes were never touched.

Measured against the supplied Yocto 0.6.0 artifact:

Yocto rootfs.img.parted.verity: 594,542,592 bytes
mkosi rootfs.img.parted.verity:  589,299,712 bytes
mkosi is smaller by:              5,242,880 bytes (0.88%)

Retained changes:

  • strip production module DWARF while preserving BTF, modinfo and loadable ELF data
  • remove non-runtime headers/static archives/libtool metadata, docs/man/info/locales, redundant distro OVMF, and package-manager state hidden by volatile /var binds
  • remove the redundant Debian ovmf guest package (the pinned TDX/SEV firmware remains in release artifacts)
  • regenerate modules.dep and related indexes after cacheable kernel/NVIDIA/ZFS stage trees are merged

Verification evidence:

  • exact two-clean-build repro-check: passed in 33:58; tarballs byte-identical (ca8189d...)
  • parity and Yocto-compatible output checks passed in both builds
  • ovmf-sev.fd and 680-byte measurement.snp.cbor retained
  • nf_tables, ZFS, and NVIDIA resolve through the regenerated module indexes
  • QEMU: dm-verity root mounted, nftables succeeded, volatile state and firstboot succeeded; it stopped only at the expected missing /dev/vdb boundary
  • exact-range prek, shellcheck, static acceptance, and REUSE all pass

@kvinwang

Copy link
Copy Markdown
Collaborator Author

Final mkosi-native audit and reproducibility verification is complete.

Key changes in the final series:

  • moved image-tool compilation (dstack-mr from this repository and pinned nitro-tpm-pcr-compute) into mkosi.build;
  • moved artifact generation/assembly/cleanup into native finalize, post-output and clean hooks;
  • replaced the ineffective Format=none setup (which skips mkosi's post-build lifecycle) with a native tar carrier that mkosi.postoutput replaces with the Yocto-compatible archive;
  • moved flavor policy to mkosi profiles, package/file cleanup to native content settings, and service/directory policy to systemd presets/tmpfiles/skeleton trees;
  • minimized the tools tree to the native misc profile plus explicit assembly packages (571 MiB locally instead of 1.6 GiB);
  • made production bypass component cache-key calculation completely;
  • made linked-worktree development keys use a host-generated source inventory without mounting Git metadata;
  • normalized privilege-dependent empty-directory modes and canonicalized squashfs through a sorted, owner/mode-normalized tar stream before mksquashfs.

The last item came from a real cross-host failure: local and tdxlab trees had identical paths and file bytes, but privileged GitHub construction differed in one setgid directory bit and backing-filesystem inode order. The canonical stream now removes both inputs. A mechanical rebuild from the formerly different local/GitHub root trees produced identical squashfs bytes.

Final verified source revision: 2611402ad7cadded2a044777f9542b5ef6579393

bare: 630921781a37210ddc21104390d1b9c9da5d725541fa48b5c0e9f1e1b1a10af1
UKI:  0b40d5095589235c465dea3dd6c294f00bd90f1c1ccb5cf9fd8c8ff475df85f0

Verification:

  • two clean, component-cache-disabled local builds: byte-identical;
  • clean direct-SSH tdxlab build: byte-identical to local for both archives;
  • standard ubuntu-latest, 4-job manual workflow: passed byte-for-byte in 46m16s: https://github.com/Dstack-TEE/dstack/actions/runs/30146117209
  • exact PR-range prek, static mkosi acceptance, component cache/merge tests, shellcheck, REUSE and all repository PR checks: passed;
  • final branch is pushed and clean.

Custom logic retained is documented in os/mkosi/README.md and is limited to the Yocto image/archive protocol, independently keyed development component outputs, strict component rootfs collision detection, and portable skeleton-mode normalization.

kvinwang added 25 commits July 26, 2026 18:52
The backend pinned Linux 7.1.4 while production Yocto is on 6.18, and that
skew alone required two out-of-tree module compatibility patches with no
production counterpart: a 937-line OpenZFS backport (ZFS 2.4.0 declares
Linux-Maximum 6.18) and an NVIDIA kernel-open patch for the OF GPIO API
removed in 7.1. Both exist only to reach 7.1.

Pin 6.18.40 (current longterm, matching PREFERRED_VERSION_linux-yocto),
delete both patches, and derive the kernel.org download path from the
version instead of hardcoding v7.x. acceptance.sh now asserts the series
against meta-dstack's distro conf so the skew cannot silently return.
The fragment seeds from x86_64_defconfig, which enables KEXEC, HIBERNATION
and PROC_KCORE, and it overrode none of them. os/yocto/tools/aws/audit-aws-ec2
-image-hardening.sh requires KEXEC=n, HIBERNATION=n, DEBUG_FS=n and
STRICT_DEVMEM=y; linux-yocto-tiny never had them on, which is why the Yocto
fragments do not state them and the skew went unnoticed.

kexec inside an attested CVM rebooting into an arbitrary kernel leaves the
RTMRs and the dm-verity rootfs describing something that is no longer
running, after KMS has released the app keys; hibernation writes private
guest memory to the host-visible disk.

Also restores the meta-dstack fragment options that were dropped or
downgraded: PM/PM_SLEEP/HOTPLUG_CPU=n, XFRM_ESP=n, RXKAD=n,
BLK_DEV_THROTTLING_LOW=y, and BRIDGE_NF_EBTABLES/VXLAN back to =y.

acceptance.sh now derives the required set from the audit script itself
rather than restating it, so the two cannot drift.
Path.is_dir() follows symlinks, so a symlink-to-directory installed by one
component was accepted as a mergeable directory by the next: os.walk then
descended through it and shutil.copy2 wrote the payload outside the staging
root. A component shipping an absolute usr/lib64 -> /usr/lib silently wrote
into the build host (the CI job runs as root) while the files it was meant
to install were missing from the image, and the merger reported success.

Directories were also recreated with the default mkdir() mode instead of the
source mode, so a 0700 component-owned state directory shipped 0755.

Extends the merge tests to cover the symlinked-directory escape, directory
mode preservation and the unsupported-entry branch; the escape test fails
against the previous implementation.
…lision

DSTACK_GIT_REVISION already means something else: os/yocto/repro-build/
repro-build.sh exports it into the whole Yocto build environment and
os/yocto/scripts/export-artifacts.sh consumes it as release metadata, where
it carries a bare 40-char SHA. The new compile-time override used the same
name with the incompatible "git:<20hex>" display form and validated nothing,
so a Yocto repro build started from a shell that had already exported the
variable could bake a bare SHA into every binary -- changing Info.rev for
anything matching the "git:" prefix, and with it the rootfs hash.

Nothing protected against this except BitBake incidentally filtering the
variable out of recipe environments.

Renames the compile-time override to DSTACK_BUILD_GIT_REVISION, documents
the format contract, and adds unit tests covering both expansion arms.
Two defects in the reproducible-ESP change, both on the path shared with the
production Yocto backend:

No backend entrypoint exports SOURCE_DATE_EPOCH, so the ":-0" fallback was
always taken. FAT timestamps start at 1980-01-01 and cannot represent 0, so
every directory entry was dated 2107 -- deterministic, but wrong.

FAT also stores local time and mtools converts using TZ, so two builders in
different time zones produced different disk.raw bytes from identical inputs.
Verified with dosfstools 4.2 / mtools 4.0.43: TZ=UTC and
TZ=America/Los_Angeles differ at byte 80944.

Clamp the epoch to the FAT epoch and pin TZ=UTC for the ESP construction.
disk.raw is now genuinely reproducible, so add it to the Yocto repro-build
whitelist, where it had been excluded as a known non-reproducible artifact.

Also drops the now-stale "command -v mmd" gate: mmd is no longer invoked, so
requiring it failed builds on hosts that have mcopy but not mmd.
The cache was mounted through --build-sources, but BuildSourcesEphemeral=yes
applies to every BuildSources entry: mkosi gives each one an overlay whose
upper layer is a tempfile.TemporaryDirectory removed when the build script
exits (mounts.py finalize_source_mounts, called from run_build_scripts).
Every component archive and toolchain download was therefore discarded, so
dev-image was a permanent 100% cache miss and rebuilt the kernel, NVIDIA,
ZFS and OVMF on every run.

BuildDirectory is the only mount a build script gets that is both writable
and preserved between runs, so the cache and the toolchain downloads move
there. The source manifest stays on a source mount: it is a read-only input
regenerated on each run.

The existing dev-cache test drives dev-cache.sh directly on the host and
cannot observe mkosi's mount semantics, so acceptance.sh now pins the
storage location instead. Also adds an ERR trap so its bare assertions
report which check failed rather than aborting silently.
The dependency loop read lddtree through a process substitution, so its exit
status was invisible, and discarded any line that was not an absolute path.
lddtree prints a bare soname for anything it cannot resolve inside the root,
so both a crashed lddtree and an unresolvable DT_NEEDED entry produced an
initramfs missing its libraries. The build succeeded, nothing inspects
initramfs contents, measurements and release tarballs were produced, and
every CVM then died in early boot with "veritysetup: error while loading
shared libraries". The CI workflow already carries a PATH workaround for
exactly this fragility.

Capture lddtree's output and status, reject empty output, and reject any
entry that is not an absolute path inside the image tree.

Also replaces the two 'find | head -1' selections. readdir order made the
chosen binary filesystem-dependent, and under pipefail a second match would
abort the script with exit 141 and no diagnostic.
The prod profile removed only docs, headers and locales, so the production
image kept /usr/bin/login, agetty, loginctl, systemd-logind, every getty unit
and systemd-getty-generator. The base skeleton masks getty@, serial-getty@
and debug-shell with /dev/null symlinks, so there is no live login prompt --
but those masks live in /etc, which dstack-prepare.sh covers with a writable
tmpfs overlay at runtime. meta-dstack's disable_login() instead deletes the
binaries and units outright, so in the Yocto image the login stack is
structurally absent from the verity-measured rootfs rather than disabled by
mutable state. The serial port is a chardev owned by the untrusted host, so
mirror that. Suspend/hibernate units go too, since the kernel now drops
CONFIG_PM entirely.

Verified with mkosi's own parse_ini that the annotated list parses to 40
entries with no comment leakage.

parity.json gains the removed paths plus the dev-profile tools that were
never gated (tcpdump, vim, gdbserver), and check-parity.py no longer accepts
a dangling symlink as a satisfied requirement -- a versioned NVIDIA library
whose target was never installed used to pass and then fail at runtime.
mkosi runs 'systemctl --root preset-all', and systemd's fallback policy for a
unit matching no rule is enable. 80-dstack.preset had 19 enable lines and no
disable, so the effective policy was "start everything with an [Install]
section" -- the inverse of Yocto's opt-in SYSTEMD_SERVICE model. Any package
a future Packages= line pulls in, directly or as a dependency, would run at
boot inside the CVM with no diff to the preset file.

Adds a terminal 99-dstack-default.preset with 'disable *' so the enable list
is exhaustive and auditable.

Also moves 'enable dstack-tee-simulator.service' into a dev-profile skeleton.
It shipped unconditionally in the production skeleton, inert only because the
unit is installed for dev alone -- a guard independent of the preset. A
production image serving synthetic TDX quotes attests successfully while
providing no hardware guarantee.

normalize-skeleton-modes.sh now covers profile skeletons too, guards the
directory loop like the file loop, and skips the skeleton root itself, which
it was chmod'ing as a side effect of an empty relative path.
mount --bind of a freshly created empty tmpfs directory hides the lower
content rather than layering over it, so everything the measured image ships
under /var/cache, /var/lib, /var/log, /var/spool and /var/tmp became
invisible and was replaced by empty, writable, unmeasured directories. The
image demonstrably has content there: Debian's chrony ships /var/lib/chrony,
and the skeleton itself creates /var/lib/docker, /var/lib/containerd and
/var/lib/tpm2-tss/system/keystore. A file added under /var/lib/<component>/
in a later change would look correct in the built image, pass the parity
check, and be inert on every boot.

The bind also took the mode of the empty source directory, turning /var/tmp
from 1777 into 0755.

Use an overlay, as poky's volatile-binds does for the Yocto image and as
dstack-prepare.sh already does for /etc, /usr and /bin. Verified: the bind
leaves 0 of 2 entries visible, the overlay leaves both readable with writes
landing in the tmpfs upper layer.

The unit also becomes RequiredBy=local-fs.target. It was Wanted, so a failed
setup left /var/log and /var/lib on the read-only squashfs and the boot
continued -- a CVM that attests successfully and then silently misbehaves.
versions.env pinned Go 1.22.2 with a SHA-256, but nvidia-container-toolkit
at the pinned revision declares 'go 1.25.0' in go.mod and nothing set
GOTOOLCHAIN, so the go command downloaded go1.25.0 from proxy.golang.org
during the build. The compiler that actually produced nvidia-ctk and
nvidia-container-runtime was therefore neither checksum-verified nor part of
any component cache key, and the build could not run at all with
GOTOOLCHAIN=local or without proxy access.

Bump the pinned toolchain to 1.25.12, which satisfies that requirement and
still builds the sysbox modules that declare go 1.22, and set
GOTOOLCHAIN=local so a future go.mod bump fails loudly instead of silently
fetching a different compiler.

Also verify toolchain downloads before publishing them under the cached
name. A truncated transfer was cached as-is and the mismatch path did not
remove it, so every later build failed identically with no way to tell that
the fix was to delete the file.
The gate matched only ^CONFIG_[A-Z0-9_]+=(y|m|n)$ and skipped everything
else, so two of the fragment's assertions were never enforced: a dropped
CONFIG_LOCALVERSION -- which names the module directory that depmod,
modules_install and make-release-artifacts.sh all resolve -- and a clamped
CONFIG_NR_CPUS both passed. Verified against the previous implementation:
both perturbations are accepted by it and rejected now.

An unparsable fragment line is now an error rather than a silent skip, the
mismatch message reports the value actually found, and a fragment that
yields implausibly few assertions fails instead of trivially passing.

A symbol that disappears entirely because its dependencies went away is now
accepted for want=n, which previously produced a spurious failure on kernel
bumps: absent and 'is not set' both mean not enabled.
…centrally

The build applied 2 of the 6 patches meta-dstack's dstack-ovmf_git.bb uses.
0001/0002 are legitimately bitbake-specific, but two were dropped silently:

0003-Debug-prefix-map threads ENV(GCC_PREFIX_MAP)/ENV(NASM_PREFIX_MAP) into
the tool definitions. It was replaced by an ad-hoc sed that only reproduced
the -Werror overrides and dropped the prefix map entirely.

0005-UefiCpuPkg backports edk2 9ccf8751 for NASM 3.x. The recipe records that
stable202502 must be kept for the pre202505 measurement layout and does not
assemble with NASM 3.x without this, so the next snapshot bump carrying nasm
3.x would have turned the build into a hard failure with the fix sitting
unused in-tree. The encoding is unchanged on NASM 2.x, so applying it also
brings this build closer to the production one.

NASM_PREFIX_MAP stays empty: Yocto carries a nasm patch adding
--debug-prefix-map, and the Debian snapshot ships stock nasm, which rejects
the option (confirmed against nasm 2.16.01).

The revision also moves to versions.env as OVMF_REVISION -- it was the only
component pin hardcoded in a build script -- and acceptance.sh now reads
SRCREV out of the recipe instead of restating the hash, which would have
happily pinned a stale value after a Yocto bump.
Verified against the installed mkosi 26 rather than from memory.

Output= shared the release name, so mkosi tracked assemble.sh's release
tarball as one of its own outputs -- something clean and --force are entitled
to delete -- and mkosi.postoutput had to delete mkosi's convenience symlink
by hand. Give mkosi a name of its own, skip compressing an artifact that is
discarded anyway, and drop it explicitly in the staging directory before it
is promoted. That also fixes the dev flavor leaving a full unmeasured Debian
rootfs tar beside the release, which only the prod name collision had hidden.

%i and %v expand in file order, so ImageId/ImageVersion now precede Output=;
declaring Output first silently yields "--rootfs.tar". acceptance.sh checks
both the ordering and that ImageVersion matches DSTACK_VERSION.

mkosi.skeleton/etc/apt/apt.conf.d/99dstack-reproducible was dead: mkosi runs
apt from outside the image, so per its own manual a package manager config
supplied via SkeletonTrees= never takes effect, and it passes these as -o
options which outrank config files anyway. All three settings are already
covered natively by Snapshot= and WithRecommends=.

SkeletonTrees= and PostInstallationScripts= restated mkosi's auto-discovery
and made the skeleton mount twice. Bootloader= is unreachable with
Bootable=no, SecureBoot=no is the default, and SplitArtifacts= only gates
UKI/kernel paths that Bootable=no already disables.

KernelCommandLine= was dead for the same reason and contradicted the command
line actually measured into the UKI (panic=30 vs panic=1, a console=tty0 that
cannot work with CONFIG_VT=n, and none of the hardening tokens).

mkosi clean ran without the build's --output-directory, so mkosi.clean was
removing from os/mkosi/ rather than the build tree.

Also records why the manual depmod plus mtime normalization is load-bearing
for repro-check: it is what keeps mkosi's own nondeterministic depmod from
firing.
…staging

build.sh recorded git rev-parse HEAD with no dirty detection while
BuildSources= mounts and compiles the working tree, so uncommitted changes
produced an image whose guest agent and measured metadata.json both claimed a
clean upstream revision. Refuse to build from a dirty worktree, with
DSTACK_ALLOW_DIRTY=1 to opt in and record -modified instead.

sysbox checkouts never ran git clean -qfdx, unlike every other component, so
a revision bump in an existing work directory carried the previous vendor/
tree and the copied-in generated protobuf sources into the new binaries. Its
subuid/subgid writes also appended to a reused staging tree, accumulating
duplicate entries that some userns tooling treats as a parse error.

nvattest builds in a fixed path under a world-writable parent; flock
serializes but does not establish ownership. Create the directory instead of
adopting it, so a leftover or attacker-created tree aborts the build.

component_run's bare assertions killed the whole build with no output when a
component descriptor was malformed; they now say which file and which symbol.

The sshd drop-in restores PermitEmptyPasswords and PubkeyAuthentication from
meta-dstack's version. Both match sshd's compiled defaults, but this file is
the image's only ssh policy and should not depend on them.

write-source-manifest.py uses env python3 like every other script; build.sh
invokes it directly, so the absolute path broke on hosts without
/usr/bin/python3.
make-release-artifacts.sh baked the command line into the UKI while
assemble.sh independently reconstructed the same string for metadata.json.
Both feed measurements -- the UKI's PE sections for GCP and AWS, metadata.json
for the TDX RTMRs via dstack-mr -- so a token added to one would have produced
measurements describing a command line the guest never booted with, and every
existing check would still have passed.

Move it to os/image/kernel-cmdline.sh and have both call it. Verified the
shared definition is byte-identical to both previous strings, so no
measurement changes.

acceptance.sh also gains the checks that were missing: mkosi.postinst was
absent from the bash -n list, so a syntax error there only surfaced part way
into a ~30 minute build, and none of the Python helpers were compile-checked
at all.
The job checked out a hardcoded BASELINE_REVISION, so it always rebuilt that
one commit regardless of the dispatched ref and could never validate the
branch it ran on -- and once this branch is squashed or rebased the pinned
commit becomes unreachable and the checkout fails outright. It was also
workflow_dispatch-only, so no PR or push exercised os/mkosi at all.

Add a static job that runs build.sh lint on every PR and master push touching
os/. It finishes in seconds and covers the whole static contract, which is
where the useful per-change signal is; the ~40 minute image build stays
opt-in behind workflow_dispatch.

The baseline hashes embed the revision they were produced from, so the
comparison now runs only when the built commit is that revision and prints an
actionable notice otherwise, instead of comparing against values that cannot
match.

Also makes the release-member checks assert something: they piped
sha256sum.txt and metadata.json to stdout without inspecting them. The
DSTACK_DEV_CACHE_DIR check was vacuous since the workflow never sets it, and
the run blocks now use set -euo pipefail.
…ensing

repro-check computed JOBS once and ran both legs with it, so output that
depends on parallelism could never be detected. That failure mode is not
hypothetical: the kernel component carries a pahole wrapper solely because
its BTF encoder varied with Kbuild's job count, and nothing equivalent guards
the OVMF, ZFS or NVIDIA builds. The legs now differ in job count as well as
in build path, overridable with REPRO_JOBS_B.

The REUSE annotation covering os/mkosi/** used precedence = "override", so
any patch backported from an upstream project would be relabelled Apache-2.0
under Phala copyright regardless of its actual license. Drop the override,
move the entry next to the Yocto backend it parallels (nothing here is a
generated file), and add real SPDX headers to the systemd units, network and
sshd drop-ins, kernel fragment and gitignore, which all support comments.

Verified with reuse lint: 1051/1051 files, compliant with REUSE 3.3.
Drops the hardcoded toolchain versions that duplicated versions.env, records
GOTOOLCHAIN=local, and describes the repro-check job-count variation.

Rewrites the acceptance paragraph, which claimed QEMU boot, /proc/config.gz
inspection and service-enablement checks as things the backend verifies. None
of those are automated; build.sh lint is a source-level static check and only
the byte-for-byte comparison runs in CI, behind a manual dispatch. Says so.

Also corrects the output description: the dev flavor emits dstack-dev-<version>/,
not the same directory name as prod.
The first real build stopped at the config gate, which is what it is for.
Three of the four assertions came from meta-dstack fragments that are
silently ineffective there, because Yocto warns on an unsatisfied fragment
line instead of failing:

  HOTPLUG_CPU=n        is "def_bool y depends on SMP" on x86, so it cannot be
                       turned off while SMP is on.
  BLK_DEV_THROTTLING_LOW=y  no longer exists; the symbol was removed upstream.
  BRIDGE_NF_EBTABLES=y depends on BRIDGE, which is a module in both builds, so
                       only =m is reachable. Restores the original value.

The fourth is a real gap that had to be closed rather than dropped:
DEBUG_FS=n is required by the production hardening audit, but
x86_64_defconfig enables BLK_DEV_IO_TRACE, which selects DEBUG_FS. blktrace
has no role in a production guest, so disable it. Checked every symbol that
selects DEBUG_FS in this kernel; it is the only one defconfig enables.

Verified by generating the real .config from linux-6.18.40 with defconfig plus
this fragment: the gate passes and KEXEC, KEXEC_FILE, HIBERNATION, DEBUG_FS,
PROC_KCORE, PM, SUSPEND and BLK_DEV_IO_TRACE are all off with STRICT_DEVMEM
on. PM_SLEEP disappears entirely, which the gate now tolerates for want=n.
…tArtifacts

Both found by the first full build, which produced and verified every release
artifact and then failed on two problems introduced by the previous commits.

build_image() calls print_output_size() on its own output after the postoutput
scripts have run and after staging is promoted, so deleting the rootfs tar in
mkosi.postoutput made mkosi die with FileNotFoundError once the release was
already complete. Remove it from build.sh after mkosi returns instead.

Dropping SplitArtifacts= was wrong. The default is [uki, kernel, initrd] and
the kernel split is produced even with Bootable=no -- it copies the kernel out
of the rootfs -- so a stray dstack-0.6.0-rootfs.vmlinuz appeared beside the
release artifacts. Restore the empty value with the reason recorded.

acceptance.sh gains guards for both, checked in each direction.
1.25.12 was chosen as the lowest version satisfying nvidia-container-toolkit's
go.mod. oe-core at the revision this repo pins ships go-binary-native_1.26.2,
so the production backend compiles the same Go components -- the container
stack and sysbox -- with 1.26.2. Use the same toolchain rather than a
different one that merely satisfies the constraint.

It is also newer than every 'go' directive among the pinned components, which
reproduces the property that makes Yocto safe without GOTOOLCHAIN at all: an
automatic toolchain switch is never requested. GOTOOLCHAIN=local stays as the
explicit backstop, because mkosi builds with WithNetwork=yes throughout and
has no equivalent of BitBake's network-free do_compile.

The checksum is the one in oe-core's go-binary-native recipe, cross-checked
against go.dev.
repro-check's second leg failed with "Your local changes to the following
files would be overwritten by checkout" in openssl's gost-engine submodule.
The recursive shallow update had partially completed, leaving that submodule
dirty, and the retry loop only re-synced URLs -- so every subsequent attempt
failed on the leftover state rather than retrying the original transient
error, and the build aborted after three identical failures.

Pass --force so a submodule whose working tree drifted from the pinned commit
is checked out regardless, and reset and clean the submodules before each
retry so a retry actually starts from a known state.

The first leg had already completed, so this was fragility in the fetch, not
a reproducibility difference.
meta-dstack installs tee-simulator.conf as a dstack-prepare.service drop-in.
The mkosi backend copied the same file to /etc/tee-simulator.conf, where
systemd never looks, so the dev image silently lost the Requires=/After=
ordering that makes dstack-prepare wait for the simulator to publish the TEE
ABI first. Install it under the drop-in directory and pin both its presence in
dev and its absence from prod in the parity manifest.
The dev-only TEE simulator spawns swtpm to back the vTPM it exposes, which is
why meta-dstack's dstack-tee-simulator recipe carries it in RDEPENDS. The mkosi
dev profile never installed it, so the simulator's TPM backend could only fail
at runtime. Add the package and pin it in the parity manifest; the recipe's two
other runtime deps, openssl and tpm2-tools, already arrive through
ca-certificates and the base package list.
@kvinwang
kvinwang force-pushed the feat/experimental-mkosi-os branch from 3fac846 to ef5428d Compare July 27, 2026 02:00
kvinwang added 3 commits July 26, 2026 19:19
The workflow only ever compared a build against three hardcoded baseline
hashes that go stale on the next commit, so it verified nothing on any
revision but one and published nothing ever.

Make it the mkosi backend's release path, mirroring guest-os.yml for the Yocto
backend. Publishing is bound to the tag: pushing mkosi-os-v<version> is the
only event that creates a GitHub Release, so a workflow_dispatch dry run cannot
be mistaken for one. The push trigger deliberately carries no paths filter,
because GitHub ANDs path and ref filters and would silently drop a release tag
placed on a commit that does not touch os/**.

Report the hashes operators actually need, led by os_image_hash: the log gets a
::notice, the job summary a table plus the sha256sum.txt preimage and
metadata.json, and image-hashes.txt ships as a release asset. The values are
read back out of the published tarball rather than the staging tree, and
digest.txt is recomputed from sha256sum.txt and cross-checked against the UKI
package, so a corrupt release fails the build instead of being published.

The release job rejects a tag whose version disagrees with versions.env or
whose measured metadata.json records a different git revision than the tagged
commit, which also catches a dirty-worktree build.

Replace the rotting baselines with build.sh repro-check, available as an opt-in
dispatch input: two legs, differing job counts, byte-for-byte comparison, no
hash to keep refreshing.
These files entered the branch through commits that were never pushed, so CI
had not seen them until the rebase and the whole prek job failed on the branch.

The two formatting fixes are what the hooks produce on their own. The
shellcheck findings are noise rather than defects: the prek hook runs without
-x, so an existing `source=` directive documents the target but cannot be
followed, and the single-quoted greps in acceptance.sh match a literal
$BUILDDIR in a generated script rather than expanding a variable. Suppress each
one at its site with the reason recorded.
Two workflows now build guest images, and both showed up in the Actions list
and the PR check list as "Build Guest Images". Qualify the Yocto one the same
way the mkosi one already is, so the run a reader is looking at is identifiable
without opening it.
@kvinwang
kvinwang merged commit d78bd37 into master Jul 27, 2026
18 checks passed
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.

2 participants