Repository: osbuild/osbuild-composer
Pull request #1787 (2021-09-15 11:09:10)

internal/common: introduce constants that are set during the build time
See individual commits.

This pull request includes:

- [ ] adequate testing for the new functionality or fixed issue
- [ ] adequate documentation informing people about the change such as
  - [ ] create a file in [news/unreleased](https://github.com/osbuild/osbuild-composer/tree/main/docs/news/unreleased) directory if this change should be mentioned in the release news
  - [ ] 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.

For user-visible changes, "adequate documentation" is an entry describing the
change for users in docs/news. Please refer to docs/news/README.md for details.

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`
-->


msehnout: This PR is now simplified and ready for review. CI is failing for unrelated reasons. I'll restart it again tomorrow.
msehnout: > I wonder if we can test this somehow... 🤔

We would have to execute an rpmbuild with different configuration. I'm not sure it is worth it.

Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8ad0d47ec..4c45dfded 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -179,6 +179,7 @@ OSTree:
           # - openstack/fedora-34-x86_64
           - openstack/rhel-8-x86_64
           - openstack/rhel-8.5-x86_64
+          - openstack/rhel-9.0-beta-nightly-x86_64
 
 New OSTree:
   stage: test
@@ -191,6 +192,17 @@ New OSTree:
       - RUNNER:
           - openstack/rhel-8.5-x86_64-large
 
+New OSTree (RHEL9-BETA):
+  stage: test
+  extends: OSTree
+  script:
+    - schutzbot/deploy.sh
+    - /usr/libexec/tests/osbuild-composer/ostree-ng-og.sh
+  parallel:
+    matrix:
+      - RUNNER:
+          - openstack/rhel-9.0-beta-nightly-x86_64
+
 OSTree simplified installer:
   stage: test
   extends: OSTree
diff --git a/HACKING.md b/HACKING.md
index e57431f12..e3632303f 100644
--- a/HACKING.md
+++ b/HACKING.md
@@ -83,7 +83,7 @@ should be the same location as the `$CONTAINER_CONFIG_DIR` described above
 The services also require a config file each which they expect to be in the
 same directory. The following test files can be copied into it:
 
-    cp ./test/data/composer/osbuild-composer.toml ./test/data/composer/osbuild-worker.toml ./containers/config/
+    cp ./test/data/composer/osbuild-composer.toml ./test/data/worker/osbuild-worker.toml ./containers/config/
 
 The `$CONTAINER_CONFIG_DIR` (default `containers/config`) directory will be mounted inside both containers (see
 the [`docker-composer.yml`](./distribution/docker-compose.yml) file).
diff --git a/NEWS.md b/NEWS.md
index 613a6a74c..85346d4f6 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,3 @@
-# OSBuild Composer - Operating System Image Composition Services
-
 ## CHANGES WITH 34:
 
   * Support temporary AWS credentials
diff --git a/containers/osbuild-composer/entrypoint.py b/containers/osbuild-composer/entrypoint.py
index 5bf9c80f0..89090428d 100644
--- a/containers/osbuild-composer/entrypoint.py
+++ b/containers/osbuild-composer/entrypoint.py
@@ -213,7 +213,7 @@ class Cli(contextlib.AbstractContextManager):
     def _spawn_composer(sockets):
         cmd = [
             "/usr/libexec/osbuild-composer/osbuild-composer",
-            "-v",
+            "-verbose",
         ]
 
         # Prepare the environment for osbuild-composer. Note that we cannot use
diff --git a/distribution/docker-compose.yml b/distribution/docker-compose.yml
index 79cf838f6..d8548335a 100644
--- a/distribution/docker-compose.yml
+++ b/distribution/docker-compose.yml
@@ -16,7 +16,7 @@ services:
       context: ..
       dockerfile: ./distribution/Dockerfile-worker
     # override the entrypoint to specify composer hostname and port
-    entrypoint: /usr/libexec/osbuild-composer/osbuild-worker composer:8700
+    entrypoint: ["/usr/libexec/osbuild-composer/osbuild-worker" "https://composer:8700"]
     volumes:
       - ${CONTAINER_CONFIG_DIR}/:/etc/osbuild-composer
     environment:
diff --git a/go.sum b/go.sum
index 901dd0c68..177f1b2c1 100644
--- a/go.sum
+++ b/go.sum
@@ -902,6 +902,7 @@ golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
 golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
diff --git a/internal/cloudapi/v2/v2.go b/internal/cloudapi/v2/v2.go
index 7b5fcce29..2b1c18796 100644
--- a/internal/cloudapi/v2/v2.go
+++ b/internal/cloudapi/v2/v2.go
@@ -15,6 +15,7 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/labstack/echo/v4"
+	"github.com/labstack/echo/v4/middleware"
 
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/common"
@@ -58,6 +59,8 @@ func (server *Server) Handler(path string) http.Handler {
 	e.StdLogger = server.logger
 	e.Pre(common.OperationIDMiddleware)
 
+	e.Use(middleware.Recover())
+
 	handler := apiHandlers{
 		server: server,
 	}
diff --git a/internal/common/constants.go b/internal/common/constants.go
new file mode 100644
index 000000000..57dc40843
--- /dev/null
+++ b/internal/common/constants.go
@@ -0,0 +1,27 @@
+package common
+
+import "fmt"
+
+// These constants are set during buildtime using additional
+// compiler flags. Not all of them are necessarily defined
+// because RPMs can be build from a tarball and spec file without
+// being in a git repository. On the other hand when building
+// composer inside of a container, there is no RPM layer so in
+// that case the RPM version doesn't exist at all.
+var (
+	// Git revision from which this code was built
+	GitRev = "undefined"
+
+	// RPM Version
+	RpmVersion = "undefined"
+)
+
+func BuildVersion() string {
+	if GitRev != "undefined" {
+		return fmt.Sprintf("git-rev:%s", GitRev)
+	} else if RpmVersion != "undefined" {
+		return fmt.Sprintf("NEVRA:%s", RpmVersion)
+	} else {
+		return "devel"
+	}
+}
diff --git a/internal/distro/rhel90/distro.go b/internal/distro/rhel90/distro.go
index 8911ef16e..5d915a36d 100644
--- a/internal/distro/rhel90/distro.go
+++ b/internal/distro/rhel90/distro.go
@@ -550,6 +550,9 @@ func newDistro(name, modulePlatformID, ostreeRef string) distro.Distro {
 	// Shared Services
 	edgeServices := []string{
 		"NetworkManager.service", "firewalld.service", "sshd.service",
+		"greenboot-grub2-set-counter", "greenboot-grub2-set-success", "greenboot-healthcheck",
+		"greenboot-rpm-ostree-grub2-check-fallback", "greenboot-status", "greenboot-task-runner",
+		"redboot-auto-reboot", "redboot-task-runner",
 	}
 
 	// Image Definitions
diff --git a/internal/distro/rhel90/package_sets.go b/internal/distro/rhel90/package_sets.go
index f28525c9b..7843a6fd0 100644
--- a/internal/distro/rhel90/package_sets.go
+++ b/internal/distro/rhel90/package_sets.go
@@ -233,7 +233,7 @@ func edgeCommitPackageSet() rpmmd.PackageSet {
 	return rpmmd.PackageSet{
 		Include: []string{
 			"redhat-release", "glibc", "glibc-minimal-langpack",
-			/*"nss-altfiles", see rhbz#1916260 */ "dracut-config-generic", "dracut-network",
+			"nss-altfiles", "dracut-config-generic", "dracut-network",
 			"basesystem", "bash", "platform-python", "shadow-utils", "chrony",
 			"setup", "shadow-utils", "sudo", "systemd", "coreutils",
 			"util-linux", "curl", "vim-minimal", "rpm", "rpm-ostree", "polkit",
@@ -279,21 +279,6 @@ func aarch64EdgeCommitPackageSet() rpmmd.PackageSet {
 // INSTALLER PACKAGE SET
 func installerPackageSet() rpmmd.PackageSet {
 	// TODO: simplify
-	/* TODO: These packages were temporarily removed because they don't
-	  exist in RHEL 9:
-		- dump
-		- gfs2-utils
-		- iwl3945-firmware
-		- iwl4965-firmware
-		- iwl6000-firmware
-		- libertas-sd8686-firmware
-		- libertas-sd8787-firmware
-		- libertas-usb8388-firmware
-		- libertas-usb8388-olpc-firmware
-		- libreport-rhel-anaconda-bugzilla
-		- metacity
-		- system-storage-manager
-	*/
 	return rpmmd.PackageSet{
 		Include: []string{
 			"aajohan-comfortaa-fonts", "abattis-cantarell-fonts",
@@ -304,7 +289,7 @@ func installerPackageSet() rpmmd.PackageSet {
 			"dejavu-sans-mono-fonts", "device-mapper-persistent-data",
 			"dmidecode", "dnf", "dracut-config-generic", "dracut-network",
 			/*"dump",*/ "efibootmgr", "ethtool", "ftp", "gdb-gdbserver", "gdisk",
-			/*"gfs2-utils",*/ "glibc-all-langpacks",
+			/*"gfs2-utils",*/ "glibc-all-langpacks", "gnome-kiosk",
 			"google-noto-sans-cjk-ttc-fonts", "grub2-efi-ia32-cdboot",
 			"grub2-efi-x64-cdboot", "grub2-tools", "grub2-tools-efi",
 			"grub2-tools-extra", "grub2-tools-minimal", "grubby",
@@ -356,30 +341,30 @@ func edgeInstallerPackageSet() rpmmd.PackageSet {
 			"bzip2", "cryptsetup", "curl", "dbus-x11", "dejavu-sans-fonts",
 			"dejavu-sans-mono-fonts", "device-mapper-persistent-data",
 			"dmidecode", "dnf", "dracut-config-generic", "dracut-network",
-			"dump", "efibootmgr", "ethtool", "ftp", "gdb-gdbserver", "gdisk",
-			"gfs2-utils", "glibc-all-langpacks",
+			/*"dump",*/ "efibootmgr", "ethtool", "ftp", "gdb-gdbserver", "gdisk",
+			/*"gfs2-utils",*/ "glibc-all-langpacks", "gnome-kiosk",
 			"google-noto-sans-cjk-ttc-fonts", "grub2-efi-ia32-cdboot",
 			"grub2-efi-x64-cdboot", "grub2-tools", "grub2-tools-efi",
 			"grub2-tools-extra", "grub2-tools-minimal", "grubby",
 			"gsettings-desktop-schemas", "hdparm", "hexedit", "hostname",
 			"initscripts", "ipmitool", "iwl1000-firmware", "iwl100-firmware",
 			"iwl105-firmware", "iwl135-firmware", "iwl2000-firmware",
-			"iwl2030-firmware", "iwl3160-firmware", "iwl3945-firmware",
-			"iwl4965-firmware", "iwl5000-firmware", "iwl5150-firmware",
+			"iwl2030-firmware", "iwl3160-firmware", /*"iwl3945-firmware",*/
+			/*"iwl4965-firmware",*/ "iwl5000-firmware", "iwl5150-firmware",
 			"iwl6000g2a-firmware", "iwl6000g2b-firmware",
 			"iwl6050-firmware", "iwl7260-firmware", "jomolhari-fonts",
 			"kacst-farsi-fonts", "kacst-qurn-fonts", "kbd", "kbd-misc",
 			"kdump-anaconda-addon", "kernel", "khmeros-base-fonts", "less",
-			"libblockdev-lvm-dbus", "libertas-sd8686-firmware",
-			"libertas-sd8787-firmware", "libertas-usb8388-firmware",
-			"libertas-usb8388-olpc-firmware", "libibverbs",
+			"libblockdev-lvm-dbus", /*"libertas-sd8686-firmware",*/
+			/*"libertas-sd8787-firmware",*/ /*"libertas-usb8388-firmware",*/
+			/*"libertas-usb8388-olpc-firmware",*/ "libibverbs",
 			"libreport-plugin-bugzilla", "libreport-plugin-reportuploader",
-			"libreport-rhel-anaconda-bugzilla", "librsvg2", "linux-firmware",
+			/*"libreport-rhel-anaconda-bugzilla",*/ "librsvg2", "linux-firmware",
 			"lklug-fonts", "lohit-assamese-fonts", "lohit-bengali-fonts",
 			"lohit-devanagari-fonts", "lohit-gujarati-fonts",
 			"lohit-gurmukhi-fonts", "lohit-kannada-fonts", "lohit-odia-fonts",
 			"lohit-tamil-fonts", "lohit-telugu-fonts", "lsof", "madan-fonts",
-			"memtest86+", "metacity", "mtr", "mt-st", "net-tools", "nfs-utils",
+			"memtest86+" /*"metacity",*/, "mtr", "mt-st", "net-tools", "nfs-utils",
 			"nmap-ncat", "nm-connection-editor", "nss-tools",
 			"openssh-clients", "openssh-server", "oscap-anaconda-addon",
 			"ostree", "pciutils", "perl-interpreter", "pigz", "plymouth",
@@ -389,7 +374,7 @@ func edgeInstallerPackageSet() rpmmd.PackageSet {
 			"shim-ia32", "shim-x64", "sil-abyssinica-fonts",
 			"sil-padauk-fonts", "sil-scheherazade-fonts", "smartmontools",
 			"smc-meera-fonts", "spice-vdagent", "strace", "syslinux",
-			"systemd", "system-storage-manager", "tar",
+			"systemd" /*"system-storage-manager",*/, "tar",
 			"thai-scalable-waree-fonts", "tigervnc-server-minimal",
 			"tigervnc-server-module", "udisks2", "udisks2-iscsi", "usbutils",
 			"vim-minimal", "volume_key", "wget", "xfsdump", "xfsprogs",
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index af90a4c18..798682ce3 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -561,7 +561,7 @@ func (api *API) statusHandler(writer http.ResponseWriter, request *http.Request,
 		DBVersion:     "0",
 		SchemaVersion: "0",
 		Backend:       "osbuild-composer",
-		Build:         "devel",
+		Build:         common.BuildVersion(),
 		Messages:      make([]string, 0),
 	})
 	common.PanicOnError(err)
diff --git a/osbuild-composer.spec b/osbuild-composer.spec
index 28f62871e..756104aad 100644
--- a/osbuild-composer.spec
+++ b/osbuild-composer.spec
@@ -124,6 +124,14 @@ export GOPATH=$GO_BUILD_PATH:%{gopath}
 export GOFLAGS=-mod=vendor
 %endif
 
+# Set the commit hash so that composer can report what source version
+# was used to build it. This has to be set explicitly when calling rpmbuild,
+# this script will not attempt to automatically discover it.
+%if %{?commit:1}0
+export LDFLAGS="${LDFLAGS} -X 'github.com/osbuild/osbuild-composer/internal/common.GitRev=%{commit}'"
+%endif
+export LDFLAGS="${LDFLAGS} -X 'github.com/osbuild/osbuild-composer/internal/common.RpmVersion=%{name}-%{epoch}:%{version}-%{release}.%{_arch}'"
+
 %gobuild -o _bin/osbuild-composer %{goipath}/cmd/osbuild-composer
 %gobuild -o _bin/osbuild-worker %{goipath}/cmd/osbuild-worker
 
diff --git a/tools/provision.sh b/tools/provision.sh
index ec4bb8021..25badacc8 100755
--- a/tools/provision.sh
+++ b/tools/provision.sh
@@ -20,7 +20,12 @@ elif [[ $ID == rhel || $ID == centos ]] && [[ ${VERSION_ID%.*} == 9 ]]; then
     sudo dnf install -y ./redhat-internal-cert-install-0.1-23.el7.csb.noarch.rpm dnf-plugins-core
     sudo dnf copr enable -y copr.devel.redhat.com/osbuild-team/epel-el9 "rhel-9.dev-$ARCH"
     # koji is not available yet apparently
-    sudo dnf install -y ansible
+    # jmespath required for json_query
+    sudo dnf install -y ansible python3-jmespath
+
+    # json_query filter, used in our ansible playbooks, was moved to the
+    # 'community.general' collection
+    sudo ansible-galaxy collection install community.general
 fi
 
 sudo mkdir -p /etc/osbuild-composer
diff --git a/vendor/github.com/labstack/echo/v4/middleware/basic_auth.go b/vendor/github.com/labstack/echo/v4/middleware/basic_auth.go
new file mode 100644
index 000000000..8cf1ed9fc
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/basic_auth.go
@@ -0,0 +1,106 @@
+package middleware
+
+import (
+	"encoding/base64"
+	"strconv"
+	"strings"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// BasicAuthConfig defines the config for BasicAuth middleware.
+	BasicAuthConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Validator is a function to validate BasicAuth credentials.
+		// Required.
+		Validator BasicAuthValidator
+
+		// Realm is a string to define realm attribute of BasicAuth.
+		// Default value "Restricted".
+		Realm string
+	}
+
+	// BasicAuthValidator defines a function to validate BasicAuth credentials.
+	BasicAuthValidator func(string, string, echo.Context) (bool, error)
+)
+
+const (
+	basic        = "basic"
+	defaultRealm = "Restricted"
+)
+
+var (
+	// DefaultBasicAuthConfig is the default BasicAuth middleware config.
+	DefaultBasicAuthConfig = BasicAuthConfig{
+		Skipper: DefaultSkipper,
+		Realm:   defaultRealm,
+	}
+)
+
+// BasicAuth returns an BasicAuth middleware.
+//
+// For valid credentials it calls the next handler.
+// For missing or invalid credentials, it sends "401 - Unauthorized" response.
+func BasicAuth(fn BasicAuthValidator) echo.MiddlewareFunc {
+	c := DefaultBasicAuthConfig
+	c.Validator = fn
+	return BasicAuthWithConfig(c)
+}
+
+// BasicAuthWithConfig returns an BasicAuth middleware with config.
+// See `BasicAuth()`.
+func BasicAuthWithConfig(config BasicAuthConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Validator == nil {
+		panic("echo: basic-auth middleware requires a validator function")
+	}
+	if config.Skipper == nil {
+		config.Skipper = DefaultBasicAuthConfig.Skipper
+	}
+	if config.Realm == "" {
+		config.Realm = defaultRealm
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			auth := c.Request().Header.Get(echo.HeaderAuthorization)
+			l := len(basic)
+
+			if len(auth) > l+1 && strings.EqualFold(auth[:l], basic) {
+				b, err := base64.StdEncoding.DecodeString(auth[l+1:])
+				if err != nil {
+					return err
+				}
+				cred := string(b)
+				for i := 0; i < len(cred); i++ {
+					if cred[i] == ':' {
+						// Verify credentials
+						valid, err := config.Validator(cred[:i], cred[i+1:], c)
+						if err != nil {
+							return err
+						} else if valid {
+							return next(c)
+						}
+						break
+					}
+				}
+			}
+
+			realm := defaultRealm
+			if config.Realm != defaultRealm {
+				realm = strconv.Quote(config.Realm)
+			}
+
+			// Need to return `401` for browsers to pop-up login box.
+			c.Response().Header().Set(echo.HeaderWWWAuthenticate, basic+" realm="+realm)
+			return echo.ErrUnauthorized
+		}
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/body_dump.go b/vendor/github.com/labstack/echo/v4/middleware/body_dump.go
new file mode 100644
index 000000000..ebd0d0ab2
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/body_dump.go
@@ -0,0 +1,107 @@
+package middleware
+
+import (
+	"bufio"
+	"bytes"
+	"io"
+	"io/ioutil"
+	"net"
+	"net/http"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// BodyDumpConfig defines the config for BodyDump middleware.
+	BodyDumpConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Handler receives request and response payload.
+		// Required.
+		Handler BodyDumpHandler
+	}
+
+	// BodyDumpHandler receives the request and response payload.
+	BodyDumpHandler func(echo.Context, []byte, []byte)
+
+	bodyDumpResponseWriter struct {
+		io.Writer
+		http.ResponseWriter
+	}
+)
+
+var (
+	// DefaultBodyDumpConfig is the default BodyDump middleware config.
+	DefaultBodyDumpConfig = BodyDumpConfig{
+		Skipper: DefaultSkipper,
+	}
+)
+
+// BodyDump returns a BodyDump middleware.
+//
+// BodyDump middleware captures the request and response payload and calls the
+// registered handler.
+func BodyDump(handler BodyDumpHandler) echo.MiddlewareFunc {
+	c := DefaultBodyDumpConfig
+	c.Handler = handler
+	return BodyDumpWithConfig(c)
+}
+
+// BodyDumpWithConfig returns a BodyDump middleware with config.
+// See: `BodyDump()`.
+func BodyDumpWithConfig(config BodyDumpConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Handler == nil {
+		panic("echo: body-dump middleware requires a handler function")
+	}
+	if config.Skipper == nil {
+		config.Skipper = DefaultBodyDumpConfig.Skipper
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) (err error) {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			// Request
+			reqBody := []byte{}
+			if c.Request().Body != nil { // Read
+				reqBody, _ = ioutil.ReadAll(c.Request().Body)
+			}
+			c.Request().Body = ioutil.NopCloser(bytes.NewBuffer(reqBody)) // Reset
+
+			// Response
+			resBody := new(bytes.Buffer)
+			mw := io.MultiWriter(c.Response().Writer, resBody)
+			writer := &bodyDumpResponseWriter{Writer: mw, ResponseWriter: c.Response().Writer}
+			c.Response().Writer = writer
+
+			if err = next(c); err != nil {
+				c.Error(err)
+			}
+
+			// Callback
+			config.Handler(c, reqBody, resBody.Bytes())
+
+			return
+		}
+	}
+}
+
+func (w *bodyDumpResponseWriter) WriteHeader(code int) {
+	w.ResponseWriter.WriteHeader(code)
+}
+
+func (w *bodyDumpResponseWriter) Write(b []byte) (int, error) {
+	return w.Writer.Write(b)
+}
+
+func (w *bodyDumpResponseWriter) Flush() {
+	w.ResponseWriter.(http.Flusher).Flush()
+}
+
+func (w *bodyDumpResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+	return w.ResponseWriter.(http.Hijacker).Hijack()
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/body_limit.go b/vendor/github.com/labstack/echo/v4/middleware/body_limit.go
new file mode 100644
index 000000000..b436bd595
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/body_limit.go
@@ -0,0 +1,117 @@
+package middleware
+
+import (
+	"fmt"
+	"io"
+	"sync"
+
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/gommon/bytes"
+)
+
+type (
+	// BodyLimitConfig defines the config for BodyLimit middleware.
+	BodyLimitConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Maximum allowed size for a request body, it can be specified
+		// as `4x` or `4xB`, where x is one of the multiple from K, M, G, T or P.
+		Limit string `yaml:"limit"`
+		limit int64
+	}
+
+	limitedReader struct {
+		BodyLimitConfig
+		reader  io.ReadCloser
+		read    int64
+		context echo.Context
+	}
+)
+
+var (
+	// DefaultBodyLimitConfig is the default BodyLimit middleware config.
+	DefaultBodyLimitConfig = BodyLimitConfig{
+		Skipper: DefaultSkipper,
+	}
+)
+
+// BodyLimit returns a BodyLimit middleware.
+//
+// BodyLimit middleware sets the maximum allowed size for a request body, if the
+// size exceeds the configured limit, it sends "413 - Request Entity Too Large"
+// response. The BodyLimit is determined based on both `Content-Length` request
+// header and actual content read, which makes it super secure.
+// Limit can be specified as `4x` or `4xB`, where x is one of the multiple from K, M,
+// G, T or P.
+func BodyLimit(limit string) echo.MiddlewareFunc {
+	c := DefaultBodyLimitConfig
+	c.Limit = limit
+	return BodyLimitWithConfig(c)
+}
+
+// BodyLimitWithConfig returns a BodyLimit middleware with config.
+// See: `BodyLimit()`.
+func BodyLimitWithConfig(config BodyLimitConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultBodyLimitConfig.Skipper
+	}
+
+	limit, err := bytes.Parse(config.Limit)
+	if err != nil {
+		panic(fmt.Errorf("echo: invalid body-limit=%s", config.Limit))
+	}
+	config.limit = limit
+	pool := limitedReaderPool(config)
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req := c.Request()
+
+			// Based on content length
+			if req.ContentLength > config.limit {
+				return echo.ErrStatusRequestEntityTooLarge
+			}
+
+			// Based on content read
+			r := pool.Get().(*limitedReader)
+			r.Reset(req.Body, c)
+			defer pool.Put(r)
+			req.Body = r
+
+			return next(c)
+		}
+	}
+}
+
+func (r *limitedReader) Read(b []byte) (n int, err error) {
+	n, err = r.reader.Read(b)
+	r.read += int64(n)
+	if r.read > r.limit {
+		return n, echo.ErrStatusRequestEntityTooLarge
+	}
+	return
+}
+
+func (r *limitedReader) Close() error {
+	return r.reader.Close()
+}
+
+func (r *limitedReader) Reset(reader io.ReadCloser, context echo.Context) {
+	r.reader = reader
+	r.context = context
+	r.read = 0
+}
+
+func limitedReaderPool(c BodyLimitConfig) sync.Pool {
+	return sync.Pool{
+		New: func() interface{} {
+			return &limitedReader{BodyLimitConfig: c}
+		},
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/compress.go b/vendor/github.com/labstack/echo/v4/middleware/compress.go
new file mode 100644
index 000000000..6ae197453
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/compress.go
@@ -0,0 +1,146 @@
+package middleware
+
+import (
+	"bufio"
+	"compress/gzip"
+	"io"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"strings"
+	"sync"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// GzipConfig defines the config for Gzip middleware.
+	GzipConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Gzip compression level.
+		// Optional. Default value -1.
+		Level int `yaml:"level"`
+	}
+
+	gzipResponseWriter struct {
+		io.Writer
+		http.ResponseWriter
+	}
+)
+
+const (
+	gzipScheme = "gzip"
+)
+
+var (
+	// DefaultGzipConfig is the default Gzip middleware config.
+	DefaultGzipConfig = GzipConfig{
+		Skipper: DefaultSkipper,
+		Level:   -1,
+	}
+)
+
+// Gzip returns a middleware which compresses HTTP response using gzip compression
+// scheme.
+func Gzip() echo.MiddlewareFunc {
+	return GzipWithConfig(DefaultGzipConfig)
+}
+
+// GzipWithConfig return Gzip middleware with config.
+// See: `Gzip()`.
+func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultGzipConfig.Skipper
+	}
+	if config.Level == 0 {
+		config.Level = DefaultGzipConfig.Level
+	}
+
+	pool := gzipCompressPool(config)
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			res := c.Response()
+			res.Header().Add(echo.HeaderVary, echo.HeaderAcceptEncoding)
+			if strings.Contains(c.Request().Header.Get(echo.HeaderAcceptEncoding), gzipScheme) {
+				res.Header().Set(echo.HeaderContentEncoding, gzipScheme) // Issue #806
+				i := pool.Get()
+				w, ok := i.(*gzip.Writer)
+				if !ok {
+					return echo.NewHTTPError(http.StatusInternalServerError, i.(error).Error())
+				}
+				rw := res.Writer
+				w.Reset(rw)
+				defer func() {
+					if res.Size == 0 {
+						if res.Header().Get(echo.HeaderContentEncoding) == gzipScheme {
+							res.Header().Del(echo.HeaderContentEncoding)
+						}
+						// We have to reset response to it's pristine state when
+						// nothing is written to body or error is returned.
+						// See issue #424, #407.
+						res.Writer = rw
+						w.Reset(ioutil.Discard)
+					}
+					w.Close()
+					pool.Put(w)
+				}()
+				grw := &gzipResponseWriter{Writer: w, ResponseWriter: rw}
+				res.Writer = grw
+			}
+			return next(c)
+		}
+	}
+}
+
+func (w *gzipResponseWriter) WriteHeader(code int) {
+	if code == http.StatusNoContent { // Issue #489
+		w.ResponseWriter.Header().Del(echo.HeaderContentEncoding)
+	}
+	w.Header().Del(echo.HeaderContentLength) // Issue #444
+	w.ResponseWriter.WriteHeader(code)
+}
+
+func (w *gzipResponseWriter) Write(b []byte) (int, error) {
+	if w.Header().Get(echo.HeaderContentType) == "" {
+		w.Header().Set(echo.HeaderContentType, http.DetectContentType(b))
+	}
+	return w.Writer.Write(b)
+}
+
+func (w *gzipResponseWriter) Flush() {
+	w.Writer.(*gzip.Writer).Flush()
+	if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
+		flusher.Flush()
+	}
+}
+
+func (w *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+	return w.ResponseWriter.(http.Hijacker).Hijack()
+}
+
+func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
+	if p, ok := w.ResponseWriter.(http.Pusher); ok {
+		return p.Push(target, opts)
+	}
+	return http.ErrNotSupported
+}
+
+func gzipCompressPool(config GzipConfig) sync.Pool {
+	return sync.Pool{
+		New: func() interface{} {
+			w, err := gzip.NewWriterLevel(ioutil.Discard, config.Level)
+			if err != nil {
+				return err
+			}
+			return w
+		},
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/cors.go b/vendor/github.com/labstack/echo/v4/middleware/cors.go
new file mode 100644
index 000000000..d6ef89644
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/cors.go
@@ -0,0 +1,211 @@
+package middleware
+
+import (
+	"net/http"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// CORSConfig defines the config for CORS middleware.
+	CORSConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// AllowOrigin defines a list of origins that may access the resource.
+		// Optional. Default value []string{"*"}.
+		AllowOrigins []string `yaml:"allow_origins"`
+
+		// AllowOriginFunc is a custom function to validate the origin. It takes the
+		// origin as an argument and returns true if allowed or false otherwise. If
+		// an error is returned, it is returned by the handler. If this option is
+		// set, AllowOrigins is ignored.
+		// Optional.
+		AllowOriginFunc func(origin string) (bool, error) `yaml:"allow_origin_func"`
+
+		// AllowMethods defines a list methods allowed when accessing the resource.
+		// This is used in response to a preflight request.
+		// Optional. Default value DefaultCORSConfig.AllowMethods.
+		AllowMethods []string `yaml:"allow_methods"`
+
+		// AllowHeaders defines a list of request headers that can be used when
+		// making the actual request. This is in response to a preflight request.
+		// Optional. Default value []string{}.
+		AllowHeaders []string `yaml:"allow_headers"`
+
+		// AllowCredentials indicates whether or not the response to the request
+		// can be exposed when the credentials flag is true. When used as part of
+		// a response to a preflight request, this indicates whether or not the
+		// actual request can be made using credentials.
+		// Optional. Default value false.
+		AllowCredentials bool `yaml:"allow_credentials"`
+
+		// ExposeHeaders defines a whitelist headers that clients are allowed to
+		// access.
+		// Optional. Default value []string{}.
+		ExposeHeaders []string `yaml:"expose_headers"`
+
+		// MaxAge indicates how long (in seconds) the results of a preflight request
+		// can be cached.
+		// Optional. Default value 0.
+		MaxAge int `yaml:"max_age"`
+	}
+)
+
+var (
+	// DefaultCORSConfig is the default CORS middleware config.
+	DefaultCORSConfig = CORSConfig{
+		Skipper:      DefaultSkipper,
+		AllowOrigins: []string{"*"},
+		AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
+	}
+)
+
+// CORS returns a Cross-Origin Resource Sharing (CORS) middleware.
+// See: https://developer.mozilla.org/en/docs/Web/HTTP/Access_control_CORS
+func CORS() echo.MiddlewareFunc {
+	return CORSWithConfig(DefaultCORSConfig)
+}
+
+// CORSWithConfig returns a CORS middleware with config.
+// See: `CORS()`.
+func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultCORSConfig.Skipper
+	}
+	if len(config.AllowOrigins) == 0 {
+		config.AllowOrigins = DefaultCORSConfig.AllowOrigins
+	}
+	if len(config.AllowMethods) == 0 {
+		config.AllowMethods = DefaultCORSConfig.AllowMethods
+	}
+
+	allowOriginPatterns := []string{}
+	for _, origin := range config.AllowOrigins {
+		pattern := regexp.QuoteMeta(origin)
+		pattern = strings.Replace(pattern, "\\*", ".*", -1)
+		pattern = strings.Replace(pattern, "\\?", ".", -1)
+		pattern = "^" + pattern + "$"
+		allowOriginPatterns = append(allowOriginPatterns, pattern)
+	}
+
+	allowMethods := strings.Join(config.AllowMethods, ",")
+	allowHeaders := strings.Join(config.AllowHeaders, ",")
+	exposeHeaders := strings.Join(config.ExposeHeaders, ",")
+	maxAge := strconv.Itoa(config.MaxAge)
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req := c.Request()
+			res := c.Response()
+			origin := req.Header.Get(echo.HeaderOrigin)
+			allowOrigin := ""
+
+			preflight := req.Method == http.MethodOptions
+			res.Header().Add(echo.HeaderVary, echo.HeaderOrigin)
+
+			// No Origin provided
+			if origin == "" {
+				if !preflight {
+					return next(c)
+				}
+				return c.NoContent(http.StatusNoContent)
+			}
+
+			if config.AllowOriginFunc != nil {
+				allowed, err := config.AllowOriginFunc(origin)
+				if err != nil {
+					return err
+				}
+				if allowed {
+					allowOrigin = origin
+				}
+			} else {
+				// Check allowed origins
+				for _, o := range config.AllowOrigins {
+					if o == "*" && config.AllowCredentials {
+						allowOrigin = origin
+						break
+					}
+					if o == "*" || o == origin {
+						allowOrigin = o
+						break
+					}
+					if matchSubdomain(origin, o) {
+						allowOrigin = origin
+						break
+					}
+				}
+
+				// Check allowed origin patterns
+				for _, re := range allowOriginPatterns {
+					if allowOrigin == "" {
+						didx := strings.Index(origin, "://")
+						if didx == -1 {
+							continue
+						}
+						domAuth := origin[didx+3:]
+						// to avoid regex cost by invalid long domain
+						if len(domAuth) > 253 {
+							break
+						}
+
+						if match, _ := regexp.MatchString(re, origin); match {
+							allowOrigin = origin
+							break
+						}
+					}
+				}
+			}
+
+			// Origin not allowed
+			if allowOrigin == "" {
+				if !preflight {
+					return next(c)
+				}
+				return c.NoContent(http.StatusNoContent)
+			}
+
+			// Simple request
+			if !preflight {
+				res.Header().Set(echo.HeaderAccessControlAllowOrigin, allowOrigin)
+				if config.AllowCredentials {
+					res.Header().Set(echo.HeaderAccessControlAllowCredentials, "true")
+				}
+				if exposeHeaders != "" {
+					res.Header().Set(echo.HeaderAccessControlExposeHeaders, exposeHeaders)
+				}
+				return next(c)
+			}
+
+			// Preflight request
+			res.Header().Add(echo.HeaderVary, echo.HeaderAccessControlRequestMethod)
+			res.Header().Add(echo.HeaderVary, echo.HeaderAccessControlRequestHeaders)
+			res.Header().Set(echo.HeaderAccessControlAllowOrigin, allowOrigin)
+			res.Header().Set(echo.HeaderAccessControlAllowMethods, allowMethods)
+			if config.AllowCredentials {
+				res.Header().Set(echo.HeaderAccessControlAllowCredentials, "true")
+			}
+			if allowHeaders != "" {
+				res.Header().Set(echo.HeaderAccessControlAllowHeaders, allowHeaders)
+			} else {
+				h := req.Header.Get(echo.HeaderAccessControlRequestHeaders)
+				if h != "" {
+					res.Header().Set(echo.HeaderAccessControlAllowHeaders, h)
+				}
+			}
+			if config.MaxAge > 0 {
+				res.Header().Set(echo.HeaderAccessControlMaxAge, maxAge)
+			}
+			return c.NoContent(http.StatusNoContent)
+		}
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/csrf.go b/vendor/github.com/labstack/echo/v4/middleware/csrf.go
new file mode 100644
index 000000000..7804997d4
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/csrf.go
@@ -0,0 +1,221 @@
+package middleware
+
+import (
+	"crypto/subtle"
+	"errors"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/gommon/random"
+)
+
+type (
+	// CSRFConfig defines the config for CSRF middleware.
+	CSRFConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// TokenLength is the length of the generated token.
+		TokenLength uint8 `yaml:"token_length"`
+		// Optional. Default value 32.
+
+		// TokenLookup is a string in the form of "<source>:<key>" that is used
+		// to extract token from the request.
+		// Optional. Default value "header:X-CSRF-Token".
+		// Possible values:
+		// - "header:<name>"
+		// - "form:<name>"
+		// - "query:<name>"
+		TokenLookup string `yaml:"token_lookup"`
+
+		// Context key to store generated CSRF token into context.
+		// Optional. Default value "csrf".
+		ContextKey string `yaml:"context_key"`
+
+		// Name of the CSRF cookie. This cookie will store CSRF token.
+		// Optional. Default value "csrf".
+		CookieName string `yaml:"cookie_name"`
+
+		// Domain of the CSRF cookie.
+		// Optional. Default value none.
+		CookieDomain string `yaml:"cookie_domain"`
+
+		// Path of the CSRF cookie.
+		// Optional. Default value none.
+		CookiePath string `yaml:"cookie_path"`
+
+		// Max age (in seconds) of the CSRF cookie.
+		// Optional. Default value 86400 (24hr).
+		CookieMaxAge int `yaml:"cookie_max_age"`
+
+		// Indicates if CSRF cookie is secure.
+		// Optional. Default value false.
+		CookieSecure bool `yaml:"cookie_secure"`
+
+		// Indicates if CSRF cookie is HTTP only.
+		// Optional. Default value false.
+		CookieHTTPOnly bool `yaml:"cookie_http_only"`
+
+		// Indicates SameSite mode of the CSRF cookie.
+		// Optional. Default value SameSiteDefaultMode.
+		CookieSameSite http.SameSite `yaml:"cookie_same_site"`
+	}
+
+	// csrfTokenExtractor defines a function that takes `echo.Context` and returns
+	// either a token or an error.
+	csrfTokenExtractor func(echo.Context) (string, error)
+)
+
+var (
+	// DefaultCSRFConfig is the default CSRF middleware config.
+	DefaultCSRFConfig = CSRFConfig{
+		Skipper:        DefaultSkipper,
+		TokenLength:    32,
+		TokenLookup:    "header:" + echo.HeaderXCSRFToken,
+		ContextKey:     "csrf",
+		CookieName:     "_csrf",
+		CookieMaxAge:   86400,
+		CookieSameSite: http.SameSiteDefaultMode,
+	}
+)
+
+// CSRF returns a Cross-Site Request Forgery (CSRF) middleware.
+// See: https://en.wikipedia.org/wiki/Cross-site_request_forgery
+func CSRF() echo.MiddlewareFunc {
+	c := DefaultCSRFConfig
+	return CSRFWithConfig(c)
+}
+
+// CSRFWithConfig returns a CSRF middleware with config.
+// See `CSRF()`.
+func CSRFWithConfig(config CSRFConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultCSRFConfig.Skipper
+	}
+	if config.TokenLength == 0 {
+		config.TokenLength = DefaultCSRFConfig.TokenLength
+	}
+	if config.TokenLookup == "" {
+		config.TokenLookup = DefaultCSRFConfig.TokenLookup
+	}
+	if config.ContextKey == "" {
+		config.ContextKey = DefaultCSRFConfig.ContextKey
+	}
+	if config.CookieName == "" {
+		config.CookieName = DefaultCSRFConfig.CookieName
+	}
+	if config.CookieMaxAge == 0 {
+		config.CookieMaxAge = DefaultCSRFConfig.CookieMaxAge
+	}
+	if config.CookieSameSite == http.SameSiteNoneMode {
+		config.CookieSecure = true
+	}
+
+	// Initialize
+	parts := strings.Split(config.TokenLookup, ":")
+	extractor := csrfTokenFromHeader(parts[1])
+	switch parts[0] {
+	case "form":
+		extractor = csrfTokenFromForm(parts[1])
+	case "query":
+		extractor = csrfTokenFromQuery(parts[1])
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req := c.Request()
+			k, err := c.Cookie(config.CookieName)
+			token := ""
+
+			// Generate token
+			if err != nil {
+				token = random.String(config.TokenLength)
+			} else {
+				// Reuse token
+				token = k.Value
+			}
+
+			switch req.Method {
+			case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
+			default:
+				// Validate token only for requests which are not defined as 'safe' by RFC7231
+				clientToken, err := extractor(c)
+				if err != nil {
+					return echo.NewHTTPError(http.StatusBadRequest, err.Error())
+				}
+				if !validateCSRFToken(token, clientToken) {
+					return echo.NewHTTPError(http.StatusForbidden, "invalid csrf token")
+				}
+			}
+
+			// Set CSRF cookie
+			cookie := new(http.Cookie)
+			cookie.Name = config.CookieName
+			cookie.Value = token
+			if config.CookiePath != "" {
+				cookie.Path = config.CookiePath
+			}
+			if config.CookieDomain != "" {
+				cookie.Domain = config.CookieDomain
+			}
+			if config.CookieSameSite != http.SameSiteDefaultMode {
+				cookie.SameSite = config.CookieSameSite
+			}
+			cookie.Expires = time.Now().Add(time.Duration(config.CookieMaxAge) * time.Second)
+			cookie.Secure = config.CookieSecure
+			cookie.HttpOnly = config.CookieHTTPOnly
+			c.SetCookie(cookie)
+
+			// Store token in the context
+			c.Set(config.ContextKey, token)
+
+			// Protect clients from caching the response
+			c.Response().Header().Add(echo.HeaderVary, echo.HeaderCookie)
+
+			return next(c)
+		}
+	}
+}
+
+// csrfTokenFromForm returns a `csrfTokenExtractor` that extracts token from the
+// provided request header.
+func csrfTokenFromHeader(header string) csrfTokenExtractor {
+	return func(c echo.Context) (string, error) {
+		return c.Request().Header.Get(header), nil
+	}
+}
+
+// csrfTokenFromForm returns a `csrfTokenExtractor` that extracts token from the
+// provided form parameter.
+func csrfTokenFromForm(param string) csrfTokenExtractor {
+	return func(c echo.Context) (string, error) {
+		token := c.FormValue(param)
+		if token == "" {
+			return "", errors.New("missing csrf token in the form parameter")
+		}
+		return token, nil
+	}
+}
+
+// csrfTokenFromQuery returns a `csrfTokenExtractor` that extracts token from the
+// provided query parameter.
+func csrfTokenFromQuery(param string) csrfTokenExtractor {
+	return func(c echo.Context) (string, error) {
+		token := c.QueryParam(param)
+		if token == "" {
+			return "", errors.New("missing csrf token in the query string")
+		}
+		return token, nil
+	}
+}
+
+func validateCSRFToken(token, clientToken string) bool {
+	return subtle.ConstantTimeCompare([]byte(token), []byte(clientToken)) == 1
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/decompress.go b/vendor/github.com/labstack/echo/v4/middleware/decompress.go
new file mode 100644
index 000000000..c046359a2
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/decompress.go
@@ -0,0 +1,120 @@
+package middleware
+
+import (
+	"bytes"
+	"compress/gzip"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"sync"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// DecompressConfig defines the config for Decompress middleware.
+	DecompressConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// GzipDecompressPool defines an interface to provide the sync.Pool used to create/store Gzip readers
+		GzipDecompressPool Decompressor
+	}
+)
+
+//GZIPEncoding content-encoding header if set to "gzip", decompress body contents.
+const GZIPEncoding string = "gzip"
+
+// Decompressor is used to get the sync.Pool used by the middleware to get Gzip readers
+type Decompressor interface {
+	gzipDecompressPool() sync.Pool
+}
+
+var (
+	//DefaultDecompressConfig defines the config for decompress middleware
+	DefaultDecompressConfig = DecompressConfig{
+		Skipper:            DefaultSkipper,
+		GzipDecompressPool: &DefaultGzipDecompressPool{},
+	}
+)
+
+// DefaultGzipDecompressPool is the default implementation of Decompressor interface
+type DefaultGzipDecompressPool struct {
+}
+
+func (d *DefaultGzipDecompressPool) gzipDecompressPool() sync.Pool {
+	return sync.Pool{
+		New: func() interface{} {
+			// create with an empty reader (but with GZIP header)
+			w, err := gzip.NewWriterLevel(ioutil.Discard, gzip.BestSpeed)
+			if err != nil {
+				return err
+			}
+
+			b := new(bytes.Buffer)
+			w.Reset(b)
+			w.Flush()
+			w.Close()
+
+			r, err := gzip.NewReader(bytes.NewReader(b.Bytes()))
+			if err != nil {
+				return err
+			}
+			return r
+		},
+	}
+}
+
+//Decompress decompresses request body based if content encoding type is set to "gzip" with default config
+func Decompress() echo.MiddlewareFunc {
+	return DecompressWithConfig(DefaultDecompressConfig)
+}
+
+//DecompressWithConfig decompresses request body based if content encoding type is set to "gzip" with config
+func DecompressWithConfig(config DecompressConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultGzipConfig.Skipper
+	}
+	if config.GzipDecompressPool == nil {
+		config.GzipDecompressPool = DefaultDecompressConfig.GzipDecompressPool
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		pool := config.GzipDecompressPool.gzipDecompressPool()
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+			switch c.Request().Header.Get(echo.HeaderContentEncoding) {
+			case GZIPEncoding:
+				b := c.Request().Body
+
+				i := pool.Get()
+				gr, ok := i.(*gzip.Reader)
+				if !ok {
+					return echo.NewHTTPError(http.StatusInternalServerError, i.(error).Error())
+				}
+
+				if err := gr.Reset(b); err != nil {
+					pool.Put(gr)
+					if err == io.EOF { //ignore if body is empty
+						return next(c)
+					}
+					return err
+				}
+				var buf bytes.Buffer
+				io.Copy(&buf, gr)
+
+				gr.Close()
+				pool.Put(gr)
+
+				b.Close() // http.Request.Body is closed by the Server, but because we are replacing it, it must be closed here
+
+				r := ioutil.NopCloser(&buf)
+				c.Request().Body = r
+			}
+			return next(c)
+		}
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/jwt.go b/vendor/github.com/labstack/echo/v4/middleware/jwt.go
new file mode 100644
index 000000000..c2e7c06d4
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/jwt.go
@@ -0,0 +1,347 @@
+// +build go1.15
+
+package middleware
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"reflect"
+	"strings"
+
+	"github.com/golang-jwt/jwt"
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// JWTConfig defines the config for JWT middleware.
+	JWTConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// BeforeFunc defines a function which is executed just before the middleware.
+		BeforeFunc BeforeFunc
+
+		// SuccessHandler defines a function which is executed for a valid token.
+		SuccessHandler JWTSuccessHandler
+
+		// ErrorHandler defines a function which is executed for an invalid token.
+		// It may be used to define a custom JWT error.
+		ErrorHandler JWTErrorHandler
+
+		// ErrorHandlerWithContext is almost identical to ErrorHandler, but it's passed the current context.
+		ErrorHandlerWithContext JWTErrorHandlerWithContext
+
+		// Signing key to validate token.
+		// This is one of the three options to provide a token validation key.
+		// The order of precedence is a user-defined KeyFunc, SigningKeys and SigningKey.
+		// Required if neither user-defined KeyFunc nor SigningKeys is provided.
+		SigningKey interface{}
+
+		// Map of signing keys to validate token with kid field usage.
+		// This is one of the three options to provide a token validation key.
+		// The order of precedence is a user-defined KeyFunc, SigningKeys and SigningKey.
+		// Required if neither user-defined KeyFunc nor SigningKey is provided.
+		SigningKeys map[string]interface{}
+
+		// Signing method used to check the token's signing algorithm.
+		// Optional. Default value HS256.
+		SigningMethod string
+
+		// Context key to store user information from the token into context.
+		// Optional. Default value "user".
+		ContextKey string
+
+		// Claims are extendable claims data defining token content. Used by default ParseTokenFunc implementation.
+		// Not used if custom ParseTokenFunc is set.
+		// Optional. Default value jwt.MapClaims
+		Claims jwt.Claims
+
+		// TokenLookup is a string in the form of "<source>:<name>" or "<source>:<name>,<source>:<name>" that is used
+		// to extract token from the request.
+		// Optional. Default value "header:Authorization".
+		// Possible values:
+		// - "header:<name>"
+		// - "query:<name>"
+		// - "param:<name>"
+		// - "cookie:<name>"
+		// - "form:<name>"
+		// Multiply sources example:
+		// - "header: Authorization,cookie: myowncookie"
+
+		TokenLookup string
+
+		// AuthScheme to be used in the Authorization header.
+		// Optional. Default value "Bearer".
+		AuthScheme string
+
+		// KeyFunc defines a user-defined function that supplies the public key for a token validation.
+		// The function shall take care of verifying the signing algorithm and selecting the proper key.
+		// A user-defined KeyFunc can be useful if tokens are issued by an external party.
+		// Used by default ParseTokenFunc implementation.
+		//
+		// When a user-defined KeyFunc is provided, SigningKey, SigningKeys, and SigningMethod are ignored.
+		// This is one of the three options to provide a token validation key.
+		// The order of precedence is a user-defined KeyFunc, SigningKeys and SigningKey.
+		// Required if neither SigningKeys nor SigningKey is provided.
+		// Not used if custom ParseTokenFunc is set.
+		// Default to an internal implementation verifying the signing algorithm and selecting the proper key.
+		KeyFunc jwt.Keyfunc
+
+		// ParseTokenFunc defines a user-defined function that parses token from given auth. Returns an error when token
+		// parsing fails or parsed token is invalid.
+		// Defaults to implementation using `github.com/golang-jwt/jwt` as JWT implementation library
+		ParseTokenFunc func(auth string, c echo.Context) (interface{}, error)
+	}
+
+	// JWTSuccessHandler defines a function which is executed for a valid token.
+	JWTSuccessHandler func(echo.Context)
+
+	// JWTErrorHandler defines a function which is executed for an invalid token.
+	JWTErrorHandler func(error) error
+
+	// JWTErrorHandlerWithContext is almost identical to JWTErrorHandler, but it's passed the current context.
+	JWTErrorHandlerWithContext func(error, echo.Context) error
+
+	jwtExtractor func(echo.Context) (string, error)
+)
+
+// Algorithms
+const (
+	AlgorithmHS256 = "HS256"
+)
+
+// Errors
+var (
+	ErrJWTMissing = echo.NewHTTPError(http.StatusBadRequest, "missing or malformed jwt")
+	ErrJWTInvalid = echo.NewHTTPError(http.StatusUnauthorized, "invalid or expired jwt")
+)
+
+var (
+	// DefaultJWTConfig is the default JWT auth middleware config.
+	DefaultJWTConfig = JWTConfig{
+		Skipper:       DefaultSkipper,
+		SigningMethod: AlgorithmHS256,
+		ContextKey:    "user",
+		TokenLookup:   "header:" + echo.HeaderAuthorization,
+		AuthScheme:    "Bearer",
+		Claims:        jwt.MapClaims{},
+		KeyFunc:       nil,
+	}
+)
+
+// JWT returns a JSON Web Token (JWT) auth middleware.
+//
+// For valid token, it sets the user in context and calls next handler.
+// For invalid token, it returns "401 - Unauthorized" error.
+// For missing token, it returns "400 - Bad Request" error.
+//
+// See: https://jwt.io/introduction
+// See `JWTConfig.TokenLookup`
+func JWT(key interface{}) echo.MiddlewareFunc {
+	c := DefaultJWTConfig
+	c.SigningKey = key
+	return JWTWithConfig(c)
+}
+
+// JWTWithConfig returns a JWT auth middleware with config.
+// See: `JWT()`.
+func JWTWithConfig(config JWTConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultJWTConfig.Skipper
+	}
+	if config.SigningKey == nil && len(config.SigningKeys) == 0 && config.KeyFunc == nil && config.ParseTokenFunc == nil {
+		panic("echo: jwt middleware requires signing key")
+	}
+	if config.SigningMethod == "" {
+		config.SigningMethod = DefaultJWTConfig.SigningMethod
+	}
+	if config.ContextKey == "" {
+		config.ContextKey = DefaultJWTConfig.ContextKey
+	}
+	if config.Claims == nil {
+		config.Claims = DefaultJWTConfig.Claims
+	}
+	if config.TokenLookup == "" {
+		config.TokenLookup = DefaultJWTConfig.TokenLookup
+	}
+	if config.AuthScheme == "" {
+		config.AuthScheme = DefaultJWTConfig.AuthScheme
+	}
+	if config.KeyFunc == nil {
+		config.KeyFunc = config.defaultKeyFunc
+	}
+	if config.ParseTokenFunc == nil {
+		config.ParseTokenFunc = config.defaultParseToken
+	}
+
+	// Initialize
+	// Split sources
+	sources := strings.Split(config.TokenLookup, ",")
+	var extractors []jwtExtractor
+	for _, source := range sources {
+		parts := strings.Split(source, ":")
+
+		switch parts[0] {
+		case "query":
+			extractors = append(extractors, jwtFromQuery(parts[1]))
+		case "param":
+			extractors = append(extractors, jwtFromParam(parts[1]))
+		case "cookie":
+			extractors = append(extractors, jwtFromCookie(parts[1]))
+		case "form":
+			extractors = append(extractors, jwtFromForm(parts[1]))
+		case "header":
+			extractors = append(extractors, jwtFromHeader(parts[1], config.AuthScheme))
+		}
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			if config.BeforeFunc != nil {
+				config.BeforeFunc(c)
+			}
+			var auth string
+			var err error
+			for _, extractor := range extractors {
+				// Extract token from extractor, if it's not fail break the loop and
+				// set auth
+				auth, err = extractor(c)
+				if err == nil {
+					break
+				}
+			}
+			// If none of extractor has a token, handle error
+			if err != nil {
+				if config.ErrorHandler != nil {
+					return config.ErrorHandler(err)
+				}
+
+				if config.ErrorHandlerWithContext != nil {
+					return config.ErrorHandlerWithContext(err, c)
+				}
+				return err
+			}
+
+			token, err := config.ParseTokenFunc(auth, c)
+			if err == nil {
+				// Store user information from token into context.
+				c.Set(config.ContextKey, token)
+				if config.SuccessHandler != nil {
+					config.SuccessHandler(c)
+				}
+				return next(c)
+			}
+			if config.ErrorHandler != nil {
+				return config.ErrorHandler(err)
+			}
+			if config.ErrorHandlerWithContext != nil {
+				return config.ErrorHandlerWithContext(err, c)
+			}
+			return &echo.HTTPError{
+				Code:     ErrJWTInvalid.Code,
+				Message:  ErrJWTInvalid.Message,
+				Internal: err,
+			}
+		}
+	}
+}
+
+func (config *JWTConfig) defaultParseToken(auth string, c echo.Context) (interface{}, error) {
+	token := new(jwt.Token)
+	var err error
+	// Issue #647, #656
+	if _, ok := config.Claims.(jwt.MapClaims); ok {
+		token, err = jwt.Parse(auth, config.KeyFunc)
+	} else {
+		t := reflect.ValueOf(config.Claims).Type().Elem()
+		claims := reflect.New(t).Interface().(jwt.Claims)
+		token, err = jwt.ParseWithClaims(auth, claims, config.KeyFunc)
+	}
+	if err != nil {
+		return nil, err
+	}
+	if !token.Valid {
+		return nil, errors.New("invalid token")
+	}
+	return token, nil
+}
+
+// defaultKeyFunc returns a signing key of the given token.
+func (config *JWTConfig) defaultKeyFunc(t *jwt.Token) (interface{}, error) {
+	// Check the signing method
+	if t.Method.Alg() != config.SigningMethod {
+		return nil, fmt.Errorf("unexpected jwt signing method=%v", t.Header["alg"])
+	}
+	if len(config.SigningKeys) > 0 {
+		if kid, ok := t.Header["kid"].(string); ok {
+			if key, ok := config.SigningKeys[kid]; ok {
+				return key, nil
+			}
+		}
+		return nil, fmt.Errorf("unexpected jwt key id=%v", t.Header["kid"])
+	}
+
+	return config.SigningKey, nil
+}
+
+// jwtFromHeader returns a `jwtExtractor` that extracts token from the request header.
+func jwtFromHeader(header string, authScheme string) jwtExtractor {
+	return func(c echo.Context) (string, error) {
+		auth := c.Request().Header.Get(header)
+		l := len(authScheme)
+		if len(auth) > l+1 && auth[:l] == authScheme {
+			return auth[l+1:], nil
+		}
+		return "", ErrJWTMissing
+	}
+}
+
+// jwtFromQuery returns a `jwtExtractor` that extracts token from the query string.
+func jwtFromQuery(param string) jwtExtractor {
+	return func(c echo.Context) (string, error) {
+		token := c.QueryParam(param)
+		if token == "" {
+			return "", ErrJWTMissing
+		}
+		return token, nil
+	}
+}
+
+// jwtFromParam returns a `jwtExtractor` that extracts token from the url param string.
+func jwtFromParam(param string) jwtExtractor {
+	return func(c echo.Context) (string, error) {
+		token := c.Param(param)
+		if token == "" {
+			return "", ErrJWTMissing
+		}
+		return token, nil
+	}
+}
+
+// jwtFromCookie returns a `jwtExtractor` that extracts token from the named cookie.
+func jwtFromCookie(name string) jwtExtractor {
+	return func(c echo.Context) (string, error) {
+		cookie, err := c.Cookie(name)
+		if err != nil {
+			return "", ErrJWTMissing
+		}
+		return cookie.Value, nil
+	}
+}
+
+// jwtFromForm returns a `jwtExtractor` that extracts token from the form field.
+func jwtFromForm(name string) jwtExtractor {
+	return func(c echo.Context) (string, error) {
+		field := c.FormValue(name)
+		if field == "" {
+			return "", ErrJWTMissing
+		}
+		return field, nil
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/key_auth.go b/vendor/github.com/labstack/echo/v4/middleware/key_auth.go
new file mode 100644
index 000000000..fd169aa2c
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/key_auth.go
@@ -0,0 +1,166 @@
+package middleware
+
+import (
+	"errors"
+	"net/http"
+	"strings"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// KeyAuthConfig defines the config for KeyAuth middleware.
+	KeyAuthConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// KeyLookup is a string in the form of "<source>:<name>" that is used
+		// to extract key from the request.
+		// Optional. Default value "header:Authorization".
+		// Possible values:
+		// - "header:<name>"
+		// - "query:<name>"
+		// - "form:<name>"
+		KeyLookup string `yaml:"key_lookup"`
+
+		// AuthScheme to be used in the Authorization header.
+		// Optional. Default value "Bearer".
+		AuthScheme string
+
+		// Validator is a function to validate key.
+		// Required.
+		Validator KeyAuthValidator
+
+		// ErrorHandler defines a function which is executed for an invalid key.
+		// It may be used to define a custom error.
+		ErrorHandler KeyAuthErrorHandler
+	}
+
+	// KeyAuthValidator defines a function to validate KeyAuth credentials.
+	KeyAuthValidator func(string, echo.Context) (bool, error)
+
+	keyExtractor func(echo.Context) (string, error)
+
+	// KeyAuthErrorHandler defines a function which is executed for an invalid key.
+	KeyAuthErrorHandler func(error, echo.Context) error
+)
+
+var (
+	// DefaultKeyAuthConfig is the default KeyAuth middleware config.
+	DefaultKeyAuthConfig = KeyAuthConfig{
+		Skipper:    DefaultSkipper,
+		KeyLookup:  "header:" + echo.HeaderAuthorization,
+		AuthScheme: "Bearer",
+	}
+)
+
+// KeyAuth returns an KeyAuth middleware.
+//
+// For valid key it calls the next handler.
+// For invalid key, it sends "401 - Unauthorized" response.
+// For missing key, it sends "400 - Bad Request" response.
+func KeyAuth(fn KeyAuthValidator) echo.MiddlewareFunc {
+	c := DefaultKeyAuthConfig
+	c.Validator = fn
+	return KeyAuthWithConfig(c)
+}
+
+// KeyAuthWithConfig returns an KeyAuth middleware with config.
+// See `KeyAuth()`.
+func KeyAuthWithConfig(config KeyAuthConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultKeyAuthConfig.Skipper
+	}
+	// Defaults
+	if config.AuthScheme == "" {
+		config.AuthScheme = DefaultKeyAuthConfig.AuthScheme
+	}
+	if config.KeyLookup == "" {
+		config.KeyLookup = DefaultKeyAuthConfig.KeyLookup
+	}
+	if config.Validator == nil {
+		panic("echo: key-auth middleware requires a validator function")
+	}
+
+	// Initialize
+	parts := strings.Split(config.KeyLookup, ":")
+	extractor := keyFromHeader(parts[1], config.AuthScheme)
+	switch parts[0] {
+	case "query":
+		extractor = keyFromQuery(parts[1])
+	case "form":
+		extractor = keyFromForm(parts[1])
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			// Extract and verify key
+			key, err := extractor(c)
+			if err != nil {
+				if config.ErrorHandler != nil {
+					return config.ErrorHandler(err, c)
+				}
+				return echo.NewHTTPError(http.StatusBadRequest, err.Error())
+			}
+			valid, err := config.Validator(key, c)
+			if err != nil {
+				if config.ErrorHandler != nil {
+					return config.ErrorHandler(err, c)
+				}
+				return &echo.HTTPError{
+					Code:     http.StatusUnauthorized,
+					Message:  "invalid key",
+					Internal: err,
+				}
+			} else if valid {
+				return next(c)
+			}
+			return echo.ErrUnauthorized
+		}
+	}
+}
+
+// keyFromHeader returns a `keyExtractor` that extracts key from the request header.
+func keyFromHeader(header string, authScheme string) keyExtractor {
+	return func(c echo.Context) (string, error) {
+		auth := c.Request().Header.Get(header)
+		if auth == "" {
+			return "", errors.New("missing key in request header")
+		}
+		if header == echo.HeaderAuthorization {
+			l := len(authScheme)
+			if len(auth) > l+1 && auth[:l] == authScheme {
+				return auth[l+1:], nil
+			}
+			return "", errors.New("invalid key in the request header")
+		}
+		return auth, nil
+	}
+}
+
+// keyFromQuery returns a `keyExtractor` that extracts key from the query string.
+func keyFromQuery(param string) keyExtractor {
+	return func(c echo.Context) (string, error) {
+		key := c.QueryParam(param)
+		if key == "" {
+			return "", errors.New("missing key in the query string")
+		}
+		return key, nil
+	}
+}
+
+// keyFromForm returns a `keyExtractor` that extracts key from the form.
+func keyFromForm(param string) keyExtractor {
+	return func(c echo.Context) (string, error) {
+		key := c.FormValue(param)
+		if key == "" {
+			return "", errors.New("missing key in the form")
+		}
+		return key, nil
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/logger.go b/vendor/github.com/labstack/echo/v4/middleware/logger.go
new file mode 100644
index 000000000..9baac4769
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/logger.go
@@ -0,0 +1,223 @@
+package middleware
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/gommon/color"
+	"github.com/valyala/fasttemplate"
+)
+
+type (
+	// LoggerConfig defines the config for Logger middleware.
+	LoggerConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Tags to construct the logger format.
+		//
+		// - time_unix
+		// - time_unix_nano
+		// - time_rfc3339
+		// - time_rfc3339_nano
+		// - time_custom
+		// - id (Request ID)
+		// - remote_ip
+		// - uri
+		// - host
+		// - method
+		// - path
+		// - protocol
+		// - referer
+		// - user_agent
+		// - status
+		// - error
+		// - latency (In nanoseconds)
+		// - latency_human (Human readable)
+		// - bytes_in (Bytes received)
+		// - bytes_out (Bytes sent)
+		// - header:<NAME>
+		// - query:<NAME>
+		// - form:<NAME>
+		//
+		// Example "${remote_ip} ${status}"
+		//
+		// Optional. Default value DefaultLoggerConfig.Format.
+		Format string `yaml:"format"`
+
+		// Optional. Default value DefaultLoggerConfig.CustomTimeFormat.
+		CustomTimeFormat string `yaml:"custom_time_format"`
+
+		// Output is a writer where logs in JSON format are written.
+		// Optional. Default value os.Stdout.
+		Output io.Writer
+
+		template *fasttemplate.Template
+		colorer  *color.Color
+		pool     *sync.Pool
+	}
+)
+
+var (
+	// DefaultLoggerConfig is the default Logger middleware config.
+	DefaultLoggerConfig = LoggerConfig{
+		Skipper: DefaultSkipper,
+		Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}",` +
+			`"host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}",` +
+			`"status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}"` +
+			`,"bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
+		CustomTimeFormat: "2006-01-02 15:04:05.00000",
+		colorer:          color.New(),
+	}
+)
+
+// Logger returns a middleware that logs HTTP requests.
+func Logger() echo.MiddlewareFunc {
+	return LoggerWithConfig(DefaultLoggerConfig)
+}
+
+// LoggerWithConfig returns a Logger middleware with config.
+// See: `Logger()`.
+func LoggerWithConfig(config LoggerConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultLoggerConfig.Skipper
+	}
+	if config.Format == "" {
+		config.Format = DefaultLoggerConfig.Format
+	}
+	if config.Output == nil {
+		config.Output = DefaultLoggerConfig.Output
+	}
+
+	config.template = fasttemplate.New(config.Format, "${", "}")
+	config.colorer = color.New()
+	config.colorer.SetOutput(config.Output)
+	config.pool = &sync.Pool{
+		New: func() interface{} {
+			return bytes.NewBuffer(make([]byte, 256))
+		},
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) (err error) {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req := c.Request()
+			res := c.Response()
+			start := time.Now()
+			if err = next(c); err != nil {
+				c.Error(err)
+			}
+			stop := time.Now()
+			buf := config.pool.Get().(*bytes.Buffer)
+			buf.Reset()
+			defer config.pool.Put(buf)
+
+			if _, err = config.template.ExecuteFunc(buf, func(w io.Writer, tag string) (int, error) {
+				switch tag {
+				case "time_unix":
+					return buf.WriteString(strconv.FormatInt(time.Now().Unix(), 10))
+				case "time_unix_nano":
+					return buf.WriteString(strconv.FormatInt(time.Now().UnixNano(), 10))
+				case "time_rfc3339":
+					return buf.WriteString(time.Now().Format(time.RFC3339))
+				case "time_rfc3339_nano":
+					return buf.WriteString(time.Now().Format(time.RFC3339Nano))
+				case "time_custom":
+					return buf.WriteString(time.Now().Format(config.CustomTimeFormat))
+				case "id":
+					id := req.Header.Get(echo.HeaderXRequestID)
+					if id == "" {
+						id = res.Header().Get(echo.HeaderXRequestID)
+					}
+					return buf.WriteString(id)
+				case "remote_ip":
+					return buf.WriteString(c.RealIP())
+				case "host":
+					return buf.WriteString(req.Host)
+				case "uri":
+					return buf.WriteString(req.RequestURI)
+				case "method":
+					return buf.WriteString(req.Method)
+				case "path":
+					p := req.URL.Path
+					if p == "" {
+						p = "/"
+					}
+					return buf.WriteString(p)
+				case "protocol":
+					return buf.WriteString(req.Proto)
+				case "referer":
+					return buf.WriteString(req.Referer())
+				case "user_agent":
+					return buf.WriteString(req.UserAgent())
+				case "status":
+					n := res.Status
+					s := config.colorer.Green(n)
+					switch {
+					case n >= 500:
+						s = config.colorer.Red(n)
+					case n >= 400:
+						s = config.colorer.Yellow(n)
+					case n >= 300:
+						s = config.colorer.Cyan(n)
+					}
+					return buf.WriteString(s)
+				case "error":
+					if err != nil {
+						// Error may contain invalid JSON e.g. `"`
+						b, _ := json.Marshal(err.Error())
+						b = b[1 : len(b)-1]
+						return buf.Write(b)
+					}
+				case "latency":
+					l := stop.Sub(start)
+					return buf.WriteString(strconv.FormatInt(int64(l), 10))
+				case "latency_human":
+					return buf.WriteString(stop.Sub(start).String())
+				case "bytes_in":
+					cl := req.Header.Get(echo.HeaderContentLength)
+					if cl == "" {
+						cl = "0"
+					}
+					return buf.WriteString(cl)
+				case "bytes_out":
+					return buf.WriteString(strconv.FormatInt(res.Size, 10))
+				default:
+					switch {
+					case strings.HasPrefix(tag, "header:"):
+						return buf.Write([]byte(c.Request().Header.Get(tag[7:])))
+					case strings.HasPrefix(tag, "query:"):
+						return buf.Write([]byte(c.QueryParam(tag[6:])))
+					case strings.HasPrefix(tag, "form:"):
+						return buf.Write([]byte(c.FormValue(tag[5:])))
+					case strings.HasPrefix(tag, "cookie:"):
+						cookie, err := c.Cookie(tag[7:])
+						if err == nil {
+							return buf.Write([]byte(cookie.Value))
+						}
+					}
+				}
+				return 0, nil
+			}); err != nil {
+				return
+			}
+
+			if config.Output == nil {
+				_, err = c.Logger().Output().Write(buf.Bytes())
+				return
+			}
+			_, err = config.Output.Write(buf.Bytes())
+			return
+		}
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/method_override.go b/vendor/github.com/labstack/echo/v4/middleware/method_override.go
new file mode 100644
index 000000000..92b14d2ed
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/method_override.go
@@ -0,0 +1,92 @@
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// MethodOverrideConfig defines the config for MethodOverride middleware.
+	MethodOverrideConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Getter is a function that gets overridden method from the request.
+		// Optional. Default values MethodFromHeader(echo.HeaderXHTTPMethodOverride).
+		Getter MethodOverrideGetter
+	}
+
+	// MethodOverrideGetter is a function that gets overridden method from the request
+	MethodOverrideGetter func(echo.Context) string
+)
+
+var (
+	// DefaultMethodOverrideConfig is the default MethodOverride middleware config.
+	DefaultMethodOverrideConfig = MethodOverrideConfig{
+		Skipper: DefaultSkipper,
+		Getter:  MethodFromHeader(echo.HeaderXHTTPMethodOverride),
+	}
+)
+
+// MethodOverride returns a MethodOverride middleware.
+// MethodOverride  middleware checks for the overridden method from the request and
+// uses it instead of the original method.
+//
+// For security reasons, only `POST` method can be overridden.
+func MethodOverride() echo.MiddlewareFunc {
+	return MethodOverrideWithConfig(DefaultMethodOverrideConfig)
+}
+
+// MethodOverrideWithConfig returns a MethodOverride middleware with config.
+// See: `MethodOverride()`.
+func MethodOverrideWithConfig(config MethodOverrideConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultMethodOverrideConfig.Skipper
+	}
+	if config.Getter == nil {
+		config.Getter = DefaultMethodOverrideConfig.Getter
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req := c.Request()
+			if req.Method == http.MethodPost {
+				m := config.Getter(c)
+				if m != "" {
+					req.Method = m
+				}
+			}
+			return next(c)
+		}
+	}
+}
+
+// MethodFromHeader is a `MethodOverrideGetter` that gets overridden method from
+// the request header.
+func MethodFromHeader(header string) MethodOverrideGetter {
+	return func(c echo.Context) string {
+		return c.Request().Header.Get(header)
+	}
+}
+
+// MethodFromForm is a `MethodOverrideGetter` that gets overridden method from the
+// form parameter.
+func MethodFromForm(param string) MethodOverrideGetter {
+	return func(c echo.Context) string {
+		return c.FormValue(param)
+	}
+}
+
+// MethodFromQuery is a `MethodOverrideGetter` that gets overridden method from
+// the query parameter.
+func MethodFromQuery(param string) MethodOverrideGetter {
+	return func(c echo.Context) string {
+		return c.QueryParam(param)
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/middleware.go b/vendor/github.com/labstack/echo/v4/middleware/middleware.go
new file mode 100644
index 000000000..a7ad73a5c
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/middleware.go
@@ -0,0 +1,89 @@
+package middleware
+
+import (
+	"net/http"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// Skipper defines a function to skip middleware. Returning true skips processing
+	// the middleware.
+	Skipper func(echo.Context) bool
+
+	// BeforeFunc defines a function which is executed just before the middleware.
+	BeforeFunc func(echo.Context)
+)
+
+func captureTokens(pattern *regexp.Regexp, input string) *strings.Replacer {
+	groups := pattern.FindAllStringSubmatch(input, -1)
+	if groups == nil {
+		return nil
+	}
+	values := groups[0][1:]
+	replace := make([]string, 2*len(values))
+	for i, v := range values {
+		j := 2 * i
+		replace[j] = "$" + strconv.Itoa(i+1)
+		replace[j+1] = v
+	}
+	return strings.NewReplacer(replace...)
+}
+
+func rewriteRulesRegex(rewrite map[string]string) map[*regexp.Regexp]string {
+	// Initialize
+	rulesRegex := map[*regexp.Regexp]string{}
+	for k, v := range rewrite {
+		k = regexp.QuoteMeta(k)
+		k = strings.Replace(k, `\*`, "(.*?)", -1)
+		if strings.HasPrefix(k, `\^`) {
+			k = strings.Replace(k, `\^`, "^", -1)
+		}
+		k = k + "$"
+		rulesRegex[regexp.MustCompile(k)] = v
+	}
+	return rulesRegex
+}
+
+func rewriteURL(rewriteRegex map[*regexp.Regexp]string, req *http.Request) error {
+	if len(rewriteRegex) == 0 {
+		return nil
+	}
+
+	// Depending how HTTP request is sent RequestURI could contain Scheme://Host/path or be just /path.
+	// We only want to use path part for rewriting and therefore trim prefix if it exists
+	rawURI := req.RequestURI
+	if rawURI != "" && rawURI[0] != '/' {
+		prefix := ""
+		if req.URL.Scheme != "" {
+			prefix = req.URL.Scheme + "://"
+		}
+		if req.URL.Host != "" {
+			prefix += req.URL.Host // host or host:port
+		}
+		if prefix != "" {
+			rawURI = strings.TrimPrefix(rawURI, prefix)
+		}
+	}
+
+	for k, v := range rewriteRegex {
+		if replacer := captureTokens(k, rawURI); replacer != nil {
+			url, err := req.URL.Parse(replacer.Replace(v))
+			if err != nil {
+				return err
+			}
+			req.URL = url
+
+			return nil // rewrite only once
+		}
+	}
+	return nil
+}
+
+// DefaultSkipper returns false which processes the middleware.
+func DefaultSkipper(echo.Context) bool {
+	return false
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/proxy.go b/vendor/github.com/labstack/echo/v4/middleware/proxy.go
new file mode 100644
index 000000000..6cfd6731e
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/proxy.go
@@ -0,0 +1,303 @@
+package middleware
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"math/rand"
+	"net"
+	"net/http"
+	"net/http/httputil"
+	"net/url"
+	"regexp"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/labstack/echo/v4"
+)
+
+// TODO: Handle TLS proxy
+
+type (
+	// ProxyConfig defines the config for Proxy middleware.
+	ProxyConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Balancer defines a load balancing technique.
+		// Required.
+		Balancer ProxyBalancer
+
+		// Rewrite defines URL path rewrite rules. The values captured in asterisk can be
+		// retrieved by index e.g. $1, $2 and so on.
+		// Examples:
+		// "/old":              "/new",
+		// "/api/*":            "/$1",
+		// "/js/*":             "/public/javascripts/$1",
+		// "/users/*/orders/*": "/user/$1/order/$2",
+		Rewrite map[string]string
+
+		// RegexRewrite defines rewrite rules using regexp.Rexexp with captures
+		// Every capture group in the values can be retrieved by index e.g. $1, $2 and so on.
+		// Example:
+		// "^/old/[0.9]+/":     "/new",
+		// "^/api/.+?/(.*)":    "/v2/$1",
+		RegexRewrite map[*regexp.Regexp]string
+
+		// Context key to store selected ProxyTarget into context.
+		// Optional. Default value "target".
+		ContextKey string
+
+		// To customize the transport to remote.
+		// Examples: If custom TLS certificates are required.
+		Transport http.RoundTripper
+
+		// ModifyResponse defines function to modify response from ProxyTarget.
+		ModifyResponse func(*http.Response) error
+	}
+
+	// ProxyTarget defines the upstream target.
+	ProxyTarget struct {
+		Name string
+		URL  *url.URL
+		Meta echo.Map
+	}
+
+	// ProxyBalancer defines an interface to implement a load balancing technique.
+	ProxyBalancer interface {
+		AddTarget(*ProxyTarget) bool
+		RemoveTarget(string) bool
+		Next(echo.Context) *ProxyTarget
+	}
+
+	commonBalancer struct {
+		targets []*ProxyTarget
+		mutex   sync.RWMutex
+	}
+
+	// RandomBalancer implements a random load balancing technique.
+	randomBalancer struct {
+		*commonBalancer
+		random *rand.Rand
+	}
+
+	// RoundRobinBalancer implements a round-robin load balancing technique.
+	roundRobinBalancer struct {
+		*commonBalancer
+		i uint32
+	}
+)
+
+var (
+	// DefaultProxyConfig is the default Proxy middleware config.
+	DefaultProxyConfig = ProxyConfig{
+		Skipper:    DefaultSkipper,
+		ContextKey: "target",
+	}
+)
+
+func proxyRaw(t *ProxyTarget, c echo.Context) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		in, _, err := c.Response().Hijack()
+		if err != nil {
+			c.Set("_error", fmt.Sprintf("proxy raw, hijack error=%v, url=%s", t.URL, err))
+			return
+		}
+		defer in.Close()
+
+		out, err := net.Dial("tcp", t.URL.Host)
+		if err != nil {
+			c.Set("_error", echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("proxy raw, dial error=%v, url=%s", t.URL, err)))
+			return
+		}
+		defer out.Close()
+
+		// Write header
+		err = r.Write(out)
+		if err != nil {
+			c.Set("_error", echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("proxy raw, request header copy error=%v, url=%s", t.URL, err)))
+			return
+		}
+
+		errCh := make(chan error, 2)
+		cp := func(dst io.Writer, src io.Reader) {
+			_, err = io.Copy(dst, src)
+			errCh <- err
+		}
+
+		go cp(out, in)
+		go cp(in, out)
+		err = <-errCh
+		if err != nil && err != io.EOF {
+			c.Set("_error", fmt.Errorf("proxy raw, copy body error=%v, url=%s", t.URL, err))
+		}
+	})
+}
+
+// NewRandomBalancer returns a random proxy balancer.
+func NewRandomBalancer(targets []*ProxyTarget) ProxyBalancer {
+	b := &randomBalancer{commonBalancer: new(commonBalancer)}
+	b.targets = targets
+	return b
+}
+
+// NewRoundRobinBalancer returns a round-robin proxy balancer.
+func NewRoundRobinBalancer(targets []*ProxyTarget) ProxyBalancer {
+	b := &roundRobinBalancer{commonBalancer: new(commonBalancer)}
+	b.targets = targets
+	return b
+}
+
+// AddTarget adds an upstream target to the list.
+func (b *commonBalancer) AddTarget(target *ProxyTarget) bool {
+	for _, t := range b.targets {
+		if t.Name == target.Name {
+			return false
+		}
+	}
+	b.mutex.Lock()
+	defer b.mutex.Unlock()
+	b.targets = append(b.targets, target)
+	return true
+}
+
+// RemoveTarget removes an upstream target from the list.
+func (b *commonBalancer) RemoveTarget(name string) bool {
+	b.mutex.Lock()
+	defer b.mutex.Unlock()
+	for i, t := range b.targets {
+		if t.Name == name {
+			b.targets = append(b.targets[:i], b.targets[i+1:]...)
+			return true
+		}
+	}
+	return false
+}
+
+// Next randomly returns an upstream target.
+func (b *randomBalancer) Next(c echo.Context) *ProxyTarget {
+	if b.random == nil {
+		b.random = rand.New(rand.NewSource(int64(time.Now().Nanosecond())))
+	}
+	b.mutex.RLock()
+	defer b.mutex.RUnlock()
+	return b.targets[b.random.Intn(len(b.targets))]
+}
+
+// Next returns an upstream target using round-robin technique.
+func (b *roundRobinBalancer) Next(c echo.Context) *ProxyTarget {
+	b.i = b.i % uint32(len(b.targets))
+	t := b.targets[b.i]
+	atomic.AddUint32(&b.i, 1)
+	return t
+}
+
+// Proxy returns a Proxy middleware.
+//
+// Proxy middleware forwards the request to upstream server using a configured load balancing technique.
+func Proxy(balancer ProxyBalancer) echo.MiddlewareFunc {
+	c := DefaultProxyConfig
+	c.Balancer = balancer
+	return ProxyWithConfig(c)
+}
+
+// ProxyWithConfig returns a Proxy middleware with config.
+// See: `Proxy()`
+func ProxyWithConfig(config ProxyConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultProxyConfig.Skipper
+	}
+	if config.Balancer == nil {
+		panic("echo: proxy middleware requires balancer")
+	}
+
+	if config.Rewrite != nil {
+		if config.RegexRewrite == nil {
+			config.RegexRewrite = make(map[*regexp.Regexp]string)
+		}
+		for k, v := range rewriteRulesRegex(config.Rewrite) {
+			config.RegexRewrite[k] = v
+		}
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) (err error) {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req := c.Request()
+			res := c.Response()
+			tgt := config.Balancer.Next(c)
+			c.Set(config.ContextKey, tgt)
+
+			if err := rewriteURL(config.RegexRewrite, req); err != nil {
+				return err
+			}
+
+			// Fix header
+			// Basically it's not good practice to unconditionally pass incoming x-real-ip header to upstream.
+			// However, for backward compatibility, legacy behavior is preserved unless you configure Echo#IPExtractor.
+			if req.Header.Get(echo.HeaderXRealIP) == "" || c.Echo().IPExtractor != nil {
+				req.Header.Set(echo.HeaderXRealIP, c.RealIP())
+			}
+			if req.Header.Get(echo.HeaderXForwardedProto) == "" {
+				req.Header.Set(echo.HeaderXForwardedProto, c.Scheme())
+			}
+			if c.IsWebSocket() && req.Header.Get(echo.HeaderXForwardedFor) == "" { // For HTTP, it is automatically set by Go HTTP reverse proxy.
+				req.Header.Set(echo.HeaderXForwardedFor, c.RealIP())
+			}
+
+			// Proxy
+			switch {
+			case c.IsWebSocket():
+				proxyRaw(tgt, c).ServeHTTP(res, req)
+			case req.Header.Get(echo.HeaderAccept) == "text/event-stream":
+			default:
+				proxyHTTP(tgt, c, config).ServeHTTP(res, req)
+			}
+			if e, ok := c.Get("_error").(error); ok {
+				err = e
+			}
+
+			return
+		}
+	}
+}
+
+// StatusCodeContextCanceled is a custom HTTP status code for situations
+// where a client unexpectedly closed the connection to the server.
+// As there is no standard error code for "client closed connection", but
+// various well-known HTTP clients and server implement this HTTP code we use
+// 499 too instead of the more problematic 5xx, which does not allow to detect this situation
+const StatusCodeContextCanceled = 499
+
+func proxyHTTP(tgt *ProxyTarget, c echo.Context, config ProxyConfig) http.Handler {
+	proxy := httputil.NewSingleHostReverseProxy(tgt.URL)
+	proxy.ErrorHandler = func(resp http.ResponseWriter, req *http.Request, err error) {
+		desc := tgt.URL.String()
+		if tgt.Name != "" {
+			desc = fmt.Sprintf("%s(%s)", tgt.Name, tgt.URL.String())
+		}
+		// If the client canceled the request (usually by closing the connection), we can report a
+		// client error (4xx) instead of a server error (5xx) to correctly identify the situation.
+		// The Go standard library (at of late 2020) wraps the exported, standard
+		// context.Canceled error with unexported garbage value requiring a substring check, see
+		// https://github.com/golang/go/blob/6965b01ea248cabb70c3749fd218b36089a21efb/src/net/net.go#L416-L430
+		if err == context.Canceled || strings.Contains(err.Error(), "operation was canceled") {
+			httpError := echo.NewHTTPError(StatusCodeContextCanceled, fmt.Sprintf("client closed connection: %v", err))
+			httpError.Internal = err
+			c.Set("_error", httpError)
+		} else {
+			httpError := echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("remote %s unreachable, could not forward: %v", desc, err))
+			httpError.Internal = err
+			c.Set("_error", httpError)
+		}
+	}
+	proxy.Transport = config.Transport
+	proxy.ModifyResponse = config.ModifyResponse
+	return proxy
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/rate_limiter.go b/vendor/github.com/labstack/echo/v4/middleware/rate_limiter.go
new file mode 100644
index 000000000..0291eb451
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/rate_limiter.go
@@ -0,0 +1,267 @@
+package middleware
+
+import (
+	"net/http"
+	"sync"
+	"time"
+
+	"github.com/labstack/echo/v4"
+	"golang.org/x/time/rate"
+)
+
+type (
+	// RateLimiterStore is the interface to be implemented by custom stores.
+	RateLimiterStore interface {
+		// Stores for the rate limiter have to implement the Allow method
+		Allow(identifier string) (bool, error)
+	}
+)
+
+type (
+	// RateLimiterConfig defines the configuration for the rate limiter
+	RateLimiterConfig struct {
+		Skipper    Skipper
+		BeforeFunc BeforeFunc
+		// IdentifierExtractor uses echo.Context to extract the identifier for a visitor
+		IdentifierExtractor Extractor
+		// Store defines a store for the rate limiter
+		Store RateLimiterStore
+		// ErrorHandler provides a handler to be called when IdentifierExtractor returns an error
+		ErrorHandler func(context echo.Context, err error) error
+		// DenyHandler provides a handler to be called when RateLimiter denies access
+		DenyHandler func(context echo.Context, identifier string, err error) error
+	}
+	// Extractor is used to extract data from echo.Context
+	Extractor func(context echo.Context) (string, error)
+)
+
+// errors
+var (
+	// ErrRateLimitExceeded denotes an error raised when rate limit is exceeded
+	ErrRateLimitExceeded = echo.NewHTTPError(http.StatusTooManyRequests, "rate limit exceeded")
+	// ErrExtractorError denotes an error raised when extractor function is unsuccessful
+	ErrExtractorError = echo.NewHTTPError(http.StatusForbidden, "error while extracting identifier")
+)
+
+// DefaultRateLimiterConfig defines default values for RateLimiterConfig
+var DefaultRateLimiterConfig = RateLimiterConfig{
+	Skipper: DefaultSkipper,
+	IdentifierExtractor: func(ctx echo.Context) (string, error) {
+		id := ctx.RealIP()
+		return id, nil
+	},
+	ErrorHandler: func(context echo.Context, err error) error {
+		return &echo.HTTPError{
+			Code:     ErrExtractorError.Code,
+			Message:  ErrExtractorError.Message,
+			Internal: err,
+		}
+	},
+	DenyHandler: func(context echo.Context, identifier string, err error) error {
+		return &echo.HTTPError{
+			Code:     ErrRateLimitExceeded.Code,
+			Message:  ErrRateLimitExceeded.Message,
+			Internal: err,
+		}
+	},
+}
+
+/*
+RateLimiter returns a rate limiting middleware
+
+	e := echo.New()
+
+	limiterStore := middleware.NewRateLimiterMemoryStore(20)
+
+	e.GET("/rate-limited", func(c echo.Context) error {
+		return c.String(http.StatusOK, "test")
+	}, RateLimiter(limiterStore))
+*/
+func RateLimiter(store RateLimiterStore) echo.MiddlewareFunc {
+	config := DefaultRateLimiterConfig
+	config.Store = store
+
+	return RateLimiterWithConfig(config)
+}
+
+/*
+RateLimiterWithConfig returns a rate limiting middleware
+
+	e := echo.New()
+
+	config := middleware.RateLimiterConfig{
+		Skipper: DefaultSkipper,
+		Store: middleware.NewRateLimiterMemoryStore(
+			middleware.RateLimiterMemoryStoreConfig{Rate: 10, Burst: 30, ExpiresIn: 3 * time.Minute}
+		)
+		IdentifierExtractor: func(ctx echo.Context) (string, error) {
+			id := ctx.RealIP()
+			return id, nil
+		},
+		ErrorHandler: func(context echo.Context, err error) error {
+			return context.JSON(http.StatusTooManyRequests, nil)
+		},
+		DenyHandler: func(context echo.Context, identifier string) error {
+			return context.JSON(http.StatusForbidden, nil)
+		},
+	}
+
+	e.GET("/rate-limited", func(c echo.Context) error {
+		return c.String(http.StatusOK, "test")
+	}, middleware.RateLimiterWithConfig(config))
+*/
+func RateLimiterWithConfig(config RateLimiterConfig) echo.MiddlewareFunc {
+	if config.Skipper == nil {
+		config.Skipper = DefaultRateLimiterConfig.Skipper
+	}
+	if config.IdentifierExtractor == nil {
+		config.IdentifierExtractor = DefaultRateLimiterConfig.IdentifierExtractor
+	}
+	if config.ErrorHandler == nil {
+		config.ErrorHandler = DefaultRateLimiterConfig.ErrorHandler
+	}
+	if config.DenyHandler == nil {
+		config.DenyHandler = DefaultRateLimiterConfig.DenyHandler
+	}
+	if config.Store == nil {
+		panic("Store configuration must be provided")
+	}
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+			if config.BeforeFunc != nil {
+				config.BeforeFunc(c)
+			}
+
+			identifier, err := config.IdentifierExtractor(c)
+			if err != nil {
+				c.Error(config.ErrorHandler(c, err))
+				return nil
+			}
+
+			if allow, err := config.Store.Allow(identifier); !allow {
+				c.Error(config.DenyHandler(c, identifier, err))
+				return nil
+			}
+			return next(c)
+		}
+	}
+}
+
+type (
+	// RateLimiterMemoryStore is the built-in store implementation for RateLimiter
+	RateLimiterMemoryStore struct {
+		visitors    map[string]*Visitor
+		mutex       sync.Mutex
+		rate        rate.Limit
+		burst       int
+		expiresIn   time.Duration
+		lastCleanup time.Time
+	}
+	// Visitor signifies a unique user's limiter details
+	Visitor struct {
+		*rate.Limiter
+		lastSeen time.Time
+	}
+)
+
+/*
+NewRateLimiterMemoryStore returns an instance of RateLimiterMemoryStore with
+the provided rate (as req/s). The provided rate less than 1 will be treated as zero.
+Burst and ExpiresIn will be set to default values.
+
+Example (with 20 requests/sec):
+
+	limiterStore := middleware.NewRateLimiterMemoryStore(20)
+
+*/
+func NewRateLimiterMemoryStore(rate rate.Limit) (store *RateLimiterMemoryStore) {
+	return NewRateLimiterMemoryStoreWithConfig(RateLimiterMemoryStoreConfig{
+		Rate: rate,
+	})
+}
+
+/*
+NewRateLimiterMemoryStoreWithConfig returns an instance of RateLimiterMemoryStore
+with the provided configuration. Rate must be provided. Burst will be set to the value of
+the configured rate if not provided or set to 0.
+
+The build-in memory store is usually capable for modest loads. For higher loads other
+store implementations should be considered.
+
+Characteristics:
+* Concurrency above 100 parallel requests may causes measurable lock contention
+* A high number of different IP addresses (above 16000) may be impacted by the internally used Go map
+* A high number of requests from a single IP address may cause lock contention
+
+Example:
+
+	limiterStore := middleware.NewRateLimiterMemoryStoreWithConfig(
+		middleware.RateLimiterMemoryStoreConfig{Rate: 50, Burst: 200, ExpiresIn: 5 * time.Minutes},
+	)
+*/
+func NewRateLimiterMemoryStoreWithConfig(config RateLimiterMemoryStoreConfig) (store *RateLimiterMemoryStore) {
+	store = &RateLimiterMemoryStore{}
+
+	store.rate = config.Rate
+	store.burst = config.Burst
+	store.expiresIn = config.ExpiresIn
+	if config.ExpiresIn == 0 {
+		store.expiresIn = DefaultRateLimiterMemoryStoreConfig.ExpiresIn
+	}
+	if config.Burst == 0 {
+		store.burst = int(config.Rate)
+	}
+	store.visitors = make(map[string]*Visitor)
+	store.lastCleanup = now()
+	return
+}
+
+// RateLimiterMemoryStoreConfig represents configuration for RateLimiterMemoryStore
+type RateLimiterMemoryStoreConfig struct {
+	Rate      rate.Limit    // Rate of requests allowed to pass as req/s
+	Burst     int           // Burst additionally allows a number of requests to pass when rate limit is reached
+	ExpiresIn time.Duration // ExpiresIn is the duration after that a rate limiter is cleaned up
+}
+
+// DefaultRateLimiterMemoryStoreConfig provides default configuration values for RateLimiterMemoryStore
+var DefaultRateLimiterMemoryStoreConfig = RateLimiterMemoryStoreConfig{
+	ExpiresIn: 3 * time.Minute,
+}
+
+// Allow implements RateLimiterStore.Allow
+func (store *RateLimiterMemoryStore) Allow(identifier string) (bool, error) {
+	store.mutex.Lock()
+	limiter, exists := store.visitors[identifier]
+	if !exists {
+		limiter = new(Visitor)
+		limiter.Limiter = rate.NewLimiter(store.rate, store.burst)
+		store.visitors[identifier] = limiter
+	}
+	limiter.lastSeen = now()
+	if now().Sub(store.lastCleanup) > store.expiresIn {
+		store.cleanupStaleVisitors()
+	}
+	store.mutex.Unlock()
+	return limiter.AllowN(now(), 1), nil
+}
+
+/*
+cleanupStaleVisitors helps manage the size of the visitors map by removing stale records
+of users who haven't visited again after the configured expiry time has elapsed
+*/
+func (store *RateLimiterMemoryStore) cleanupStaleVisitors() {
+	for id, visitor := range store.visitors {
+		if now().Sub(visitor.lastSeen) > store.expiresIn {
+			delete(store.visitors, id)
+		}
+	}
+	store.lastCleanup = now()
+}
+
+/*
+actual time method which is mocked in test file
+*/
+var now = time.Now
diff --git a/vendor/github.com/labstack/echo/v4/middleware/recover.go b/vendor/github.com/labstack/echo/v4/middleware/recover.go
new file mode 100644
index 000000000..0dbe740da
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/recover.go
@@ -0,0 +1,101 @@
+package middleware
+
+import (
+	"fmt"
+	"runtime"
+
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/gommon/log"
+)
+
+type (
+	// RecoverConfig defines the config for Recover middleware.
+	RecoverConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Size of the stack to be printed.
+		// Optional. Default value 4KB.
+		StackSize int `yaml:"stack_size"`
+
+		// DisableStackAll disables formatting stack traces of all other goroutines
+		// into buffer after the trace for the current goroutine.
+		// Optional. Default value false.
+		DisableStackAll bool `yaml:"disable_stack_all"`
+
+		// DisablePrintStack disables printing stack trace.
+		// Optional. Default value as false.
+		DisablePrintStack bool `yaml:"disable_print_stack"`
+
+		// LogLevel is log level to printing stack trace.
+		// Optional. Default value 0 (Print).
+		LogLevel log.Lvl
+	}
+)
+
+var (
+	// DefaultRecoverConfig is the default Recover middleware config.
+	DefaultRecoverConfig = RecoverConfig{
+		Skipper:           DefaultSkipper,
+		StackSize:         4 << 10, // 4 KB
+		DisableStackAll:   false,
+		DisablePrintStack: false,
+		LogLevel:          0,
+	}
+)
+
+// Recover returns a middleware which recovers from panics anywhere in the chain
+// and handles the control to the centralized HTTPErrorHandler.
+func Recover() echo.MiddlewareFunc {
+	return RecoverWithConfig(DefaultRecoverConfig)
+}
+
+// RecoverWithConfig returns a Recover middleware with config.
+// See: `Recover()`.
+func RecoverWithConfig(config RecoverConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultRecoverConfig.Skipper
+	}
+	if config.StackSize == 0 {
+		config.StackSize = DefaultRecoverConfig.StackSize
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			defer func() {
+				if r := recover(); r != nil {
+					err, ok := r.(error)
+					if !ok {
+						err = fmt.Errorf("%v", r)
+					}
+					stack := make([]byte, config.StackSize)
+					length := runtime.Stack(stack, !config.DisableStackAll)
+					if !config.DisablePrintStack {
+						msg := fmt.Sprintf("[PANIC RECOVER] %v %s\n", err, stack[:length])
+						switch config.LogLevel {
+						case log.DEBUG:
+							c.Logger().Debug(msg)
+						case log.INFO:
+							c.Logger().Info(msg)
+						case log.WARN:
+							c.Logger().Warn(msg)
+						case log.ERROR:
+							c.Logger().Error(msg)
+						case log.OFF:
+							// None.
+						default:
+							c.Logger().Print(msg)
+						}
+					}
+					c.Error(err)
+				}
+			}()
+			return next(c)
+		}
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/redirect.go b/vendor/github.com/labstack/echo/v4/middleware/redirect.go
new file mode 100644
index 000000000..13877db38
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/redirect.go
@@ -0,0 +1,152 @@
+package middleware
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/labstack/echo/v4"
+)
+
+// RedirectConfig defines the config for Redirect middleware.
+type RedirectConfig struct {
+	// Skipper defines a function to skip middleware.
+	Skipper
+
+	// Status code to be used when redirecting the request.
+	// Optional. Default value http.StatusMovedPermanently.
+	Code int `yaml:"code"`
+}
+
+// redirectLogic represents a function that given a scheme, host and uri
+// can both: 1) determine if redirect is needed (will set ok accordingly) and
+// 2) return the appropriate redirect url.
+type redirectLogic func(scheme, host, uri string) (ok bool, url string)
+
+const www = "www."
+
+// DefaultRedirectConfig is the default Redirect middleware config.
+var DefaultRedirectConfig = RedirectConfig{
+	Skipper: DefaultSkipper,
+	Code:    http.StatusMovedPermanently,
+}
+
+// HTTPSRedirect redirects http requests to https.
+// For example, http://labstack.com will be redirect to https://labstack.com.
+//
+// Usage `Echo#Pre(HTTPSRedirect())`
+func HTTPSRedirect() echo.MiddlewareFunc {
+	return HTTPSRedirectWithConfig(DefaultRedirectConfig)
+}
+
+// HTTPSRedirectWithConfig returns an HTTPSRedirect middleware with config.
+// See `HTTPSRedirect()`.
+func HTTPSRedirectWithConfig(config RedirectConfig) echo.MiddlewareFunc {
+	return redirect(config, func(scheme, host, uri string) (bool, string) {
+		if scheme != "https" {
+			return true, "https://" + host + uri
+		}
+		return false, ""
+	})
+}
+
+// HTTPSWWWRedirect redirects http requests to https www.
+// For example, http://labstack.com will be redirect to https://www.labstack.com.
+//
+// Usage `Echo#Pre(HTTPSWWWRedirect())`
+func HTTPSWWWRedirect() echo.MiddlewareFunc {
+	return HTTPSWWWRedirectWithConfig(DefaultRedirectConfig)
+}
+
+// HTTPSWWWRedirectWithConfig returns an HTTPSRedirect middleware with config.
+// See `HTTPSWWWRedirect()`.
+func HTTPSWWWRedirectWithConfig(config RedirectConfig) echo.MiddlewareFunc {
+	return redirect(config, func(scheme, host, uri string) (bool, string) {
+		if scheme != "https" && !strings.HasPrefix(host, www) {
+			return true, "https://www." + host + uri
+		}
+		return false, ""
+	})
+}
+
+// HTTPSNonWWWRedirect redirects http requests to https non www.
+// For example, http://www.labstack.com will be redirect to https://labstack.com.
+//
+// Usage `Echo#Pre(HTTPSNonWWWRedirect())`
+func HTTPSNonWWWRedirect() echo.MiddlewareFunc {
+	return HTTPSNonWWWRedirectWithConfig(DefaultRedirectConfig)
+}
+
+// HTTPSNonWWWRedirectWithConfig returns an HTTPSRedirect middleware with config.
+// See `HTTPSNonWWWRedirect()`.
+func HTTPSNonWWWRedirectWithConfig(config RedirectConfig) echo.MiddlewareFunc {
+	return redirect(config, func(scheme, host, uri string) (ok bool, url string) {
+		if scheme != "https" {
+			host = strings.TrimPrefix(host, www)
+			return true, "https://" + host + uri
+		}
+		return false, ""
+	})
+}
+
+// WWWRedirect redirects non www requests to www.
+// For example, http://labstack.com will be redirect to http://www.labstack.com.
+//
+// Usage `Echo#Pre(WWWRedirect())`
+func WWWRedirect() echo.MiddlewareFunc {
+	return WWWRedirectWithConfig(DefaultRedirectConfig)
+}
+
+// WWWRedirectWithConfig returns an HTTPSRedirect middleware with config.
+// See `WWWRedirect()`.
+func WWWRedirectWithConfig(config RedirectConfig) echo.MiddlewareFunc {
+	return redirect(config, func(scheme, host, uri string) (bool, string) {
+		if !strings.HasPrefix(host, www) {
+			return true, scheme + "://www." + host + uri
+		}
+		return false, ""
+	})
+}
+
+// NonWWWRedirect redirects www requests to non www.
+// For example, http://www.labstack.com will be redirect to http://labstack.com.
+//
+// Usage `Echo#Pre(NonWWWRedirect())`
+func NonWWWRedirect() echo.MiddlewareFunc {
+	return NonWWWRedirectWithConfig(DefaultRedirectConfig)
+}
+
+// NonWWWRedirectWithConfig returns an HTTPSRedirect middleware with config.
+// See `NonWWWRedirect()`.
+func NonWWWRedirectWithConfig(config RedirectConfig) echo.MiddlewareFunc {
+	return redirect(config, func(scheme, host, uri string) (bool, string) {
+		if strings.HasPrefix(host, www) {
+			return true, scheme + "://" + host[4:] + uri
+		}
+		return false, ""
+	})
+}
+
+func redirect(config RedirectConfig, cb redirectLogic) echo.MiddlewareFunc {
+	if config.Skipper == nil {
+		config.Skipper = DefaultRedirectConfig.Skipper
+	}
+	if config.Code == 0 {
+		config.Code = DefaultRedirectConfig.Code
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req, scheme := c.Request(), c.Scheme()
+			host := req.Host
+			if ok, url := cb(scheme, host, req.RequestURI); ok {
+				return c.Redirect(config.Code, url)
+			}
+
+			return next(c)
+		}
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/request_id.go b/vendor/github.com/labstack/echo/v4/middleware/request_id.go
new file mode 100644
index 000000000..b0baeeb2d
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/request_id.go
@@ -0,0 +1,70 @@
+package middleware
+
+import (
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/gommon/random"
+)
+
+type (
+	// RequestIDConfig defines the config for RequestID middleware.
+	RequestIDConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Generator defines a function to generate an ID.
+		// Optional. Default value random.String(32).
+		Generator func() string
+
+		// RequestIDHandler defines a function which is executed for a request id.
+		RequestIDHandler func(echo.Context, string)
+	}
+)
+
+var (
+	// DefaultRequestIDConfig is the default RequestID middleware config.
+	DefaultRequestIDConfig = RequestIDConfig{
+		Skipper:   DefaultSkipper,
+		Generator: generator,
+	}
+)
+
+// RequestID returns a X-Request-ID middleware.
+func RequestID() echo.MiddlewareFunc {
+	return RequestIDWithConfig(DefaultRequestIDConfig)
+}
+
+// RequestIDWithConfig returns a X-Request-ID middleware with config.
+func RequestIDWithConfig(config RequestIDConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultRequestIDConfig.Skipper
+	}
+	if config.Generator == nil {
+		config.Generator = generator
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req := c.Request()
+			res := c.Response()
+			rid := req.Header.Get(echo.HeaderXRequestID)
+			if rid == "" {
+				rid = config.Generator()
+			}
+			res.Header().Set(echo.HeaderXRequestID, rid)
+			if config.RequestIDHandler != nil {
+				config.RequestIDHandler(c, rid)
+			}
+
+			return next(c)
+		}
+	}
+}
+
+func generator() string {
+	return random.String(32)
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/rewrite.go b/vendor/github.com/labstack/echo/v4/middleware/rewrite.go
new file mode 100644
index 000000000..e5b0a6b56
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/rewrite.go
@@ -0,0 +1,81 @@
+package middleware
+
+import (
+	"regexp"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// RewriteConfig defines the config for Rewrite middleware.
+	RewriteConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Rules defines the URL path rewrite rules. The values captured in asterisk can be
+		// retrieved by index e.g. $1, $2 and so on.
+		// Example:
+		// "/old":              "/new",
+		// "/api/*":            "/$1",
+		// "/js/*":             "/public/javascripts/$1",
+		// "/users/*/orders/*": "/user/$1/order/$2",
+		// Required.
+		Rules map[string]string `yaml:"rules"`
+
+		// RegexRules defines the URL path rewrite rules using regexp.Rexexp with captures
+		// Every capture group in the values can be retrieved by index e.g. $1, $2 and so on.
+		// Example:
+		// "^/old/[0.9]+/":     "/new",
+		// "^/api/.+?/(.*)":     "/v2/$1",
+		RegexRules map[*regexp.Regexp]string `yaml:"regex_rules"`
+	}
+)
+
+var (
+	// DefaultRewriteConfig is the default Rewrite middleware config.
+	DefaultRewriteConfig = RewriteConfig{
+		Skipper: DefaultSkipper,
+	}
+)
+
+// Rewrite returns a Rewrite middleware.
+//
+// Rewrite middleware rewrites the URL path based on the provided rules.
+func Rewrite(rules map[string]string) echo.MiddlewareFunc {
+	c := DefaultRewriteConfig
+	c.Rules = rules
+	return RewriteWithConfig(c)
+}
+
+// RewriteWithConfig returns a Rewrite middleware with config.
+// See: `Rewrite()`.
+func RewriteWithConfig(config RewriteConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Rules == nil && config.RegexRules == nil {
+		panic("echo: rewrite middleware requires url path rewrite rules or regex rules")
+	}
+
+	if config.Skipper == nil {
+		config.Skipper = DefaultBodyDumpConfig.Skipper
+	}
+
+	if config.RegexRules == nil {
+		config.RegexRules = make(map[*regexp.Regexp]string)
+	}
+	for k, v := range rewriteRulesRegex(config.Rules) {
+		config.RegexRules[k] = v
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) (err error) {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			if err := rewriteURL(config.RegexRules, c.Request()); err != nil {
+				return err
+			}
+			return next(c)
+		}
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/secure.go b/vendor/github.com/labstack/echo/v4/middleware/secure.go
new file mode 100644
index 000000000..6c4051723
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/secure.go
@@ -0,0 +1,145 @@
+package middleware
+
+import (
+	"fmt"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// SecureConfig defines the config for Secure middleware.
+	SecureConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// XSSProtection provides protection against cross-site scripting attack (XSS)
+		// by setting the `X-XSS-Protection` header.
+		// Optional. Default value "1; mode=block".
+		XSSProtection string `yaml:"xss_protection"`
+
+		// ContentTypeNosniff provides protection against overriding Content-Type
+		// header by setting the `X-Content-Type-Options` header.
+		// Optional. Default value "nosniff".
+		ContentTypeNosniff string `yaml:"content_type_nosniff"`
+
+		// XFrameOptions can be used to indicate whether or not a browser should
+		// be allowed to render a page in a <frame>, <iframe> or <object> .
+		// Sites can use this to avoid clickjacking attacks, by ensuring that their
+		// content is not embedded into other sites.provides protection against
+		// clickjacking.
+		// Optional. Default value "SAMEORIGIN".
+		// Possible values:
+		// - "SAMEORIGIN" - The page can only be displayed in a frame on the same origin as the page itself.
+		// - "DENY" - The page cannot be displayed in a frame, regardless of the site attempting to do so.
+		// - "ALLOW-FROM uri" - The page can only be displayed in a frame on the specified origin.
+		XFrameOptions string `yaml:"x_frame_options"`
+
+		// HSTSMaxAge sets the `Strict-Transport-Security` header to indicate how
+		// long (in seconds) browsers should remember that this site is only to
+		// be accessed using HTTPS. This reduces your exposure to some SSL-stripping
+		// man-in-the-middle (MITM) attacks.
+		// Optional. Default value 0.
+		HSTSMaxAge int `yaml:"hsts_max_age"`
+
+		// HSTSExcludeSubdomains won't include subdomains tag in the `Strict Transport Security`
+		// header, excluding all subdomains from security policy. It has no effect
+		// unless HSTSMaxAge is set to a non-zero value.
+		// Optional. Default value false.
+		HSTSExcludeSubdomains bool `yaml:"hsts_exclude_subdomains"`
+
+		// ContentSecurityPolicy sets the `Content-Security-Policy` header providing
+		// security against cross-site scripting (XSS), clickjacking and other code
+		// injection attacks resulting from execution of malicious content in the
+		// trusted web page context.
+		// Optional. Default value "".
+		ContentSecurityPolicy string `yaml:"content_security_policy"`
+
+		// CSPReportOnly would use the `Content-Security-Policy-Report-Only` header instead
+		// of the `Content-Security-Policy` header. This allows iterative updates of the
+		// content security policy by only reporting the violations that would
+		// have occurred instead of blocking the resource.
+		// Optional. Default value false.
+		CSPReportOnly bool `yaml:"csp_report_only"`
+
+		// HSTSPreloadEnabled will add the preload tag in the `Strict Transport Security`
+		// header, which enables the domain to be included in the HSTS preload list
+		// maintained by Chrome (and used by Firefox and Safari): https://hstspreload.org/
+		// Optional.  Default value false.
+		HSTSPreloadEnabled bool `yaml:"hsts_preload_enabled"`
+
+		// ReferrerPolicy sets the `Referrer-Policy` header providing security against
+		// leaking potentially sensitive request paths to third parties.
+		// Optional. Default value "".
+		ReferrerPolicy string `yaml:"referrer_policy"`
+	}
+)
+
+var (
+	// DefaultSecureConfig is the default Secure middleware config.
+	DefaultSecureConfig = SecureConfig{
+		Skipper:            DefaultSkipper,
+		XSSProtection:      "1; mode=block",
+		ContentTypeNosniff: "nosniff",
+		XFrameOptions:      "SAMEORIGIN",
+		HSTSPreloadEnabled: false,
+	}
+)
+
+// Secure returns a Secure middleware.
+// Secure middleware provides protection against cross-site scripting (XSS) attack,
+// content type sniffing, clickjacking, insecure connection and other code injection
+// attacks.
+func Secure() echo.MiddlewareFunc {
+	return SecureWithConfig(DefaultSecureConfig)
+}
+
+// SecureWithConfig returns a Secure middleware with config.
+// See: `Secure()`.
+func SecureWithConfig(config SecureConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultSecureConfig.Skipper
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req := c.Request()
+			res := c.Response()
+
+			if config.XSSProtection != "" {
+				res.Header().Set(echo.HeaderXXSSProtection, config.XSSProtection)
+			}
+			if config.ContentTypeNosniff != "" {
+				res.Header().Set(echo.HeaderXContentTypeOptions, config.ContentTypeNosniff)
+			}
+			if config.XFrameOptions != "" {
+				res.Header().Set(echo.HeaderXFrameOptions, config.XFrameOptions)
+			}
+			if (c.IsTLS() || (req.Header.Get(echo.HeaderXForwardedProto) == "https")) && config.HSTSMaxAge != 0 {
+				subdomains := ""
+				if !config.HSTSExcludeSubdomains {
+					subdomains = "; includeSubdomains"
+				}
+				if config.HSTSPreloadEnabled {
+					subdomains = fmt.Sprintf("%s; preload", subdomains)
+				}
+				res.Header().Set(echo.HeaderStrictTransportSecurity, fmt.Sprintf("max-age=%d%s", config.HSTSMaxAge, subdomains))
+			}
+			if config.ContentSecurityPolicy != "" {
+				if config.CSPReportOnly {
+					res.Header().Set(echo.HeaderContentSecurityPolicyReportOnly, config.ContentSecurityPolicy)
+				} else {
+					res.Header().Set(echo.HeaderContentSecurityPolicy, config.ContentSecurityPolicy)
+				}
+			}
+			if config.ReferrerPolicy != "" {
+				res.Header().Set(echo.HeaderReferrerPolicy, config.ReferrerPolicy)
+			}
+			return next(c)
+		}
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/slash.go b/vendor/github.com/labstack/echo/v4/middleware/slash.go
new file mode 100644
index 000000000..4188675b0
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/slash.go
@@ -0,0 +1,130 @@
+package middleware
+
+import (
+	"strings"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// TrailingSlashConfig defines the config for TrailingSlash middleware.
+	TrailingSlashConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Status code to be used when redirecting the request.
+		// Optional, but when provided the request is redirected using this code.
+		RedirectCode int `yaml:"redirect_code"`
+	}
+)
+
+var (
+	// DefaultTrailingSlashConfig is the default TrailingSlash middleware config.
+	DefaultTrailingSlashConfig = TrailingSlashConfig{
+		Skipper: DefaultSkipper,
+	}
+)
+
+// AddTrailingSlash returns a root level (before router) middleware which adds a
+// trailing slash to the request `URL#Path`.
+//
+// Usage `Echo#Pre(AddTrailingSlash())`
+func AddTrailingSlash() echo.MiddlewareFunc {
+	return AddTrailingSlashWithConfig(DefaultTrailingSlashConfig)
+}
+
+// AddTrailingSlashWithConfig returns a AddTrailingSlash middleware with config.
+// See `AddTrailingSlash()`.
+func AddTrailingSlashWithConfig(config TrailingSlashConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultTrailingSlashConfig.Skipper
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req := c.Request()
+			url := req.URL
+			path := url.Path
+			qs := c.QueryString()
+			if !strings.HasSuffix(path, "/") {
+				path += "/"
+				uri := path
+				if qs != "" {
+					uri += "?" + qs
+				}
+
+				// Redirect
+				if config.RedirectCode != 0 {
+					return c.Redirect(config.RedirectCode, sanitizeURI(uri))
+				}
+
+				// Forward
+				req.RequestURI = uri
+				url.Path = path
+			}
+			return next(c)
+		}
+	}
+}
+
+// RemoveTrailingSlash returns a root level (before router) middleware which removes
+// a trailing slash from the request URI.
+//
+// Usage `Echo#Pre(RemoveTrailingSlash())`
+func RemoveTrailingSlash() echo.MiddlewareFunc {
+	return RemoveTrailingSlashWithConfig(TrailingSlashConfig{})
+}
+
+// RemoveTrailingSlashWithConfig returns a RemoveTrailingSlash middleware with config.
+// See `RemoveTrailingSlash()`.
+func RemoveTrailingSlashWithConfig(config TrailingSlashConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultTrailingSlashConfig.Skipper
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			req := c.Request()
+			url := req.URL
+			path := url.Path
+			qs := c.QueryString()
+			l := len(path) - 1
+			if l > 0 && strings.HasSuffix(path, "/") {
+				path = path[:l]
+				uri := path
+				if qs != "" {
+					uri += "?" + qs
+				}
+
+				// Redirect
+				if config.RedirectCode != 0 {
+					return c.Redirect(config.RedirectCode, sanitizeURI(uri))
+				}
+
+				// Forward
+				req.RequestURI = uri
+				url.Path = path
+			}
+			return next(c)
+		}
+	}
+}
+
+func sanitizeURI(uri string) string {
+	// double slash `\\`, `//` or even `\/` is absolute uri for browsers and by redirecting request to that uri
+	// we are vulnerable to open redirect attack. so replace all slashes from the beginning with single slash
+	if len(uri) > 1 && (uri[0] == '\\' || uri[0] == '/') && (uri[1] == '\\' || uri[1] == '/') {
+		uri = "/" + strings.TrimLeft(uri, `/\`)
+	}
+	return uri
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/static.go b/vendor/github.com/labstack/echo/v4/middleware/static.go
new file mode 100644
index 000000000..0106f7ce2
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/static.go
@@ -0,0 +1,276 @@
+package middleware
+
+import (
+	"fmt"
+	"html/template"
+	"net/http"
+	"net/url"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/gommon/bytes"
+)
+
+type (
+	// StaticConfig defines the config for Static middleware.
+	StaticConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// Root directory from where the static content is served.
+		// Required.
+		Root string `yaml:"root"`
+
+		// Index file for serving a directory.
+		// Optional. Default value "index.html".
+		Index string `yaml:"index"`
+
+		// Enable HTML5 mode by forwarding all not-found requests to root so that
+		// SPA (single-page application) can handle the routing.
+		// Optional. Default value false.
+		HTML5 bool `yaml:"html5"`
+
+		// Enable directory browsing.
+		// Optional. Default value false.
+		Browse bool `yaml:"browse"`
+
+		// Enable ignoring of the base of the URL path.
+		// Example: when assigning a static middleware to a non root path group,
+		// the filesystem path is not doubled
+		// Optional. Default value false.
+		IgnoreBase bool `yaml:"ignoreBase"`
+
+		// Filesystem provides access to the static content.
+		// Optional. Defaults to http.Dir(config.Root)
+		Filesystem http.FileSystem `yaml:"-"`
+	}
+)
+
+const html = `
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <meta http-equiv="X-UA-Compatible" content="ie=edge">
+  <title>{{ .Name }}</title>
+  <style>
+    body {
+			font-family: Menlo, Consolas, monospace;
+			padding: 48px;
+		}
+		header {
+			padding: 4px 16px;
+			font-size: 24px;
+		}
+    ul {
+			list-style-type: none;
+			margin: 0;
+    	padding: 20px 0 0 0;
+			display: flex;
+			flex-wrap: wrap;
+    }
+    li {
+			width: 300px;
+			padding: 16px;
+		}
+		li a {
+			display: block;
+			overflow: hidden;
+			white-space: nowrap;
+			text-overflow: ellipsis;
+			text-decoration: none;
+			transition: opacity 0.25s;
+		}
+		li span {
+			color: #707070;
+			font-size: 12px;
+		}
+		li a:hover {
+			opacity: 0.50;
+		}
+		.dir {
+			color: #E91E63;
+		}
+		.file {
+			color: #673AB7;
+		}
+  </style>
+</head>
+<body>
+	<header>
+		{{ .Name }}
+	</header>
+	<ul>
+		{{ range .Files }}
+		<li>
+		{{ if .Dir }}
+			{{ $name := print .Name "/" }}
+			<a class="dir" href="{{ $name }}">{{ $name }}</a>
+			{{ else }}
+			<a class="file" href="{{ .Name }}">{{ .Name }}</a>
+			<span>{{ .Size }}</span>
+		{{ end }}
+		</li>
+		{{ end }}
+  </ul>
+</body>
+</html>
+`
+
+var (
+	// DefaultStaticConfig is the default Static middleware config.
+	DefaultStaticConfig = StaticConfig{
+		Skipper: DefaultSkipper,
+		Index:   "index.html",
+	}
+)
+
+// Static returns a Static middleware to serves static content from the provided
+// root directory.
+func Static(root string) echo.MiddlewareFunc {
+	c := DefaultStaticConfig
+	c.Root = root
+	return StaticWithConfig(c)
+}
+
+// StaticWithConfig returns a Static middleware with config.
+// See `Static()`.
+func StaticWithConfig(config StaticConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Root == "" {
+		config.Root = "." // For security we want to restrict to CWD.
+	}
+	if config.Skipper == nil {
+		config.Skipper = DefaultStaticConfig.Skipper
+	}
+	if config.Index == "" {
+		config.Index = DefaultStaticConfig.Index
+	}
+	if config.Filesystem == nil {
+		config.Filesystem = http.Dir(config.Root)
+		config.Root = "."
+	}
+
+	// Index template
+	t, err := template.New("index").Parse(html)
+	if err != nil {
+		panic(fmt.Sprintf("echo: %v", err))
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) (err error) {
+			if config.Skipper(c) {
+				return next(c)
+			}
+
+			p := c.Request().URL.Path
+			if strings.HasSuffix(c.Path(), "*") { // When serving from a group, e.g. `/static*`.
+				p = c.Param("*")
+			}
+			p, err = url.PathUnescape(p)
+			if err != nil {
+				return
+			}
+			name := filepath.Join(config.Root, filepath.Clean("/"+p)) // "/"+ for security
+
+			if config.IgnoreBase {
+				routePath := path.Base(strings.TrimRight(c.Path(), "/*"))
+				baseURLPath := path.Base(p)
+				if baseURLPath == routePath {
+					i := strings.LastIndex(name, routePath)
+					name = name[:i] + strings.Replace(name[i:], routePath, "", 1)
+				}
+			}
+
+			file, err := openFile(config.Filesystem, name)
+			if err != nil {
+				if !os.IsNotExist(err) {
+					return err
+				}
+
+				if err = next(c); err == nil {
+					return err
+				}
+
+				he, ok := err.(*echo.HTTPError)
+				if !(ok && config.HTML5 && he.Code == http.StatusNotFound) {
+					return err
+				}
+
+				file, err = openFile(config.Filesystem, filepath.Join(config.Root, config.Index))
+				if err != nil {
+					return err
+				}
+			}
+
+			defer file.Close()
+
+			info, err := file.Stat()
+			if err != nil {
+				return err
+			}
+
+			if info.IsDir() {
+				index, err := openFile(config.Filesystem, filepath.Join(name, config.Index))
+				if err != nil {
+					if config.Browse {
+						return listDir(t, name, file, c.Response())
+					}
+
+					if os.IsNotExist(err) {
+						return next(c)
+					}
+				}
+
+				defer index.Close()
+
+				info, err = index.Stat()
+				if err != nil {
+					return err
+				}
+
+				return serveFile(c, index, info)
+			}
+
+			return serveFile(c, file, info)
+		}
+	}
+}
+
+func openFile(fs http.FileSystem, name string) (http.File, error) {
+	pathWithSlashes := filepath.ToSlash(name)
+	return fs.Open(pathWithSlashes)
+}
+
+func serveFile(c echo.Context, file http.File, info os.FileInfo) error {
+	http.ServeContent(c.Response(), c.Request(), info.Name(), info.ModTime(), file)
+	return nil
+}
+
+func listDir(t *template.Template, name string, dir http.File, res *echo.Response) (err error) {
+	files, err := dir.Readdir(-1)
+	if err != nil {
+		return
+	}
+
+	// Create directory index
+	res.Header().Set(echo.HeaderContentType, echo.MIMETextHTMLCharsetUTF8)
+	data := struct {
+		Name  string
+		Files []interface{}
+	}{
+		Name: name,
+	}
+	for _, f := range files {
+		data.Files = append(data.Files, struct {
+			Name string
+			Dir  bool
+			Size string
+		}{f.Name(), f.IsDir(), bytes.Format(f.Size())})
+	}
+	return t.Execute(res, data)
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/timeout.go b/vendor/github.com/labstack/echo/v4/middleware/timeout.go
new file mode 100644
index 000000000..731136541
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/timeout.go
@@ -0,0 +1,128 @@
+package middleware
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/labstack/echo/v4"
+)
+
+type (
+	// TimeoutConfig defines the config for Timeout middleware.
+	TimeoutConfig struct {
+		// Skipper defines a function to skip middleware.
+		Skipper Skipper
+
+		// ErrorMessage is written to response on timeout in addition to http.StatusServiceUnavailable (503) status code
+		// It can be used to define a custom timeout error message
+		ErrorMessage string
+
+		// OnTimeoutRouteErrorHandler is an error handler that is executed for error that was returned from wrapped route after
+		// request timeouted and we already had sent the error code (503) and message response to the client.
+		// NB: do not write headers/body inside this handler. The response has already been sent to the client and response writer
+		// will not accept anything no more. If you want to know what actual route middleware timeouted use `c.Path()`
+		OnTimeoutRouteErrorHandler func(err error, c echo.Context)
+
+		// Timeout configures a timeout for the middleware, defaults to 0 for no timeout
+		// NOTE: when difference between timeout duration and handler execution time is almost the same (in range of 100microseconds)
+		// the result of timeout does not seem to be reliable - could respond timeout, could respond handler output
+		// difference over 500microseconds (0.5millisecond) response seems to be reliable
+		Timeout time.Duration
+	}
+)
+
+var (
+	// DefaultTimeoutConfig is the default Timeout middleware config.
+	DefaultTimeoutConfig = TimeoutConfig{
+		Skipper:      DefaultSkipper,
+		Timeout:      0,
+		ErrorMessage: "",
+	}
+)
+
+// Timeout returns a middleware which returns error (503 Service Unavailable error) to client immediately when handler
+// call runs for longer than its time limit. NB: timeout does not stop handler execution.
+func Timeout() echo.MiddlewareFunc {
+	return TimeoutWithConfig(DefaultTimeoutConfig)
+}
+
+// TimeoutWithConfig returns a Timeout middleware with config.
+// See: `Timeout()`.
+func TimeoutWithConfig(config TimeoutConfig) echo.MiddlewareFunc {
+	// Defaults
+	if config.Skipper == nil {
+		config.Skipper = DefaultTimeoutConfig.Skipper
+	}
+
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			if config.Skipper(c) || config.Timeout == 0 {
+				return next(c)
+			}
+
+			handlerWrapper := echoHandlerFuncWrapper{
+				ctx:        c,
+				handler:    next,
+				errChan:    make(chan error, 1),
+				errHandler: config.OnTimeoutRouteErrorHandler,
+			}
+			handler := http.TimeoutHandler(handlerWrapper, config.Timeout, config.ErrorMessage)
+			handler.ServeHTTP(c.Response().Writer, c.Request())
+
+			select {
+			case err := <-handlerWrapper.errChan:
+				return err
+			default:
+				return nil
+			}
+		}
+	}
+}
+
+type echoHandlerFuncWrapper struct {
+	ctx        echo.Context
+	handler    echo.HandlerFunc
+	errHandler func(err error, c echo.Context)
+	errChan    chan error
+}
+
+func (t echoHandlerFuncWrapper) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	// replace echo.Context Request with the one provided by TimeoutHandler to let later middlewares/handler on the chain
+	// handle properly it's cancellation
+	t.ctx.SetRequest(r)
+
+	// replace writer with TimeoutHandler custom one. This will guarantee that
+	// `writes by h to its ResponseWriter will return ErrHandlerTimeout.`
+	originalWriter := t.ctx.Response().Writer
+	t.ctx.Response().Writer = rw
+
+	// in case of panic we restore original writer and call panic again
+	// so it could be handled with global middleware Recover()
+	defer func() {
+		if err := recover(); err != nil {
+			t.ctx.Response().Writer = originalWriter
+			panic(err)
+		}
+	}()
+
+	err := t.handler(t.ctx)
+	if ctxErr := r.Context().Err(); ctxErr == context.DeadlineExceeded {
+		if err != nil && t.errHandler != nil {
+			t.errHandler(err, t.ctx)
+		}
+		return // on timeout we can not send handler error to client because `http.TimeoutHandler` has already sent headers
+	}
+	// we restore original writer only for cases we did not timeout. On timeout we have already sent response to client
+	// and should not anymore send additional headers/data
+	// so on timeout writer stays what http.TimeoutHandler uses and prevents writing headers/body
+	t.ctx.Response().Writer = originalWriter
+	if err != nil {
+		// call global error handler to write error to the client. This is needed or `http.TimeoutHandler` will send status code by itself
+		// and after that our tries to write status code will not work anymore
+		t.ctx.Error(err)
+		// we pass error from handler to middlewares up in handler chain to act on it if needed. But this means that
+		// global error handler is probably be called twice as `t.ctx.Error` already does that.
+		t.errChan <- err
+	}
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/util.go b/vendor/github.com/labstack/echo/v4/middleware/util.go
new file mode 100644
index 000000000..ab951a0e9
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/middleware/util.go
@@ -0,0 +1,54 @@
+package middleware
+
+import (
+	"strings"
+)
+
+func matchScheme(domain, pattern string) bool {
+	didx := strings.Index(domain, ":")
+	pidx := strings.Index(pattern, ":")
+	return didx != -1 && pidx != -1 && domain[:didx] == pattern[:pidx]
+}
+
+// matchSubdomain compares authority with wildcard
+func matchSubdomain(domain, pattern string) bool {
+	if !matchScheme(domain, pattern) {
+		return false
+	}
+	didx := strings.Index(domain, "://")
+	pidx := strings.Index(pattern, "://")
+	if didx == -1 || pidx == -1 {
+		return false
+	}
+	domAuth := domain[didx+3:]
+	// to avoid long loop by invalid long domain
+	if len(domAuth) > 253 {
+		return false
+	}
+	patAuth := pattern[pidx+3:]
+
+	domComp := strings.Split(domAuth, ".")
+	patComp := strings.Split(patAuth, ".")
+	for i := len(domComp)/2 - 1; i >= 0; i-- {
+		opp := len(domComp) - 1 - i
+		domComp[i], domComp[opp] = domComp[opp], domComp[i]
+	}
+	for i := len(patComp)/2 - 1; i >= 0; i-- {
+		opp := len(patComp) - 1 - i
+		patComp[i], patComp[opp] = patComp[opp], patComp[i]
+	}
+
+	for i, v := range domComp {
+		if len(patComp) <= i {
+			return false
+		}
+		p := patComp[i]
+		if p == "*" {
+			return true
+		}
+		if p != v {
+			return false
+		}
+	}
+	return false
+}
diff --git a/vendor/github.com/labstack/gommon/bytes/README.md b/vendor/github.com/labstack/gommon/bytes/README.md
new file mode 100644
index 000000000..6e3b0fd3d
--- /dev/null
+++ b/vendor/github.com/labstack/gommon/bytes/README.md
@@ -0,0 +1,29 @@
+# Bytes
+
+- Format bytes integer to human readable bytes string.
+- Parse human readable bytes string to bytes integer.
+
+## Installation
+
+```go
+go get github.com/labstack/gommon/bytes
+```
+
+## [Usage](https://github.com/labstack/gommon/blob/master/bytes/bytes_test.go)
+
+### Format
+
+```go
+println(bytes.Format(13231323))
+```
+
+`12.62MB`
+
+### Parse
+
+```go
+b, _ = Parse("2M")
+println(b)
+```
+
+`2097152`
diff --git a/vendor/github.com/labstack/gommon/bytes/bytes.go b/vendor/github.com/labstack/gommon/bytes/bytes.go
new file mode 100644
index 000000000..2f6bcec6e
--- /dev/null
+++ b/vendor/github.com/labstack/gommon/bytes/bytes.go
@@ -0,0 +1,109 @@
+package bytes
+
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+type (
+	// Bytes struct
+	Bytes struct{}
+)
+
+const (
+	_ = 1.0 << (10 * iota) // ignore first value by assigning to blank identifier
+	KB
+	MB
+	GB
+	TB
+	PB
+	EB
+)
+
+var (
+	pattern = regexp.MustCompile(`(?i)^(-?\d+(?:\.\d+)?)\s?([KMGTPE]B?|B?)$`)
+	global  = New()
+)
+
+// New creates a Bytes instance.
+func New() *Bytes {
+	return &Bytes{}
+}
+
+// Format formats bytes integer to human readable string.
+// For example, 31323 bytes will return 30.59KB.
+func (*Bytes) Format(b int64) string {
+	multiple := ""
+	value := float64(b)
+
+	switch {
+	case b >= EB:
+		value /= EB
+		multiple = "EB"
+	case b >= PB:
+		value /= PB
+		multiple = "PB"
+	case b >= TB:
+		value /= TB
+		multiple = "TB"
+	case b >= GB:
+		value /= GB
+		multiple = "GB"
+	case b >= MB:
+		value /= MB
+		multiple = "MB"
+	case b >= KB:
+		value /= KB
+		multiple = "KB"
+	case b == 0:
+		return "0"
+	default:
+		return strconv.FormatInt(b, 10) + "B"
+	}
+
+	return fmt.Sprintf("%.2f%s", value, multiple)
+}
+
+// Parse parses human readable bytes string to bytes integer.
+// For example, 6GB (6G is also valid) will return 6442450944.
+func (*Bytes) Parse(value string) (i int64, err error) {
+	parts := pattern.FindStringSubmatch(value)
+	if len(parts) < 3 {
+		return 0, fmt.Errorf("error parsing value=%s", value)
+	}
+	bytesString := parts[1]
+	multiple := strings.ToUpper(parts[2])
+	bytes, err := strconv.ParseFloat(bytesString, 64)
+	if err != nil {
+		return
+	}
+
+	switch multiple {
+	default:
+		return int64(bytes), nil
+	case "K", "KB":
+		return int64(bytes * KB), nil
+	case "M", "MB":
+		return int64(bytes * MB), nil
+	case "G", "GB":
+		return int64(bytes * GB), nil
+	case "T", "TB":
+		return int64(bytes * TB), nil
+	case "P", "PB":
+		return int64(bytes * PB), nil
+	case "E", "EB":
+		return int64(bytes * EB), nil
+	}
+}
+
+// Format wraps global Bytes's Format function.
+func Format(b int64) string {
+	return global.Format(b)
+}
+
+// Parse wraps global Bytes's Parse function.
+func Parse(val string) (int64, error) {
+	return global.Parse(val)
+}
diff --git a/vendor/github.com/labstack/gommon/random/random.go b/vendor/github.com/labstack/gommon/random/random.go
new file mode 100644
index 000000000..482d05248
--- /dev/null
+++ b/vendor/github.com/labstack/gommon/random/random.go
@@ -0,0 +1,48 @@
+package random
+
+import (
+	"math/rand"
+	"strings"
+	"time"
+)
+
+type (
+	Random struct {
+	}
+)
+
+// Charsets
+const (
+	Uppercase    = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+	Lowercase    = "abcdefghijklmnopqrstuvwxyz"
+	Alphabetic   = Uppercase + Lowercase
+	Numeric      = "0123456789"
+	Alphanumeric = Alphabetic + Numeric
+	Symbols      = "`" + `~!@#$%^&*()-_+={}[]|\;:"<>,./?`
+	Hex          = Numeric + "abcdef"
+)
+
+var (
+	global = New()
+)
+
+func New() *Random {
+	rand.Seed(time.Now().UnixNano())
+	return new(Random)
+}
+
+func (r *Random) String(length uint8, charsets ...string) string {
+	charset := strings.Join(charsets, "")
+	if charset == "" {
+		charset = Alphanumeric
+	}
+	b := make([]byte, length)
+	for i := range b {
+		b[i] = charset[rand.Int63()%int64(len(charset))]
+	}
+	return string(b)
+}
+
+func String(length uint8, charsets ...string) string {
+	return global.String(length, charsets...)
+}
diff --git a/vendor/golang.org/x/time/AUTHORS b/vendor/golang.org/x/time/AUTHORS
new file mode 100644
index 000000000..15167cd74
--- /dev/null
+++ b/vendor/golang.org/x/time/AUTHORS
@@ -0,0 +1,3 @@
+# This source code refers to The Go Authors for copyright purposes.
+# The master list of authors is in the main Go distribution,
+# visible at http://tip.golang.org/AUTHORS.
diff --git a/vendor/golang.org/x/time/CONTRIBUTORS b/vendor/golang.org/x/time/CONTRIBUTORS
new file mode 100644
index 000000000..1c4577e96
--- /dev/null
+++ b/vendor/golang.org/x/time/CONTRIBUTORS
@@ -0,0 +1,3 @@
+# This source code was written by the Go contributors.
+# The master list of contributors is in the main Go distribution,
+# visible at http://tip.golang.org/CONTRIBUTORS.
diff --git a/vendor/golang.org/x/time/LICENSE b/vendor/golang.org/x/time/LICENSE
new file mode 100644
index 000000000..6a66aea5e
--- /dev/null
+++ b/vendor/golang.org/x/time/LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/vendor/golang.org/x/time/PATENTS b/vendor/golang.org/x/time/PATENTS
new file mode 100644
index 000000000..733099041
--- /dev/null
+++ b/vendor/golang.org/x/time/PATENTS
@@ -0,0 +1,22 @@
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the Go project.
+
+Google hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section)
+patent license to make, have made, use, offer to sell, sell, import,
+transfer and otherwise run, modify and propagate the contents of this
+implementation of Go, where such license applies only to those patent
+claims, both currently owned or controlled by Google and acquired in
+the future, licensable by Google that are necessarily infringed by this
+implementation of Go.  This grant does not include claims that would be
+infringed only as a consequence of further modification of this
+implementation.  If you or your agent or exclusive licensee institute or
+order or agree to the institution of patent litigation against any
+entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that this implementation of Go or any code incorporated within this
+implementation of Go constitutes direct or contributory patent
+infringement, or inducement of patent infringement, then any patent
+rights granted to you under this License for this implementation of Go
+shall terminate as of the date such litigation is filed.
diff --git a/vendor/golang.org/x/time/rate/rate.go b/vendor/golang.org/x/time/rate/rate.go
new file mode 100644
index 000000000..a98fe7782
--- /dev/null
+++ b/vendor/golang.org/x/time/rate/rate.go
@@ -0,0 +1,402 @@
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package rate provides a rate limiter.
+package rate
+
+import (
+	"context"
+	"fmt"
+	"math"
+	"sync"
+	"time"
+)
+
+// Limit defines the maximum frequency of some events.
+// Limit is represented as number of events per second.
+// A zero Limit allows no events.
+type Limit float64
+
+// Inf is the infinite rate limit; it allows all events (even if burst is zero).
+const Inf = Limit(math.MaxFloat64)
+
+// Every converts a minimum time interval between events to a Limit.
+func Every(interval time.Duration) Limit {
+	if interval <= 0 {
+		return Inf
+	}
+	return 1 / Limit(interval.Seconds())
+}
+
+// A Limiter controls how frequently events are allowed to happen.
+// It implements a "token bucket" of size b, initially full and refilled
+// at rate r tokens per second.
+// Informally, in any large enough time interval, the Limiter limits the
+// rate to r tokens per second, with a maximum burst size of b events.
+// As a special case, if r == Inf (the infinite rate), b is ignored.
+// See https://en.wikipedia.org/wiki/Token_bucket for more about token buckets.
+//
+// The zero value is a valid Limiter, but it will reject all events.
+// Use NewLimiter to create non-zero Limiters.
+//
+// Limiter has three main methods, Allow, Reserve, and Wait.
+// Most callers should use Wait.
+//
+// Each of the three methods consumes a single token.
+// They differ in their behavior when no token is available.
+// If no token is available, Allow returns false.
+// If no token is available, Reserve returns a reservation for a future token
+// and the amount of time the caller must wait before using it.
+// If no token is available, Wait blocks until one can be obtained
+// or its associated context.Context is canceled.
+//
+// The methods AllowN, ReserveN, and WaitN consume n tokens.
+type Limiter struct {
+	mu     sync.Mutex
+	limit  Limit
+	burst  int
+	tokens float64
+	// last is the last time the limiter's tokens field was updated
+	last time.Time
+	// lastEvent is the latest time of a rate-limited event (past or future)
+	lastEvent time.Time
+}
+
+// Limit returns the maximum overall event rate.
+func (lim *Limiter) Limit() Limit {
+	lim.mu.Lock()
+	defer lim.mu.Unlock()
+	return lim.limit
+}
+
+// Burst returns the maximum burst size. Burst is the maximum number of tokens
+// that can be consumed in a single call to Allow, Reserve, or Wait, so higher
+// Burst values allow more events to happen at once.
+// A zero Burst allows no events, unless limit == Inf.
+func (lim *Limiter) Burst() int {
+	lim.mu.Lock()
+	defer lim.mu.Unlock()
+	return lim.burst
+}
+
+// NewLimiter returns a new Limiter that allows events up to rate r and permits
+// bursts of at most b tokens.
+func NewLimiter(r Limit, b int) *Limiter {
+	return &Limiter{
+		limit: r,
+		burst: b,
+	}
+}
+
+// Allow is shorthand for AllowN(time.Now(), 1).
+func (lim *Limiter) Allow() bool {
+	return lim.AllowN(time.Now(), 1)
+}
+
+// AllowN reports whether n events may happen at time now.
+// Use this method if you intend to drop / skip events that exceed the rate limit.
+// Otherwise use Reserve or Wait.
+func (lim *Limiter) AllowN(now time.Time, n int) bool {
+	return lim.reserveN(now, n, 0).ok
+}
+
+// A Reservation holds information about events that are permitted by a Limiter to happen after a delay.
+// A Reservation may be canceled, which may enable the Limiter to permit additional events.
+type Reservation struct {
+	ok        bool
+	lim       *Limiter
+	tokens    int
+	timeToAct time.Time
+	// This is the Limit at reservation time, it can change later.
+	limit Limit
+}
+
+// OK returns whether the limiter can provide the requested number of tokens
+// within the maximum wait time.  If OK is false, Delay returns InfDuration, and
+// Cancel does nothing.
+func (r *Reservation) OK() bool {
+	return r.ok
+}
+
+// Delay is shorthand for DelayFrom(time.Now()).
+func (r *Reservation) Delay() time.Duration {
+	return r.DelayFrom(time.Now())
+}
+
+// InfDuration is the duration returned by Delay when a Reservation is not OK.
+const InfDuration = time.Duration(1<<63 - 1)
+
+// DelayFrom returns the duration for which the reservation holder must wait
+// before taking the reserved action.  Zero duration means act immediately.
+// InfDuration means the limiter cannot grant the tokens requested in this
+// Reservation within the maximum wait time.
+func (r *Reservation) DelayFrom(now time.Time) time.Duration {
+	if !r.ok {
+		return InfDuration
+	}
+	delay := r.timeToAct.Sub(now)
+	if delay < 0 {
+		return 0
+	}
+	return delay
+}
+
+// Cancel is shorthand for CancelAt(time.Now()).
+func (r *Reservation) Cancel() {
+	r.CancelAt(time.Now())
+	return
+}
+
+// CancelAt indicates that the reservation holder will not perform the reserved action
+// and reverses the effects of this Reservation on the rate limit as much as possible,
+// considering that other reservations may have already been made.
+func (r *Reservation) CancelAt(now time.Time) {
+	if !r.ok {
+		return
+	}
+
+	r.lim.mu.Lock()
+	defer r.lim.mu.Unlock()
+
+	if r.lim.limit == Inf || r.tokens == 0 || r.timeToAct.Before(now) {
+		return
+	}
+
+	// calculate tokens to restore
+	// The duration between lim.lastEvent and r.timeToAct tells us how many tokens were reserved
+	// after r was obtained. These tokens should not be restored.
+	restoreTokens := float64(r.tokens) - r.limit.tokensFromDuration(r.lim.lastEvent.Sub(r.timeToAct))
+	if restoreTokens <= 0 {
+		return
+	}
+	// advance time to now
+	now, _, tokens := r.lim.advance(now)
+	// calculate new number of tokens
+	tokens += restoreTokens
+	if burst := float64(r.lim.burst); tokens > burst {
+		tokens = burst
+	}
+	// update state
+	r.lim.last = now
+	r.lim.tokens = tokens
+	if r.timeToAct == r.lim.lastEvent {
+		prevEvent := r.timeToAct.Add(r.limit.durationFromTokens(float64(-r.tokens)))
+		if !prevEvent.Before(now) {
+			r.lim.lastEvent = prevEvent
+		}
+	}
+
+	return
+}
+
+// Reserve is shorthand for ReserveN(time.Now(), 1).
+func (lim *Limiter) Reserve() *Reservation {
+	return lim.ReserveN(time.Now(), 1)
+}
+
+// ReserveN returns a Reservation that indicates how long the caller must wait before n events happen.
+// The Limiter takes this Reservation into account when allowing future events.
+// The returned Reservation’s OK() method returns false if n exceeds the Limiter's burst size.
+// Usage example:
+//   r := lim.ReserveN(time.Now(), 1)
+//   if !r.OK() {
+//     // Not allowed to act! Did you remember to set lim.burst to be > 0 ?
+//     return
+//   }
+//   time.Sleep(r.Delay())
+//   Act()
+// Use this method if you wish to wait and slow down in accordance with the rate limit without dropping events.
+// If you need to respect a deadline or cancel the delay, use Wait instead.
+// To drop or skip events exceeding rate limit, use Allow instead.
+func (lim *Limiter) ReserveN(now time.Time, n int) *Reservation {
+	r := lim.reserveN(now, n, InfDuration)
+	return &r
+}
+
+// Wait is shorthand for WaitN(ctx, 1).
+func (lim *Limiter) Wait(ctx context.Context) (err error) {
+	return lim.WaitN(ctx, 1)
+}
+
+// WaitN blocks until lim permits n events to happen.
+// It returns an error if n exceeds the Limiter's burst size, the Context is
+// canceled, or the expected wait time exceeds the Context's Deadline.
+// The burst limit is ignored if the rate limit is Inf.
+func (lim *Limiter) WaitN(ctx context.Context, n int) (err error) {
+	lim.mu.Lock()
+	burst := lim.burst
+	limit := lim.limit
+	lim.mu.Unlock()
+
+	if n > burst && limit != Inf {
+		return fmt.Errorf("rate: Wait(n=%d) exceeds limiter's burst %d", n, burst)
+	}
+	// Check if ctx is already cancelled
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	default:
+	}
+	// Determine wait limit
+	now := time.Now()
+	waitLimit := InfDuration
+	if deadline, ok := ctx.Deadline(); ok {
+		waitLimit = deadline.Sub(now)
+	}
+	// Reserve
+	r := lim.reserveN(now, n, waitLimit)
+	if !r.ok {
+		return fmt.Errorf("rate: Wait(n=%d) would exceed context deadline", n)
+	}
+	// Wait if necessary
+	delay := r.DelayFrom(now)
+	if delay == 0 {
+		return nil
+	}
+	t := time.NewTimer(delay)
+	defer t.Stop()
+	select {
+	case <-t.C:
+		// We can proceed.
+		return nil
+	case <-ctx.Done():
+		// Context was canceled before we could proceed.  Cancel the
+		// reservation, which may permit other events to proceed sooner.
+		r.Cancel()
+		return ctx.Err()
+	}
+}
+
+// SetLimit is shorthand for SetLimitAt(time.Now(), newLimit).
+func (lim *Limiter) SetLimit(newLimit Limit) {
+	lim.SetLimitAt(time.Now(), newLimit)
+}
+
+// SetLimitAt sets a new Limit for the limiter. The new Limit, and Burst, may be violated
+// or underutilized by those which reserved (using Reserve or Wait) but did not yet act
+// before SetLimitAt was called.
+func (lim *Limiter) SetLimitAt(now time.Time, newLimit Limit) {
+	lim.mu.Lock()
+	defer lim.mu.Unlock()
+
+	now, _, tokens := lim.advance(now)
+
+	lim.last = now
+	lim.tokens = tokens
+	lim.limit = newLimit
+}
+
+// SetBurst is shorthand for SetBurstAt(time.Now(), newBurst).
+func (lim *Limiter) SetBurst(newBurst int) {
+	lim.SetBurstAt(time.Now(), newBurst)
+}
+
+// SetBurstAt sets a new burst size for the limiter.
+func (lim *Limiter) SetBurstAt(now time.Time, newBurst int) {
+	lim.mu.Lock()
+	defer lim.mu.Unlock()
+
+	now, _, tokens := lim.advance(now)
+
+	lim.last = now
+	lim.tokens = tokens
+	lim.burst = newBurst
+}
+
+// reserveN is a helper method for AllowN, ReserveN, and WaitN.
+// maxFutureReserve specifies the maximum reservation wait duration allowed.
+// reserveN returns Reservation, not *Reservation, to avoid allocation in AllowN and WaitN.
+func (lim *Limiter) reserveN(now time.Time, n int, maxFutureReserve time.Duration) Reservation {
+	lim.mu.Lock()
+
+	if lim.limit == Inf {
+		lim.mu.Unlock()
+		return Reservation{
+			ok:        true,
+			lim:       lim,
+			tokens:    n,
+			timeToAct: now,
+		}
+	}
+
+	now, last, tokens := lim.advance(now)
+
+	// Calculate the remaining number of tokens resulting from the request.
+	tokens -= float64(n)
+
+	// Calculate the wait duration
+	var waitDuration time.Duration
+	if tokens < 0 {
+		waitDuration = lim.limit.durationFromTokens(-tokens)
+	}
+
+	// Decide result
+	ok := n <= lim.burst && waitDuration <= maxFutureReserve
+
+	// Prepare reservation
+	r := Reservation{
+		ok:    ok,
+		lim:   lim,
+		limit: lim.limit,
+	}
+	if ok {
+		r.tokens = n
+		r.timeToAct = now.Add(waitDuration)
+	}
+
+	// Update state
+	if ok {
+		lim.last = now
+		lim.tokens = tokens
+		lim.lastEvent = r.timeToAct
+	} else {
+		lim.last = last
+	}
+
+	lim.mu.Unlock()
+	return r
+}
+
+// advance calculates and returns an updated state for lim resulting from the passage of time.
+// lim is not changed.
+// advance requires that lim.mu is held.
+func (lim *Limiter) advance(now time.Time) (newNow time.Time, newLast time.Time, newTokens float64) {
+	last := lim.last
+	if now.Before(last) {
+		last = now
+	}
+
+	// Avoid making delta overflow below when last is very old.
+	maxElapsed := lim.limit.durationFromTokens(float64(lim.burst) - lim.tokens)
+	elapsed := now.Sub(last)
+	if elapsed > maxElapsed {
+		elapsed = maxElapsed
+	}
+
+	// Calculate the new number of tokens, due to time that passed.
+	delta := lim.limit.tokensFromDuration(elapsed)
+	tokens := lim.tokens + delta
+	if burst := float64(lim.burst); tokens > burst {
+		tokens = burst
+	}
+
+	return now, last, tokens
+}
+
+// durationFromTokens is a unit conversion function from the number of tokens to the duration
+// of time it takes to accumulate them at a rate of limit tokens per second.
+func (limit Limit) durationFromTokens(tokens float64) time.Duration {
+	seconds := tokens / float64(limit)
+	return time.Nanosecond * time.Duration(1e9*seconds)
+}
+
+// tokensFromDuration is a unit conversion function from a time duration to the number of tokens
+// which could be accumulated during that duration at a rate of limit tokens per second.
+func (limit Limit) tokensFromDuration(d time.Duration) float64 {
+	// Split the integer and fractional parts ourself to minimize rounding errors.
+	// See golang.org/issues/34861.
+	sec := float64(d/time.Second) * float64(limit)
+	nsec := float64(d%time.Second) * float64(limit)
+	return sec + nsec/1e9
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 98f342954..837180d5e 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -252,9 +252,12 @@ github.com/kr/text
 # github.com/labstack/echo/v4 v4.5.0
 ## explicit
 github.com/labstack/echo/v4
+github.com/labstack/echo/v4/middleware
 # github.com/labstack/gommon v0.3.0
+github.com/labstack/gommon/bytes
 github.com/labstack/gommon/color
 github.com/labstack/gommon/log
+github.com/labstack/gommon/random
 # github.com/mattn/go-colorable v0.1.8
 github.com/mattn/go-colorable
 # github.com/mattn/go-ieproxy v0.0.1
@@ -433,6 +436,8 @@ golang.org/x/text/transform
 golang.org/x/text/unicode/bidi
 golang.org/x/text/unicode/norm
 golang.org/x/text/width
+# golang.org/x/time v0.0.0-20201208040808-7e3f01d25324
+golang.org/x/time/rate
 # google.golang.org/api v0.56.0
 ## explicit
 google.golang.org/api/compute/v1


Test patch:
diff --git a/test/cases/ostree-ng-og.sh b/test/cases/ostree-ng-og.sh
new file mode 100644
index 000000000..975f5947e
--- /dev/null
+++ b/test/cases/ostree-ng-og.sh
@@ -0,0 +1,648 @@
+#!/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${1}\033[0m"
+}
+
+# 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.
+OSTREE_REF="test/rhel/9/${ARCH}/edge"
+OS_VARIANT="rhel9-unknown"
+TEST_UUID=$(uuidgen)
+IMAGE_KEY="osbuild-composer-ostree-test-${TEST_UUID}"
+BIOS_GUEST_ADDRESS=192.168.100.50
+UEFI_GUEST_ADDRESS=192.168.100.51
+PROD_REPO_URL=http://192.168.100.1/repo
+PROD_REPO=/var/www/html/repo
+STAGE_REPO_ADDRESS=192.168.200.1
+STAGE_REPO_URL="http://${STAGE_REPO_ADDRESS}/repo/"
+QUAY_REPO_URL="docker://quay.io/osbuild/testing-rhel-edge-push"
+QUAY_REPO_TAG=$(tr -dc a-z0-9 < /dev/urandom | head -c 4 ; echo '')
+ARTIFACTS="ci-artifacts"
+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)
+
+if [[ "${ID}-${VERSION_ID}" != "rhel-9.0" ]]; then
+        echo "unsupported distro: ${ID}-${VERSION_ID}"
+        exit 1
+fi
+
+CONTAINER_TYPE=edge-container
+CONTAINER_FILENAME=container.tar
+INSTALLER_TYPE=edge-installer
+INSTALLER_FILENAME=installer.iso
+
+
+# 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")")
+    tar -xf "$TARBALL" -C "${TEMPDIR}"
+    rm -f "$TARBALL"
+
+    # Move the JSON file into place.
+    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"
+    else
+        sudo composer-cli --json compose start-ostree --ref "$OSTREE_REF" "$blueprint_name" "$image_type" | tee "$COMPOSE_START"
+    fi
+    COMPOSE_ID=$(jq -r '.body.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=$(jq -r '.body.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"
+    # Remove tag from quay.io repo
+    skopeo delete --creds "${QUAY_USERNAME}:${QUAY_PASSWORD}" "${QUAY_REPO_URL}:${QUAY_REPO_TAG}"
+
+    # Clear 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 rm -f "$LIBVIRT_UEFI_IMAGE_PATH"
+
+    # 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
+
+    # Remove prod repo
+    sudo rm -rf "$PROD_REPO"
+
+    # Remomve tmp dir.
+    sudo rm -rf "$TEMPDIR"
+
+    # Stop prod repo http service
+    sudo systemctl disable --now httpd
+}
+
+# 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 edge prod and stage repo
+##
+###########################################################
+greenprint "🔧 Prepare edge prod repo"
+# Start prod repo web service
+# osbuild-composer-tests have mod_ssl as a dependency. The package installs
+# an example configuration which automatically enabled httpd on port 443, but
+# that one is already in use. Remove the default configuration as it is useless
+# anyway.
+sudo rm -f /etc/httpd/conf.d/ssl.conf
+sudo systemctl enable --now httpd.service
+
+# Have a clean prod repo
+sudo rm -rf "$PROD_REPO"
+sudo mkdir -p "$PROD_REPO"
+sudo ostree --repo="$PROD_REPO" init --mode=archive
+sudo ostree --repo="$PROD_REPO" remote add --no-gpg-verify edge-stage "$STAGE_REPO_URL"
+
+# 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 --ip-range=192.168.200.0/24 --gateway=192.168.200.254 edge
+
+##########################################################
+##
+## edge container image for building installer image
+##
+##########################################################
+
+# 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 "🗜 Pushing image to quay.io"
+IMAGE_FILENAME="${COMPOSE_ID}-${CONTAINER_FILENAME}"
+skopeo copy --dest-creds "${QUAY_USERNAME}:${QUAY_PASSWORD}" "oci-archive:${IMAGE_FILENAME}" "${QUAY_REPO_URL}:${QUAY_REPO_TAG}"
+greenprint "Downloading image from quay.io"
+sudo podman login quay.io --username "${QUAY_USERNAME}" --password "${QUAY_PASSWORD}"
+sudo podman pull "${QUAY_REPO_URL}:${QUAY_REPO_TAG}"
+sudo podman images
+greenprint "🗜 Running the image"
+sudo podman run -d --name rhel-edge --network edge --ip "$STAGE_REPO_ADDRESS" "${QUAY_REPO_URL}:${QUAY_REPO_TAG}"
+# 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;
+
+# Sync installer edge content
+greenprint "📡 Sync installer content from stage repo"
+sudo ostree --repo="$PROD_REPO" pull --mirror edge-stage "$OSTREE_REF"
+
+# 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
+
+########################################################
+##
+## rhel-edge installer image building from container image
+##
+########################################################
+
+# Write a blueprint for installer image.
+tee "$BLUEPRINT_FILE" > /dev/null << EOF
+name = "installer"
+description = "A rhel-edge installer image"
+version = "0.0.1"
+modules = []
+groups = []
+EOF
+
+greenprint "📄 installer blueprint"
+cat "$BLUEPRINT_FILE"
+
+# Prepare the blueprint for the compose.
+greenprint "📋 Preparing installer blueprint"
+sudo composer-cli blueprints push "$BLUEPRINT_FILE"
+sudo composer-cli blueprints depsolve installer
+
+# Build installer image.
+# Test --url arg following by URL with tailling slash for bz#1942029
+build_image installer "${INSTALLER_TYPE}" "${PROD_REPO_URL}/"
+
+# Download the image
+greenprint "📥 Downloading the installer image"
+sudo composer-cli compose image "${COMPOSE_ID}" > /dev/null
+ISO_FILENAME="${COMPOSE_ID}-${INSTALLER_FILENAME}"
+sudo mv "${ISO_FILENAME}" /var/lib/libvirt/images
+
+# Clean compose and blueprints.
+greenprint "🧹 Clean up installer blueprint and compose"
+sudo composer-cli compose delete "${COMPOSE_ID}" > /dev/null
+sudo composer-cli blueprints delete installer > /dev/null
+
+########################################################
+##
+## install rhel-edge image with installer(ISO)
+##
+########################################################
+
+# Ensure SELinux is happy with our new images.
+greenprint "👿 Running restorecon on image directory"
+sudo restorecon -Rv /var/lib/libvirt/images/
+
+# Create qcow2 file for virt install.
+greenprint "🖥 Create qcow2 file for virt install"
+LIBVIRT_BIOS_IMAGE_PATH=/var/lib/libvirt/images/${IMAGE_KEY}-bios.qcow2
+LIBVIRT_UEFI_IMAGE_PATH=/var/lib/libvirt/images/${IMAGE_KEY}-uefi.qcow2
+sudo qemu-img create -f qcow2 "${LIBVIRT_BIOS_IMAGE_PATH}" 20G
+sudo qemu-img create -f qcow2 "${LIBVIRT_UEFI_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
+ostreesetup --nogpg --osname=rhel-edge --remote=rhel-edge --url=file:///ostree/repo --ref=${OSTREE_REF}
+poweroff
+
+%post --log=/var/log/anaconda/post-install.log --erroronfail
+
+# no sudo password for user admin
+echo -e 'admin\tALL=(ALL)\tNOPASSWD: ALL' >> /etc/sudoers
+
+# delete local ostree repo and add external prod edge repo
+ostree remote delete rhel-edge
+ostree remote add --no-gpg-verify --no-sign-verify rhel-edge ${PROD_REPO_URL}
+%end
+STOPHERE
+
+##################################################
+##
+## Install and test Edge image on BIOS VM
+##
+##################################################
+# Install ostree image via anaconda.
+greenprint "💿 Install ostree image via installer(ISO) 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_BIOS_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 "/var/lib/libvirt/images/${ISO_FILENAME}" \
+                   --nographics \
+                   --noautoconsole \
+                   --wait=-1 \
+                   --noreboot
+
+# Start VM.
+greenprint "📟 Start BIOS 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
+
+# Get ostree commit value.
+greenprint "🕹 Get ostree install commit value"
+INSTALL_HASH=$(curl "${PROD_REPO_URL}/refs/heads/${OSTREE_REF}")
+
+# Run Edge test on BIOS VM
+# 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
+greenprint "📼 Run Edge tests on BIOS VM"
+sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=rhel-edge -e ostree_commit="${INSTALL_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
+check_result
+
+# Clean up BIOS VM
+greenprint "🧹 Clean up BIOS VM"
+if [[ $(sudo virsh domstate "${IMAGE_KEY}-bios") == "running" ]]; then
+    sudo virsh destroy "${IMAGE_KEY}-bios"
+fi
+sudo virsh undefine "${IMAGE_KEY}-bios"
+sudo rm -f "$LIBVIRT_BIOS_IMAGE_PATH"
+
+##################################################
+##
+## Install, upgrade and test Edge image on UEFI VM
+##
+##################################################
+# Install ostree image via anaconda.
+greenprint "💿 Install ostree image via installer(ISO) 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_UEFI_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 "/var/lib/libvirt/images/${ISO_FILENAME}" \
+                   --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 UEFI 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
+
+##################################################
+##
+## upgrade rhel-edge with new upgrade commit
+##
+##################################################
+
+# 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."
+key = "${SSH_KEY_PUB}"
+home = "/home/admin/"
+groups = ["wheel"]
+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.
+build_image upgrade  "${CONTAINER_TYPE}" "$PROD_REPO_URL"
+
+# 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 "$STAGE_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;
+
+# Pull upgrade to prod mirror
+greenprint "⛓ Pull upgrade to prod mirror"
+sudo ostree --repo="$PROD_REPO" pull --mirror edge-stage "$OSTREE_REF"
+sudo ostree --repo="$PROD_REPO" static-delta generate "$OSTREE_REF"
+sudo ostree --repo="$PROD_REPO" summary -u
+
+# 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
+
+# Upgrade image/commit.
+greenprint "🗳 Upgrade ostree image/commit"
+sudo ssh "${SSH_OPTIONS[@]}" -i "${SSH_KEY}" admin@${UEFI_GUEST_ADDRESS} 'sudo rpm-ostree upgrade'
+sudo ssh "${SSH_OPTIONS[@]}" -i "${SSH_KEY}" admin@${UEFI_GUEST_ADDRESS} 'nohup sudo 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}" /usr/share/tests/osbuild-composer/ansible/check_ostree_nort.yaml || RESULTS=0
+check_result
+
+# Final success clean up
+clean_up
+
+exit 0
diff --git a/test/cases/ostree-ng.sh b/test/cases/ostree-ng.sh
index 96f6966c3..48da470a0 100755
--- a/test/cases/ostree-ng.sh
+++ b/test/cases/ostree-ng.sh
@@ -500,7 +500,7 @@ EOF
 
 # Test IoT/Edge OS
 greenprint "📼 Run Edge tests on BIOS VM"
-sudo ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=rhel-edge -e ostree_commit="${INSTALL_HASH}" /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="${INSTALL_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
 check_result
 
 # Clean up BIOS VM
@@ -682,7 +682,7 @@ ansible_ssh_common_args="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/
 EOF
 
 # Test IoT/Edge OS
-sudo ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=rhel-edge -e ostree_commit="${UPGRADE_HASH}" /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}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
 check_result
 
 # Final success clean up
diff --git a/test/cases/ostree-raw-image.sh b/test/cases/ostree-raw-image.sh
index df16b2a6e..e5863cdc9 100755
--- a/test/cases/ostree-raw-image.sh
+++ b/test/cases/ostree-raw-image.sh
@@ -439,7 +439,7 @@ ansible_become_pass=${EDGE_USER_PASSWORD}
 EOF
 
 # Test IoT/Edge OS
-sudo ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${INSTALL_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
+sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${INSTALL_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
 check_result
 
 # Clean up BIOS VM
@@ -512,7 +512,7 @@ ansible_become_pass=${EDGE_USER_PASSWORD}
 EOF
 
 # Test IoT/Edge OS
-sudo ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${INSTALL_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
+sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${INSTALL_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
 check_result
 
 ##################################################################
@@ -648,7 +648,7 @@ ansible_become_pass=${EDGE_USER_PASSWORD}
 EOF
 
 # Test IoT/Edge OS
-sudo ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${UPGRADE_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
+sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${UPGRADE_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
 check_result
 
 # Final success clean up
diff --git a/test/cases/ostree-simplified-installer.sh b/test/cases/ostree-simplified-installer.sh
index 86f4da90f..9b42f89ad 100755
--- a/test/cases/ostree-simplified-installer.sh
+++ b/test/cases/ostree-simplified-installer.sh
@@ -463,7 +463,7 @@ ansible_become_pass=${EDGE_USER_PASSWORD}
 EOF
 
 # Test IoT/Edge OS
-sudo ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${INSTALL_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
+sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${INSTALL_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
 check_result
 
 # Clean up BIOS VM
@@ -540,7 +540,7 @@ ansible_become_pass=${EDGE_USER_PASSWORD}
 EOF
 
 # Test IoT/Edge OS
-sudo ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${INSTALL_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
+sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${INSTALL_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
 check_result
 
 ##################################################################
@@ -675,7 +675,7 @@ ansible_become_pass=${EDGE_USER_PASSWORD}
 EOF
 
 # Test IoT/Edge OS
-sudo ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${UPGRADE_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
+sudo ansible-playbook -v -i "${TEMPDIR}"/inventory -e image_type=redhat -e ostree_commit="${UPGRADE_HASH}" /usr/share/tests/osbuild-composer/ansible/check_ostree.yaml || RESULTS=0
 check_result
 
 # Final success clean up
diff --git a/test/cases/ostree.sh b/test/cases/ostree.sh
index e20301993..14527c028 100755
--- a/test/cases/ostree.sh
+++ b/test/cases/ostree.sh
@@ -53,6 +53,15 @@ function greenprint {
     echo -e "\033[1;32m${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
@@ -170,13 +179,13 @@ build_image() {
     else
         sudo composer-cli --json compose start "$blueprint_name" $IMAGE_TYPE | tee "$COMPOSE_START"
     fi
-    COMPOSE_ID=$(jq -r '.build_id' "$COMPOSE_START")
+    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=$(jq -r '.queue_status' "$COMPOSE_INFO")
+        COMPOSE_STATUS=$(get_build_info ".queue_status" "$COMPOSE_INFO")
 
         # Is the compose finished?
         if [[ $COMPOSE_STATUS != RUNNING ]] && [[ $COMPOSE_STATUS != WAITING ]]; then
@@ -261,7 +270,7 @@ modules = []
 groups = []
 
 [[packages]]
-name = "python36"
+name = "python3"
 version = "*"
 EOF
 
@@ -426,7 +435,7 @@ modules = []
 groups = []
 
 [[packages]]
-name = "python36"
+name = "python3"
 version = "*"
 
 [[packages]]
diff --git a/test/data/ansible/check_ostree_nort.yaml b/test/data/ansible/check_ostree_nort.yaml
new file mode 100644
index 000000000..cb0803fcb
--- /dev/null
+++ b/test/data/ansible/check_ostree_nort.yaml
@@ -0,0 +1,530 @@
+---
+- hosts: ostree_guest
+  become: no
+  vars:
+    workspace: "{{ lookup('env', 'WORKSPACE') }}"
+    total_counter: "0"
+    failed_counter: "0"
+
+  tasks:
+    # current target host's IP address
+    - debug: var=ansible_all_ipv4_addresses
+
+    # default kernel or rt kernel
+    - name: check installed kernel
+      command: uname -r
+      register: result_kernel
+
+    # first installed or upgraded
+    # first installed has one commit, but upgraded has two
+    - name: determin which stage the checking is running on
+      shell: rpm-ostree status --json | jq '.deployments | length'
+      register: result_stage
+
+    - set_fact:
+        checking_stage: "{{ result_stage.stdout }}"
+
+    # case: check ostree commit correctly updated
+    - name: get deployed ostree commit
+      command: rpm-ostree status --json
+      register: result_commit
+
+    - name: make a json result
+      set_fact:
+        deploy_commit: "{{ result_commit.stdout | from_json | json_query('deployments[0].checksum') }}"
+
+    - name: check commit deployed and built
+      block:
+        - assert:
+            that:
+              - deploy_commit == ostree_commit
+            fail_msg: "deployed ostree commit is not commit built by osbuild-composer"
+            success_msg: "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 }}"
+
+    # case from bug: https://bugzilla.redhat.com/show_bug.cgi?id=1848453
+    - name: check ostree-remount status
+      command: systemctl is-active ostree-remount.service
+      register: result_remount
+
+    - name: ostree-remount should be started
+      block:
+        - assert:
+            that:
+              - result_remount.stdout == "active"
+            fail_msg: "ostree-remount is not started by default"
+            success_msg: "starting ostree-remount successful"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # bios or uefi
+    - name: bios or uefi
+      stat:
+        path: /sys/firmware/efi
+      register: result_uefi
+
+    - set_fact:
+        device_name: /dev/vda2
+
+    - set_fact:
+        device_name: /dev/vda3
+      when: result_uefi.stat.exists
+
+    # for edge-simplified-installer and edge-raw-image, /sysroot is mounted at /dev/vda4, need to set device_name to /dev/vda4
+    - name: check if it is simplified-installer or raw-image
+      command: df -h
+      register: result_df
+    
+    - set_fact:
+        device_name: /dev/vda4
+      when: "'/dev/vda4' in result_df.stdout"
+
+    # case: check /sysroot moutn point
+    - name: check /sysroot mount point
+      command: findmnt -r -o SOURCE -n /sysroot
+      register: result_sysroot_mount_point
+
+    - name: "/sysroot should be mounted on {{ device_name }}"
+      block:
+        - assert:
+            that:
+              - result_sysroot_mount_point.stdout == "{{ device_name }}"
+            fail_msg: "/var does not mount on {{ device_name }}"
+            success_msg: "/var mounts on {{ device_name }}"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check /sysroot mount status
+    - name: check /sysroot mount status
+      shell: findmnt -r -o OPTIONS -n /sysroot | awk -F "," '{print $1}'
+      register: result_sysroot_mount_status
+
+    - name: /sysroot should be mount with rw permission
+      block:
+        - assert:
+            that:
+              - result_sysroot_mount_status.stdout == "rw"
+            fail_msg: "/sysroot is not mounted with rw permission"
+            success_msg: "/sysroot is mounted with rw permission"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check /var mount point
+    - name: check /var mount point
+      command: findmnt -r -o SOURCE -n /var
+      register: result_var_mount_point
+
+    - name: "/var should be mounted on {{ device_name }}[/ostree/deploy/{{ image_type }}/var]"
+      block:
+        - assert:
+            that:
+              - result_var_mount_point.stdout == "{{ device_name }}[/ostree/deploy/{{ image_type }}/var]"
+            fail_msg: "/var does not mount on {{ device_name }}[/ostree/deploy/{{ image_type }}/var]"
+            success_msg: "/var mounts on {{ device_name }}[/ostree/deploy/{{ image_type }}/var]"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check /var mount status
+    - name: check /var mount status
+      shell: findmnt -r -o OPTIONS -n /var | awk -F "," '{print $1}'
+      register: result_var_mount_status
+
+    - name: /var should be mount with rw permission
+      block:
+        - assert:
+            that:
+              - result_var_mount_status.stdout == "rw"
+            fail_msg: "/var is not mounted with rw permission"
+            success_msg: "/var is mounted with rw permission"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check /usr mount point
+    - name: check /usr mount point
+      command: findmnt -r -o SOURCE -n /usr
+      register: result_usr_mount_point
+
+    - name: "/usr should be mounted on {{ device_name }}[/ostree/deploy/{{ image_type }}/deploy/{{ deploy_commit }}.0/usr]"
+      block:
+        - assert:
+            that:
+              - result_usr_mount_point.stdout == "{{ device_name }}[/ostree/deploy/{{ image_type }}/deploy/{{ deploy_commit }}.0/usr]"
+            fail_msg: "/usr does not mount on {{ device_name }}[/ostree/deploy/{{ image_type }}/deploy/{{ deploy_commit }}.0/usr]"
+            success_msg: "/usr mounts on {{ device_name }}[/ostree/deploy/{{ image_type }}/deploy/{{ deploy_commit }}.0/usr]"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check /usr mount status
+    - name: check /usr mount status
+      shell: findmnt -r -o OPTIONS -n /usr | awk -F "," '{print $1}'
+      register: result_usr_mount_status
+
+    - name: /usr should be mount with rw permission
+      block:
+        - assert:
+            that:
+              - result_usr_mount_status.stdout == "ro"
+            fail_msg: "/usr is not mounted with ro permission"
+            success_msg: "/usr is mounted with ro permission"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    - name: get the first 10 chars in commit hash
+      set_fact:
+        commit_log: "{{ deploy_commit[:11] }}"
+
+    # case: check wget installed after upgrade
+    - name: check installed package
+      shell: rpm -qa | sort
+      register: result_packages
+
+    - name: check wget installed
+      block:
+        - assert:
+            that:
+              - "'wget' in result_packages.stdout"
+            fail_msg: "wget not installed, ostree upgrade might be failed"
+            success_msg: "wget installed in ostree upgrade"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+      when: checking_stage == "2"
+
+    - name: save installed package to log file
+      copy:
+        content: "{{ result_packages.stdout }}"
+        dest: "{{ workspace }}/{{ commit_log }}.installed.ostree.log"
+      delegate_to: localhost
+
+    # case: check ostree-remount mount log
+    - name: check ostree-remount mount log
+      command: journalctl -u ostree-remount
+      register: result_remount_jounalctl
+
+    - name: ostree-remount should remount /var and /sysroot
+      block:
+        - assert:
+            that:
+              - "'/sysroot' in result_remount_jounalctl.stdout"
+              - "'/var' in result_remount_jounalctl.stdout"
+            fail_msg: "/sysroot or /var are not remounted by ostree-remount"
+            success_msg: "/sysroot and /var are remount"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check dmesg error and failed log
+    - name: check dmesg output
+      command: dmesg
+      register: result_dmesg
+
+    - name: save dmesg output to log file
+      copy:
+        content: "{{ result_dmesg.stdout }}"
+        dest: "{{ workspace }}/{{ commit_log }}.dmesg.ostree.log"
+      delegate_to: localhost
+
+    - name: check dmesg error and fail log
+      shell: dmesg --notime | grep -i "error\|fail" || true
+      register: result_dmesg_error
+
+    # case: check running container with podman
+    - name: run ubi8 image
+      command: podman run registry.access.redhat.com/ubi8/ubi-minimal:latest cat /etc/redhat-release
+      register: podman_result
+      become: yes
+      ignore_errors: yes  # due to https://bugzilla.redhat.com/show_bug.cgi?id=1903983
+
+    - name: run container test
+      block:
+        - assert:
+            that:
+              - podman_result is succeeded
+              - "'Red Hat Enterprise Linux release' in podman_result.stdout"
+            fail_msg: "failed run container with podman"
+            success_msg: "running container with podman successed"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check dnf package and it should not be installed
+    # https://github.com/osbuild/osbuild-composer/blob/master/internal/distro/rhel8/distro.go#L642
+    - name: dnf should not be installed
+      block:
+        - name: dnf should not be installed
+          shell: rpm -qa | grep dnf || echo -n PASS
+          register: result_dnf
+
+        - assert:
+            that:
+              - result_dnf.stdout == "PASS"
+            fail_msg: "dnf is installed"
+            success_msg: "No dnf 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'] != 'RedHat' and ansible_facts ['distribution_version'] != '8.5'
+
+    # case: check installed greenboot packages
+    # https://github.com/osbuild/osbuild-composer/blob/master/internal/distro/rhel8/distro.go#L634
+    - name: greenboot and it's related packages should be installed
+      block:
+        - name: greenboot and it's related packages should be installed
+          shell: rpm -qa | grep greenboot
+          register: result_greenboot_packages
+
+        - assert:
+            that:
+              - "'greenboot-0' in result_greenboot_packages.stdout"
+              - "'greenboot-grub2' in result_greenboot_packages.stdout"
+              - "'greenboot-rpm-ostree-grub2' in result_greenboot_packages.stdout"
+              - "'greenboot-reboot' in result_greenboot_packages.stdout"
+              - "'greenboot-status' in result_greenboot_packages.stdout"
+            fail_msg: "Some of greenboot and its related packages are not installed"
+            success_msg: "All greenboot and its related packages are installed"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check greenboot* services
+    - name: a list of greenboot* service should be enabled
+      block:
+        - name: a list of greenboot* service should be enabled
+          command: systemctl is-enabled greenboot-grub2-set-counter greenboot-grub2-set-success greenboot-healthcheck greenboot-rpm-ostree-grub2-check-fallback greenboot-status greenboot-task-runner redboot-auto-reboot redboot-task-runner
+          register: result_greenboot_service
+
+        - assert:
+            that:
+              - result_greenboot_service.stdout == 'enabled\nenabled\nenabled\nenabled\nenabled\nenabled\nenabled\nenabled'
+            fail_msg: "Some of greenboot* services are not enabled"
+            success_msg: "All greenboot* services are enabled"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check greenboot* services log
+    - name: all greenboot* service should run without error
+      block:
+        - name: all greenboot* service should run without error
+          command: journalctl -b -0 -u boot-complete.target -u greenboot -u greenboot-healthcheck -u greenboot-rpm-ostree-grub2-check-fallback -u greenboot-grub2-set-counter -u greenboot-grub2-set-success -u greenboot-status -u redboot -u redboot-auto-reboot -u redboot.target
+          register: result_greenboot_log
+
+        - assert:
+            that:
+              - "'Script \\'00_required_scripts_start.sh\\' SUCCESS' in result_greenboot_log.stdout"
+              - "'Script \\'00_wanted_scripts_start.sh\\' SUCCESS' in result_greenboot_log.stdout"
+              - "'greenboot Health Checks Runner' in result_greenboot_log.stdout"
+              - "'Reached target Boot Completion Check' in result_greenboot_log.stdout"
+              - "'Mark boot as successful in grubenv' in result_greenboot_log.stdout"
+              - "'Boot Status is GREEN - Health Check SUCCESS' in result_greenboot_log.stdout"
+              - "'greenboot MotD Generator' in result_greenboot_log.stdout"
+            fail_msg: "Some errors happened in service boot"
+            success_msg: "All greenboot services booted success"
+
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check grubenv variables
+    - name: grubenv variables should contain boot_success=1
+      block:
+        - name: grubenv variables should contain boot_success=1
+          command: grub2-editenv list
+          register: result_grubenv
+          become: yes
+
+        - assert:
+            that:
+              - "'boot_success=1' in result_grubenv.stdout"
+            fail_msg: "Not found boot_success=1"
+            success_msg: "Found boot_success=1"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check rollback function if boot error found
+    - name: install sanely failing health check unit to test red boot status behavior
+      block:
+        - name: install sanely failing health check unit to test red boot status behavior
+          command: rpm-ostree install http://file-server-virt-qe-3rd.apps.ocp4.prod.psi.redhat.com/greenboot-failing-unit-1.0-1.el8.noarch.rpm
+          become: yes
+
+        - name: reboot to deploy new ostree commit
+          reboot:
+          become: yes
+
+        - name: waits until instance is reachable
+          wait_for:
+            host: "{{ ansible_all_ipv4_addresses[0] }}"
+            port: 22
+            search_regex: OpenSSH
+            delay: 10
+          register: result_rollback
+
+        - assert:
+            that:
+              - result_rollback is succeeded
+            fail_msg: "Rollback failed"
+            success_msg: "Rollback success"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+
+    # case: check ostree commit after rollback
+    - name: check ostree commit after rollback
+      block:
+        - name: check ostree commit after rollback
+          command: rpm-ostree status --json
+          register: result_commit
+
+        - name: make a json result
+          set_fact:
+            deploy_commit: "{{ result_commit.stdout | from_json | json_query('deployments[0].checksum') }}"
+
+        - assert:
+            that:
+              - deploy_commit == ostree_commit
+            fail_msg: "Not rollback to last commit"
+            success_msg: "Rollback success"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+      when: result_rollback is succeeded
+
+    # case: check greenboot* services log again
+    - name: fallback log should be found here
+      block:
+        - name: fallback log should be found here
+          command: journalctl -b -0 -u boot-complete.target -u greenboot -u greenboot-healthcheck -u greenboot-rpm-ostree-grub2-check-fallback -u greenboot-grub2-set-counter -u greenboot-grub2-set-success -u greenboot-status -u redboot -u redboot-auto-reboot -u redboot.target
+          register: result_greenboot_log
+
+        - assert:
+            that:
+              - "'FALLBACK BOOT DETECTED! Default rpm-ostree deployment has been rolled back' in result_greenboot_log.stdout"
+              - "'Script \\'00_required_scripts_start.sh\\' SUCCESS' in result_greenboot_log.stdout"
+              - "'Script \\'00_wanted_scripts_start.sh\\' SUCCESS' in result_greenboot_log.stdout"
+              - "'greenboot Health Checks Runner' in result_greenboot_log.stdout"
+              - "'Reached target Boot Completion Check' in result_greenboot_log.stdout"
+              - "'Mark boot as successful in grubenv' in result_greenboot_log.stdout"
+              - "'Boot Status is GREEN - Health Check SUCCESS' in result_greenboot_log.stdout"
+              - "'greenboot MotD Generator' in result_greenboot_log.stdout"
+            fail_msg: "Fallback log not found"
+            success_msg: "Found fallback log"
+
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+      when: result_rollback is succeeded
+
+    # case: check grubenv variables again
+    - name: grubenv variables should contain boot_success=1
+      block:
+        - name: grubenv variables should contain boot_success=1
+          command: grub2-editenv list
+          register: result_grubenv
+          become: yes
+
+        - assert:
+            that:
+              - "'boot_success=1' in result_grubenv.stdout"
+            fail_msg: "Not found boot_success=1"
+            success_msg: "Found boot_success=1"
+      always:
+        - set_fact:
+            total_counter: "{{ total_counter | int + 1 }}"
+      rescue:
+        - name: failed count + 1
+          set_fact:
+            failed_counter: "{{ failed_counter | int + 1 }}"
+      when: result_rollback is succeeded
+
+    - assert:
+        that:
+          - failed_counter == "0"
+        fail_msg: "Run {{ total_counter }} tests, but {{ failed_counter }} of them failed"
+        success_msg: "Totally {{ total_counter }} test passed"
diff --git a/test/data/manifests/rhel_90-aarch64-edge_commit-boot.json b/test/data/manifests/rhel_90-aarch64-edge_commit-boot.json
index 4149fd0fe..b932d3ddb 100644
--- a/test/data/manifests/rhel_90-aarch64-edge_commit-boot.json
+++ b/test/data/manifests/rhel_90-aarch64-edge_commit-boot.json
@@ -615,6 +615,7 @@
                   "sha256:48f54283e14169425e68b87180435123af1906bd12c6631895d48106f5e841c9",
                   "sha256:7f3874f19f5af6867d7d9ff0a34442f4d92c153c139a1c0a058c7e3ae4ce1e7a",
                   "sha256:8a46cffbaee57de4881ef8c746195cb4da090ae0544bf90cf346dcfe3f322f4d",
+                  "sha256:d12649d69a0a7088ee0663735ce9aa6f05ae710aefe1391004cb1cb424dbdf40",
                   "sha256:785dba3e8f6c225e5a6eb5ae410c03e19c890573d010a368b754bb83e606ce94",
                   "sha256:2a399fa929245800ed68c8febee80ca2e1c20a08e92f9cd8c73e2cdd3aa04490",
                   "sha256:0f72fb02c99abc767b209829f3ebba2fe62c908a5d58317d3243f17ebd183709",
@@ -781,7 +782,15 @@
               "enabled_services": [
                 "NetworkManager.service",
                 "firewalld.service",
-                "sshd.service"
+                "sshd.service",
+                "greenboot-grub2-set-counter",
+                "greenboot-grub2-set-success",
+                "greenboot-healthcheck",
+                "greenboot-rpm-ostree-grub2-check-fallback",
+                "greenboot-status",
+                "greenboot-task-runner",
+                "redboot-auto-reboot",
+                "redboot-task-runner"
               ]
             }
           },
@@ -1831,6 +1840,9 @@
           "sha256:d0b15c7dbe30b7890b86d77dcde6034409c9604809ff103ab54597f4a09aff28": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.0-20210827/Packages/libblockdev-fs-2.25-7.el9.aarch64.rpm"
           },
+          "sha256:d12649d69a0a7088ee0663735ce9aa6f05ae710aefe1391004cb1cb424dbdf40": {
+            "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.0-20210827/Packages/nss-altfiles-2.18.1-20.el9.aarch64.rpm"
+          },
           "sha256:d165d14d89664d4e60cadc3625d5a76735a8c47043d9da3e3808ed44a90aeeeb": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.0-20210827/Packages/python3-libs-3.9.6-6.el9.aarch64.rpm"
           },
@@ -7949,6 +7961,15 @@
         "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.0-20210827/Packages/nss-3.67.0-8.el9.aarch64.rpm",
         "checksum": "sha256:8a46cffbaee57de4881ef8c746195cb4da090ae0544bf90cf346dcfe3f322f4d"
       },
+      {
+        "name": "nss-altfiles",
+        "epoch": 0,
+        "version": "2.18.1",
+        "release": "20.el9",
+        "arch": "aarch64",
+        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.0-20210827/Packages/nss-altfiles-2.18.1-20.el9.aarch64.rpm",
+        "checksum": "sha256:d12649d69a0a7088ee0663735ce9aa6f05ae710aefe1391004cb1cb424dbdf40"
+      },
       {
         "name": "nss-softokn",
         "epoch": 0,
@@ -8575,6 +8596,7 @@
       "npth-1.6-8.el9.aarch64",
       "nspr-4.31.0-5.el9.aarch64",
       "nss-3.67.0-8.el9.aarch64",
+      "nss-altfiles-2.18.1-20.el9.aarch64",
       "nss-softokn-3.67.0-8.el9.aarch64",
       "nss-softokn-freebl-3.67.0-8.el9.aarch64",
       "nss-sysinit-3.67.0-8.el9.aarch64",
@@ -8715,6 +8737,31 @@
           "actual": "system_u:object_r:mnt_t:s0",
           "expected": "system_u:object_r:default_t:s0",
           "filename": "/proc"
+        },
+        {
+          "actual": "system_u:object_r:unlabeled_t:s0",
+          "expected": "system_u:object_r:container_runtime_exec_t:s0",
+          "filename": "/usr/bin/crun"
+        },
+        {
+          "actual": "system_u:object_r:unlabeled_t:s0",
+          "expected": "system_u:object_r:container_runtime_exec_t:s0",
+          "filename": "/usr/bin/podman"
+        },
+        {
+          "actual": "system_u:object_r:unlabeled_t:s0",
+          "expected": "system_u:object_r:container_runtime_exec_t:s0",
+          "filename": "/usr/bin/runc"
+        },
+        {
+          "actual": "system_u:object_r:unlabeled_t:s0",
+          "expected": "system_u:object_r:usbguard_unit_file_t:s0",
+          "filename": "/usr/lib/systemd/system/usbguard.service"
+        },
+        {
+          "actual": "system_u:object_r:unlabeled_t:s0",
+          "expected": "system_u:object_r:usbguard_exec_t:s0",
+          "filename": "/usr/sbin/usbguard-daemon"
         }
       ],
       "policy": {
@@ -8732,12 +8779,6 @@
       "exit.target",
       "fstrim.timer",
       "fwupd-refresh.timer",
-      "greenboot-grub2-set-counter.service",
-      "greenboot-grub2-set-success.service",
-      "greenboot-healthcheck.service",
-      "greenboot-rpm-ostree-grub2-check-fallback.service",
-      "greenboot-status.service",
-      "greenboot-task-runner.service",
       "halt.target",
       "kexec.target",
       "mdcheck_continue.timer",
@@ -8754,8 +8795,6 @@
       "proc-sys-fs-binfmt_misc.mount",
       "raid-check.timer",
       "rdisc.service",
-      "redboot-auto-reboot.service",
-      "redboot-task-runner.service",
       "remote-cryptsetup.target",
       "remote-veritysetup.target",
       "rpm-ostree-bootstatus.service",
@@ -8791,6 +8830,12 @@
       "dm-event.socket",
       "firewalld.service",
       "getty@.service",
+      "greenboot-grub2-set-counter.service",
+      "greenboot-grub2-set-success.service",
+      "greenboot-healthcheck.service",
+      "greenboot-rpm-ostree-grub2-check-fallback.service",
+      "greenboot-status.service",
+      "greenboot-task-runner.service",
       "import-state.service",
       "loadmodules.service",
       "lvm2-lvmpolld.socket",
@@ -8799,6 +8844,8 @@
       "nis-domainname.service",
       "ostree-remount.service",
       "reboot.target",
+      "redboot-auto-reboot.service",
+      "redboot-task-runner.service",
       "remote-fs.target",
       "runlevel6.target",
       "selinux-autorelabel-mark.service",
diff --git a/test/data/manifests/rhel_90-aarch64-edge_container-boot.json b/test/data/manifests/rhel_90-aarch64-edge_container-boot.json
index 190608e13..5771f1111 100644
--- a/test/data/manifests/rhel_90-aarch64-edge_container-boot.json
+++ b/test/data/manifests/rhel_90-aarch64-edge_container-boot.json
@@ -615,6 +615,7 @@
                   "sha256:48f54283e14169425e68b87180435123af1906bd12c6631895d48106f5e841c9",
                   "sha256:7f3874f19f5af6867d7d9ff0a34442f4d92c153c139a1c0a058c7e3ae4ce1e7a",
                   "sha256:8a46cffbaee57de4881ef8c746195cb4da090ae0544bf90cf346dcfe3f322f4d",
+                  "sha256:d12649d69a0a7088ee0663735ce9aa6f05ae710aefe1391004cb1cb424dbdf40",
                   "sha256:785dba3e8f6c225e5a6eb5ae410c03e19c890573d010a368b754bb83e606ce94",
                   "sha256:2a399fa929245800ed68c8febee80ca2e1c20a08e92f9cd8c73e2cdd3aa04490",
                   "sha256:0f72fb02c99abc767b209829f3ebba2fe62c908a5d58317d3243f17ebd183709",
@@ -781,7 +782,15 @@
               "enabled_services": [
                 "NetworkManager.service",
                 "firewalld.service",
-                "sshd.service"
+                "sshd.service",
+                "greenboot-grub2-set-counter",
+                "greenboot-grub2-set-success",
+                "greenboot-healthcheck",
+                "greenboot-rpm-ostree-grub2-check-fallback",
+                "greenboot-status",
+                "greenboot-task-runner",
+                "redboot-auto-reboot",
+                "redboot-task-runner"
               ]
             }
           },
@@ -2019,6 +2028,9 @@
           "sha256:d0b15c7dbe30b7890b86d77dcde6034409c9604809ff103ab54597f4a09aff28": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.0-20210827/Packages/libblockdev-fs-2.25-7.el9.aarch64.rpm"
           },
+          "sha256:d12649d69a0a7088ee0663735ce9aa6f05ae710aefe1391004cb1cb424dbdf40": {
+            "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.0-20210827/Packages/nss-altfiles-2.18.1-20.el9.aarch64.rpm"
+          },
           "sha256:d165d14d89664d4e60cadc3625d5a76735a8c47043d9da3e3808ed44a90aeeeb": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.0-20210827/Packages/python3-libs-3.9.6-6.el9.aarch64.rpm"
           },
@@ -9027,6 +9039,15 @@
         "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.0-20210827/Packages/nss-3.67.0-8.el9.aarch64.rpm",
         "checksum": "sha256:8a46cffbaee57de4881ef8c746195cb4da090ae0544bf90cf346dcfe3f322f4d"
       },
+      {
+        "name": "nss-altfiles",
+        "epoch": 0,
+        "version": "2.18.1",
+        "release": "20.el9",
+        "arch": "aarch64",
+        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.0-20210827/Packages/nss-altfiles-2.18.1-20.el9.aarch64.rpm",
+        "checksum": "sha256:d12649d69a0a7088ee0663735ce9aa6f05ae710aefe1391004cb1cb424dbdf40"
+      },
       {
         "name": "nss-softokn",
         "epoch": 0,
diff --git a/test/data/manifests/rhel_90-x86_64-edge_commit-boot.json b/test/data/manifests/rhel_90-x86_64-edge_commit-boot.json
index 34b7b7cae..b028104b0 100644
--- a/test/data/manifests/rhel_90-x86_64-edge_commit-boot.json
+++ b/test/data/manifests/rhel_90-x86_64-edge_commit-boot.json
@@ -650,6 +650,7 @@
                   "sha256:731b91b14eb7bc79961d02940db4fe2f8644c6c5d5005bd3c62cc3830443b692",
                   "sha256:22bee502caf00522d7e3733699502e9d109d1a884c3006ee212e900101b07231",
                   "sha256:ad91a2693eae9b8159810a59526a22e0f9518d79d4845f81531331dd6cbac36d",
+                  "sha256:0178538e8a7935d1418e78f74c58880239ad3e8eab94638af562a128ad133e20",
                   "sha256:f31d17d77b9b35e6907b1803e47cede1d3d552d5cb45d46d940d4bd57d28dac5",
                   "sha256:925973b321b7f87ddba9ca957f75ba6585d95a1b2604fe880a05ef1d3621c50a",
                   "sha256:6e5a2b51da0bfecbbc0f11fc023eb17f4fcaf70f23ada1294ba4be8b33bda125",
@@ -816,7 +817,15 @@
               "enabled_services": [
                 "NetworkManager.service",
                 "firewalld.service",
-                "sshd.service"
+                "sshd.service",
+                "greenboot-grub2-set-counter",
+                "greenboot-grub2-set-success",
+                "greenboot-healthcheck",
+                "greenboot-rpm-ostree-grub2-check-fallback",
+                "greenboot-status",
+                "greenboot-task-runner",
+                "redboot-auto-reboot",
+                "redboot-task-runner"
               ]
             }
           },
@@ -915,6 +924,9 @@
           "sha256:01619e0b96181bff3b990016a5857b9d8859b7a4887d8c120b6f60c688bac526": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210827/Packages/npth-1.6-8.el9.x86_64.rpm"
           },
+          "sha256:0178538e8a7935d1418e78f74c58880239ad3e8eab94638af562a128ad133e20": {
+            "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210827/Packages/nss-altfiles-2.18.1-20.el9.x86_64.rpm"
+          },
           "sha256:02c6622480af52421fc2e54be42b8dd1ce6502e237c9a4e8231ae587b49767e3": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210827/Packages/zlib-1.2.11-31.el9.x86_64.rpm"
           },
@@ -8299,6 +8311,15 @@
         "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210827/Packages/nss-3.67.0-8.el9.x86_64.rpm",
         "checksum": "sha256:ad91a2693eae9b8159810a59526a22e0f9518d79d4845f81531331dd6cbac36d"
       },
+      {
+        "name": "nss-altfiles",
+        "epoch": 0,
+        "version": "2.18.1",
+        "release": "20.el9",
+        "arch": "x86_64",
+        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210827/Packages/nss-altfiles-2.18.1-20.el9.x86_64.rpm",
+        "checksum": "sha256:0178538e8a7935d1418e78f74c58880239ad3e8eab94638af562a128ad133e20"
+      },
       {
         "name": "nss-softokn",
         "epoch": 0,
@@ -8931,6 +8952,7 @@
       "npth-1.6-8.el9.x86_64",
       "nspr-4.31.0-5.el9.x86_64",
       "nss-3.67.0-8.el9.x86_64",
+      "nss-altfiles-2.18.1-20.el9.x86_64",
       "nss-softokn-3.67.0-8.el9.x86_64",
       "nss-softokn-freebl-3.67.0-8.el9.x86_64",
       "nss-sysinit-3.67.0-8.el9.x86_64",
@@ -9071,6 +9093,16 @@
           "actual": "system_u:object_r:mnt_t:s0",
           "expected": "system_u:object_r:default_t:s0",
           "filename": "/proc"
+        },
+        {
+          "actual": "system_u:object_r:unlabeled_t:s0",
+          "expected": "system_u:object_r:usbguard_unit_file_t:s0",
+          "filename": "/usr/lib/systemd/system/usbguard.service"
+        },
+        {
+          "actual": "system_u:object_r:unlabeled_t:s0",
+          "expected": "system_u:object_r:usbguard_exec_t:s0",
+          "filename": "/usr/sbin/usbguard-daemon"
         }
       ],
       "policy": {
@@ -9088,12 +9120,6 @@
       "exit.target",
       "fstrim.timer",
       "fwupd-refresh.timer",
-      "greenboot-grub2-set-counter.service",
-      "greenboot-grub2-set-success.service",
-      "greenboot-healthcheck.service",
-      "greenboot-rpm-ostree-grub2-check-fallback.service",
-      "greenboot-status.service",
-      "greenboot-task-runner.service",
       "halt.target",
       "kexec.target",
       "mdcheck_continue.timer",
@@ -9110,8 +9136,6 @@
       "proc-sys-fs-binfmt_misc.mount",
       "raid-check.timer",
       "rdisc.service",
-      "redboot-auto-reboot.service",
-      "redboot-task-runner.service",
       "remote-cryptsetup.target",
       "remote-veritysetup.target",
       "rpm-ostree-bootstatus.service",
@@ -9147,6 +9171,12 @@
       "dm-event.socket",
       "firewalld.service",
       "getty@.service",
+      "greenboot-grub2-set-counter.service",
+      "greenboot-grub2-set-success.service",
+      "greenboot-healthcheck.service",
+      "greenboot-rpm-ostree-grub2-check-fallback.service",
+      "greenboot-status.service",
+      "greenboot-task-runner.service",
       "import-state.service",
       "loadmodules.service",
       "lvm2-lvmpolld.socket",
@@ -9156,6 +9186,8 @@
       "nis-domainname.service",
       "ostree-remount.service",
       "reboot.target",
+      "redboot-auto-reboot.service",
+      "redboot-task-runner.service",
       "remote-fs.target",
       "runlevel6.target",
       "selinux-autorelabel-mark.service",
diff --git a/test/data/manifests/rhel_90-x86_64-edge_commit_rt-boot.json b/test/data/manifests/rhel_90-x86_64-edge_commit_rt-boot.json
index c5e75e3fd..9a1becf58 100644
--- a/test/data/manifests/rhel_90-x86_64-edge_commit_rt-boot.json
+++ b/test/data/manifests/rhel_90-x86_64-edge_commit_rt-boot.json
@@ -29,6 +29,9 @@
       }
     }
   },
+  "supported_arches": [
+    "x86_64"
+  ],
   "manifest": {
     "version": "2",
     "pipelines": [
@@ -703,6 +706,7 @@
                   "sha256:731b91b14eb7bc79961d02940db4fe2f8644c6c5d5005bd3c62cc3830443b692",
                   "sha256:22bee502caf00522d7e3733699502e9d109d1a884c3006ee212e900101b07231",
                   "sha256:ad91a2693eae9b8159810a59526a22e0f9518d79d4845f81531331dd6cbac36d",
+                  "sha256:0178538e8a7935d1418e78f74c58880239ad3e8eab94638af562a128ad133e20",
                   "sha256:f31d17d77b9b35e6907b1803e47cede1d3d552d5cb45d46d940d4bd57d28dac5",
                   "sha256:925973b321b7f87ddba9ca957f75ba6585d95a1b2604fe880a05ef1d3621c50a",
                   "sha256:6e5a2b51da0bfecbbc0f11fc023eb17f4fcaf70f23ada1294ba4be8b33bda125",
@@ -1021,7 +1025,15 @@
               "enabled_services": [
                 "NetworkManager.service",
                 "firewalld.service",
-                "sshd.service"
+                "sshd.service",
+                "greenboot-grub2-set-counter",
+                "greenboot-grub2-set-success",
+                "greenboot-healthcheck",
+                "greenboot-rpm-ostree-grub2-check-fallback",
+                "greenboot-status",
+                "greenboot-task-runner",
+                "redboot-auto-reboot",
+                "redboot-task-runner"
               ]
             }
           },
@@ -1120,6 +1132,9 @@
           "sha256:01619e0b96181bff3b990016a5857b9d8859b7a4887d8c120b6f60c688bac526": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210827/Packages/npth-1.6-8.el9.x86_64.rpm"
           },
+          "sha256:0178538e8a7935d1418e78f74c58880239ad3e8eab94638af562a128ad133e20": {
+            "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210827/Packages/nss-altfiles-2.18.1-20.el9.x86_64.rpm"
+          },
           "sha256:02c6622480af52421fc2e54be42b8dd1ce6502e237c9a4e8231ae587b49767e3": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210827/Packages/zlib-1.2.11-31.el9.x86_64.rpm"
           },
@@ -10304,6 +10319,15 @@
         "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210827/Packages/nss-3.67.0-8.el9.x86_64.rpm",
         "checksum": "sha256:ad91a2693eae9b8159810a59526a22e0f9518d79d4845f81531331dd6cbac36d"
       },
+      {
+        "name": "nss-altfiles",
+        "epoch": 0,
+        "version": "2.18.1",
+        "release": "20.el9",
+        "arch": "x86_64",
+        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210827/Packages/nss-altfiles-2.18.1-20.el9.x86_64.rpm",
+        "checksum": "sha256:0178538e8a7935d1418e78f74c58880239ad3e8eab94638af562a128ad133e20"
+      },
       {
         "name": "nss-softokn",
         "epoch": 0,
@@ -11068,6 +11092,7 @@
       "npth-1.6-8.el9.x86_64",
       "nspr-4.31.0-5.el9.x86_64",
       "nss-3.67.0-8.el9.x86_64",
+      "nss-altfiles-2.18.1-20.el9.x86_64",
       "nss-softokn-3.67.0-8.el9.x86_64",
       "nss-softokn-freebl-3.67.0-8.el9.x86_64",
       "nss-sysinit-3.67.0-8.el9.x86_64",
@@ -11305,6 +11330,16 @@
           "actual": "system_u:object_r:mnt_t:s0",
           "expected": "system_u:object_r:default_t:s0",
           "filename": "/proc"
+        },
+        {
+          "actual": "system_u:object_r:unlabeled_t:s0",
+          "expected": "system_u:object_r:usbguard_unit_file_t:s0",
+          "filename": "/usr/lib/systemd/system/usbguard.service"
+        },
+        {
+          "actual": "system_u:object_r:unlabeled_t:s0",
+          "expected": "system_u:object_r:usbguard_exec_t:s0",
+          "filename": "/usr/sbin/usbguard-daemon"
         }
       ],
       "policy": {
@@ -11323,12 +11358,6 @@
       "exit.target",
       "fstrim.timer",
       "fwupd-refresh.timer",
-      "greenboot-grub2-set-counter.service",
-      "greenboot-grub2-set-success.service",
-      "greenboot-healthcheck.service",
-      "greenboot-rpm-ostree-grub2-check-fallback.service",
-      "greenboot-status.service",
-      "greenboot-task-runner.service",
       "halt.target",
       "kexec.target",
       "kvm_stat.service",
@@ -11348,8 +11377,6 @@
       "rdisc.service",
       "realtime-entsk.service",
       "realtime-setup.service",
-      "redboot-auto-reboot.service",
-      "redboot-task-runner.service",
       "remote-cryptsetup.target",
       "remote-veritysetup.target",
       "rhsm-facts.service",
@@ -11388,6 +11415,12 @@
       "dnf-makecache.timer",
       "firewalld.service",
       "getty@.service",
+      "greenboot-grub2-set-counter.service",
+      "greenboot-grub2-set-success.service",
+      "greenboot-healthcheck.service",
+      "greenboot-rpm-ostree-grub2-check-fallback.service",
+      "greenboot-status.service",
+      "greenboot-task-runner.service",
       "import-state.service",
       "kdump.service",
       "loadmodules.service",
@@ -11398,6 +11431,8 @@
       "nis-domainname.service",
       "ostree-remount.service",
       "reboot.target",
+      "redboot-auto-reboot.service",
+      "redboot-task-runner.service",
       "remote-fs.target",
       "rhsmcertd.service",
       "runlevel6.target",
diff --git a/test/data/manifests/rhel_90-x86_64-edge_container-boot.json b/test/data/manifests/rhel_90-x86_64-edge_container-boot.json
index 56e4e2018..42b433657 100644
--- a/test/data/manifests/rhel_90-x86_64-edge_container-boot.json
+++ b/test/data/manifests/rhel_90-x86_64-edge_container-boot.json
@@ -650,6 +650,7 @@
                   "sha256:731b91b14eb7bc79961d02940db4fe2f8644c6c5d5005bd3c62cc3830443b692",
                   "sha256:22bee502caf00522d7e3733699502e9d109d1a884c3006ee212e900101b07231",
                   "sha256:ad91a2693eae9b8159810a59526a22e0f9518d79d4845f81531331dd6cbac36d",
+                  "sha256:0178538e8a7935d1418e78f74c58880239ad3e8eab94638af562a128ad133e20",
                   "sha256:f31d17d77b9b35e6907b1803e47cede1d3d552d5cb45d46d940d4bd57d28dac5",
                   "sha256:925973b321b7f87ddba9ca957f75ba6585d95a1b2604fe880a05ef1d3621c50a",
                   "sha256:6e5a2b51da0bfecbbc0f11fc023eb17f4fcaf70f23ada1294ba4be8b33bda125",
@@ -816,7 +817,15 @@
               "enabled_services": [
                 "NetworkManager.service",
                 "firewalld.service",
-                "sshd.service"
+                "sshd.service",
+                "greenboot-grub2-set-counter",
+                "greenboot-grub2-set-success",
+                "greenboot-healthcheck",
+                "greenboot-rpm-ostree-grub2-check-fallback",
+                "greenboot-status",
+                "greenboot-task-runner",
+                "redboot-auto-reboot",
+                "redboot-task-runner"
               ]
             }
           },
@@ -1077,6 +1086,9 @@
           "sha256:01619e0b96181bff3b990016a5857b9d8859b7a4887d8c120b6f60c688bac526": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210827/Packages/npth-1.6-8.el9.x86_64.rpm"
           },
+          "sha256:0178538e8a7935d1418e78f74c58880239ad3e8eab94638af562a128ad133e20": {
+            "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210827/Packages/nss-altfiles-2.18.1-20.el9.x86_64.rpm"
+          },
           "sha256:02c6622480af52421fc2e54be42b8dd1ce6502e237c9a4e8231ae587b49767e3": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210827/Packages/zlib-1.2.11-31.el9.x86_64.rpm"
           },
@@ -9378,6 +9390,15 @@
         "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210827/Packages/nss-3.67.0-8.el9.x86_64.rpm",
         "checksum": "sha256:ad91a2693eae9b8159810a59526a22e0f9518d79d4845f81531331dd6cbac36d"
       },
+      {
+        "name": "nss-altfiles",
+        "epoch": 0,
+        "version": "2.18.1",
+        "release": "20.el9",
+        "arch": "x86_64",
+        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210827/Packages/nss-altfiles-2.18.1-20.el9.x86_64.rpm",
+        "checksum": "sha256:0178538e8a7935d1418e78f74c58880239ad3e8eab94638af562a128ad133e20"
+      },
       {
         "name": "nss-softokn",
         "epoch": 0,
@@ -9649,6 +9670,5 @@
         "checksum": "sha256:b6b91777247881481f67ca222ca83e18c5649194e00f40fca8a6297d191bb261"
       }
     ]
-  },
-  "image-info": {}
+  }
 }