xcp-ng-ce-iso
ISO assembly pipeline: takes the community RPM builds and releases a bootable XCP-hl ISO.
Repository: Vagrantin/xcp-ng-ce-iso · Language: Bash / YAML · License: AGPL-3.0
Purpose
This repo takes the signed xo-lite-ce RPM produced by
xolite-ce and the signed xoa-proxy RPM produced by
xoa-proxy, overlays them onto a stock XCP-ng 8.3 base using the
official create-install-image
toolchain, and publishes the resulting ISO as a GitHub Release.
Toolchain, create-install-image
XCP-ng’s official installer ISO is assembled with the create-install-image
toolchain. XCP-hl uses it directly rather than maintaining a fork.
The toolchain provides two scripts:
| Script | Runs as | Produces |
|---|---|---|
create-installimg.sh | root | install.img (SquashFS ramdisk) |
create-iso.sh | non-root | final .iso file |
They must be run separately, in order. create-iso.sh accepts a
--sign-script argument for MD5 imprinting.
Repo structure
xcp-ng-ce-iso/
├── configs/
│ ├── base/
│ │ └── CUSTOMREPO.tmpl ← community repo template
│ └── 8.3/ ← XCP-ng 8.3 config (target)
├── community-repo/
│ └── x86_64/
│ └── repodata/ ← generated by createrepo_c
├── scripts/
│ └── debug/ ← debug helper scripts (overlaid in CI)
├── Dockerfile.build ← build environment
└── .github/workflows/
└── build-iso.ymlCUSTOMREPO.tmpl
The community repo definition template must be copied from configs/base/
to configs/8.3/ before the build runs:
cp configs/base/CUSTOMREPO.tmpl configs/8.3/CUSTOMREPO.tmplThis file tells the installer where to find the community RPM repo. The
--define-repo flag passes it to both build scripts.
Community RPM repository layout
yum automatically appends the host architecture (x86_64/) to the base
URL configured in a repo file. Therefore the community repo must be structured
with repodata/ under the arch subdirectory:
community-repo/
└── x86_64/
├── xo-lite-ce-*.rpm
├── xoa-proxy-*.rpm
├── xcp-hl-release-*.rpm
└── repodata/
├── repomd.xml
└── ...Generate the repo metadata against the arch subdirectory:
createrepo_c community-repo/x86_64/How packages reach the installed host
install.img is the installer’s own ramdisk, not the installed host’s
filesystem. Adding a package to packages.lst puts it in the installer
environment and nowhere else.The installed host is built by host-installer from the ISO’s Packages/
directory, and it installs xcp-ng-deps plus that package’s dependency
closure. Staging an RPM on the media is therefore not enough on its own: if
nothing in the closure requires it, it sits in the ISO repo unused.
The community packages ride in on this chain:
xcp-ng-deps
└── requires xo-lite ──► provided by xo-lite-ce
├── requires xoa-proxy
└── requires xcp-hl-releaseThat Requires: is the only route in. xcp-hl-release used to be staged by
name with --extra-packages as well, but that flag only places a package in
the ISO’s repo without selecting it for install, so once the dependency existed
it was redundant and only obscured which mechanism delivered the package.
A consequence worth knowing: pinning an xolite-ce release from before the
Requires: was added produces an ISO with no xcp-hl-release on it. The
build’s verification step catches that rather than shipping it.
Verify on a host installed from the ISO:
rpm -q xcp-hl-release
ls /etc/yum.repos.d/xcp-hl.repoDocker build environment
The build runs inside xcp-ng-build-env:8.3, committed as
xcp-ng-build-ready after initial setup:
# First run: commit the prepared image
docker run --name xcpng-build xcp-ng/xcp-ng-build-env:8.3 /bin/true
docker commit xcpng-build xcp-ng-build-readyBuild process, step by step
1. Prepare the community repo
# Download signed RPM from xolite-ce latest release
gh release download --repo Vagrantin/xolite-ce \
--pattern "xo-lite-ce-*.rpm" \
--dir community-repo/x86_64/
# Download signed RPM from xoa-proxy latest release
gh release download --repo Vagrantin/xoa-proxy \
--pattern "xoa-proxy-*.rpm" \
--dir community-repo/x86_64/
# Generate repo metadata
createrepo_c community-repo/x86_64/2. Inject the community public key into the installer chroot
The ISO signing subkey (GPG_PRIVATE_KEY in this repo) is imported into the
runner’s keyring at the start of the pipeline. The public half is then
exported from that keyring and injected into the installer chroot templates,
so the installer can verify the community RPMs during installation without any
manual key import by the end user.
rpm --import inside a container writes to the container’s RPM database,
not to the chroot installroot that ends up in install.img. Always pass
--root to target the correct location.# Export the public key from the runner keyring
gpg --armor --export "${GPG_KEY_ID}" > /tmp/RPM-GPG-KEY-xcp-ng-ce
# Copy into the toolchain's installroot template
mkdir -p create-install-image/templates/installimg/base/etc/pki/rpm-gpg/
cp /tmp/RPM-GPG-KEY-xcp-ng-ce \
create-install-image/templates/installimg/base/etc/pki/rpm-gpg/
# Import into the installer chroot at build time
rpm --root="${ROOTFS}" --import /etc/pki/rpm-gpg/RPM-GPG-KEY-xcp-ng-ce3. Run create-installimg.sh (root)
# Must run as root inside the build container
sudo ./create-installimg.sh \
--define-repo base \
--define-repo updates \
--define-repo community \
[other options]The --define-repo flag must list all three repos: base, updates, and
community.
4. Stamp the ISO build number
host-installer reads [build] number from the media’s .treeinfo and writes
it to BUILD_NUMBER in /etc/xensource-inventory on the installed host.
Upstream ships the placeholder cloud, and nothing else on an installed host
records which media it came from: the volume label does not survive the
install, and the logs kept in /var/log/installer/ do not mention it.
Stamp the ce counter into the template before create-iso.sh copies it into
the ISO. Only @@...@@ tokens get substituted, so a literal value passes
through untouched:
sed -i "s/^number = .*/number = ${CE_COUNTER}/" \
create-install-image/templates/iso/8.3/.treeinfoOn a host installed from the resulting ISO:
$ grep BUILD_NUMBER /etc/xensource-inventory
BUILD_NUMBER='ce23'
# xapi reads that inventory, so it is visible remotely too
$ xe host-param-get uuid=<host-uuid> param-name=software-version
... build_number: ce23; ...5. Run create-iso.sh (non-root)
./create-iso.sh \
--sign-script implantisomd5 \
--define-repo base \
--define-repo updates \
--define-repo community \
[other options]6. isohybrid post-processing
isohybrid --uefi output.isoVerify the result with:
fdisk -l output.iso
xorriso -report_el_torito output.iso7. Checksum and sign
Each release ships three files alongside the ISO. The checksum file is named after the ISO it covers:
xcp-ng-ce-8.3.iso
xcp-ng-ce-8.3.iso.sha256
xcp-ng-ce-8.3.iso.sha256.ascThe checksum file contains the SHA256 hash of the ISO. The .asc file is a
GPG detached signature over the checksum file, produced with the ISO signing
subkey. Together they form a two-step verification chain:
ISO signing subkey
└── signs ──► xcp-ng-ce-8.3.iso.sha256 (contains hash of the ISO)
└── hash matches ──► xcp-ng-ce-8.3.iso# Produce the checksum file
sha256sum xcp-ng-ce-8.3.iso > xcp-ng-ce-8.3.iso.sha256
# Sign it with the ISO signing subkey
gpg --batch --pinentry-mode loopback \
--detach-sign --armor \
xcp-ng-ce-8.3.iso.sha256
# → produces xcp-ng-ce-8.3.iso.sha256.asc8. Verifying a release (end user procedure)
sha256sum -c looks for the ISO by filename in the current
directory.# 1. Download all three files into the same directory
cd ~/Downloads
# download xcp-ng-ce-8.3.iso, xcp-ng-ce-8.3.iso.sha256,
# xcp-ng-ce-8.3.iso.sha256.asc from the GitHub release page
# 2. Import the community GPG key (one-time setup)
gpg --keyserver keys.openpgp.org \
--recv-keys 2F591DB9D2C128C4C3D963F46DA00DCA5BBA215AStep 3: verify the checksum file was signed by this project:
gpg --verify xcp-ng-ce-8.3.iso.sha256.asc \
xcp-ng-ce-8.3.iso.sha256Expected output (the important line is Good signature):
gpg: Signature made ...
gpg: Good signature from "XCP-ng home lab Edition <xcp-ng-ce.lid530@passmail.com>"If you see BAD signature the checksum file has been tampered with. Do not
proceed.
Step 4: verify the ISO matches the signed checksum:
sha256sum -c xcp-ng-ce-8.3.iso.sha256Expected output:
xcp-ng-ce-8.3.iso: OKIf you see FAILED the ISO file is corrupted or was replaced. Delete it and
re-download.
install.img internals
install.img is a bzip2-compressed cpio (newc) archive, not SquashFS.
create-installimg.sh builds it with find . | cpio -o -H newc | bzip2, so
unsquashfs/mksquashfs do not apply.It holds the installer’s ramdisk, not the installed host’s filesystem; see How packages reach the installed host.
Unpacking and repacking
# Unpack
mkdir installimg-root
bzip2 -dc install.img | (cd installimg-root && cpio -idm)
# Make changes inside installimg-root/
# ...
# Repack (matching upstream: cpio newc, bzip2)
(cd installimg-root && find . | cpio -o -H newc) | bzip2 > install.img.newList the contents without unpacking:
bzip2 -dc install.img | cpio -ithost-installer patching for the ISO storage partition
XCP-hl reserves a 20 GB ISO partition at install time, which means changing
host-installer itself. That is done by patching its files inside
install.img at build time, not by shipping a forked host-installer RPM.
The reason is that xo-lite-ce can win dependency resolution by providing a
virtual capability (Provides: xo-lite), but host-installer is a real
package that already exists in XCP-ng’s base/updates repos. Publishing a
same-named replacement would need an epoch bump on a live-host-facing
channel, and there is nowhere safe to put it. Patching the ramdisk keeps the
change confined to the installer environment, which is discarded once the
install finishes.
Two patches in xcp-ng-ce-iso/patches/ drive it, applied by the
Patch toolchain with git CI step:
| Patch | Target | Purpose |
|---|---|---|
installer-iso-sr-hook.patch | scripts/create-installimg.sh | Adds a patch -p1 -d "$ROOTFS/opt/xensource/installer" step after the yum install, beside the existing installer-branding block |
host-installer-iso-sr.patch | $ROOTFS/opt/xensource/installer/ | The change itself: partitioning, mkfs, fstab, inventory, plus the first-boot script and unit |
The second patch is generated from the
host-installer fork
(branch feat/xcp-hl-iso-storage), where the change is maintained as real
commits against upstream and can be run against its own test/ suite:
cd host-installer
git diff 10.10.38-8.3..feat/xcp-hl-iso-storage \
-- backend.py constants.py answerfile.py xcp-hl/ \
> ../xcp-ng-ce-iso/patches/host-installer-iso-sr.patchRegenerate it whenever that branch is rebased onto a newer upstream tag. A
patch that no longer applies fails the build loudly rather than silently
shipping an unpatched installer, and a later CI step unpacks the built
install.img and greps backend.py to confirm the change actually landed.
What the patch does. constants.py gains iso_size (20480 MB) and
min_primary_disk_size_with_iso (100 GB); backend.py adds an ISO entry
to the partition table it lays out, sized and created immediately before the
LVM partition so the local SR still claims the tail of the disk, formats it
ext4 with a fixed xcphl-iso label, writes an fstab entry and an
ISO_PARTITION inventory key, and drops a first-boot script plus systemd
unit onto the target root.
Registering the SR needs XAPI, which only runs on the installed host, so
that half happens at first boot, modelled on storage-init.service, which
creates the primary local SR the same way. Re-attaching the SR after later
reboots needs no help from us: XAPI plugs every unplugged PBD at startup
(Create_storage.plug_unplugged_pbds), typically ~10 s after its API comes
up.
answerfile.xml injection (optional)
answerfile.xml referenced via file:/// in the installer resolves to the
ramdisk root (from install.img), not the ISO CD-ROM root. To inject an
answerfile for unattended installs, place it inside install.img via the
repack workflow above.
Disable GPG-related checks via answerfile attributes:
<installation gpgcheck="false" repo-gpgcheck="false">
...
</installation>CI workflow (GitHub Actions)
The workflow is dispatch-only. The iso-agent pushes the release tag and
then dispatches the workflow on that tag ref, passing the exact component
release tags to bake in. A plain tag push no longer triggers a build: resolving
components via releases/latest raced with freshly published component
releases and could bake a stale RPM.
Key environment requirements:
env:
TMPDIR: /tmp # Must be set here AND in Dockerfile
HOME: /tmp # Required for non-root create-iso.sh stepKey steps:
- name: Derive versions from tag
run: |
TAG="${{ github.ref_name }}" # e.g. v8.3-ce4
XCPNG_VER=$(echo "${TAG}" | sed 's/^v//;s/-ce.*//') # → 8.3
echo "XCPNG_VER=${XCPNG_VER}" >> $GITHUB_ENV
- name: Import ISO signing key
# GPG_PRIVATE_KEY in this repo holds the ISO signing subkey,
# different key material from GPG_PRIVATE_KEY in xolite-ce / xoa-proxy
run: |
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch \
--pinentry-mode loopback \
--passphrase "${{ secrets.GPG_PASSPHRASE }}" --import
- name: Export public key for installer chroot
run: |
gpg --armor --export "${GPG_KEY_ID}" > /tmp/RPM-GPG-KEY-xcp-ng-ce
cp /tmp/RPM-GPG-KEY-xcp-ng-ce \
create-install-image/templates/installimg/base/etc/pki/rpm-gpg/
- name: Set up Docker build environment
run: docker build -t xcp-ng-build-ready -f Dockerfile.build .
- name: Download xolite-ce RPM
run: |
gh release download --repo Vagrantin/xolite-ce \
--pattern "xo-lite-ce-*.rpm" \
--dir community-repo/x86_64/
- name: Download xoa-proxy RPM
run: |
gh release download --repo Vagrantin/xoa-proxy \
--pattern "xoa-proxy-*.rpm" \
--dir community-repo/x86_64/
- name: Prepare community repo
run: createrepo_c community-repo/x86_64/
- name: Copy CUSTOMREPO.tmpl
run: cp configs/base/CUSTOMREPO.tmpl configs/8.3/
- name: Run create-installimg.sh (root)
run: |
docker run --rm --privileged \
-v $PWD:/build \
-v $PWD/create-install-image:/create-install-image \
xcp-ng-build-ready \
bash -c "cd /create-install-image && sudo ./create-installimg.sh ..."
- name: Run create-iso.sh (non-root)
run: |
docker run --rm \
-e HOME=/tmp \
-v $PWD:/build \
xcp-ng-build-ready \
bash -c "cd /create-install-image && ./create-iso.sh ..."
- name: Restore working directory ownership
run: sudo chown -R $(id -u):$(id -g) .
- name: isohybrid post-processing
run: isohybrid --uefi output.iso
- name: implantisomd5
run: implantisomd5 output.iso
- name: Generate and sign checksum
run: |
CHECKSUM_FILE="${OUTPUT}.sha256"
sha256sum "${OUTPUT}" > "${CHECKSUM_FILE}"
echo "CHECKSUM_FILE=${CHECKSUM_FILE}" >> $GITHUB_ENV
gpg --batch --pinentry-mode loopback \
--detach-sign --armor "${CHECKSUM_FILE}"
gpg --verify "${CHECKSUM_FILE}.asc" "${CHECKSUM_FILE}"
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
${{ env.OUTPUT }}
${{ env.CHECKSUM_FILE }}
${{ env.CHECKSUM_FILE }}.asc
- name: Remove GPG private key
if: always()
run: shred -u /tmp/community-signing.key || truecreate-install-image.
Verify the mount target matches the actual cloned directory name.Contributing
To report a problem or suggest a change, open an issue on Vagrantin/xcp-hl.