Repository: skupperproject/skupper
Pull request #2150 (2025-06-04 20:40:51)

Add link delete command for nonkube
Add command to delete links.

The file "Link-<filename>.yaml" is removed from ~/.local/share/skupper/namesapces/<namespace>/input/resources.
When the site is reloaded the link is removed.

Currently on the specified link is removed, so if the user wants to remove more than one link, the user will need to issue this
command for each link to be removed. 


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/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/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/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/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/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/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/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/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
 


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/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/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/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/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/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