Repository: osbuild/osbuild-composer
Pull request #2201 (2022-01-18 21:17:33)

Support specifying OSTree Parent and URL for creating upgrade commits
This PR changes the way we handle ostree parameters for compose requests.  The new behaviour is outlined in #2131.
The ostree parameter validation now happens in the ostree package.  The package defines custom error types for different error conditions.  This is necessary for the APIs to be able to communicate what went wrong.  Feedback on the error messages (and variable names) is very welcome.

I updated the weldr and cloud API tests to reflect the new changes and for higher coverage.  The tests can now set up a mock ostree repository to resolve the URL for testing successful compose requests.

The purpose of this change is to support creating upgrade commits (from one distro version to another).  Conventionally, the ostree `ref` depends on the os version (e.g., `rhel/8/x86_64/edge`).  To generate an upgrade commit (e.g., from RHEL 8.x to RHEL 9.0) we need to be able to specify the old ref (`rhel/8/...`) as the parent and the new ref for the new commit (`rhel/9/...`).  This is now supported, though we need to add integration tests for the specific use case.

-----

This pull request includes:

- [ ] adequate testing for the new functionality or fixed issue
- [ ] adequate documentation informing people about the change such as
  - [ ] submit a PR for the [guides](https://github.com/osbuild/guides) repository if this PR changed any behavior described there: https://www.osbuild.org/guides/

<!--
Thanks for proposing a change to osbuild-composer!

Please don't remove the above check list. These are things that each pull
request must have before it is merged. It helps maintainers to not forget
anything.

If the reason for ticking any of the boxes is ambiguous, please add a short
note explaining why.

In addition, if this pull request fixes a downstream issue, please refer to
test/README.md and add these additional items:

- [ ] 1st commit of any `rhbz#` related PR contains bug reproducer; CI reports FAIL or
- [ ] PR contains automated tests for new functionality and
- [ ] QE has approved reproducer/new tests and
- [ ] Subsequent commits provide bug fixes without modifying the reproducer; CI reports PASS and
- [ ] QE approves this PR; RHBZ status is set to `MODIFIED + Verified=Tested`
-->


henrywang: @achilleas-k, my plan is to add different ref upgrade test, but not from rhel 8 to rhel 9 because that's not easy to do that in our CI. I'll cover rhel 8 to rhel 9 upgrade test on downstream CI. Any thoughts? Thanks.
achilleas-k: > @achilleas-k, my plan is to add different ref upgrade test, but not from rhel 8 to rhel 9 because that's not easy to do that in our CI. I'll cover rhel 8 to rhel 9 upgrade test on downstream CI. Any thoughts? Thanks.

Sounds good.  Testing the upgrade in downstream CI makes sense.
In our CI, we should at least test that we can build a commit based on a parent with a different ref.  I tested it locally last night when I opened the PR and I got a mysterious failure in the `org.osbuild.users` stage.  I'll look into it today.
achilleas-k: See https://github.com/osbuild/osbuild/pull/948 for user upgrade issue (and solution).
This is an existing issue that's not (directly) related to this PR, so I think we shouldn't block on this.
For testing this PR, we can temporarily work around it by not having a user (or not have a key for the user) in the upgrade commit.  This is what we already do in the `ostree-ng.sh` test.
achilleas-k: Needs a rebase and conflict resolution
henrywang: @achilleas-k Can we make a rebase because [PR#2219](https://github.com/osbuild/osbuild-composer/pull/2219) got merged already. So I can use new mock build to test this PR and add test case. Thanks!
achilleas-k: > @achilleas-k Can we make a rebase when you have time because [PR#2219](https://github.com/osbuild/osbuild-composer/pull/2219) got merged already. So I can use new mock build to test this PR and add test case. Thanks!

I'll rebase later today.  It's going to need some work because of the conflicting changes in the cloudapi from the Koji PR (#2214).
achilleas-k: Rebased
henrywang: @achilleas-k Looks openstack can't get configured RHEL 8.6 image.
```
Error: Error creating OpenStack server: Bad request with: [POST https://api.rhos-01.prod.psi.rdu2.redhat.com:13774/v2.1/servers], error message: {"badRequest": {"code": 400, "message": "Can not find requested image"}}
```
henrywang: @achilleas-k Tested on RHEL 9 (`RHEL 9 to RHEL 9 with different ref`)
```
[admin@vm-bios ~]$ rpm-ostree status
State: idle
Deployments:
● rhel-edge:test/rhel/x/x86_64/edge
                   Version: 9.0 (2022-02-03T08:31:56Z)
                    Commit: af35638feb86b1b6711de0bcb1315e3ca92ab7013eef3bde8ae7aa2c31868790

  rhel-edge:rhel/9/x86_64/edge
                   Version: 9.0 (2022-02-03T08:19:48Z)
                    Commit: 1b243056c63ffd14b0f7e1c1c78e91cef1ad553f30283938e2a8629c14789395
```
henrywang: @achilleas-k @gicmo RHEL 8.6 Edge system(`normal kernel 4.18.0-361.el8.x86_64`) upgrades to RHEL 9.0 Edge system (`rt kernel 5.14.0-51.rt21.51.el9.x86_64`)with `rebase` without any error. The issue is the upgrade blueprints can't have `[[customizations.user]]` configured. osbuild [PR #948](https://github.com/osbuild/osbuild/pull/948) will fix this issue.
```
[admin@localhost ~]$ rpm-ostree status
State: idle
Deployments:
* rhel-edge:rhel/9/x86_64/edge
                   Version: 9.0 (2022-02-03T13:39:07Z)
                    Commit: 6a470df710f6d65ee9332e15bf60e12c7561c99bb84fe9c0b60a43f811f44b49

  rhel-edge:rhel/8/x86_64/edge
                   Version: 8.6 (2022-02-01T06:08:47Z)
                    Commit: b28a859ba09fd3dcc45f63d0c8a49199db5b1d80747d2ebf43f1585686a210a4
```
achilleas-k: Update https://github.com/osbuild/osbuild-composer/pull/2201/commits/f9ca52ab47256868c8bcb3214c572cd126d8b6fa:
User stages (`org.osbuild.user`) for ostree commits are created without SSH keys anymore.  This avoids the problem with trying to write the SSH key in a non-existent home directory.  The key writing had no effect since the home directories created during a commit build are not retained.  Instead we create keys during first boot when the user's home directory is created.

@henrywang This should avoid the issue with the upgrade commit failing when there's a user customization with an SSH key.  Can you add this to the test?  I tested it locally for RHEL 8.6 before pushing.
henrywang: > Update [f9ca52a](https://github.com/osbuild/osbuild-composer/commit/f9ca52ab47256868c8bcb3214c572cd126d8b6fa): User stages (`org.osbuild.user`) for ostree commits are created without SSH keys anymore. This avoids the problem with trying to write the SSH key in a non-existent home directory. The key writing had no effect since the home directories created during a commit build are not retained. Instead we create keys during first boot when the user's home directory is created.
> 
> @henrywang This should avoid the issue with the upgrade commit failing when there's a user customization with an SSH key. Can you add this to the test? I tested it locally for RHEL 8.6 before pushing. Specifying a user that already exists in the parent while building an upgrade commit and having an SSH key now should append that SSH key to the existing `authorized_keys` file.

Thanks @achilleas-k for doing this. I tested in my local env, your fix works. And I added ostree rebase test case in this PR.
achilleas-k: OSTree rebase test is failing in CS8 and RHEL 9 at the failed boot / rollback test.  Wonder why 8.6 isn't.
henrywang: > OSTree rebase test is failing in CS8 and RHEL 9 at the failed boot / rollback test. Wonder why 8.6 isn't.

The CS8 issue should be related with cpu and memory resource on for CS8 vm. The cs8 vm(2 cpu, 4G memory) can't afford two vms running at the same time.
Resource after one VM got installed.
```
centos@centos-stream-8 ~]$ free -mh
              total        used        free      shared  buff/cache   available
Mem:          3.6Gi       1.8Gi       104Mi        10Mi       1.8Gi       1.6Gi
```
My `gitlab-ci-terraform` [PR #56](https://github.com/osbuild/gitlab-ci-terraform/pull/56) got merged. So we have large CS8 vm. Let's run test again on large cs8 vm.
henrywang: @achilleas-k YES! CS8 vm resource is definitely the root cause! It's green now with `large` vm. Really don't like this kind of debugging experience....
henrywang: The RHEL 9 issue is about greenboot. The UEFI vm can't rolllback and keeps rebooting after new deployment failed. This issue does not exist on BIOS vm. @runcom @nullr0ute Do you have any idea about this issue? Thanks.
runcom: > The UEFI vm can't rolllback and keeps rebooting after new deployment failed.

do we have any logs? Or manual testing with logs? Seems ostree related somehow or greenboot itself
henrywang: > > The UEFI vm can't rolllback and keeps rebooting after new deployment failed.
> 
> do we have any logs? Or manual testing with logs? Seems ostree related somehow or greenboot itself

How can I get log? The vm keeps rebooting, can't get into vm.
Here's the log I copied from console while vm booting.
```
[FAILED] Failed to start greenboot Health Checks Runner.
See 'systemctl status greenboot-healthcheck.service' for details.
[DEPEND] Dependency failed for Boot Completion Check.
[DEPEND] Dependency failed for Mark boot as successful in grubenv.
[DEPEND] Dependency failed for Multi-User System.
[DEPEND] Dependency failed for greenboot Success Scripts Runner.
         Starting greenboot Failure Scripts Runner...
         Starting Record Runlevel Change in UTMP...
[  OK  ] Finished greenboot Failure Scripts Runner.
[  OK  ] Started Modem Manager.
[  OK  ] Reached target Generic red boot target.
         Starting greenboot MotD Generator...
         Starting Reboot on red boot status...
```

achilleas-k: A (perhaps relevant?) note: this PR uses older repositories with greenboot < v14.  Any chance this is a bug fixed in the newer greenboot?
runcom: > A (perhaps relevant?) note: this PR uses older repositories with greenboot < 0.14. Any chance this is a bug fixed in the newer greenboot?

let's re-kick test on the other PR - rn they're just capped in the CI infra and aren't running :(
henrywang: @achilleas-k @runcom Then only failed/issue for this PR is [bz#2053469](https://bugzilla.redhat.com/show_bug.cgi?id=2053469)
achilleas-k: Rebased on main.
@henrywang I think after the rebase there might be some redundant checks in the ansible playbook.  Can you have a look please?  Thanks!
runcom: some RPM jobs failed in CI (might be infra related)
henrywang: @achilleas-k This PR needs your fix https://github.com/osbuild/osbuild-composer/pull/2142/commits/70f9780e9a67d57e8713466336284d5cc8977362 as well.

henrywang: @achilleas-k I just make a rebase because CS9 PR just got landed. I hope I did correct thing.
henrywang: @achilleas-k Still has rebooting loop issue. We can skip this test only on `ostree-rebase.sh` on UEFI test if there's no any fix. I made a change to support the skip.
```diff
diff --git i/test/cases/ostree-rebase.sh w/test/cases/ostree-rebase.sh
index 67a8af8a..2cb4a7af 100755
--- i/test/cases/ostree-rebase.sh
+++ w/test/cases/ostree-rebase.sh
@@ -601,7 +601,7 @@ ansible_ssh_common_args="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/
 EOF

 # Test IoT/Edge OS
-sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=rhel-edge -e ostree_commit="${UPGRADE_HASH}" -e ostree_ref="rhel-edge:${OSTREE_REF}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
+sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=rhel-edge -e ostree_commit="${UPGRADE_HASH}" -e ostree_ref="rhel-edge:${OSTREE_REF}" -e skip_rollback_test="true" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
 check_result

 # Final success clean up
diff --git i/test/data/ansible/check_ostree.yaml w/test/data/ansible/check_ostree.yaml
index b0596714..5fb6ba9d 100644
--- i/test/data/ansible/check_ostree.yaml
+++ w/test/data/ansible/check_ostree.yaml
@@ -3,6 +3,7 @@
   become: no
   vars:
     workspace: "{{ lookup('env', 'WORKSPACE') }}"
+    skip_rollback_test: "false"
     total_counter: "0"
     failed_counter: "0"

@@ -535,6 +536,7 @@
         - name: failed count + 1
           set_fact:
             failed_counter: "{{ failed_counter | int + 1 }}"
+      when: skip_rollback_test == "false"

     # case: check ostree commit after rollback
     - name: check ostree commit after rollback
@@ -559,7 +561,9 @@
         - name: failed count + 1
           set_fact:
             failed_counter: "{{ failed_counter | int + 1 }}"
-      when: result_rollback is succeeded
+      when:
+        - skip_rollback_test == "false"
+        - result_rollback is succeeded

     # case: check greenboot* services log again
     - name: fallback log should be found here
@@ -588,7 +592,9 @@
         - name: failed count + 1
           set_fact:
             failed_counter: "{{ failed_counter | int + 1 }}"
-      when: result_rollback is succeeded
+      when:
+        - skip_rollback_test == "false"
+        - result_rollback is succeeded

     # case: check grubenv variables again
     - name: grubenv variables should contain boot_success=1
@@ -610,7 +616,9 @@
         - name: failed count + 1
           set_fact:
             failed_counter: "{{ failed_counter | int + 1 }}"
-      when: result_rollback is succeeded
+      when:
+        - skip_rollback_test == "false"
+        - result_rollback is succeeded

     - assert:
         that:
```
achilleas-k: > @achilleas-k Still has rebooting loop issue. We can skip this test only on `ostree-rebase.sh` on UEFI test if there's no any fix. I made a change to support the skip.

Thanks!  Applied.
henrywang: Thanks @achilleas-k! All ostree tests are GREEN! Re-tried two `integration` tests.
teg: > Thanks @achilleas-k! All ostree tests are GREEN! Re-tried two `integration` tests.

Looks like you are hitting #2322
henrywang: @teg OH! Ack. Thanks! Do we need wait for issue #2322 fixed before landing this PR? Thanks.
teg: I think not, but maybe @ondrejbudai or @achilleas-k have other thoughts
achilleas-k: I wouldn't be against an admin merge, but it's not my call :)
teg: I should amend: all for an admin pr, but let's make sure it doesn't conflict with the LVM one ...
achilleas-k: > I should amend: all for an admin pr, but let's make sure it doesn't conflict with the LVM one ...

Ah right.  I can check for that, or we can block until it's merged
achilleas-k: > I should amend: all for an admin pr, but let's make sure it doesn't conflict with the LVM one ...

Rebasing `gicmo/lvm` on this PR causes no conflicts and all local unit tests pass.  I can't think of any reason they might conflict beyond that.  The LVM PR is mostly contained in the `internal/disk` and `internal/osbuild*` packages and this one is API changes.

I understand if you want to wait to make absolutely sure though.
teg: Force pushed on top of LVM PR and various CI fixes.

Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 0a0d8b442..ec8066ece 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -307,6 +307,19 @@ OSTree raw image:
           - rhos-01/rhel-9.0-nightly-x86_64-large
           - rhos-01/centos-stream-9-x86_64-large
 
+Rebase OSTree:
+  stage: test
+  extends: OSTree
+  script:
+    - schutzbot/deploy.sh
+    - /usr/libexec/tests/osbuild-composer/ostree-rebase.sh
+  parallel:
+    matrix:
+      - RUNNER:
+          - rhos-01/rhel-8.6-nightly-x86_64-large
+          - rhos-01/rhel-9.0-nightly-x86_64-large
+          - rhos-01/centos-stream-8-x86_64-large
+
 .INTEGRATION_TESTS: &INTEGRATION_TESTS
   SCRIPT:
     - koji.sh
diff --git a/cmd/osbuild-pipeline/main.go b/cmd/osbuild-pipeline/main.go
index d80493de2..d81e3038a 100644
--- a/cmd/osbuild-pipeline/main.go
+++ b/cmd/osbuild-pipeline/main.go
@@ -10,6 +10,7 @@ import (
 	"path"
 
 	"github.com/osbuild/osbuild-composer/internal/distroregistry"
+	"github.com/osbuild/osbuild-composer/internal/ostree"
 
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/distro"
@@ -145,7 +146,7 @@ func main() {
 		manifest, err := imageType.Manifest(composeRequest.Blueprint.Customizations,
 			distro.ImageOptions{
 				Size: imageType.Size(0),
-				OSTree: distro.OSTreeImageOptions{
+				OSTree: ostree.RequestParams{
 					Ref:    composeRequest.OSTree.Ref,
 					Parent: composeRequest.OSTree.Parent,
 					URL:    composeRequest.OSTree.URL,
diff --git a/internal/cloudapi/v2/errors.go b/internal/cloudapi/v2/errors.go
index 90c8ae537..7145bf514 100644
--- a/internal/cloudapi/v2/errors.go
+++ b/internal/cloudapi/v2/errors.go
@@ -39,6 +39,7 @@ const (
 	ErrorNoBaseURLInPayloadRepository ServiceErrorCode = 24
 	ErrorInvalidNumberOfImageBuilds   ServiceErrorCode = 25
 	ErrorInvalidJobType               ServiceErrorCode = 26
+	ErrorInvalidOSTreeParams          ServiceErrorCode = 27
 
 	// Internal errors, these are bugs
 	ErrorFailedToInitializeBlueprint              ServiceErrorCode = 1000
@@ -102,6 +103,7 @@ func getServiceErrors() serviceErrors {
 		serviceError{ErrorNoBaseURLInPayloadRepository, http.StatusBadRequest, "BaseURL must be specified for payload repositories"},
 		serviceError{ErrorInvalidJobType, http.StatusNotFound, "Requested job has invalid type"},
 		serviceError{ErrorInvalidNumberOfImageBuilds, http.StatusBadRequest, "Compose request has unsupported number of image builds"},
+		serviceError{ErrorInvalidOSTreeParams, http.StatusBadRequest, "Invalid OSTree parameters or parameter combination"},
 
 		serviceError{ErrorFailedToInitializeBlueprint, http.StatusInternalServerError, "Failed to initialize blueprint"},
 		serviceError{ErrorFailedToGenerateManifestSeed, http.StatusInternalServerError, "Failed to generate manifest seed"},
diff --git a/internal/cloudapi/v2/openapi.v2.gen.go b/internal/cloudapi/v2/openapi.v2.gen.go
index a261dfeaa..13ce9aae8 100644
--- a/internal/cloudapi/v2/openapi.v2.gen.go
+++ b/internal/cloudapi/v2/openapi.v2.gen.go
@@ -335,8 +335,9 @@ type List struct {
 
 // OSTree defines model for OSTree.
 type OSTree struct {
-	Ref *string `json:"ref,omitempty"`
-	Url *string `json:"url,omitempty"`
+	Parent *string `json:"parent,omitempty"`
+	Ref    *string `json:"ref,omitempty"`
+	Url    *string `json:"url,omitempty"`
 }
 
 // ObjectReference defines model for ObjectReference.
@@ -639,78 +640,79 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
 // Base64 encoded, gzipped, json marshaled Swagger object
 var swaggerSpec = []string{
 
-	"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",
-	"TclmG52bx/8LAAD//wPiCUUCTwAA",
+	"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",
 }
 
 // GetSwagger returns the content of the embedded swagger specification file
diff --git a/internal/cloudapi/v2/openapi.v2.yml b/internal/cloudapi/v2/openapi.v2.yml
index a5d59d0ad..3a6e3ba70 100644
--- a/internal/cloudapi/v2/openapi.v2.yml
+++ b/internal/cloudapi/v2/openapi.v2.yml
@@ -842,6 +842,15 @@ components:
         ref:
           type: string
           example: 'rhel/8/x86_64/edge'
+        parent:
+          type: string
+          examples:
+            commit_id:
+              value: '02604b2da6e954bd34b8b82a835e5a77d2b60ffa'
+              summary: A commit ID
+            ref:
+              value: 'rhel/8/x86_64/edge'
+              summary: A branch-like ref
     Subscription:
       type: object
       required:
diff --git a/internal/cloudapi/v2/v2.go b/internal/cloudapi/v2/v2.go
index 0f74eb7d6..08f0afea6 100644
--- a/internal/cloudapi/v2/v2.go
+++ b/internal/cloudapi/v2/v2.go
@@ -272,24 +272,30 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 			}
 		}
 
-		// set default ostree ref, if one not provided
-		ostreeOptions := ir.Ostree
-		if ostreeOptions == nil || ostreeOptions.Ref == nil {
-			imageOptions.OSTree = distro.OSTreeImageOptions{Ref: imageType.OSTreeRef()}
-		} else if !ostree.VerifyRef(*ostreeOptions.Ref) {
-			return HTTPError(ErrorInvalidOSTreeRef)
-		} else {
-			imageOptions.OSTree = distro.OSTreeImageOptions{Ref: *ostreeOptions.Ref}
+		ostreeOptions := ostree.RequestParams{}
+		if ir.Ostree != nil {
+			if ir.Ostree.Ref != nil {
+				ostreeOptions.Ref = *ir.Ostree.Ref
+			}
+			if ir.Ostree.Url != nil {
+				ostreeOptions.URL = *ir.Ostree.Url
+			}
+			if ir.Ostree.Parent != nil {
+				ostreeOptions.Parent = *ir.Ostree.Parent
+			}
 		}
-
-		var parent string
-		if ostreeOptions != nil && ostreeOptions.Url != nil {
-			imageOptions.OSTree.URL = *ostreeOptions.Url
-			parent, err = ostree.ResolveRef(imageOptions.OSTree.URL, imageOptions.OSTree.Ref)
-			if err != nil {
-				return HTTPErrorWithInternal(ErrorInvalidOSTreeRepo, err)
+		if imageOptions.OSTree, err = ostree.ResolveParams(ostreeOptions, imageType.OSTreeRef()); err != nil {
+			switch v := err.(type) {
+			case ostree.RefError:
+				return HTTPError(ErrorInvalidOSTreeRef)
+			case ostree.ResolveRefError:
+				return HTTPErrorWithInternal(ErrorInvalidOSTreeRepo, v)
+			case ostree.ParameterComboError:
+				return HTTPError(ErrorInvalidOSTreeParams)
+			default:
+				// general case
+				return HTTPError(ErrorInvalidOSTreeParams)
 			}
-			imageOptions.OSTree.Parent = parent
 		}
 
 		var irTarget *target.Target
diff --git a/internal/distro/distro.go b/internal/distro/distro.go
index c7341223e..c6553d263 100644
--- a/internal/distro/distro.go
+++ b/internal/distro/distro.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/disk"
+	"github.com/osbuild/osbuild-composer/internal/ostree"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 )
 
@@ -127,18 +128,11 @@ type ImageType interface {
 
 // The ImageOptions specify options for a specific image build
 type ImageOptions struct {
-	OSTree       OSTreeImageOptions
+	OSTree       ostree.RequestParams
 	Size         uint64
 	Subscription *SubscriptionImageOptions
 }
 
-// The OSTreeImageOptions specify ostree-specific image options
-type OSTreeImageOptions struct {
-	Ref    string
-	Parent string
-	URL    string
-}
-
 // The SubscriptionImageOptions specify subscription-specific image options
 // ServerUrl denotes the host to register the system with
 // BaseUrl specifies the repository URL for DNF
diff --git a/internal/distro/rhel86/pipelines.go b/internal/distro/rhel86/pipelines.go
index 3d510aefd..28955aa33 100644
--- a/internal/distro/rhel86/pipelines.go
+++ b/internal/distro/rhel86/pipelines.go
@@ -418,9 +418,29 @@ func osPipeline(t *imageType,
 		if err != nil {
 			return nil, err
 		}
-		p.AddStage(osbuild.NewUsersStage(userOptions))
+
 		if t.rpmOstree {
+			// for ostree, writing the key during user creation is redundant
+			// and can cause issues so create users without keys and write them
+			// on first boot
+			userOptionsSansKeys := new(osbuild.UsersStageOptions)
+			userOptionsSansKeys.Users = make(map[string]osbuild.UsersStageOptionsUser, len(userOptions.Users))
+			for name, options := range userOptions.Users {
+				userOptionsSansKeys.Users[name] = osbuild.UsersStageOptionsUser{
+					UID:         options.UID,
+					GID:         options.GID,
+					Groups:      options.Groups,
+					Description: options.Description,
+					Home:        options.Home,
+					Shell:       options.Shell,
+					Password:    options.Password,
+					Key:         nil,
+				}
+			}
+			p.AddStage(osbuild.NewUsersStage(userOptionsSansKeys))
 			p.AddStage(osbuild.NewFirstBootStage(usersFirstBootOptions(userOptions)))
+		} else {
+			p.AddStage(osbuild.NewUsersStage(userOptions))
 		}
 	}
 
diff --git a/internal/distro/rhel90/pipelines.go b/internal/distro/rhel90/pipelines.go
index b7746e483..800d925c9 100644
--- a/internal/distro/rhel90/pipelines.go
+++ b/internal/distro/rhel90/pipelines.go
@@ -409,9 +409,28 @@ func osPipeline(t *imageType,
 		if err != nil {
 			return nil, err
 		}
-		p.AddStage(osbuild.NewUsersStage(userOptions))
 		if t.rpmOstree {
+			// for ostree, writing the key during user creation is redundant
+			// and can cause issues so create users without keys and write them
+			// on first boot
+			userOptionsSansKeys := new(osbuild.UsersStageOptions)
+			userOptionsSansKeys.Users = make(map[string]osbuild.UsersStageOptionsUser, len(userOptions.Users))
+			for name, options := range userOptions.Users {
+				userOptionsSansKeys.Users[name] = osbuild.UsersStageOptionsUser{
+					UID:         options.UID,
+					GID:         options.GID,
+					Groups:      options.Groups,
+					Description: options.Description,
+					Home:        options.Home,
+					Shell:       options.Shell,
+					Password:    options.Password,
+					Key:         nil,
+				}
+			}
+			p.AddStage(osbuild.NewUsersStage(userOptionsSansKeys))
 			p.AddStage(osbuild.NewFirstBootStage(usersFirstBootOptions(userOptions)))
+		} else {
+			p.AddStage(osbuild.NewUsersStage(userOptions))
 		}
 	}
 
diff --git a/internal/ostree/errors.go b/internal/ostree/errors.go
new file mode 100644
index 000000000..2c32d3678
--- /dev/null
+++ b/internal/ostree/errors.go
@@ -0,0 +1,47 @@
+package ostree
+
+import "fmt"
+
+// ResolveRefError is returned when there is a failure to resolve the
+// reference.
+type ResolveRefError struct {
+	msg string
+}
+
+func (e ResolveRefError) Error() string {
+	return e.msg
+}
+
+// NewResolveRefError creates and returns a new ResolveRefError with a given
+// formatted message.
+func NewResolveRefError(msg string, args ...interface{}) ResolveRefError {
+	return ResolveRefError{msg: fmt.Sprintf(msg, args...)}
+}
+
+// InvalidParamsError is returned when a parameter is invalid (e.g., malformed
+// or contains illegal characters).
+type RefError struct {
+	msg string
+}
+
+func (e RefError) Error() string {
+	return e.msg
+}
+
+// NewRefError creates and returns a new InvalidParameterError
+// with a given formatted message.
+func NewRefError(msg string, args ...interface{}) RefError {
+	return RefError{msg: fmt.Sprintf(msg, args...)}
+}
+
+type ParameterComboError struct {
+	msg string
+}
+
+func (e ParameterComboError) Error() string {
+	return e.msg
+}
+
+func NewParameterComboError(msg string, args ...interface{}) ParameterComboError {
+	return ParameterComboError{msg: fmt.Sprintf(msg, args...)}
+}
diff --git a/internal/ostree/mock_ostree_repo/mock_repo.go b/internal/ostree/mock_ostree_repo/mock_repo.go
new file mode 100644
index 000000000..61b22231c
--- /dev/null
+++ b/internal/ostree/mock_ostree_repo/mock_repo.go
@@ -0,0 +1,37 @@
+package mock_ostree_repo
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+)
+
+type OSTreeTestRepo struct {
+	OSTreeRef string
+	Server    *httptest.Server
+}
+
+func (repo *OSTreeTestRepo) TearDown() {
+	if repo == nil {
+		return
+	}
+	repo.Server.Close()
+}
+
+func Setup(ref string) *OSTreeTestRepo {
+	repo := new(OSTreeTestRepo)
+	repo.OSTreeRef = ref
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/refs/heads/"+ref, func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, "02604b2da6e954bd34b8b82a835e5a77d2b60ffa")
+	})
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		// catch-all handler, return 404
+		http.NotFound(w, r)
+	})
+
+	repo.Server = httptest.NewServer(mux)
+
+	return repo
+}
diff --git a/internal/ostree/ostree.go b/internal/ostree/ostree.go
index 20822ada0..3bee97715 100644
--- a/internal/ostree/ostree.go
+++ b/internal/ostree/ostree.go
@@ -2,7 +2,6 @@ package ostree
 
 import (
 	"encoding/hex"
-	"fmt"
 	"io/ioutil"
 	"net/http"
 	"net/url"
@@ -13,41 +12,86 @@ import (
 
 var ostreeRefRE = regexp.MustCompile(`^(?:[\w\d][-._\w\d]*\/)*[\w\d][-._\w\d]*$`)
 
-type OSTreeRequest struct {
+type RequestParams struct {
 	URL    string `json:"url"`
 	Ref    string `json:"ref"`
 	Parent string `json:"parent"`
 }
 
 func VerifyRef(ref string) bool {
-	if len(ref) > 0 && ostreeRefRE.MatchString(ref) {
-		return true
-	}
-	return false
+	return len(ref) > 0 && ostreeRefRE.MatchString(ref)
 }
 
+// ResolveRef resolves the URL path specified by the location and ref
+// (location+"refs/heads/"+ref) and returns the commit ID for the named ref. If
+// there is an error, it will be of type ResolveRefError.
 func ResolveRef(location, ref string) (string, error) {
 	u, err := url.Parse(location)
 	if err != nil {
-		return "", err
+		return "", NewResolveRefError(err.Error())
 	}
 	u.Path = path.Join(u.Path, "refs/heads/", ref)
 	resp, err := http.Get(u.String())
 	if err != nil {
-		return "", err
+		return "", NewResolveRefError(err.Error())
 	}
 	if resp.StatusCode != http.StatusOK {
-		return "", fmt.Errorf("ostree repository %q returned status: %s", u.String(), resp.Status)
+		return "", NewResolveRefError("ostree repository %q returned status: %s", u.String(), resp.Status)
 	}
 	body, err := ioutil.ReadAll(resp.Body)
 	if err != nil {
-		return "", err
+		return "", NewResolveRefError(err.Error())
 	}
 	parent := strings.TrimSpace(string(body))
 	// Check that this is at least a hex string.
 	_, err = hex.DecodeString(parent)
 	if err != nil {
-		return "", fmt.Errorf("ostree repository %q returned invalid reference", u.String())
+		return "", NewResolveRefError("ostree repository %q returned invalid reference", u.String())
 	}
 	return parent, nil
 }
+
+// ResolveParams resolves all necessary missing parameters in the given struct:
+// it sets the defaultRef if none is provided and resolves the parent commit if
+// a URL and Ref are provided. If there is an error, it will be of type
+// InvalidParameterError or ResolveRefError (from the ResolveRef function)
+func ResolveParams(params RequestParams, defaultRef string) (RequestParams, error) {
+	resolved := RequestParams{}
+	resolved.Ref = params.Ref
+	// if ref is not provided, use distro default
+	if resolved.Ref == "" {
+		resolved.Ref = defaultRef
+	} else if !VerifyRef(params.Ref) { // only verify if specified in params
+		return resolved, NewRefError("Invalid ostree ref %q", params.Ref)
+	}
+
+	if params.Parent != "" {
+		// parent must also be a valid ref
+		if !VerifyRef(params.Parent) {
+			return resolved, NewRefError("Invalid ostree parent ref %q", params.Parent)
+		}
+		if params.URL == "" {
+			// specifying parent ref also requires URL
+			return resolved, NewParameterComboError("ostree parent ref specified, but no URL to retrieve it")
+		}
+	}
+
+	resolved.URL = params.URL
+	if resolved.URL != "" {
+		// if a URL is specified, we need to fetch the commit at the URL
+		// the reference to resolve is the parent commit which is defined by
+		// the 'parent' argument
+		// if the parent argument is not specified, we use the specified ref
+		// if neither is specified, we use the default ref
+		parentRef := params.Parent
+		if parentRef == "" {
+			parentRef = resolved.Ref
+		}
+		parent, err := ResolveRef(resolved.URL, parentRef)
+		if err != nil {
+			return resolved, err // ResolveRefError
+		}
+		resolved.Parent = parent
+	}
+	return resolved, nil
+}
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index dcaf8f354..7be7d1bbd 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -2155,7 +2155,7 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 		BlueprintName string               `json:"blueprint_name"`
 		ComposeType   string               `json:"compose_type"`
 		Size          uint64               `json:"size"`
-		OSTree        ostree.OSTreeRequest `json:"ostree"`
+		OSTree        ostree.RequestParams `json:"ostree"`
 		Branch        string               `json:"branch"`
 		Upload        *uploadRequest       `json:"upload"`
 	}
@@ -2224,18 +2224,6 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 		return
 	}
 
-	// set default ostree ref, if one not provided
-	if cr.OSTree.Ref == "" {
-		cr.OSTree.Ref = imageType.OSTreeRef()
-	} else if !ostree.VerifyRef(cr.OSTree.Ref) {
-		errors := responseError{
-			ID:  "InvalidChars",
-			Msg: "Invalid ostree ref",
-		}
-		statusResponseError(writer, http.StatusBadRequest, errors)
-		return
-	}
-
 	composeID := uuid.New()
 
 	var targets []*target.Target
@@ -2243,7 +2231,6 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 		t := uploadRequestToTarget(*cr.Upload, imageType)
 		targets = append(targets, t)
 	}
-
 	// Check for test parameter
 	q, err := url.ParseQuery(request.URL.RawQuery)
 	if err != nil {
@@ -2256,36 +2243,20 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 	}
 	testMode := q.Get("test")
 
-	// Fetch parent ostree commit from ref + url if commit is not
-	// provided. The parameter name "parent" is perhaps slightly misleading
-	// as it represent whatever commit sha the image type requires, not
-	// strictly speaking just the parent commit.
-	if cr.OSTree.Ref != "" && cr.OSTree.URL != "" {
-		if cr.OSTree.Parent != "" {
+	if testMode == "1" || testMode == "2" {
+		// Fake a parent commit for test requests
+		cr.OSTree.Parent = "02604b2da6e954bd34b8b82a835e5a77d2b60ffa"
+	} else {
+		ostreeParams, err := ostree.ResolveParams(cr.OSTree, imageType.OSTreeRef())
+		if err != nil {
 			errors := responseError{
 				ID:  "OSTreeOptionsError",
-				Msg: "Supply at most one of Parent and URL",
+				Msg: err.Error(),
 			}
 			statusResponseError(writer, http.StatusBadRequest, errors)
 			return
 		}
-		var parent string
-		if testMode == "1" || testMode == "2" {
-			// Fake a parent commit for test requests
-			parent = "02604b2da6e954bd34b8b82a835e5a77d2b60ffa"
-		} else {
-			// Resolve the URL and get the parent commit
-			parent, err = ostree.ResolveRef(cr.OSTree.URL, cr.OSTree.Ref)
-			if err != nil {
-				errors := responseError{
-					ID:  "OSTreeCommitError",
-					Msg: err.Error(),
-				}
-				statusResponseError(writer, http.StatusBadRequest, errors)
-				return
-			}
-		}
-		cr.OSTree.Parent = parent
+		cr.OSTree = ostreeParams
 	}
 
 	packageSets, err := api.depsolveBlueprintForImageType(*bp, imageType)
@@ -2329,7 +2300,7 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 	manifest, err := imageType.Manifest(bp.Customizations,
 		distro.ImageOptions{
 			Size: size,
-			OSTree: distro.OSTreeImageOptions{
+			OSTree: ostree.RequestParams{
 				Ref:    cr.OSTree.Ref,
 				Parent: cr.OSTree.Parent,
 				URL:    cr.OSTree.URL,
diff --git a/schutzbot/team_ssh_keys.txt b/schutzbot/team_ssh_keys.txt
index 912a81f76..67aa4e3b9 100644
--- a/schutzbot/team_ssh_keys.txt
+++ b/schutzbot/team_ssh_keys.txt
@@ -12,3 +12,4 @@ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDY/ylCrPBzil4TnZR4tWULpz3QgfBMQyEnMOHDAJNp
 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCtJv3QKdqQ+0+jJND7bXVq9ux87yyi4qyJk7iOsX2VsgAUuYXpBf337p5yNB3N1kjOwGYSDjvDvS7GuhdatuvJI3/xzcyodbwJp32AT76e9uvUQHTBBGmUvBLzw3nk8ZDNp5d4rt2cZvlhv7lzDSt30DF14ivg5Xp/V0tK0BEfFlvYHuHheDeiSOQRQ392J7TefPQOW+JpxANU4Bxc1aHIettaIqQMWm9r4ZELd8M83IYt5Btp1bPsnfYywQMYqNXyDuhwhcsBTR5kVObP0DwxKZbMNPmA2lBvrX2GMIa+qfvKIW87KooaoPLt7CR7/DKfQ1S492L1wIwNUPUBLsQD xiaofwan@dhcp-8-203.nay.redhat.com
 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIAYU2wzSk9r1l3iOwsvaJXCsfQIUga3xzShZJAM1zHv akoutsou-R@redhat.com
 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPqEtsCdSozq0DT8sOazpizsBP65Ni6SMqrQA85Wnfs1 akoutsou-J@redhat.com
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC61wMCjOSHwbVb4VfVyl5sn497qW4PsdQ7Ty7aD6wDNZ/QjjULkDV/yW5WjDlDQ7UqFH0Sr7vywjqDizUAqK7zM5FsUKsUXWHWwg/ehKg8j9xKcMv11AkFoUoujtfAujnKODkk58XSA9whPr7qcw3vPrmog680pnMSzf9LC7J6kXfs6lkoKfBh9VnlxusCrw2yg0qI1fHAZBLPx7mW6+me71QZsS6sVz8v8KXyrXsKTdnF50FjzHcK9HXDBtSJS5wA3fkcRYymJe0o6WMWNdgSRVpoSiWaHHmFgdMUJaYoCfhXzyl7LtNb3Q+Sveg+tJK7JaRXBLMUllOlJ6ll5Hod yih-redhat@redhat.com
diff --git a/templates/dashboards/grafana-dashboard-image-builder-worker-general.configmap.yml b/templates/dashboards/grafana-dashboard-image-builder-worker-general.configmap.yml
index 125ef190d..34ff8d025 100644
--- a/templates/dashboards/grafana-dashboard-image-builder-worker-general.configmap.yml
+++ b/templates/dashboards/grafana-dashboard-image-builder-worker-general.configmap.yml
@@ -31,7 +31,7 @@ data:
       "editable": true,
       "gnetId": null,
       "graphTooltip": 0,
-      "iteration": 1644336592519,
+      "iteration": 1645525186957,
       "links": [],
       "panels": [
         {
@@ -279,8 +279,6 @@ data:
               },
               "mappings": [],
               "max": 1,
-              "min": 3,
-              "noValue": "0",
               "thresholds": {
                 "mode": "absolute",
                 "steps": [
@@ -300,7 +298,7 @@ data:
             "x": 16,
             "y": 1
           },
-          "id": 194,
+          "id": 228,
           "options": {
             "legend": {
               "calcs": [],
@@ -314,10 +312,9 @@ data:
           "targets": [
             {
               "exemplar": true,
-              "expr": "(\n  sum(rate(image_builder_worker_total_jobs{type=~\"osbuild:.*\", status=\"5xx\"}[$interval]))\n  / \n  sum(rate(image_builder_worker_total_jobs{type=~\"osbuild:.*\", status!=\"4xx\"}[$interval]))\n)\nOR on() vector(0) # set fallback incase the above query result is empty",
-              "instant": false,
+              "expr": "(\n  sum(rate(image_builder_worker_total_jobs{type=~\"osbuild:.*\", status=\"5xx\"}[$interval]))\n  /\n  sum(rate(image_builder_worker_total_jobs{type=~\"osbuild:.*\", status!=\"4xx\"}[$interval]))\n)\nOR on() vector(0) # set fallback incase the above query result is empty",
               "interval": "",
-              "legendFormat": "build_job_error_rate",
+              "legendFormat": "",
               "refId": "A"
             }
           ],
@@ -1219,7 +1216,7 @@ data:
         },
         {
           "datasource": "${datasource}",
-          "description": "The throughput rate of osbuild job errors and non-errors over time for the selected time range",
+          "description": "The throughput rate of depsolve job errors and non-errors over time for the selected time range",
           "fieldConfig": {
             "defaults": {
               "color": {
@@ -1318,12 +1315,12 @@ data:
               "refId": "B"
             }
           ],
-          "title": "Build Job Throughput Rate",
+          "title": "Depsolve Job Throughput Rate",
           "type": "timeseries"
         },
         {
           "datasource": "${datasource}",
-          "description": "The number of osbuild job errors (as a percentage) over time for the selected time range",
+          "description": "The number of depsolve job errors (as a percentage) over time for the selected time range",
           "fieldConfig": {
             "defaults": {
               "color": {
@@ -1359,6 +1356,8 @@ data:
               },
               "mappings": [],
               "max": 1,
+              "min": 3,
+              "noValue": "0",
               "thresholds": {
                 "mode": "absolute",
                 "steps": [
@@ -1378,7 +1377,7 @@ data:
             "x": 16,
             "y": 35
           },
-          "id": 228,
+          "id": 194,
           "options": {
             "legend": {
               "calcs": [],
@@ -1392,13 +1391,14 @@ data:
           "targets": [
             {
               "exemplar": true,
-              "expr": "(\n  sum(rate(image_builder_worker_total_jobs{type=~\"osbuild:.*\", status=\"5xx\"}[$interval]))\n  /\n  sum(rate(image_builder_worker_total_jobs{type=~\"osbuild:.*\", status!=\"4xx\"}[$interval]))\n)\nOR on() vector(0) # set fallback incase the above query result is empty",
+              "expr": "(\n  sum(rate(image_builder_worker_total_jobs{type=\"depsolve\", status=\"5xx\"}[$interval]))\n  / \n  sum(rate(image_builder_worker_total_jobs{type=\"depsolve\", status!=\"4xx\"}[$interval]))\n)\nOR on() vector(0) # set fallback incase the above query result is empty",
+              "instant": false,
               "interval": "",
               "legendFormat": "",
               "refId": "A"
             }
           ],
-          "title": "Build Job Error Rate",
+          "title": "Depsolve Job Error Rate",
           "type": "timeseries"
         },
         {
@@ -2743,5 +2743,5 @@ data:
       "timezone": "",
       "title": "Image Builder Worker",
       "uid": "image-builder-worker",
-      "version": 3
+      "version": 4
     }


Test patch:
diff --git a/internal/cloudapi/v2/v2_test.go b/internal/cloudapi/v2/v2_test.go
index 7ed367a5b..7b206d7f1 100644
--- a/internal/cloudapi/v2/v2_test.go
+++ b/internal/cloudapi/v2/v2_test.go
@@ -15,6 +15,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/distro/test_distro"
 	distro_mock "github.com/osbuild/osbuild-composer/internal/mocks/distro"
 	rpmmd_mock "github.com/osbuild/osbuild-composer/internal/mocks/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/ostree/mock_ostree_repo"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/test"
 	"github.com/osbuild/osbuild-composer/internal/worker"
@@ -132,6 +133,12 @@ func TestCompose(t *testing.T) {
 	srv, _, cancel := newV2Server(t, dir)
 	defer cancel()
 
+	// create two ostree repos, one to serve the default test_distro ref (for fallback tests) and one to serve a custom ref
+	ostreeRepoDefault := mock_ostree_repo.Setup(test_distro.New().OSTreeRef())
+	defer ostreeRepoDefault.TearDown()
+	ostreeRepoOther := mock_ostree_repo.Setup("some/other/ref")
+	defer ostreeRepoOther.TearDown()
+
 	// unsupported distribution
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
@@ -271,6 +278,273 @@ func TestCompose(t *testing.T) {
 		"href": "/api/image-builder-composer/v2/compose",
 		"kind": "ComposeId"
 	}`, "id")
+
+	// ostree parameters (success)
+
+	// ref only
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "edge-commit",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}],
+			"upload_options": {
+				"region": "eu-central-1"
+			},
+			"ostree": {
+				"ref": "rhel/10/x86_64/edge"
+			}
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusCreated, `
+	{
+		"href": "/api/image-builder-composer/v2/compose",
+		"kind": "ComposeId"
+	}`, "id")
+
+	// url only (must use ostreeRepoDefault for default ref)
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "edge-commit",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}],
+			"upload_options": {
+				"region": "eu-central-1"
+			},
+			"ostree": {
+				"url": "%s"
+			}
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name, ostreeRepoDefault.Server.URL), http.StatusCreated, `
+	{
+		"href": "/api/image-builder-composer/v2/compose",
+		"kind": "ComposeId"
+	}`, "id")
+
+	// ref + url
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "edge-commit",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}],
+			"upload_options": {
+				"region": "eu-central-1"
+			},
+			"ostree": {
+				"ref": "%s",
+				"url": "%s"
+			}
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name, ostreeRepoDefault.OSTreeRef, ostreeRepoDefault.Server.URL), http.StatusCreated, `
+	{
+		"href": "/api/image-builder-composer/v2/compose",
+		"kind": "ComposeId"
+	}`, "id")
+
+	// parent + url
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "edge-commit",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}],
+			"upload_options": {
+				"region": "eu-central-1"
+			},
+			"ostree": {
+				"parent": "%s",
+				"url": "%s"
+			}
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name, ostreeRepoDefault.OSTreeRef, ostreeRepoDefault.Server.URL), http.StatusCreated, `
+	{
+		"href": "/api/image-builder-composer/v2/compose",
+		"kind": "ComposeId"
+	}`, "id")
+
+	// ref + parent + url
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "edge-commit",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}],
+			"upload_options": {
+				"region": "eu-central-1"
+			},
+			"ostree": {
+				"parent": "%s",
+				"url": "%s",
+				"ref": "a/new/ref"
+			}
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name, ostreeRepoOther.OSTreeRef, ostreeRepoOther.Server.URL), http.StatusCreated, `
+	{
+		"href": "/api/image-builder-composer/v2/compose",
+		"kind": "ComposeId"
+	}`, "id")
+
+	// ostree errors
+
+	// bad url
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "edge-commit",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}],
+			"upload_options": {
+				"region": "eu-central-1"
+			},
+			"ostree": {
+				"ref": "rhel/10/x86_64/edge",
+				"url": "not-a-URL"
+			}
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusBadRequest, `
+	{
+		"href": "/api/image-builder-composer/v2/errors/10",
+		"id": "10",
+		"kind": "Error",
+		"code": "IMAGE-BUILDER-COMPOSER-10",
+		"reason": "Error resolving OSTree repo"
+	}`, "operation_id")
+
+	// bad ref
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "edge-commit",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}],
+			"upload_options": {
+				"region": "eu-central-1"
+			},
+			"ostree": {
+				"ref": "/bad/ref"
+			}
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusBadRequest, `
+	{
+		"href": "/api/image-builder-composer/v2/errors/9",
+		"id": "9",
+		"kind": "Error",
+		"code": "IMAGE-BUILDER-COMPOSER-9",
+		"reason": "Invalid OSTree ref"
+	}`, "operation_id")
+
+	// bad parent ref
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "edge-commit",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}],
+			"upload_options": {
+				"region": "eu-central-1"
+			},
+			"ostree": {
+				"ref": "%s",
+				"url": "%s",
+				"parent": "/bad/ref/number/2"
+			}
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name, ostreeRepoDefault.OSTreeRef, ostreeRepoDefault.Server.URL), http.StatusBadRequest, `
+	{
+		"href": "/api/image-builder-composer/v2/errors/9",
+		"id": "9",
+		"kind": "Error",
+		"code": "IMAGE-BUILDER-COMPOSER-9",
+		"reason": "Invalid OSTree ref"
+	}`, "operation_id")
+
+	// incorrect ref for URL
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "edge-commit",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}],
+			"upload_options": {
+				"region": "eu-central-1"
+			},
+			"ostree": {
+				"url": "%s",
+				"parent": "incorrect/ref"
+			}
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name, ostreeRepoOther.Server.URL), http.StatusBadRequest, `
+	{
+		"href": "/api/image-builder-composer/v2/errors/10",
+		"id": "10",
+		"kind": "Error",
+		"code": "IMAGE-BUILDER-COMPOSER-10",
+		"reason": "Error resolving OSTree repo"
+	}`, "operation_id")
+
+	// parent ref without URL
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "edge-commit",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}],
+			"upload_options": {
+				"region": "eu-central-1"
+			},
+			"ostree": {
+				"parent": "some/ref"
+			}
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusBadRequest, `
+	{
+		"href": "/api/image-builder-composer/v2/errors/27",
+		"id": "27",
+		"kind": "Error",
+		"code": "IMAGE-BUILDER-COMPOSER-27",
+		"reason": "Invalid OSTree parameters or parameter combination"
+	}`, "operation_id")
 }
 
 func TestComposeStatusSuccess(t *testing.T) {
diff --git a/internal/distro/distro_test_common/distro_test_common.go b/internal/distro/distro_test_common/distro_test_common.go
index 009ff10f7..135be8702 100644
--- a/internal/distro/distro_test_common/distro_test_common.go
+++ b/internal/distro/distro_test_common/distro_test_common.go
@@ -15,6 +15,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/distroregistry"
+	"github.com/osbuild/osbuild-composer/internal/ostree"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 )
 
@@ -103,7 +104,7 @@ func TestDistro_Manifest(t *testing.T, pipelinePath string, prefix string, regis
 			got, err := imageType.Manifest(tt.ComposeRequest.Blueprint.Customizations,
 				distro.ImageOptions{
 					Size: imageType.Size(0),
-					OSTree: distro.OSTreeImageOptions{
+					OSTree: ostree.RequestParams{
 						Ref: imageType.OSTreeRef(),
 					},
 				},
diff --git a/internal/distro/test_distro/distro.go b/internal/distro/test_distro/distro.go
index 2fbfba565..f1810e386 100644
--- a/internal/distro/test_distro/distro.go
+++ b/internal/distro/test_distro/distro.go
@@ -160,7 +160,7 @@ func (t *TestImageType) MIMEType() string {
 }
 
 func (t *TestImageType) OSTreeRef() string {
-	return ""
+	return t.architecture.distribution.OSTreeRef()
 }
 
 func (t *TestImageType) Size(size uint64) uint64 {
@@ -232,6 +232,7 @@ func newTestDistro(name, modulePlatformID, releasever string) *TestDistro {
 		name:             name,
 		releasever:       releasever,
 		modulePlatformID: modulePlatformID,
+		ostreeRef:        "test/13/x86_64/edge",
 	}
 
 	ta1 := TestArch{
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index 339097a2c..b588615b9 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -21,6 +21,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/distro/test_distro"
 	"github.com/osbuild/osbuild-composer/internal/distroregistry"
 	rpmmd_mock "github.com/osbuild/osbuild-composer/internal/mocks/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/ostree/mock_ostree_repo"
 	"github.com/osbuild/osbuild-composer/internal/reporegistry"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/store"
@@ -128,8 +129,8 @@ func TestBasic(t *testing.T) {
 	require.NoError(t, err)
 	defer os.RemoveAll(tempdir)
 
+	api, _ := createWeldrAPI(tempdir, rpmmd_mock.BaseFixture)
 	for _, c := range cases {
-		api, _ := createWeldrAPI(tempdir, rpmmd_mock.BaseFixture)
 		test.TestRoute(t, api, true, "GET", c.Path, ``, c.ExpectedStatus, c.ExpectedJSON)
 	}
 }
@@ -737,7 +738,7 @@ func TestCompose(t *testing.T) {
 		},
 		Packages: []rpmmd.PackageSpec{},
 	}
-	expectedComposeOSTreeRef := &store.Compose{
+	expectedComposeOSTree := &store.Compose{
 		Blueprint: &blueprint.Blueprint{
 			Name:           "test",
 			Version:        "0.0.0",
@@ -753,22 +754,6 @@ func TestCompose(t *testing.T) {
 		},
 		Packages: []rpmmd.PackageSpec{},
 	}
-	expectedComposeOSTreeURL := &store.Compose{
-		Blueprint: &blueprint.Blueprint{
-			Name:           "test",
-			Version:        "0.0.0",
-			Packages:       []blueprint.Package{},
-			Modules:        []blueprint.Package{},
-			Groups:         []blueprint.Group{},
-			Customizations: nil,
-		},
-		ImageBuild: store.ImageBuild{
-			QueueStatus: common.IBFinished,
-			ImageType:   imgType,
-			Manifest:    manifest,
-		},
-		Packages: []rpmmd.PackageSpec{},
-	}
 
 	// For 2nd distribution
 	arch2, err := test_distro.New2().GetArch(test_distro.TestArchName)
@@ -796,6 +781,12 @@ func TestCompose(t *testing.T) {
 		Packages: []rpmmd.PackageSpec{},
 	}
 
+	// create two ostree repos, one to serve the default test_distro ref (for fallback tests) and one to serve a custom ref
+	ostreeRepoDefault := mock_ostree_repo.Setup(test_distro.New().OSTreeRef())
+	defer ostreeRepoDefault.TearDown()
+	ostreeRepoOther := mock_ostree_repo.Setup("some/other/ref")
+	defer ostreeRepoOther.TearDown()
+
 	var cases = []struct {
 		External        bool
 		Method          string
@@ -806,16 +797,156 @@ func TestCompose(t *testing.T) {
 		ExpectedCompose *store.Compose
 		IgnoreFields    []string
 	}{
-		{true, "POST", "/api/v0/compose", fmt.Sprintf(`{"blueprint_name": "http-server","compose_type": "%s","branch": "master"}`, test_distro.TestImageTypeName), http.StatusBadRequest, `{"status":false,"errors":[{"id":"UnknownBlueprint","msg":"Unknown blueprint name: http-server"}]}`, nil, []string{"build_id"}},
-		{false, "POST", "/api/v0/compose", fmt.Sprintf(`{"blueprint_name": "test","compose_type": "%s","branch": "master"}`, test_distro.TestImageTypeName), http.StatusOK, `{"status": true}`, expectedComposeLocal, []string{"build_id"}},
-		{false, "POST", "/api/v1/compose", fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","upload":{"image_name":"test_upload","provider":"aws","settings":{"region":"frankfurt","accessKeyID":"accesskey","secretAccessKey":"secretkey","bucket":"clay","key":"imagekey"}}}`, test_distro.TestImageTypeName), http.StatusOK, `{"status": true}`, expectedComposeLocalAndAws, []string{"build_id"}},
-		{false, "POST", "/api/v1/compose", fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"refid","parent":"parentid","url":""}}`, test_distro.TestImageTypeName), http.StatusOK, `{"status": true}`, expectedComposeOSTreeRef, []string{"build_id"}},
-		{false, "POST", "/api/v1/compose?test=2", fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"refid","parent":"","url":"http://ostree/"}}`, test_distro.TestImageTypeName), http.StatusOK, `{"status": true}`, expectedComposeOSTreeURL, []string{"build_id"}},
-		{false, "POST", "/api/v1/compose", fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"refid","parent":"","url":"invalid-url"}}`, test_distro.TestImageTypeName), http.StatusBadRequest, `{"status":false,"errors":[{"id":"OSTreeCommitError","msg":"Get \"invalid-url/refs/heads/refid\": unsupported protocol scheme \"\""}]}`, nil, []string{"build_id"}},
-		{false, "POST", "/api/v1/compose", fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"/bad/ref","parent":"","url":"http://ostree/"}}`, test_distro.TestImageTypeName), http.StatusBadRequest, `{"status":false,"errors":[{"id":"InvalidChars","msg":"Invalid ostree ref"}]}`, expectedComposeOSTreeURL, []string{"build_id"}},
-		{false, "POST", "/api/v1/compose", fmt.Sprintf(`{"blueprint_name": "test-distro-2","compose_type": "%s","branch": "master"}`, test_distro.TestImageTypeName), http.StatusOK, `{"status": true}`, expectedComposeGoodDistro, []string{"build_id"}},
-		{false, "POST", "/api/v1/compose", fmt.Sprintf(`{"blueprint_name": "test-fedora-1","compose_type": "%s","branch": "master"}`, test_distro.TestImageTypeName), http.StatusBadRequest, `{"status": false,"errors":[{"id":"DistroError", "msg":"Unknown distribution: fedora-1"}]}`, nil, []string{"build_id"}},
-		{false, "POST", "/api/v1/compose", `{"blueprint_name": "test-distro-2","compose_type": "imaginary_type","branch": "master"}`, http.StatusBadRequest, `{"status": false,"errors":[{"id":"ComposeError", "msg":"Failed to get compose type \"imaginary_type\": invalid image type: imaginary_type"}]}`, nil, []string{"build_id"}},
+		{
+			true,
+			"POST",
+			"/api/v0/compose",
+			fmt.Sprintf(`{"blueprint_name": "http-server","compose_type": "%s","branch": "master"}`, test_distro.TestImageTypeName),
+			http.StatusBadRequest,
+			`{"status":false,"errors":[{"id":"UnknownBlueprint","msg":"Unknown blueprint name: http-server"}]}`,
+			nil,
+			[]string{"build_id"},
+		},
+		{
+			false,
+			"POST",
+			"/api/v0/compose",
+			fmt.Sprintf(`{"blueprint_name": "test","compose_type": "%s","branch": "master"}`, test_distro.TestImageTypeName),
+			http.StatusOK,
+			`{"status": true}`,
+			expectedComposeLocal,
+			[]string{"build_id"},
+		},
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","upload":{"image_name":"test_upload","provider":"aws","settings":{"region":"frankfurt","accessKeyID":"accesskey","secretAccessKey":"secretkey","bucket":"clay","key":"imagekey"}}}`, test_distro.TestImageTypeName),
+			http.StatusOK,
+			`{"status": true}`,
+			expectedComposeLocalAndAws,
+			[]string{"build_id"},
+		},
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test-distro-2","compose_type": "%s","branch": "master"}`, test_distro.TestImageTypeName),
+			http.StatusOK,
+			`{"status": true}`,
+			expectedComposeGoodDistro,
+			[]string{"build_id"},
+		},
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test-fedora-1","compose_type": "%s","branch": "master"}`, test_distro.TestImageTypeName),
+			http.StatusBadRequest,
+			`{"status": false,"errors":[{"id":"DistroError", "msg":"Unknown distribution: fedora-1"}]}`,
+			nil,
+			[]string{"build_id"},
+		},
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			`{"blueprint_name": "test-distro-2","compose_type": "imaginary_type","branch": "master"}`,
+			http.StatusBadRequest,
+			`{"status": false,"errors":[{"id":"ComposeError", "msg":"Failed to get compose type \"imaginary_type\": invalid image type: imaginary_type"}]}`,
+			nil,
+			[]string{"build_id"},
+		},
+
+		// === OSTree params ===
+		// Ref + Parent = error (parent without URL)
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"refid","parent":"parentid","url":""}}`, test_distro.TestImageTypeName),
+			http.StatusBadRequest,
+			`{"status": false, "errors":[{"id":"OSTreeOptionsError","msg":"ostree parent ref specified, but no URL to retrieve it"}]}`,
+			expectedComposeOSTree,
+			[]string{"build_id"},
+		},
+		// Valid Ref + URL = OK
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"%s","parent":"","url":"%s"}}`, test_distro.TestImageTypeName, ostreeRepoOther.OSTreeRef, ostreeRepoOther.Server.URL),
+			http.StatusOK,
+			`{"status": true}`,
+			expectedComposeOSTree,
+			[]string{"build_id"},
+		},
+		// Ref + invalid URL = error
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"whatever","parent":"","url":"invalid-url"}}`, test_distro.TestImageTypeName),
+			http.StatusBadRequest,
+			`{"status":false,"errors":[{"id":"OSTreeOptionsError","msg":"Get \"invalid-url/refs/heads/whatever\": unsupported protocol scheme \"\""}]}`,
+			nil,
+			[]string{"build_id"},
+		},
+		// Bad Ref + URL = error
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"/bad/ref","parent":"","url":"http://ostree/"}}`, test_distro.TestImageTypeName),
+			http.StatusBadRequest,
+			`{"status":false,"errors":[{"id":"OSTreeOptionsError","msg":"Invalid ostree ref \"/bad/ref\""}]}`,
+			expectedComposeOSTree,
+			[]string{"build_id"},
+		},
+		// Incorrect Ref + URL = the parameters are okay, but the ostree repo returns 404
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"%s","parent":"","url":"%s"}}`, test_distro.TestImageTypeName, "the/wrong/ref", ostreeRepoDefault.Server.URL),
+			http.StatusBadRequest,
+			fmt.Sprintf(`{"status":false,"errors":[{"id":"OSTreeOptionsError","msg":"ostree repository \"%s/refs/heads/the/wrong/ref\" returned status: 404 Not Found"}]}`, ostreeRepoDefault.Server.URL),
+			expectedComposeOSTree,
+			[]string{"build_id"},
+		},
+		// Ref + Parent + URL = OK
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"%s","parent":"%s","url":"%s"}}`, test_distro.TestImageTypeName, "the/new/ref", ostreeRepoOther.OSTreeRef, ostreeRepoOther.Server.URL),
+			http.StatusOK,
+			`{"status":true}`,
+			expectedComposeOSTree,
+			[]string{"build_id"},
+		},
+		// Parent + URL = OK
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"","parent":"%s","url":"%s"}}`, test_distro.TestImageTypeName, ostreeRepoDefault.OSTreeRef, ostreeRepoDefault.Server.URL),
+			http.StatusOK,
+			`{"status":true}`,
+			expectedComposeOSTree,
+			[]string{"build_id"},
+		},
+		// URL only = OK (uses default ref, so we need to specify URL for ostree repo with default ref)
+		{
+			false,
+			"POST",
+			"/api/v1/compose",
+			fmt.Sprintf(`{"blueprint_name": "test","compose_type":"%s","branch":"master","ostree":{"ref":"","parent":"","url":"%s"}}`, test_distro.TestImageTypeName, ostreeRepoDefault.Server.URL),
+			http.StatusOK,
+			`{"status":true}`,
+			expectedComposeOSTree,
+			[]string{"build_id"},
+		},
 	}
 
 	tempdir, err := ioutil.TempDir("", "weldr-tests-")
diff --git a/test/cases/base_tests.sh b/test/cases/base_tests.sh
index c364f7e2c..ede5e1099 100755
--- a/test/cases/base_tests.sh
+++ b/test/cases/base_tests.sh
@@ -1,8 +1,6 @@
 #!/bin/bash
 set -euo pipefail
 
-source /usr/libexec/tests/osbuild-composer/shared_lib.sh
-
 WORKING_DIRECTORY=/usr/libexec/osbuild-composer
 TESTS_PATH=/usr/libexec/osbuild-composer-test
 mkdir --parents /tmp/logs
@@ -13,19 +11,12 @@ FAILED_TESTS=()
 
 TEST_CASES=(
   "osbuild-weldr-tests"
+  "osbuild-dnf-json-tests"
   "osbuild-composer-cli-tests"
   "osbuild-auth-tests"
   "osbuild-composer-dbjobqueue-tests"
 )
 
-if nvrGreaterOrEqual "osbuild-composer" "41"; then
-    # 0 - osbuild-composer == v41
-    # 11 - osbuild-composer > v41
-    # 12 - osbuild-composer < v41
-    echo "INFO: enabling osbuild-dnf-json-tests"
-    TEST_CASES+=("osbuild-dnf-json-tests")
-fi
-
 # Print out a nice test divider so we know when tests stop and start.
 test_divider () {
     printf "%0.s-" {1..78} && echo
diff --git a/test/cases/ostree-rebase.sh b/test/cases/ostree-rebase.sh
new file mode 100755
index 000000000..2cb4a7af2
--- /dev/null
+++ b/test/cases/ostree-rebase.sh
@@ -0,0 +1,610 @@
+#!/bin/bash
+set -euo pipefail
+
+# Provision the software under test.
+/usr/libexec/osbuild-composer-test/provision.sh
+
+# Get OS data.
+source /etc/os-release
+ARCH=$(uname -m)
+
+# Colorful output.
+function greenprint {
+    echo -e "\033[1;32m[$(date -Isecond)] ${1}\033[0m"
+}
+
+function get_build_info() {
+    key="$1"
+    fname="$2"
+    if rpm -q --quiet weldr-client; then
+        key=".body${key}"
+    fi
+    jq -r "${key}" "${fname}"
+}
+
+# Start libvirtd and test it.
+greenprint "🚀 Starting libvirt daemon"
+sudo systemctl start libvirtd
+sudo virsh list --all > /dev/null
+
+# Set a customized dnsmasq configuration for libvirt so we always get the
+# same address on bootup.
+sudo tee /tmp/integration.xml > /dev/null << EOF
+<network>
+  <name>integration</name>
+  <uuid>1c8fe98c-b53a-4ca4-bbdb-deb0f26b3579</uuid>
+  <forward mode='nat'>
+    <nat>
+      <port start='1024' end='65535'/>
+    </nat>
+  </forward>
+  <bridge name='integration' stp='on' delay='0'/>
+  <mac address='52:54:00:36:46:ef'/>
+  <ip address='192.168.100.1' netmask='255.255.255.0'>
+    <dhcp>
+      <range start='192.168.100.2' end='192.168.100.254'/>
+      <host mac='34:49:22:B0:83:30' name='vm-bios' ip='192.168.100.50'/>
+      <host mac='34:49:22:B0:83:31' name='vm-uefi' ip='192.168.100.51'/>
+    </dhcp>
+  </ip>
+</network>
+EOF
+if ! sudo virsh net-info integration > /dev/null 2>&1; then
+    sudo virsh net-define /tmp/integration.xml
+fi
+if [[ $(sudo virsh net-info integration | grep 'Active' | awk '{print $2}') == 'no' ]]; then
+    sudo virsh net-start integration
+fi
+
+# Allow anyone in the wheel group to talk to libvirt.
+greenprint "🚪 Allowing users in wheel group to talk to libvirt"
+sudo tee /etc/polkit-1/rules.d/50-libvirt.rules > /dev/null << EOF
+polkit.addRule(function(action, subject) {
+    if (action.id == "org.libvirt.unix.manage" &&
+        subject.isInGroup("adm")) {
+            return polkit.Result.YES;
+    }
+});
+EOF
+
+# Set up variables.
+TEST_UUID=$(uuidgen)
+IMAGE_KEY="edge-${TEST_UUID}"
+BIOS_GUEST_ADDRESS=192.168.100.50
+UEFI_GUEST_ADDRESS=192.168.100.51
+PROD_REPO_ADDRESS=192.168.200.1
+PROD_REPO_URL="http://${PROD_REPO_ADDRESS}:8080/repo/"
+ARTIFACTS="ci-artifacts"
+CONTAINER_TYPE=edge-container
+CONTAINER_FILENAME=container.tar
+mkdir -p "${ARTIFACTS}"
+
+# Set up temporary files.
+TEMPDIR=$(mktemp -d)
+BLUEPRINT_FILE=${TEMPDIR}/blueprint.toml
+KS_FILE=${TEMPDIR}/ks.cfg
+COMPOSE_START=${TEMPDIR}/compose-start-${IMAGE_KEY}.json
+COMPOSE_INFO=${TEMPDIR}/compose-info-${IMAGE_KEY}.json
+
+# SSH setup.
+SSH_OPTIONS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5)
+SSH_DATA_DIR=$(/usr/libexec/osbuild-composer-test/gen-ssh.sh)
+SSH_KEY=${SSH_DATA_DIR}/id_rsa
+SSH_KEY_PUB=$(cat "${SSH_KEY}".pub)
+
+case "${ID}-${VERSION_ID}" in
+    "rhel-8.6")
+        OSTREE_REF="rhel/8/${ARCH}/edge"
+        OS_VARIANT="rhel8-unknown"
+        # Use a stable installer image unless it's the nightly pipeline
+        BOOT_LOCATION="http://download.devel.redhat.com/released/rhel-8/RHEL-8/8.5.0/BaseOS/x86_64/os/"
+        if [ "${NIGHTLY:=false}" == "true" ]; then
+            BOOT_LOCATION="${COMPOSE_URL:-}/compose/BaseOS/x86_64/os/"
+        fi
+        PARENT_REF="rhel/8/${ARCH}/edge"
+        ;;
+    "rhel-9.0")
+        OSTREE_REF="rhel/9/${ARCH}/edge"
+        OS_VARIANT="rhel9-unknown"
+        # Use a stable installer image unless it's the nightly pipeline
+        BOOT_LOCATION="http://download.devel.redhat.com/released/rhel-8/RHEL-8/8.5.0/BaseOS/x86_64/os/"
+        if [ "${NIGHTLY:=false}" == "true" ]; then
+            BOOT_LOCATION="${COMPOSE_URL:-}/compose/BaseOS/x86_64/os/"
+        fi
+        PARENT_REF="rhel/9/${ARCH}/edge"
+        ;;
+    "centos-8")
+        OSTREE_REF="centos/8/${ARCH}/edge"
+        OS_VARIANT="centos8"
+        BOOT_LOCATION="http://mirror.centos.org/centos/8-stream/BaseOS/x86_64/os/"
+        PARENT_REF="centos/8/${ARCH}/edge"
+        ;;
+    "centos-9")
+        OSTREE_REF="centos/9/${ARCH}/edge"
+        OS_VARIANT="centos9"
+        BOOT_LOCATION="${COMPOSE_URL:-}/compose/BaseOS/x86_64/os/"
+        PARENT_REF="centos/9/${ARCH}/edge"
+        ;;
+    *)
+        echo "unsupported distro: ${ID}-${VERSION_ID}"
+        exit 1;;
+esac
+
+
+# Get the compose log.
+get_compose_log () {
+    COMPOSE_ID=$1
+    LOG_FILE=${ARTIFACTS}/osbuild-${ID}-${VERSION_ID}-${COMPOSE_ID}.log
+
+    # Download the logs.
+    sudo composer-cli compose log "$COMPOSE_ID" | tee "$LOG_FILE" > /dev/null
+}
+
+# Get the compose metadata.
+get_compose_metadata () {
+    COMPOSE_ID=$1
+    METADATA_FILE=${ARTIFACTS}/osbuild-${ID}-${VERSION_ID}-${COMPOSE_ID}.json
+
+    # Download the metadata.
+    sudo composer-cli compose metadata "$COMPOSE_ID" > /dev/null
+
+    # Find the tarball and extract it.
+    TARBALL=$(basename "$(find . -maxdepth 1 -type f -name "*-metadata.tar")")
+    sudo tar -xf "$TARBALL" -C "${TEMPDIR}"
+    sudo rm -f "$TARBALL"
+
+    # Move the JSON file into place.
+    sudo cat "${TEMPDIR}"/"${COMPOSE_ID}".json | jq -M '.' | tee "$METADATA_FILE" > /dev/null
+}
+
+# Build ostree image.
+build_image() {
+    blueprint_name=$1
+    image_type=$2
+
+    # Get worker unit file so we can watch the journal.
+    WORKER_UNIT=$(sudo systemctl list-units | grep -o -E "osbuild.*worker.*\.service")
+    sudo journalctl -af -n 1 -u "${WORKER_UNIT}" &
+    WORKER_JOURNAL_PID=$!
+    # Stop watching the worker journal when exiting.
+    trap 'sudo pkill -P ${WORKER_JOURNAL_PID}' EXIT
+
+    # Start the compose.
+    greenprint "🚀 Starting compose"
+    if [ $# -eq 3 ]; then
+        repo_url=$3
+        sudo composer-cli --json compose start-ostree --ref "$OSTREE_REF" --url "$repo_url" "$blueprint_name" "$image_type" | tee "$COMPOSE_START"
+    elif [ $# -eq 4 ]; then
+        repo_url=$3
+        parent_ref=$4
+        sudo composer-cli --json compose start-ostree --ref "$OSTREE_REF" --parent "$parent_ref" --url "$repo_url" "$blueprint_name" "$image_type" | tee "$COMPOSE_START"
+    else
+        sudo composer-cli --json compose start-ostree --ref "$OSTREE_REF" "$blueprint_name" "$image_type" | tee "$COMPOSE_START"
+    fi
+    COMPOSE_ID=$(get_build_info ".build_id" "$COMPOSE_START")
+
+    # Wait for the compose to finish.
+    greenprint "⏱ Waiting for compose to finish: ${COMPOSE_ID}"
+    while true; do
+        sudo composer-cli --json compose info "${COMPOSE_ID}" | tee "$COMPOSE_INFO" > /dev/null
+        COMPOSE_STATUS=$(get_build_info ".queue_status" "$COMPOSE_INFO")
+
+        # Is the compose finished?
+        if [[ $COMPOSE_STATUS != RUNNING ]] && [[ $COMPOSE_STATUS != WAITING ]]; then
+            break
+        fi
+
+        # Wait 30 seconds and try again.
+        sleep 5
+    done
+
+    # Capture the compose logs from osbuild.
+    greenprint "💬 Getting compose log and metadata"
+    get_compose_log "$COMPOSE_ID"
+    get_compose_metadata "$COMPOSE_ID"
+
+    # Kill the journal monitor immediately and remove the trap
+    sudo pkill -P ${WORKER_JOURNAL_PID}
+    trap - EXIT
+
+    # Did the compose finish with success?
+    if [[ $COMPOSE_STATUS != FINISHED ]]; then
+        echo "Something went wrong with the compose. 😢"
+        exit 1
+    fi
+}
+
+# Wait for the ssh server up to be.
+wait_for_ssh_up () {
+    SSH_STATUS=$(sudo ssh "${SSH_OPTIONS[@]}" -i "${SSH_KEY}" admin@"${1}" '/bin/bash -c "echo -n READY"')
+    if [[ $SSH_STATUS == READY ]]; then
+        echo 1
+    else
+        echo 0
+    fi
+}
+
+# Clean up our mess.
+clean_up () {
+    greenprint "🧼 Cleaning up"
+
+    # Clear vm
+    # BIOS vm
+    if [[ $(sudo virsh domstate "${IMAGE_KEY}-bios") == "running" ]]; then
+        sudo virsh destroy "${IMAGE_KEY}-bios"
+    fi
+    sudo virsh undefine "${IMAGE_KEY}-bios"
+    # Remove qcow2 file.
+    sudo sudo virsh vol-delete --pool images "${IMAGE_KEY}-bios.qcow2"
+
+    #UEFI vm
+    if [[ $(sudo virsh domstate "${IMAGE_KEY}-uefi") == "running" ]]; then
+        sudo virsh destroy "${IMAGE_KEY}-uefi"
+    fi
+    sudo virsh undefine "${IMAGE_KEY}-uefi" --nvram
+    # Remove qcow2 file.
+    sudo sudo virsh vol-delete --pool images "${IMAGE_KEY}-uefi.qcow2"
+
+    # Remove all containers and images if exist
+    sudo podman system reset --force
+
+    # Remomve tmp dir.
+    sudo rm -rf "$TEMPDIR"
+}
+
+# Test result checking
+check_result () {
+    greenprint "🎏 Checking for test result"
+    if [[ $RESULTS == 1 ]]; then
+        greenprint "💚 Success"
+    else
+        greenprint "❌ Failed"
+        clean_up
+        exit 1
+    fi
+}
+
+# Prepare stage repo network
+greenprint "🔧 Prepare stage repo network"
+sudo podman network inspect edge >/dev/null 2>&1 || sudo podman network create --driver=bridge --subnet=192.168.200.0/24 --gateway=192.168.200.254 edge
+
+##########################################################
+##
+## Build edge-container image and start it in podman
+##
+##########################################################
+
+# Write a blueprint for ostree image.
+tee "$BLUEPRINT_FILE" > /dev/null << EOF
+name = "container"
+description = "A base rhel-edge container image"
+version = "0.0.1"
+modules = []
+groups = []
+
+[[packages]]
+name = "python3"
+version = "*"
+
+[[customizations.user]]
+name = "admin"
+description = "Administrator account"
+password = "\$6\$GRmb7S0p8vsYmXzH\$o0E020S.9JQGaHkszoog4ha4AQVs3sk8q0DvLjSMxoxHBKnB2FBXGQ/OkwZQfW/76ktHd0NX5nls2LPxPuUdl."
+key = "${SSH_KEY_PUB}"
+home = "/home/admin/"
+groups = ["wheel"]
+EOF
+
+greenprint "📄 container blueprint"
+cat "$BLUEPRINT_FILE"
+
+# Prepare the blueprint for the compose.
+greenprint "📋 Preparing container blueprint"
+sudo composer-cli blueprints push "$BLUEPRINT_FILE"
+sudo composer-cli blueprints depsolve container
+
+# Build container image.
+build_image container "${CONTAINER_TYPE}"
+
+# Download the image
+greenprint "📥 Downloading the container image"
+sudo composer-cli compose image "${COMPOSE_ID}" > /dev/null
+
+# Clear stage repo running env
+greenprint "🧹 Clearing stage repo running env"
+# Remove any status containers if exist
+sudo podman ps -a -q --format "{{.ID}}" | sudo xargs --no-run-if-empty podman rm -f
+# Remove all images
+sudo podman rmi -f -a
+
+# Deal with stage repo image
+greenprint "🗜 Starting container"
+IMAGE_FILENAME="${COMPOSE_ID}-${CONTAINER_FILENAME}"
+sudo podman pull "oci-archive:${IMAGE_FILENAME}"
+sudo podman images
+# Run edge stage repo
+greenprint "🛰 Running edge stage repo"
+# Get image id to run image
+EDGE_IMAGE_ID=$(sudo podman images --filter "dangling=true" --format "{{.ID}}")
+sudo podman run -d --name rhel-edge --network edge --ip "$PROD_REPO_ADDRESS" "$EDGE_IMAGE_ID"
+# Clear image file
+sudo rm -f "$IMAGE_FILENAME"
+
+# Wait for container to be running
+until [ "$(sudo podman inspect -f '{{.State.Running}}' rhel-edge)" == "true" ]; do
+    sleep 1;
+done;
+
+# Clean compose and blueprints.
+greenprint "🧽 Clean up container blueprint and compose"
+sudo composer-cli compose delete "${COMPOSE_ID}" > /dev/null
+sudo composer-cli blueprints delete container > /dev/null
+
+# Ensure SELinux is happy with our new images.
+greenprint "👿 Running restorecon on image directory"
+sudo restorecon -Rv /var/lib/libvirt/images/
+
+# Create bios vm qcow2 file for virt install.
+greenprint "Create bios vm qcow2 file for virt install"
+LIBVIRT_IMAGE_PATH=/var/lib/libvirt/images/${IMAGE_KEY}-bios.qcow2
+sudo qemu-img create -f qcow2 "${LIBVIRT_IMAGE_PATH}" 20G
+
+# Write kickstart file for ostree image installation.
+greenprint "Generate kickstart file"
+tee "$KS_FILE" > /dev/null << STOPHERE
+text
+network --bootproto=dhcp --device=link --activate --onboot=on
+
+zerombr
+clearpart --all --initlabel --disklabel=msdos
+autopart --nohome --noswap --type=plain
+rootpw --lock --iscrypted locked
+ostreesetup --nogpg --osname=rhel-edge --remote=rhel-edge --url=${PROD_REPO_URL} --ref=${OSTREE_REF}
+poweroff
+
+%post --log=/var/log/anaconda/post-install.log --erroronfail
+# no sudo password for SSH user
+echo -e 'admin\tALL=(ALL)\tNOPASSWD: ALL' >> /etc/sudoers
+%end
+STOPHERE
+
+# Install ostree image via anaconda on BIOS vm.
+greenprint "Install ostree image via anaconda on BIOS vm"
+sudo virt-install  --initrd-inject="${KS_FILE}" \
+                   --extra-args="inst.ks=file:/ks.cfg console=ttyS0,115200" \
+                   --name="${IMAGE_KEY}-bios"\
+                   --disk path="${LIBVIRT_IMAGE_PATH}",format=qcow2 \
+                   --ram 3072 \
+                   --vcpus 2 \
+                   --network network=integration,mac=34:49:22:B0:83:30 \
+                   --os-type linux \
+                   --os-variant ${OS_VARIANT} \
+                   --location "${BOOT_LOCATION}" \
+                   --nographics \
+                   --noautoconsole \
+                   --wait=-1 \
+                   --noreboot
+
+# Start VM.
+greenprint "Start VM"
+sudo virsh start "${IMAGE_KEY}-bios"
+
+# Check for ssh ready to go.
+greenprint "🛃 Checking for SSH is ready to go"
+for LOOP_COUNTER in $(seq 0 30); do
+    RESULTS="$(wait_for_ssh_up $BIOS_GUEST_ADDRESS)"
+    if [[ $RESULTS == 1 ]]; then
+        echo "SSH is ready now! 🥳"
+        break
+    fi
+    sleep 10
+done
+
+# Check image installation result
+check_result
+
+# Create uefi vm qcow2 file for virt install.
+greenprint "Create uefi vm qcow2 file for virt install"
+LIBVIRT_IMAGE_PATH=/var/lib/libvirt/images/${IMAGE_KEY}-uefi.qcow2
+sudo qemu-img create -f qcow2 "${LIBVIRT_IMAGE_PATH}" 20G
+
+# Install ostree image via anaconda on UEFI vm.
+greenprint "Install ostree image via anaconda on UEFI vm"
+sudo virt-install  --initrd-inject="${KS_FILE}" \
+                   --extra-args="inst.ks=file:/ks.cfg console=ttyS0,115200" \
+                   --name="${IMAGE_KEY}-uefi"\
+                   --disk path="${LIBVIRT_IMAGE_PATH}",format=qcow2 \
+                   --ram 3072 \
+                   --vcpus 2 \
+                   --network network=integration,mac=34:49:22:B0:83:31 \
+                   --os-type linux \
+                   --os-variant ${OS_VARIANT} \
+                   --location "${BOOT_LOCATION}" \
+                   --boot uefi,loader_ro=yes,loader_type=pflash,nvram_template=/usr/share/edk2/ovmf/OVMF_VARS.fd,loader_secure=no \
+                   --nographics \
+                   --noautoconsole \
+                   --wait=-1 \
+                   --noreboot
+
+# Start VM.
+greenprint "Start VM"
+sudo virsh start "${IMAGE_KEY}-uefi"
+
+# Check for ssh ready to go.
+greenprint "🛃 Checking for SSH is ready to go"
+for LOOP_COUNTER in $(seq 0 30); do
+    RESULTS="$(wait_for_ssh_up $UEFI_GUEST_ADDRESS)"
+    if [[ $RESULTS == 1 ]]; then
+        echo "SSH is ready now! 🥳"
+        break
+    fi
+    sleep 10
+done
+
+# Check image installation result
+check_result
+
+##################################################################
+##
+## Build upgrade image with new ref
+##
+##################################################################
+
+# Write a blueprint for ostree image.
+tee "$BLUEPRINT_FILE" > /dev/null << EOF
+name = "upgrade"
+description = "An upgrade rhel-edge container image"
+version = "0.0.2"
+modules = []
+groups = []
+
+[[packages]]
+name = "python3"
+version = "*"
+
+[[packages]]
+name = "wget"
+version = "*"
+
+[[customizations.user]]
+name = "admin"
+description = "Administrator account"
+password = "\$6\$GRmb7S0p8vsYmXzH\$o0E020S.9JQGaHkszoog4ha4AQVs3sk8q0DvLjSMxoxHBKnB2FBXGQ/OkwZQfW/76ktHd0NX5nls2LPxPuUdl."
+home = "/home/admin/"
+key = "${SSH_KEY_PUB}"
+groups = ["wheel"]
+
+[customizations.kernel]
+name = "kernel-rt"
+EOF
+
+greenprint "📄 upgrade blueprint"
+cat "$BLUEPRINT_FILE"
+
+# Prepare the blueprint for the compose.
+greenprint "📋 Preparing upgrade blueprint"
+sudo composer-cli blueprints push "$BLUEPRINT_FILE"
+sudo composer-cli blueprints depsolve upgrade
+
+# Build upgrade image.
+OSTREE_REF="test/rhel/x/${ARCH}/edge"
+build_image upgrade "${CONTAINER_TYPE}" "$PROD_REPO_URL" "$PARENT_REF"
+
+# Download the image
+greenprint "📥 Downloading the upgrade image"
+sudo composer-cli compose image "${COMPOSE_ID}" > /dev/null
+
+# Clear stage repo running env
+greenprint "🧹 Clearing stage repo running env"
+# Remove any status containers if exist
+sudo podman ps -a -q --format "{{.ID}}" | sudo xargs --no-run-if-empty podman rm -f
+# Remove all images
+sudo podman rmi -f -a
+
+# Deal with stage repo container
+greenprint "🗜 Extracting image"
+IMAGE_FILENAME="${COMPOSE_ID}-${CONTAINER_FILENAME}"
+sudo podman pull "oci-archive:${IMAGE_FILENAME}"
+sudo podman images
+# Clear image file
+sudo rm -f "$IMAGE_FILENAME"
+
+# Run edge stage repo
+greenprint "🛰 Running edge stage repo"
+# Get image id to run image
+EDGE_IMAGE_ID=$(sudo podman images --filter "dangling=true" --format "{{.ID}}")
+sudo podman run -d --name rhel-edge --network edge --ip "$PROD_REPO_ADDRESS" "$EDGE_IMAGE_ID"
+# Wait for container to be running
+until [ "$(sudo podman inspect -f '{{.State.Running}}' rhel-edge)" == "true" ]; do
+    sleep 1;
+done;
+
+# Get ostree commit value.
+greenprint "🕹  Get ostree upgrade commit value"
+UPGRADE_HASH=$(curl "${PROD_REPO_URL}refs/heads/${OSTREE_REF}")
+
+# Clean compose and blueprints.
+greenprint "🧽 Clean up upgrade blueprint and compose"
+sudo composer-cli compose delete "${COMPOSE_ID}" > /dev/null
+sudo composer-cli blueprints delete upgrade > /dev/null
+
+# Rebase with new ref on BIOS vm
+greenprint "🗳 Rebase with new ref on BIOS vm"
+sudo ssh "${SSH_OPTIONS[@]}" -i "${SSH_KEY}" admin@${BIOS_GUEST_ADDRESS} "sudo rpm-ostree rebase rhel-edge:${OSTREE_REF}"
+sudo ssh "${SSH_OPTIONS[@]}" -i "${SSH_KEY}" admin@${BIOS_GUEST_ADDRESS} "nohup sudo -S systemctl reboot &>/dev/null & exit"
+
+# Sleep 10 seconds here to make sure vm restarted already
+sleep 10
+
+# Check for ssh ready to go.
+greenprint "🛃 Checking for SSH is ready to go"
+# shellcheck disable=SC2034  # Unused variables left for readability
+for LOOP_COUNTER in $(seq 0 30); do
+    RESULTS="$(wait_for_ssh_up $BIOS_GUEST_ADDRESS)"
+    if [[ $RESULTS == 1 ]]; then
+        echo "SSH is ready now! 🥳"
+        break
+    fi
+    sleep 10
+done
+
+# Check ostree upgrade result
+check_result
+
+# Add instance IP address into /etc/ansible/hosts
+sudo tee "${TEMPDIR}"/inventory > /dev/null << EOF
+[ostree_guest]
+${BIOS_GUEST_ADDRESS}
+[ostree_guest:vars]
+ansible_python_interpreter=/usr/bin/python3
+ansible_user=admin
+ansible_private_key_file=${SSH_KEY}
+ansible_ssh_common_args="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
+EOF
+
+# Test IoT/Edge OS
+sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=rhel-edge -e ostree_commit="${UPGRADE_HASH}" -e ostree_ref="rhel-edge:${OSTREE_REF}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
+check_result
+
+# Rebase with new ref on UEFI vm
+greenprint "🗳 Rebase with new ref on UEFI vm"
+sudo ssh "${SSH_OPTIONS[@]}" -i "${SSH_KEY}" admin@${UEFI_GUEST_ADDRESS} "sudo rpm-ostree rebase rhel-edge:${OSTREE_REF}"
+sudo ssh "${SSH_OPTIONS[@]}" -i "${SSH_KEY}" admin@${UEFI_GUEST_ADDRESS} "nohup sudo -S systemctl reboot &>/dev/null & exit"
+
+# Sleep 10 seconds here to make sure vm restarted already
+sleep 10
+
+# Check for ssh ready to go.
+greenprint "🛃 Checking for SSH is ready to go"
+# shellcheck disable=SC2034  # Unused variables left for readability
+for LOOP_COUNTER in $(seq 0 30); do
+    RESULTS="$(wait_for_ssh_up $UEFI_GUEST_ADDRESS)"
+    if [[ $RESULTS == 1 ]]; then
+        echo "SSH is ready now! 🥳"
+        break
+    fi
+    sleep 10
+done
+
+# Check ostree upgrade result
+check_result
+
+# Add instance IP address into /etc/ansible/hosts
+sudo tee "${TEMPDIR}"/inventory > /dev/null << EOF
+[ostree_guest]
+${UEFI_GUEST_ADDRESS}
+[ostree_guest:vars]
+ansible_python_interpreter=/usr/bin/python3
+ansible_user=admin
+ansible_private_key_file=${SSH_KEY}
+ansible_ssh_common_args="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
+EOF
+
+# Test IoT/Edge OS
+sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=rhel-edge -e ostree_commit="${UPGRADE_HASH}" -e ostree_ref="rhel-edge:${OSTREE_REF}" -e skip_rollback_test="true" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
+check_result
+
+# Final success clean up
+clean_up
+
+exit 0
diff --git a/test/cases/ostree.sh b/test/cases/ostree.sh
index b1f0ee6a4..0f90ab71b 100755
--- a/test/cases/ostree.sh
+++ b/test/cases/ostree.sh
@@ -199,7 +199,7 @@ build_image() {
     # Start the compose.
     greenprint "🚀 Starting compose"
     if [[ $blueprint_name == upgrade ]]; then
-        sudo composer-cli --json compose start-ostree --ref "$OSTREE_REF" --parent "$COMMIT_HASH" "$blueprint_name" $IMAGE_TYPE | tee "$COMPOSE_START"
+        sudo composer-cli --json compose start-ostree --ref "$OSTREE_REF" "$blueprint_name" $IMAGE_TYPE | tee "$COMPOSE_START"
     else
         sudo composer-cli --json compose start "$blueprint_name" $IMAGE_TYPE | tee "$COMPOSE_START"
     fi
@@ -336,10 +336,6 @@ greenprint "Clean up osbuild-composer"
 sudo composer-cli compose delete "${COMPOSE_ID}" > /dev/null
 sudo composer-cli blueprints delete ostree > /dev/null
 
-# Get ostree commit value.
-greenprint "Get ostree image commit value"
-COMMIT_HASH=$(jq -r '."ostree-commit"' < ${HTTPD_PATH}/compose.json)
-
 # Ensure SELinux is happy with our new images.
 greenprint "👿 Running restorecon on image directory"
 sudo restorecon -Rv /var/lib/libvirt/images/
diff --git a/test/data/ansible/check_ostree.yaml b/test/data/ansible/check_ostree.yaml
index 4cf98319a..5fb6ba9d1 100644
--- a/test/data/ansible/check_ostree.yaml
+++ b/test/data/ansible/check_ostree.yaml
@@ -3,6 +3,7 @@
   become: no
   vars:
     workspace: "{{ lookup('env', 'WORKSPACE') }}"
+    skip_rollback_test: "false"
     total_counter: "0"
     failed_counter: "0"
 
@@ -10,6 +11,8 @@
     # current target host's IP address
     - debug: var=ansible_all_ipv4_addresses
 
+    - debug: var=ansible_facts['distribution_version']
+
     # default kernel or rt kernel
     - name: check installed kernel
       command: uname -r
@@ -65,6 +68,27 @@
           set_fact:
             failed_counter: "{{ failed_counter | int + 1 }}"
 
+    # case: check ostree ref
+    - name: check ostree ref
+      shell: rpm-ostree status --json | jq -r '.deployments[0].origin'
+      register: result_ref
+
+    - name: check ostree ref deployed
+      block:
+        - assert:
+            that:
+              - result_ref.stdout == ostree_ref
+            fail_msg: "deployed ostree ref failed"
+            success_msg: "ostree ref successful building and deployment"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+      when: (ostree_ref is defined) and (ostree_ref|length > 0)
+
     # case from bug: https://bugzilla.redhat.com/show_bug.cgi?id=1848453
     - name: check ostree-remount status
       command: systemctl is-active ostree-remount.service
@@ -389,6 +413,26 @@
             (ansible_facts['distribution'] == 'CentOS' and ansible_facts['distribution_major_version'] is version('8', '==')) or
             (ansible_facts['distribution'] == 'Fedora')
 
+    - name: greenboot should be installed
+      block:
+        - name: greenboot should be installed
+          shell: rpm -qa | grep greenboot
+          register: result_greenboot_packages
+
+        - assert:
+            that:
+              - "'greenboot-0' in result_greenboot_packages.stdout"
+            fail_msg: "greenboot is not installed"
+            success_msg: "greenboot is installed"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+      when: ansible_facts['distribution_version'] != '8.5'
+
     # case: check greenboot* services
     - name: a list of greenboot* service should be enabled
       block:
@@ -492,6 +536,7 @@
         - name: failed count + 1
           set_fact:
             failed_counter: "{{ failed_counter | int + 1 }}"
+      when: skip_rollback_test == "false"
 
     # case: check ostree commit after rollback
     - name: check ostree commit after rollback
@@ -516,7 +561,9 @@
         - name: failed count + 1
           set_fact:
             failed_counter: "{{ failed_counter | int + 1 }}"
-      when: result_rollback is succeeded
+      when:
+        - skip_rollback_test == "false"
+        - result_rollback is succeeded
 
     # case: check greenboot* services log again
     - name: fallback log should be found here
@@ -545,7 +592,9 @@
         - name: failed count + 1
           set_fact:
             failed_counter: "{{ failed_counter | int + 1 }}"
-      when: result_rollback is succeeded
+      when:
+        - skip_rollback_test == "false"
+        - result_rollback is succeeded
 
     # case: check grubenv variables again
     - name: grubenv variables should contain boot_success=1
@@ -567,7 +616,9 @@
         - name: failed count + 1
           set_fact:
             failed_counter: "{{ failed_counter | int + 1 }}"
-      when: result_rollback is succeeded
+      when:
+        - skip_rollback_test == "false"
+        - result_rollback is succeeded
 
     - assert:
         that: