Repository: skupperproject/skupper
Pull request #2156 (2025-06-10 20:58:27)

network observer operator script


Patch:
diff --git a/Dockerfile.cli b/Dockerfile.cli
index 3495b9ec..41c07efe 100644
--- a/Dockerfile.cli
+++ b/Dockerfile.cli
@@ -2,6 +2,8 @@ FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
 
 ARG TARGETARCH
 
+RUN apt update && apt install -y jq
+
 WORKDIR /go/src/app
 COPY go.mod .
 COPY go.sum .
@@ -11,17 +13,19 @@ COPY . .
 ENV CGO_ENABLED=0
 RUN make build-cli GOARCH=$TARGETARCH
 
-FROM --platform=$TARGETPLATFORM registry.access.redhat.com/ubi9-minimal
+# Use scratch for minimal image size
+FROM scratch
+
 LABEL \
   org.opencontainers.image.title="Skupper CLI" \
   org.opencontainers.image.description="Provide users with the ability to operate their Skupper resources."
 
-# Create user and group and switch to user's context
-RUN microdnf -y install shadow-utils \
-&& microdnf clean all
-RUN useradd --uid 10000 runner
-USER 10000
-
 WORKDIR /app
+
+# Copy the statically linked binary
 COPY --from=builder /go/src/app/skupper .
+
+# Use numeric user ID (no need to create user in scratch)
+USER 10000
+
 ENTRYPOINT ["/app/skupper"]
diff --git a/Dockerfile.controller b/Dockerfile.controller
index bf39a398..bbc1f4d2 100644
--- a/Dockerfile.controller
+++ b/Dockerfile.controller
@@ -2,26 +2,31 @@ FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
 
 ARG TARGETARCH
 
+RUN apt update && apt install -y jq
+
 WORKDIR /go/src/app
 COPY go.mod .
 COPY go.sum .
 RUN go mod download
 COPY . .
 
+# Build statically linked binary
 ENV CGO_ENABLED=0
 RUN make build-controller GOARCH=$TARGETARCH
 
-FROM --platform=$TARGETPLATFORM registry.access.redhat.com/ubi9-minimal
+# Use scratch for minimal image size
+FROM scratch
+
 LABEL \
   org.opencontainers.image.title="Skupper Controller" \
   org.opencontainers.image.description="Kubernetes controller for operating skupper networks"
 
-# Create user and group and switch to user's context
-RUN microdnf -y install shadow-utils \
-&& microdnf clean all
-RUN useradd --uid 10000 runner
-USER 10000
-
 WORKDIR /app
+
+# Copy the statically linked binary
 COPY --from=builder /go/src/app/controller .
+
+# Use numeric user ID (no need to create user in scratch)
+USER 10000
+
 CMD ["/app/controller"]
diff --git a/Dockerfile.kube-adaptor b/Dockerfile.kube-adaptor
index a95a00cd..abff4527 100644
--- a/Dockerfile.kube-adaptor
+++ b/Dockerfile.kube-adaptor
@@ -2,6 +2,8 @@ FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
 
 ARG TARGETARCH
 
+RUN apt update && apt install -y jq
+
 WORKDIR /go/src/app
 
 COPY go.mod .
@@ -10,19 +12,19 @@ RUN go mod download
 
 COPY . .
 ENV CGO_ENABLED=0
-RUN make GOARCH=$TARGETARCH build-kube-adaptor
+RUN make build-kube-adaptor GOARCH=$TARGETARCH
+
+# Use scratch for minimal image size
+FROM scratch
 
-FROM --platform=$TARGETPLATFORM registry.access.redhat.com/ubi9-minimal
 LABEL \
   org.opencontainers.image.title="Skupper Router Adaptor" \
   org.opencontainers.image.description="Kubernetes aware interface for the skupper-router"
 
-# Create user and group and switch to user's context
-RUN microdnf -y install shadow-utils \
-&& microdnf clean all
-RUN useradd --uid 10000 runner
-USER 10000
-
 WORKDIR /app
+
 COPY --from=builder /go/src/app/kube-adaptor .
+
+USER 10000
+
 CMD ["/app/kube-adaptor"]
diff --git a/Dockerfile.network-observer b/Dockerfile.network-observer
index 890fba30..2d8e88e4 100644
--- a/Dockerfile.network-observer
+++ b/Dockerfile.network-observer
@@ -2,6 +2,8 @@ FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
 
 ARG TARGETARCH
 
+RUN apt update && apt install -y jq
+
 WORKDIR /go/src/app
 
 COPY go.mod .
@@ -21,18 +23,19 @@ RUN tar -zxf main.tar.gz
 WORKDIR ./skupper-console-main
 RUN yarn install && yarn build
 
-FROM --platform=$TARGETPLATFORM registry.access.redhat.com/ubi9-minimal
+# Use scratch for minimal image size
+FROM scratch
 LABEL \
   org.opencontainers.image.title="Skupper Network Observer" \
   org.opencontainers.image.description="Exposes Skupper network telemetry through an API, metrics and a web console"
 
-# Create user and group and switch to user's context
-RUN microdnf -y install shadow-utils \
-&& microdnf clean all
-RUN useradd --uid 10000 runner
-USER 10000
-
 WORKDIR /app
+
+# Copy the statically linked binary
 COPY --from=builder /go/src/app/network-observer .
 COPY --from=console-builder /skupper-console/skupper-console-main/build/ console
+
+# Use numeric user ID (no need to create user in scratch)
+USER 10000
+
 ENTRYPOINT ["/app/network-observer"]
diff --git a/Dockerfile.system-controller b/Dockerfile.system-controller
index 0b716447..adc1a8ed 100644
--- a/Dockerfile.system-controller
+++ b/Dockerfile.system-controller
@@ -2,6 +2,8 @@ FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
 
 ARG TARGETARCH
 
+RUN apt update && apt install -y jq
+
 WORKDIR /go/src/app
 COPY go.mod .
 COPY go.sum .
@@ -11,17 +13,19 @@ COPY . .
 ENV CGO_ENABLED=0
 RUN make system-controller GOARCH=$TARGETARCH
 
-FROM --platform=$TARGETPLATFORM registry.access.redhat.com/ubi9-minimal
+# Use scratch for minimal image size
+FROM scratch
+
 LABEL \
   org.opencontainers.image.title="Skupper System Controller" \
   org.opencontainers.image.description="Updates runtime resources for all system sites under a given user."
 
-# Create user and group and switch to user's context
-RUN microdnf -y install shadow-utils \
-&& microdnf clean all
-RUN useradd --uid 10000 runner
-USER 10000
-
 WORKDIR /app
+
+# Copy the statically linked binary
 COPY --from=builder /go/src/app/system-controller .
+
+# Use numeric user ID (no need to create user in scratch)
+USER 10000
+
 ENTRYPOINT ["/app/system-controller"]
diff --git a/Makefile b/Makefile
index f6114801..c8469781 100644
--- a/Makefile
+++ b/Makefile
@@ -70,6 +70,13 @@ podman-build: $(patsubst Dockerfile.%,podman-build-%,$(CONTAINERFILES))
 podman-build-%: Dockerfile.%
 	${PODMAN} build $(SHARED_IMAGE_LABELS) -t "${REGISTRY}/$*:${IMAGE_TAG}" -f $< .
 
+# Push all container images built by podman-build
+podman-push: $(patsubst Dockerfile.%,podman-push-%,$(CONTAINERFILES))
+
+# Pattern rule to push individual images
+podman-push-%: podman-build-%
+	${PODMAN} push "${REGISTRY}/$*:${IMAGE_TAG}"
+
 
 ## multi-platform container images built in docker buildkit builder and
 # exported to oci archive format.
@@ -154,6 +161,9 @@ pack-network-observer-helm-chart:
 generate-operator-bundle:
 	./scripts/skupper-operator-bundle-generator.sh ${IMAGE_TAG} ${ROUTER_IMAGE_TAG}
 
+generate-network-observer-operator-bundle:
+	./scripts/skupper-network-observer-operator-generator.sh ${IMAGE_TAG}
+
 generate-network-observer:
 	helm template skupper-network-observer ./charts/network-observer/ \
 		--set skipManagementLabels=true \
@@ -186,4 +196,5 @@ clean:
 		network-observer generate-doc \
 		cover.out oci-archives bundle bundle.Dockerfile \
 		skupper-*.tgz artifacthub-repo.yml \
-		network-observer-*.tgz  skupper-*-scope.yaml
+		network-observer-*.tgz  skupper-*-scope.yaml \
+		network-observer-operator
diff --git a/go.mod b/go.mod
index ca32e492..1a1f090b 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,7 @@ module github.com/skupperproject/skupper
 
 go 1.24.0
 
-toolchain go1.24.2
+toolchain go1.24.4
 
 require (
 	github.com/Azure/go-amqp v1.0.5
@@ -26,7 +26,7 @@ require (
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/pflag v1.0.5
 	golang.org/x/sync v0.12.0
-	golang.org/x/sys v0.31.0
+	golang.org/x/sys v0.33.0
 	golang.org/x/text v0.23.0
 	golang.org/x/time v0.9.0
 	gotest.tools/v3 v3.5.1
diff --git a/go.sum b/go.sum
index 00f9550d..7503daaf 100644
--- a/go.sum
+++ b/go.sum
@@ -566,8 +566,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
diff --git a/internal/certs/certs.go b/internal/certs/certs.go
index 26140212..6ab82947 100644
--- a/internal/certs/certs.go
+++ b/internal/certs/certs.go
@@ -21,7 +21,6 @@ import (
 	"crypto/x509/pkix"
 	"encoding/pem"
 	"fmt"
-	"log"
 	"math/big"
 	"net"
 	"strings"
@@ -57,31 +56,43 @@ type CertificateAuthority struct {
 
 type CertificateData map[string][]byte
 
-func decodeDataElement(in []byte, name string) []byte {
+func decodeDataElement(in []byte, name string) ([]byte, error) {
 	block, _ := pem.Decode(in)
 	if block == nil {
-		log.Fatal("failed to decode PEM block of type " + name)
+		return nil, fmt.Errorf("failed to read PEM encoded data from %q", name)
 	}
-	return block.Bytes
+	return block.Bytes, nil
 }
 
-func getCAFromSecret(secret *corev1.Secret) *CertificateAuthority {
+func getCAFromSecret(secret *corev1.Secret) (*CertificateAuthority, error) {
 	if secret == nil || secret.Data == nil {
-		return nil
+		return nil, nil
+	}
+
+	certBytes, err := decodeDataElement(secret.Data["tls.crt"], "tls.crt")
+	if err != nil {
+		return nil, err
+	}
+
+	cert, err := x509.ParseCertificate(certBytes)
+	if err != nil {
+		return nil, err
 	}
-	cert, err := x509.ParseCertificate(decodeDataElement(secret.Data["tls.crt"], "certificate"))
+
+	privateKeyBytes, err := decodeDataElement(secret.Data["tls.key"], "tls.key")
 	if err != nil {
-		log.Fatal("failed to get CA certificate from secret")
+		return nil, err
 	}
-	key, err := x509.ParsePKCS1PrivateKey(decodeDataElement(secret.Data["tls.key"], "private key"))
+	key, err := x509.ParsePKCS1PrivateKey(privateKeyBytes)
 	if err != nil {
-		log.Fatal("failed to get CA private key from secret", err)
+		return nil, fmt.Errorf("failed to get CA private key from secret %s", err)
 	}
+
 	return &CertificateAuthority{
 		Certificate: cert,
 		Key:         key,
 		CrtData:     secret.Data["tls.crt"],
-	}
+	}, nil
 }
 
 // GenerateSecret generates a kubernetes secret.
@@ -90,12 +101,17 @@ func getCAFromSecret(secret *corev1.Secret) *CertificateAuthority {
 // hosts are the host names in the x509 certificate. Comma separated if more than one hostname
 // expiration is when the secret expires, if zero is passed in, the expiration is set to 5 years from now
 // ca is the certificate authority, if nil a ca cert will be created.
-func GenerateSecret(name string, subject string, hosts string, expiration time.Duration, ca *corev1.Secret) corev1.Secret {
-	caCert := getCAFromSecret(ca)
+func GenerateSecret(name string, subject string, hosts string, expiration time.Duration, ca *corev1.Secret) (*corev1.Secret, error) {
+	caCert, err := getCAFromSecret(ca)
+
+	if err != nil {
+		return nil, fmt.Errorf("error reading CA Certificate from Secret %q: %s", ca.Name, err)
+	}
 
 	priv, err := rsa.GenerateKey(rand.Reader, 2048)
 	if err != nil {
-		log.Fatalf("failed to generate private key: %s", err)
+		return nil, fmt.Errorf("failed to generate private key: %v", err)
+
 	}
 
 	notBefore := time.Now()
@@ -107,7 +123,7 @@ func GenerateSecret(name string, subject string, hosts string, expiration time.D
 	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
 	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
 	if err != nil {
-		log.Fatalf("failed to generate serial number: %s", err)
+		return nil, err
 	}
 
 	template := x509.Certificate{
@@ -147,7 +163,7 @@ func GenerateSecret(name string, subject string, hosts string, expiration time.D
 
 	derBytes, err := x509.CreateCertificate(rand.Reader, &template, parent, publicKey(priv), cakey)
 	if err != nil {
-		log.Fatalf("Failed to create certificate: %s", err)
+		return nil, err
 	}
 
 	secret := corev1.Secret{
@@ -173,7 +189,7 @@ func GenerateSecret(name string, subject string, hosts string, expiration time.D
 		secret.Data["ca.crt"] = secret.Data["tls.crt"] //self.signed
 	}
 
-	return secret
+	return &secret, nil
 }
 
 func DecodeCertificate(data []byte) (*x509.Certificate, error) {
diff --git a/internal/cmd/skupper/link/kube/link_generate.go b/internal/cmd/skupper/link/kube/link_generate.go
index 8678fa66..80f0dfc8 100644
--- a/internal/cmd/skupper/link/kube/link_generate.go
+++ b/internal/cmd/skupper/link/kube/link_generate.go
@@ -36,7 +36,7 @@ type CmdLinkGenerate struct {
 	output             string
 	activeSite         *v2alpha1.Site
 	generateCredential bool
-	generatedLink      v2alpha1.Link
+	generatedLinks     []v2alpha1.Link
 	timeout            time.Duration
 }
 
@@ -165,22 +165,31 @@ func (cmd *CmdLinkGenerate) Run() error {
 		return fmt.Errorf("Output format is not specified")
 	}
 
-	resource := v2alpha1.Link{
-		TypeMeta: metav1.TypeMeta{
-			APIVersion: "skupper.io/v2alpha1",
-			Kind:       "Link",
-		},
-		ObjectMeta: metav1.ObjectMeta{
-			Name: cmd.linkName,
-		},
-		Spec: v2alpha1.LinkSpec{
-			TlsCredentials: cmd.tlsCredentials,
-			Cost:           cmd.cost,
-			Endpoints:      cmd.activeSite.Status.Endpoints,
-		},
+	endpointMap := getEndpointsByGroups(cmd.activeSite.Status.Endpoints)
+
+	var resources []v2alpha1.Link
+	for key, endpointGroup := range endpointMap {
+
+		resource := v2alpha1.Link{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: "skupper.io/v2alpha1",
+				Kind:       "Link",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name: strings.Join([]string{cmd.linkName, key}, "-"),
+			},
+			Spec: v2alpha1.LinkSpec{
+				TlsCredentials: cmd.tlsCredentials,
+				Cost:           cmd.cost,
+				Endpoints:      endpointGroup,
+			},
+		}
+
+		resources = append(resources, resource)
+
 	}
 
-	cmd.generatedLink = resource
+	cmd.generatedLinks = resources
 
 	if cmd.generateCredential {
 		//Check if the certificate was previously created
@@ -215,9 +224,14 @@ func (cmd *CmdLinkGenerate) Run() error {
 func (cmd *CmdLinkGenerate) WaitUntil() error {
 
 	var resourcesToPrint []string
-	encodedOutput, err := utils.Encode(cmd.output, cmd.generatedLink)
-	if err != nil {
-		return err
+
+	for _, generatedLink := range cmd.generatedLinks {
+		encodedOutput, err := utils.Encode(cmd.output, generatedLink)
+		if err != nil {
+			return err
+		}
+
+		resourcesToPrint = append(resourcesToPrint, encodedOutput)
 	}
 
 	if cmd.generateCredential {
@@ -262,11 +276,10 @@ func (cmd *CmdLinkGenerate) WaitUntil() error {
 
 	}
 
-	resourcesToPrint = append(resourcesToPrint, encodedOutput)
 	printResources(resourcesToPrint, cmd.output)
 
 	if cmd.generateCredential {
-		_, err = cmd.Client.Certificates(cmd.Namespace).Get(context.TODO(), cmd.tlsCredentials, metav1.GetOptions{})
+		_, err := cmd.Client.Certificates(cmd.Namespace).Get(context.TODO(), cmd.tlsCredentials, metav1.GetOptions{})
 		if err != nil {
 			return fmt.Errorf("there was an error trying to delete the generated certificate: %s", err)
 		}
@@ -294,3 +307,18 @@ func printResources(resources []string, outputFormat string) {
 		}
 	}
 }
+
+func getEndpointsByGroups(endpointList []v2alpha1.Endpoint) map[string][]v2alpha1.Endpoint {
+
+	endpointGroup := make(map[string][]v2alpha1.Endpoint)
+
+	for _, endpoint := range endpointList {
+		if len(endpointGroup[endpoint.Group]) > 0 {
+			endpointGroup[endpoint.Group] = append(endpointGroup[endpoint.Group], endpoint)
+		} else {
+			endpointGroup[endpoint.Group] = []v2alpha1.Endpoint{endpoint}
+		}
+	}
+
+	return endpointGroup
+}
diff --git a/internal/cmd/skupper/link/kube/link_update.go b/internal/cmd/skupper/link/kube/link_update.go
index 904b0a53..2651c7e1 100644
--- a/internal/cmd/skupper/link/kube/link_update.go
+++ b/internal/cmd/skupper/link/kube/link_update.go
@@ -126,40 +126,26 @@ func (cmd *CmdLinkUpdate) InputToOptions() {
 
 func (cmd *CmdLinkUpdate) Run() error {
 
-	currentSite, err := cmd.Client.Links(cmd.Namespace).Get(context.TODO(), cmd.linkName, metav1.GetOptions{})
+	currentLink, err := cmd.Client.Links(cmd.Namespace).Get(context.TODO(), cmd.linkName, metav1.GetOptions{})
 
 	if err != nil {
 		return err
 	}
 
-	updatedCost := currentSite.Spec.Cost
-	if cmd.cost != currentSite.Spec.Cost {
+	updatedCost := currentLink.Spec.Cost
+	if cmd.cost != currentLink.Spec.Cost {
 		updatedCost = cmd.cost
 	}
 
-	updatedTlsCredentials := currentSite.Spec.TlsCredentials
+	updatedTlsCredentials := currentLink.Spec.TlsCredentials
 	if cmd.tlsCredentials != "" {
 		updatedTlsCredentials = cmd.tlsCredentials
 	}
 
-	resource := v2alpha1.Link{
-		TypeMeta: metav1.TypeMeta{
-			APIVersion: "skupper.io/v2alpha1",
-			Kind:       "Link",
-		},
-		ObjectMeta: metav1.ObjectMeta{
-			Name:              cmd.linkName,
-			Namespace:         cmd.Namespace,
-			CreationTimestamp: currentSite.CreationTimestamp,
-			ResourceVersion:   currentSite.ResourceVersion,
-		},
-		Spec: v2alpha1.LinkSpec{
-			TlsCredentials: updatedTlsCredentials,
-			Cost:           updatedCost,
-		},
-	}
+	currentLink.Spec.TlsCredentials = updatedTlsCredentials
+	currentLink.Spec.Cost = updatedCost
 
-	_, err = cmd.Client.Links(cmd.Namespace).Update(context.TODO(), &resource, metav1.UpdateOptions{})
+	_, err = cmd.Client.Links(cmd.Namespace).Update(context.TODO(), currentLink, metav1.UpdateOptions{})
 	return err
 
 }
diff --git a/internal/cmd/skupper/link/nonkube/link_delete.go b/internal/cmd/skupper/link/nonkube/link_delete.go
index 7e90879f..788efb78 100644
--- a/internal/cmd/skupper/link/nonkube/link_delete.go
+++ b/internal/cmd/skupper/link/nonkube/link_delete.go
@@ -1,17 +1,21 @@
 package nonkube
 
 import (
+	"errors"
 	"fmt"
 
 	"github.com/skupperproject/skupper/internal/cmd/skupper/common"
+	"github.com/skupperproject/skupper/internal/nonkube/client/fs"
+	"github.com/skupperproject/skupper/internal/utils/validator"
 	"github.com/spf13/cobra"
 )
 
 type CmdLinkDelete struct {
-	CobraCmd  *cobra.Command
-	Flags     *common.CommandLinkDeleteFlags
-	Namespace string
-	siteName  string
+	linkHandler *fs.LinkHandler
+	CobraCmd    *cobra.Command
+	Flags       *common.CommandLinkDeleteFlags
+	namespace   string
+	linkName    string
 }
 
 func NewCmdLinkDelete() *CmdLinkDelete {
@@ -19,12 +23,52 @@ func NewCmdLinkDelete() *CmdLinkDelete {
 }
 
 func (cmd *CmdLinkDelete) NewClient(cobraCommand *cobra.Command, args []string) {
-	//TODO
+	if cmd.CobraCmd != nil && cmd.CobraCmd.Flag(common.FlagNameNamespace) != nil && cmd.CobraCmd.Flag(common.FlagNameNamespace).Value.String() != "" {
+		cmd.namespace = cmd.CobraCmd.Flag(common.FlagNameNamespace).Value.String()
+	}
+
+	cmd.linkHandler = fs.NewLinkHandler(cmd.namespace)
+}
+
+func (cmd *CmdLinkDelete) ValidateInput(args []string) error {
+	var validationErrors []error
+	resourceStringValidator := validator.NewResourceStringValidator()
+
+	// Validate arguments name if specified
+	if len(args) > 1 {
+		validationErrors = append(validationErrors, fmt.Errorf("only one argument is allowed for this command"))
+	} else if len(args) == 1 {
+		if args[0] == "" {
+			validationErrors = append(validationErrors, fmt.Errorf("link name must not be empty"))
+		} else {
+			ok, err := resourceStringValidator.Evaluate(args[0])
+			if !ok {
+				validationErrors = append(validationErrors, fmt.Errorf("link name is not valid: %s", err))
+			} else {
+				cmd.linkName = args[0]
+			}
+		}
+	} else {
+		validationErrors = append(validationErrors, fmt.Errorf("link name must be specified"))
+	}
+
+	if cmd.linkName != "" {
+		// Validate that there is a link with this name
+		_, err := cmd.linkHandler.Get(cmd.linkName, fs.GetOptions{LogWarning: false, RuntimeFirst: false})
+		if err != nil {
+			validationErrors = append(validationErrors, fmt.Errorf("There is no link resource in the namespace with the name %q", cmd.linkName))
+		}
+	}
+	return errors.Join(validationErrors...)
 }
 
-func (cmd *CmdLinkDelete) ValidateInput(args []string) error { return nil }
-func (cmd *CmdLinkDelete) InputToOptions()                   {}
 func (cmd *CmdLinkDelete) Run() error {
-	return fmt.Errorf("command not supported by the selected platform")
+	err := cmd.linkHandler.Delete(cmd.linkName)
+	if err != nil {
+		return err
+	}
+	return nil
 }
+
+func (cmd *CmdLinkDelete) InputToOptions()  {}
 func (cmd *CmdLinkDelete) WaitUntil() error { return nil }
diff --git a/internal/cmd/skupper/link/nonkube/link_status.go b/internal/cmd/skupper/link/nonkube/link_status.go
index 3d4dbda3..0f41879f 100644
--- a/internal/cmd/skupper/link/nonkube/link_status.go
+++ b/internal/cmd/skupper/link/nonkube/link_status.go
@@ -3,11 +3,12 @@ package nonkube
 import (
 	"errors"
 	"fmt"
-	"github.com/skupperproject/skupper/internal/cmd/skupper/common/utils"
-	"github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1"
 	"os"
 	"text/tabwriter"
 
+	"github.com/skupperproject/skupper/internal/cmd/skupper/common/utils"
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1"
+
 	"github.com/skupperproject/skupper/internal/cmd/skupper/common"
 	"github.com/skupperproject/skupper/internal/nonkube/client/fs"
 	"github.com/skupperproject/skupper/internal/utils/validator"
@@ -62,7 +63,7 @@ func (cmd *CmdLinkStatus) ValidateInput(args []string) error {
 func (cmd *CmdLinkStatus) Run() error {
 
 	if cmd.linkName != "" {
-		selectedLink, err := cmd.linkHandler.Get(cmd.linkName, fs.GetOptions{LogWarning: false})
+		selectedLink, err := cmd.linkHandler.Get(cmd.linkName, fs.GetOptions{LogWarning: false, RuntimeFirst: true})
 		if err != nil {
 			return fmt.Errorf("There is no link resource in the namespace with the name %q", cmd.linkName)
 		}
diff --git a/internal/cmd/skupper/system/nonkube/system_install.go b/internal/cmd/skupper/system/nonkube/system_install.go
index 0e92a87e..7be4d8aa 100644
--- a/internal/cmd/skupper/system/nonkube/system_install.go
+++ b/internal/cmd/skupper/system/nonkube/system_install.go
@@ -50,8 +50,6 @@ func (cmd *CmdSystemInstall) Run() error {
 		return fmt.Errorf("failed to configure the environment : %s", err)
 	}
 
-	fmt.Printf("Platform %s is now configured for Skupper\n", string(config.GetPlatform()))
-
 	return nil
 }
 
diff --git a/internal/cmd/skupper/system/nonkube/system_uninstall.go b/internal/cmd/skupper/system/nonkube/system_uninstall.go
index 957b2fe3..bba57874 100644
--- a/internal/cmd/skupper/system/nonkube/system_uninstall.go
+++ b/internal/cmd/skupper/system/nonkube/system_uninstall.go
@@ -69,8 +69,6 @@ func (cmd *CmdSystemUninstall) Run() error {
 		return fmt.Errorf("failed to uninstall : %s", err)
 	}
 
-	fmt.Printf("Platform %s infrastructure for Skupper is now uninstalled\n", string(config.GetPlatform()))
-
 	return nil
 }
 
diff --git a/internal/cmd/skupper/token/kube/token_issue.go b/internal/cmd/skupper/token/kube/token_issue.go
index b7985006..10af925e 100644
--- a/internal/cmd/skupper/token/kube/token_issue.go
+++ b/internal/cmd/skupper/token/kube/token_issue.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"path/filepath"
 	"strconv"
 
 	"github.com/skupperproject/skupper/internal/cmd/skupper/common"
@@ -70,9 +71,18 @@ func (cmd *CmdTokenIssue) ValidateInput(args []string) error {
 		if !ok {
 			validationErrors = append(validationErrors, fmt.Errorf("token file name is not valid: %s", err))
 		} else {
-			// check we can use as a filename
-			if _, err := os.ReadDir(args[0]); err == nil {
+			// check filename and directory are valid to create the token
+			if fileInfo, err := os.Stat(args[0]); err == nil && fileInfo.IsDir() {
 				validationErrors = append(validationErrors, fmt.Errorf("token file name is a directory"))
+			} else {
+				directory, filename := filepath.Split(args[0])
+				// test token can be create in directory
+				file, err := os.CreateTemp(directory, filename)
+				if err != nil {
+					validationErrors = append(validationErrors, fmt.Errorf("invalid token file name: %s", errors.Unwrap(err)))
+				} else {
+					os.Remove(file.Name())
+				}
 			}
 			cmd.fileName = args[0]
 		}
diff --git a/internal/cmd/skupper/version/kube/version.go b/internal/cmd/skupper/version/kube/version.go
index 265611e5..3b5fe927 100644
--- a/internal/cmd/skupper/version/kube/version.go
+++ b/internal/cmd/skupper/version/kube/version.go
@@ -69,13 +69,11 @@ func (cmd *CmdVersion) InputToOptions() {
 	if cmd.KubeClient != nil {
 		// search for running pods in the current namespace
 		runningPodList, err := cmd.KubeClient.CoreV1().Pods(cmd.namespace).List(context.Background(), metav1.ListOptions{LabelSelector: "app.kubernetes.io/part-of in (skupper, skupper-network-observer)"})
-		if err != nil {
-			return
-		}
-
-		for _, runningPod := range runningPodList.Items {
-			for _, container := range runningPod.Status.ContainerStatuses {
-				mapRunningPods[container.Name] = container.Image
+		if err == nil {
+			for _, runningPod := range runningPodList.Items {
+				for _, container := range runningPod.Status.ContainerStatuses {
+					mapRunningPods[container.Name] = container.Image
+				}
 			}
 		}
 	}
@@ -85,11 +83,11 @@ func (cmd *CmdVersion) InputToOptions() {
 	} else {
 		cmd.manifest = configs.ManifestManager{Components: images.KubeComponents, EnableSHA: false, RunningPods: mapRunningPods}
 	}
-
 }
 
 func (cmd *CmdVersion) Run() error {
 	files := cmd.manifest.GetConfiguredManifest()
+
 	if cmd.output != "" {
 		encodedOutput, err := utils.Encode(cmd.output, files)
 		if err != nil {
diff --git a/internal/kube/adaptor/config_sync.go b/internal/kube/adaptor/config_sync.go
index 835cd11a..f8d201bb 100644
--- a/internal/kube/adaptor/config_sync.go
+++ b/internal/kube/adaptor/config_sync.go
@@ -193,7 +193,7 @@ func syncListeners(agent *qdr.Agent, desired *qdr.RouterConfig) error {
 		return fmt.Errorf("Error retrieving local listeners: %s", err)
 	}
 
-	if differences := qdr.ListenersDifference(qdr.FilterListeners(actual, qdr.IsNotNormalListener), desired.GetMatchingListeners(qdr.IsNotNormalListener)); !differences.Empty() {
+	if differences := qdr.ListenersDifference(qdr.FilterListeners(actual, qdr.IsNotProtectedListener), desired.GetMatchingListeners(qdr.IsNotProtectedListener)); !differences.Empty() {
 		if err := agent.UpdateListenerConfig(differences); err != nil {
 			return fmt.Errorf("Error syncing listeners: %s", err)
 		}
diff --git a/internal/kube/certificates/mgr.go b/internal/kube/certificates/mgr.go
index 503ebe43..9d0d046b 100644
--- a/internal/kube/certificates/mgr.go
+++ b/internal/kube/certificates/mgr.go
@@ -312,9 +312,13 @@ func (m *CertificateManagerImpl) updateSecret(key string, certificate *skupperv2
 }
 
 func (m *CertificateManagerImpl) generateSecret(certificate *skupperv2alpha1.Certificate) (*corev1.Secret, error) {
-	var secret corev1.Secret
+	var secret *corev1.Secret
+	var err error
 	if certificate.Spec.Signing {
-		secret = certs.GenerateSecret(certificate.Name, certificate.Spec.Subject, "", 0, nil)
+		secret, err = certs.GenerateSecret(certificate.Name, certificate.Spec.Subject, "", 0, nil)
+		if err != nil {
+			return secret, err
+		}
 	} else {
 		expiration := time.Hour * 24 * 365 * 5 // TODO: make this configurable (through controller setting or field on certificate?)
 		caKey := fmt.Sprintf("%s/%s", certificate.Namespace, certificate.Spec.Ca)
@@ -324,10 +328,13 @@ func (m *CertificateManagerImpl) generateSecret(certificate *skupperv2alpha1.Cer
 			return nil, fmt.Errorf("CA %q not found", caKey)
 		}
 		// TODO: handle server and client roles properly
-		secret = certs.GenerateSecret(certificate.Name, certificate.Spec.Subject, strings.Join(certificate.Spec.Hosts, ","), expiration, ca)
+		secret, err = certs.GenerateSecret(certificate.Name, certificate.Spec.Subject, strings.Join(certificate.Spec.Hosts, ","), expiration, ca)
+		if err != nil {
+			return nil, err
+		}
 	}
 	secret.ObjectMeta.OwnerReferences = ownerReferences(certificate)
-	return &secret, nil
+	return secret, nil
 }
 
 func (m *CertificateManagerImpl) createSecret(key string, certificate *skupperv2alpha1.Certificate) error {
diff --git a/internal/kube/controller/controller.go b/internal/kube/controller/controller.go
index 6c560601..ce06283b 100644
--- a/internal/kube/controller/controller.go
+++ b/internal/kube/controller/controller.go
@@ -414,7 +414,10 @@ func (c *Controller) generateLinkConfig(namespace string, name string, subject s
 	if err != nil {
 		return err
 	}
-	token := generator.NewCertToken(name, subject)
+	token, err := generator.NewCertToken(name, subject)
+	if err != nil {
+		return err
+	}
 	return token.Write(writer)
 }
 
diff --git a/internal/kube/grants/tokens.go b/internal/kube/grants/tokens.go
index f003616a..c668b9a5 100644
--- a/internal/kube/grants/tokens.go
+++ b/internal/kube/grants/tokens.go
@@ -88,10 +88,13 @@ func (g *TokenGenerator) setValidHostsFromSite(site *skupperv2alpha1.Site) bool
 	return true
 }
 
-func (g *TokenGenerator) NewCertToken(name string, subject string) Token {
-	cert := certs.GenerateSecret(name, subject, strings.Join(g.hosts, ","), 0, g.ca)
+func (g *TokenGenerator) NewCertToken(name string, subject string) (Token, error) {
+	cert, err := certs.GenerateSecret(name, subject, strings.Join(g.hosts, ","), 0, g.ca)
+	if err != nil {
+		return nil, err
+	}
 	token := &CertToken{
-		tlsCredentials: &cert,
+		tlsCredentials: cert,
 	}
 	for i, endpoints := range g.endpoints {
 		linkName := name
@@ -113,7 +116,7 @@ func (g *TokenGenerator) NewCertToken(name string, subject string) Token {
 		}
 		token.links = append(token.links, link)
 	}
-	return token
+	return token, nil
 }
 
 func (t *CertToken) Write(writer io.Writer) error {
diff --git a/internal/kube/site/site.go b/internal/kube/site/site.go
index fa931627..4edfadab 100644
--- a/internal/kube/site/site.go
+++ b/internal/kube/site/site.go
@@ -238,6 +238,10 @@ func (s *Site) reconcile(siteDef *skupperv2alpha1.Site, inRecovery bool) error {
 }
 
 func (s *Site) initialRouterConfig() *qdr.RouterConfig {
+	//
+	// If the set of listeners in this initial configuration changes, make sure to update the function
+	// IsNotProtectedListener to include the complete list of "protected" listeners.
+	//
 	rc := qdr.InitialConfig(s.name+"-${HOSTNAME}", s.site.GetSiteId(), version.Version, s.isEdge(), 3)
 	rc.AddAddress(qdr.Address{
 		Prefix:       "mc",
diff --git a/internal/nonkube/bootstrap/controller/startsh-container.template b/internal/nonkube/bootstrap/controller/startsh-container.template
new file mode 100644
index 00000000..76a2379e
--- /dev/null
+++ b/internal/nonkube/bootstrap/controller/startsh-container.template
@@ -0,0 +1,9 @@
+#!/usr/bin/env sh
+
+set -o errexit
+set -o nounset
+
+export SKUPPER_OUTPUT_PATH=${SKUPPER_OUTPUT_PATH}
+export CONTAINER_ENDPOINT=${CONTAINER_ENDPOINT}
+
+{{.ContainerEngine}} start {{.Name}}
diff --git a/internal/nonkube/bootstrap/controller/startup.go b/internal/nonkube/bootstrap/controller/startup.go
new file mode 100644
index 00000000..fa9e2014
--- /dev/null
+++ b/internal/nonkube/bootstrap/controller/startup.go
@@ -0,0 +1,121 @@
+package controller
+
+import (
+	"bytes"
+	_ "embed"
+	"fmt"
+	"github.com/skupperproject/skupper/api/types"
+	"github.com/skupperproject/skupper/internal/nonkube/common"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	"log/slog"
+	"os"
+	"path"
+	"text/template"
+)
+
+var (
+	//go:embed startsh-container.template
+	StartScriptContainerTemplate string
+
+	//go:embed stopsh-container.template
+	StopScriptContainerTemplate string
+)
+
+type StartupScript interface {
+	Create() error
+	Remove()
+	GetPath() string
+	GetStartFileName() string
+	GetStopFileName() string
+}
+
+type startupScripts struct {
+	StartScript     string
+	StopScript      string
+	Name            string
+	SkupperPlatform string
+	ContainerEngine string
+	path            string
+}
+
+type StartupScriptsArgs struct {
+	Name     string
+	Platform types.Platform
+	Bundle   bool
+}
+
+func GetStartupScripts(args StartupScriptsArgs, controllerPath string) (StartupScript, error) {
+	scripts := &startupScripts{
+		StartScript:     StartScriptContainerTemplate,
+		StopScript:      StopScriptContainerTemplate,
+		Name:            args.Name,
+		SkupperPlatform: "podman",
+	}
+
+	if !args.Platform.IsContainerEngine() && !args.Bundle {
+		return nil, fmt.Errorf("startup scripts can only be used with podman or docker platforms")
+	}
+	scripts.SkupperPlatform = string(args.Platform)
+	scripts.ContainerEngine = scripts.SkupperPlatform
+	if args.Bundle {
+		scripts.ContainerEngine = "{{.ContainerEngine}}"
+	}
+
+	scriptsPath := path.Join(controllerPath, string(api.ScriptsPath))
+
+	scripts.path = scriptsPath
+	return scripts, nil
+}
+
+func (s *startupScripts) Create() error {
+
+	var startBuf bytes.Buffer
+	var stopBuf bytes.Buffer
+	logger := common.NewLogger()
+	logger.Debug("creating startup scripts")
+
+	if err := os.MkdirAll(s.path, 0755); err != nil {
+		return fmt.Errorf("error creating skupper system controller directory %q: %v", s.path, err)
+	}
+
+	startTemplate := template.Must(template.New("start").Parse(s.StartScript))
+	if err := startTemplate.Execute(&startBuf, s); err != nil {
+		return fmt.Errorf("failed to create start script: %w", err)
+	}
+	startFileName := path.Join(s.path, s.GetStartFileName())
+	logger.Debug("writing start script", slog.String("path", startFileName))
+	err := os.WriteFile(startFileName, startBuf.Bytes(), 0755)
+	if err != nil {
+		return err
+	}
+	stopTemplate := template.Must(template.New("stop").Parse(s.StopScript))
+	if err := stopTemplate.Execute(&stopBuf, s); err != nil {
+		return fmt.Errorf("failed to create stop script: %w", err)
+	}
+	stopFileName := path.Join(s.path, s.GetStopFileName())
+	logger.Debug("writing stop script", slog.String("path", stopFileName))
+	err = os.WriteFile(stopFileName, stopBuf.Bytes(), 0755)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s *startupScripts) Remove() {
+	startFileName := path.Join(s.path, s.GetStartFileName())
+	stopFileName := path.Join(s.path, s.GetStopFileName())
+	_ = os.Remove(startFileName)
+	_ = os.Remove(stopFileName)
+}
+
+func (s *startupScripts) GetPath() string {
+	return s.path
+}
+
+func (s *startupScripts) GetStartFileName() string {
+	return "start.sh"
+}
+
+func (s *startupScripts) GetStopFileName() string {
+	return "stop.sh"
+}
diff --git a/internal/nonkube/bootstrap/controller/stopsh-container.template b/internal/nonkube/bootstrap/controller/stopsh-container.template
new file mode 100644
index 00000000..dd5c969f
--- /dev/null
+++ b/internal/nonkube/bootstrap/controller/stopsh-container.template
@@ -0,0 +1,6 @@
+#!/usr/bin/env sh
+
+set -o errexit
+set -o nounset
+
+{{.ContainerEngine}} stop -t 10 {{.Name}}
diff --git a/internal/nonkube/bootstrap/controller/systemd.go b/internal/nonkube/bootstrap/controller/systemd.go
new file mode 100644
index 00000000..591bea32
--- /dev/null
+++ b/internal/nonkube/bootstrap/controller/systemd.go
@@ -0,0 +1,248 @@
+package controller
+
+import (
+	"bytes"
+	_ "embed"
+	"fmt"
+	"github.com/skupperproject/skupper/internal/nonkube/common"
+	"github.com/skupperproject/skupper/pkg/container"
+	"log/slog"
+	"os"
+	"os/exec"
+	"path"
+	"path/filepath"
+	"text/template"
+
+	"github.com/skupperproject/skupper/api/types"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+)
+
+var (
+	//go:embed systemd_container_service.template
+	SystemdContainerServiceTemplate string
+)
+
+const (
+	rootSystemdBasePath = "/etc/systemd/system"
+)
+
+type SystemdService interface {
+	GetServiceName() string
+	Create() error
+	Remove() error
+	GetServiceFile() string
+}
+
+type CommandExecutor func(name string, arg ...string) *exec.Cmd
+
+type systemdServiceInfo struct {
+	Name                string
+	Image               string
+	Env                 map[string](string)
+	Mounts              []container.Volume
+	Annotations         map[string]string
+	Platform            string
+	RuntimeDir          string
+	GetUid              api.IdGetter
+	command             CommandExecutor
+	rootSystemdBasePath string
+	ScriptPath          string
+}
+
+func NewSystemdServiceInfo(systemContainer container.Container, platform string) (SystemdService, error) {
+
+	scriptPath := path.Join(api.GetSystemControllerPath(), "internal", "scripts")
+
+	return &systemdServiceInfo{
+		Name:                "skupper-controller",
+		Image:               systemContainer.Image,
+		Env:                 systemContainer.Env,
+		Mounts:              systemContainer.Mounts,
+		Annotations:         systemContainer.Annotations,
+		RuntimeDir:          api.GetRuntimeDir(),
+		GetUid:              os.Getuid,
+		command:             exec.Command,
+		rootSystemdBasePath: rootSystemdBasePath,
+		Platform:            platform,
+		ScriptPath:          scriptPath,
+	}, nil
+}
+
+func (s *systemdServiceInfo) GetServiceName() string {
+	return fmt.Sprintf("%s.service", s.Name)
+}
+
+func (s *systemdServiceInfo) Create() error {
+	if !api.IsRunningInContainer() && !s.isSystemdEnabled() {
+		msg := "SystemD is not enabled"
+		if s.GetUid() != 0 {
+			msg += " at user level"
+		}
+		return fmt.Errorf("%s", msg)
+	}
+	var logger = common.NewLogger()
+	logger.Debug("creating systemd service")
+	var buf = new(bytes.Buffer)
+	var service *template.Template
+	logger.Debug("using service template for:", slog.String("platform", s.Platform))
+	if s.Platform == string(types.PlatformLinux) {
+		return fmt.Errorf("the creation of the systemd service is not supported on Linux platform")
+	} else {
+		service = template.Must(template.New(s.GetServiceName()).Parse(SystemdContainerServiceTemplate))
+	}
+	err := service.Execute(buf, s)
+	if err != nil {
+		return err
+	}
+
+	// Creating the base dir
+	baseDir := filepath.Dir(s.GetServiceFile())
+	if _, err := os.Stat(baseDir); err != nil {
+		if err = os.MkdirAll(baseDir, 0755); err != nil {
+			return fmt.Errorf("unable to create base directory %s - %q", baseDir, err)
+		}
+	}
+
+	// Saving systemd user service
+	serviceName := s.GetServiceName()
+	logger.Debug("writing service file", slog.String("path", s.GetServiceFile()))
+	err = os.WriteFile(s.GetServiceFile(), buf.Bytes(), 0644)
+	if err != nil {
+		return fmt.Errorf("unable to write unit file (%s): %w", s.GetServiceFile(), err)
+	}
+
+	// Only enable when running locally
+	if !api.IsRunningInContainer() {
+		logger.Debug("enabling systemd service", slog.String("name", serviceName))
+		return s.enableService(serviceName)
+	}
+
+	return nil
+}
+
+func (s *systemdServiceInfo) GetServiceFile() string {
+	if s.GetUid() == 0 {
+		return path.Join(s.rootSystemdBasePath, s.GetServiceName())
+	}
+	return path.Join(api.GetConfigHome(), "systemd/user", s.GetServiceName())
+}
+
+func (s *systemdServiceInfo) Remove() error {
+	if !api.IsRunningInContainer() && !s.isSystemdEnabled() {
+		return fmt.Errorf("SystemD is not enabled at user level")
+	}
+
+	logger := common.NewLogger()
+
+	// Stopping systemd user service
+	if !api.IsRunningInContainer() {
+		logger.Debug("stopping service", slog.String("name", s.GetServiceName()))
+		cmd := s.getCmdStopSystemdService(s.GetServiceName())
+		_ = cmd.Run()
+
+		// Disabling systemd user service
+		logger.Debug("disabling service", slog.String("name", s.GetServiceName()))
+		cmd = s.getCmdDisableSystemdService(s.GetServiceName())
+		_ = cmd.Run()
+	}
+
+	// Removing the .service file
+	logger.Debug("removing service", slog.String("path", s.GetServiceFile()))
+	_ = os.Remove(s.GetServiceFile())
+
+	// Reloading systemd user daemon
+	if !api.IsRunningInContainer() {
+		logger.Debug("reloading systemd daemon")
+		cmd := s.getCmdReloadSystemdDaemon()
+		_ = cmd.Run()
+
+		// Resetting failed status
+		logger.Debug("resetting failed systemd service", slog.String("name", s.GetServiceName()))
+		cmd = s.getCmdResetFailedSystemService(s.GetServiceName())
+		_ = cmd.Run()
+	}
+
+	return nil
+}
+
+func (s *systemdServiceInfo) enableService(serviceName string) error {
+	// Enabling systemd user service
+	cmd := s.getCmdEnableSystemdService(serviceName)
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("unable to enable service (%s): %w", s.GetServiceFile(), err)
+	}
+
+	// Reloading systemd user daemon
+	cmd = s.getCmdReloadSystemdDaemon()
+	err = cmd.Run()
+	if err != nil {
+		return fmt.Errorf("Unable to user service daemon-reload: %w", err)
+	}
+
+	// Starting systemd user service
+	cmd = s.getCmdStartSystemdService(serviceName)
+	err = cmd.Run()
+	if err != nil {
+		return fmt.Errorf("Unable to start user service: %w", err)
+	}
+
+	return nil
+}
+
+func (s *systemdServiceInfo) getCmdEnableSystemdService(serviceName string) *exec.Cmd {
+	if s.GetUid() == 0 {
+		return s.command("systemctl", "enable", serviceName)
+	}
+	return s.command("systemctl", "--user", "enable", serviceName)
+}
+
+func (s *systemdServiceInfo) getCmdDisableSystemdService(serviceName string) *exec.Cmd {
+	if s.GetUid() == 0 {
+		return s.command("systemctl", "disable", serviceName)
+	}
+	return s.command("systemctl", "--user", "disable", serviceName)
+}
+
+func (s *systemdServiceInfo) getCmdReloadSystemdDaemon() *exec.Cmd {
+	if s.GetUid() == 0 {
+		return s.command("systemctl", "daemon-reload")
+	}
+	return s.command("systemctl", "--user", "daemon-reload")
+}
+
+func (s *systemdServiceInfo) getCmdStartSystemdService(serviceName string) *exec.Cmd {
+	if s.GetUid() == 0 {
+		return s.command("systemctl", "start", serviceName)
+	}
+	return s.command("systemctl", "--user", "start", serviceName)
+}
+
+func (s *systemdServiceInfo) getCmdStopSystemdService(serviceName string) *exec.Cmd {
+	if s.GetUid() == 0 {
+		return s.command("systemctl", "stop", serviceName)
+	}
+	return s.command("systemctl", "--user", "stop", serviceName)
+}
+
+func (s *systemdServiceInfo) getCmdResetFailedSystemService(serviceName string) *exec.Cmd {
+	if s.GetUid() == 0 {
+		return s.command("systemctl", "reset-failed", serviceName)
+	}
+	return s.command("systemctl", "--user", "reset-failed", serviceName)
+}
+
+func (s *systemdServiceInfo) getCmdIsSystemdEnabled() *exec.Cmd {
+	if s.GetUid() == 0 {
+		return s.command("systemctl", []string{"list-units", "--no-pager"}...)
+	}
+	return s.command("systemctl", []string{"--user", "list-units", "--no-pager"}...)
+}
+
+func (s *systemdServiceInfo) isSystemdEnabled() bool {
+	cmd := s.getCmdIsSystemdEnabled()
+	if err := cmd.Run(); err != nil {
+		return false
+	}
+	return true
+}
diff --git a/internal/nonkube/bootstrap/controller/systemd_container_service.template b/internal/nonkube/bootstrap/controller/systemd_container_service.template
new file mode 100644
index 00000000..2c059c76
--- /dev/null
+++ b/internal/nonkube/bootstrap/controller/systemd_container_service.template
@@ -0,0 +1,21 @@
+[Unit]
+Description={{.Name}}
+After=network-online.target
+Wants=network-online.target
+RequiresMountsFor={{.RuntimeDir}}/containers
+{{- range $volume := .Mounts}}
+RequiresMountsFor={{$volume.Name}}
+{{- end}}
+
+[Service]
+TimeoutStopSec=70
+RemainAfterExit=yes
+{{- range $key, $value := .Env}}
+Environment="{{$key}}={{$value}}"
+{{- end}}
+ExecStart=/bin/bash {{.ScriptPath}}/start.sh
+ExecStop=/bin/bash {{.ScriptPath}}/stop.sh
+Type=simple
+
+[Install]
+WantedBy=default.target
diff --git a/internal/nonkube/bootstrap/install.go b/internal/nonkube/bootstrap/install.go
index ee0d9d21..c516ac39 100644
--- a/internal/nonkube/bootstrap/install.go
+++ b/internal/nonkube/bootstrap/install.go
@@ -1,9 +1,32 @@
 package bootstrap
 
 import (
+	"context"
+	"fmt"
+	"os"
+	"os/exec"
+	"os/user"
+	"strconv"
+	"strings"
+
+	"github.com/skupperproject/skupper/api/types"
+	"github.com/skupperproject/skupper/internal/images"
+	"github.com/skupperproject/skupper/internal/nonkube/bootstrap/controller"
+	internalclient "github.com/skupperproject/skupper/internal/nonkube/client/compat"
 	"github.com/skupperproject/skupper/internal/nonkube/common"
+	"github.com/skupperproject/skupper/pkg/container"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
 )
 
+type ControllerConfig struct {
+	containerEngine          string
+	containerEndpointDefault string
+	username                 string
+	hostDataHome             string
+	xdgDataHome              string
+	containerEndpoint        string
+}
+
 func Install(platform string) error {
 
 	systemdGlobal, err := common.NewSystemdGlobal(platform)
@@ -16,5 +39,237 @@ func Install(platform string) error {
 		return err
 	}
 
+	config, err := configEnvVariables(platform)
+	if err != nil {
+		return err
+	}
+
+	containerName := fmt.Sprintf("%s-skupper-controller", config.username)
+
+	isContainerAlreadyRunningInPodman := IsContainerRunning(containerName, types.PlatformPodman)
+
+	if isContainerAlreadyRunningInPodman {
+		fmt.Printf("Warning: The system controller container %q is already running in Podman.\n", containerName)
+		return nil
+	}
+
+	isContainerAlreadyRunningInDocker := IsContainerRunning(containerName, types.PlatformDocker)
+
+	if isContainerAlreadyRunningInDocker {
+		fmt.Printf("Warning: The system controller container %q is already running in Docker.\n", containerName)
+		return nil
+	}
+
+	cli, err := internalclient.NewCompatClient(config.containerEndpoint, "")
+	if err != nil {
+		return fmt.Errorf("failed to create container client: %v", err)
+	}
+
+	err = cli.ImagePull(context.TODO(), images.GetSystemControllerImageName())
+	if err != nil {
+		return fmt.Errorf("failed to pull system-controller image: %v", err)
+	}
+	fmt.Printf("Pulled system-controller image: %s\n", images.GetSystemControllerImageName())
+
+	env := map[string]string{
+		"CONTAINER_ENDPOINT":  config.containerEndpoint,
+		"SKUPPER_OUTPUT_PATH": config.hostDataHome,
+		"CONTAINER_ENGINE":    config.containerEngine,
+	}
+
+	//To mount a volume as a bind, the host path must be specified in the Name field
+	//instead of the Source field. If the values Name/Destination are empty, volumes will be ignored, not mounted,
+	//and the system-controller container will fail to start.
+
+	mounts := []container.Volume{}
+
+	volumeDestination := fmt.Sprintf("/var/run/%s.sock", platform)
+
+	if strings.HasPrefix(config.containerEndpoint, "unix://") {
+		socketPath := strings.TrimPrefix(config.containerEndpoint, "unix://")
+		mounts = append(mounts, container.Volume{
+			Name:        socketPath,
+			Destination: volumeDestination,
+			Mode:        "z",
+			RW:          true,
+		})
+	} else if strings.HasPrefix(config.containerEndpoint, "/") {
+
+		mounts = append(mounts, container.Volume{
+			Name:        config.containerEndpoint,
+			Destination: volumeDestination,
+			Mode:        "z",
+			RW:          true,
+		})
+	}
+
+	mounts = append(mounts, container.Volume{
+		Name:        config.hostDataHome,
+		Destination: "/output",
+		Mode:        "z",
+		RW:          true,
+	})
+
+	//This is necessary to start the container with an equivalent to "--security-opt label=disable"
+	annotations := map[string]string{
+		"io.podman.annotations.label": "disable",
+	}
+
+	sysControllerContainer := container.Container{
+		Name:        containerName,
+		Image:       images.GetSystemControllerImageName(),
+		Env:         env,
+		Mounts:      mounts,
+		Annotations: annotations,
+	}
+
+	err = cli.ContainerCreate(&sysControllerContainer)
+	if err != nil {
+		return fmt.Errorf("failed to create system-controller container: %v", err)
+	}
+	err = cli.ContainerStart(sysControllerContainer.Name)
+	if err != nil {
+		return fmt.Errorf("failed to start system-controller container: %v", err)
+	}
+
+	err = createSystemdService(sysControllerContainer, platform)
+	if err != nil {
+		return fmt.Errorf("failed to create system-controller systemd service: %v", err)
+	}
+
+	fmt.Printf("Platform %s is now configured for Skupper\n", platform)
+
 	return nil
 }
+
+func configEnvVariables(platform string) (*ControllerConfig, error) {
+
+	controllerConfig := ControllerConfig{}
+	var err error
+
+	// Set CONTAINER_ENGINE
+	switch platform {
+	case "docker":
+		_, err = exec.LookPath("docker")
+		if err != nil {
+			return nil, fmt.Errorf("docker not found")
+		}
+
+	default:
+		_, err = exec.LookPath("podman")
+		if err != nil {
+			return nil, fmt.Errorf("podman not found")
+		}
+
+	}
+	err = os.Setenv("CONTAINER_ENGINE", platform)
+	if err != nil {
+		return nil, err
+	}
+
+	controllerConfig.containerEngine = platform
+
+	// Get current username
+	currentUser, err := user.Current()
+	if err != nil {
+		return nil, fmt.Errorf("Failed to get current user: %v", err)
+	}
+	uid := currentUser.Uid
+	controllerConfig.username = currentUser.Username
+
+	xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR")
+	if xdgRuntimeDir == "" {
+		xdgRuntimeDir = fmt.Sprintf("/run/user/%s", uid)
+	}
+
+	containerEndpointDefault := fmt.Sprintf("unix://%s/podman/podman.sock", xdgRuntimeDir)
+
+	if platform == "docker" {
+		containerEndpointDefault = "unix:///run/docker.sock"
+	}
+
+	uidInt, _ := strconv.Atoi(uid)
+	xdgDataHome := "/output"
+	hostDataHome := api.GetHostDataHome()
+	if uidInt == 0 {
+		if platform == "podman" {
+			containerEndpointDefault = "unix:///run/podman/podman.sock"
+		}
+		hostDataHome = "/var/lib/skupper"
+	}
+
+	if err := os.MkdirAll(api.GetDefaultOutputNamespacesPath(), 0755); err != nil {
+		return nil, fmt.Errorf("Failed to create directory: %v", err)
+	}
+
+	containerEndpoint := os.Getenv("CONTAINER_ENDPOINT")
+	if containerEndpoint == "" {
+		containerEndpoint = containerEndpointDefault
+	}
+
+	err = os.Setenv("CONTAINER_ENDPOINT", containerEndpoint)
+	if err != nil {
+		return nil, err
+	}
+
+	controllerConfig.containerEndpoint = containerEndpoint
+	controllerConfig.containerEndpointDefault = containerEndpointDefault
+	controllerConfig.hostDataHome = hostDataHome
+	controllerConfig.xdgDataHome = xdgDataHome
+
+	return &controllerConfig, nil
+}
+
+func createSystemdService(container container.Container, platform string) error {
+
+	// Creating startup scripts
+	startupArgs := controller.StartupScriptsArgs{
+		Name:     container.Name,
+		Platform: types.Platform(platform),
+	}
+	scripts, err := controller.GetStartupScripts(startupArgs, api.GetSystemControllerPath())
+	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)
+	}
+
+	// Creating systemd user service
+	systemd, err := controller.NewSystemdServiceInfo(container, platform)
+	if err != nil {
+		return err
+	}
+	if err = systemd.Create(); err != nil {
+		return fmt.Errorf("unable to create startup service %q - %v", systemd.GetServiceName(), err)
+	}
+
+	return nil
+}
+
+func IsContainerRunning(containerName string, platform types.Platform) bool {
+
+	endpoint := fmt.Sprintf("unix://%s/podman/podman.sock", api.GetRuntimeDir())
+	if platform == types.PlatformDocker {
+		endpoint = "unix:///run/docker.sock"
+	}
+
+	cli, err := internalclient.NewCompatClient(endpoint, "")
+	if err != nil {
+		return false
+	}
+
+	containers, err := cli.ContainerList()
+	if err != nil {
+		return false
+	}
+
+	for _, container := range containers {
+		if container.Name == containerName {
+			return true
+		}
+	}
+
+	return false
+}
diff --git a/internal/nonkube/bootstrap/uninstall.go b/internal/nonkube/bootstrap/uninstall.go
index f8a27f87..2890bcbd 100644
--- a/internal/nonkube/bootstrap/uninstall.go
+++ b/internal/nonkube/bootstrap/uninstall.go
@@ -1,14 +1,75 @@
 package bootstrap
 
 import (
-	"github.com/skupperproject/skupper/internal/nonkube/common"
-	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	"fmt"
 	"os"
+	"os/user"
 	"path"
+
+	"github.com/skupperproject/skupper/api/types"
+	"github.com/skupperproject/skupper/internal/nonkube/bootstrap/controller"
+	internalclient "github.com/skupperproject/skupper/internal/nonkube/client/compat"
+	"github.com/skupperproject/skupper/internal/nonkube/common"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
 )
 
 func Uninstall(platform string) error {
 
+	currentUser, err := user.Current()
+	if err != nil {
+		return fmt.Errorf("failed to get current user: %v", err)
+	}
+
+	containerName := fmt.Sprintf("%s-skupper-controller", currentUser.Username)
+
+	isContainerAlreadyRunningInPodman := IsContainerRunning(containerName, types.PlatformPodman)
+
+	if isContainerAlreadyRunningInPodman && platform == "docker" {
+		fmt.Printf("Warning: The system controller container %q is already running in Podman but the selected platform is Docker.\n", containerName)
+		return nil
+	}
+
+	isContainerAlreadyRunningInDocker := IsContainerRunning(containerName, types.PlatformDocker)
+
+	if isContainerAlreadyRunningInDocker && platform == "podman" {
+		fmt.Printf("Warning: The system controller container %q is already running in Docker but the selected platform is Podman.\n", containerName)
+		return nil
+	}
+
+	endpoint := ""
+
+	if platform == "docker" {
+		endpoint = "unix:///run/docker.sock"
+	}
+
+	cli, err := internalclient.NewCompatClient(endpoint, "")
+	if err != nil {
+		return fmt.Errorf("failed to create container client: %v", err)
+	}
+
+	container, err := cli.ContainerInspect(containerName)
+	if err != nil || container == nil {
+		return nil
+	}
+
+	err = cli.ContainerStop(containerName)
+	if err != nil {
+		return fmt.Errorf("failed to stop system-controller container: %v", err)
+	}
+
+	err = cli.ContainerRemove(containerName)
+	if err != nil {
+		return fmt.Errorf("failed to remove system-controller container: %v", err)
+	}
+
+	systemdService, err := controller.NewSystemdServiceInfo(*container, platform)
+	if err != nil {
+		return nil
+
+	}
+
+	systemdService.Remove()
+
 	systemdGlobal, err := common.NewSystemdGlobal(platform)
 	if err != nil {
 		return err
@@ -19,12 +80,14 @@ func Uninstall(platform string) error {
 		return err
 	}
 
+	fmt.Printf("Platform %s infrastructure for Skupper is now uninstalled\n", platform)
+
 	return nil
 }
 
 func CheckActiveSites() (bool, error) {
 
-	entries, err := os.ReadDir(path.Join(api.GetDataHome(), "namespaces/"))
+	entries, err := os.ReadDir(path.Join(api.GetHostDataHome(), "namespaces/"))
 	if err != nil {
 		return false, err
 	}
diff --git a/internal/nonkube/bundle/install.sh.template b/internal/nonkube/bundle/install.sh.template
index dfa7f772..7fb48c41 100644
--- a/internal/nonkube/bundle/install.sh.template
+++ b/internal/nonkube/bundle/install.sh.template
@@ -40,7 +40,7 @@ fi
 export NAMESPACES_PATH="${SKUPPER_OUTPUT_PATH}/namespaces"
 export PLATFORM_FILE="${NAMESPACES_PATH}/${NAMESPACE}/internal/platform.yaml"
 export USER="${USER:-$(id -un)}"
-SITE_ID="$(hostname -s)-${USER}-$(date +%s)"
+SITE_ID="$(hostname -s)-${USER}-$(date +%s-%N)"
 export SITE_ID
 export NORMAL_PORT=""
 
diff --git a/internal/nonkube/client/compat/client.go b/internal/nonkube/client/compat/client.go
index bf703ada..e7ea4758 100644
--- a/internal/nonkube/client/compat/client.go
+++ b/internal/nonkube/client/compat/client.go
@@ -159,10 +159,10 @@ func GetDefaultContainerEndpoint() string {
 	if os.Getenv(types.ENV_PLATFORM) == "docker" {
 		return "unix:///run/docker.sock"
 	}
-	return fmt.Sprintf("unix://%s/podman/podman.sock", getRuntimeDir())
+	return fmt.Sprintf("unix://%s/podman/podman.sock", GetRuntimeDir())
 }
 
-func getRuntimeDir() string {
+func GetRuntimeDir() string {
 	if getUid() == 0 {
 		return "/run"
 	}
diff --git a/internal/nonkube/client/fs/custom_resource_handler.go b/internal/nonkube/client/fs/custom_resource_handler.go
index 546dd835..a30a6ce5 100644
--- a/internal/nonkube/client/fs/custom_resource_handler.go
+++ b/internal/nonkube/client/fs/custom_resource_handler.go
@@ -102,6 +102,8 @@ func (b *BaseCustomResourceHandler) DeleteFile(path string, name string, kind st
 	completeFilePath = filepath.Join(path, fmt.Sprintf("%s-%s", kind, name))
 	if kind == "" && name == "" {
 		completeFilePath = path
+	} else if strings.HasPrefix(name, kind+"-") {
+		completeFilePath = filepath.Join(path, name)
 	}
 
 	_, err := os.Stat(completeFilePath)
diff --git a/internal/nonkube/client/fs/link_handler.go b/internal/nonkube/client/fs/link_handler.go
index d400d09b..0543fd68 100644
--- a/internal/nonkube/client/fs/link_handler.go
+++ b/internal/nonkube/client/fs/link_handler.go
@@ -39,33 +39,50 @@ func (s *LinkHandler) Add(resource v2alpha1.Link) error {
 
 func (s *LinkHandler) Get(name string, opts GetOptions) (*v2alpha1.Link, error) {
 	var context v2alpha1.Link
-	fileName := name + ".yaml"
+	fileName := name
+	if !strings.HasSuffix(name, "yaml") {
+		fileName = name + ".yaml"
+	}
 
-	// Read from runtime directory
-	err, link := s.ReadFile(s.pathProvider.GetRuntimeNamespace(), fileName, common.Links)
-	if err != nil {
-		if opts.LogWarning {
-			os.Stderr.WriteString("Site not initialized yet\n")
+	if opts.RuntimeFirst == true {
+		// Read from runtime directory
+		err, link := s.ReadFile(s.pathProvider.GetRuntimeNamespace(), fileName, common.Links)
+		if err != nil {
+			if opts.LogWarning {
+				os.Stderr.WriteString("Site not initialized yet\n")
+			}
+			return nil, err
 		}
-		return nil, err
-	}
 
-	// remove the secret and parse link
-	lastIndex := strings.LastIndex(string(link), "---")
-	index := 0
-	if lastIndex != -1 {
-		index = lastIndex + 3
-	}
+		// remove the secret and parse link
+		lastIndex := strings.LastIndex(string(link), "---")
+		index := 0
+		if lastIndex != -1 {
+			index = lastIndex + 3
+		}
 
-	if err = s.DecodeYaml(link[index:], &context); err != nil {
-		return nil, err
+		if err = s.DecodeYaml(link[index:], &context); err != nil {
+			return nil, err
+		}
+	} else {
+		// read from input directory to get latest config
+		err, file := s.ReadFile(s.pathProvider.GetNamespace(), fileName, common.Links)
+		if err != nil {
+			return nil, err
+		}
+		if err := s.DecodeYaml(file, &context); err != nil {
+			return nil, err
+		}
 	}
 
 	return &context, nil
 }
 
 func (s *LinkHandler) Delete(name string) error {
-	fileName := name + ".yaml"
+	fileName := name
+	if !strings.HasSuffix(name, "yaml") {
+		fileName = name + ".yaml"
+	}
 
 	if err := s.DeleteFile(s.pathProvider.GetNamespace(), fileName, common.Links); err != nil {
 		return err
diff --git a/internal/nonkube/client/runtime/info.go b/internal/nonkube/client/runtime/info.go
index 6c8608c9..06bc4e57 100644
--- a/internal/nonkube/client/runtime/info.go
+++ b/internal/nonkube/client/runtime/info.go
@@ -21,5 +21,8 @@ func GetLocalRouterPort(namespace string) (int, error) {
 	if err != nil {
 		return 0, fmt.Errorf("unable to determine router port: %w", err)
 	}
+	if len(ra.Spec.Roles) == 0 {
+		return 0, fmt.Errorf("no roles defined on RouterAccess: %s", ra.Name)
+	}
 	return ra.Spec.Roles[0].Port, nil
 }
diff --git a/internal/nonkube/common/fs_config_renderer.go b/internal/nonkube/common/fs_config_renderer.go
index afaf0448..0d3a425e 100644
--- a/internal/nonkube/common/fs_config_renderer.go
+++ b/internal/nonkube/common/fs_config_renderer.go
@@ -282,7 +282,7 @@ func (c *FileSystemConfigurationRenderer) createTokens(siteState *api.SiteState)
 		if err != nil {
 			return fmt.Errorf("unable to load client secret %s: %v", secretName, err)
 		}
-		routerTokens := api.CreateTokens(*linkAccess, *serverSecret, *secret)
+		routerTokens := api.CreateTokens(*linkAccess, serverSecret, *secret)
 		// routerAccess is valid (inter-router and edge endpoints defined)
 		if len(routerTokens) > 0 {
 			tokens = append(tokens, routerTokens...)
@@ -371,18 +371,21 @@ func (c *FileSystemConfigurationRenderer) createTlsCertificates(siteState *api.S
 		if certificate.Spec.Signing == false {
 			continue
 		}
-		secret := certs.GenerateSecret(name, certificate.Spec.Subject, "", 0, nil)
+		secret, err := certs.GenerateSecret(name, certificate.Spec.Subject, "", 0, nil)
+		if err != nil {
+			return err
+		}
 
 		ignoreExisting := true
 		userCaSecret, err := c.loadUserCertAsSecret(siteState, "ca", name)
 		if userCaSecret != nil && err == nil {
 			// override with user provided CA
 			ignoreExisting = false
-			secret = *userCaSecret
+			secret = userCaSecret
 			fmt.Printf("-> User provided CA found: %s\n", name)
 		}
 		caPath := path.Join(outputPath, string(api.IssuersPath), name)
-		err = writeSecretFilesIgnore(caPath, &secret, ignoreExisting)
+		err = writeSecretFilesIgnore(caPath, secret, ignoreExisting)
 		if err != nil {
 			return err
 		}
@@ -390,7 +393,7 @@ func (c *FileSystemConfigurationRenderer) createTlsCertificates(siteState *api.S
 	// generate all other certificates now
 	for name, certificate := range siteState.Certificates {
 		var purpose string
-		var secret corev1.Secret
+		var secret *corev1.Secret
 		var caSecret *corev1.Secret
 		if certificate.Spec.Ca != "" {
 			caSecret, err = c.loadCASecret(siteState, certificate.Spec.Ca)
@@ -400,25 +403,31 @@ func (c *FileSystemConfigurationRenderer) createTlsCertificates(siteState *api.S
 		}
 		if certificate.Spec.Client {
 			purpose = "client"
-			secret = certs.GenerateSecret(name, certificate.Spec.Subject, strings.Join(certificate.Spec.Hosts, ","), 0, caSecret)
+			secret, err = certs.GenerateSecret(name, certificate.Spec.Subject, strings.Join(certificate.Spec.Hosts, ","), 0, caSecret)
+			if err != nil {
+				return err
+			}
 			// TODO Not sure if connect.json is needed (probably need to get rid of it)
 			if connectJson := c.connectJson(siteState); connectJson != nil {
 				secret.Data["connect.json"] = []byte(*connectJson)
 			}
 		} else if certificate.Spec.Server {
 			purpose = "server"
-			secret = certs.GenerateSecret(name, certificate.Spec.Subject, strings.Join(certificate.Spec.Hosts, ","), 0, caSecret)
+			secret, err = certs.GenerateSecret(name, certificate.Spec.Subject, strings.Join(certificate.Spec.Hosts, ","), 0, caSecret)
+			if err != nil {
+				return err
+			}
 		} else {
 			continue
 		}
 		userSecret, err := c.loadUserCertAsSecret(siteState, purpose, name)
 		if userSecret != nil && err == nil {
 			// override with user provided secret
-			secret = *userSecret
+			secret = userSecret
 			fmt.Printf("-> User provided %s certificate found: %s\n", purpose, name)
 		}
 		certPath := path.Join(outputPath, string(api.CertificatesPath), name)
-		err = writeSecretFiles(certPath, &secret)
+		err = writeSecretFiles(certPath, secret)
 		if err != nil {
 			return err
 		}
diff --git a/internal/nonkube/common/startsh-container.template b/internal/nonkube/common/startsh-container.template
index 4d053a3c..f7b0f685 100644
--- a/internal/nonkube/common/startsh-container.template
+++ b/internal/nonkube/common/startsh-container.template
@@ -3,7 +3,7 @@
 set -o errexit
 set -o nounset
 
-for container in $({{.ContainerEngine}} container ls --filter label=application=skupper --filter label=skupper.io/site-id={{.SiteId}} --format {{"{{"}}.Names{{"}}"}} --all); do
+for container in $({{.ContainerEngine}} container ls --filter label=application=skupper-v2 --filter label=skupper.io/site-id={{.SiteId}} --format {{"{{"}}.Names{{"}}"}} --all); do
     {{.ContainerEngine}} start ${container}
 done
 
diff --git a/internal/nonkube/common/stopsh-container.template b/internal/nonkube/common/stopsh-container.template
index 5c71118f..9b9f6a5c 100644
--- a/internal/nonkube/common/stopsh-container.template
+++ b/internal/nonkube/common/stopsh-container.template
@@ -3,6 +3,6 @@
 set -o errexit
 set -o nounset
 
-for container in $({{.ContainerEngine}} container ls --filter label=application=skupper --filter label=skupper.io/site-id={{.SiteId}} --format {{"{{"}}.Names{{"}}"}} --all); do
+for container in $({{.ContainerEngine}} container ls --filter label=application=skupper-v2 --filter label=skupper.io/site-id={{.SiteId}} --format {{"{{"}}.Names{{"}}"}} --all); do
     {{.ContainerEngine}} stop -t 10 ${container}
 done
diff --git a/internal/nonkube/common/systemd.go b/internal/nonkube/common/systemd.go
index 1153fed7..4f945356 100644
--- a/internal/nonkube/common/systemd.go
+++ b/internal/nonkube/common/systemd.go
@@ -286,49 +286,41 @@ func NewSystemdGlobal(platform string) (SystemdGlobal, error) {
 func (sg *systemdGlobal) getCmdEnableSocket() *exec.Cmd {
 	if sg.getUid() == 0 {
 		return sg.command("systemctl", "enable", sg.platform+".socket")
-	} else if sg.platform == "podman" {
-		return sg.command("systemctl", "--user", "enable", sg.platform+".socket")
 	}
-	return nil
+
+	return sg.command("systemctl", "--user", "enable", "podman.socket")
 }
 
 func (sg *systemdGlobal) getCmdStartSocket() *exec.Cmd {
 	if sg.getUid() == 0 {
 		return sg.command("systemctl", "start", sg.platform+".socket")
-	} else if sg.platform == "podman" {
-		return sg.command("systemctl", "--user", "start", sg.platform+".socket")
 	}
-	return nil
+
+	return sg.command("systemctl", "--user", "start", "podman.socket")
+
 }
 
 func (sg *systemdGlobal) getCmdStopSocket() *exec.Cmd {
 
 	if sg.getUid() == 0 {
 		return sg.command("systemctl", "stop", sg.platform+".socket")
-	} else if sg.platform == "podman" {
-		return sg.command("systemctl", "--user", "stop", sg.platform+".socket")
 	}
 
-	return nil
+	return sg.command("systemctl", "--user", "stop", "podman.socket")
+
 }
 
 func (sg *systemdGlobal) getCmdDisableSocket() *exec.Cmd {
 
 	if sg.getUid() == 0 {
 		return sg.command("systemctl", "disable", sg.platform+".socket")
-	} else if sg.platform == "podman" {
-		return sg.command("systemctl", "--user", "disable", sg.platform+".socket")
 	}
 
-	return nil
+	return sg.command("systemctl", "--user", "disable", "podman.socket")
 }
 
 func (sg *systemdGlobal) Enable() error {
 
-	if sg.platform == "docker" && sg.getUid() != 0 {
-		return fmt.Errorf("Docker installation must be run as root user.")
-	}
-
 	err := sg.getCmdEnableSocket().Run()
 	if err != nil {
 		return err
diff --git a/internal/qdr/qdr.go b/internal/qdr/qdr.go
index 30c21c27..32625fde 100644
--- a/internal/qdr/qdr.go
+++ b/internal/qdr/qdr.go
@@ -824,8 +824,14 @@ func (r *RouterConfig) UpdateConfigMap(configmap *corev1.ConfigMap) (bool, error
 
 type ListenerPredicate func(Listener) bool
 
-func IsNotNormalListener(l Listener) bool {
-	return l.Role != "normal" && l.Role != ""
+func IsNotProtectedListener(l Listener) bool {
+	protectedNames := [3]string{"@9090", "amqp", "amqps"}
+	for _, name := range protectedNames {
+		if l.Name == name {
+			return false
+		}
+	}
+	return true
 }
 
 func FilterListeners(in map[string]Listener, predicate ListenerPredicate) map[string]Listener {
diff --git a/internal/site/routeraccess.go b/internal/site/routeraccess.go
index 7e962db9..23b9d57f 100644
--- a/internal/site/routeraccess.go
+++ b/internal/site/routeraccess.go
@@ -74,7 +74,7 @@ type RouterAccessConfig struct {
 
 func (g *RouterAccessConfig) Apply(config *qdr.RouterConfig) bool {
 	changed := false
-	lc := qdr.ListenersDifference(config.GetMatchingListeners(qdr.IsNotNormalListener), g.listeners)
+	lc := qdr.ListenersDifference(config.GetMatchingListeners(qdr.IsNotProtectedListener), g.listeners)
 	// delete before add with listeners, as changes are handled as delete and add
 	for _, value := range lc.Deleted {
 		if removed, _ := config.RemoveListener(value.Name); removed {
diff --git a/pkg/nonkube/api/environment.go b/pkg/nonkube/api/environment.go
index acf9cfaa..f055d818 100644
--- a/pkg/nonkube/api/environment.go
+++ b/pkg/nonkube/api/environment.go
@@ -188,3 +188,8 @@ func GetInternalOutputPath(namespace string, internalPath InternalPath) string {
 func GetInternalBundleOutputPath(namespace string, internalPath InternalPath) string {
 	return path.Join(GetDefaultBundleOutputPath(namespace), string(internalPath))
 }
+
+func GetSystemControllerPath() string {
+	dataHome := GetHostDataHome()
+	return path.Join(dataHome, "system-controller")
+}
diff --git a/pkg/nonkube/api/site_state.go b/pkg/nonkube/api/site_state.go
index 38421939..4b3c2b33 100644
--- a/pkg/nonkube/api/site_state.go
+++ b/pkg/nonkube/api/site_state.go
@@ -283,7 +283,8 @@ func (s *SiteState) ToRouterConfig(sslProfileBasePath string, platform string) q
 	}
 	var routerName = s.Site.Name
 	if !s.bundle {
-		routerName = fmt.Sprintf("%s-%d", s.Site.Name, time.Now().Unix())
+		now := time.Now()
+		routerName = fmt.Sprintf("%s-%d-%d", s.Site.Name, now.Unix(), now.Nanosecond())
 	}
 	routerConfig := qdr.InitialConfig(routerName, s.SiteId, version.Version, !s.IsInterior(), 3)
 	routerConfig.AddAddress(qdr.Address{
@@ -308,6 +309,7 @@ func (s *SiteState) ToRouterConfig(sslProfileBasePath string, platform string) q
 		}
 		metadataJson, _ := encodingjson.Marshal(metadata)
 		routerConfig.Metadata.Metadata = string(metadataJson)
+		routerConfig.SiteConfig.Name += "-{{.SiteNameSuffix}}"
 		routerConfig.SiteConfig.Namespace = "{{.Namespace}}"
 		routerConfig.SiteConfig.Platform = "{{.Platform}}"
 	}
diff --git a/pkg/nonkube/api/token.go b/pkg/nonkube/api/token.go
index f6791152..36dd206f 100644
--- a/pkg/nonkube/api/token.go
+++ b/pkg/nonkube/api/token.go
@@ -11,7 +11,7 @@ import (
 
 	"github.com/skupperproject/skupper/internal/utils"
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1"
-	"k8s.io/api/core/v1"
+	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime/serializer/json"
 	"k8s.io/client-go/kubernetes/scheme"
@@ -44,7 +44,7 @@ func (t *Token) Marshal() ([]byte, error) {
 	return buffer.Bytes(), nil
 }
 
-func CreateTokens(routerAccess v2alpha1.RouterAccess, serverSecret v1.Secret, clientSecret v1.Secret) []*Token {
+func CreateTokens(routerAccess v2alpha1.RouterAccess, serverSecret *v1.Secret, clientSecret v1.Secret) []*Token {
 	var tokens []*Token
 	interRouter := 0
 	edge := 0
@@ -106,19 +106,22 @@ func CreateTokens(routerAccess v2alpha1.RouterAccess, serverSecret v1.Secret, cl
 	var hosts []string
 	hosts = append(hosts, utils.DefaultStr(routerAccess.Spec.BindHost, "127.0.0.1"))
 	// reading SANs from server certificate
-	serverCertificateData := serverSecret.Data["tls.crt"]
-	serverCertificateBlk, _ := pem.Decode(serverCertificateData)
-	if serverCertificateBlk != nil {
-		serverCertificate, err := x509.ParseCertificate(serverCertificateBlk.Bytes)
-		if err == nil {
-			for _, ipAddr := range serverCertificate.IPAddresses {
-				if ipAddr.String() != "" && !slices.Contains(hosts, ipAddr.String()) {
-					hosts = append(hosts, ipAddr.String())
+
+	if serverSecret != nil {
+		serverCertificateData := serverSecret.Data["tls.crt"]
+		serverCertificateBlk, _ := pem.Decode(serverCertificateData)
+		if serverCertificateBlk != nil {
+			serverCertificate, err := x509.ParseCertificate(serverCertificateBlk.Bytes)
+			if err == nil {
+				for _, ipAddr := range serverCertificate.IPAddresses {
+					if ipAddr.String() != "" && !slices.Contains(hosts, ipAddr.String()) {
+						hosts = append(hosts, ipAddr.String())
+					}
 				}
-			}
-			for _, dnsName := range serverCertificate.DNSNames {
-				if dnsName != "" && !slices.Contains(hosts, dnsName) {
-					hosts = append(hosts, dnsName)
+				for _, dnsName := range serverCertificate.DNSNames {
+					if dnsName != "" && !slices.Contains(hosts, dnsName) {
+						hosts = append(hosts, dnsName)
+					}
 				}
 			}
 		}
diff --git a/scripts/kind-dev-cluster b/scripts/kind-dev-cluster
index 3079ac10..5a774d07 100755
--- a/scripts/kind-dev-cluster
+++ b/scripts/kind-dev-cluster
@@ -13,13 +13,15 @@ USAGE: $0 [options...]
  -n, --name		    Name of the cluster to create [default skupper-dev]
  -i, --images	    Source of skupper images
 	One of docker, podman, ociarchive, none [default docker]
+ -c, --container    Container platform to use
+	One of docker, podman [default docker]
+ -v, --verbose      Produces verbose output 
 EOF
 }
 
 readonly KIND=${KIND:-kind}
 readonly KUBECTL=${KUBECTL:-kubectl}
 readonly HELM=${HELM:-helm}
-readonly DOCKER=${DOCKER:-docker}
 readonly PYTHON=${PYTHON:-python3}
 readonly IMAGE_TAG=${IMAGE_TAG:-v2-dev}
 readonly ROUTER_IMAGE_TAG=${SKUPPER_ROUTER_IMAGE_TAG:-main}
@@ -28,66 +30,94 @@ readonly SKUPPER_ROUTER_IMAGE=${SKUPPER_ROUTER_IMAGE:-${REGISTRY}/skupper-router
 readonly SKUPPER_CONTROLLER_IMAGE=${SKUPPER_CONTROLLER_IMAGE:-${REGISTRY}/controller:${IMAGE_TAG}}
 readonly SKUPPER_KUBE_ADAPTOR_IMAGE=${SKUPPER_KUBE_ADAPTOR_IMAGE:-${REGISTRY}/kube-adaptor:${IMAGE_TAG}}
 readonly SKUPPER_NETWORK_OBSERVER_IMAGE=${SKUPPER_NETWORK_OBSERVER_IMAGE:-${REGISTRY}/network-observer:${IMAGE_TAG}}
-
+CONTAINER=docker
 KIND_LOG_LEVEL="1"
-DEBUG=${DEBUG:=false}
+VERBOSE=${VERBOSE:=false}
 CLUSTER="skupper-dev"
 IMAGE_SOURCE="docker"
 FORCE_RECREATE="false"
 METALLB="false"
 SUBNET="1"
 
-if [ "${DEBUG}" == "true" ]; then
-  set -x
-  KIND_LOG_LEVEL="6"
-fi
+verbose_log() {
+	if [ "${VERBOSE}" == "true" ]; then
+		echo "(skdev) $1"
+	fi
+}
 
 ensure::kind() {
 	if ! command -v "${KIND}" > /dev/null 2>&1; then
-		echo "${KIND} not found";
+		echo "(skdev) ${KIND} not found, exiting";
 		echo "See https://kind.sigs.k8s.io/ for installation and usage.";
 		exit 1
+	else 
+	   verbose_log "Found Kind ..."
 	fi
 }
-ensure::docker() {
-	if ! command -v "${DOCKER}" > /dev/null 2>&1; then
-		echo "${DOCKER} not found";
-		echo "This tool assumes docker is used to run kind and does not support the experimental podman provider";
+
+ensure::container() {
+	if [[ "$CONTAINER" == "docker" ]]; then
+		if ! command -v docker > /dev/null 2>&1; then
+			echo "(skdev) docker not found, exiting"
+			exit 1
+		else
+		  	DOCKER_VERSION=$(docker --version)
+			verbose_log "docker found. Version: $DOCKER_VERSION"
+		fi
+	elif [[ "$CONTAINER" == "podman" ]]; then
+		if command -v podman > /dev/null 2>&1; then
+			# Podman is found. Get and print its version and set KIND_EXPERIMENTAL_PROVIDER
+			PODMAN_VERSION=$(podman --version)
+			verbose_log "podman found. Version: $PODMAN_VERSION"
+			export KIND_EXPERIMENTAL_PROVIDER=podman
+		else
+			# Podman is not found. Print error and exit.
+			echo "(skdev) podman not found, exiting"
+			exit 1
+		fi
+	else
+	    echo "Invalid container specified, must be docker or podman"
 		exit 1
 	fi
 }
+
 ensure::helm() {
 	if ! command -v "${HELM}" > /dev/null 2>&1; then
-		echo "${HELM} not found";
-		echo "This tool uses helm to enable some features. See https://helm.sh/ for installation.";
+		verbose_log "This tool uses helm to enable some features. See https://helm.sh/ for installation.";
+		echo "${HELM} not found, exiting";
 		exit 1
 	fi
 }
 ensure::python() {
 	if ! command -v "${PYTHON}" > /dev/null 2>&1; then
-		echo "${PYTHON} not found";
 		echo "This tool uses python3 for munging subnet addresses for installing metallb.";
+		echo "${PYTHON} not found, exiting";
 		exit 1
+	else
+	    verbose_log "Python version is $(python --version)"
 	fi
 }
 kind::cluster::list() {
     ${KIND} get clusters
 }
+
 kind::cluster::delete() {
     ${KIND} delete cluster \
         --name "$1"
 }
+
 kind::cluster::create() {
     ${KIND} create cluster \
 		--verbosity="${KIND_LOG_LEVEL}" \
         --name "$1"
 }
+
 kind::imageload::docker() {
 	for image in "${SKUPPER_CONTROLLER_IMAGE}" \
 		"${SKUPPER_KUBE_ADAPTOR_IMAGE}" \
 		"${SKUPPER_ROUTER_IMAGE}" \
 		"${SKUPPER_NETWORK_OBSERVER_IMAGE}"; do
-		if ${DOCKER} image inspect "$image" > /dev/null 2>&1; then
+		if ${CONTAINER} image inspect "$image" > /dev/null 2>&1; then
 		    ${KIND} load docker-image --name="$1" "$image"
 		else
 			echo "(skdev) WARNING: skipped loading image $image"
@@ -114,8 +144,12 @@ kind::imageload::podman() {
 	done
 }
 
-docker::network::subnet () {
-		${DOCKER} network inspect "$1" | jq -r '.[].IPAM.Config[0].Subnet'
+container::network::subnet() {
+	    subnetPath='.[].IPAM.Config[].Subnet  | select(test("([0-9]{1,3}[.]){3}[0-9]{1,3}/[1-3]?[0-9]")?)'
+		if [ "${CONTAINER}" == "podman" ]; then
+		    subnetPath='.[].subnets[].subnet | select(test("([0-9]{1,3}[.]){3}[0-9]{1,3}/[1-3]?[0-9]")?)'
+		fi
+		${CONTAINER} network inspect "$1" | jq -r "$subnetPath"								
 }
 
 metallb::l2::config() {
@@ -127,6 +161,8 @@ metadata:
   name: default
   namespace: metallb-system
 spec:
+  avoidBuggyIPs: true
+  autoAssign: true
   addresses:
   - ${subnet}
 ---
@@ -164,11 +200,22 @@ main () {
 				shift
 				shift
 				;;
+			-c|--container)
+				CONTAINER="$2"
+				shift
+				shift
+				;;				
 			-i|--images)
 				IMAGE_SOURCE="$2"
 				shift
 				shift
 				;;
+			-v|--verbose)
+				VERBOSE="true"
+				set -x
+				KIND_LOG_LEVEL="6"
+				shift
+				;;				
 			*)
 				echo "Unknown argument $1"
 				usage
@@ -178,7 +225,7 @@ main () {
 	done
 
 	ensure::kind
-	ensure::docker
+	ensure::container
 	if [ -z "${KUBECONFIG-}" ]; then
 		export KUBECONFIG="$HOME/.kube/skupperdev-config-$CLUSTER"
 		echo "(skdev) WARNING: KUBECONFIG not set. Defaulting to ${KUBECONFIG}"
@@ -218,13 +265,18 @@ main () {
 		ensure::helm
 		ensure::python
 		echo "(skdev) deploying metallb to ${CLUSTER}"
-		kind_subnet=$(docker::network::subnet kind)
+		kind_subnet=$(container::network::subnet kind)
+
+		METALLB_DEBUG_FLAG=""
+		if [[ "${VERBOSE}" == "true" ]]; then
+			METALLB_DEBUG_FLAG="--debug"
+		fi
 		"${HELM}" repo add metallb https://metallb.github.io/metallb
 		"${HELM}" upgrade --install metallb metallb/metallb \
 			--namespace metallb-system --create-namespace \
 			--set speaker.ignoreExcludeLB=true \
-			--version 0.14.* \
-			--wait
+			--version 0.15.* \
+			--wait ${METALLB_DEBUG_FLAG} # empty or --debug
 		"${KUBECTL}" apply -f <(metallb::l2::config "$kind_subnet" "$SUBNET")
 	fi
 
diff --git a/scripts/skupper-network-observer-operator-generator.sh b/scripts/skupper-network-observer-operator-generator.sh
new file mode 100755
index 00000000..fc1ac5d7
--- /dev/null
+++ b/scripts/skupper-network-observer-operator-generator.sh
@@ -0,0 +1,304 @@
+#! /usr/bin/env bash
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+readonly SKUPPER_IMAGE_TAG=${1:-2.0.1}
+
+readonly OPERATOR_SDK=${OPERATOR_SDK:-operator-sdk}
+readonly KUBECTL=${KUBECTL:-kubectl}
+readonly MIN_KUBE_VERSION=${MIN_KUBE_VERSION:-1.25.0}
+
+readonly BUNDLE_VERSION=${BUNDLE_VERSION:-2.0.1}
+readonly PREVIOUS_BUNDLE_VERSION=${PREVIOUS_BUNDLE_VERSION:-2.0.0}
+readonly BUNDLE_CHANNELS=${BUNDLE_CHANNELS:-"stable-2,stable-v2.0"}
+readonly BUNDLE_DEFAULT_CHANNEL=${BUNDLE_DEFAULT_CHANNEL:-stable-2}
+
+readonly REPLACED_VERSION=network-observer-operator.v${PREVIOUS_BUNDLE_VERSION}
+readonly OLM_SKIP_RANGE='">2.0.0 <2.0.1"'
+
+readonly SKUPPER_IMAGE_REGISTRY=${SKUPPER_IMAGE_REGISTRY:-quay.io/skupper}
+readonly PROMETHEUS_IMAGE_TAG=${PROMETHEUS_IMAGE_TAG:-v2.55.1}
+readonly OAUTH_PROXY_IMAGE_TAG=${OAUTH_PROXY_IMAGE_TAG:-4.18.0}
+readonly NGINX_IMAGE_TAG=${NGINX_IMAGE_TAG:-1.27.3-alpine}
+
+readonly IMG=${SKUPPER_IMAGE_REGISTRY}/network-observer-operator:v${BUNDLE_VERSION}
+readonly BUNDLE_IMG=${SKUPPER_IMAGE_REGISTRY}/network-observer-operator-bundle:v${BUNDLE_VERSION}
+
+readonly SKUPPER_NETWORK_OBSERVER_SHA=${SKUPPER_NETWORK_OBSERVER_SHA:-$(skopeo inspect --format "{{.Digest}}" docker://${SKUPPER_IMAGE_REGISTRY}/network-observer:${SKUPPER_IMAGE_TAG})}
+readonly PROMETHEUS_SHA=${PROMETHEUS_SHA:-$(skopeo inspect --format "{{.Digest}}" docker://quay.io/prometheus/prometheus:${PROMETHEUS_IMAGE_TAG})}
+readonly OAUTH_PROXY_SHA=${OAUTH_PROXY_SHA:-$(skopeo inspect --format "{{.Digest}}" docker://quay.io/openshift/origin-oauth-proxy:${OAUTH_PROXY_IMAGE_TAG})}
+readonly NGINX_SHA=${NGINX_SHA:-$(skopeo inspect --format "{{.Digest}}" docker://mirror.gcr.io/nginxinc/nginx-unprivileged:${NGINX_IMAGE_TAG})}
+
+readonly SKUPPER_NETWORK_OBSERVER_IMAGE=${SKUPPER_NETWORK_OBSERVER_IMAGE:-${SKUPPER_IMAGE_REGISTRY}/network-observer@${SKUPPER_NETWORK_OBSERVER_SHA}}
+readonly PROMETHEUS_IMAGE=${PROMETHEUS_IMAGE:-${SKUPPER_IMAGE_REGISTRY}/prometheus/prometheus@${PROMETHEUS_SHA}}
+readonly OAUTH_PROXY_IMAGE=${OAUTH_PROXY_IMAGE:-${SKUPPER_IMAGE_REGISTRY}/openshift/origin-oauth-proxy@${OAUTH_PROXY_SHA}}
+readonly NGINX_IMAGE=${NGINX_IMAGE:-mirror.gcr.io/nginxinc/nginx-unprivileged@${OAUTH_PROXY_SHA}}
+
+DEBUG=${DEBUG:=false}
+
+ensure::operator-sdk() {
+	if ! command -v "${OPERATOR_SDK}" > /dev/null 2>&1; then
+		echo "${OPERATOR_SDK} not found";
+		echo "See https://sdk.operatorframework.io/ for installation and usage.";
+		exit 1
+	fi
+}
+
+skupper::network-observer-bundle::kustomize-manager() {
+		cat << EOF
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+images:
+- name: controller
+  newName: ${SKUPPER_IMAGE_REGISTRY}/network-observer-operator
+  newTag: v${BUNDLE_VERSION}
+resources:
+- manager.yaml
+EOF
+}
+
+skupper::network-observer-bundle::kustomize-default() {
+		cat << EOF
+# Add runAsUser to deployment to pass default openshift admission controllers
+- path: runas_user_patch.yaml
+  target:
+    kind: Deployment
+# Add missing cluster roles for manager to provision non-default resources
+- path: clusterrole_patch.yaml
+  target:
+    kind: ClusterRole
+    name: manager-role
+EOF
+}
+
+skupper::network-observer-bundle::clusterrole_patch() {
+		cat << EOF
+- op: add
+  path: /rules/-
+  value:
+    apiGroups:
+      - ""
+    resources:
+      - serviceaccounts
+    verbs:
+      - "*"
+- op: add
+  path: /rules/-
+  value:
+    apiGroups:
+      - "route.openshift.io"
+    resources:
+      - routes
+    verbs:
+      - "*"
+- op: add
+  path: /rules/-
+  value:
+    apiGroups:
+      - "networking.k8s.io"
+    resources:
+      - ingress
+    verbs:
+      - "*"
+- op: add
+  path: /rules/-
+  value:
+    apiGroups:
+      - "rbac.authorization.k8s.io"
+    resources:
+      - rolebindings
+      - roles
+    verbs:
+      - "*"
+- op: add
+  path: /rules/-
+  value:
+    apiGroups:
+      - batch
+    resources:
+      - jobs
+    verbs:
+      - "*"
+- op: add
+  path: /rules/-
+  value:
+    apiGroups:
+      - skupper.io
+    resources:
+      - certificates
+    verbs:
+      - "*"
+EOF
+}
+
+skupper::network-observer-bundle::runas_user_patch() {
+		cat << EOF
+# Set runAsUser 1001 - from helm operator sdk image
+#- op: add
+#  path: /spec/template/spec/containers/0/securityContext/runAsUser
+#  value: 1001
+- op: add
+  path: /spec/template/spec/containers/0/securityContext/runAsNonRoot
+  value: true
+- op: add
+  path: /spec/template/spec/containers/0/securityContext/seccompProfile
+  value:
+    type: RuntimeDefault
+EOF
+}
+
+skupper::network-observer-bundle::kustomize-related-images() {
+		cat << EOF
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- bases/network-observer-operator.clusterserviceversion.yaml
+
+patches:
+  - path: patch-related-images.yaml
+EOF
+}
+
+skupper::network-observer-bundle::related-images() {
+		cat << EOF
+apiVersion: operators.coreos.com/v1alpha1
+kind: ClusterServiceVersion
+metadata:
+  name: network-observer-operator.v${BUNDLE_VERSION}
+  namespace: placeholder
+spec:
+  relatedImages:
+    - image: ${SKUPPER_NETWORK_OBSERVER_IMAGE}
+      name: skupper_network_observer_image    
+    - image: ${PROMETHEUS_IMAGE}
+      name: ose-prometheus
+    - image: ${OAUTH_PROXY_IMAGE}
+      name: ose-oauth-proxy
+    - image: ${NGINX_IMAGE}
+      name: nginx-unprivileged
+EOF
+}
+
+skupper::network-observer-bundle::clusterserviceversion() {
+		cat << EOF
+apiVersion: operators.coreos.com/v1alpha1
+kind: ClusterServiceVersion
+metadata:
+  annotations:
+    capabilities: Seamless Upgrades
+    categories: Integration & Delivery, Networking, Streaming & Messaging
+    certified: 'false'
+    containerImage: quay.io/skupper/network-observer:${SKUPPER_IMAGE_TAG}
+    description: Skupper Network Obsever Operator provides the ability to monitor a service network
+    olm.skipRange: ${OLM_SKIP_RANGE}
+    operators.operatorframework.io/builder: operator-sdk-v1.17.0+git
+    operators.operatorframework.io/project_layout: go.kubebuilder.io/v3
+    repository: https://github.com/skuppproject/skupper
+    support: Skupper Project
+  name: network-observer-operator.v${BUNDLE_VERSION}
+  namespace: placeholder
+  labels:
+    operatorframework.io/os.linux: supported
+    operatorframework.io/arch.amd64: supported
+    operatorframework.io/arch.s390x: supported
+spec:
+  apiservicedefinitions: {}
+  description: Skupper enables communication between services running in different network locations.
+  displayName: Skupper
+  icon:
+  - base64data: PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPjxkZWZzPjxzdHlsZT4uY2xzLTF7ZmlsbDojMzUzNTM1O30uY2xzLTJ7ZmlsbDojMzg1ODZjO30uY2xzLTN7ZmlsbDojZmZmO30uY2xzLTR7ZmlsbDojZDVjNWI3O308L3N0eWxlPjwvZGVmcz48dGl0bGU+c2t1cHBlcl9pY29uX3JnYl9kZWZhdWx0PC90aXRsZT48cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Ik05OTkuOTEsNDQ2LjQxbC0xMy42LTEuMzVjLTEuNzUtLjI2LTIyLjYtMy42OS01My4zNS0yMS44MS0xOC44MS0xMS4wOC0zNy42LTI1LjQyLTU1Ljg1LTQyLjYxLTIzLTIxLjY0LTQ1LjE1LTQ3Ljg5LTY1LjktNzhBNjAzLjIsNjAzLjIsMCwwLDAsNjI1LjgyLDEyOC40QzU0My43NCw3OS4yNyw0ODEuNDYsNjguOTMsNDY0LjMzLDY2LjkzYTMzMC44NSwzMzAuODUsMCwwLDAtMzcuODQtMi4zNEEyMjcuMjIsMjI3LjIyLDAsMCwwLDM5MCw2Ny4zN0MzNTYuNjcsNzIuNzYsMzI4LjUsODYsMzA2LjI0LDEwNi43OWMtMjAuNDYsMTkuMDktMzUuNjMsNDQuMzItNDUuMSw3NS05LjcxLDMxLjQ4LTEzLjU5LDY5LjY2LTExLjU0LDExMy40OCwyLDQyLjk0LTIuMjUsOTEuODEtMTIuMzEsMTQxLjMyYTcyOS4zMSw3MjkuMzEsMCwwLDEtNDUuNzUsMTQ0Ljg2Yy0yMC42MSw0Ny4wNy00NC43Miw4Ny44NC03MS42NSwxMjEuMTgtMjcuNDIsMzMuOTQtNTYuMjMsNTguMjEtODUuNjQsNzIuMTNsLTEyLjUyLDUuOTNMMSw3OTAuNWw3LjgyLDIxLjU1LDQuNzMsMTNjMS4zMywzLjY3LDEzLjg2LDM2LjY0LDQzLjU0LDY0LjY5LDguNDEsOCwxOS45NCwxOC4xNCwzNC41NiwyNi40MywxNy40Miw5Ljg5LDM1LjYsMTQuOSw1NCwxNC45YTEwNS4zNCwxMDUuMzQsMCwwLDAsMTUuNi0xLjE4YzU4Ljg4LTguODcsMTI2LjI3LTMwLjY2LDE3NC4yNS00OC4xNGExNDMuMjksMTQzLjI5LDAsMCwwLDE0LjU4LDE4LjdjMzUuOTUsMzkuMTEsOTMuNzEsNTguOTQsMTcxLjY1LDU4Ljk0LDM5LDAsODQtNSwxMzMuNTktMTQuOTFDNzIzLjYxLDkzMC44OCw3NzkuNTQsOTA5LDgyMS42LDg3OS4zNWMzNi4yNS0yNS41Miw2Mi4zNi01Ni42Nyw3Ny42Mi05Mi42YTE5MC4yMiwxOTAuMjIsMCwwLDAsMTUtODAuMjQsNTguNzksNTguNzksMCwwLDAsMjcuMzUsN2MxMy44NiwwLDIzLTUuMzcsMjYuMzQtNy42OGw2Ljc3LTQuNzNhOS4xNiw5LjE2LDAsMCwwLDMuODEtNi4xM2wxLjI1LTguMTdjLjMzLTIuMTQsMi42OS0xOS4zLTEuMzEtNDMuMzUsMS0xLjA1LDItMi4xLDMtMy4yLDE0LjYzLTE2LjU5LDI1LTM4LjgzLDMxLjgzLTY4LDUuNjItMjQuMTIsOC42Mi01Mi4zNiw5LjE4LTg2LjM1bC4yMy0xNCwuMzgtMjMuMloiLz48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik05ODMuNDgsNDcwLjFjLS45NC0uMDktOTUtMTAuOTMtMTkzLTE1My4xOUE1NzcuNTUsNTc3LjU1LDAsMCwwLDYxMi44OCwxNTBDNTM1LjExLDEwMy40Nyw0NzcuMjgsOTMuODEsNDYxLjQxLDkyYy0yNS42OC0zLTQ3LjczLTIuOS02Ny40My4yOC0yOC4yNyw0LjU4LTUyLDE1LjY3LTcwLjU1LDMzLTE3LjIsMTYtMzAuMDYsMzcuNTktMzguMjEsNjQtOC44NSwyOC42OC0xMi4zNyw2NC0xMC40NSwxMDQuODcsMi4xLDQ1LTIuMzIsOTYtMTIuOCwxNDcuNTJhNzU0LjU5LDc1NC41OSwwLDAsMS00Ny4zNSwxNDkuOTVjLTIxLjUxLDQ5LjEyLTQ2Ljc5LDkxLjgyLTc1LjEzLDEyNi45QzEwOS42Nyw3NTUuMzgsNzcuODksNzgyLDQ1LDc5Ny41M2wtMTIuNTEsNS45Myw0LjcyLDEzYTE1Ny44NCwxNTcuODQsMCwwLDAsMzcuMTcsNTVjMTcuNzQsMTYuNzcsNDUuNDQsMzkuMjMsODMuMTMsMzMuNTUsNTMuODEtOC4xMSwxMTctMjgsMTY2LTQ1LjY2YTE2NC45LDE2NC45LDAsMCwxLTEyLTQ1Ljc0Yy03LjM2LDYtMTQuNDIsMTEtMjAuNTcsMTUuNDQtNC43MiwzLjM3LTkuNjEsNi44Ni0xMS42LDguNzVhMzYuNzEsMzYuNzEsMCwwLDEtMjUuMjcsMTAuMjhjLTE2LjQ2LDAtMzAuOS0xMC44Mi0zOC42My0yOS02LjMyLTE0LjgzLTcuNjEtMzMuMTEtMy42NS01MS41LDQuNy0yMS44NCwxNi44LTQzLjM5LDM1LTYyLjMyLDI5Ljg1LTMxLjA4LDQxLjc2LTU4LjgsNTAuNDYtNzksNS42Ny0xMy4yMSwxMC41Ny0yNC42MiwxOS4xNS0zMy4xMSw5LjQ3LTkuMzksMjIuODQtMTMuOTUsNDAuODgtMTMuOTVhMTY4LjI0LDE2OC4yNCwwLDAsMSwzMC44OSwzLjQxYzI0LjEtMjUuMDcsNTAuNjktMzAuODIsMTAxLjI1LTQxLjc2LDE3LjcyLTMuODQsMzkuNzktOC42MSw2Ni43NS0xNS4yMyw4MS4zMS0xOS45NSwxMzIuMS0yNC4xNCwxNjAuMzgtMjQuMTQsNy44MywwLDE0LjkyLjMxLDIxLjE0LjkzLDQuODMtMi43NiwxMy4xLTcuNjEsMjctMTYsMTQuODktOSwyOC0xNSw0My4zMS0xNSwyNC40NywwLDQzLjA5LDE0LjQ2LDgwLjE5LDQzLjI3bC4wOS4wN2M2LjA3LDQuNzEsMTIuOTQsMTAuMDUsMjAuNDUsMTUuODEsMzQuNTEsMjYuNDUsNTIuMzcsNTcuMSw2MS42LDgyLjUsMTguNjItMjkuMDgsMjUuOTItNzQuNiwyNi43OS0xMjcuNDhsLjIzLTE0WiIvPjxwYXRoIGNsYXNzPSJjbHMtMyIgZD0iTTUyMi4yOSwxNjEuMDhhOTMuNTQsOTMuNTQsMCwwLDEsMjkuODEsMi41OCw4OC43Myw4OC43MywwLDAsMSwyNS40LDEwLjc5LDc1LjY0LDc1LjY0LDAsMCwxLDE5LjIxLDE3LDYxLjM2LDYxLjM2LDAsMCwxLDExLjE4LDIxLjksNTQuMTEsNTQuMTEsMCwwLDEsMS45MSwxNS4wNyw1MS45NCw1MS45NCwwLDAsMS0yLjMxLDE0LjQ5LDU0LjgxLDU0LjgxLDAsMCwxLTYuMjUsMTMuNDYsNjEuNCw2MS40LDAsMCwxLTEwLDEyLDEyLjIsMTIuMiwwLDAsMC0yLjMyLDMsMTQsMTQsMCwwLDAtMS40MSwzLjYxLDE2LjMxLDE2LjMxLDAsMCwwLS40NCw0LDE3LjY3LDE3LjY3LDAsMCwwLC41OSw0LjIzbDMuODgsMTQuNTlhMTguMjcsMTguMjcsMCwwLDEsLjU0LDYuNCwxNi41NSwxNi41NSwwLDAsMS0xLjYyLDUuNzgsMTQuNDYsMTQuNDYsMCwwLDEtMy41MSw0LjU1LDEyLjg3LDEyLjg3LDAsMCwxLTUuMTIsMi42NmwtMzYuMDYsOS4yNmExNC43MywxNC43MywwLDAsMS02LjMzLjIsMTYuMjMsMTYuMjMsMCwwLDEtNS45LTIuMzgsMTgsMTgsMCwwLDEtNC43Ny00LjU3LDE4LjgxLDE4LjgxLDAsMCwxLTIuOTQtNi4zbC00LTE1LjczYTE4LjYyLDE4LjYyLDAsMCwwLTEuNzEtNC4zMiwxOC4xNiwxOC4xNiwwLDAsMC0yLjctMy42OCwxNy44NSwxNy44NSwwLDAsMC0zLjUtMi44NywxNi45MSwxNi45MSwwLDAsMC00LjE2LTEuODYsODkuODgsODkuODgsMCwwLDEtMTguMTMtNy41QTc5LjkxLDc5LjkxLDAsMCwxLDQ3NiwyNjYuMjdhNjguMzcsNjguMzcsMCwwLDEtMTItMTQuMzdBNTkuNTQsNTkuNTQsMCwwLDEsNDU2LjgzLDIzNWE1Mi45NCw1Mi45NCwwLDAsMSwuMzYtMjcuMzUsNTcsNTcsMCwwLDEsMTMuMjEtMjMuMTIsNzAuODEsNzAuODEsMCwwLDEsMjIuNzctMTYuMTZBODUuODcsODUuODcsMCwwLDEsNTIyLjI5LDE2MS4wOFoiLz48cGF0aCBjbGFzcz0iY2xzLTMiIGQ9Ik02NDcuMzUsMjc4bDEwLjEzLDEzLjc3YTE0LjQ1LDE0LjQ1LDAsMCwxLDIuNzUsOC41MiwxMi41MywxMi41MywwLDAsMS0uNzEsNC4yMUE5LjgxLDkuODEsMCwwLDEsNjU3LjQsMzA4bC0zMCwzMS4zM2ExNC43MiwxNC43MiwwLDAsMC0zLjczLDcuMzVBMTguNTcsMTguNTcsMCwwLDAsNjI0LDM1NWExNy41MywxNy41MywwLDAsMCwzLjg2LDcuMjUsMTMsMTMsMCwwLDAsNi44MSw0LjA2bDM4LjksOC42NmExMS4yNSwxMS4yNSwwLDAsMSw0LjUsMi4xOSwxNC43NiwxNC43NiwwLDAsMSwzLjQ3LDQsMTgsMTgsMCwwLDEsMi4xMSw1LjI1LDE5LjIyLDE5LjIyLDAsMCwxLC40Myw2LDE3LjY0LDE3LjY0LDAsMCwxLS44NCw0LjE2LDE1LjM1LDE1LjM1LDAsMCwxLTEuNjksMy41MywxMy4yMiwxMy4yMiwwLDAsMS0yLjM5LDIuNzQsMTAuNjksMTAuNjksMCwwLDEtMi45NSwxLjc5LDkuNCw5LjQsMCwwLDEtMS4yNy40MSwxMCwxMCwwLDAsMS0xLjMzLjIzLDguOTMsOC45MywwLDAsMS0xLjM3LDAsOS42Niw5LjY2LDAsMCwxLTEuNDEtLjE2bC04My0xNS45YTEzLjE0LDEzLjE0LDAsMCwwLTEuNjQtLjIxLDEyLjY1LDEyLjY1LDAsMCwwLTEuNjMsMCwxMi4zOCwxMi4zOCwwLDAsMC0xLjYyLjIyLDEyLjc1LDEyLjc1LDAsMCwwLTEuNTguNDMsMTAuODIsMTAuODIsMCwwLDAtMS41NC42MywxMi42OSwxMi42OSwwLDAsMC0xLjQ4LjgzLDE1LjEzLDE1LjEzLDAsMCwwLTEuNCwxLDE0Ljc5LDE0Ljc5LDAsMCwwLTEuMywxLjIxbC03OS4xOCw4Mi43M2ExNi40MywxNi40MywwLDAsMS0yLjY4LDIuMjgsMTYuODUsMTYuODUsMCwwLDEtMS40NS44NywxNC43OCwxNC43OCwwLDAsMS01LjQ4LDEuNjgsMTMuNTEsMTMuNTEsMCwwLDEtNC0uMjIsMTIuMzksMTIuMzksMCwwLDEtNi44Ny00LjA2bC0yLjM2LTIuNzJhMTcuNDMsMTcuNDMsMCwwLDEtMy41MS02LjQ4LDIwLjI4LDIwLjI4LDAsMCwxLS43My03LjQyQTIyLjE4LDIyLjE4LDAsMCwxLDQ3Niw0NDYuMTZsNDEuNjgtNDAuODlhMTguOCwxOC44LDAsMCwwLDUuMS04LjgzLDE5LjQ2LDE5LjQ2LDAsMCwwLDAtOS41MywxNy45LDE3LjksMCwwLDAtNC40Ni04LjExLDE1Ljg0LDE1Ljg0LDAsMCwwLTguNDMtNC41MmwtNTcuNTUtMTFhMTQuNDEsMTQuNDEsMCwwLDEtNS40LTIuMjUsMTQuNjMsMTQuNjMsMCwwLDEtNi4xLTkuMzUsMTQuMzMsMTQuMzMsMCwwLDEsLjA5LTUuOWwxLjU4LTcuMTlhMTQuNzcsMTQuNzcsMCwwLDEsMS41MS00LDE0LjUyLDE0LjUyLDAsMCwxLDIuNDktMy4yNiwxNC42OCwxNC42OCwwLDAsMSw3LTMuODhjLjUyLS4xMiwxLS4yMSwxLjU4LS4yOGExNC4xNywxNC4xNywwLDAsMSwxLjYtLjEsMTIuOCwxMi44LDAsMCwxLDEuNjMuMDgsMTEuOTIsMTEuOTIsMCwwLDEsMS42NC4yN2wxMDQuNjUsMjMuM2ExMy45NCwxMy45NCwwLDAsMCwxLjY4LjI2LDExLjY1LDExLjY1LDAsMCwwLDEuNjcsMCwxMi44MSwxMi44MSwwLDAsMCwxLjY0LS4xNSwxNC43NywxNC43NywwLDAsMCwxLjYtLjM2LDEyLjM0LDEyLjM0LDAsMCwwLDEuNTQtLjU2LDEyLjY0LDEyLjY0LDAsMCwwLDEuNDctLjc0LDEyLjksMTIuOSwwLDAsMCwxLjM4LS45NCwxMy41LDEzLjUsMCwwLDAsMS4yOS0xLjExWiIvPjxwYXRoIGNsYXNzPSJjbHMtNCIgZD0iTTk1MS41OCw2ODIuNVM5NDgsNjg1LDk0MS41Niw2ODVjLTEwLjkyLDAtMjkuOTEtNy40Mi01Mi42LTQ3LjY0aDBjLTM3LjM3LTY4LjIyLTc1LjY3LTgyLjc4LTc3LjMyLTgzLjM4YTQuNDgsNC40OCwwLDAsMC0zLDguNDRjLjM4LjE0LDM4LjgyLDE0Ljc0LDc1LjU1LDg0Ljg2bDAsLjA5Yy00LjkxLDguNC05LjQ4LDExLjMxLTkuNDgsMTEuMzEsMTUuNDUsMjQuMDYsNTQuODcsMjAyLTIyNC45NCwyNTcuODUtNTAsMTAtOTIuMzQsMTQuMzctMTI4LDE0LjM3LTE2My43NywwLTE4Ny45NS05Mi4yNy0xODIuNDMtMTU3Ljg4YTQ2LjYzLDQ2LjYzLDAsMCwwLTExLjY2LTUuNjdjNC43Mi04LjI0LDEyLjExLTIyLjMsMjMuMy00Ni4xNCw3LjEtMTUuMTMsMTAuMTctMzIsOS4xMy01MGE0LjUyLDQuNTIsMCwwLDAtMS4zOS0zLDQuMzksNC4zOSwwLDAsMC0zLjMyLTEuMjEsNC40OCw0LjQ4LDAsMCwwLTQuMTksNC43NGMxLDE2Ljc4LTEuNzQsMzEuNzEtOC4yOSw0NS42Ny0xNS4xOCwzMi4zNS0yMy4xMiw0Ni4xNS0yNi42NSw1MS42N2wwLDBjLTE5LjM5LDI0LjEtNDYsMzguMTEtNTYuNDMsNDhhOC4yLDguMiwwLDAsMS01LjY5LDIuNDhjLTE1LjI0LDAtMzEuOS00Ny41NiwxMy4yNC05NC41Nyw1MS01My4wNyw1NS42Mi05OC4yOSw2OS4xMS0xMTEuNjYsNC4yOS00LjI0LDEyLjEzLTUuNjksMjAuODQtNS42OSwxOC43MywwLDQxLjQ1LDYuNjksNDEuNDUsNi42OSwyOC4zNS0zOC43Nyw1MC44OC0zMy4yNywxNjQuMjMtNjEuMDksNzguODMtMTkuMzUsMTI3LjE2LTIzLjMzLDE1My41OS0yMy4zMywxOC40NiwwLDI2LjIyLDEuOTUsMjYuMjIsMS45NWgwYy4xOCwwLDIuNjgtLjU4LDM2LjcxLTIxLjE4LDExLjc0LTcuMTEsMjAuMTItMTAuOTEsMjguNTYtMTAuOTEsMTcuNDgsMCwzNS4xOSwxNi4zLDgzLjQxLDUzLjI3Qzk2Myw2MDcuODksOTUxLjU4LDY4Mi41LDk1MS41OCw2ODIuNVoiLz48cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Ik04MzIuNDcsNjg4LjQ2czUyLTk3LTUyLjg3LTEyMS4xN1M2NDYuMTIsNjE0LjcsNjU5LjY4LDY1OWMwLDAtNDYtMS40MS01NS40NywxMy43MiwwLDAtMzEuNDYtMTAzLjkzLTE2Ny0zMS44MkMzNTcuMzQsNjgzLjM4LDM4NC45NCw3NjQuMTksNDE5LDc4Mi41MWM0Ljg3LDIuNjIsNy44Nyw5LjQ5LDIuNywyMC4xMWE1Ni4xNyw1Ni4xNywwLDAsMC01LjE0LDMyLjQ4YzQuMTQsMzAuOTUsMzMuNDgsNDQuNjMsMTA5LjI2LDM0LjNxMTEtMS41LDIxLjE5LTMuOGwxLjU4LS4zN3EyLjQ5LS41Nyw0Ljk0LTEuMThhMjUzLDI1MywwLDAsMCwxMTgtNjcuMzJjMy45My04LjEyLDQuNDItMjIuNS43NS0zMi40MS02LjU2LTE3LjY4LTI0Ljk0LTI2LjE0LTI1LjEzLTI2LjIybDAtLjA5Yy04LjUyLTQuNDktMTguNDMtNy43MS0yNS41Mi0zLjg4LTEzLjIzLDcuMTYtMjMuNjcsMTYuMjQtMjUuNTUsMS40NC0uODgtNi45MiwxLjI5LTQxLjkzLDQyLjkyLTUzLjM2czU5Ljc1LDguMjEsNjIsMTguMzVjMS41NCw2Ljg4LTIuMTksMjAuNjQtMTEuODcsMjEuMTctNS4yNS4yOC03LjI3LDQuNi05LjExLDkuNzZhOC43LDguNywwLDAsMCwxLDgsNjEuNTgsNjEuNTgsMCwwLDEsMTAuMzEsMTcuNzNjNC44OSwxMy4xOSw0Ljg2LDI3LjUsMCw0Mi42N0ExNTYuOTEsMTU2LjkxLDAsMCwwLDc3Mi43Miw4MDZsMS45My0uMzgsMi42NC0uNTVhMTY3LDE2NywwLDAsMCwzMS44My0xMC40NUM4NzIsNzY2Ljg2LDg0MS40MSw2OTUuMzEsODMyLjQ3LDY4OC40NloiLz48cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Ik03NDMuNDksODMyLjA1YTE4MC41MywxODAuNTMsMCwwLDEtNjMuMS0xMS40MywyNzkuMjksMjc5LjI5LDAsMCwxLTM4LjYsMzAuMDksMjc0LjYyLDI3NC42MiwwLDAsMS03MC4yMiwzMi40OWMxNi45LDExLjM2LDQ4Ljc2LDIwLjQ1LDEwNi4yOSwzLjE0LDU1LjgxLTE2Ljc4LDc4LjY5LTM5LDg3Ljk0LTU1LjdBMTc5Ljg5LDE3OS44OSwwLDAsMSw3NDMuNDksODMyLjA1WiIvPjxwYXRoIGNsYXNzPSJjbHMtMyIgZD0iTTU3MS4xNiw2NzMuMzFjLTkuNzktMjUuMDctNDAuOTQtMzcuMTktNzUuMTItMzEuNjhhNTcuNjIsNTcuNjIsMCwxLDEtNjUuNDUsMzYuMjVjLTE0LjQ4LDE3LjQ5LTIwLjEzLDM4LjI4LTEzLjA3LDU2LjM4LDEyLDMwLjc0LDU2LjEyLDQyLDk4LjU0LDI1LjE4UzU4My4xNiw3MDQuMDUsNTcxLjE2LDY3My4zMVoiLz48cGF0aCBjbGFzcz0iY2xzLTMiIGQ9Ik03NzYsNjc2LjExYTUwLjUsNTAuNSwwLDAsMS0zMS4xMi05MC4yOGMtMjguNTEsMi41My01MS4yMiwyMC43OC01My4xNSw0NC42OC0yLjIzLDI3LjQ5LDIzLjg4LDUyLDU4LjMxLDU0Ljg0LDIwLjg5LDEuNjksNDAtNSw1Mi4zNy0xNi43QTUwLjIzLDUwLjIzLDAsMCwxLDc3Niw2NzYuMTFaIi8+PC9zdmc+
+    mediatype: image/svg+xml
+  installModes:
+  - supported: false
+    type: OwnNamespace
+  - supported: false
+    type: SingleNamespace
+  - supported: false
+    type: MultiNamespace
+  - supported: true
+    type: AllNamespaces
+  keywords:
+  - skupper
+  - service
+  - mesh
+  - van
+  links:
+  - name: Skupper Network Observer Operator
+    url: https://github.com/skupperproject/skupper
+  maintainers:
+  - email: skupper@googlegroups.com
+    name: Skupper Community
+  maturity: stable
+  minKubeVersion: ${MIN_KUBE_VERSION}
+  provider:
+    name: Skupper Project
+    url: https://skupper.io
+  replaces: ${REPLACED_VERSION}
+  selector: {}
+  version: ${BUNDLE_VERSION}
+EOF
+}
+
+main () {
+  ensure::operator-sdk
+
+	ktempdir=$(mktemp -d --tmpdir=./)
+	if [ "${DEBUG}" != "true" ]; then
+		trap 'rm -rf $ktempdir' EXIT
+	fi
+
+  CUR_DIR=$(pwd)
+  cd ${ktempdir}
+
+  # generate project scaffold
+  "${OPERATOR_SDK}" init \
+	--plugins helm \
+	--domain skupper.io \
+	--project-name network-observer-operator \
+	--group observability \
+	--version v2alpha1 \
+	--kind NetworkObserver \
+  --helm-chart ../charts/network-observer/;
+
+  # generate kustomize csv input
+  mkdir -p config/manifests/bases 
+  skupper::network-observer-bundle::clusterserviceversion > "config/manifests/bases/network-observer-operator.clusterserviceversion.yaml"  
+
+  # generate kustomize manager image
+  skupper::network-observer-bundle::kustomize-manager > "config/manager/kustomization.yaml"
+
+  # generate kustomize default and patches
+  skupper::network-observer-bundle::kustomize-default >> "config/default/kustomization.yaml"
+  skupper::network-observer-bundle::clusterrole_patch > "config/default/clusterrole_patch.yaml"
+  skupper::network-observer-bundle::runas_user_patch > "config/default/runas_user_patch.yaml"
+
+  # generate bundle
+  "${KUBECTL}" kustomize "config/manifests" | "${OPERATOR_SDK}" generate bundle -q --version ${BUNDLE_VERSION} --channels ${BUNDLE_CHANNELS} --default-channel ${BUNDLE_DEFAULT_CHANNEL}
+
+  # append related images
+  mkdir -p config/manifests/overlays/bases
+  mv bundle/manifests/network-observer-operator.clusterserviceversion.yaml config/manifests/overlays/bases  
+  skupper::network-observer-bundle::related-images > config/manifests/overlays/patch-related-images.yaml
+  skupper::network-observer-bundle::kustomize-related-images > config/manifests/overlays/kustomization.yaml
+  "${KUBECTL}" kustomize config/manifests/overlays > bundle/manifests/network-observer-operator.v${BUNDLE_VERSION}.clusterserviceversion.yaml
+
+  # validate bundle
+  "${OPERATOR_SDK}" bundle validate ./bundle 
+
+  # build and push images using generated makefile
+  make docker-buildx IMG=${IMG} PLATFORMS=linux/arm64,linux/amd64
+  make bundle-build bundle-push BUNDLE_IMG=${BUNDLE_IMG}
+
+  # setup directory for bundle and deploy content
+  rm -rf ${CUR_DIR}/network-observer-operator
+  mkdir -p ${CUR_DIR}/network-observer-operator
+  cp -R bundle ${CUR_DIR}/network-observer-operator/bundle
+  cp bundle.Dockerfile ${CUR_DIR}/network-observer-operator/bundle
+  mkdir -p ${CUR_DIR}/network-observer-operator/deploy
+  cp config/samples/observability_v2alpha1_networkobserver.yaml ${CUR_DIR}/network-observer-operator/deploy/skupper-network-observer-cr-sample.yaml
+
+  cd ${CUR_DIR}
+}
+main "$@"


Test patch:
diff --git a/Dockerfile.ci-test b/Dockerfile.ci-test
index 521a327c..4fd964a4 100644
--- a/Dockerfile.ci-test
+++ b/Dockerfile.ci-test
@@ -3,6 +3,8 @@ FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
 ARG TARGETOS
 ARG TARGETARCH
 
+RUN apt update && apt install -y jq
+
 WORKDIR /go/src/app
 
 COPY go.mod .
diff --git a/internal/certs/certs_test.go b/internal/certs/certs_test.go
index b51eab22..c6925f20 100644
--- a/internal/certs/certs_test.go
+++ b/internal/certs/certs_test.go
@@ -18,6 +18,8 @@ import (
 	"testing"
 
 	"gotest.tools/v3/assert"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
 func TestGenerateCASecret(t *testing.T) {
@@ -26,7 +28,10 @@ func TestGenerateCASecret(t *testing.T) {
 	name := "ca-secret"
 	host := host1 + ", " + host2
 	cn := "www.example.com"
-	ca_secret := GenerateSecret(name, cn, host, 0, nil)
+	ca_secret, err := GenerateSecret(name, cn, host, 0, nil)
+	if err != nil {
+		t.Error(err)
+	}
 	data, ok := ca_secret.Data["tls.crt"]
 	if !ok {
 		t.Error("Invalid secret, tls.crt is missing")
@@ -46,10 +51,16 @@ func TestGenerateCASecret(t *testing.T) {
 
 func TestGenerateSecret(t *testing.T) {
 	ca_cn := "www.example.com"
-	ca_secret := GenerateSecret("test-secret", ca_cn, "134.565.56.77", 0, nil)
+	ca_secret, err := GenerateSecret("test-secret", ca_cn, "134.565.56.77", 0, nil)
+	if err != nil {
+		t.Error(err)
+	}
 	my_secret_cn := "www.my.example.com"
 	my_secret_host := "172.565.56.77"
-	my_secret := GenerateSecret("my_secret", my_secret_cn, my_secret_host, 86400000000000 /*duration of 1 day*/, &ca_secret)
+	my_secret, err := GenerateSecret("my_secret", my_secret_cn, my_secret_host, 86400000000000 /*duration of 1 day*/, ca_secret)
+	if err != nil {
+		t.Error(err)
+	}
 	data, ok := my_secret.Data["tls.crt"]
 	if !ok {
 		t.Error("Invalid secret, tls.crt is missing")
@@ -61,4 +72,20 @@ func TestGenerateSecret(t *testing.T) {
 
 	assert.Equal(t, my_secret_cn, my_cert.Subject.CommonName)
 	assert.Equal(t, ca_cn, my_cert.Issuer.CommonName)
+
+	// Instantiate the Secret object.
+	caSecret := &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "emptyCASecret",
+			Namespace: "testNamespace",
+		},
+		//  To create a 0-byte data field, initialize the map as empty.
+		//  The type is map[string][]byte.
+		Data: map[string][]byte{},
+		Type: corev1.SecretTypeOpaque,
+	}
+
+	_, err = GenerateSecret("test-secret", ca_cn, "134.565.56.77", 0, caSecret)
+	errorText := err.Error()
+	assert.Equal(t, errorText, "error reading CA Certificate from Secret \"emptyCASecret\": failed to read PEM encoded data from \"tls.crt\"")
 }
diff --git a/internal/cmd/skupper/link/kube/link_generate_test.go b/internal/cmd/skupper/link/kube/link_generate_test.go
index 812256fc..0d00485b 100644
--- a/internal/cmd/skupper/link/kube/link_generate_test.go
+++ b/internal/cmd/skupper/link/kube/link_generate_test.go
@@ -11,6 +11,7 @@ import (
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1"
 	"gotest.tools/v3/assert"
 	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 )
@@ -858,7 +859,7 @@ func TestCmdLinkGenerate_WaitUntil(t *testing.T) {
 	type test struct {
 		name               string
 		generateCredential bool
-		generatedLink      v2alpha1.Link
+		generatedLinks     []v2alpha1.Link
 		outputType         string
 		tlsCredentials     string
 		timeout            time.Duration
@@ -887,9 +888,24 @@ func TestCmdLinkGenerate_WaitUntil(t *testing.T) {
 		{
 			name:               "bad format for the output",
 			generateCredential: false,
-			outputType:         "not supported",
-			timeout:            time.Second,
-			expectError:        true,
+			generatedLinks: []v2alpha1.Link{
+				{
+					TypeMeta: metav1.TypeMeta{
+						APIVersion: "skupper.io/v2alpha1",
+						Kind:       "Link",
+					},
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "link",
+					},
+					Spec: v2alpha1.LinkSpec{
+						TlsCredentials: "credentials",
+						Cost:           1,
+					},
+				},
+			},
+			outputType:  "not supported",
+			timeout:     time.Second,
+			expectError: true,
 		},
 		{
 			name:               "the output contains the link and the secret",
@@ -963,6 +979,7 @@ func TestCmdLinkGenerate_WaitUntil(t *testing.T) {
 			command.generateCredential = test.generateCredential
 			command.tlsCredentials = test.tlsCredentials
 			command.timeout = test.timeout
+			command.generatedLinks = test.generatedLinks
 
 			err := command.WaitUntil()
 
diff --git a/internal/cmd/skupper/link/nonkube/link_delete_test.go b/internal/cmd/skupper/link/nonkube/link_delete_test.go
index 6072c5b0..80ca2faf 100644
--- a/internal/cmd/skupper/link/nonkube/link_delete_test.go
+++ b/internal/cmd/skupper/link/nonkube/link_delete_test.go
@@ -1,1 +1,197 @@
 package nonkube
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/skupperproject/skupper/internal/cmd/skupper/common"
+	"github.com/skupperproject/skupper/internal/cmd/skupper/common/testutils"
+	"github.com/skupperproject/skupper/internal/nonkube/client/fs"
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	"github.com/spf13/cobra"
+	"gotest.tools/v3/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestCmdLinkDelete_ValidateInput(t *testing.T) {
+	type test struct {
+		name              string
+		args              []string
+		linkHandler       *fs.LinkHandler
+		flags             *common.CommandLinkDeleteFlags
+		cobraGenericFlags map[string]string
+		expectedError     string
+	}
+
+	if os.Getuid() == 0 {
+		api.DefaultRootDataHome = t.TempDir()
+	} else {
+		t.Setenv("XDG_DATA_HOME", t.TempDir())
+	}
+	tmpDir := api.GetDataHome()
+	path := filepath.Join(tmpDir, "/namespaces/test/", string(api.InputSiteStatePath))
+
+	testTable := []test{
+		{
+			name:          "Link name is not specified",
+			args:          []string{},
+			flags:         &common.CommandLinkDeleteFlags{},
+			expectedError: "link name must be specified",
+		},
+		{
+			name:          "Link name is nil",
+			args:          []string{""},
+			flags:         &common.CommandLinkDeleteFlags{},
+			expectedError: "link name must not be empty",
+		},
+		{
+			name:          "Link name is not valid",
+			args:          []string{"my name"},
+			flags:         &common.CommandLinkDeleteFlags{},
+			expectedError: "link name is not valid: value does not match this regular expression: ^[a-z0-9]([-a-z0-9]*[a-z0-9])*(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])*)*$",
+		},
+		{
+			name:          "more than one argument is specified",
+			args:          []string{"my", "Link"},
+			flags:         &common.CommandLinkDeleteFlags{},
+			expectedError: "only one argument is allowed for this command",
+		},
+		{
+			name:          "Link doesn't exist",
+			args:          []string{"no-exist"},
+			flags:         &common.CommandLinkDeleteFlags{},
+			expectedError: "There is no link resource in the namespace with the name \"no-exist\"",
+		},
+		{
+			name:          "kubernetes flags are not valid on this platform",
+			args:          []string{"mine"},
+			flags:         &common.CommandLinkDeleteFlags{},
+			expectedError: "",
+			cobraGenericFlags: map[string]string{
+				common.FlagNameContext:    "test",
+				common.FlagNameKubeconfig: "test",
+			},
+		},
+	}
+
+	// Add temp files so Link exists for update tests
+	LinkResource := v2alpha1.Link{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v2alpha1",
+			Kind:       "Link",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "Link-mine",
+			Namespace: "test",
+		},
+	}
+	command := &CmdLinkDelete{Flags: &common.CommandLinkDeleteFlags{}}
+	command.CobraCmd = &cobra.Command{Use: "test"}
+	command.namespace = "test"
+	command.linkHandler = fs.NewLinkHandler(command.namespace)
+
+	defer command.linkHandler.Delete("mine")
+	content, err := command.linkHandler.EncodeToYaml(LinkResource)
+	assert.Check(t, err == nil)
+	err = command.linkHandler.WriteFile(path, "mine.yaml", content, common.Links)
+	assert.Check(t, err == nil)
+
+	for _, test := range testTable {
+		t.Run(test.name, func(t *testing.T) {
+			command.linkName = ""
+			if test.flags != nil {
+				command.Flags = test.flags
+			}
+
+			if test.cobraGenericFlags != nil && len(test.cobraGenericFlags) > 0 {
+				for name, value := range test.cobraGenericFlags {
+					command.CobraCmd.Flags().String(name, value, "")
+				}
+			}
+
+			testutils.CheckValidateInput(t, command, test.expectedError, test.args)
+		})
+	}
+}
+
+func TestCmdLinkDelete_Run(t *testing.T) {
+	type test struct {
+		name              string
+		namespace         string
+		deleteName        string
+		linkHandler       *fs.LinkHandler
+		errorMessage      string
+		expectedNamespace string
+	}
+
+	testTable := []test{
+		{
+			name:              "run default",
+			deleteName:        "no-exist",
+			errorMessage:      "no such file or directory",
+			expectedNamespace: "default",
+		},
+		{
+			name:              "run namespace",
+			namespace:         "test2",
+			deleteName:        "router-east",
+			errorMessage:      "",
+			expectedNamespace: "test2",
+		},
+	}
+
+	// Add a temp file so Link exists for delete tests
+	LinkResource := v2alpha1.Link{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v2alpha1",
+			Kind:       "Link",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "router-east",
+			Namespace: "test2",
+		},
+	}
+
+	if os.Getuid() == 0 {
+		api.DefaultRootDataHome = t.TempDir()
+	} else {
+		t.Setenv("XDG_DATA_HOME", t.TempDir())
+	}
+	tmpDir := api.GetDataHome()
+	path := filepath.Join(tmpDir, "/namespaces/test2/", string(api.InputSiteStatePath))
+
+	command := &CmdLinkDelete{Flags: &common.CommandLinkDeleteFlags{}}
+	command.namespace = "test2"
+	command.linkHandler = fs.NewLinkHandler(command.namespace)
+
+	content, err := command.linkHandler.EncodeToYaml(LinkResource)
+	assert.Check(t, err == nil)
+	err = command.linkHandler.WriteFile(path, "router-east.yaml", content, common.Links)
+	assert.Check(t, err == nil)
+	defer command.linkHandler.Delete("router-east.yaml")
+
+	for _, test := range testTable {
+
+		t.Run(test.name, func(t *testing.T) {
+			command.namespace = test.namespace
+			command.linkName = test.deleteName
+			command.InputToOptions()
+
+			err := command.Run()
+			if err != nil {
+				assert.Check(t, strings.HasSuffix(err.Error(), test.errorMessage))
+			} else {
+				assert.Check(t, err == nil)
+				assert.Equal(t, command.namespace, test.expectedNamespace)
+				// only deleting from input/resources directory
+				// expect specified link is deleted
+				opts := fs.GetOptions{RuntimeFirst: false, LogWarning: false}
+				Link, _ := command.linkHandler.Get(command.linkName, opts)
+				assert.Check(t, Link == nil)
+			}
+		})
+	}
+}
diff --git a/internal/cmd/skupper/token/kube/token_issue_test.go b/internal/cmd/skupper/token/kube/token_issue_test.go
index ca4fc2c9..45ac0f6f 100644
--- a/internal/cmd/skupper/token/kube/token_issue_test.go
+++ b/internal/cmd/skupper/token/kube/token_issue_test.go
@@ -316,9 +316,53 @@ func TestCmdTokenIssue_ValidateInput(t *testing.T) {
 			},
 			expectedError: "token file name is a directory",
 		},
+		{
+			name: "directory doesn't exist.",
+			args: []string{"/invalid/token.yaml"},
+			flags: common.CommandTokenIssueFlags{
+				ExpirationWindow:   15 * time.Minute,
+				RedemptionsAllowed: 1,
+				Timeout:            60 * time.Second,
+				Cost:               "1",
+			},
+			skupperObjects: []runtime.Object{
+				&v2alpha1.SiteList{
+					Items: []v2alpha1.Site{
+						{
+							ObjectMeta: v1.ObjectMeta{
+								Name:      "site1",
+								Namespace: "test",
+							},
+							Spec: v2alpha1.SiteSpec{
+								LinkAccess: "route",
+							},
+							Status: v2alpha1.SiteStatus{
+								Status: v2alpha1.Status{
+									Conditions: []v1.Condition{
+										{
+											Type:   "Configured",
+											Status: "True",
+										},
+										{
+											Type:   "Running",
+											Status: "True",
+										},
+										{
+											Type:   "Ready",
+											Status: "True",
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			expectedError: "invalid token file name: no such file or directory",
+		},
 		{
 			name: "redemptions is not valid",
-			args: []string{"~/token.yaml"},
+			args: []string{"token.yaml"},
 			flags: common.CommandTokenIssueFlags{
 				ExpirationWindow:   15 * time.Minute,
 				RedemptionsAllowed: 0,
@@ -362,7 +406,7 @@ func TestCmdTokenIssue_ValidateInput(t *testing.T) {
 		},
 		{
 			name: "expiration is not valid",
-			args: []string{"~/token.yaml"},
+			args: []string{"token.yaml"},
 			flags: common.CommandTokenIssueFlags{
 				ExpirationWindow:   10 * time.Second,
 				RedemptionsAllowed: 1,
@@ -406,7 +450,7 @@ func TestCmdTokenIssue_ValidateInput(t *testing.T) {
 		},
 		{
 			name: "timeout is not valid",
-			args: []string{"~/token.yaml"},
+			args: []string{"token.yaml"},
 			flags: common.CommandTokenIssueFlags{
 				ExpirationWindow:   15 * time.Minute,
 				RedemptionsAllowed: 1,
@@ -450,7 +494,7 @@ func TestCmdTokenIssue_ValidateInput(t *testing.T) {
 		},
 		{
 			name: "cost is not valid",
-			args: []string{"~/token.yaml"},
+			args: []string{"token.yaml"},
 			flags: common.CommandTokenIssueFlags{
 				ExpirationWindow:   15 * time.Minute,
 				RedemptionsAllowed: 1,
@@ -494,7 +538,7 @@ func TestCmdTokenIssue_ValidateInput(t *testing.T) {
 		},
 		{
 			name: "link access is not valid",
-			args: []string{"~/token.yaml"},
+			args: []string{"token.yaml"},
 			flags: common.CommandTokenIssueFlags{
 				ExpirationWindow:   15 * time.Minute,
 				RedemptionsAllowed: 1,
@@ -535,7 +579,7 @@ func TestCmdTokenIssue_ValidateInput(t *testing.T) {
 		},
 		{
 			name: "flags all valid",
-			args: []string{"~/token.yaml"},
+			args: []string{"/tmp/token.yaml"},
 			flags: common.CommandTokenIssueFlags{
 				ExpirationWindow:   15 * time.Minute,
 				RedemptionsAllowed: 1,
diff --git a/internal/cmd/skupper/version/kube/version_test.go b/internal/cmd/skupper/version/kube/version_test.go
index 4303d5cf..f73dab55 100644
--- a/internal/cmd/skupper/version/kube/version_test.go
+++ b/internal/cmd/skupper/version/kube/version_test.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/skupperproject/skupper/internal/cmd/skupper/common"
 	"github.com/skupperproject/skupper/internal/cmd/skupper/common/testutils"
+	fakeclient "github.com/skupperproject/skupper/internal/kube/client/fake"
 	"github.com/skupperproject/skupper/internal/utils/configs"
 	"gotest.tools/v3/assert"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -116,6 +117,11 @@ func TestCmdVersion_Run(t *testing.T) {
 			flags:        common.CommandVersionFlags{Output: "not-valid"},
 			errorMessage: "format not-valid not supported",
 		},
+		{
+			name:         "no cluster",
+			flags:        common.CommandVersionFlags{},
+			errorMessage: "Cluster is not accessible",
+		},
 	}
 
 	for _, test := range testTable {
@@ -142,8 +148,14 @@ func TestCmdVersion_Run(t *testing.T) {
 
 func newCmdVersionWithMocks(namespace string, k8sObjects []runtime.Object, skupperObjects []runtime.Object, fakeSkupperError string) (*CmdVersion, error) {
 
+	client, err := fakeclient.NewFakeClient(namespace, k8sObjects, skupperObjects, fakeSkupperError)
+	if err != nil {
+		return nil, err
+	}
 	cmdVersion := &CmdVersion{
-		namespace: namespace,
+		Client:     client.GetSkupperClient().SkupperV2alpha1(),
+		namespace:  namespace,
+		KubeClient: client.GetKubeClient(),
 	}
 
 	return cmdVersion, nil
diff --git a/internal/kube/certificates/mgr_test.go b/internal/kube/certificates/mgr_test.go
index c588cbd7..e13417cf 100644
--- a/internal/kube/certificates/mgr_test.go
+++ b/internal/kube/certificates/mgr_test.go
@@ -520,7 +520,10 @@ func (c *Call) eventcount(events int) *Call {
 
 func fixtureCASecret(t *testing.T, name, namespace string) *corev1.Secret {
 	t.Helper()
-	secret := certs.GenerateSecret(name, "skupper test CA", "", time.Hour*8, nil)
+	secret, err := certs.GenerateSecret(name, "skupper test CA", "", time.Hour*8, nil)
+	if err != nil {
+		t.Error(err)
+	}
 	secret.Namespace = namespace
-	return &secret
+	return secret
 }
diff --git a/internal/kube/grants/common_test.go b/internal/kube/grants/common_test.go
index 5941350e..b635d9d6 100644
--- a/internal/kube/grants/common_test.go
+++ b/internal/kube/grants/common_test.go
@@ -132,10 +132,13 @@ func (*factory) grant(name string, namespace string, uid string) *v2alpha1.Acces
 
 }
 
-func (*factory) secret(name string, namespace string, subject string, hosts []string) *corev1.Secret {
-	secret := certs.GenerateSecret(name, subject, strings.Join(hosts, ","), 0, nil)
+func (*factory) secret(name string, namespace string, subject string, hosts []string) (*corev1.Secret, error) {
+	secret, err := certs.GenerateSecret(name, subject, strings.Join(hosts, ","), 0, nil)
+	if err != nil {
+		return nil, err
+	}
 	secret.ObjectMeta.Namespace = namespace
-	return &secret
+	return secret, nil
 }
 
 func (*factory) genericSecret(name string, namespace string) *corev1.Secret {
diff --git a/internal/kube/grants/enabled_test.go b/internal/kube/grants/enabled_test.go
index 85a950d7..a8f15458 100644
--- a/internal/kube/grants/enabled_test.go
+++ b/internal/kube/grants/enabled_test.go
@@ -7,6 +7,11 @@ import (
 )
 
 func Test_tlsCredentialsUpdated(t *testing.T) {
+	secret, err := tf.secret("simple", "test", "My Subject", []string{"foo.com", "bar.org"})
+	if err != nil {
+		t.Error(err)
+	}
+
 	var tests = []struct {
 		name   string
 		key    string
@@ -15,7 +20,7 @@ func Test_tlsCredentialsUpdated(t *testing.T) {
 		{
 			name:   "simple",
 			key:    "test/simple",
-			secret: tf.secret("simple", "test", "My Subject", []string{"foo.com", "bar.org"}),
+			secret: secret,
 		},
 		{
 			name:   "non tls secret",
diff --git a/internal/kube/grants/init_test.go b/internal/kube/grants/init_test.go
index b899a969..27c45e61 100644
--- a/internal/kube/grants/init_test.go
+++ b/internal/kube/grants/init_test.go
@@ -15,6 +15,14 @@ import (
 
 func Test_Initialise(t *testing.T) {
 	grantUid := "0bde3bc8-a4a2-404a-bfbe-44fdf7bf3231"
+	myCredsSecret, err := tf.secret("my-creds", "test", "grant server", nil)
+	if err != nil {
+		t.Error(err)
+	}
+	grantSecret, err := tf.secret("skupper-grant-server", "test", "grant server", nil)
+	if err != nil {
+		t.Error(err)
+	}
 	var tests = []struct {
 		name           string
 		config         GrantConfig
@@ -35,7 +43,7 @@ func Test_Initialise(t *testing.T) {
 				BaseUrl:              "foo:5432",
 				TlsCredentialsSecret: "my-creds",
 			},
-			k8sObjects:     []runtime.Object{tf.secret("my-creds", "test", "grant server", nil)},
+			k8sObjects:     []runtime.Object{myCredsSecret},
 			expectedStatus: "OK",
 			expectedUrl:    "https://foo:5432/" + grantUid,
 		},
@@ -48,7 +56,7 @@ func Test_Initialise(t *testing.T) {
 				TlsCredentialsSecret: "skupper-grant-server",
 			},
 			//add pregenerated secret as the certificate controller is not in action in this test
-			k8sObjects: []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1), tf.secret("skupper-grant-server", "test", "grant server", nil)},
+			k8sObjects: []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1), grantSecret},
 			endpoint: &v2alpha1.Endpoint{
 				Host: "my-host",
 				Port: "1234",
@@ -65,7 +73,7 @@ func Test_Initialise(t *testing.T) {
 				TlsCredentialsSecret: "skupper-grant-server",
 			},
 			//add pregenerated secret as the certificate controller is not in action in this test
-			k8sObjects:     []runtime.Object{tf.secret("skupper-grant-server", "test", "grant server", nil)},
+			k8sObjects:     []runtime.Object{grantSecret},
 			expectedStatus: "Pending",
 		},
 	}
diff --git a/internal/kube/grants/redeem_test.go b/internal/kube/grants/redeem_test.go
index e0a9d88a..f83d921e 100644
--- a/internal/kube/grants/redeem_test.go
+++ b/internal/kube/grants/redeem_test.go
@@ -100,6 +100,14 @@ func (t *TestTripper) RoundTrip(request *http.Request) (*http.Response, error) {
 }
 
 func Test_handleTokenResponse(t *testing.T) {
+	myToken, err := tf.secret("my-token", "", "My Subject", nil)
+	if err != nil {
+		t.Error(err)
+	}
+	myToken1, err := tf.secret("my-token", "test", "Another Subject", nil)
+	if err != nil {
+		t.Error(err)
+	}
 	var tests = []struct {
 		name                 string
 		token                *v2alpha1.AccessToken
@@ -120,7 +128,7 @@ func Test_handleTokenResponse(t *testing.T) {
 			token: tf.token("my-token", "test", "http://foo/xyz", "mycode", ""),
 			site:  tf.site("my-site", "test"),
 			body: &CertToken{
-				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				tlsCredentials: myToken,
 				links: []*v2alpha1.Link{
 					tf.link("my-token", "", []v2alpha1.Endpoint{
 						{
@@ -145,7 +153,7 @@ func Test_handleTokenResponse(t *testing.T) {
 			token: tf.token("my-token", "test", "http://foo/xyz", "mycode", ""),
 			site:  tf.site("my-site", "test"),
 			body: &CertToken{
-				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				tlsCredentials: myToken,
 				links: []*v2alpha1.Link{
 					tf.link("my-token", "", []v2alpha1.Endpoint{
 						{
@@ -163,7 +171,7 @@ func Test_handleTokenResponse(t *testing.T) {
 			token: tf.token("my-token", "test", "http://foo/xyz", "mycode", ""),
 			site:  tf.site("my-site", "test"),
 			body: &CertToken{
-				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				tlsCredentials: myToken,
 				links: []*v2alpha1.Link{
 					tf.link("my-token", "", []v2alpha1.Endpoint{
 						{
@@ -174,14 +182,14 @@ func Test_handleTokenResponse(t *testing.T) {
 				},
 			},
 			expectedStatus:  "Controller could not create received secret: secrets \"my-token\" already exists",
-			extraK8sObjects: []runtime.Object{tf.secret("my-token", "test", "Another Subject", nil)},
+			extraK8sObjects: []runtime.Object{myToken1},
 		},
 		{
 			name:  "link collision",
 			token: tf.token("my-token", "test", "http://foo/xyz", "mycode", ""),
 			site:  tf.site("my-site", "test"),
 			body: &CertToken{
-				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				tlsCredentials: myToken,
 				links: []*v2alpha1.Link{
 					tf.link("my-token", "", []v2alpha1.Endpoint{
 						{
@@ -199,7 +207,7 @@ func Test_handleTokenResponse(t *testing.T) {
 			token: tf.addLinkCost(tf.token("my-token", "test", "http://foo/xyz", "mycode", ""), 10),
 			site:  tf.site("my-site", "test"),
 			body: &CertToken{
-				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				tlsCredentials: myToken,
 				links: []*v2alpha1.Link{
 					tf.link("my-token", "", []v2alpha1.Endpoint{
 						{
@@ -384,7 +392,11 @@ func Test_RedeemAccessToken(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			client, err := fake.NewFakeClient("test", []runtime.Object{tf.secret("skupper-site-ca", "test", "Test Site CA", nil)}, []runtime.Object{tf.site("my-site", "test"), tf.grant("my-grant", "test", "")}, "")
+			siteCa, err := tf.secret("skupper-site-ca", "test", "Test Site CA", nil)
+			if err != nil {
+				t.Error(err)
+			}
+			client, err := fake.NewFakeClient("test", []runtime.Object{siteCa}, []runtime.Object{tf.site("my-site", "test"), tf.grant("my-grant", "test", "")}, "")
 			site, err := client.GetSkupperClient().SkupperV2alpha1().Sites("test").Get(context.TODO(), "my-site", metav1.GetOptions{})
 			if err != nil {
 				t.Error(err)
@@ -404,10 +416,14 @@ func Test_RedeemAccessToken(t *testing.T) {
 			server := newServer(":0", tt.scheme == "https", grants)
 			server.listen()
 			grants.setUrl(fmt.Sprintf("localhost:%d", server.port()))
+			myCreds, err := tf.secret("my-creds", "test", "grant server", []string{"localhost"})
+			if err != nil {
+				t.Error(err)
+			}
 			go server.serve()
 			defer server.stop()
 			if tt.scheme == "https" {
-				secret := tf.secret("my-creds", "test", "grant server", []string{"localhost"})
+				secret := myCreds
 				err = server.setCertificateFromSecret(secret)
 				if err != nil {
 					t.Error(err)
@@ -478,7 +494,10 @@ func (g *TestTokenGenerator) generate(namespace string, name string, subject str
 	if err != nil {
 		return err
 	}
-	token := generator.NewCertToken(name, subject)
+	token, err := generator.NewCertToken(name, subject)
+	if err != nil {
+		return err
+	}
 	return token.Write(writer)
 }
 
diff --git a/internal/kube/grants/server_test.go b/internal/kube/grants/server_test.go
index f09d9a39..337d2d5f 100644
--- a/internal/kube/grants/server_test.go
+++ b/internal/kube/grants/server_test.go
@@ -11,6 +11,10 @@ import (
 )
 
 func Test_setCertificateFromSecret(t *testing.T) {
+	simpleSecret, err := tf.secret("simple", "", "My Subject", []string{"foo.com", "bar.org"})
+	if err != nil {
+		t.Error(err)
+	}
 	var tests = []struct {
 		name             string
 		secret           *corev1.Secret
@@ -20,7 +24,7 @@ func Test_setCertificateFromSecret(t *testing.T) {
 	}{
 		{
 			name:             "simple",
-			secret:           tf.secret("simple", "", "My Subject", []string{"foo.com", "bar.org"}),
+			secret:           simpleSecret,
 			expectedSubject:  "My Subject",
 			expectedDNSNames: []string{"foo.com", "bar.org"},
 		},
diff --git a/internal/kube/grants/tokens_test.go b/internal/kube/grants/tokens_test.go
index 31fd2399..cf8fc8a1 100644
--- a/internal/kube/grants/tokens_test.go
+++ b/internal/kube/grants/tokens_test.go
@@ -11,6 +11,10 @@ import (
 )
 
 func Test_CertTokenWrite(t *testing.T) {
+	mySecret, err := tf.secret("my-token", "", "My Subject", nil)
+	if err != nil {
+		t.Error(err)
+	}
 	var tests = []struct {
 		name          string
 		cert          *CertToken
@@ -20,7 +24,7 @@ func Test_CertTokenWrite(t *testing.T) {
 		{
 			name: "good",
 			cert: &CertToken{
-				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				tlsCredentials: mySecret,
 				links: []*v2alpha1.Link{
 					tf.link("my-token", "", []v2alpha1.Endpoint{
 						{
@@ -34,7 +38,7 @@ func Test_CertTokenWrite(t *testing.T) {
 		{
 			name: "bad",
 			cert: &CertToken{
-				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				tlsCredentials: mySecret,
 				links: []*v2alpha1.Link{
 					tf.link("my-token", "", []v2alpha1.Endpoint{
 						{
@@ -50,7 +54,7 @@ func Test_CertTokenWrite(t *testing.T) {
 		{
 			name: "later bad",
 			cert: &CertToken{
-				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				tlsCredentials: mySecret,
 				links: []*v2alpha1.Link{
 					tf.link("my-token", "", []v2alpha1.Endpoint{
 						{
diff --git a/internal/kube/watchers/watchers_test.go b/internal/kube/watchers/watchers_test.go
index b2e62565..a68b05e6 100644
--- a/internal/kube/watchers/watchers_test.go
+++ b/internal/kube/watchers/watchers_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"sync"
 	"testing"
 	"time"
 
@@ -57,10 +58,6 @@ func TestCallback(t *testing.T) {
 }
 
 func TestNamespaceWatcher(t *testing.T) {
-	type call struct {
-		Key       string
-		Namespace *corev1.Namespace
-	}
 	type get struct {
 		key       string
 		namespace *corev1.Namespace
@@ -73,7 +70,7 @@ func TestNamespaceWatcher(t *testing.T) {
 		deleted   []*corev1.Namespace
 		recovered []*corev1.Namespace
 		expected  []*corev1.Namespace
-		callbacks []call
+		callbacks []callbackResult[corev1.Namespace]
 		gets      []get
 		err       string
 	}{
@@ -88,10 +85,10 @@ func TestNamespaceWatcher(t *testing.T) {
 			expected: []*corev1.Namespace{
 				namespace("foo"),
 			},
-			callbacks: []call{
+			callbacks: []callbackResult[corev1.Namespace]{
 				{
-					Key:       "foo",
-					Namespace: namespace("foo"),
+					Key: "foo",
+					Obj: namespace("foo"),
 				},
 			},
 			gets: []get{
@@ -121,14 +118,14 @@ func TestNamespaceWatcher(t *testing.T) {
 			expected: []*corev1.Namespace{
 				namespace("bar"),
 			},
-			callbacks: []call{
+			callbacks: []callbackResult[corev1.Namespace]{
 				{
-					Key:       "foo",
-					Namespace: namespace("foo"),
+					Key: "foo",
+					Obj: namespace("foo"),
 				},
 				{
-					Key:       "bar",
-					Namespace: namespace("bar"),
+					Key: "bar",
+					Obj: namespace("bar"),
 				},
 				{
 					Key: "foo",
@@ -152,17 +149,7 @@ func TestNamespaceWatcher(t *testing.T) {
 				assert.Assert(t, err)
 			}
 			processor := NewEventProcessor("tester", client)
-			var actual []call
-			handler := func(key string, namespace *corev1.Namespace) error {
-				actual = append(actual, call{
-					Key:       key,
-					Namespace: namespace,
-				})
-				if tt.err != "" {
-					return errors.New(tt.err)
-				}
-				return nil
-			}
+			handler, getCallbacks := makeHandler[corev1.Namespace](tt.err)
 			watcher := processor.WatchNamespaces(nil, handler)
 			stopCh := make(chan struct{})
 			watcher.Start(stopCh)
@@ -173,6 +160,10 @@ func TestNamespaceWatcher(t *testing.T) {
 				assert.Assert(t, cmp.Contains(recovered, expected))
 			}
 			assert.Equal(t, len(recovered), len(tt.recovered))
+
+			for i := 0; i < len(tt.initial); i++ {
+				processor.TestProcess()
+			}
 			for _, namespace := range tt.added {
 				_, err = client.GetKubeClient().CoreV1().Namespaces().Create(context.Background(), namespace, metav1.CreateOptions{})
 				assert.Assert(t, err)
@@ -181,10 +172,10 @@ func TestNamespaceWatcher(t *testing.T) {
 				err = client.GetKubeClient().CoreV1().Namespaces().Delete(context.Background(), namespace.Name, metav1.DeleteOptions{})
 				assert.Assert(t, err)
 			}
-			for i := 0; i < len(tt.initial)+len(tt.added)+len(tt.deleted); i++ {
+			for i := 0; i < len(tt.added)+len(tt.deleted); i++ {
 				processor.TestProcess()
 			}
-			assert.DeepEqual(t, tt.callbacks, actual)
+			assert.DeepEqual(t, tt.callbacks, getCallbacks())
 			all := watcher.List()
 			assert.DeepEqual(t, all, tt.expected)
 			for _, get := range tt.gets {
@@ -213,10 +204,6 @@ func namespace(name string) *corev1.Namespace {
 }
 
 func TestNodeWatcher(t *testing.T) {
-	type call struct {
-		Key  string
-		Node *corev1.Node
-	}
 	type get struct {
 		key  string
 		node *corev1.Node
@@ -229,7 +216,7 @@ func TestNodeWatcher(t *testing.T) {
 		deleted   []*corev1.Node
 		recovered []*corev1.Node
 		expected  []*corev1.Node
-		callbacks []call
+		callbacks []callbackResult[corev1.Node]
 		gets      []get
 		err       string
 	}{
@@ -244,10 +231,10 @@ func TestNodeWatcher(t *testing.T) {
 			expected: []*corev1.Node{
 				node("foo"),
 			},
-			callbacks: []call{
+			callbacks: []callbackResult[corev1.Node]{
 				{
-					Key:  "foo",
-					Node: node("foo"),
+					Key: "foo",
+					Obj: node("foo"),
 				},
 			},
 			gets: []get{
@@ -277,14 +264,14 @@ func TestNodeWatcher(t *testing.T) {
 			expected: []*corev1.Node{
 				node("bar"),
 			},
-			callbacks: []call{
+			callbacks: []callbackResult[corev1.Node]{
 				{
-					Key:  "foo",
-					Node: node("foo"),
+					Key: "foo",
+					Obj: node("foo"),
 				},
 				{
-					Key:  "bar",
-					Node: node("bar"),
+					Key: "bar",
+					Obj: node("bar"),
 				},
 				{
 					Key: "foo",
@@ -308,17 +295,7 @@ func TestNodeWatcher(t *testing.T) {
 				assert.Assert(t, err)
 			}
 			processor := NewEventProcessor("tester", client)
-			var actual []call
-			handler := func(key string, node *corev1.Node) error {
-				actual = append(actual, call{
-					Key:  key,
-					Node: node,
-				})
-				if tt.err != "" {
-					return errors.New(tt.err)
-				}
-				return nil
-			}
+			handler, getCallbacks := makeHandler[corev1.Node](tt.err)
 			watcher := processor.WatchNodes(handler)
 			stopCh := make(chan struct{})
 			watcher.Start(stopCh)
@@ -328,6 +305,9 @@ func TestNodeWatcher(t *testing.T) {
 			for _, expected := range tt.recovered {
 				assert.Assert(t, cmp.Contains(recovered, expected))
 			}
+			for i := 0; i < len(tt.initial); i++ {
+				processor.TestProcess()
+			}
 			assert.Equal(t, len(recovered), len(tt.recovered))
 			for _, node := range tt.added {
 				_, err = client.GetKubeClient().CoreV1().Nodes().Create(context.Background(), node, metav1.CreateOptions{})
@@ -337,10 +317,10 @@ func TestNodeWatcher(t *testing.T) {
 				err = client.GetKubeClient().CoreV1().Nodes().Delete(context.Background(), node.Name, metav1.DeleteOptions{})
 				assert.Assert(t, err)
 			}
-			for i := 0; i < len(tt.initial)+len(tt.added)+len(tt.deleted); i++ {
+			for i := 0; i < len(tt.added)+len(tt.deleted); i++ {
 				processor.TestProcess()
 			}
-			assert.DeepEqual(t, tt.callbacks, actual)
+			assert.DeepEqual(t, tt.callbacks, getCallbacks())
 			all := watcher.List()
 			assert.DeepEqual(t, all, tt.expected)
 			for _, get := range tt.gets {
@@ -395,3 +375,31 @@ func TestProcessRequeueLimit(t *testing.T) {
 	}
 	assert.Equal(t, stubHandler.CallCount, 6, "Should Requeue 5 times + 1 for the initial event")
 }
+
+type callbackResult[T any] struct {
+	Key string
+	Obj *T
+}
+
+func makeHandler[T any](errStr string) (handler func(key string, obj *T) error, getter func() []callbackResult[T]) {
+	var (
+		actual []callbackResult[T]
+		mu     sync.Mutex
+	)
+	return func(key string, obj *T) error {
+			mu.Lock()
+			defer mu.Unlock()
+			actual = append(actual, callbackResult[T]{
+				Key: key,
+				Obj: obj,
+			})
+			if errStr != "" {
+				return errors.New(errStr)
+			}
+			return nil
+		}, func() []callbackResult[T] {
+			mu.Lock()
+			defer mu.Unlock()
+			return actual
+		}
+}
diff --git a/internal/nonkube/bootstrap/controller/startup_test.go b/internal/nonkube/bootstrap/controller/startup_test.go
new file mode 100644
index 00000000..f9608523
--- /dev/null
+++ b/internal/nonkube/bootstrap/controller/startup_test.go
@@ -0,0 +1,60 @@
+package controller
+
+import (
+	"fmt"
+	"os"
+	"path"
+	"strings"
+	"testing"
+
+	"github.com/skupperproject/skupper/api/types"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	"gotest.tools/v3/assert"
+)
+
+func TestGetStartupScripts(t *testing.T) {
+	xdgDataHomeOrig := os.Getenv("XDG_DATA_HOME")
+	skupperPlatformOrig := os.Getenv("SKUPPER_PLATFORM")
+	defer func() {
+		t.Setenv("XDG_DATA_HOME", xdgDataHomeOrig)
+		t.Setenv("SKUPPER_PLATFORM", skupperPlatformOrig)
+	}()
+
+	tempDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tempDir)
+
+	for _, platform := range []string{"podman", "docker"} {
+		t.Run("platform-"+platform, func(t *testing.T) {
+			t.Setenv("SKUPPER_PLATFORM", platform)
+			startupArgs := StartupScriptsArgs{
+				Platform: types.Platform(platform),
+				Bundle:   false,
+				Name:     "system-test-container",
+			}
+			scripts, err := GetStartupScripts(startupArgs, api.GetSystemControllerPath())
+			assert.Assert(t, err)
+			assert.Assert(t, scripts != nil)
+			assert.Assert(t, os.MkdirAll(scripts.GetPath(), 0755))
+			assert.Assert(t, scripts.Create())
+			_, err = os.ReadDir(scripts.GetPath())
+			assert.Assert(t, err)
+			startSh, err := os.ReadFile(path.Join(scripts.GetPath(), "start.sh"))
+			assert.Assert(t, err)
+			assert.Assert(t, strings.Contains(string(startSh), fmt.Sprintf("%s start %s", platform, startupArgs.Name)))
+			stopSh, err := os.ReadFile(path.Join(scripts.GetPath(), "stop.sh"))
+			assert.Assert(t, strings.Contains(string(stopSh), fmt.Sprintf("%s stop -t 10 %s", platform, startupArgs.Name)))
+			assert.Assert(t, err)
+			scripts.Remove()
+		})
+	}
+
+	t.Run("invalid-platform", func(t *testing.T) {
+		startupArgs := StartupScriptsArgs{
+			Platform: types.PlatformKubernetes,
+		}
+		scripts, err := GetStartupScripts(startupArgs, api.GetSystemControllerPath())
+		assert.ErrorContains(t, err, "startup scripts can only be used with podman or docker platforms")
+		assert.Assert(t, scripts == nil)
+	})
+
+}
diff --git a/internal/nonkube/client/runtime/certs_test.go b/internal/nonkube/client/runtime/certs_test.go
index 82b2564b..38322a02 100644
--- a/internal/nonkube/client/runtime/certs_test.go
+++ b/internal/nonkube/client/runtime/certs_test.go
@@ -38,7 +38,10 @@ func TestTlsConfigRetriever(t *testing.T) {
 	t.Run("tls-config-retriever-valid-certs", func(t *testing.T) {
 		validity, err := time.ParseDuration("1h")
 		assert.Assert(t, err)
-		ca := certs.GenerateSecret("my-ca", "my-ca", "my-ca.host", validity, nil)
+		ca, err := certs.GenerateSecret("my-ca", "my-ca", "my-ca.host", validity, nil)
+		if err != nil {
+			t.Error(err)
+		}
 
 		certPath := path.Join(baseCertsPath, "valid-certificate")
 		assert.Assert(t, os.MkdirAll(certPath, 0755))
diff --git a/internal/nonkube/client/runtime/info_test.go b/internal/nonkube/client/runtime/info_test.go
index b79f3342..28b1f3d4 100644
--- a/internal/nonkube/client/runtime/info_test.go
+++ b/internal/nonkube/client/runtime/info_test.go
@@ -32,6 +32,12 @@ func TestGetLocalRouterAddress(t *testing.T) {
 	routerAddress, err = GetLocalRouterAddress(namespace)
 	assert.Assert(t, err)
 	assert.Equal(t, routerAddress, "amqps://127.0.0.1:5671")
+
+	// Make router access invalid
+	ss.RouterAccesses["skupper-local"].Spec.Roles = nil
+	assert.Assert(t, api.MarshalSiteState(*ss, outputDir))
+	routerAddress, err = GetLocalRouterAddress(namespace)
+	assert.ErrorContains(t, err, "no roles defined on RouterAccess: skupper-local")
 }
 
 func fakeSkupperLocalRouterAccess(namespace string) *v2alpha1.RouterAccess {
diff --git a/internal/nonkube/common/fs_config_renderer_test.go b/internal/nonkube/common/fs_config_renderer_test.go
index 32cd0df6..d1962b11 100644
--- a/internal/nonkube/common/fs_config_renderer_test.go
+++ b/internal/nonkube/common/fs_config_renderer_test.go
@@ -123,15 +123,24 @@ func compareCertificates(t *testing.T, customOutputPath string) {
 func createInputCertificates(t *testing.T, customOutputPath string) {
 	// preparing certificates
 	fakeHosts := "10.0.0.1,10.0.0.2,fake.domain"
-	ca := certs.GenerateSecret("fake-ca", "fake-ca", "", 0, nil)
-	server := certs.GenerateSecret("fake-server-cert", "fake-server-cert", fakeHosts, 0, &ca)
-	client := certs.GenerateSecret("fake-client-cert", "fake-client-cert", "", 0, &ca)
+	ca, err := certs.GenerateSecret("fake-ca", "fake-ca", "", 0, nil)
+	if err != nil {
+		t.Error(err)
+	}
+	server, err := certs.GenerateSecret("fake-server-cert", "fake-server-cert", fakeHosts, 0, ca)
+	if err != nil {
+		t.Error(err)
+	}
+	client, err := certs.GenerateSecret("fake-client-cert", "fake-client-cert", "", 0, ca)
+	if err != nil {
+		t.Error(err)
+	}
 
 	// paths for each provided certificate
 	caPath := path.Join(customOutputPath, "namespaces/default", string(api.InputIssuersPath), "skupper-site-ca")
 	serverPath := path.Join(customOutputPath, "namespaces/default", string(api.InputCertificatesPath), "link-access-one")
 	clientPath := path.Join(customOutputPath, "namespaces/default", string(api.InputCertificatesPath), "client-link-access-one")
-	certsMap := map[string]corev1.Secret{
+	certsMap := map[string]*corev1.Secret{
 		caPath:     ca,
 		serverPath: server,
 		clientPath: client,
diff --git a/internal/site/routeraccess_test.go b/internal/site/routeraccess_test.go
index bd708608..1a89404b 100644
--- a/internal/site/routeraccess_test.go
+++ b/internal/site/routeraccess_test.go
@@ -289,7 +289,7 @@ func TestRouterAccessConfig_Apply(t *testing.T) {
 			argsConfig.SslProfiles = tt.args.sslProfiles
 
 			if got := g.Apply(&argsConfig); got != tt.want {
-				t.Errorf("RouterAccessConfig.Apply() = %v, want %v", got, tt.want)
+				t.Errorf("RouterAccessConfig.Apply() = %v, want %v (subtest: %s)", got, tt.want, tt.name)
 			}
 		})
 	}
diff --git a/pkg/nonkube/api/token_test.go b/pkg/nonkube/api/token_test.go
index 92cedfc0..e931777a 100644
--- a/pkg/nonkube/api/token_test.go
+++ b/pkg/nonkube/api/token_test.go
@@ -42,7 +42,7 @@ func TestCreateTokens(t *testing.T) {
 	tests := []struct {
 		name          string
 		ra            v2alpha1.RouterAccess
-		serverSecret  v1.Secret
+		serverSecret  *v1.Secret
 		expectedHosts []string
 	}{
 		{
@@ -67,7 +67,7 @@ func TestCreateTokens(t *testing.T) {
 		{
 			name:         "invalid-server-cert",
 			ra:           fakeRouterAccessBothRoles(),
-			serverSecret: fakeServerSecretBad(),
+			serverSecret: fakeServerSecretBad(t),
 			expectedHosts: []string{
 				"127.0.0.1",
 			},
@@ -75,7 +75,7 @@ func TestCreateTokens(t *testing.T) {
 		{
 			name:         "server-cert-no-hosts",
 			ra:           fakeRouterAccessBothRoles(),
-			serverSecret: fakeServerSecret([]string{}),
+			serverSecret: fakeServerSecret([]string{}, t),
 			expectedHosts: []string{
 				"127.0.0.1",
 			},
@@ -92,7 +92,7 @@ func TestCreateTokens(t *testing.T) {
 		{
 			name:         "sans-provided-empty-server-cert",
 			ra:           fakeRouterAccessWithSANs("fake.host.one", "fake.host.two"),
-			serverSecret: fakeServerSecret([]string{}),
+			serverSecret: fakeServerSecret([]string{}, t),
 			expectedHosts: []string{
 				"127.0.0.1",
 				"fake.host.one",
@@ -102,7 +102,7 @@ func TestCreateTokens(t *testing.T) {
 		{
 			name:         "sans-provided-server-cert-with-hosts",
 			ra:           fakeRouterAccessWithSANs("fake.host.one", "fake.host.two"),
-			serverSecret: fakeServerSecret([]string{"server.host.one", "server.host.two"}),
+			serverSecret: fakeServerSecret([]string{"server.host.one", "server.host.two"}, t),
 			expectedHosts: []string{
 				"127.0.0.1",
 				"server.host.one",
@@ -112,7 +112,7 @@ func TestCreateTokens(t *testing.T) {
 		{
 			name:         "sans-provided-server-cert-with-hosts-and-ips",
 			ra:           fakeRouterAccessWithSANs("fake.host.one", "fake.host.two"),
-			serverSecret: fakeServerSecret([]string{"server.host.one", "server.host.two", "10.0.0.1", "10.0.0.2"}),
+			serverSecret: fakeServerSecret([]string{"server.host.one", "server.host.two", "10.0.0.1", "10.0.0.2"}, t),
 			expectedHosts: []string{
 				"127.0.0.1",
 				"10.0.0.1",
@@ -205,16 +205,29 @@ func fakeRouterAccessWithSANs(sans ...string) v2alpha1.RouterAccess {
 	return ra
 }
 
-func fakeServerSecretBad() v1.Secret {
-	ca := certs.GenerateSecret("fake-ca", "fake-ca", "", 0, nil)
-	server := certs.GenerateSecret("fake-server-cert", "fake-server-cert", "", 0, &ca)
-	delete(server.Data, "tls.crt")
-	return server
+func fakeServerSecretBad(t *testing.T) *v1.Secret {
+	ca, err := certs.GenerateSecret("fake-ca", "fake-ca", "", 0, nil)
+	if err != nil {
+		t.Error(err)
+	}
+	serverCert, err := certs.GenerateSecret("fake-server-cert", "fake-server-cert", "", 0, ca)
+	if err != nil {
+		t.Error(err)
+	}
+
+	delete(serverCert.Data, "tls.crt")
+	return serverCert
 }
 
-func fakeServerSecret(hosts []string) v1.Secret {
+func fakeServerSecret(hosts []string, t *testing.T) *v1.Secret {
 	hostsCsv := strings.Join(hosts, ",")
-	ca := certs.GenerateSecret("fake-ca", "fake-ca", "", 0, nil)
-	server := certs.GenerateSecret("fake-server-cert", "fake-server-cert", hostsCsv, 0, &ca)
-	return server
+	ca, err := certs.GenerateSecret("fake-ca", "fake-ca", "", 0, nil)
+	if err != nil {
+		t.Error(err)
+	}
+	serverCert, err := certs.GenerateSecret("fake-server-cert", "fake-server-cert", hostsCsv, 0, ca)
+	if err != nil {
+		t.Error(err)
+	}
+	return serverCert
 }
diff --git a/tests/Makefile b/tests/Makefile
index 10201d61..c3c54381 100644
--- a/tests/Makefile
+++ b/tests/Makefile
@@ -1,7 +1,7 @@
 ROOT_PATH := $(shell pwd)
 EXTRA_VARS := --extra-vars "@$(ROOT_PATH)/vars.yml"
 COLLECTION_PATH := $(ROOT_PATH)/e2e/collections/ansible_collections/e2e/tests
-TESTS_CI := attached-connector,ha,hello-world
+TESTS_CI := attached-connector,ha,hello-world,expose-pods-by-name
 
 # E2E Test directories
 E2E_TEST_DIRS := $(sort $(wildcard $(ROOT_PATH)/e2e/scenarios/*))
diff --git a/tests/e2e/scenarios/expose-pods-by-name/README.md b/tests/e2e/scenarios/expose-pods-by-name/README.md
new file mode 100644
index 00000000..700ca19e
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/README.md
@@ -0,0 +1,95 @@
+# Skupper Expose Pods By Name Test
+
+This README provides an overview of the "Skupper Expose Pods By Name Test"
+
+## Overview
+
+This test creates two Skupper sites  and link them.
+A backend application is deployed in the `east` namespace and exposed
+via Skupper in the `west` namespace, with 3 replicas.
+We have the option exposePodsByName set in both Connector and Listener
+
+The test  perform the following steps : 
+- Ensure that the exposed services matches the service pod names.
+- Restart the service deployment. This will force new pod names replacing the old ones.
+- Ensure that the exposed services matches only the new pod names.
+
+
+## Prerequisites
+
+- Two Kubernetes clusters (identified as "west" and "east" in your inventory)
+- Ansible with the following collections installed:
+  - `e2e.tests`
+  - `skupper.v2`
+  - Required e2e test roles
+- Appropriate kubeconfig files with access to both clusters
+
+## Playbook Structure
+
+The playbook executes the following sequence of operations:
+
+1. **Setup**
+   - Create Skupper sites
+   - Generate AccessToken and AccessGrant
+   - Connect the two Skupper sites
+   - Deploy the backend application and the 3 replicas
+   - Set the option exposePodsByName in the appropriate connectors and listeners
+
+2. **Run the test**
+   - Ensure that all Skupper sites are connected
+   - Ensure that the exposed services matches the service pod names
+   - Restart the backend deployment
+   - Ensure that the exposed services matches the service pod names, specifically if the old pods were removed from services list.
+   - If the exposed services names matches exactly the number and name of backend pods, the test PASS
+
+3. **Teardown**
+   - Delete the Kubernetes job
+   - Remove the backend application
+   - Delete all Skupper components: Sites, Listeners, and Connectors
+   - Delete the namespaces
+
+## Resource Files
+
+The playbook expects the following resource definition files:
+
+### West Cluster
+- `resources/west/listener.yml` - Skupper listener configuration
+- `resources/west/site.yml` - Skupper site configuration
+
+### East Cluster
+- `resources/east/backend.yml` - Backend application deployment
+- `resources/east/connector.yml` - Skupper connector configuration
+- `resources/east/site.yml` - Skupper site configuration
+
+## Variables
+
+### Global Variables (`group_vars/all.yml`)
+
+```yaml
+ansible_connection: local
+ansible_user: "{{ lookup('env', 'USER') }}"
+debug: false
+namespace_prefix: "e2e"
+generate_namespaces_namespace_label: "xpbn"
+remove_namespaces: true
+```
+
+### Host-specific Variables
+
+#### West Cluster (`host_vars/west.yml`)
+
+```yaml
+# Kubeconfig path for west site
+kubeconfig_1: "{{ ansible_env.HOME }}/.kube/config"
+kubeconfig: "{{ kubeconfig_1 }}"
+namespace_name: "west"
+```
+
+#### East Cluster (`host_vars/east.yml`)
+
+```yaml
+# Kubeconfig path for east site
+kubeconfig_2: "{{ ansible_env.HOME }}/.kube/config"
+kubeconfig: "{{ kubeconfig_2 }}"
+namespace_name: "east"
+```
diff --git a/tests/e2e/scenarios/expose-pods-by-name/inventory/group_vars/all.yml b/tests/e2e/scenarios/expose-pods-by-name/inventory/group_vars/all.yml
new file mode 100644
index 00000000..cdca74ad
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/inventory/group_vars/all.yml
@@ -0,0 +1,9 @@
+---
+ansible_connection: local
+ansible_user: "{{ lookup('env', 'USER') }}"
+debug: false
+namespace_prefix: "e2e"
+generate_namespaces_namespace_label: "xpbn"
+remove_namespaces: true
+resource_retry_value: 30
+resource_delay_value: 10
diff --git a/tests/e2e/scenarios/expose-pods-by-name/inventory/host_vars/east.yaml b/tests/e2e/scenarios/expose-pods-by-name/inventory/host_vars/east.yaml
new file mode 100644
index 00000000..a4979a9a
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/inventory/host_vars/east.yaml
@@ -0,0 +1,5 @@
+---
+# Kubeconfig path for east site
+kubeconfig_2: "{{ ansible_env.HOME }}/.kube/config"
+kubeconfig: "{{ kubeconfig_2 }}"
+namespace_name: "east"
diff --git a/tests/e2e/scenarios/expose-pods-by-name/inventory/host_vars/west.yaml b/tests/e2e/scenarios/expose-pods-by-name/inventory/host_vars/west.yaml
new file mode 100644
index 00000000..33c19318
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/inventory/host_vars/west.yaml
@@ -0,0 +1,5 @@
+---
+# Kubeconfig path for west site
+kubeconfig_1: "{{ ansible_env.HOME }}/.kube/config"
+kubeconfig: "{{ kubeconfig_1 }}"
+namespace_name: "west"
diff --git a/tests/e2e/scenarios/expose-pods-by-name/inventory/hosts.yml b/tests/e2e/scenarios/expose-pods-by-name/inventory/hosts.yml
new file mode 100644
index 00000000..9aff776d
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/inventory/hosts.yml
@@ -0,0 +1,5 @@
+---
+all:
+  hosts:
+    west:
+    east:
diff --git a/tests/e2e/scenarios/expose-pods-by-name/resources/east/backend.yaml b/tests/e2e/scenarios/expose-pods-by-name/resources/east/backend.yaml
new file mode 100644
index 00000000..fa2cfb55
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/resources/east/backend.yaml
@@ -0,0 +1,21 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: backend
+  labels:
+    app: backend
+spec:
+  selector:
+    matchLabels:
+      app: backend
+  replicas: 3
+  template:
+    metadata:
+      labels:
+        app: backend
+    spec:
+      containers:
+        - name: backend
+          image: quay.io/skupper/hello-world-backend
+          ports:
+            - containerPort: 8080
diff --git a/tests/e2e/scenarios/expose-pods-by-name/resources/east/connector.yaml b/tests/e2e/scenarios/expose-pods-by-name/resources/east/connector.yaml
new file mode 100644
index 00000000..079570e1
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/resources/east/connector.yaml
@@ -0,0 +1,9 @@
+apiVersion: skupper.io/v2alpha1
+kind: Connector
+metadata:
+  name: backend
+spec:
+  routingKey: backend
+  port: 8080
+  selector: app=backend
+  exposePodsByName: true
diff --git a/tests/e2e/scenarios/expose-pods-by-name/resources/east/site.yaml b/tests/e2e/scenarios/expose-pods-by-name/resources/east/site.yaml
new file mode 100644
index 00000000..d7fdbbcd
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/resources/east/site.yaml
@@ -0,0 +1,5 @@
+apiVersion: skupper.io/v2alpha1
+kind: Site
+metadata:
+  name: east
+spec:
diff --git a/tests/e2e/scenarios/expose-pods-by-name/resources/west/listener.yaml b/tests/e2e/scenarios/expose-pods-by-name/resources/west/listener.yaml
new file mode 100644
index 00000000..171bcf35
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/resources/west/listener.yaml
@@ -0,0 +1,9 @@
+apiVersion: skupper.io/v2alpha1
+kind: Listener
+metadata:
+  name: backend
+spec:
+  port: 8080
+  host: backend
+  routingKey: backend
+  exposePodsByName: true
diff --git a/tests/e2e/scenarios/expose-pods-by-name/resources/west/site.yaml b/tests/e2e/scenarios/expose-pods-by-name/resources/west/site.yaml
new file mode 100644
index 00000000..5890c060
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/resources/west/site.yaml
@@ -0,0 +1,6 @@
+apiVersion: skupper.io/v2alpha1
+kind: Site
+metadata:
+  name: west
+spec:
+  linkAccess: default
diff --git a/tests/e2e/scenarios/expose-pods-by-name/test.yml b/tests/e2e/scenarios/expose-pods-by-name/test.yml
new file mode 100644
index 00000000..ebae9a22
--- /dev/null
+++ b/tests/e2e/scenarios/expose-pods-by-name/test.yml
@@ -0,0 +1,399 @@
+---
+- name: Skupper Expose-Pods-By-Name Test
+  hosts: all
+  connection: local
+  gather_facts: false
+
+  vars:
+    kubeconfig: kubeconfig
+    search_character: "-"
+
+  tasks:
+    - name: "[ ExposePodsByNameTest ] - Main test block"
+      block:
+        - name: "[ ExposePodsByNameTest - Setup ] Generating the namespaces"
+          ansible.builtin.include_role:
+            name: e2e.tests.generate_namespaces
+
+        - name: "[ ExposePodsByNameTest - Setup ] Creating Skupper resources on east namespace"
+          skupper.v2.resource:
+            kubeconfig: "{{ kubeconfig }}"
+            path: "{{ item }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+          with_items:
+            - "{{ playbook_dir }}/resources/east/backend.yaml"
+            - "{{ playbook_dir }}/resources/east/site.yaml"
+            - "{{ playbook_dir }}/resources/east/connector.yaml"
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - Setup ] Creating Skupper resources on west namespace"
+          skupper.v2.resource:
+            kubeconfig: "{{ kubeconfig }}"
+            path: "{{ item }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+          with_items:
+            - "{{ playbook_dir }}/resources/west/site.yaml"
+            - "{{ playbook_dir }}/resources/west/listener.yaml"
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - Setup ] Issue a Skupper access token from west namespace"
+          skupper.v2.token:
+            kubeconfig: "{{ kubeconfig }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+            name: west-xpbn-grant
+            redemptions_allowed: 2
+          register: west
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - Setup ] Apply token to east site"
+          skupper.v2.resource:
+            kubeconfig: "{{ kubeconfig }}"
+            def: "{{ hostvars['west']['west']['token'] }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - Setup ] Wait for links to get in Ready state"
+          kubernetes.core.k8s_info:
+            api_version: skupper.io/v2alpha1
+            kind: Link
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+            kubeconfig: "{{ kubeconfig }}"
+          register: link_list
+          until:
+            - link_list.resources is defined
+            - link_list.resources | length > 0
+            - link_list.resources[0].status is defined
+            - link_list.resources[0].status.status is defined
+            - link_list.resources[0].status.status == "Ready"
+          retries: "{{ resource_retry_value * RESOURCE_RETRY_MULTIPLIER }}"
+          delay: "{{ resource_delay_value * RESOURCE_DELAY_MULTIPLIER }}"
+          when:
+            - "'east' in inventory_hostname"
+
+        # Get Pods array
+        - name: "[ ExposePodsByNameTest - RunTest ] Get the pod names in east"
+          kubernetes.core.k8s_info:
+            api_version: v1
+            kind: Pod
+            kubeconfig: "{{ kubeconfig }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+            label_selectors:
+              - app = backend
+          register: pod_list
+          until:
+            - pod_list.resources is defined
+            - pod_list.resources | length == 3
+          retries: "{{ resource_retry_value * RESOURCE_RETRY_MULTIPLIER }}"
+          delay: "{{ resource_delay_value * RESOURCE_DELAY_MULTIPLIER }}"
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Extract pod names into an array"
+          ansible.builtin.set_fact:
+            pod_names_array: "{{ pod_list.resources | map(attribute='metadata.name') | list }}"
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Display the array of pod names"
+          ansible.builtin.debug:
+            var: pod_names_array
+          when:
+            - pod_names_array is defined
+            - "'east' in inventory_hostname"
+
+        # Get Services array
+        - name: "[ ExposePodsByNameTest - RunTest ] Get the service names in west"
+          kubernetes.core.k8s_info:
+            api_version: v1
+            kind: Service
+            kubeconfig: "{{ kubeconfig }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+            label_selectors:
+              - internal.skupper.io/listener = true
+          register: svc_list
+          until:
+            - svc_list.resources is defined
+            - svc_list.resources | length == 4
+          retries: "{{ resource_retry_value * RESOURCE_RETRY_MULTIPLIER }}"
+          delay: "{{ resource_delay_value * RESOURCE_DELAY_MULTIPLIER }}"
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Extract service names into an array"
+          ansible.builtin.set_fact:
+            svc_names_array: "{{ svc_list.resources | selectattr('metadata.name', 'search', search_character) | map(attribute='metadata.name') | list }}"
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Display the array of service names"
+          ansible.builtin.debug:
+            var: svc_names_array
+          when:
+            - svc_names_array is defined
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Compare initial pods and services"
+          ansible.builtin.set_fact:
+            pod_svc_difference: "{{ hostvars['east']['pod_names_array'] | ansible.builtin.symmetric_difference(svc_names_array) }}"
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Check if we have differences between pods and service names"
+          ansible.builtin.set_fact:
+            num_differences: "{{ pod_svc_difference | length }}"
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Failing the test if the exposed services does not matches the pod names"
+          ansible.builtin.fail:
+            msg: "The test has FAILED - Pod names {{ hostvars['east']['pod_names_array'] }}, Service names {{ svc_names_array }}"
+          when:
+            - "'west' in inventory_hostname"
+            - num_differences | int > 0
+
+        #####
+        # Restart the Deployment
+        #####
+        - name: "[ ExposePodsByNameTest - RunTest ] Restarting the deployments"
+          ansible.builtin.shell: |
+            kubectl -n {{ namespace_prefix }}-{{ namespace_name }} \
+            rollout restart deployment backend
+          register: rollout
+          changed_when: rollout.rc == 0
+          failed_when: rollout.rc != 0 and 'not found' not in rollout.stderr
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Get the name of the new ReplicaSet"
+          ansible.builtin.shell: |
+            kubectl -n  {{ namespace_prefix }}-{{ namespace_name }} get ReplicaSet \
+            --sort-by=.metadata.creationTimestamp | tail -n 1 | awk -F " " '{print $1}'
+          register: new_replicaset_out
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Set the name of the new ReplicaSet as fact"
+          ansible.builtin.set_fact:
+            new_replicaset: "{{ new_replicaset_out.stdout | trim }}"
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Wait until all replicas are ready"
+          kubernetes.core.k8s_info:
+            api_version: apps/v1
+            kind: ReplicaSet
+            kubeconfig: "{{ kubeconfig }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+            name: "{{ new_replicaset }}"
+          register: new_replicaset_status
+          until:
+            - new_replicaset_status is defined
+            - new_replicaset_status.resources is defined
+            - new_replicaset_status.resources | length > 0
+            - new_replicaset_status.resources[0].status is defined
+            - new_replicaset_status.resources[0].status.replicas is defined
+            - new_replicaset_status.resources[0].status.replicas == new_replicaset_status.resources[0].metadata.annotations['deployment.kubernetes.io/desired-replicas'] | int
+          retries: "{{ resource_retry_value * RESOURCE_RETRY_MULTIPLIER }}"
+          delay: "{{ resource_delay_value * RESOURCE_DELAY_MULTIPLIER }}"
+          when:
+            - "'east' in inventory_hostname"
+
+        #####
+        # Validation after deployment restart
+        #####
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Wait for each old pod to disappear"
+          kubernetes.core.k8s_info:
+            api_version: v1
+            kind: Pod
+            name: "{{ item }}"
+            kubeconfig: "{{ kubeconfig }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+          register: pod_removed
+          until:
+            - pod_removed.resources is defined
+            - pod_removed.resources | length == 0
+          retries: "{{ resource_retry_value * RESOURCE_RETRY_MULTIPLIER }}"
+          delay: "{{ resource_delay_value * RESOURCE_DELAY_MULTIPLIER }}"
+          loop: "{{ pod_names_array }}"
+          when:
+            - pod_names_array is defined
+            - "'east' in inventory_hostname"
+
+        # Get New Pods array
+        - name: "[ ExposePodsByNameTest - RunTest ] Get the pod names in east"
+          kubernetes.core.k8s_info:
+            api_version: v1
+            kind: Pod
+            kubeconfig: "{{ kubeconfig }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+            label_selectors:
+              - app = backend
+          register: new_pod_list
+          until:
+            - new_pod_list is defined
+            - new_pod_list.resources is defined
+            - new_pod_list.resources | length == 3
+            - new_pod_list.resources[0].status is defined
+            - new_pod_list.resources[0].status.phase is defined
+            - new_pod_list.resources[0].status.phase == "Running"
+          retries: "{{ resource_retry_value * RESOURCE_RETRY_MULTIPLIER }}"
+          delay: "{{ resource_delay_value * RESOURCE_DELAY_MULTIPLIER }}"
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Extract pod names into an array"
+          ansible.builtin.set_fact:
+            new_pod_names_array: "{{ new_pod_list.resources | map(attribute='metadata.name') | list }}"
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Display the array of new pod names"
+          ansible.builtin.debug:
+            var: new_pod_names_array
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Compare old pod names and new pod names"
+          ansible.builtin.set_fact:
+            old_pod_new_pod_difference: "{{ new_pod_names_array | ansible.builtin.symmetric_difference(pod_names_array) }}"
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Display the differencec in pod names"
+          ansible.builtin.debug:
+            var: old_pod_new_pod_difference
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Check if we have differences between pods and service names"
+          ansible.builtin.set_fact:
+            num_differences_old_new_pods: "{{ old_pod_new_pod_difference | length }}"
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Failing the test if the the pod names haven't changed after deployment restart"
+          ansible.builtin.fail:
+            msg: "The test has FAILED - Old Pod names {{ pod_names_array }}, New Pod names {{ new_pod_names_array }}"
+          when:
+            - "'east' in inventory_hostname"
+            - num_differences_old_new_pods | int != 6
+
+        # Get Services array
+        - name: "[ ExposePodsByNameTest - RunTest ] Get the service names in west"
+          kubernetes.core.k8s_info:
+            api_version: v1
+            kind: Service
+            kubeconfig: "{{ kubeconfig }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+            label_selectors:
+              - internal.skupper.io/listener = true
+          register: svc_list
+          until:
+            - svc_list.resources is defined
+            - svc_list.resources | length == 4
+          retries: "{{ resource_retry_value * RESOURCE_RETRY_MULTIPLIER }}"
+          delay: "{{ resource_delay_value * RESOURCE_DELAY_MULTIPLIER }}"
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Extract service names into an array"
+          ansible.builtin.set_fact:
+            svc_names_array: "{{ svc_list.resources | selectattr('metadata.name', 'search', search_character) | map(attribute='metadata.name') | list }}"
+          when:
+            - svc_list.resources is defined
+            - svc_list.resources | length == 4
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Display the array of service names"
+          ansible.builtin.debug:
+            var: svc_names_array
+          when:
+            - svc_names_array is defined
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Compare pods and services after deployment reset"
+          ansible.builtin.set_fact:
+            pod_svc_difference: "{{ hostvars['east']['new_pod_names_array'] | ansible.builtin.symmetric_difference(svc_names_array) }}"
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Check if we have differences between pods and service names"
+          ansible.builtin.set_fact:
+            num_differences: "{{ pod_svc_difference | length }}"
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Display if both pods and services have matching names"
+          ansible.builtin.debug:
+            var: pod_svc_difference
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Failing the test if the exposed services does not matches the pod names after deployment restart"
+          ansible.builtin.fail:
+            msg: "The test has FAILED - Pod names {{ hostvars['east']['pod_names_array'] }}, Service names {{ svc_names_array }}"
+          when:
+            - "'west' in inventory_hostname"
+            - num_differences | int > 0
+
+        - name: "[ ExposePodsByNameTest - RunTest ] Test succeeded"
+          ansible.builtin.debug:
+            msg: "The test has PASSED - Pod names {{ hostvars['east']['pod_names_array'] }}, Service names {{ svc_names_array }}"
+          when:
+            - "'west' in inventory_hostname"
+            - num_differences | int == 0
+
+      always:
+        - name: "[ ExposePodsByNameTest - Teardown ] Removing Skupper resources from east namespace"
+          skupper.v2.resource:
+            kubeconfig: "{{ kubeconfig }}"
+            path: "{{ item }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+            state: absent
+          with_items:
+            - "{{ playbook_dir }}/resources/east/backend.yaml"
+            - "{{ playbook_dir }}/resources/east/site.yaml"
+            - "{{ playbook_dir }}/resources/east/connector.yaml"
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - Teardown ] Removing Skupper resources from west namespace"
+          skupper.v2.resource:
+            kubeconfig: "{{ kubeconfig }}"
+            path: "{{ item }}"
+            namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
+            state: absent
+          with_items:
+            - "{{ playbook_dir }}/resources/west/site.yaml"
+            - "{{ playbook_dir }}/resources/west/listener.yaml"
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - Teardown ] Removing the AccessGrant west-xpbn-grant in namespace west"
+          ansible.builtin.command: "kubectl -n {{ namespace_prefix }}-{{ namespace_name }} delete AccessGrant west-xpbn-grant --kubeconfig {{ kubeconfig }}"
+          register: out
+          failed_when: out.failed and 'not exists' not in out.stderr_lines[0]
+          when:
+            - "'west' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - Teardown ] Removing the AccessToken token-west-xpbn-grant in namespace east"
+          ansible.builtin.command: "kubectl -n {{ namespace_prefix }}-{{ namespace_name }} delete AccessToken token-west-xpbn-grant --kubeconfig {{ kubeconfig }}"
+          register: out
+          failed_when: out.failed and 'not exists' not in out.stderr_lines[0]
+          when:
+            - "'east' in inventory_hostname"
+
+        - name: "[ ExposePodsByNameTest - Teardown ] Removing the namespaces"
+          ansible.builtin.command: "kubectl delete namespace {{ item }} --kubeconfig {{ kubeconfig }}"
+          with_items:
+            - "{{ namespace_prefix }}-east"
+            - "{{ namespace_prefix }}-west"
+          register: out
+          failed_when: out.failed and 'not exists' not in out.stderr_lines
+          when:
+            - remove_namespaces | default(false) | bool
diff --git a/tests/e2e/scenarios/ha/test.yml b/tests/e2e/scenarios/ha/test.yml
index a08206f1..28e86e49 100644
--- a/tests/e2e/scenarios/ha/test.yml
+++ b/tests/e2e/scenarios/ha/test.yml
@@ -1,3 +1,4 @@
+---
 - name: Skupper HA Test
   hosts: all
   connection: local
@@ -63,7 +64,14 @@
             namespace: "{{ namespace_prefix }}-{{ namespace_name }}"
             kubeconfig: "{{ kubeconfig }}"
           register: link_list
-          until: link_list.resources | length > 1 and link_list.resources[0].status.status == "Ready" and link_list.resources[1].status.status == "Ready"
+          until:
+            - link_list.resources | length > 1
+            - link_list.resources[0].status is defined
+            - link_list.resources[0].status.status is defined
+            - link_list.resources[0].status.status == "Ready"
+            - link_list.resources[1].status is defined
+            - link_list.resources[1].status.status is defined
+            - link_list.resources[1].status.status == "Ready"
           retries: "{{ resource_retry_value * RESOURCE_RETRY_MULTIPLIER }}"
           delay: "{{ resource_delay_value * RESOURCE_DELAY_MULTIPLIER }}"
           when:
@@ -237,4 +245,4 @@
           register: out
           failed_when: out.failed and 'not exists' not in out.stderr_lines
           when:
-           - remove_namespaces | default(false) | bool
+            - remove_namespaces | default(false) | bool