Repository: osbuild/osbuild-composer
Pull request #656 (2020-05-21 12:58:32)

Add Fedora IoT support
This adds support for generating Fedora IoT commit tarballs for Fedora 32. See individual commits for details.

I had a short discussion with @jgiardino about this, and she suggested that we could handle the required ostree-specific options in the same way as we handle image size, for now. That is, in the cockpit-composer UI, the user would can specify the parent commit and the ostree ref in the same place they select the image type (where the image size is currently configured).

Does that make sense to you @jkozol?

jkozol: > That is, in the cockpit-composer UI, the user would can specify the parent commit and the ostree ref in the same place they select the image type

@teg this makes sense to me. I am a little unsure where the `parent commit` and `ostree ref` are derived from. Is the `parent commit` a commit in an ostree that the `ostree ref` points at and then composer will commit on top of the `parent commit`?
teg: > I am a little unsure where the `parent commit` and `ostree ref` are derived from. Is the `parent commit` a commit in an ostree that the `ostree ref` points at and then composer will commit on top of the `parent commit`?

Yes, and no. I think @gicmo can explain this the best. But essentially, how it works is this:

We will produce a tarball, containing a ostree repository, containing exactly one commit.

This commit can then be pulled (by others) into an existing repository, and in that case, we need to declare in the commit we make, what the parent commit sha is, so this is all stitched together correctly.

Similarly, the ref is the branch our commit would be pulled onto.

None of this is actually done by composer, but we need to include the information in the produced tarball so the right thing happens when someone does the pull.
jgiardino: Here is a rough mockup of what Tom describes. You can also see this example in codesandbox: https://codesandbox.io/s/image-parent-commit-joww9?file=/index.js

Some comments and questions I have:

1. What's the best label to use for `ostree ref`? I used "Branch" for now based on Tom's explanation, but I'm not sure if this is the best label.
2. These additional fields should include help text, which is not currently shown in the mockup. Here's an initial draft: 
    - Parent commit - Provide the SHA of the previous commit. This new commit will provide the update for the commit that you specify as the is the parent commit.
        - Does anyone have an example of a SHA that we can include with the help text?
    - Branch - 🤷 
3. We currently display image size for all image types, but does that make sense for this one?

<img width="720" alt="image" src="https://user-images.githubusercontent.com/21063328/82711652-f7b0d000-9c53-11ea-9ac5-f762c4f8141d.png">

teg: > Here is a rough mockup of what Tom describes.

Thanks Jenn!

> 3. We currently display image size for all image types, but does that make sense for this one?

It does not. It also makes no sense for the current Tar image type.

teg: I updated the PR to rename the image type internally to "fedora-iot-commit" from "iot-commit". This is ready for review/merge, as far as I'm concerned.
teg: Ah, and let's continue the UI discussion of course, either here or in a "cockpit-composer" PR/issue.

Patch:
diff --git a/cmd/osbuild-pipeline/main.go b/cmd/osbuild-pipeline/main.go
index 572621285..15dc7a34b 100644
--- a/cmd/osbuild-pipeline/main.go
+++ b/cmd/osbuild-pipeline/main.go
@@ -75,8 +75,8 @@ func main() {
 		panic(err)
 	}
 
-	distro := distros.GetDistro(composeRequest.Distro)
-	if distro == nil {
+	d := distros.GetDistro(composeRequest.Distro)
+	if d == nil {
 		_, _ = fmt.Fprintf(os.Stderr, "The provided distribution '%s' is not supported. Use one of these:\n", composeRequest.Distro)
 		for _, d := range distros.List() {
 			_, _ = fmt.Fprintln(os.Stderr, " *", d)
@@ -84,10 +84,10 @@ func main() {
 		return
 	}
 
-	arch, err := distro.GetArch(composeRequest.Arch)
+	arch, err := d.GetArch(composeRequest.Arch)
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "The provided architecture '%s' is not supported by %s. Use one of these:\n", composeRequest.Arch, distro.Name())
-		for _, a := range distro.ListArches() {
+		fmt.Fprintf(os.Stderr, "The provided architecture '%s' is not supported by %s. Use one of these:\n", composeRequest.Arch, d.Name())
+		for _, a := range d.ListArches() {
 			_, _ = fmt.Fprintln(os.Stderr, " *", a)
 		}
 		return
@@ -95,7 +95,7 @@ func main() {
 
 	imageType, err := arch.GetImageType(composeRequest.ImageType)
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "The provided image type '%s' is not supported by %s for %s. Use one of these:\n", composeRequest.ImageType, distro.Name(), arch.Name())
+		fmt.Fprintf(os.Stderr, "The provided image type '%s' is not supported by %s for %s. Use one of these:\n", composeRequest.ImageType, d.Name(), arch.Name())
 		for _, t := range arch.ListImageTypes() {
 			_, _ = fmt.Fprintln(os.Stderr, " *", t)
 		}
@@ -134,13 +134,13 @@ func main() {
 	}
 
 	rpmmd := rpmmd.NewRPMMD(path.Join(home, ".cache/osbuild-composer/rpmmd"))
-	packageSpecs, checksums, err := rpmmd.Depsolve(packages, excludePkgs, repos, distro.ModulePlatformID(), arch.Name())
+	packageSpecs, checksums, err := rpmmd.Depsolve(packages, excludePkgs, repos, d.ModulePlatformID(), arch.Name())
 	if err != nil {
 		panic("Could not depsolve: " + err.Error())
 	}
 
 	buildPkgs := imageType.BuildPackages()
-	buildPackageSpecs, _, err := rpmmd.Depsolve(buildPkgs, nil, repos, distro.ModulePlatformID(), arch.Name())
+	buildPackageSpecs, _, err := rpmmd.Depsolve(buildPkgs, nil, repos, d.ModulePlatformID(), arch.Name())
 	if err != nil {
 		panic("Could not depsolve build packages: " + err.Error())
 	}
@@ -157,7 +157,13 @@ func main() {
 			panic(err)
 		}
 	} else {
-		manifest, err := imageType.Manifest(composeRequest.Blueprint.Customizations, repos, packageSpecs, buildPackageSpecs, imageType.Size(0))
+		manifest, err := imageType.Manifest(composeRequest.Blueprint.Customizations,
+			distro.ImageOptions{
+				Size: imageType.Size(0),
+			},
+			repos,
+			packageSpecs,
+			buildPackageSpecs)
 		if err != nil {
 			panic(err.Error())
 		}
diff --git a/internal/distro/distro.go b/internal/distro/distro.go
index 1d5417b7c..dd4a5cdad 100644
--- a/internal/distro/distro.go
+++ b/internal/distro/distro.go
@@ -79,7 +79,19 @@ type ImageType interface {
 	// Returns an osbuild manifest, containing the sources and pipeline necessary
 	// to build an image, given output format with all packages and customizations
 	// specified in the given blueprint.
-	Manifest(b *blueprint.Customizations, repos []rpmmd.RepoConfig, packageSpecs, buildPackageSpecs []rpmmd.PackageSpec, size uint64) (*osbuild.Manifest, error)
+	Manifest(b *blueprint.Customizations, options ImageOptions, repos []rpmmd.RepoConfig, packageSpecs, buildPackageSpecs []rpmmd.PackageSpec) (*osbuild.Manifest, error)
+}
+
+// The ImageOptions specify options for a specific image build
+type ImageOptions struct {
+	OSTree OSTreeImageOptions
+	Size   uint64
+}
+
+// The OSTreeImageOptions specify ostree-specific image options
+type OSTreeImageOptions struct {
+	Ref    string
+	Parent string
 }
 
 type Registry struct {
diff --git a/internal/distro/fedora30/distro.go b/internal/distro/fedora30/distro.go
index 3bc90c2c7..c51df9ae0 100644
--- a/internal/distro/fedora30/distro.go
+++ b/internal/distro/fedora30/distro.go
@@ -168,11 +168,11 @@ func (t *imageType) BuildPackages() []string {
 }
 
 func (t *imageType) Manifest(c *blueprint.Customizations,
+	options distro.ImageOptions,
 	repos []rpmmd.RepoConfig,
 	packageSpecs,
-	buildPackageSpecs []rpmmd.PackageSpec,
-	size uint64) (*osbuild.Manifest, error) {
-	pipeline, err := t.pipeline(c, repos, packageSpecs, buildPackageSpecs, size)
+	buildPackageSpecs []rpmmd.PackageSpec) (*osbuild.Manifest, error) {
+	pipeline, err := t.pipeline(c, repos, packageSpecs, buildPackageSpecs, options.Size)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/distro/fedora31/distro.go b/internal/distro/fedora31/distro.go
index 59e460a0a..5a87f016e 100644
--- a/internal/distro/fedora31/distro.go
+++ b/internal/distro/fedora31/distro.go
@@ -167,11 +167,11 @@ func (t *imageType) BuildPackages() []string {
 }
 
 func (t *imageType) Manifest(c *blueprint.Customizations,
+	options distro.ImageOptions,
 	repos []rpmmd.RepoConfig,
 	packageSpecs,
-	buildPackageSpecs []rpmmd.PackageSpec,
-	size uint64) (*osbuild.Manifest, error) {
-	pipeline, err := t.pipeline(c, repos, packageSpecs, buildPackageSpecs, size)
+	buildPackageSpecs []rpmmd.PackageSpec) (*osbuild.Manifest, error) {
+	pipeline, err := t.pipeline(c, repos, packageSpecs, buildPackageSpecs, options.Size)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/distro/fedora32/distro.go b/internal/distro/fedora32/distro.go
index 748381db7..37c957b89 100644
--- a/internal/distro/fedora32/distro.go
+++ b/internal/distro/fedora32/distro.go
@@ -2,6 +2,7 @@ package fedora32
 
 import (
 	"errors"
+	"fmt"
 	"sort"
 	"strconv"
 
@@ -44,8 +45,9 @@ type imageType struct {
 	disabledServices []string
 	kernelOptions    string
 	bootable         bool
+	rpmOstree        bool
 	defaultSize      uint64
-	assembler        func(uefi bool, size uint64) *osbuild.Assembler
+	assembler        func(uefi bool, options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler
 }
 
 func (a *arch) Distro() distro.Distro {
@@ -124,6 +126,7 @@ func (a *arch) setImageTypes(imageTypes ...imageType) {
 			disabledServices: it.disabledServices,
 			kernelOptions:    it.kernelOptions,
 			bootable:         it.bootable,
+			rpmOstree:        it.rpmOstree,
 			defaultSize:      it.defaultSize,
 			assembler:        it.assembler,
 		}
@@ -164,15 +167,19 @@ func (t *imageType) BasePackages() ([]string, []string) {
 }
 
 func (t *imageType) BuildPackages() []string {
-	return append(t.arch.distro.buildPackages, t.arch.buildPackages...)
+	packages := append(t.arch.distro.buildPackages, t.arch.buildPackages...)
+	if t.rpmOstree {
+		packages = append(packages, "rpm-ostree")
+	}
+	return packages
 }
 
 func (t *imageType) Manifest(c *blueprint.Customizations,
+	options distro.ImageOptions,
 	repos []rpmmd.RepoConfig,
 	packageSpecs,
-	buildPackageSpecs []rpmmd.PackageSpec,
-	size uint64) (*osbuild.Manifest, error) {
-	pipeline, err := t.pipeline(c, repos, packageSpecs, buildPackageSpecs, size)
+	buildPackageSpecs []rpmmd.PackageSpec) (*osbuild.Manifest, error) {
+	pipeline, err := t.pipeline(c, options, repos, packageSpecs, buildPackageSpecs)
 	if err != nil {
 		return nil, err
 	}
@@ -186,6 +193,65 @@ func (t *imageType) Manifest(c *blueprint.Customizations,
 func New() *Fedora32 {
 	const GigaByte = 1024 * 1024 * 1024
 
+	iotImgType := imageType{
+		name:     "fedora-iot-commit",
+		filename: "commit.tar",
+		mimeType: "application/x-tar",
+		packages: []string{
+			"fedora-release-iot",
+			"glibc", "glibc-minimal-langpack", "nss-altfiles",
+			"sssd-client", "libsss_sudo", "shadow-utils",
+			"kernel",
+			"dracut-config-generic", "dracut-network",
+			"rpm-ostree", "polkit", "lvm2",
+			"chrony", "zram",
+			"cryptsetup", "pinentry",
+			"keyutils",
+			"e2fsprogs", "dosfstools",
+			"gnupg2",
+			"basesystem", "python3", "bash",
+			"xz", "gzip",
+			"coreutils", "which", "curl",
+			"firewalld", "iptables",
+			"NetworkManager", "NetworkManager-wifi", "NetworkManager-wwan",
+			"wpa_supplicant", "iwd",
+			"dnsmasq", "traceroute",
+			"hostname", "iproute", "iputils",
+			"openssh-clients", "openssh-server", "passwd",
+			"policycoreutils", "procps-ng", "rootfiles", "rpm",
+			"selinux-policy-targeted", "setup", "shadow-utils",
+			"sudo", "systemd", "util-linux", "vim-minimal",
+			"less", "tar",
+			"fwupd", // "usbguard", BUG: this fails due to an SELinux policy issue
+			"greenboot", "greenboot-grub2", "greenboot-rpm-ostree-grub2", "greenboot-reboot", "greenboot-status",
+			"ignition",
+			"rsync",
+			"ima-evm-utils",
+			"bash-completion",
+			"tmux", "screen",
+			"policycoreutils-python-utils",
+			"setools-console",
+			"audit", "rng-tools",
+			"bluez", "bluez-libs", "bluez-mesh", "wpan-tools",
+			"kernel-tools", "libgpiod-utils",
+			"podman", "container-selinux", "skopeo", "criu",
+			"slirp4netns",
+			"clevis", "clevis-dracut", "clevis-luks",
+			"attr",
+			// x86 specific
+			"grub2", "grub2-efi-x64", "efibootmgr", "shim-x64", "microcode_ctl",
+			"iwl1000-firmware", "iwl100-firmware", "iwl105-firmware", "iwl135-firmware",
+			"iwl2000-firmware", "iwl2030-firmware", "iwl3160-firmware", "iwl5000-firmware",
+			"iwl5150-firmware", "iwl6000-firmware", "iwl6050-firmware", "iwl7260-firmware",
+		},
+		enabledServices: []string{
+			"NetworkManager.service", "firewalld.service", "rngd.service", "sshd.service", "zram-swap.service",
+		},
+		rpmOstree: true,
+		assembler: func(uefi bool, options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler {
+			return ostreeCommitAssembler(options, arch)
+		},
+	}
 	amiImgType := imageType{
 		name:     "ami",
 		filename: "image.vhdx",
@@ -211,8 +277,8 @@ func New() *Fedora32 {
 		kernelOptions: "ro no_timer_check console=ttyS0,115200n8 console=tty1 biosdevname=0 net.ifnames=0 console=ttyS0,115200",
 		bootable:      true,
 		defaultSize:   6 * GigaByte,
-		assembler: func(uefi bool, size uint64) *osbuild.Assembler {
-			return qemuAssembler("vhdx", "image.vhdx", uefi, size)
+		assembler: func(uefi bool, options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler {
+			return qemuAssembler("vhdx", "image.vhdx", uefi, options)
 		},
 	}
 
@@ -234,7 +300,9 @@ func New() *Fedora32 {
 		kernelOptions: "ro biosdevname=0 net.ifnames=0",
 		bootable:      false,
 		defaultSize:   2 * GigaByte,
-		assembler:     func(uefi bool, size uint64) *osbuild.Assembler { return rawFSAssembler("filesystem.img", size) },
+		assembler: func(uefi bool, options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler {
+			return rawFSAssembler("filesystem.img", options)
+		},
 	}
 
 	partitionedDisk := imageType{
@@ -255,8 +323,8 @@ func New() *Fedora32 {
 		kernelOptions: "ro biosdevname=0 net.ifnames=0",
 		bootable:      true,
 		defaultSize:   2 * GigaByte,
-		assembler: func(uefi bool, size uint64) *osbuild.Assembler {
-			return qemuAssembler("raw", "disk.img", uefi, size)
+		assembler: func(uefi bool, options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler {
+			return qemuAssembler("raw", "disk.img", uefi, options)
 		},
 	}
 
@@ -283,8 +351,8 @@ func New() *Fedora32 {
 		kernelOptions: "ro biosdevname=0 net.ifnames=0",
 		bootable:      true,
 		defaultSize:   2 * GigaByte,
-		assembler: func(uefi bool, size uint64) *osbuild.Assembler {
-			return qemuAssembler("qcow2", "disk.qcow2", uefi, size)
+		assembler: func(uefi bool, options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler {
+			return qemuAssembler("qcow2", "disk.qcow2", uefi, options)
 		},
 	}
 
@@ -310,8 +378,8 @@ func New() *Fedora32 {
 		kernelOptions: "ro biosdevname=0 net.ifnames=0",
 		bootable:      true,
 		defaultSize:   2 * GigaByte,
-		assembler: func(uefi bool, size uint64) *osbuild.Assembler {
-			return qemuAssembler("qcow2", "disk.qcow2", uefi, size)
+		assembler: func(uefi bool, options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler {
+			return qemuAssembler("qcow2", "disk.qcow2", uefi, options)
 		},
 	}
 
@@ -333,7 +401,9 @@ func New() *Fedora32 {
 		kernelOptions: "ro biosdevname=0 net.ifnames=0",
 		bootable:      false,
 		defaultSize:   2 * GigaByte,
-		assembler:     func(uefi bool, size uint64) *osbuild.Assembler { return tarAssembler("root.tar.xz", "xz") },
+		assembler: func(uefi bool, options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler {
+			return tarAssembler("root.tar.xz", "xz")
+		},
 	}
 
 	vhdImgType := imageType{
@@ -368,8 +438,8 @@ func New() *Fedora32 {
 		kernelOptions: "ro biosdevname=0 rootdelay=300 console=ttyS0 earlyprintk=ttyS0 net.ifnames=0",
 		bootable:      true,
 		defaultSize:   2 * GigaByte,
-		assembler: func(uefi bool, size uint64) *osbuild.Assembler {
-			return qemuAssembler("vpc", "disk.vhd", uefi, size)
+		assembler: func(uefi bool, options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler {
+			return qemuAssembler("vpc", "disk.vhd", uefi, options)
 		},
 	}
 
@@ -392,8 +462,8 @@ func New() *Fedora32 {
 		kernelOptions: "ro biosdevname=0 net.ifnames=0",
 		bootable:      true,
 		defaultSize:   2 * GigaByte,
-		assembler: func(uefi bool, size uint64) *osbuild.Assembler {
-			return qemuAssembler("vmdk", "disk.vmdk", uefi, size)
+		assembler: func(uefi bool, options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler {
+			return qemuAssembler("vmdk", "disk.vmdk", uefi, options)
 		},
 	}
 
@@ -422,6 +492,7 @@ func New() *Fedora32 {
 		},
 	}
 	x8664.setImageTypes(
+		iotImgType,
 		amiImgType,
 		ext4FilesystemType,
 		partitionedDisk,
@@ -478,7 +549,7 @@ func sources(packages []rpmmd.PackageSpec) *osbuild.Sources {
 	}
 }
 
-func (t *imageType) pipeline(c *blueprint.Customizations, repos []rpmmd.RepoConfig, packageSpecs, buildPackageSpecs []rpmmd.PackageSpec, size uint64) (*osbuild.Pipeline, error) {
+func (t *imageType) pipeline(c *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSpecs, buildPackageSpecs []rpmmd.PackageSpec) (*osbuild.Pipeline, error) {
 	p := &osbuild.Pipeline{}
 	p.SetBuild(t.buildPipeline(repos, *t.arch, buildPackageSpecs), "org.osbuild.fedora32")
 
@@ -528,6 +599,8 @@ func (t *imageType) pipeline(c *blueprint.Customizations, repos []rpmmd.RepoConf
 	if t.bootable {
 		p.AddStage(osbuild.NewFSTabStage(t.fsTabStageOptions(t.arch.uefi)))
 		p.AddStage(osbuild.NewGRUB2Stage(t.grub2StageOptions(t.kernelOptions, c.GetKernel(), t.arch.uefi)))
+	} else if t.rpmOstree {
+		p.AddStage(osbuild.NewFSTabStage(t.fsOstreeTabStageOptions()))
 	}
 
 	if services := c.GetServices(); services != nil || t.enabledServices != nil {
@@ -540,7 +613,16 @@ func (t *imageType) pipeline(c *blueprint.Customizations, repos []rpmmd.RepoConf
 
 	p.AddStage(osbuild.NewSELinuxStage(t.selinuxStageOptions()))
 
-	p.Assembler = t.assembler(t.arch.uefi, size)
+	if t.rpmOstree {
+		p.AddStage(osbuild.NewRPMOSTreeStage(&osbuild.RPMOSTreeStageOptions{
+			EtcGroupMembers: []string{
+				// NOTE: should this somehow be configurable? How does it relate to regular group options?
+				"wheel", "docker",
+			},
+		}))
+	}
+
+	p.Assembler = t.assembler(t.arch.uefi, options, t.arch)
 
 	return p, nil
 }
@@ -664,6 +746,37 @@ func (r *imageType) fsTabStageOptions(uefi bool) *osbuild.FSTabStageOptions {
 	return &options
 }
 
+func (t *imageType) fsOstreeTabStageOptions() *osbuild.FSTabStageOptions {
+	return &osbuild.FSTabStageOptions{
+		FileSystems: []*osbuild.FSTabEntry{
+			{
+				Label:   "root",
+				VFSType: "ext4",
+				Path:    "/",
+				Options: "defaults",
+				Freq:    1,
+				PassNo:  1,
+			},
+			{
+				Label:   "boot",
+				VFSType: "ext4",
+				Path:    "/boot",
+				Options: "defaults",
+				Freq:    1,
+				PassNo:  1,
+			},
+			{
+				Label:   "EFI-SYSTEM",
+				VFSType: "vfat",
+				Path:    "/boot/efi",
+				Options: "umask=0077,shortname=winnt",
+				Freq:    0,
+				PassNo:  2,
+			},
+		},
+	}
+}
+
 func (r *imageType) grub2StageOptions(kernelOptions string, kernel *blueprint.KernelCustomization, uefi bool) *osbuild.GRUB2StageOptions {
 	id := uuid.MustParse("76a22bf4-f153-4541-b6c7-0332c0dfaeac")
 
@@ -692,14 +805,14 @@ func (r *imageType) selinuxStageOptions() *osbuild.SELinuxStageOptions {
 	}
 }
 
-func qemuAssembler(format string, filename string, uefi bool, size uint64) *osbuild.Assembler {
+func qemuAssembler(format string, filename string, uefi bool, imageOptions distro.ImageOptions) *osbuild.Assembler {
 	var options osbuild.QEMUAssemblerOptions
 	if uefi {
 		fstype := uuid.MustParse("C12A7328-F81F-11D2-BA4B-00A0C93EC93B")
 		options = osbuild.QEMUAssemblerOptions{
 			Format:   format,
 			Filename: filename,
-			Size:     size,
+			Size:     imageOptions.Size,
 			PTUUID:   "8DFDFF87-C96E-EA48-A3A6-9408F1F6B1EF",
 			PTType:   "gpt",
 			Partitions: []osbuild.QEMUPartition{
@@ -730,7 +843,7 @@ func qemuAssembler(format string, filename string, uefi bool, size uint64) *osbu
 		options = osbuild.QEMUAssemblerOptions{
 			Format:   format,
 			Filename: filename,
-			Size:     size,
+			Size:     imageOptions.Size,
 			PTUUID:   "0x14fc63d2",
 			PTType:   "mbr",
 			Partitions: []osbuild.QEMUPartition{
@@ -757,12 +870,28 @@ func tarAssembler(filename, compression string) *osbuild.Assembler {
 		})
 }
 
-func rawFSAssembler(filename string, size uint64) *osbuild.Assembler {
+func rawFSAssembler(filename string, options distro.ImageOptions) *osbuild.Assembler {
 	id := uuid.MustParse("76a22bf4-f153-4541-b6c7-0332c0dfaeac")
 	return osbuild.NewRawFSAssembler(
 		&osbuild.RawFSAssemblerOptions{
 			Filename:           filename,
 			RootFilesystemUUID: id,
-			Size:               size,
+			Size:               options.Size,
 		})
 }
+
+func ostreeCommitAssembler(options distro.ImageOptions, arch distro.Arch) *osbuild.Assembler {
+	ref := options.OSTree.Ref
+	if ref == "" {
+		ref = fmt.Sprintf("fedora/32/%s/iot", arch.Name())
+	}
+	return osbuild.NewOSTreeCommitAssembler(
+		&osbuild.OSTreeCommitAssemblerOptions{
+			Ref:    ref,
+			Parent: options.OSTree.Parent,
+			Tar: osbuild.OSTreeCommitAssemblerTarOptions{
+				Filename: "commit.tar",
+			},
+		},
+	)
+}
diff --git a/internal/distro/rhel8/distro.go b/internal/distro/rhel8/distro.go
index f497cee50..ba5e10242 100644
--- a/internal/distro/rhel8/distro.go
+++ b/internal/distro/rhel8/distro.go
@@ -150,11 +150,11 @@ func (t *rhel8ImageType) BuildPackages() []string {
 }
 
 func (t *rhel8ImageType) Manifest(c *blueprint.Customizations,
+	options distro.ImageOptions,
 	repos []rpmmd.RepoConfig,
 	packageSpecs,
-	buildPackageSpecs []rpmmd.PackageSpec,
-	size uint64) (*osbuild.Manifest, error) {
-	pipeline, err := t.pipeline(c, repos, packageSpecs, buildPackageSpecs, size)
+	buildPackageSpecs []rpmmd.PackageSpec) (*osbuild.Manifest, error) {
+	pipeline, err := t.pipeline(c, repos, packageSpecs, buildPackageSpecs, options.Size)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/osbuild/assembler.go b/internal/osbuild/assembler.go
index 44e8324c7..354766889 100644
--- a/internal/osbuild/assembler.go
+++ b/internal/osbuild/assembler.go
@@ -32,12 +32,14 @@ func (assembler *Assembler) UnmarshalJSON(data []byte) error {
 	}
 	var options AssemblerOptions
 	switch rawAssembler.Name {
-	case "org.osbuild.tar":
-		options = new(TarAssemblerOptions)
+	case "org.osbuild.ostree.commit":
+		options = new(OSTreeCommitAssemblerOptions)
 	case "org.osbuild.qemu":
 		options = new(QEMUAssemblerOptions)
 	case "org.osbuild.rawfs":
 		options = new(RawFSAssemblerOptions)
+	case "org.osbuild.tar":
+		options = new(TarAssemblerOptions)
 	default:
 		return errors.New("unexpected assembler name")
 	}
diff --git a/internal/osbuild/fstab_stage.go b/internal/osbuild/fstab_stage.go
index 8c0d6fc1c..4bf679218 100644
--- a/internal/osbuild/fstab_stage.go
+++ b/internal/osbuild/fstab_stage.go
@@ -22,7 +22,8 @@ func NewFSTabStage(options *FSTabStageOptions) *Stage {
 // An FSTabEntry represents one line in /etc/fstab. With the one exception
 // that the the spec field must be represented as an UUID.
 type FSTabEntry struct {
-	UUID    string `json:"uuid"`
+	UUID    string `json:"uuid,omitempty"`
+	Label   string `json:"label,omitempty"`
 	VFSType string `json:"vfs_type"`
 	Path    string `json:"path,omitempty"`
 	Options string `json:"options,omitempty"`
diff --git a/internal/osbuild/ostree_commit_assembler.go b/internal/osbuild/ostree_commit_assembler.go
new file mode 100644
index 000000000..eabb28b45
--- /dev/null
+++ b/internal/osbuild/ostree_commit_assembler.go
@@ -0,0 +1,23 @@
+package osbuild
+
+// OSTreeCommitAssemblerOptions desrcibe how to assemble a tree into an OSTree commit.
+type OSTreeCommitAssemblerOptions struct {
+	Ref    string                          `json:"ref"`
+	Parent string                          `json:"parent,omitempty"`
+	Tar    OSTreeCommitAssemblerTarOptions `json:"tar"`
+}
+
+// OSTreeCommitAssemblerTarOptions desrcibes the output tarball
+type OSTreeCommitAssemblerTarOptions struct {
+	Filename string `json:"filename"`
+}
+
+func (OSTreeCommitAssemblerOptions) isAssemblerOptions() {}
+
+// NewOSTreeCommitAssembler creates a new OSTree Commit Assembler object.
+func NewOSTreeCommitAssembler(options *OSTreeCommitAssemblerOptions) *Assembler {
+	return &Assembler{
+		Name:    "org.osbuild.ostree.commit",
+		Options: options,
+	}
+}
diff --git a/internal/osbuild/rpm_ostree_stage.go b/internal/osbuild/rpm_ostree_stage.go
new file mode 100644
index 000000000..b5a961441
--- /dev/null
+++ b/internal/osbuild/rpm_ostree_stage.go
@@ -0,0 +1,16 @@
+package osbuild
+
+// The RPM-OSTree stage describes how to transform the imgae into an OSTree.
+type RPMOSTreeStageOptions struct {
+	EtcGroupMembers []string `json:"etc_group_members,omitempty"`
+}
+
+func (RPMOSTreeStageOptions) isStageOptions() {}
+
+// NewLocaleStage creates a new Locale Stage object.
+func NewRPMOSTreeStage(options *RPMOSTreeStageOptions) *Stage {
+	return &Stage{
+		Name:    "org.osbuild.rpm-ostree",
+		Options: options,
+	}
+}
diff --git a/internal/osbuild/stage.go b/internal/osbuild/stage.go
index 54b42415a..dd2689585 100644
--- a/internal/osbuild/stage.go
+++ b/internal/osbuild/stage.go
@@ -62,6 +62,8 @@ func (stage *Stage) UnmarshalJSON(data []byte) error {
 		options = new(FirewallStageOptions)
 	case "org.osbuild.rpm":
 		options = new(RPMStageOptions)
+	case "org.osbuild.rpm-ostree":
+		options = new(RPMOSTreeStageOptions)
 	case "org.osbuild.systemd":
 		options = new(SystemdStageOptions)
 	case "org.osbuild.script":
diff --git a/internal/rcm/api.go b/internal/rcm/api.go
index e6f220888..befa596ed 100644
--- a/internal/rcm/api.go
+++ b/internal/rcm/api.go
@@ -151,8 +151,8 @@ func (api *API) submit(writer http.ResponseWriter, request *http.Request, _ http
 
 	buildRequest := composeRequest.ImageBuilds[0]
 
-	distro := api.distros.GetDistro(buildRequest.Distribution)
-	if distro == nil {
+	d := api.distros.GetDistro(buildRequest.Distribution)
+	if d == nil {
 		writer.WriteHeader(http.StatusBadRequest)
 		_, err := writer.Write([]byte("unknown distro"))
 		if err != nil {
@@ -161,7 +161,7 @@ func (api *API) submit(writer http.ResponseWriter, request *http.Request, _ http
 		return
 	}
 
-	arch, err := distro.GetArch(buildRequest.Architecture)
+	arch, err := d.GetArch(buildRequest.Architecture)
 	if err != nil {
 		writer.WriteHeader(http.StatusBadRequest)
 		_, err := writer.Write([]byte("unknown architecture for distro"))
@@ -193,7 +193,7 @@ func (api *API) submit(writer http.ResponseWriter, request *http.Request, _ http
 		})
 	}
 
-	packages, buildPackages, err := depsolve(api.rpmMetadata, distro, imageType, repoConfigs, arch)
+	packages, buildPackages, err := depsolve(api.rpmMetadata, d, imageType, repoConfigs, arch)
 	if err != nil {
 		writer.WriteHeader(http.StatusBadRequest)
 		_, err := writer.Write([]byte(err.Error()))
@@ -203,8 +203,13 @@ func (api *API) submit(writer http.ResponseWriter, request *http.Request, _ http
 		return
 	}
 
-	size := imageType.Size(0)
-	manifest, err := imageType.Manifest(nil, repoConfigs, packages, buildPackages, size)
+	manifest, err := imageType.Manifest(nil,
+		distro.ImageOptions{
+			Size: imageType.Size(0),
+		},
+		repoConfigs,
+		packages,
+		buildPackages)
 	if err != nil {
 		writer.WriteHeader(http.StatusBadRequest)
 		_, err := writer.Write([]byte(err.Error()))
diff --git a/internal/store/json.go b/internal/store/json.go
index d4a407041..c027c931a 100644
--- a/internal/store/json.go
+++ b/internal/store/json.go
@@ -336,6 +336,7 @@ var imageTypeCompatMapping = map[string]string{
 	"ext4-filesystem":   "Raw-filesystem",
 	"partitioned-disk":  "Partitioned-disk",
 	"tar":               "Tar",
+	"fedora-iot-commit": "fedora-iot-commit",
 	"test_type":         "test_type",         // used only in json_test.go
 	"test_type_invalid": "test_type_invalid", // used only in json_test.go
 }
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index d20e2e150..bd5d4a864 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -1506,11 +1506,17 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 		return
 	}
 
+	type OSTreeRequest struct {
+		Ref    string `json:"ref"`
+		Parent string `json:"parent"`
+	}
+
 	// https://weldr.io/lorax/pylorax.api.html#pylorax.api.v0.v0_compose_start
 	type ComposeRequest struct {
 		BlueprintName string         `json:"blueprint_name"`
 		ComposeType   string         `json:"compose_type"`
 		Size          uint64         `json:"size"`
+		OSTree        OSTreeRequest  `json:"ostree"`
 		Branch        string         `json:"branch"`
 		Upload        *uploadRequest `json:"upload"`
 	}
@@ -1603,7 +1609,17 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 	}
 
 	size := imageType.Size(cr.Size)
-	manifest, err := imageType.Manifest(bp.Customizations, api.allRepositories(), packages, buildPackages, size)
+	manifest, err := imageType.Manifest(bp.Customizations,
+		distro.ImageOptions{
+			Size: size,
+			OSTree: distro.OSTreeImageOptions{
+				Ref:    cr.OSTree.Ref,
+				Parent: cr.OSTree.Parent,
+			},
+		},
+		api.allRepositories(),
+		packages,
+		buildPackages)
 	if err != nil {
 		errors := responseError{
 			ID:  "ManifestCreationFailed",


Test patch:
diff --git a/internal/distro/distro_test_common/distro_test_common.go b/internal/distro/distro_test_common/distro_test_common.go
index 08088d9c7..a1ccdf0e4 100644
--- a/internal/distro/distro_test_common/distro_test_common.go
+++ b/internal/distro/distro_test_common/distro_test_common.go
@@ -82,10 +82,12 @@ func TestDistro_Manifest(t *testing.T, pipelinePath string, prefix string, distr
 				return
 			}
 			got, err := imageType.Manifest(tt.ComposeRequest.Blueprint.Customizations,
+				distro.ImageOptions{
+					Size: imageType.Size(0),
+				},
 				repos,
 				tt.RpmMD.Packages,
-				tt.RpmMD.BuildPackages,
-				imageType.Size(0))
+				tt.RpmMD.BuildPackages)
 
 			if (err == nil && tt.Manifest == nil) || (err != nil && tt.Manifest != nil) {
 				t.Errorf("distro.Manifest() error = %v", err)
diff --git a/internal/distro/fedora32/distro_test.go b/internal/distro/fedora32/distro_test.go
index 6908af30e..eebe5b7b1 100644
--- a/internal/distro/fedora32/distro_test.go
+++ b/internal/distro/fedora32/distro_test.go
@@ -135,7 +135,14 @@ func TestImageType_BuildPackages(t *testing.T) {
 				t.Errorf("d.GetArch(%v) returned err = %v; expected nil", archLabel, err)
 				continue
 			}
-			assert.ElementsMatch(t, buildPackages[archLabel], itStruct.BuildPackages())
+			if itLabel == "fedora-iot-commit" {
+				// For now we only include rpm-ostree when building fedora-iot-commit image types, this we may want
+				// to reconsider. The only reason to specia-case it is that it might pull in a lot of dependencies
+				// for a niche usecase.
+				assert.ElementsMatch(t, append(buildPackages[archLabel], "rpm-ostree"), itStruct.BuildPackages())
+			} else {
+				assert.ElementsMatch(t, buildPackages[archLabel], itStruct.BuildPackages())
+			}
 		}
 	}
 }
diff --git a/internal/distro/fedoratest/distro.go b/internal/distro/fedoratest/distro.go
index e697403b5..646e8009a 100644
--- a/internal/distro/fedoratest/distro.go
+++ b/internal/distro/fedoratest/distro.go
@@ -91,10 +91,10 @@ func (t *imageType) BuildPackages() []string {
 }
 
 func (t *imageType) Manifest(c *blueprint.Customizations,
+	options distro.ImageOptions,
 	repos []rpmmd.RepoConfig,
 	packageSpecs,
-	buildPackageSpecs []rpmmd.PackageSpec,
-	size uint64) (*osbuild.Manifest, error) {
+	buildPackageSpecs []rpmmd.PackageSpec) (*osbuild.Manifest, error) {
 	return &osbuild.Manifest{
 		Pipeline: osbuild.Pipeline{},
 		Sources:  osbuild.Sources{},
diff --git a/internal/distro/test_distro/distro.go b/internal/distro/test_distro/distro.go
index 9d19dca50..716b84bde 100644
--- a/internal/distro/test_distro/distro.go
+++ b/internal/distro/test_distro/distro.go
@@ -74,7 +74,7 @@ func (t *TestImageType) BuildPackages() []string {
 	return nil
 }
 
-func (t *TestImageType) Manifest(b *blueprint.Customizations, repos []rpmmd.RepoConfig, packageSpecs, buildPackageSpecs []rpmmd.PackageSpec, size uint64) (*osbuild.Manifest, error) {
+func (t *TestImageType) Manifest(b *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSpecs, buildPackageSpecs []rpmmd.PackageSpec) (*osbuild.Manifest, error) {
 	return &osbuild.Manifest{
 		Sources:  osbuild.Sources{},
 		Pipeline: osbuild.Pipeline{},
diff --git a/internal/osbuild/assembler_test.go b/internal/osbuild/assembler_test.go
index fd05ea10b..14f9d94c6 100644
--- a/internal/osbuild/assembler_test.go
+++ b/internal/osbuild/assembler_test.go
@@ -108,6 +108,19 @@ func TestAssembler_UnmarshalJSON(t *testing.T) {
 			},
 			data: []byte(`{"name":"org.osbuild.rawfs","options":{"filename":"filesystem.img","root_fs_uuid":"76a22bf4-f153-4541-b6c7-0332c0dfaeac","size":2147483648}}`),
 		},
+		{
+			name: "ostree commit assembler",
+			assembler: Assembler{
+				Name: "org.osbuild.ostree.commit",
+				Options: &OSTreeCommitAssemblerOptions{
+					Ref: "foo",
+					Tar: OSTreeCommitAssemblerTarOptions{
+						Filename: "foo.tar",
+					},
+				},
+			},
+			data: []byte(`{"name":"org.osbuild.ostree.commit","options":{"ref":"foo","tar":{"filename":"foo.tar"}}}`),
+		},
 	}
 
 	assert := assert.New(t)
diff --git a/internal/osbuild/stage_test.go b/internal/osbuild/stage_test.go
index 21663cd25..8e3651ca4 100644
--- a/internal/osbuild/stage_test.go
+++ b/internal/osbuild/stage_test.go
@@ -195,6 +195,20 @@ func TestStage_UnmarshalJSON(t *testing.T) {
 				data: []byte(`{"name":"org.osbuild.rpm","options":{"gpgkeys":["key1","key2"],"packages":["checksum1","checksum2"]}}`),
 			},
 		},
+		{
+			name: "rpm-ostree",
+			fields: fields{
+				Name: "org.osbuild.rpm-ostree",
+				Options: &RPMOSTreeStageOptions{
+					EtcGroupMembers: []string{
+						"wheel",
+					},
+				},
+			},
+			args: args{
+				data: []byte(`{"name":"org.osbuild.rpm-ostree","options":{"etc_group_members":["wheel"]}}`),
+			},
+		},
 		{
 			name: "script",
 			fields: fields{
diff --git a/internal/worker/server_test.go b/internal/worker/server_test.go
index 494d0b9d7..63f0f0492 100644
--- a/internal/worker/server_test.go
+++ b/internal/worker/server_test.go
@@ -7,6 +7,7 @@ import (
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/require"
 
+	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/distro/fedoratest"
 	"github.com/osbuild/osbuild-composer/internal/jobqueue/testjobqueue"
 	"github.com/osbuild/osbuild-composer/internal/test"
@@ -52,7 +53,7 @@ func TestCreate(t *testing.T) {
 	}
 	server := worker.NewServer(nil, testjobqueue.New(), nil)
 
-	manifest, err := imageType.Manifest(nil, nil, nil, nil, imageType.Size(0))
+	manifest, err := imageType.Manifest(nil, distro.ImageOptions{Size: imageType.Size(0)}, nil, nil, nil)
 	if err != nil {
 		t.Fatalf("error creating osbuild manifest")
 	}
@@ -78,7 +79,7 @@ func testUpdateTransition(t *testing.T, from, to string, expectedStatus int) {
 
 	id := uuid.Nil
 	if from != "VOID" {
-		manifest, err := imageType.Manifest(nil, nil, nil, nil, imageType.Size(0))
+		manifest, err := imageType.Manifest(nil, distro.ImageOptions{Size: imageType.Size(0)}, nil, nil, nil)
 		if err != nil {
 			t.Fatalf("error creating osbuild manifest")
 		}