Repository: skupperproject/skupper
Pull request #1584 (2024-08-09 23:31:59)

Site bundles
This PR introduces the ability to produce site bundles that can be installed to run
with podman, docker or systemd (a local installation of the skupper-router).

It can produce two different site bundle formats. One is a self-extracting shell
script and the other is a tarball that contains an installation script.

To specify the desired format, you need to export the `SKUPPER_PLATFORM` environment
variable and set it to `bundle` (self-extracting) or `tarball`.

After specifying the desired format, you just need to execute the bootstrap (binary)
or the bootstrap.sh (script) inside a directory that contains your site definition,
which is merely a set of Skupper V2 custom resources (CR).

After the bundle generation is completed, it will inform you where you can find the
generated artifact.

Along with this new feature, this PR also performs a couple of other related changes:

* Enhanced bootstrap and remove scripts to comply with Posix Shell
* Renamed non-kube and non_kube packages to nonkube 
* New unit tests
* Fixed failing nonkube unit tests
* Renamed internal environment variable from OUTPUT_PATH to SKUPPER_OUTPUT_PATH


fgiorgetti: Added a README file under `cmd/bootstrap` explaining how to bootstrap
non-kube sites and site bundles.
I hope it is helpful.

Patch:
diff --git a/api/types/types.go b/api/types/types.go
index e326aa66..a2f11e5b 100644
--- a/api/types/types.go
+++ b/api/types/types.go
@@ -323,6 +323,8 @@ const (
 	PlatformPodman     Platform = "podman"
 	PlatformDocker     Platform = "docker"
 	PlatformSystemd    Platform = "systemd"
+	PlatformBundle     Platform = "bundle"
+	PlatformBundleTgz  Platform = "tarball"
 )
 
 func (p Platform) IsKubernetes() bool {
@@ -333,6 +335,14 @@ func (p Platform) IsContainerEngine() bool {
 	return p == PlatformDocker || p == PlatformPodman
 }
 
+func (p Platform) IsBundle() bool {
+	return p == PlatformBundle || p.IsTarball()
+}
+
+func (p Platform) IsTarball() bool {
+	return p == PlatformBundleTgz
+}
+
 type ConsoleAuthMode string
 
 const (
diff --git a/cmd/bootstrap/README.md b/cmd/bootstrap/README.md
new file mode 100644
index 00000000..b08c21cf
--- /dev/null
+++ b/cmd/bootstrap/README.md
@@ -0,0 +1,279 @@
+# Bootstrapping non-kubernetes sites
+
+In this current phase of the Skupper V2 implementation, non-kubernetes sites
+can be bootstrapped using a locally built binary, that can be produced by running
+`make build-bootstrap`, or using the (eventually outdated) quay.io/skupper/bootstrap
+container image by calling `./cmd/bootstrap/bootstrap.sh`.
+
+Non-kubernetes sites can be created using the standard V2 site declaration
+approach, which is based on the new set of Custom Resource Definitions (CRDs).
+
+The same CRDs used to create a V2 Skupper site on a Kubernetes cluster can be
+used to create non-kubernetes sites. Some particular options and fields are
+valid or required only on specific platforms, but overall the structure is the
+same.
+
+Differently than the current Kubernetes implementation, non-kubernetes sites
+are static. Meaning that you must have all your Custom Resources (CRs) ready
+at the time your site is bootstrapped and if something needs to change, you
+will need to remove and bootstrap your site again.
+
+## Supported platforms
+
+You can specify the target platform to be bootstrapped by exporting
+the `SKUPPER_PLATFORM` environment variable, using one of the following
+allowed values:
+
+* podman
+* docker
+* systemd
+* bundle
+* tarball
+
+### Container engine platforms
+
+When `podman` or `docker` platform is used, then the bootstrap procedure will
+require that the respective container engine endpoint is available. The default
+unix socket will be used based on the current user and platform selected.
+
+### Systemd
+
+The `systemd` platform actually requires that you have a local installation of
+the `skupper-router` (`skrouterd` binary must be available in your PATH).
+
+### Site bundle strategies
+
+If you do not want to run an actual site from your CRs, but instead, you just
+need to produce a bundle that can be installed somewhere else, then you can
+use `bundle` or `tarball` as the platform.
+
+The `bundle` platform will produce a self-extracting shell archive that can
+be executed to install your local non-kubernetes site.
+
+If you are not comfortable executing the produced script, you can also choose
+`tarball` as the platform. It produces a tar ball that contains a `install.sh`
+script, that basically performs the same procedure of the `bundle`.
+
+Both scripts accept flags:
+
+```
+-h               help
+-p <platform>    podman, docker, systemd
+-x               remove
+-d <directory>   dump static tokens
+```
+
+## Usage
+
+### Bootstrapping and removing non-kubernetes sites
+
+Place all your Custom Resources (CRs) on a local directory.
+You must always provide a `Site` (CR), plus some other resource
+that makes your running site meaningful, like Listeners and/or Connectors.
+
+In case you want your local site to listen for incoming links from other sites,
+at present, you have to explicitly provide a `RouterAccess` (CR).
+
+If you want your non-kubernetes site to establish links to other sites, make
+sure you also provide `AccessToken` or `Link` (CRs).
+
+***NOTE:** A V2 representation of the **"Hello world example"** is available below
+to provide initial guidance.*
+
+Now that you have all your CRs placed on a local directory, just run:
+
+#### Bootstrapping
+
+```shell
+# To bootstrap your site using the binary
+export SKUPPER_PLATFORM=podman
+./bootstrap ./
+```
+
+Alternatively you can also run the `bootstrap.sh` script:
+
+```shell
+# To bootstrap your site using the shell script
+export SKUPPER_PLATFORM=podman
+./cmd/bootstrap/bootstrap.sh ./
+```
+
+Remember that you can set the `SKUPPER_PLATFORM` environment variable to
+any of the platforms mentioned earlier.
+
+After the bootstrap procedure is completed, it will provide you some relevant
+information like:
+
+* Location where static tokens have been defined (when a `RouterAccess` is defined)
+* Location where the site bundle has been saved (when using `bundle` or `tarball`)
+
+#### Site bundles
+
+When a site bundle is produced, the bootstrap procedure will point you
+to the location where it has been saved.
+
+#### Removing
+
+To remove your site, you can run a local script, that takes the site name
+as an argument. The platform is identified by the script so you don't need
+to export it.
+
+```shell
+./cmd/bootstrap/remove.sh <site-name>
+```
+
+## Example
+
+Here is a very basic demonstration on how you can run the `Hello World` example
+locally using just non-kubernetes sites.
+
+The example is assuming you are running both `west` and `east` sites on your
+machine. So if you want to run it on different machines, make sure to adjust
+IP addresses properly.
+
+### Hello world
+
+This example basically runs a **frontend** application on the `west` site,
+which depends on a **backend** that is meant to run on the `east` site.
+
+To simulate it, the frontend application will be executed using podman,
+it will be exporting port 7070 in the loopback interface of the host machine,
+and we will tell it to expect that the backend service will be available
+at `http://host.containers.internal:8080`, which from inside the container,
+means the host machine at port 8080.
+
+The backend will also run on the host machine with podman, and it will be
+exporting port 9090 to the loopback interface of the host machine. So after
+both `frontend` and `backend` containers are running, they won't be able
+to communicate.
+
+We will use two Skupper sites to resolve that. On the `west` site, Skupper
+will expose a `Listener` (CR), bound to port `8080` of the host machine.
+
+On the `east` site, we will have a `Connector` (CR) that targets localhost
+at port 9090, in the host machine.
+
+The ideal scenario would be to run each component on different machines,
+where Skupper could add real value, but just for the purpose of making it
+simple to run, the example is defined to work at a single machine.
+
+#### Workloads
+
+To run the `frontend` container, use:
+
+```shell
+podman run --name frontend -d --rm -p 127.0.0.1:7070:8080 quay.io/skupper/hello-world-frontend --backend http://host.containers.internal:8080
+```
+
+And to run the `backend` container, use:
+
+```shell
+podman run --name backend -d --rm -p 127.0.0.1:9090:8080 quay.io/skupper/hello-world-backend
+```
+
+#### West
+
+The `west` site will be defined using the following CRs:
+
+##### Site
+```yaml
+apiVersion: skupper.io/v1alpha1
+kind: Site
+metadata:
+  name: west
+```
+
+##### Listener
+
+```yaml
+apiVersion: skupper.io/v1alpha1
+kind: Listener
+metadata:
+  name: backend
+spec:
+  host: 0.0.0.0
+  port: 8080
+  routingKey: backend-8080
+```
+
+##### RouterAccess
+
+```yaml
+apiVersion: skupper.io/v1alpha1
+kind: RouterAccess
+metadata:
+  name: go-west
+spec:
+  roles:
+    - port: 55671
+      name: inter-router
+    - port: 45671
+      name: edge
+  bindHost: 127.0.0.1
+```
+
+Now that the `west` site has been created, copy the generated token into
+the `east` site local directory. Example:
+
+```shell
+cp ${HOME}/.local/share/skupper/sites/west/runtime/token/link-go-west.yaml ./east/links/link-go-west.yaml
+```
+
+#### East
+
+The `east` site will be defined using the following CRs:
+
+**Site**
+```yaml
+apiVersion: skupper.io/v1alpha1
+kind: Site
+metadata:
+  name: east
+```
+
+**Connector**
+```yaml
+---
+apiVersion: skupper.io/v1alpha1
+kind: Connector
+metadata:
+  name: backend
+spec:
+  host: 127.0.0.1
+  port: 9090
+  routingKey: backend-8080
+```
+
+**Link**
+```yaml
+---
+apiVersion: v1
+data:
+  ca.crt: ___redacted___
+  connect.json: ___redacted___
+  tls.crt: ___redacted___
+  tls.key: ___redacted___
+kind: Secret
+metadata:
+  name: link-go-west
+---
+apiVersion: skupper.io/v1alpha1
+kind: Link
+metadata:
+  name: link-go-west
+spec:
+  cost: 1
+  endpoints:
+  - host: 127.0.0.1
+    name: inter-router
+    port: "55671"
+  - host: 127.0.0.1
+    name: edge
+    port: "45671"
+  tlsCredentials: link-go-west
+```
+
+### Testing the scenario
+
+Once both sites have been initialized, open **http://127.0.0.1:7070**
+in your browser and it should work.
diff --git a/cmd/bootstrap/bootstrap.go b/cmd/bootstrap/bootstrap.go
index 31171a4b..aa29e661 100644
--- a/cmd/bootstrap/bootstrap.go
+++ b/cmd/bootstrap/bootstrap.go
@@ -9,10 +9,11 @@ import (
 
 	"github.com/skupperproject/skupper/api/types"
 	"github.com/skupperproject/skupper/pkg/config"
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
-	"github.com/skupperproject/skupper/pkg/non_kube/common"
-	"github.com/skupperproject/skupper/pkg/non_kube/compat"
-	"github.com/skupperproject/skupper/pkg/non_kube/systemd"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/bundle"
+	"github.com/skupperproject/skupper/pkg/nonkube/common"
+	"github.com/skupperproject/skupper/pkg/nonkube/compat"
+	"github.com/skupperproject/skupper/pkg/nonkube/systemd"
 	"github.com/skupperproject/skupper/pkg/version"
 )
 
@@ -62,7 +63,7 @@ func main() {
 				os.Exit(1)
 			}
 		}
-	} else {
+	} else if !platform.IsBundle() {
 		binary := "podman"
 		if platform == types.PlatformSystemd {
 			binary = "skrouterd"
@@ -87,30 +88,47 @@ func main() {
 		fmt.Println("Failed to bootstrap:", err)
 		os.Exit(1)
 	}
-	fmt.Printf("Site %q has been created\n", siteState.Site.Name)
-	siteHome, err := apis.GetHostSiteHome(siteState.Site)
-	if err != nil {
-		fmt.Println("Failed to get site's home directory:", err)
+	var bundleSuffix string
+	if platform.IsBundle() {
+		bundleSuffix = " (as a distributable bundle)"
+	}
+	fmt.Printf("Site %q has been created%s\n", siteState.Site.Name, bundleSuffix)
+	if !platform.IsBundle() {
+		siteHome, err := apis.GetHostSiteHome(siteState.Site)
+		if err != nil {
+			fmt.Println("Failed to get site's home directory:", err)
+		} else {
+			tokenPath := path.Join(siteHome, common.RuntimeTokenPath)
+			hostTokenPath := tokenPath
+			if apis.IsRunningInContainer() {
+				tokenPath = path.Join("/output", "sites", siteState.Site.Name, common.RuntimeTokenPath)
+			}
+			tokens, _ := os.ReadDir(tokenPath)
+			for _, token := range tokens {
+				if !token.IsDir() {
+					fmt.Println("Static tokens have been defined at:", hostTokenPath)
+					break
+				}
+			}
+		}
 	} else {
-		tokenPath := path.Join(siteHome, common.RuntimeTokenPath)
-		hostTokenPath := tokenPath
-		if apis.IsRunningInContainer() {
-			tokenPath = path.Join("/output", "sites", siteState.Site.Name, common.RuntimeTokenPath)
+		dataHome, err := apis.GetHostDataHome()
+		if err != nil {
+			fmt.Println("Failed to get site bundle base directory:", err)
 		}
-		tokens, _ := os.ReadDir(tokenPath)
-		for _, token := range tokens {
-			if !token.IsDir() {
-				fmt.Println("Static tokens have been defined at:", hostTokenPath)
-				break
-			}
+		installationFile := path.Join(dataHome, "sites", fmt.Sprintf("skupper-install-%s.sh", siteState.Site.Name))
+		if platform.IsTarball() {
+			installationFile = path.Join(dataHome, "sites", fmt.Sprintf("skupper-install-%s.tar.gz", siteState.Site.Name))
 		}
+		fmt.Println("Installation bundle available at:", installationFile)
 	}
 }
 
 func bootstrap(inputPath string) (*apis.SiteState, error) {
 	var siteStateLoader apis.SiteStateLoader
 	siteStateLoader = &common.FileSystemSiteStateLoader{
-		Path: inputPath,
+		Path:   inputPath,
+		Bundle: platform.IsBundle(),
 	}
 	siteState, err := siteStateLoader.Load()
 	if err != nil {
@@ -119,6 +137,8 @@ func bootstrap(inputPath string) (*apis.SiteState, error) {
 	var siteStateRenderer apis.StaticSiteStateRenderer
 	if platform == types.PlatformSystemd {
 		siteStateRenderer = &systemd.SiteStateRenderer{}
+	} else if platform.IsBundle() {
+		siteStateRenderer = &bundle.SiteStateRenderer{}
 	} else {
 		siteStateRenderer = &compat.SiteStateRenderer{}
 	}
diff --git a/cmd/bootstrap/bootstrap.sh b/cmd/bootstrap/bootstrap.sh
index af510ca4..c6922225 100755
--- a/cmd/bootstrap/bootstrap.sh
+++ b/cmd/bootstrap/bootstrap.sh
@@ -1,9 +1,15 @@
+#!/bin/sh
+
 set -Ceu
 
 IMAGE="quay.io/skupper/bootstrap"
 INPUT_PATH="${1:-${PWD}}"
-OUTPUT_PATH="${XDG_DATA_HOME:-${HOME}/.local/share}/skupper"
+SKUPPER_OUTPUT_PATH="${XDG_DATA_HOME:-${HOME}/.local/share}/skupper"
 SERVICE_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/systemd/user"
+if [ -z "${UID:-}" ]; then
+    UID="$(id -u)"
+    export UID
+fi
 LOG_FILE="$(mktemp /tmp/skupper-bootstrap.XXXXX.log)"
 
 exit_error() {
@@ -12,23 +18,39 @@ exit_error() {
 }
 
 is_sock_endpoint() {
-    [[ "${CONTAINER_ENDPOINT}" =~ ^(\/|unix:\/\/) ]] && return 0
+    case "${CONTAINER_ENDPOINT}" in
+        "/"*)
+            return 0
+            ;;
+        "unix://"*)
+            return 0
+            ;;
+    esac
     return 1
 }
 
 is_container_platform() {
-    [[ "${SKUPPER_PLATFORM}" != "systemd" ]] && return 0
+    if [ "${SKUPPER_PLATFORM}" = "podman" ] || [ "${SKUPPER_PLATFORM}" = "docker" ]; then
+        return 0
+    fi
+    return 1
+}
+
+is_bundle_platform() {
+    if [ "${SKUPPER_PLATFORM}" = "bundle" ] || [ "${SKUPPER_PLATFORM}" = "tarball" ]; then
+        return 0
+    fi
     return 1
 }
 
 container_env() {
-    export SKUPPER_PLATFORM=${SKUPPER_PLATFORM:-podman}
-    export CONTAINER_ENGINE=${CONTAINER_ENGINE:-podman}
+    export SKUPPER_PLATFORM="${SKUPPER_PLATFORM:-podman}"
+    export CONTAINER_ENGINE="${CONTAINER_ENGINE:-podman}"
     # Determining if the respective binaries are available
     case "${SKUPPER_PLATFORM}" in
         systemd)
             command -v skrouterd > /dev/null || exit_error "SystemD platform cannot be used: skrouterd not found"
-            command -v ${CONTAINER_ENGINE} > /dev/null || exit_error "SystemD platform cannot be used: ${CONTAINER_ENGINE} (container engine) not found"
+            command -v "${CONTAINER_ENGINE}" > /dev/null || exit_error "SystemD platform cannot be used: ${CONTAINER_ENGINE} (container engine) not found"
             ;;
         docker)
             command -v docker > /dev/null || exit_error "Docker platform cannot be used: docker not found"
@@ -43,27 +65,32 @@ container_env() {
     GID=$(id -g "${UID}")
     export RUNAS="${UID}:${GID}"
     export USERNS="keep-id"
-    if [[ "${CONTAINER_ENGINE}" = "docker" ]]; then
+    if [ "${CONTAINER_ENGINE}" = "docker" ]; then
         export CONTAINER_ENDPOINT_DEFAULT="unix:///run/docker.sock"
         export USERNS="host"
-        export DOCKERGID=$(getent group docker | cut -d: -f3)
-        export RUNAS="${UID}:${DOCKERGID}"
+        DOCKER_GID=$(getent group docker | cut -d: -f3)
+        export RUNAS="${UID}:${DOCKER_GID}"
     fi
-    if [[ ${UID} -eq 0 ]]; then
-        if [[ "${CONTAINER_ENGINE}" = "podman" ]]; then
+    if [ "${UID}" -eq 0 ]; then
+        if [ "${CONTAINER_ENGINE}" = "podman" ]; then
             export CONTAINER_ENDPOINT_DEFAULT="unix:///run/podman/podman.sock"
         fi
         export USERNS=""
-        export OUTPUT_PATH="/usr/local/share/skupper"
+        export SKUPPER_OUTPUT_PATH="/usr/local/share/skupper"
         export SERVICE_DIR="/etc/systemd/system"
     fi
-    mkdir -p "${OUTPUT_PATH}"
+    mkdir -p "${SKUPPER_OUTPUT_PATH}"
     export CONTAINER_ENDPOINT="${CONTAINER_ENDPOINT:-${CONTAINER_ENDPOINT_DEFAULT}}"
 }
 
 create_service() {
+    # do not create service when preparing a site bundle
+    if is_bundle_platform; then
+        return
+    fi
+
     # if systemd is not available, skip it
-    if [[ ${UID} -eq 0 ]]; then
+    if [ "${UID}" -eq 0 ]; then
         systemctl list-units > /dev/null 2>&1 || return
     else
         systemctl --user list-units > /dev/null 2>&1 || return
@@ -71,35 +98,35 @@ create_service() {
 
     # generated service file
     site_name="$(grep -E 'Site ".*" has been created' "${LOG_FILE}" | awk -F'"' '{print $2}')"
-    if [[ -z "${site_name}" ]]; then
+    if [ -z "${site_name}" ]; then
         echo "Unable to create SystemD service (site name could not be identified)"
         return
     fi
     service_name="skupper-site-${site_name}.service"
-    service_file="${OUTPUT_PATH}/sites/${site_name}/runtime/scripts/${service_name}"
-    if [[ ! -f ${service_file} ]]; then
+    service_file="${SKUPPER_OUTPUT_PATH}/sites/${site_name}/runtime/scripts/${service_name}"
+    if [ ! -f "${service_file}" ]; then
         echo "SystemD service has not been defined"
         return
     fi
 
     # Moving it to the appropriate location
-    if [[ ${UID} -eq 0 ]]; then
-        mv "${service_file}" /etc/systemd/system/
+    if [ "${UID}" -eq 0 ]; then
+        cp -f "${service_file}" /etc/systemd/system/
         systemctl enable --now "${service_name}"
         systemctl daemon-reload
     else
-        if [[ ! -d "${SERVICE_DIR}" ]]; then
+        if [ ! -d "${SERVICE_DIR}" ]; then
             echo "Unable to define path to SystemD service"
             return
         fi
-        mv "${service_file}" "${SERVICE_DIR}"
+        cp -f "${service_file}" "${SERVICE_DIR}"
         systemctl --user enable --now "${service_name}"
         systemctl --user daemon-reload
     fi
 }
 
 main() {
-    if [[ -z "${INPUT_PATH}" ]] || [[ ! -d "${INPUT_PATH}" ]]; then
+    if [ -z "${INPUT_PATH}" ] || [ ! -d "${INPUT_PATH}" ]; then
         exit_error "Use: bootstrap.sh <local path to CRs>"
     fi
 
@@ -107,36 +134,37 @@ main() {
     container_env
 
     # Must be mounted into the container
-    MOUNTS=()
-    ENV_VARS=()
+    MOUNTS=""
+    ENV_VARS=""
     
     # Mounts
     if is_sock_endpoint && is_container_platform; then
-        MOUNTS+=(-v "${CONTAINER_ENDPOINT/unix:\/\//}":/${CONTAINER_ENGINE}.sock:z)
+        file_container_endpoint=$(echo "${CONTAINER_ENDPOINT}" | sed -e "s#unix://##g")
+        MOUNTS="${MOUNTS} -v '${file_container_endpoint}:/${CONTAINER_ENGINE}.sock:z'"
     fi
-    MOUNTS+=(-v "${INPUT_PATH}":/input:z)
-    MOUNTS+=(-v "${OUTPUT_PATH}":/output:z)
+    MOUNTS="${MOUNTS} -v '${INPUT_PATH}:/input:z'"
+    MOUNTS="${MOUNTS} -v '${SKUPPER_OUTPUT_PATH}:/output:z'"
     
     # Env vars
     if is_container_platform; then
         if is_sock_endpoint; then
-            ENV_VARS+=(-e CONTAINER_ENDPOINT="/${CONTAINER_ENGINE}.sock")
+            ENV_VARS="${ENV_VARS} -e 'CONTAINER_ENDPOINT=/${CONTAINER_ENGINE}.sock'"
         else
-            ENV_VARS+=(-e CONTAINER_ENDPOINT="${CONTAINER_ENDPOINT}")
+            ENV_VARS="${ENV_VARS} -e 'CONTAINER_ENDPOINT=${CONTAINER_ENDPOINT}'"
         fi
     fi
-    ENV_VARS+=(-e OUTPUT_PATH="${OUTPUT_PATH}")
-    ENV_VARS+=(-e SKUPPER_PLATFORM="${SKUPPER_PLATFORM}")
+    ENV_VARS="${ENV_VARS} -e 'SKUPPER_OUTPUT_PATH=${SKUPPER_OUTPUT_PATH}'"
+    ENV_VARS="${ENV_VARS} -e 'SKUPPER_PLATFORM=${SKUPPER_PLATFORM}'"
 
     # Running the bootstrap
     ${CONTAINER_ENGINE} pull ${IMAGE}
-    ${CONTAINER_ENGINE} run --rm --name skupper-bootstrap \
-        --network host --security-opt label=disable -u "${RUNAS}" --userns="${USERNS}" \
-        ${MOUNTS[@]} \
-        ${ENV_VARS[@]} \
-        ${IMAGE} 2>&1 | tee "${LOG_FILE}"
-
-    [[ $? -eq 0 ]] && create_service
+    if eval "${CONTAINER_ENGINE}" run --rm --name skupper-bootstrap \
+        --network host --security-opt label=disable -u \""${RUNAS}"\" --userns=\""${USERNS}"\" \
+        "${MOUNTS}" \
+        "${ENV_VARS}" \
+        "${IMAGE}" 2>&1 | tee "${LOG_FILE}"; then
+        create_service
+    fi
 }
 
-main $@
+main "$@"
diff --git a/cmd/bootstrap/remove.sh b/cmd/bootstrap/remove.sh
index c89de81e..3d7f7065 100755
--- a/cmd/bootstrap/remove.sh
+++ b/cmd/bootstrap/remove.sh
@@ -1,15 +1,21 @@
+#!/bin/sh
+
 set -Ceu
 
-if [[ $# -eq 0 ]] || [[ -z "${1}" ]]; then
+if [ $# -eq 0 ] || [ -z "${1}" ]; then
     echo "Use: ${0##*/} <site-name>"
     exit 1
 fi
 
+if [ -z "${UID:-}" ]; then
+    UID="$(id -u)"
+    export UID
+fi
 site=$1
 sites_path="${HOME}/.local/share/skupper/sites"
 service_path="${HOME}/.config/systemd/user"
 systemctl="systemctl --user"
-if [[ ${UID} -eq 0 ]]; then
+if [ "${UID}" -eq 0 ]; then
     sites_path="/usr/local/share/skupper/sites"
     service_path="/etc/systemd/system"
     systemctl="systemctl"
@@ -18,27 +24,27 @@ fi
 remove_definition() {
     platform_file="${sites_path}/${site}/runtime/state/platform.yaml"
     SKUPPER_PLATFORM=$(grep '^platform: ' "${platform_file}" | sed -e 's/.*: //g')
-    if [[ "${SKUPPER_PLATFORM}" != "systemd" ]]; then
-        ${SKUPPER_PLATFORM} rm -f ${site}-skupper-router
+    if [ "${SKUPPER_PLATFORM}" != "systemd" ]; then
+        ${SKUPPER_PLATFORM} rm -f "${site}-skupper-router"
     fi
-    rm -rf ${sites_path}/${site}/
+    rm -rf "${sites_path:?}/${site:?}/"
 }
 
 remove_service() {
     service="skupper-site-${site}.service"
-    ${systemctl} stop ${service}
-    ${systemctl} disable ${service}
-    rm -f ${service_path}/${service}
+    ${systemctl} stop "${service}"
+    ${systemctl} disable "${service}"
+    rm -f "${service_path:?}/${service:?}"
     ${systemctl} daemon-reload
     ${systemctl} reset-failed
 }
 
 main () {
-    if [[ ! "${site}" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]; then
+    if ! echo "${site:?}" | grep -E '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'; then
         echo "Invalid site name"
         exit 0
     fi
-    if [[ ! -d "${sites_path}/${site}" ]]; then
+    if [ ! -d "${sites_path:?}/${site:?}" ]; then
         echo "Site does not exist"
         exit 0
     fi
@@ -46,4 +52,4 @@ main () {
     remove_service
 }
 
-main $@
\ No newline at end of file
+main "$@"
\ No newline at end of file
diff --git a/internal/nonkube/bundle/common.go b/internal/nonkube/bundle/common.go
new file mode 100644
index 00000000..4c48dc82
--- /dev/null
+++ b/internal/nonkube/bundle/common.go
@@ -0,0 +1,23 @@
+package bundle
+
+import (
+	_ "embed"
+
+	"github.com/skupperproject/skupper/internal/utils"
+)
+
+const (
+	scriptExit = "\nexit 0\n"
+	shellDelim = "\n__TARBALL_CONTENT__\n"
+)
+
+var (
+	//go:embed install.sh.template
+	installScript string
+	//go:embed self_extract.sh.template
+	selfExtractPart string
+)
+
+type BundleGenerator interface {
+	Generate(tarball *utils.Tarball) error
+}
diff --git a/internal/nonkube/bundle/extract.go b/internal/nonkube/bundle/extract.go
new file mode 100644
index 00000000..08912452
--- /dev/null
+++ b/internal/nonkube/bundle/extract.go
@@ -0,0 +1,71 @@
+package bundle
+
+import (
+	"bytes"
+	_ "embed"
+	"fmt"
+	"os"
+	"path"
+	"text/template"
+
+	"github.com/skupperproject/skupper/internal/utils"
+)
+
+type SelfExtractingBundle struct {
+	SiteName   string
+	OutputPath string
+}
+
+func (s *SelfExtractingBundle) InstallFile() string {
+	return path.Join(s.OutputPath, fmt.Sprintf("skupper-install-%s.sh", s.SiteName))
+}
+
+func (s *SelfExtractingBundle) Generate(tarBall *utils.Tarball) error {
+	var data = new(bytes.Buffer)
+	var err error
+
+	write := func(buf interface{}) error {
+		var size, written int
+		switch b := buf.(type) {
+		case string:
+			size = len(b)
+			written, err = data.WriteString(b)
+		case []byte:
+			size = len(b)
+			written, err = data.Write(b)
+		}
+		if err != nil || written != size {
+			return fmt.Errorf("error writing data (size: %d - written: %d): %w", size, written, err)
+		}
+		return nil
+	}
+
+	installScriptTemplate := template.Must(template.New("install").Parse(installScript))
+	var parsedInstallScript = new(bytes.Buffer)
+	err = installScriptTemplate.Execute(parsedInstallScript, map[string]interface{}{
+		"SiteName":        s.SiteName,
+		"SelfExtractPart": selfExtractPart,
+	})
+	if err != nil {
+		return err
+	}
+	if err := write(parsedInstallScript.String()); err != nil {
+		return err
+	}
+	if err := write(scriptExit); err != nil {
+		return err
+	}
+	if err := write(shellDelim); err != nil {
+		return err
+	}
+	siteData, err := tarBall.SaveData()
+	if err != nil {
+		return fmt.Errorf("error saving tarball data: %w", err)
+	}
+	if err := write(siteData); err != nil {
+		return err
+	}
+
+	err = os.WriteFile(s.InstallFile(), data.Bytes(), 0755)
+	return err
+}
diff --git a/internal/nonkube/bundle/install.sh.template b/internal/nonkube/bundle/install.sh.template
new file mode 100644
index 00000000..dda9b08e
--- /dev/null
+++ b/internal/nonkube/bundle/install.sh.template
@@ -0,0 +1,292 @@
+#!/bin/sh
+
+set -Ceu
+
+{{.SelfExtractPart}}
+
+# Bundle installation and removal script
+
+# site name to be provided by bundle generation
+export SITE_NAME="{{.SiteName}}"
+export SKUPPER_PLATFORM="podman"
+export PLATFORM_COMMAND="podman"
+export REMOVE=false
+export DUMP_TOKENS=false
+
+# standard output directories
+if [ -z "${UID:-}" ]; then
+    UID="$(id -u)"
+    export UID
+fi
+export SKUPPER_OUTPUT_PATH="${XDG_DATA_HOME:-${HOME}/.local/share}/skupper"
+export SERVICE_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/systemd/user"
+export RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/${UID}}"
+export SYSTEMCTL="systemctl --user"
+export USERNS="keep-id"
+GID=$(id -g "${UID}")
+export RUNAS="${UID}:${GID}"
+if [ "${UID}" -eq 0 ]; then
+    export SKUPPER_OUTPUT_PATH="/usr/local/share/skupper"
+    export SERVICE_DIR="/etc/systemd/system"
+    export RUNTIME_DIR="/run"
+    export SYSTEMCTL="systemctl"
+    # shellcheck disable=SC2089
+    export USERNS="''"
+fi
+export SITES_PATH="${SKUPPER_OUTPUT_PATH}/sites"
+export PLATFORM_FILE="${SITES_PATH}/${SITE_NAME}/runtime/state/platform.yaml"
+export USER="${USER:-$(id -un)}"
+SITE_ID="$(hostname -s)-${USER}-$(date +%s)"
+export SITE_ID
+
+exit_error() {
+    echo "$*"
+    exit 1
+}
+
+usage() {
+    echo "Usage: $0 [-p <podman|docker|systemd>] [-x] [-d <output-dir>]" >&2
+    echo "    -p    platform to use (one of: podman, docker, systemd)" >&2
+    echo "    -x    remove existing site definition" >&2
+    echo "    -d    dump tokens from bundle to the provide output directory" >&2
+    exit 1
+}
+
+parse_opts() {
+    while getopts "xhd:p:" opt; do
+        case "${opt}" in
+            p)
+                valid_platforms="podman docker systemd"
+                export SKUPPER_PLATFORM="${OPTARG}"
+                is_platform_valid=false
+                for valid_platform in ${valid_platforms}; do
+                    if [ "${valid_platform}" = "${SKUPPER_PLATFORM}" ]; then
+                        is_platform_valid=true
+                        break
+                    fi
+                done
+                if [ ! "${is_platform_valid}" ]; then
+                    echo "Invalid platform: ${SKUPPER_PLATFORM}"
+                    usage
+                fi
+                if [ "${SKUPPER_PLATFORM}" = "docker" ]; then
+                    export PLATFORM_COMMAND="docker"
+                    if [ "${UID}" -ne 0 ]; then
+                        # shellcheck disable=SC2090
+                        export USERNS="host"
+                    fi
+                    docker_gid=$(getent group docker | cut -d: -f3)
+                    export RUNAS="${UID}:${docker_gid}"
+                elif [ "${SKUPPER_PLATFORM}" = "systemd" ]; then
+                    export PLATFORM_COMMAND="skrouterd"
+                fi
+                ;;
+            d)
+                export DUMP_TOKENS=true
+                export SKUPPER_TOKEN_OUTPUT_PATH="${OPTARG}"
+                if [ -z "${SKUPPER_TOKEN_OUTPUT_PATH}" ] || [ ! -d "${SKUPPER_TOKEN_OUTPUT_PATH}" ]; then
+                    echo "Output directory is not valid"
+                    usage
+                fi
+                if [ ! -w "${SKUPPER_TOKEN_OUTPUT_PATH}" ]; then
+                    echo "No permission to write tokens to provided output directory"
+                    usage
+                fi
+                ;;
+            x)
+                export REMOVE=true
+                ;;
+            h)
+                usage
+                ;;
+            *)
+                usage
+                ;;
+        esac
+    done
+}
+
+create_service() {
+    # if systemd is not available, skip it
+    ${SYSTEMCTL} list-units > /dev/null 2>&1 || return
+    service_name="skupper-site-${SITE_NAME}.service"
+    service_file_suffix="container"
+    [ "${SKUPPER_PLATFORM}" = "systemd" ] && service_file_suffix="systemd"
+    service_file="${SKUPPER_OUTPUT_PATH}/sites/${SITE_NAME}/runtime/scripts/${service_name}.${service_file_suffix}"
+
+    if [ ! -f "${service_file}" ]; then
+        echo "SystemD service has not been defined"
+        return 0
+    fi
+
+    # Moving it to the appropriate location
+    if [ "${UID}" -eq 0 ]; then
+        cp "${service_file}" "/etc/systemd/system/${service_name}"
+    else
+        if [ ! -d "${SERVICE_DIR}" ]; then
+            echo "Unable to define path to SystemD service"
+            return
+        fi
+        cp "${service_file}" "${SERVICE_DIR}/${service_name}"
+    fi
+
+    ${SYSTEMCTL} enable --now "${service_name}"
+    ${SYSTEMCTL} daemon-reload
+}
+
+remove_service() {
+    service="skupper-site-${SITE_NAME}.service"
+    ${SYSTEMCTL} stop ${service}
+    ${SYSTEMCTL} disable ${service}
+    rm -f "${SERVICE_DIR:?}/${service}"
+    ${SYSTEMCTL} daemon-reload
+    ${SYSTEMCTL} reset-failed
+}
+
+remove_site() {
+    if [ ! -d "${SITES_PATH}/${SITE_NAME}" ]; then
+        echo "Site \"${SITE_NAME}\" does not exist"
+        exit 0
+    fi
+
+    echo "Removing Skupper site definition for: ${SITE_NAME}"
+    SKUPPER_PLATFORM=$(grep '^platform: ' "${PLATFORM_FILE}" | sed -e 's/.*: //g')
+    if [ "${SKUPPER_PLATFORM}" != "systemd" ]; then
+        # removing router container
+        ${SKUPPER_PLATFORM} rm -f ${SITE_NAME}-skupper-router
+    fi
+    # removing site definition
+    rm -rf "${SITES_PATH:?}/${SITE_NAME:?}/"
+}
+
+dump_tokens() {
+    tokens="$(ls "./${SITE_NAME}/runtime/token/")"
+    if [ -z "${tokens}" ]; then
+        echo "No tokens defined in this site bundle"
+        return
+    fi
+    token_out_dir="${SKUPPER_TOKEN_OUTPUT_PATH:?}/${SITE_NAME:?}"
+    mkdir -p "${token_out_dir}"
+    cp "./${SITE_NAME}/runtime/token/"*.yaml "${token_out_dir}"
+    echo "Tokens for site \"${SITE_NAME}\" have been saved into ${token_out_dir}"
+}
+
+set_router_access_port() {
+    config_file="${SITES_PATH}/${SITE_NAME}/config/router/skrouterd.json"
+    python "${SITES_PATH:?}/${SITE_NAME:?}/runtime/scripts/router_free_port.py" "${config_file}"
+}
+
+create_sed_script() {
+    ssl_profile_base_path="\${SSL_PROFILE_BASE_PATH}"
+    [ "${SKUPPER_PLATFORM}" = "systemd" ] && ssl_profile_base_path="${SITES_PATH}/${SITE_NAME}"
+    container_engine="podman"
+    [ "${SKUPPER_PLATFORM}" != "systemd" ] && container_engine="${SKUPPER_PLATFORM}"
+    # populating sed script
+    # note that this is processed a golang template prior to
+    # being inserted into the bundle
+    # shellcheck disable=SC1083
+    {
+        echo "s#{{"{{"}}.SiteNameSuffix{{"}}"}}#${SITE_ID}#g"
+        echo "s#{{"{{"}}.Platform{{"}}"}}#${SKUPPER_PLATFORM}#g"
+        echo "s#{{"{{"}}.SslProfileBasePath{{"}}"}}#${ssl_profile_base_path}#g"
+        echo "s#{{"{{"}}.ContainerEngine{{"}}"}}#${container_engine}#g"
+        echo "s#{{"{{"}}.SiteId{{"}}"}}#${SITE_ID}#g"
+        echo "s#{{"{{"}}.SitesPath{{"}}"}}#${SITES_PATH}#g"
+        echo "s#{{"{{"}}.RuntimeDir{{"}}"}}#${RUNTIME_DIR}#g"
+        echo "s#{{"{{"}}.SiteScriptPath{{"}}"}}#${SITES_PATH}/${SITE_NAME}/runtime/scripts#g"
+        echo "s#{{"{{"}}.SiteConfigPath{{"}}"}}#${SITES_PATH}/${SITE_NAME}/config/router#g"
+        echo "s#{{"{{"}}.RunAs{{"}}"}}#${RUNAS}#g"
+        echo "s#{{"{{"}}.UserNamespace{{"}}"}}#${USERNS}#g"
+    } >> script.sed
+}
+
+render_sed_templates() {
+    grep -rl '{{"{{"}}' "${SITES_PATH}/${SITE_NAME}" | xargs -r -i sed -f script.sed -i {}
+    rm -f script.sed
+}
+
+show_token_info() {
+    token_path="${SITES_PATH:?}/${SITE_NAME:?}/runtime/token"
+    [ ! -d "${token_path}" ] && return
+    tokens_found=$(find "${token_path}" -type f | wc -l)
+    if [ "${tokens_found}" -gt 0 ]; then
+        echo "Static tokens have been defined at: ${token_path}"
+    fi
+}
+
+create_containers() {
+    [ "${SKUPPER_PLATFORM}" = "systemd" ] && return
+    "${SITES_PATH:?}/${SITE_NAME:?}/runtime/scripts/containers_create.sh"
+}
+
+create_site() {
+    if [ -d "${SITES_PATH}/${SITE_NAME}" ]; then
+        echo "Failed: site \"${SITE_NAME}\" is already defined"
+        exit 1
+    fi
+
+    echo "Skupper site bundle installation"
+    echo "Site name: ${SITE_NAME}"
+    echo "Platform : ${SKUPPER_PLATFORM}"
+
+    # Create base directory tree
+    mkdir -p "${SITES_PATH}"
+
+    # Installing site definition files
+    cp -rf "./${SITE_NAME}" "${SITES_PATH}/${SITE_NAME}"
+
+    # Creating platform.yaml
+    echo "platform: ${SKUPPER_PLATFORM}" > "${PLATFORM_FILE}"
+
+    # Adjust router normal access port
+    set_router_access_port
+
+    # Creating sed script to render template files at install time
+    create_sed_script
+    render_sed_templates
+
+    # If bundle has tokens, show token location after site has been created
+    show_token_info
+
+    # Creating containers (container engine only)
+    create_containers
+
+    # Create systemd service
+    create_service
+
+    echo "Site \"${SITE_NAME}\" is now running"
+}
+
+sanity_check() {
+    required_fields="SITE_NAME SKUPPER_OUTPUT_PATH SERVICE_DIR SITES_PATH SKUPPER_PLATFORM"
+    required_commands="python sed find grep xargs tar getent id ${PLATFORM_COMMAND}"
+
+    for field_name in ${required_fields}; do
+        eval [ -n "\${${field_name}}" ] || exit_error "Internal error: required field ${field_name} not defined"
+    done
+    for cmd in ${required_commands}; do
+        if ! command -v "${cmd}" > /dev/null 2>&1; then
+            exit_error "A required command could not be found: ${cmd}"
+        fi
+    done
+}
+
+main() {
+    # validate provided options
+    parse_opts "$@"
+
+    sanity_check
+
+    if ${REMOVE}; then
+        remove_site
+        remove_service
+        return
+    elif ${DUMP_TOKENS}; then
+        dump_tokens
+        return
+    fi
+
+    create_site
+}
+
+main "$@"
diff --git a/internal/nonkube/bundle/self_extract.sh.template b/internal/nonkube/bundle/self_extract.sh.template
new file mode 100644
index 00000000..084411f1
--- /dev/null
+++ b/internal/nonkube/bundle/self_extract.sh.template
@@ -0,0 +1,13 @@
+# Self extract
+TAR_CONTENT_START=$(awk '/^__TARBALL_CONTENT__$/ {print NR+1; exit 0;}' "$0")
+TMP_DIR=$(mktemp -d /tmp/skupper-bundle.XXXXX)
+CUR_DIR=$(pwd)
+
+cleanup() {
+  [ -d "${TMP_DIR}" ] && rm -rf "${TMP_DIR}"
+  cd "${CUR_DIR}"
+}
+
+trap cleanup EXIT
+tail -n+"${TAR_CONTENT_START}" "$0" | tar zxf - -C "${TMP_DIR}"
+cd "${TMP_DIR}"
diff --git a/internal/nonkube/bundle/tarball.go b/internal/nonkube/bundle/tarball.go
new file mode 100644
index 00000000..9e4d5e7d
--- /dev/null
+++ b/internal/nonkube/bundle/tarball.go
@@ -0,0 +1,39 @@
+package bundle
+
+import (
+	"bytes"
+	_ "embed"
+	"fmt"
+	"path"
+	"text/template"
+
+	"github.com/skupperproject/skupper/internal/utils"
+)
+
+type TarballBundle struct {
+	SiteName   string
+	OutputPath string
+}
+
+func (s *TarballBundle) InstallFile() string {
+	return path.Join(s.OutputPath, fmt.Sprintf("skupper-install-%s.tar.gz", s.SiteName))
+}
+
+func (s *TarballBundle) Generate(tarBall *utils.Tarball) error {
+	var err error
+
+	installScriptTemplate := template.Must(template.New("install").Parse(installScript))
+	var parsedInstallScript = new(bytes.Buffer)
+	err = installScriptTemplate.Execute(parsedInstallScript, map[string]interface{}{
+		"SiteName":        s.SiteName,
+		"SelfExtractPart": "",
+	})
+	if err != nil {
+		return err
+	}
+	if err = tarBall.AddFileData("install.sh", 0755, parsedInstallScript.Bytes()); err != nil {
+		return fmt.Errorf("error writing install.sh: %w", err)
+	}
+	err = tarBall.Save(s.InstallFile())
+	return err
+}
diff --git a/internal/non-kube/client/compat/client.go b/internal/nonkube/client/compat/client.go
similarity index 100%
rename from internal/non-kube/client/compat/client.go
rename to internal/nonkube/client/compat/client.go
diff --git a/internal/non-kube/client/compat/client_test.go b/internal/nonkube/client/compat/client_test.go
similarity index 100%
rename from internal/non-kube/client/compat/client_test.go
rename to internal/nonkube/client/compat/client_test.go
diff --git a/internal/non-kube/client/compat/container.go b/internal/nonkube/client/compat/container.go
similarity index 100%
rename from internal/non-kube/client/compat/container.go
rename to internal/nonkube/client/compat/container.go
diff --git a/internal/non-kube/client/compat/container_mock.go b/internal/nonkube/client/compat/container_mock.go
similarity index 95%
rename from internal/non-kube/client/compat/container_mock.go
rename to internal/nonkube/client/compat/container_mock.go
index dade9337..8a918524 100644
--- a/internal/non-kube/client/compat/container_mock.go
+++ b/internal/nonkube/client/compat/container_mock.go
@@ -13,6 +13,7 @@ import (
 	"github.com/go-openapi/strfmt"
 	"github.com/google/uuid"
 	"github.com/skupperproject/skupper-libpod/v4/client/containers_compat"
+	"github.com/skupperproject/skupper-libpod/v4/client/images_compat"
 	"github.com/skupperproject/skupper-libpod/v4/client/networks_compat"
 	"github.com/skupperproject/skupper-libpod/v4/client/system_compat"
 	"github.com/skupperproject/skupper-libpod/v4/client/volumes_compat"
@@ -40,6 +41,7 @@ func NewCompatClientMock(containers []*container.Container) *CompatClient {
 // performed by the mock implementation.
 type RestClientMock struct {
 	Containers   []*container.Container
+	Images       []*container.Image
 	networks     map[string]*container.Network
 	Volumes      map[string]*container.Volume
 	VolumesFiles map[string]map[string]string
@@ -107,6 +109,8 @@ func (r *RestClientMock) Submit(operation *runtime.ClientOperation) (interface{}
 		res, err = r.HandleSystemInfo(operation, r.ErrorHook)
 	case "ImageCreate":
 		res, err = r.HandleImageCreate(operation, r.ErrorHook)
+	case "ImageList":
+		res, err = r.HandleImageList(operation, r.ErrorHook)
 	case "ContainerList":
 		res, err = r.HandleContainerList(operation, r.ErrorHook)
 	case "ContainerInspect":
@@ -772,9 +776,42 @@ func (r *RestClientMock) HandleImageCreate(operation *runtime.ClientOperation, h
 			return nil, err
 		}
 	}
+	params := operation.Params.(*images_compat.ImageCreateParams)
+	imageName := fmt.Sprintf("%s:%s", *params.FromImage, *params.Tag)
+	for _, img := range r.Images {
+		if img.Id == imageName {
+			return nil, nil
+		}
+	}
+	r.Images = append(r.Images, &container.Image{
+		Id:         imageName,
+		Repository: imageName,
+		Digest:     imageName,
+		Created:    strconv.FormatInt(time.Now().Unix(), 10),
+	})
 	return nil, nil
 }
 
+func (r *RestClientMock) HandleImageList(operation *runtime.ClientOperation, hook func(operation *runtime.ClientOperation) error) (interface{}, error) {
+	if hook != nil {
+		if err := hook(operation); err != nil {
+			return nil, err
+		}
+	}
+	res := new(images_compat.ImageListOK)
+	for _, img := range r.Images {
+		var created int64
+		created, _ = strconv.ParseInt(img.Created, 10, 64)
+		res.Payload = append(res.Payload, &models.ImageSummary{
+			Created:     &created,
+			ID:          &img.Id,
+			RepoDigests: []string{img.Digest},
+			RepoTags:    []string{img.Repository},
+		})
+	}
+	return res, nil
+}
+
 func mockContainers(image string) []*container.Container {
 	return []*container.Container{{
 		ID:    "abcd",
diff --git a/internal/non-kube/client/compat/network.go b/internal/nonkube/client/compat/network.go
similarity index 100%
rename from internal/non-kube/client/compat/network.go
rename to internal/nonkube/client/compat/network.go
diff --git a/internal/non-kube/client/compat/parser.go b/internal/nonkube/client/compat/parser.go
similarity index 100%
rename from internal/non-kube/client/compat/parser.go
rename to internal/nonkube/client/compat/parser.go
diff --git a/internal/non-kube/client/compat/rest_test.go b/internal/nonkube/client/compat/rest_test.go
similarity index 100%
rename from internal/non-kube/client/compat/rest_test.go
rename to internal/nonkube/client/compat/rest_test.go
diff --git a/internal/non-kube/client/compat/version.go b/internal/nonkube/client/compat/version.go
similarity index 100%
rename from internal/non-kube/client/compat/version.go
rename to internal/nonkube/client/compat/version.go
diff --git a/internal/non-kube/client/compat/volume.go b/internal/nonkube/client/compat/volume.go
similarity index 100%
rename from internal/non-kube/client/compat/volume.go
rename to internal/nonkube/client/compat/volume.go
diff --git a/internal/non-kube/client/compat/volume_test.go b/internal/nonkube/client/compat/volume_test.go
similarity index 100%
rename from internal/non-kube/client/compat/volume_test.go
rename to internal/nonkube/client/compat/volume_test.go
diff --git a/internal/utils/command.go b/internal/utils/command.go
new file mode 100644
index 00000000..8f5e3e92
--- /dev/null
+++ b/internal/utils/command.go
@@ -0,0 +1,29 @@
+package utils
+
+import (
+	"bytes"
+)
+
+const (
+	maxLineLength = 80
+	indent        = "   "
+	newLinePrefix = " \\\n" + indent
+)
+
+func PrettyPrintCommand(command string, args []string) string {
+	var lineLength int
+	buf := new(bytes.Buffer)
+	buf.WriteString(command)
+	lineLength = len(command)
+	for i, arg := range args {
+		buf.WriteString(" ")
+		buf.WriteString(arg)
+		lineLength += len(arg) + 1
+		if lineLength > maxLineLength && i < len(args)-1 {
+			buf.WriteString(newLinePrefix)
+			lineLength = len(indent)
+		}
+	}
+	buf.WriteString("\n")
+	return buf.String()
+}
diff --git a/internal/utils/tarball.go b/internal/utils/tarball.go
new file mode 100644
index 00000000..669ab0fb
--- /dev/null
+++ b/internal/utils/tarball.go
@@ -0,0 +1,235 @@
+package utils
+
+import (
+	"archive/tar"
+	"bytes"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"os"
+	"path"
+	"strings"
+	"sync"
+	"time"
+)
+
+type Tarball struct {
+	buf       *bytes.Buffer
+	gz        *gzip.Writer
+	tw        *tar.Writer
+	lastAdded time.Time
+	basePath  string
+	mutex     *sync.Mutex
+}
+
+// NewTarball returns an initialized Tarball
+func NewTarball() *Tarball {
+	tb := &Tarball{}
+	tb.buf = new(bytes.Buffer)
+	tb.gz = gzip.NewWriter(tb.buf)
+	tb.tw = tar.NewWriter(tb.gz)
+	tb.mutex = &sync.Mutex{}
+	return tb
+}
+
+// Save saves tarball based on added directories.
+// The provided filename will be created or truncated
+// if it already exists.
+// Once Save method is executed, this instance cannot
+// be used anymore.
+func (t *Tarball) Save(filename string) error {
+	var err error
+	err = t.close()
+	if err != nil {
+		return err
+	}
+	err = os.WriteFile(filename, t.buf.Bytes(), 0644)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// SaveData saves written files and returns the tarball data.
+// Once SaveData method is executed, this instance cannot
+// be used anymore.
+func (t *Tarball) SaveData() ([]byte, error) {
+	err := t.close()
+	if err != nil {
+		return nil, err
+	}
+	return t.buf.Bytes(), nil
+}
+
+func (t *Tarball) close() error {
+	t.mutex.Lock()
+	defer t.mutex.Unlock()
+	var err error
+	err = t.tw.Flush()
+	if err != nil {
+		return err
+	}
+	err = t.tw.Close()
+	if err != nil {
+		return err
+	}
+	err = t.gz.Close()
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// AddFiles adds all files (recursively) based on the
+// provided directory.
+func (t *Tarball) AddFiles(dir string, includesOnly ...string) error {
+	t.mutex.Lock()
+	defer t.mutex.Unlock()
+	t.lastAdded = time.Now()
+	if !strings.HasSuffix(dir, "/") {
+		dir = dir + "/"
+	}
+	t.basePath = dir
+	var err error
+	if len(includesOnly) > 0 {
+		for _, inc := range includesOnly {
+			err = t.addFiles(path.Join(dir, inc))
+		}
+	} else {
+		err = t.addFiles(dir)
+	}
+	t.lastAdded = time.Time{}
+	t.basePath = ""
+	return err
+}
+
+func (t *Tarball) addFiles(dir string) error {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return err
+	}
+	innerDir := dir[len(t.basePath):]
+	for _, entry := range entries {
+		fileStat, err := os.Stat(path.Join(dir, entry.Name()))
+		if err != nil {
+			return err
+		}
+		if entry.IsDir() {
+			t.tw.WriteHeader(&tar.Header{
+				Name:     path.Join(innerDir, entry.Name()) + "/",
+				Mode:     int64(fileStat.Mode()),
+				Typeflag: tar.TypeDir,
+				ModTime:  fileStat.ModTime(),
+			})
+			err = t.addFiles(path.Join(dir, entry.Name()))
+			if err != nil {
+				return err
+			}
+		} else {
+			fileName := path.Join(innerDir, entry.Name())
+			err = t.tw.WriteHeader(&tar.Header{
+				Name:    fileName,
+				Mode:    int64(fileStat.Mode()),
+				Size:    fileStat.Size(),
+				ModTime: fileStat.ModTime(),
+			})
+			if err != nil {
+				return err
+			}
+			data, err := os.ReadFile(path.Join(dir, entry.Name()))
+			if err != nil {
+				return err
+			}
+			_, err = t.tw.Write(data)
+			if err != nil {
+				return err
+			}
+			err = t.tw.Flush()
+			if err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func (t *Tarball) AddFileData(fileName string, mode int64, data []byte) error {
+	var err error
+	err = t.tw.WriteHeader(&tar.Header{
+		Name:    fileName,
+		Mode:    mode,
+		Size:    int64(len(data)),
+		ModTime: time.Now(),
+	})
+	if err != nil {
+		return err
+	}
+	_, err = t.tw.Write(data)
+	if err != nil {
+		return err
+	}
+	err = t.tw.Flush()
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// Extract extracts the given tar.gz filename into the provided outputPath
+func (t *Tarball) Extract(fileName, outputPath string) error {
+	// Validating outputPath
+	if outputPath == "" {
+		return fmt.Errorf("outputPath is empty")
+	}
+	outputPathStat, err := os.Stat(outputPath)
+	if err != nil {
+		err = os.MkdirAll(outputPath, 0755)
+		if err != nil {
+			return err
+		}
+	} else if !outputPathStat.Mode().IsDir() {
+		return fmt.Errorf("outputPath is not a directory")
+	}
+
+	tgzFile, err := os.Open(fileName)
+	if err != nil {
+		return err
+	}
+	gzipReader, err := gzip.NewReader(tgzFile)
+	if err != nil {
+		return err
+	}
+	tarReader := tar.NewReader(gzipReader)
+	for {
+		header, err := tarReader.Next()
+		switch {
+		case err == io.EOF:
+			return nil
+		case err != nil:
+			return err
+		case header == nil:
+			continue
+		}
+		targetFilePath := path.Join(outputPath, header.Name)
+		switch header.Typeflag {
+		case tar.TypeDir:
+			if _, err := os.Stat(targetFilePath); err != nil {
+				if err := os.MkdirAll(targetFilePath, 0755); err != nil {
+					return err
+				}
+			}
+		case tar.TypeReg:
+			file, err := os.OpenFile(targetFilePath, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
+			if err != nil {
+				return err
+			}
+			if _, err := io.Copy(file, tarReader); err != nil {
+				return err
+			}
+			err = file.Close()
+			if err != nil {
+				return err
+			}
+		}
+	}
+}
diff --git a/pkg/non_kube/apis/environment.go b/pkg/nonkube/apis/environment.go
similarity index 87%
rename from pkg/non_kube/apis/environment.go
rename to pkg/nonkube/apis/environment.go
index e46f929b..bedf020a 100644
--- a/pkg/non_kube/apis/environment.go
+++ b/pkg/nonkube/apis/environment.go
@@ -45,13 +45,13 @@ func GetRuntimeDir() string {
 	return runtimeDir
 }
 
-// GetHostDataHome returns the value of the OUTPUT_PATH environment
+// GetHostDataHome returns the value of the SKUPPER_OUTPUT_PATH environment
 // variable when running via container or the result of GetDataHome() otherwise.
 // This is only useful during the bootstrap process.
 func GetHostDataHome() (string, error) {
-	// If container provides OUTPUT_PATH use it
-	if os.Getenv("OUTPUT_PATH") != "" {
-		return os.Getenv("OUTPUT_PATH"), nil
+	// If container provides SKUPPER_OUTPUT_PATH use it
+	if os.Getenv("SKUPPER_OUTPUT_PATH") != "" {
+		return os.Getenv("SKUPPER_OUTPUT_PATH"), nil
 	}
 	return GetDataHome(), nil
 }
diff --git a/pkg/non_kube/apis/environment_test.go b/pkg/nonkube/apis/environment_test.go
similarity index 100%
rename from pkg/non_kube/apis/environment_test.go
rename to pkg/nonkube/apis/environment_test.go
diff --git a/pkg/non_kube/apis/site_state.go b/pkg/nonkube/apis/site_state.go
similarity index 94%
rename from pkg/non_kube/apis/site_state.go
rename to pkg/nonkube/apis/site_state.go
index 4b62874b..94966211 100644
--- a/pkg/non_kube/apis/site_state.go
+++ b/pkg/nonkube/apis/site_state.go
@@ -1,6 +1,7 @@
 package apis
 
 import (
+	encodingjson "encoding/json"
 	"fmt"
 	"os"
 	"path"
@@ -34,9 +35,10 @@ type SiteState struct {
 	Claims          map[string]*v1alpha1.AccessToken
 	Certificates    map[string]*v1alpha1.Certificate
 	SecuredAccesses map[string]*v1alpha1.SecuredAccess
+	bundle          bool
 }
 
-func NewSiteState() *SiteState {
+func NewSiteState(bundle bool) *SiteState {
 	return &SiteState{
 		Site:            &v1alpha1.Site{},
 		Listeners:       make(map[string]*v1alpha1.Listener),
@@ -48,9 +50,14 @@ func NewSiteState() *SiteState {
 		Claims:          make(map[string]*v1alpha1.AccessToken),
 		Certificates:    map[string]*v1alpha1.Certificate{},
 		SecuredAccesses: map[string]*v1alpha1.SecuredAccess{},
+		bundle:          bundle,
 	}
 }
 
+func (s *SiteState) IsBundle() bool {
+	return s.bundle
+}
+
 func (s *SiteState) IsInterior() bool {
 	return s.Site.Spec.RouterMode != "edge"
 }
@@ -73,7 +80,7 @@ func (s *SiteState) CreateRouterAccess(name string, port int) {
 	s.RouterAccesses[name] = &v1alpha1.RouterAccess{
 		TypeMeta: metav1.TypeMeta{
 			APIVersion: "skupper.io/v1alpha1",
-			Kind:       "LinkAccess",
+			Kind:       "RouterAccess",
 		},
 		ObjectMeta: metav1.ObjectMeta{
 			Name: name,
@@ -231,7 +238,17 @@ func (s *SiteState) ToRouterConfig(sslProfileBasePath string) qdr.RouterConfig {
 		s.SiteId = uuid.New().String()
 	}
 	routerConfig := qdr.InitialConfig(s.Site.Name, s.SiteId, version.Version, !s.IsInterior(), 3)
-
+	// override metadata
+	if s.bundle {
+		routerConfig.Metadata.Id += "-{{.SiteNameSuffix}}"
+		metadata := qdr.SiteMetadata{
+			Id:       "{{.SiteId}}",
+			Version:  version.Version,
+			Platform: "{{.Platform}}",
+		}
+		metadataJson, _ := encodingjson.Marshal(metadata)
+		routerConfig.Metadata.Metadata = string(metadataJson)
+	}
 	// LinkAccess
 	s.linkAccessMap().DesiredConfig(nil, path.Join(sslProfileBasePath, "certificates/server")).Apply(&routerConfig)
 	// Link
diff --git a/pkg/non_kube/apis/token.go b/pkg/nonkube/apis/token.go
similarity index 100%
rename from pkg/non_kube/apis/token.go
rename to pkg/nonkube/apis/token.go
diff --git a/pkg/nonkube/bundle/container.go b/pkg/nonkube/bundle/container.go
new file mode 100644
index 00000000..b39338d7
--- /dev/null
+++ b/pkg/nonkube/bundle/container.go
@@ -0,0 +1,64 @@
+package bundle
+
+import (
+	"bytes"
+	"fmt"
+	"regexp"
+	"strings"
+
+	internal "github.com/skupperproject/skupper/internal/utils"
+	"github.com/skupperproject/skupper/pkg/container"
+)
+
+var (
+	shellEscape = regexp.MustCompile(`([^a-zA-Z0-9,._+:@%/-])`)
+)
+
+const (
+	shellReplace = "\\$1"
+)
+
+func escapeArgument(argument string) string {
+	if strings.Contains(argument, "{{.") {
+		return argument
+	}
+	noNewLineArgument := strings.Replace(argument, "\n", "", -1)
+	return shellEscape.ReplaceAllString(strings.Trim(noNewLineArgument, "\n"), shellReplace)
+}
+
+func containersToShell(containers map[string]container.Container) []byte {
+	buf := new(bytes.Buffer)
+
+	if len(containers) > 0 {
+		buf.WriteString("#!/bin/sh\n\n")
+	}
+
+	for _, c := range containers {
+		var createCmd []string
+		createCmd = append(createCmd, "{{.ContainerEngine}}", "run", "-d")
+		createCmd = append(createCmd, fmt.Sprintf("--name=%s", escapeArgument(c.Name)))
+		createCmd = append(createCmd, "--user={{.RunAs}}")
+		createCmd = append(createCmd, "--userns={{.UserNamespace}}")
+		for envName, envVal := range c.Env {
+			createCmd = append(createCmd, fmt.Sprintf("--env=%s=%s", envName, escapeArgument(envVal)))
+		}
+		createCmd = append(createCmd, "--label=application=skupper")
+		for labelName, labelVal := range c.Labels {
+			createCmd = append(createCmd, fmt.Sprintf("--label=%s=%s", labelName, escapeArgument(labelVal)))
+		}
+		for _, mount := range c.FileMounts {
+			options := ""
+			if len(mount.Options) > 0 {
+				options = fmt.Sprintf(":%s", strings.Join(mount.Options, ""))
+			}
+			createCmd = append(createCmd, fmt.Sprintf("--volume=%s:%s%s", mount.Source, mount.Destination, options))
+		}
+		createCmd = append(createCmd, "--restart=always")
+		createCmd = append(createCmd, "--network=host")
+		createCmd = append(createCmd, c.Image)
+		prettyCreateCmd := internal.PrettyPrintCommand(createCmd[0], createCmd[1:])
+		buf.WriteString(prettyCreateCmd)
+	}
+
+	return buf.Bytes()
+}
diff --git a/pkg/nonkube/bundle/router_free_port.py b/pkg/nonkube/bundle/router_free_port.py
new file mode 100644
index 00000000..d4634ea3
--- /dev/null
+++ b/pkg/nonkube/bundle/router_free_port.py
@@ -0,0 +1,70 @@
+"""
+Discovers a free TCP port to be used by the normal
+listener defined in the router config file (json).
+The port will be bound to 127.0.0.1 only and discovery
+starts at port 5671.
+Once a free port is discovered, the provided router
+config file will be updated accordingly.
+"""
+import json
+import os
+import socket
+import sys
+
+
+def next_free_port():
+    """
+    Discovers the next free TCP port (starting from 5671)
+    """
+    s = socket.socket()
+    port=5671
+    while port <= 65535:
+        try:
+            s.bind(('127.0.0.1', port))
+            break
+        except:
+            port+=1
+    if port > 65535:
+        raise Exception("Unable to determine free TCP port to use")
+    return port
+
+
+def usage():
+    """
+    Shows usage and exit with return code 1
+    """
+    print("Usage: %s skrouterd.json" %(sys.argv[0]))
+    sys.exit(1)
+
+
+def main():
+    """
+    Validates arguments and run the port discovery and update procedures
+    """
+    if len(sys.argv) != 2:
+        usage()
+    config_file = sys.argv[1]
+    if not os.path.isfile(config_file):
+        print("Invalid router config file: %s" %(config_file))
+        usage()
+
+    data = {}
+    with open(config_file) as f:
+        data = json.load(f)
+
+    for entry in data:
+        if entry[0] != 'listener':
+            continue
+        listener = entry[1]
+        if listener['role'] == 'normal':
+            free_port = next_free_port()
+            listener['port'] = free_port
+
+    f.close()
+
+    with open(config_file, "w") as f:
+        json.dump(data, f, indent=4)
+
+
+# Identify normal listener and update port
+main()
diff --git a/pkg/nonkube/bundle/site_state_renderer.go b/pkg/nonkube/bundle/site_state_renderer.go
new file mode 100644
index 00000000..f4bdea69
--- /dev/null
+++ b/pkg/nonkube/bundle/site_state_renderer.go
@@ -0,0 +1,210 @@
+package bundle
+
+import (
+	_ "embed"
+	"fmt"
+	"os"
+	"path"
+
+	"github.com/skupperproject/skupper/api/types"
+	internalbundle "github.com/skupperproject/skupper/internal/nonkube/bundle"
+	"github.com/skupperproject/skupper/internal/utils"
+	"github.com/skupperproject/skupper/pkg/config"
+	"github.com/skupperproject/skupper/pkg/container"
+	"github.com/skupperproject/skupper/pkg/images"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/common"
+)
+
+var (
+	//go:embed router_free_port.py
+	FreePortScript string
+)
+
+type SiteStateRenderer struct {
+	loadedSiteState *apis.SiteState
+	siteState       *apis.SiteState
+	configRenderer  *common.FileSystemConfigurationRenderer
+	containers      map[string]container.Container
+}
+
+func (s *SiteStateRenderer) Render(loadedSiteState *apis.SiteState) error {
+	var err error
+	var validator apis.SiteStateValidator = &common.SiteStateValidator{}
+	err = validator.Validate(loadedSiteState)
+	if err != nil {
+		return err
+	}
+	s.loadedSiteState = loadedSiteState
+	// active (runtime) SiteState
+	s.siteState = common.CopySiteState(s.loadedSiteState)
+	err = common.RedeemClaims(s.siteState)
+	if err != nil {
+		return fmt.Errorf("failed to redeem claims: %v", err)
+	}
+	// router config needs to be updated, after generation
+	// to add a template variable for local port to be determined
+	// during bundle installation
+	if err = common.CreateRouterAccess(s.siteState); err != nil {
+		return err
+	}
+	s.siteState.CreateLinkAccessesCertificates()
+	s.siteState.CreateBridgeCertificates()
+	// rendering non-kube configuration files and certificates
+	s.configRenderer = &common.FileSystemConfigurationRenderer{
+		SslProfileBasePath: "{{.SslProfileBasePath}}",
+		Force:              false, // TODO discuss how this should be handled?
+	}
+	err = s.configRenderer.Render(s.siteState)
+	if err != nil {
+		fmt.Println(err)
+		os.Exit(1)
+	}
+	// Serializing loaded and runtime site states
+	if err = s.configRenderer.MarshalSiteStates(*s.loadedSiteState, *s.siteState); err != nil {
+		return err
+	}
+
+	if err = s.prepareContainers(); err != nil {
+		return err
+	}
+	if err = s.createContainerScript(); err != nil {
+		return err
+	}
+	if err = s.createFreePortScript(); err != nil {
+		return err
+	}
+	// Create systemd service and scripts
+	if err = CreateSystemdServices(s.siteState); err != nil {
+		return err
+	}
+	if err = CreateStartupScripts(s.siteState); err != nil {
+		return err
+	}
+	if err = s.createBundle(); err != nil {
+		return err
+	}
+	if err = s.removeSiteFiles(); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s *SiteStateRenderer) prepareContainers() error {
+	s.containers = make(map[string]container.Container)
+	s.containers[types.RouterComponent] = container.Container{
+		Name:  fmt.Sprintf("%s-skupper-router", s.siteState.Site.Name),
+		Image: images.GetRouterImageName(),
+		Env: map[string]string{
+			"APPLICATION_NAME":      "skupper-router",
+			"QDROUTERD_CONF":        "/etc/skupper-router/config/" + types.TransportConfigFile,
+			"QDROUTERD_CONF_TYPE":   "json",
+			"SKUPPER_SITE_ID":       s.configRenderer.RouterConfig.GetSiteMetadata().Id,
+			"SSL_PROFILE_BASE_PATH": "/etc/skupper-router",
+		},
+		Labels: map[string]string{
+			types.ComponentAnnotation: types.TransportDeploymentName,
+			types.SiteId:              s.configRenderer.RouterConfig.GetSiteMetadata().Id,
+		},
+		FileMounts: []container.FileMount{
+			{
+				Source:      path.Join("{{.SitesPath}}", s.siteState.Site.Name, "config/router"),
+				Destination: "/etc/skupper-router/config",
+				Options:     []string{"z"},
+			},
+			{
+				Source:      path.Join("{{.SitesPath}}", s.siteState.Site.Name, "certificates"),
+				Destination: "/etc/skupper-router/certificates",
+				Options:     []string{"z"},
+			},
+		},
+		RestartPolicy: "always",
+		// TODO handle resource utilization with container sites
+		//      use pkg.nonkube.cgroups.CgroupControllers to
+		//      validate whether CPU and memory thresholds can be
+		//      set to the container
+	}
+	return nil
+}
+
+func (s *SiteStateRenderer) createContainerScript() error {
+	siteHome, err := apis.GetHostSiteHome(s.siteState.Site)
+	if err != nil {
+		return err
+	}
+	scriptsPath := path.Join(siteHome, common.RuntimeScriptsPath)
+	if apis.IsRunningInContainer() {
+		scriptsPath = path.Join(common.GetDefaultOutputPath(s.siteState.Site.Name), common.RuntimeScriptsPath)
+	}
+	scriptContent := containersToShell(s.containers)
+	err = os.WriteFile(path.Join(scriptsPath, "containers_create.sh"), scriptContent, 0755)
+	if err != nil {
+		return fmt.Errorf("failed to create containers script: %v", err)
+	}
+	return nil
+}
+
+func (s *SiteStateRenderer) createBundle() error {
+	dataHome, err := apis.GetHostDataHome()
+	if err != nil {
+		return err
+	}
+	sitesHomeDir := path.Join(dataHome, "sites")
+	if apis.IsRunningInContainer() {
+		sitesHomeDir = path.Join("/output", "sites")
+	}
+	siteHomeDir := path.Join(sitesHomeDir, s.siteState.Site.Name)
+	tarball := utils.NewTarball()
+	err = tarball.AddFiles(sitesHomeDir, s.siteState.Site.Name)
+	if err != nil {
+		return fmt.Errorf("failed to add files to tarball (%q): %v", siteHomeDir, err)
+	}
+	var generator internalbundle.BundleGenerator
+	if !config.GetPlatform().IsTarball() {
+		generator = &internalbundle.SelfExtractingBundle{
+			SiteName:   s.siteState.Site.Name,
+			OutputPath: sitesHomeDir,
+		}
+	} else {
+		generator = &internalbundle.TarballBundle{
+			SiteName:   s.siteState.Site.Name,
+			OutputPath: sitesHomeDir,
+		}
+	}
+	err = generator.Generate(tarball)
+	if err != nil {
+		return fmt.Errorf("failed to generate site bundle (%q): %v", s.siteState.Site.Name, err)
+	}
+	return nil
+}
+
+func (s *SiteStateRenderer) removeSiteFiles() error {
+	siteHomeDir, err := apis.GetHostSiteHome(s.siteState.Site)
+	if err != nil {
+		return err
+	}
+	if apis.IsRunningInContainer() {
+		siteHomeDir = path.Join("/output", "sites", s.siteState.Site.Name)
+	}
+	err = os.RemoveAll(siteHomeDir)
+	if err != nil {
+		return fmt.Errorf("file to remove temporary site directory %q: %v", siteHomeDir, err)
+	}
+	return nil
+}
+
+func (s *SiteStateRenderer) createFreePortScript() error {
+	siteHome, err := apis.GetHostSiteHome(s.siteState.Site)
+	if err != nil {
+		return err
+	}
+	scriptsPath := path.Join(siteHome, common.RuntimeScriptsPath)
+	if apis.IsRunningInContainer() {
+		scriptsPath = path.Join(common.GetDefaultOutputPath(s.siteState.Site.Name), common.RuntimeScriptsPath)
+	}
+	err = os.WriteFile(path.Join(scriptsPath, "router_free_port.py"), []byte(FreePortScript), 0755)
+	if err != nil {
+		return fmt.Errorf("failed to create router_free_port.py script: %v", err)
+	}
+	return nil
+}
diff --git a/pkg/nonkube/bundle/systemd.go b/pkg/nonkube/bundle/systemd.go
new file mode 100644
index 00000000..2f48314f
--- /dev/null
+++ b/pkg/nonkube/bundle/systemd.go
@@ -0,0 +1,62 @@
+package bundle
+
+import (
+	"bytes"
+	_ "embed"
+	"fmt"
+	"os"
+	"path"
+	"text/template"
+
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/common"
+)
+
+func CreateSystemdServices(siteState *apis.SiteState) error {
+	var err error
+	serviceTemplates := map[string]string{
+		"systemd":   common.SystemdServiceTemplate,
+		"container": common.SystemdContainerServiceTemplate,
+	}
+	siteHome, err := apis.GetHostSiteHome(siteState.Site)
+	if err != nil {
+		return err
+	}
+	scriptsPath := path.Join(siteHome, common.RuntimeScriptsPath)
+	if apis.IsRunningInContainer() {
+		scriptsPath = path.Join(common.GetDefaultOutputPath(siteState.Site.Name), common.RuntimeScriptsPath)
+	}
+	for platform, serviceTemplate := range serviceTemplates {
+		var buf = new(bytes.Buffer)
+		parsedTemplate := template.Must(template.New("service").Parse(serviceTemplate))
+		parsedTemplate.Option()
+		err = parsedTemplate.Execute(buf, map[string]interface{}{
+			"Site":           siteState.Site,
+			"RuntimeDir":     "{{.RuntimeDir}}",
+			"SiteScriptPath": "{{.SiteScriptPath}}",
+			"SiteConfigPath": "{{.SiteConfigPath}}",
+		})
+		if err != nil {
+			return fmt.Errorf("failed to execute %s service template: %w", platform, err)
+		}
+		serviceFile := path.Join(scriptsPath, fmt.Sprintf("skupper-site-%s.service.%s", siteState.Site.Name, platform))
+		err = os.WriteFile(serviceFile, buf.Bytes(), 0644)
+		if err != nil {
+			return fmt.Errorf("failed to write %s service file: %w", platform, err)
+		}
+	}
+	return nil
+}
+
+func CreateStartupScripts(siteState *apis.SiteState) error {
+	// Creating startup scripts first
+	scripts, err := common.GetStartupScripts(siteState.Site, "{{.SiteId}}")
+	if err != nil {
+		return fmt.Errorf("error getting startup scripts: %w", err)
+	}
+	err = scripts.Create()
+	if err != nil {
+		return fmt.Errorf("error creating startup scripts: %w", err)
+	}
+	return nil
+}
diff --git a/pkg/non_kube/cgroups/cgroups.go b/pkg/nonkube/cgroups/cgroups.go
similarity index 100%
rename from pkg/non_kube/cgroups/cgroups.go
rename to pkg/nonkube/cgroups/cgroups.go
diff --git a/pkg/non_kube/common/claim_redeemer.go b/pkg/nonkube/common/claim_redeemer.go
similarity index 98%
rename from pkg/non_kube/common/claim_redeemer.go
rename to pkg/nonkube/common/claim_redeemer.go
index 37826fbd..d6b49fd9 100644
--- a/pkg/non_kube/common/claim_redeemer.go
+++ b/pkg/nonkube/common/claim_redeemer.go
@@ -1,8 +1,6 @@
 package common
 
 import (
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
-
 	"bytes"
 	"crypto/tls"
 	"crypto/x509"
@@ -10,9 +8,10 @@ import (
 	"io"
 	"net/http"
 	"strings"
-
 	"time"
 
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
+
 	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
 
 	corev1 "k8s.io/api/core/v1"
diff --git a/pkg/non_kube/common/fs_config_renderer.go b/pkg/nonkube/common/fs_config_renderer.go
similarity index 95%
rename from pkg/non_kube/common/fs_config_renderer.go
rename to pkg/nonkube/common/fs_config_renderer.go
index 40b77174..744208ff 100644
--- a/pkg/non_kube/common/fs_config_renderer.go
+++ b/pkg/nonkube/common/fs_config_renderer.go
@@ -12,7 +12,7 @@ import (
 
 	"github.com/skupperproject/skupper/pkg/certs"
 	"github.com/skupperproject/skupper/pkg/config"
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
 	"github.com/skupperproject/skupper/pkg/qdr"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -50,7 +50,10 @@ const (
 
 func GetDefaultOutputPath(siteName string) string {
 	if apis.IsRunningInContainer() {
-		return path.Join("/output", "sites", siteName)
+		outputStat, err := os.Stat("/output")
+		if err == nil && outputStat.IsDir() {
+			return path.Join("/output", "sites", siteName)
+		}
 	}
 	return path.Join(apis.GetDataHome(), "sites", siteName)
 }
@@ -72,7 +75,7 @@ func (c *FileSystemConfigurationRenderer) Render(siteState *apis.SiteState) erro
 	if c.OutputPath == "" {
 		c.OutputPath = GetDefaultOutputPath(siteState.Site.Name)
 	} else {
-		_ = os.Setenv("OUTPUT_PATH", c.OutputPath)
+		_ = os.Setenv("SKUPPER_OUTPUT_PATH", c.OutputPath)
 	}
 	if c.SslProfileBasePath == "" {
 		c.SslProfileBasePath = DefaultSslProfileBasePath
@@ -135,10 +138,12 @@ func (c *FileSystemConfigurationRenderer) Render(siteState *apis.SiteState) erro
 	if platform == "kubernetes" {
 		platform = "podman"
 	}
-	content := fmt.Sprintf("platform: %s\n", string(platform))
-	err = os.WriteFile(path.Join(c.OutputPath, RuntimeSiteStatePath, "platform.yaml"), []byte(content), 0644)
-	if err != nil {
-		return fmt.Errorf("failed to write runtime platform: %w", err)
+	if !platform.IsBundle() {
+		content := fmt.Sprintf("platform: %s\n", string(platform))
+		err = os.WriteFile(path.Join(c.OutputPath, RuntimeSiteStatePath, "platform.yaml"), []byte(content), 0644)
+		if err != nil {
+			return fmt.Errorf("failed to write runtime platform: %w", err)
+		}
 	}
 
 	return nil
diff --git a/pkg/non_kube/common/site_state_loader.go b/pkg/nonkube/common/site_state_loader.go
similarity index 84%
rename from pkg/non_kube/common/site_state_loader.go
rename to pkg/nonkube/common/site_state_loader.go
index 35a14106..e0d21bf7 100644
--- a/pkg/non_kube/common/site_state_loader.go
+++ b/pkg/nonkube/common/site_state_loader.go
@@ -9,7 +9,7 @@ import (
 	"strings"
 
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -18,12 +18,17 @@ import (
 )
 
 type FileSystemSiteStateLoader struct {
-	Path string
+	Path   string
+	Bundle bool
 }
 
 func (f *FileSystemSiteStateLoader) Load() (*apis.SiteState, error) {
-	var siteState = apis.NewSiteState()
-	yamlFileNames, err := f.readAllFiles(f.Path)
+	var siteState = apis.NewSiteState(f.Bundle)
+	filter := func(filename string) bool {
+		return strings.HasSuffix(filename, ".yaml") || strings.HasSuffix(filename, ".yml")
+	}
+	dirReader := new(DirectoryReader)
+	yamlFileNames, err := dirReader.ReadDir(f.Path, filter)
 	if err != nil {
 		return nil, err
 	}
@@ -113,8 +118,11 @@ func LoadIntoSiteState(reader *bufio.Reader, siteState *apis.SiteState) error {
 	return nil
 }
 
-func (f *FileSystemSiteStateLoader) readAllFiles(inputDir string) ([]string, error) {
-	dir, err := os.Open(inputDir)
+type FilenameFilter func(string) bool
+type DirectoryReader struct{}
+
+func (f *DirectoryReader) ReadDir(dirname string, filter FilenameFilter) ([]string, error) {
+	dir, err := os.Open(dirname)
 	if err != nil {
 		return nil, err
 	}
@@ -123,7 +131,7 @@ func (f *FileSystemSiteStateLoader) readAllFiles(inputDir string) ([]string, err
 		return nil, err
 	}
 	if !dirInfo.IsDir() {
-		return nil, fmt.Errorf("%s is not a directory", inputDir)
+		return nil, fmt.Errorf("%s is not a directory", dirname)
 	}
 	files, err := dir.ReadDir(0)
 	if err != nil {
@@ -132,14 +140,14 @@ func (f *FileSystemSiteStateLoader) readAllFiles(inputDir string) ([]string, err
 	var fileNames []string
 	for _, file := range files {
 		if file.IsDir() {
-			recursiveFiles, err := f.readAllFiles(path.Join(inputDir, file.Name()))
+			recursiveFiles, err := f.ReadDir(path.Join(dirname, file.Name()), filter)
 			if err != nil {
 				return nil, err
 			}
 			fileNames = append(fileNames, recursiveFiles...)
 		} else {
-			if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") {
-				fileNames = append(fileNames, path.Join(inputDir, file.Name()))
+			if filter == nil || filter(file.Name()) {
+				fileNames = append(fileNames, path.Join(dirname, file.Name()))
 			}
 		}
 	}
diff --git a/pkg/non_kube/common/site_state_renderer_common.go b/pkg/nonkube/common/site_state_renderer_common.go
similarity index 80%
rename from pkg/non_kube/common/site_state_renderer_common.go
rename to pkg/nonkube/common/site_state_renderer_common.go
index f558ce5d..9c8785f2 100644
--- a/pkg/non_kube/common/site_state_renderer_common.go
+++ b/pkg/nonkube/common/site_state_renderer_common.go
@@ -4,13 +4,13 @@ import (
 	"fmt"
 	"maps"
 
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
 	"github.com/skupperproject/skupper/pkg/utils"
 )
 
 func CopySiteState(siteState *apis.SiteState) *apis.SiteState {
 	// Preserving loaded state
-	var activeSiteState = apis.NewSiteState()
+	var activeSiteState = apis.NewSiteState(siteState.IsBundle())
 	siteState.Site.DeepCopyInto(activeSiteState.Site)
 	activeSiteState.SiteId = siteState.SiteId
 	activeSiteState.Listeners = maps.Clone(siteState.Listeners)
@@ -28,9 +28,13 @@ func CopySiteState(siteState *apis.SiteState) *apis.SiteState {
 func CreateRouterAccess(siteState *apis.SiteState) error {
 	if !siteState.HasRouterAccess() {
 		name := fmt.Sprintf("skupper-local")
-		port, err := utils.TcpPortNextFree(5671)
-		if err != nil {
-			return err
+		var port = 5671
+		var err error
+		if !siteState.IsBundle() {
+			port, err = utils.TcpPortNextFree(port)
+			if err != nil {
+				return err
+			}
 		}
 		siteState.CreateRouterAccess(name, port)
 	}
diff --git a/pkg/non_kube/common/site_state_validator.go b/pkg/nonkube/common/site_state_validator.go
similarity index 99%
rename from pkg/non_kube/common/site_state_validator.go
rename to pkg/nonkube/common/site_state_validator.go
index ec6e49c5..28990ac5 100644
--- a/pkg/non_kube/common/site_state_validator.go
+++ b/pkg/nonkube/common/site_state_validator.go
@@ -6,7 +6,7 @@ import (
 	"regexp"
 
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
 	"github.com/skupperproject/skupper/pkg/utils"
 	corev1 "k8s.io/api/core/v1"
 )
diff --git a/pkg/non_kube/common/startsh-container.template b/pkg/nonkube/common/startsh-container.template
similarity index 61%
rename from pkg/non_kube/common/startsh-container.template
rename to pkg/nonkube/common/startsh-container.template
index 6ff882de..4d053a3c 100644
--- a/pkg/non_kube/common/startsh-container.template
+++ b/pkg/nonkube/common/startsh-container.template
@@ -3,7 +3,7 @@
 set -o errexit
 set -o nounset
 
-for container in $({{.SkupperPlatform}} container ls --filter label=application=skupper --filter label=skupper.io/site-id={{.SiteId}} --format {{"{{"}}.Names{{"}}"}} --all); do
-    {{.SkupperPlatform}} start ${container}
+for container in $({{.ContainerEngine}} container ls --filter label=application=skupper --filter label=skupper.io/site-id={{.SiteId}} --format {{"{{"}}.Names{{"}}"}} --all); do
+    {{.ContainerEngine}} start ${container}
 done
 
diff --git a/pkg/non_kube/common/startup.go b/pkg/nonkube/common/startup.go
similarity index 90%
rename from pkg/non_kube/common/startup.go
rename to pkg/nonkube/common/startup.go
index ef9eda84..bb62d5cb 100644
--- a/pkg/non_kube/common/startup.go
+++ b/pkg/nonkube/common/startup.go
@@ -10,7 +10,7 @@ import (
 
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
 	"github.com/skupperproject/skupper/pkg/config"
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
 )
 
 var (
@@ -35,6 +35,7 @@ type startupScripts struct {
 	Site            *v1alpha1.Site
 	SiteId          string
 	SkupperPlatform string
+	ContainerEngine string
 	path            string
 }
 
@@ -48,10 +49,14 @@ func GetStartupScripts(site *v1alpha1.Site, siteId string) (StartupScript, error
 	}
 
 	platform := config.GetPlatform()
-	if !platform.IsContainerEngine() {
+	if !platform.IsContainerEngine() && !platform.IsBundle() {
 		return nil, fmt.Errorf("startup scripts can only be used with podman or docker platforms")
 	}
 	scripts.SkupperPlatform = string(platform)
+	scripts.ContainerEngine = scripts.SkupperPlatform
+	if platform.IsBundle() {
+		scripts.ContainerEngine = "{{.ContainerEngine}}"
+	}
 	siteHome, err := apis.GetHostSiteHome(site)
 	if err != nil {
 		return nil, err
diff --git a/pkg/non_kube/common/startup_test.go b/pkg/nonkube/common/startup_test.go
similarity index 100%
rename from pkg/non_kube/common/startup_test.go
rename to pkg/nonkube/common/startup_test.go
diff --git a/pkg/non_kube/common/stopsh-container.template b/pkg/nonkube/common/stopsh-container.template
similarity index 59%
rename from pkg/non_kube/common/stopsh-container.template
rename to pkg/nonkube/common/stopsh-container.template
index 7fdd35dd..5c71118f 100644
--- a/pkg/non_kube/common/stopsh-container.template
+++ b/pkg/nonkube/common/stopsh-container.template
@@ -3,6 +3,6 @@
 set -o errexit
 set -o nounset
 
-for container in $({{.SkupperPlatform}} container ls --filter label=application=skupper --filter label=skupper.io/site-id={{.SiteId}} --format {{"{{"}}.Names{{"}}"}} --all); do
-    {{.SkupperPlatform}} stop -t 10 ${container}
+for container in $({{.ContainerEngine}} container ls --filter label=application=skupper --filter label=skupper.io/site-id={{.SiteId}} --format {{"{{"}}.Names{{"}}"}} --all); do
+    {{.ContainerEngine}} stop -t 10 ${container}
 done
diff --git a/pkg/non_kube/common/systemd.go b/pkg/nonkube/common/systemd.go
similarity index 99%
rename from pkg/non_kube/common/systemd.go
rename to pkg/nonkube/common/systemd.go
index 0fa6e7e3..b95daa2c 100644
--- a/pkg/non_kube/common/systemd.go
+++ b/pkg/nonkube/common/systemd.go
@@ -13,7 +13,7 @@ import (
 	"github.com/skupperproject/skupper/api/types"
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
 	"github.com/skupperproject/skupper/pkg/config"
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
 )
 
 var (
diff --git a/pkg/non_kube/compat/site_state_renderer.go b/pkg/nonkube/compat/site_state_renderer.go
similarity index 96%
rename from pkg/non_kube/compat/site_state_renderer.go
rename to pkg/nonkube/compat/site_state_renderer.go
index 06069aed..47962b82 100644
--- a/pkg/non_kube/compat/site_state_renderer.go
+++ b/pkg/nonkube/compat/site_state_renderer.go
@@ -8,11 +8,11 @@ import (
 	"time"
 
 	"github.com/skupperproject/skupper/api/types"
-	internalclient "github.com/skupperproject/skupper/internal/non-kube/client/compat"
+	internalclient "github.com/skupperproject/skupper/internal/nonkube/client/compat"
 	"github.com/skupperproject/skupper/pkg/container"
 	"github.com/skupperproject/skupper/pkg/images"
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
-	"github.com/skupperproject/skupper/pkg/non_kube/common"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/common"
 	"github.com/skupperproject/skupper/pkg/utils"
 )
 
@@ -118,7 +118,7 @@ func (s *SiteStateRenderer) prepareContainers() error {
 		},
 		RestartPolicy: "always",
 		// TODO handle resource utilization with container sites
-		//      use pkg.non_kube.cgroups.CgroupControllers to
+		//      use pkg.nonkube.cgroups.CgroupControllers to
 		//      validate whether CPU and memory thresholds can be
 		//      set to the container
 	}
diff --git a/pkg/non_kube/systemd/site_state_renderer.go b/pkg/nonkube/systemd/site_state_renderer.go
similarity index 95%
rename from pkg/non_kube/systemd/site_state_renderer.go
rename to pkg/nonkube/systemd/site_state_renderer.go
index 17ab2adc..5dcf641a 100644
--- a/pkg/non_kube/systemd/site_state_renderer.go
+++ b/pkg/nonkube/systemd/site_state_renderer.go
@@ -4,8 +4,8 @@ import (
 	"fmt"
 	"os"
 
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
-	"github.com/skupperproject/skupper/pkg/non_kube/common"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/common"
 	"github.com/skupperproject/skupper/pkg/utils"
 )
 


Test patch:
diff --git a/internal/cmd/skupper/connector/kube/connector_create_test.go b/internal/cmd/skupper/connector/kube/connector_create_test.go
index 06041fc1..cc6c79fa 100644
--- a/internal/cmd/skupper/connector/kube/connector_create_test.go
+++ b/internal/cmd/skupper/connector/kube/connector_create_test.go
@@ -910,7 +910,6 @@ func TestCmdConnectorCreate_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		cmd, err := newCmdConnectorCreateWithMocks("test", test.k8sObjects, test.skupperObjects, test.skupperErrorMessage)
 		assert.Assert(t, err)
 
@@ -920,7 +919,6 @@ func TestCmdConnectorCreate_WaitUntil(t *testing.T) {
 		cmd.namespace = "test"
 
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
 
 			err := cmd.WaitUntil()
 			if test.expectError {
diff --git a/internal/cmd/skupper/connector/kube/connector_update_test.go b/internal/cmd/skupper/connector/kube/connector_update_test.go
index 627ae74d..2e930d2f 100644
--- a/internal/cmd/skupper/connector/kube/connector_update_test.go
+++ b/internal/cmd/skupper/connector/kube/connector_update_test.go
@@ -574,7 +574,6 @@ func TestCmdConnectorUpdate_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		cmd, err := newCmdConnectorUpdateWithMocks("test", test.k8sObjects, test.skupperObjects, test.skupperErrorMessage)
 		assert.Assert(t, err)
 
@@ -587,7 +586,7 @@ func TestCmdConnectorUpdate_WaitUntil(t *testing.T) {
 		cmd.newSettings.output = cmd.flags.output
 
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
+
 			err := cmd.WaitUntil()
 			if test.expectError {
 				assert.Check(t, err != nil)
diff --git a/internal/cmd/skupper/link/kube/link_delete_test.go b/internal/cmd/skupper/link/kube/link_delete_test.go
index 74ed3de7..84bfad6f 100644
--- a/internal/cmd/skupper/link/kube/link_delete_test.go
+++ b/internal/cmd/skupper/link/kube/link_delete_test.go
@@ -2,8 +2,6 @@ package kube
 
 import (
 	"fmt"
-	"testing"
-
 	"github.com/skupperproject/skupper/internal/cmd/skupper/utils"
 	fakeclient "github.com/skupperproject/skupper/internal/kube/client/fake"
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
@@ -11,6 +9,7 @@ import (
 	"gotest.tools/assert"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
+	"testing"
 )
 
 func TestCmdLinkDelete_NewCmdLinkDelete(t *testing.T) {
@@ -369,13 +368,12 @@ func TestCmdLinkDelete_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		cmd, err := newCmdLinkDeleteWithMocks("test", nil, test.skupperObjects, "")
 		assert.Assert(t, err)
 		cmd.linkName = "my-link"
 		cmd.timeout = test.timeout
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
+
 			err := cmd.WaitUntil()
 			if err != nil {
 				assert.Check(t, test.expectError)
diff --git a/internal/cmd/skupper/link/kube/link_generate_test.go b/internal/cmd/skupper/link/kube/link_generate_test.go
index 481b05ef..b76ae04b 100644
--- a/internal/cmd/skupper/link/kube/link_generate_test.go
+++ b/internal/cmd/skupper/link/kube/link_generate_test.go
@@ -2,8 +2,6 @@ package kube
 
 import (
 	"fmt"
-	"testing"
-
 	"github.com/skupperproject/skupper/internal/cmd/skupper/utils"
 	fakeclient "github.com/skupperproject/skupper/internal/kube/client/fake"
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
@@ -13,6 +11,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
+	"testing"
 )
 
 func TestCmdLinkGenerate_NewCmdLinkGenerate(t *testing.T) {
@@ -952,12 +951,11 @@ func TestCmdLinkGenerate_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		command, err := newCmdLinkGenerateWithMocks("test", test.k8sObjects, test.skupperObjects, test.skupperError)
 		assert.Assert(t, err)
 
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
+
 			command.output = test.outputType
 			command.generateCredential = test.generateCredential
 			command.tlsSecret = test.tlsSecret
diff --git a/internal/cmd/skupper/link/kube/link_update_test.go b/internal/cmd/skupper/link/kube/link_update_test.go
index 533f9448..1689be24 100644
--- a/internal/cmd/skupper/link/kube/link_update_test.go
+++ b/internal/cmd/skupper/link/kube/link_update_test.go
@@ -2,8 +2,6 @@ package kube
 
 import (
 	"fmt"
-	"testing"
-
 	"github.com/skupperproject/skupper/internal/cmd/skupper/utils"
 	fakeclient "github.com/skupperproject/skupper/internal/kube/client/fake"
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
@@ -12,6 +10,7 @@ import (
 	v12 "k8s.io/api/core/v1"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
+	"testing"
 )
 
 func TestCmdLinkUpdate_NewCmdLinkUpdate(t *testing.T) {
@@ -608,7 +607,6 @@ func TestCmdLinkUpdate_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		cmd, err := newCmdLinkUpdateWithMocks("test", test.k8sObjects, test.skupperObjects, test.skupperErrorMessage)
 		assert.Assert(t, err)
 		cmd.linkName = test.linkName
@@ -616,7 +614,7 @@ func TestCmdLinkUpdate_WaitUntil(t *testing.T) {
 		cmd.timeout = test.timeout
 
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
+
 			err := cmd.WaitUntil()
 
 			if test.expectError {
diff --git a/internal/cmd/skupper/listener/kube/listener_create_test.go b/internal/cmd/skupper/listener/kube/listener_create_test.go
index 1a25c6f5..6e611a82 100644
--- a/internal/cmd/skupper/listener/kube/listener_create_test.go
+++ b/internal/cmd/skupper/listener/kube/listener_create_test.go
@@ -780,7 +780,6 @@ func TestCmdListenerCreate_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		cmd, err := newCmdListenerCreateWithMocks("test", test.k8sObjects, test.skupperObjects, test.skupperErrorMessage)
 		assert.Assert(t, err)
 
@@ -794,7 +793,6 @@ func TestCmdListenerCreate_WaitUntil(t *testing.T) {
 		cmd.namespace = "test"
 
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
 
 			err := cmd.WaitUntil()
 			if test.expectError {
diff --git a/internal/cmd/skupper/listener/kube/listener_update_test.go b/internal/cmd/skupper/listener/kube/listener_update_test.go
index 908b9934..7f0509fc 100644
--- a/internal/cmd/skupper/listener/kube/listener_update_test.go
+++ b/internal/cmd/skupper/listener/kube/listener_update_test.go
@@ -507,7 +507,6 @@ func TestCmdListenerUpdate_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		cmd, err := newCmdListenerUpdateWithMocks("test", test.k8sObjects, test.skupperObjects, test.skupperErrorMessage)
 		assert.Assert(t, err)
 
@@ -520,7 +519,6 @@ func TestCmdListenerUpdate_WaitUntil(t *testing.T) {
 		cmd.newSettings.output = cmd.flags.output
 
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
 			err := cmd.WaitUntil()
 			if test.expectError {
 				assert.Check(t, err != nil)
diff --git a/internal/cmd/skupper/site/kube/site_create_test.go b/internal/cmd/skupper/site/kube/site_create_test.go
index c64ab138..9064b591 100644
--- a/internal/cmd/skupper/site/kube/site_create_test.go
+++ b/internal/cmd/skupper/site/kube/site_create_test.go
@@ -2,7 +2,6 @@ package kube
 
 import (
 	"fmt"
-
 	"github.com/skupperproject/skupper/internal/cmd/skupper/utils"
 
 	"testing"
@@ -421,7 +420,6 @@ func TestCmdSiteCreate_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		command := &CmdSiteCreate{
 			Namespace: "test",
 		}
@@ -433,7 +431,6 @@ func TestCmdSiteCreate_WaitUntil(t *testing.T) {
 		command.output = test.output
 
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
 
 			err := command.WaitUntil()
 
diff --git a/internal/cmd/skupper/site/kube/site_delete_test.go b/internal/cmd/skupper/site/kube/site_delete_test.go
index bd12bdcb..909e7038 100644
--- a/internal/cmd/skupper/site/kube/site_delete_test.go
+++ b/internal/cmd/skupper/site/kube/site_delete_test.go
@@ -355,7 +355,6 @@ func TestCmdSiteDelete_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		command := &CmdSiteDelete{
 			Namespace: "test",
 		}
@@ -365,7 +364,7 @@ func TestCmdSiteDelete_WaitUntil(t *testing.T) {
 		command.Client = fakeSkupperClient.GetSkupperClient().SkupperV1alpha1()
 		command.siteName = "my-site"
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
+
 			err := command.WaitUntil()
 			if err != nil {
 				assert.Check(t, test.expectError)
diff --git a/internal/cmd/skupper/site/kube/site_update_test.go b/internal/cmd/skupper/site/kube/site_update_test.go
index 4f9405f1..68ba1dd3 100644
--- a/internal/cmd/skupper/site/kube/site_update_test.go
+++ b/internal/cmd/skupper/site/kube/site_update_test.go
@@ -596,7 +596,6 @@ func TestCmdSiteUpdate_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		command := &CmdSiteUpdate{
 			Namespace: "test",
 		}
@@ -607,7 +606,6 @@ func TestCmdSiteUpdate_WaitUntil(t *testing.T) {
 		command.siteName = test.siteName
 
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
 
 			err := command.WaitUntil()
 			if err != nil {
diff --git a/internal/cmd/skupper/token/kube/token_issue_test.go b/internal/cmd/skupper/token/kube/token_issue_test.go
index 37d3d3fe..f69b69b0 100644
--- a/internal/cmd/skupper/token/kube/token_issue_test.go
+++ b/internal/cmd/skupper/token/kube/token_issue_test.go
@@ -712,7 +712,6 @@ func TestCmdTokenIssue_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		cmd, err := newCmdTokenIssueWithMocks("test", test.k8sObjects, test.skupperObjects, test.skupperErrorMessage)
 		assert.Assert(t, err)
 
@@ -726,7 +725,6 @@ func TestCmdTokenIssue_WaitUntil(t *testing.T) {
 		cmd.namespace = "test"
 
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
 			err := cmd.WaitUntil()
 			if test.expectError {
 				assert.Check(t, err != nil)
diff --git a/internal/cmd/skupper/token/kube/token_redeem_test.go b/internal/cmd/skupper/token/kube/token_redeem_test.go
index 43139931..1f1d9e31 100644
--- a/internal/cmd/skupper/token/kube/token_redeem_test.go
+++ b/internal/cmd/skupper/token/kube/token_redeem_test.go
@@ -417,7 +417,6 @@ func TestCmdTokenRedeem_WaitUntil(t *testing.T) {
 	}
 
 	for _, test := range testTable {
-		test := test
 		cmd, err := newCmdTokenRedeemWithMocks("test", test.k8sObjects, test.skupperObjects, test.skupperErrorMessage)
 		assert.Assert(t, err)
 
@@ -427,7 +426,6 @@ func TestCmdTokenRedeem_WaitUntil(t *testing.T) {
 		cmd.namespace = "test"
 
 		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
 			err := cmd.WaitUntil()
 			if test.expectError {
 				assert.Check(t, err != nil)
diff --git a/internal/non-kube/client/compat/image_test.go b/internal/non-kube/client/compat/image_test.go
deleted file mode 100644
index 00fe4e6d..00000000
--- a/internal/non-kube/client/compat/image_test.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package compat
-
-import (
-	"encoding/json"
-	"testing"
-
-	"gotest.tools/assert"
-)
-
-func TestImageList(t *testing.T) {
-	cli, err := NewCompatClient("", "")
-	assert.Assert(t, err)
-	images, err := cli.ImageList()
-	assert.Assert(t, err)
-	for _, image := range images {
-		imageJson, _ := json.MarshalIndent(image, "", "  ")
-		t.Log(string(imageJson))
-	}
-}
diff --git a/internal/nonkube/bundle/extract_test.go b/internal/nonkube/bundle/extract_test.go
new file mode 100644
index 00000000..f9d17372
--- /dev/null
+++ b/internal/nonkube/bundle/extract_test.go
@@ -0,0 +1,164 @@
+package bundle
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path"
+	"strings"
+	"testing"
+
+	"github.com/skupperproject/skupper/internal/utils"
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
+	"gotest.tools/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestSelfExtractingBundle_InstallFile(t *testing.T) {
+	b := &SelfExtractingBundle{
+		SiteName:   "my-site",
+		OutputPath: "/tmp",
+	}
+	assert.Equal(t, b.InstallFile(), "/tmp/skupper-install-my-site.sh")
+}
+
+// TestSelfExtractingBundle_Generate unit test the self extract generate
+// only, not producing a valid bundle. But it validates if the self-extracting
+// bundle is produced and that it is an executable.
+func TestSelfExtractingBundle_Generate(t *testing.T) {
+	var cleanupPaths []string
+	b := &SelfExtractingBundle{
+		SiteName:   "my-site",
+		OutputPath: "/tmp",
+	}
+	// cleanup function
+	defer func() {
+		var errors []error
+		appendError := func(e error) {
+			if e == nil {
+				return
+			}
+			errors = append(errors, e)
+		}
+		for _, cleanupPath := range cleanupPaths {
+			appendError(os.RemoveAll(cleanupPath))
+		}
+		assert.Equal(t, len(errors), 0, "No errors expected during cleanup, but found: %v", errors)
+	}()
+	var sitePath string
+	var err error
+
+	t.Run("generate-fake-crs", func(t *testing.T) {
+		sitePath, err = fakeSiteCrs(true)
+		assert.Assert(t, err)
+		cleanupPaths = append(cleanupPaths, sitePath)
+	})
+
+	t.Run("generate-self-extracting-bundle", func(t *testing.T) {
+		tb := utils.NewTarball()
+		assert.Assert(t, tb.AddFiles(sitePath))
+		assert.Assert(t, b.Generate(tb))
+		cleanupPaths = append(cleanupPaths, b.InstallFile())
+	})
+
+	t.Run("validate-install-file", func(t *testing.T) {
+		installFileStat, err := os.Stat(b.InstallFile())
+		assert.Assert(t, err)
+		assert.Assert(t, installFileStat.Mode().IsRegular())
+		assert.Assert(t, installFileStat.Mode().Perm() == os.FileMode(0755))
+
+		helpCommand := exec.Command(b.InstallFile(), "-h")
+		output, err := helpCommand.CombinedOutput()
+		assert.Assert(t, err != nil)
+		assert.Assert(t, strings.Contains(string(output), fmt.Sprintf("Usage: %s", b.InstallFile())))
+	})
+
+}
+
+func fakeSiteCrs(routerAccess bool) (string, error) {
+	tempDir, err := os.MkdirTemp("", "fakecrs.*")
+	if err != nil {
+		return "", err
+	}
+	ss := &apis.SiteState{
+		Site: &v1alpha1.Site{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: "skupper.io/v1alpha1",
+				Kind:       "Site",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "my-site",
+			},
+		},
+		Connectors: map[string]*v1alpha1.Connector{
+			"my-backend": {
+				TypeMeta: metav1.TypeMeta{
+					APIVersion: "skupper.io/v1alpha1",
+					Kind:       "Connector",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "my-backend",
+				},
+				Spec: v1alpha1.ConnectorSpec{
+					RoutingKey: "my-backend",
+					Host:       "127.0.0.1",
+					Port:       8080,
+				},
+			},
+		},
+		Listeners: map[string]*v1alpha1.Listener{
+			"my-listener": {
+				TypeMeta: metav1.TypeMeta{
+					APIVersion: "skupper.io/v1alpha1",
+					Kind:       "Listener",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "my-listener",
+				},
+				Spec: v1alpha1.ListenerSpec{
+					RoutingKey: "my-listener",
+					Host:       "127.0.0.1",
+					Port:       9090,
+				},
+			},
+		},
+	}
+	if routerAccess {
+		ss.RouterAccesses = map[string]*v1alpha1.RouterAccess{
+			"default": {
+				TypeMeta: metav1.TypeMeta{
+					APIVersion: "skupper.io/v1alpha1",
+					Kind:       "RouterAccess",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "default",
+				},
+				Spec: v1alpha1.RouterAccessSpec{
+					Roles: []v1alpha1.RouterAccessRole{
+						{
+							Name: "inter-router",
+							Port: 55671,
+						},
+						{
+							Name: "edge",
+							Port: 45671,
+						},
+					},
+					BindHost: "127.0.0.1",
+					SubjectAlternativeNames: []string{
+						"localhost",
+						"127.0.0.1",
+					},
+				},
+			},
+		}
+	}
+	siteDir := path.Join(tempDir, ss.Site.ObjectMeta.Name)
+	err = os.MkdirAll(siteDir, 0755)
+	if err != nil {
+		return tempDir, err
+	}
+	err = apis.MarshalSiteState(*ss, siteDir)
+	return tempDir, err
+}
diff --git a/internal/nonkube/bundle/tarball_test.go b/internal/nonkube/bundle/tarball_test.go
new file mode 100644
index 00000000..b77ba74f
--- /dev/null
+++ b/internal/nonkube/bundle/tarball_test.go
@@ -0,0 +1,85 @@
+package bundle
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path"
+	"strings"
+	"testing"
+
+	"github.com/skupperproject/skupper/internal/utils"
+	"gotest.tools/assert"
+)
+
+func TestTarballBundle_InstallFile(t *testing.T) {
+	b := &TarballBundle{
+		SiteName:   "my-site",
+		OutputPath: "/tmp",
+	}
+	assert.Equal(t, b.InstallFile(), "/tmp/skupper-install-my-site.tar.gz")
+}
+
+// TestTarballBundle_Generate unit test the tar ball generation
+// only, not producing a valid tarball bundle. But it validates
+// if the produced tarball is valid and that it contains an
+// installation script.
+func TestTarballBundle_Generate(t *testing.T) {
+	var cleanupPaths []string
+	b := &TarballBundle{
+		SiteName:   "my-site",
+		OutputPath: "/tmp",
+	}
+	// cleanup function
+	defer func() {
+		var errors []error
+		appendError := func(e error) {
+			if e == nil {
+				return
+			}
+			errors = append(errors, e)
+		}
+		for _, cleanupPath := range cleanupPaths {
+			appendError(os.RemoveAll(cleanupPath))
+		}
+		assert.Equal(t, len(errors), 0, "No errors expected during cleanup, but found: %v", errors)
+	}()
+	var sitePath string
+	var extractPath string
+	var err error
+	tb := utils.NewTarball()
+
+	t.Run("generate-fake-crs", func(t *testing.T) {
+		sitePath, err = fakeSiteCrs(true)
+		assert.Assert(t, err)
+		cleanupPaths = append(cleanupPaths, sitePath)
+	})
+
+	t.Run("generate-tarball-bundle", func(t *testing.T) {
+		assert.Assert(t, tb.AddFiles(sitePath))
+		assert.Assert(t, b.Generate(tb))
+		cleanupPaths = append(cleanupPaths, b.InstallFile())
+	})
+
+	t.Run("validate-tarball-bundle", func(t *testing.T) {
+		installFileStat, err := os.Stat(b.InstallFile())
+		assert.Assert(t, err)
+		assert.Assert(t, installFileStat.Mode().IsRegular())
+		assert.Assert(t, installFileStat.Mode().Perm() == os.FileMode(0644))
+		extractPath, err = os.MkdirTemp("", "tarballbundle.*")
+		assert.Assert(t, err)
+		cleanupPaths = append(cleanupPaths, extractPath)
+	})
+
+	t.Run("validate-tarball-content", func(t *testing.T) {
+		assert.Assert(t, tb.Extract(b.InstallFile(), extractPath))
+		installFile := path.Join(extractPath, "install.sh")
+		helpCommand := exec.Command(installFile, "-h")
+		output, err := helpCommand.CombinedOutput()
+		assert.Assert(t, err != nil)
+		assert.Assert(t, strings.Contains(string(output), fmt.Sprintf("Usage: %s", installFile)))
+		mySiteDir, err := os.Stat(path.Join(extractPath, "my-site"))
+		assert.Assert(t, err)
+		assert.Assert(t, mySiteDir.Mode().IsDir())
+	})
+}
diff --git a/internal/non-kube/client/compat/container_test.go b/internal/nonkube/client/compat/container_test.go
similarity index 100%
rename from internal/non-kube/client/compat/container_test.go
rename to internal/nonkube/client/compat/container_test.go
diff --git a/internal/non-kube/client/compat/image.go b/internal/nonkube/client/compat/image.go
similarity index 100%
rename from internal/non-kube/client/compat/image.go
rename to internal/nonkube/client/compat/image.go
diff --git a/internal/nonkube/client/compat/image_test.go b/internal/nonkube/client/compat/image_test.go
new file mode 100644
index 00000000..5bae957f
--- /dev/null
+++ b/internal/nonkube/client/compat/image_test.go
@@ -0,0 +1,28 @@
+package compat
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+
+	"github.com/skupperproject/skupper/pkg/container"
+	"gotest.tools/assert"
+)
+
+func TestImageMock(t *testing.T) {
+	imagesPulled := 0
+	cli := NewCompatClientMock([]*container.Container{})
+	t.Run("image-pull", func(t *testing.T) {
+		assert.Assert(t, cli.ImagePull(context.Background(), "quay.io/skupper/skupper-router:main"))
+		imagesPulled += 1
+	})
+	t.Run("image-list", func(t *testing.T) {
+		images, err := cli.ImageList()
+		assert.Assert(t, err)
+		assert.Assert(t, len(images) == imagesPulled)
+		for _, image := range images {
+			imageJson, _ := json.MarshalIndent(image, "", "  ")
+			t.Log(string(imageJson))
+		}
+	})
+}
diff --git a/internal/utils/command_test.go b/internal/utils/command_test.go
new file mode 100644
index 00000000..8ceda65e
--- /dev/null
+++ b/internal/utils/command_test.go
@@ -0,0 +1,32 @@
+package utils
+
+import (
+	"testing"
+
+	"gotest.tools/assert"
+)
+
+const (
+	expectedOutput = `sample_command argument1 argument2 argument3 argument4 argument5 argument6 argument7 \
+    argument8 argument9 argument10 argument11 argument12 argument13 argument14 argument15 \
+    argument16 argument17 argument18 argument19 argument20 argument21 argument22 argument23 \
+    argument24 argument25 argument26 argument27 argument28 argument29
+`
+)
+
+var (
+	commandArgs = []string{
+		"sample_command", "argument1", "argument2", "argument3", "argument4", "argument5",
+		"argument6", "argument7", "argument8", "argument9", "argument10", "argument11", "argument12",
+		"argument13", "argument14", "argument15", "argument16", "argument17", "argument18",
+		"argument19", "argument20", "argument21", "argument22", "argument23", "argument24",
+		"argument25", "argument26", "argument27", "argument28", "argument29",
+	}
+)
+
+func TestPrettyPrintCommand(t *testing.T) {
+	output := PrettyPrintCommand(commandArgs[0], commandArgs[1:])
+	assert.Equal(t, expectedOutput, output)
+	shortCommand := PrettyPrintCommand("command", []string{"arg1", "arg2", "arg3"})
+	assert.Equal(t, "command arg1 arg2 arg3\n", shortCommand)
+}
diff --git a/internal/utils/tarball_test.go b/internal/utils/tarball_test.go
new file mode 100644
index 00000000..023a52cd
--- /dev/null
+++ b/internal/utils/tarball_test.go
@@ -0,0 +1,198 @@
+package utils
+
+import (
+	"fmt"
+	"math"
+	"os"
+	"path"
+	"strings"
+	"testing"
+
+	"github.com/skupperproject/skupper/pkg/nonkube/common"
+	"gotest.tools/assert"
+)
+
+func TestTarball(t *testing.T) {
+	const testFileContent = "test data"
+
+	for _, tc := range []struct {
+		description string
+		files       int
+		directories int
+		levels      int
+	}{
+		{
+			description: "only-files",
+			files:       10,
+			directories: 0,
+			levels:      1,
+		},
+		{
+			description: "files-and-directories",
+			files:       10,
+			directories: 3,
+			levels:      1,
+		},
+		{
+			description: "files-and-directories-multiple-levels",
+			files:       10,
+			directories: 3,
+			levels:      3,
+		},
+	} {
+		t.Run(tc.description, func(t *testing.T) {
+			var cleanupList []string
+			tree := generateDirectoryTree(tc.directories, tc.levels)
+			baseDir, err := os.MkdirTemp("", "testtarball.*")
+			assert.Assert(t, err)
+			defer func() {
+				assert.Assert(t, os.RemoveAll(baseDir))
+				for _, fileOrDir := range cleanupList {
+					assert.Assert(t, os.RemoveAll(fileOrDir))
+				}
+			}()
+			// Generating files and asserting generation was successful
+			err = createFiles(baseDir, tc.files, []byte(testFileContent), tree)
+			assert.Assert(t, err, "unable to create files")
+			generatedFilesExpected := tc.files * (len(tree) + 1)
+			dirReader := new(common.DirectoryReader)
+			filesFound, err := dirReader.ReadDir(baseDir, nil)
+			assert.Assert(t, err)
+			assert.Equal(t, generatedFilesExpected, len(filesFound))
+			for _, file := range filesFound {
+				data, err := os.ReadFile(file)
+				assert.Assert(t, err)
+				assert.Equal(t, string(data), testFileContent)
+			}
+			var savedData, savedDataExtra []byte
+			var savedFile = baseDir + ".tar.gz"
+			var savedFileExtra = baseDir + "-extra.tar.gz"
+			t.Run(tc.description+"-SaveData", func(t *testing.T) {
+				// Compressing generated files
+				tb := NewTarball()
+				assert.Assert(t, tb != nil)
+				assert.Assert(t, tb.AddFiles(baseDir))
+				savedData, err = tb.SaveData()
+				assert.Assert(t, err)
+				assert.Assert(t, len(savedData) > 0)
+			})
+			t.Run(tc.description+"-Save", func(t *testing.T) {
+				// Compressing generated files
+				tb := NewTarball()
+				assert.Assert(t, tb != nil)
+				assert.Assert(t, tb.AddFiles(baseDir))
+				err = tb.Save(savedFile)
+				assert.Assert(t, err)
+				cleanupList = append(cleanupList, savedFile)
+				savedFileStat, err := os.Stat(savedFile)
+				assert.Assert(t, err)
+				assert.Assert(t, savedFileStat.Size() == int64(len(savedData)))
+			})
+			t.Run(tc.description+"-AddFileData-SaveData", func(t *testing.T) {
+				// Compressing generated files adding an extra file
+				tb := NewTarball()
+				assert.Assert(t, tb != nil)
+				assert.Assert(t, tb.AddFiles(baseDir))
+				assert.Assert(t, tb.AddFileData("sample.file", 0755, []byte(testFileContent)))
+				savedDataExtra, err = tb.SaveData()
+				assert.Assert(t, err)
+				assert.Assert(t, len(savedDataExtra) > 0)
+			})
+			t.Run(tc.description+"-AddFileData-Save", func(t *testing.T) {
+				// Compressing generated files adding an extra file
+				tb := NewTarball()
+				assert.Assert(t, tb != nil)
+				assert.Assert(t, tb.AddFiles(baseDir))
+				assert.Assert(t, tb.AddFileData("sample.file", 0755, []byte(testFileContent)))
+				err = tb.Save(savedFileExtra)
+				assert.Assert(t, err)
+				cleanupList = append(cleanupList, savedFileExtra)
+				savedFileStat, err := os.Stat(savedFileExtra)
+				assert.Assert(t, err)
+				assert.Assert(t, savedFileStat.Size() == int64(len(savedDataExtra)))
+				assert.Assert(t, len(savedDataExtra) > len(savedData))
+			})
+			t.Run(tc.description+"-Uncompress", func(t *testing.T) {
+				baseDirCopy := baseDir + ".copy"
+				tb := NewTarball()
+				err = tb.Extract(savedFile, baseDirCopy)
+				assert.Assert(t, err)
+				cleanupList = append(cleanupList, baseDirCopy)
+				filesFoundCopy, err := dirReader.ReadDir(baseDirCopy, nil)
+				assert.Assert(t, err)
+				assert.Equal(t, generatedFilesExpected, len(filesFoundCopy))
+				for _, file := range filesFoundCopy {
+					data, err := os.ReadFile(file)
+					assert.Assert(t, err)
+					assert.Equal(t, string(data), testFileContent)
+				}
+			})
+			t.Run(tc.description+"-UncompressExtra", func(t *testing.T) {
+				baseDirExtra := baseDir + ".extra"
+				tb := NewTarball()
+				err = tb.Extract(savedFileExtra, baseDirExtra)
+				assert.Assert(t, err)
+				cleanupList = append(cleanupList, baseDirExtra)
+				filesFoundExtra, err := dirReader.ReadDir(baseDirExtra, nil)
+				assert.Assert(t, err)
+				assert.Equal(t, generatedFilesExpected+1, len(filesFoundExtra))
+				for _, file := range filesFoundExtra {
+					data, err := os.ReadFile(file)
+					assert.Assert(t, err)
+					assert.Equal(t, string(data), testFileContent)
+					if strings.HasSuffix(file, "sample.file") {
+						extraFileStat, err := os.Stat(file)
+						assert.Assert(t, err)
+						assert.Assert(t, extraFileStat.Mode() == os.FileMode(0755))
+					}
+				}
+			})
+		})
+	}
+}
+
+// createFiles iterates through the directory "tree" list,
+// creating the given amount of "files" using the provided
+// "content" into the "baseDir".
+func createFiles(baseDir string, files int, content []byte, tree []string) error {
+	tree = append(tree, "")
+	for _, dir := range tree {
+		err := os.MkdirAll(path.Join(baseDir, dir), 0755)
+		if err != nil {
+			return err
+		}
+		for i := 1; i <= files; i++ {
+			filename := path.Join(baseDir, dir, fmt.Sprintf("file.%d", i))
+			err = os.WriteFile(filename, content, 0644)
+			if err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func generateDirectoryTree(dirs, levels int) []string {
+	var tree []string
+	indexSum := func(level int) int {
+		sum := 0
+		for l := level; l >= 1; l-- {
+			sum += int(math.Pow(float64(dirs), float64(l)))
+		}
+		return sum
+	}
+	for level := 1; level <= levels; level++ {
+		var baseDirs = []string{""}
+		if level > 1 {
+			initialIdx := indexSum(level - 2)
+			finalIdx := indexSum(level - 1)
+			baseDirs = tree[initialIdx:finalIdx]
+		}
+		for _, baseDir := range baseDirs {
+			for dir := 1; dir <= dirs; dir++ {
+				tree = append(tree, path.Join(baseDir, fmt.Sprintf("dir%d", dir)))
+			}
+		}
+	}
+	return tree
+}
diff --git a/pkg/non_kube/apis/site_state_test.go b/pkg/nonkube/apis/site_state_test.go
similarity index 78%
rename from pkg/non_kube/apis/site_state_test.go
rename to pkg/nonkube/apis/site_state_test.go
index aeaaf2f2..938f9742 100644
--- a/pkg/non_kube/apis/site_state_test.go
+++ b/pkg/nonkube/apis/site_state_test.go
@@ -60,7 +60,7 @@ func TestSiteState_HasRouterAccess(t *testing.T) {
 }
 
 func TestSiteState_CreateRouterAccess(t *testing.T) {
-	ss := NewSiteState()
+	ss := NewSiteState(false)
 	name := "skupper-local"
 	assert.Assert(t, !ss.HasRouterAccess())
 	ss.CreateRouterAccess(name, 5671)
@@ -77,22 +77,47 @@ func TestSiteState_CreateRouterAccess(t *testing.T) {
 }
 
 func TestSiteState_ToRouterConfig(t *testing.T) {
-	ss := fakeSiteState()
-	sslProfileBasePath := "${SSL_PROFILE_BASE_PATH}"
-	routerConfig := ss.ToRouterConfig(sslProfileBasePath)
-	assert.Equal(t, len(routerConfig.Listeners), 3)
-	rolesFound := map[string]bool{}
-	for _, listener := range routerConfig.Listeners {
-		rolesFound[string(listener.Role)] = true
+	for _, test := range []struct {
+		name   string
+		bundle bool
+	}{
+		{
+			name:   "regular-config",
+			bundle: false,
+		},
+		{
+			name:   "bundle-config",
+			bundle: true,
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			ss := fakeSiteState()
+			ss.bundle = test.bundle
+			sslProfileBasePath := "${SSL_PROFILE_BASE_PATH}"
+			routerConfig := ss.ToRouterConfig(sslProfileBasePath)
+			if test.bundle {
+				assert.Assert(t, strings.HasSuffix(routerConfig.Metadata.Id, "-{{.SiteNameSuffix}}"))
+				assert.Assert(t, strings.Contains(routerConfig.Metadata.Metadata, `"id":"{{.SiteId}}"`), routerConfig.Metadata.Metadata)
+				assert.Assert(t, ss.IsBundle())
+			} else {
+				assert.Assert(t, !strings.HasSuffix(routerConfig.Metadata.Id, "-{{.SiteNameSuffix}}"))
+				assert.Assert(t, strings.Contains(routerConfig.Metadata.Metadata, `"id":"site-id"`), routerConfig.Metadata.Metadata)
+			}
+			assert.Equal(t, len(routerConfig.Listeners), 3)
+			rolesFound := map[string]bool{}
+			for _, listener := range routerConfig.Listeners {
+				rolesFound[string(listener.Role)] = true
+			}
+			assert.Equal(t, len(rolesFound), 3, "expecting normal, inter-router and edge, found: %s", rolesFound)
+			assert.Equal(t, len(routerConfig.Connectors), 1)
+			assert.Equal(t, len(routerConfig.SslProfiles), 3)
+			assert.Assert(t, strings.HasPrefix(routerConfig.SslProfiles["link-access-one"].CaCertFile, sslProfileBasePath))
+			assert.Assert(t, strings.HasPrefix(routerConfig.SslProfiles["link-one-profile"].CaCertFile, sslProfileBasePath))
+			assert.Assert(t, strings.HasPrefix(routerConfig.SslProfiles["local-access-one"].CaCertFile, sslProfileBasePath))
+			assert.Equal(t, len(routerConfig.Bridges.TcpListeners), 2)
+			assert.Equal(t, len(routerConfig.Bridges.TcpConnectors), 1)
+		})
 	}
-	assert.Equal(t, len(rolesFound), 3, "expecting normal, inter-router and edge, found: %s", rolesFound)
-	assert.Equal(t, len(routerConfig.Connectors), 1)
-	assert.Equal(t, len(routerConfig.SslProfiles), 3)
-	assert.Assert(t, strings.HasPrefix(routerConfig.SslProfiles["link-access-one"].CaCertFile, sslProfileBasePath))
-	assert.Assert(t, strings.HasPrefix(routerConfig.SslProfiles["link-one-profile"].CaCertFile, sslProfileBasePath))
-	assert.Assert(t, strings.HasPrefix(routerConfig.SslProfiles["local-access-one"].CaCertFile, sslProfileBasePath))
-	assert.Equal(t, len(routerConfig.Bridges.TcpListeners), 2)
-	assert.Equal(t, len(routerConfig.Bridges.TcpConnectors), 1)
 }
 
 func TestMarshalSiteState(t *testing.T) {
diff --git a/pkg/nonkube/bundle/container_test.go b/pkg/nonkube/bundle/container_test.go
new file mode 100644
index 00000000..a9a41cae
--- /dev/null
+++ b/pkg/nonkube/bundle/container_test.go
@@ -0,0 +1,135 @@
+package bundle
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/skupperproject/skupper/pkg/container"
+	"gotest.tools/assert"
+)
+
+func TestEscapeCommand(t *testing.T) {
+	var tests = []struct {
+		argument string
+		expected string
+	}{
+		{
+			argument: "standard-argument",
+			expected: "standard-argument",
+		},
+		{
+			argument: "spaces in argument",
+			expected: "spaces\\ in\\ argument",
+		},
+		{
+			argument: "special {[ch@ract$rs]}; in \\ argument",
+			expected: "special\\ \\{\\[ch@ract\\$rs\\]\\}\\;\\ in\\ \\\\\\ argument",
+		},
+	}
+	for _, test := range tests {
+		result := escapeArgument(test.argument)
+		assert.Equal(t, test.expected, result)
+	}
+}
+
+func TestContainerToShell(t *testing.T) {
+	var tests = []struct {
+		description   string
+		containers    map[string]container.Container
+		expectedParts []string
+	}{
+		{
+			description: "minimal-container",
+			containers: map[string]container.Container{
+				"container1": container.Container{
+					Name:  "container1",
+					Image: "image1",
+				},
+			},
+			expectedParts: []string{
+				`#!/bin/sh`,
+				`{{.ContainerEngine}} run -d --name=container1 --user={{.RunAs}} --userns={{.UserNamespace}}`,
+				`--label=application=skupper --restart=always --network=host image1`,
+			},
+		},
+		{
+			description: "normal-container",
+			containers: map[string]container.Container{
+				"container1": container.Container{
+					Name:  "container1",
+					Image: "image1",
+					Env: map[string]string{
+						"ENV_VAR1": "VALUE_1",
+						"ENV_VAR2": "VALUE_2",
+						"ENV_VAR3": "VALUE_3",
+						"ENV_VAR4": "VALUE_4",
+					},
+					Labels: map[string]string{
+						"label1": "value1",
+						"label2": "value2",
+						"label3": "value3",
+						"label4": "value4",
+					},
+					FileMounts: []container.FileMount{
+						{
+							Source:      "/home/user/directory",
+							Destination: "/dest/directory",
+							Options:     []string{"z"},
+						},
+					},
+				},
+			},
+			expectedParts: []string{
+				`#!/bin/sh`,
+				`{{.ContainerEngine}} run -d --name=container1 --user={{.RunAs}} --userns={{.UserNamespace}} `,
+				` --env=ENV_VAR1=VALUE_1 `, ` --env=ENV_VAR2=VALUE_2 `, ` --env=ENV_VAR3=VALUE_3 `, ` --env=ENV_VAR4=VALUE_4 `,
+				` --label=application=skupper `, ` --label=label1=value1 `, ` --label=label2=value2 `, ` --label=label3=value3 `,
+				` --label=label4=value4 `, ` --restart=always `, `--network=host `, ` image1`,
+			},
+		},
+		{
+			description: "multiple-containers",
+			containers: map[string]container.Container{
+				"container1": container.Container{
+					Name:  "container1",
+					Image: "image1",
+					Env: map[string]string{
+						"ENV_VAR1": "VALUE_1",
+						"ENV_VAR2": "VALUE_2",
+						"ENV_VAR3": "VALUE_3",
+						"ENV_VAR4": "VALUE_4",
+					},
+					Labels: map[string]string{
+						"label1": "value1",
+						"label2": "value2",
+						"label3": "value3",
+						"label4": "value4",
+					},
+					FileMounts: []container.FileMount{
+						{
+							Source:      "/home/user/directory",
+							Destination: "/dest/directory",
+							Options:     []string{"z"},
+						},
+					},
+				},
+			},
+			expectedParts: []string{
+				`#!/bin/sh`,
+				`{{.ContainerEngine}} run -d --name=container1 --user={{.RunAs}} --userns={{.UserNamespace}} `,
+				` --env=ENV_VAR1=VALUE_1 `, ` --env=ENV_VAR2=VALUE_2 `, ` --env=ENV_VAR3=VALUE_3 `, ` --env=ENV_VAR4=VALUE_4 `,
+				` --label=application=skupper `, ` --label=label1=value1 `, ` --label=label2=value2 `, ` --label=label3=value3 `,
+				` --label=label4=value4 `, ` --restart=always `, `--network=host `, ` image1`,
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.description, func(t *testing.T) {
+			scriptData := containersToShell(test.containers)
+			script := string(scriptData)
+			for _, part := range test.expectedParts {
+				assert.Assert(t, strings.Contains(script, part), "%s not found in %s", part, script)
+			}
+		})
+	}
+}
diff --git a/pkg/non_kube/common/fs_config_renderer_test.go b/pkg/nonkube/common/fs_config_renderer_test.go
similarity index 98%
rename from pkg/non_kube/common/fs_config_renderer_test.go
rename to pkg/nonkube/common/fs_config_renderer_test.go
index 41eeb382..98f30c71 100644
--- a/pkg/non_kube/common/fs_config_renderer_test.go
+++ b/pkg/nonkube/common/fs_config_renderer_test.go
@@ -6,7 +6,7 @@ import (
 	"testing"
 
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
 	"gotest.tools/assert"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -135,7 +135,7 @@ func fakeSiteState() *apis.SiteState {
 		RouterAccesses: map[string]*v1alpha1.RouterAccess{
 			"link-access-one": {
 				TypeMeta: metav1.TypeMeta{
-					Kind:       "LinkAccess",
+					Kind:       "RouterAccess",
 					APIVersion: "skupper.io/v1alpha1",
 				},
 				ObjectMeta: metav1.ObjectMeta{
diff --git a/pkg/non_kube/common/site_state_loader_test.go b/pkg/nonkube/common/site_state_loader_test.go
similarity index 91%
rename from pkg/non_kube/common/site_state_loader_test.go
rename to pkg/nonkube/common/site_state_loader_test.go
index d2161f11..60826292 100644
--- a/pkg/non_kube/common/site_state_loader_test.go
+++ b/pkg/nonkube/common/site_state_loader_test.go
@@ -5,7 +5,7 @@ import (
 	"path"
 	"testing"
 
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
 	"gotest.tools/assert"
 )
 
diff --git a/pkg/non_kube/common/site_state_validator_test.go b/pkg/nonkube/common/site_state_validator_test.go
similarity index 99%
rename from pkg/non_kube/common/site_state_validator_test.go
rename to pkg/nonkube/common/site_state_validator_test.go
index 219dfde3..96f7246e 100644
--- a/pkg/non_kube/common/site_state_validator_test.go
+++ b/pkg/nonkube/common/site_state_validator_test.go
@@ -4,7 +4,7 @@ import (
 	"testing"
 
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
-	"github.com/skupperproject/skupper/pkg/non_kube/apis"
+	"github.com/skupperproject/skupper/pkg/nonkube/apis"
 	"gotest.tools/assert"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
diff --git a/pkg/non_kube/common/systemd_container_service.template b/pkg/nonkube/common/systemd_container_service.template
similarity index 100%
rename from pkg/non_kube/common/systemd_container_service.template
rename to pkg/nonkube/common/systemd_container_service.template
diff --git a/pkg/non_kube/common/systemd_service.template b/pkg/nonkube/common/systemd_service.template
similarity index 100%
rename from pkg/non_kube/common/systemd_service.template
rename to pkg/nonkube/common/systemd_service.template
diff --git a/pkg/non_kube/common/systemd_test.go b/pkg/nonkube/common/systemd_test.go
similarity index 93%
rename from pkg/non_kube/common/systemd_test.go
rename to pkg/nonkube/common/systemd_test.go
index d65d5a60..c9e96494 100644
--- a/pkg/non_kube/common/systemd_test.go
+++ b/pkg/nonkube/common/systemd_test.go
@@ -13,11 +13,11 @@ import (
 )
 
 func TestSystemdService(t *testing.T) {
-	outputPathOrig := os.Getenv("OUTPUT_PATH")
+	outputPathOrig := os.Getenv("SKUPPER_OUTPUT_PATH")
 	xdgConfigHomeOrig := os.Getenv("XDG_CONFIG_HOME")
 	platformOrig := os.Getenv("SKUPPER_PLATFORM")
 	defer func() {
-		_ = os.Setenv("OUTPUT_PATH", outputPathOrig)
+		_ = os.Setenv("SKUPPER_OUTPUT_PATH", outputPathOrig)
 		_ = os.Setenv("XDG_CONFIG_HOME", xdgConfigHomeOrig)
 		_ = os.Setenv("SKUPPER_PLATFORM", platformOrig)
 	}()
@@ -25,7 +25,7 @@ func TestSystemdService(t *testing.T) {
 
 	outputPath := t.TempDir()
 	assert.Assert(t, os.MkdirAll(outputPath, 0755))
-	assert.Assert(t, os.Setenv("OUTPUT_PATH", outputPath))
+	assert.Assert(t, os.Setenv("SKUPPER_OUTPUT_PATH", outputPath))
 	assert.Assert(t, os.Setenv("XDG_CONFIG_HOME", outputPath))
 
 	for _, platform := range []string{"systemd", "podman", "docker"} {