Repository: skupperproject/skupper
Pull request #2121 (2025-05-14 16:15:49)

System controller
# Introducing: System Controller

Its initial goal is to keep the runtime resource statuses updated, so that
users can easily determine the current VAN state.

The system controller can run as a standalone process, built using: `make system-controller`,
or as a container (depending on the CONTAINER_ENGINE environment variable, default set to podman),
by executing `cmd/system-controller/system-controller.sh`.

## How it works?

A single instance of the `system-controller` can be executed per-user and it watches all
Skupper namespaces defined under the given user, monitoring the state of a namespace and
controlling the lifecycle of the Collector Lite, based on the availability of the respective
router process (per namespace).

The Collector Lite has been subtly modified to work with Kubernetes and System sites, allowing
the `skupper-network-status` ConfigMap to be updated, regardless of the platform where it is
running.

Once the ConfigMap is updated by the Collector Lite, the System Controller will parse the
current VAN state and update the respective resources under `runtime/resources` for a namespace.

If the Router is brought down, for any reason, the Collector Lite is stopped and runtime resources
are updated back to the initial state (Configured).

## Components

### NamespacesHandler

Main controller handler triggered by the creation or removal of a namespace.
It creates a NamespaceController thread per namespace.

### NamespaceController

A thread created to control the components needed for each existing namespace.

### RouterConfigHandler

Detects when the router configuration has been created, meaning that a namespace has
been initialized, triggering the RouterStateHandler.

### RouterStateHandler

Run a Heartbeats client that informs when the router state changes (up or down), informing
the CollectorLifecycleHandler about it.

### CollectorLifecycleHandler

Controls the lifecycle of the Collector Lite based on router availability.
It is triggered by the RouterStateHandler component.

### NetworkStatusHandler

Processes changes done to the `skupper-network-status` ConfigMap and updates the statuses
of the respective `runtime/resources`.

## Related changes needed

Here is a list of all changes needed to existing components.

## internal/cmd

Some of the nonkube command implementations where using `api.GetHostSiteInternalPath`. This is OK
when you're only dealing with the actual path on the host machine, but since the System Controller
can run as a container, the commands needed to be modified to use `api.GetInternalOutputPath` instead.

There is another advantage on this change for testing purposes, as instead of setting the `SKUPPER_OUTPUT_PATH`
environment variable to customize where test resources are expected to be placed, you can set the `XDG_DATA_HOME`.
The `SKUPPER_OUTPUT_PATH` is meant to be used inside containers only, when the output path is mounted into
the container, while the `XDG_DATA_HOME` is always validated on rootless cases.

Another related change needed for testing purposes, was done to the allow a `DefaultRootDataHome` to be specified,
which allows the Skupper data home directory to be customized when using rootful mode. Previously it was set
statically to `/var/lib`. Now the `internal/cmd` tests have been tweaked to use both.

### internal/kube/flow -> internal/flow

An abstraction of the `StatusSync` has been extracted and moved from `internal/kube/flow` into `internal/flow`.
It now depends on the `StatusSyncClient` implementations, which are meant to provide mechanisms for manipulating
the `skupper-network-status` ConfigMap, regardless of the platform where it is running.

### internal/fs/watcher.go

The FileWatcher implementation has been refactored and improved as it was not yet been used internally.
Along with that, a new set of tests have been created to ensure a reliable behavior.

### internal/kube/controller/controller.go

The `extractSiteRecords` generic function has been exported moved to the `internal/network` package.

### internal/kube/site/site.go

The `getLinkRecordsForSite` generic function has been exported and moved to the `internal/network` package.

### internal/nonkube/bundle

Bundle generation now needs to update the TCP port allocated dynamically by the `router_free_port.py` tool
into the runtime resource `RouterAccess-skupper-local.yaml`, as this resource is used by the System Controller
to identify the listener port used to communicate with the Router.

Another change needed is that, now, the generated `Site ID` must be updated into the Site.metadata.uid under
`runtime/resources`.

### internal/nonkube/client/fs

* The ConfigMap kind is now supported
* BaseCustomResourceHandler and PathProvider now work inside a container



Patch:
diff --git a/.gitignore b/.gitignore
index 661f8d7c..e3adaedd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@
 /bootstrap
 /controller
 /network-observer
+/system-controller
 
 # Test binary, built with `go test -c`
 *.test
diff --git a/Dockerfile.system-controller b/Dockerfile.system-controller
new file mode 100644
index 00000000..0b716447
--- /dev/null
+++ b/Dockerfile.system-controller
@@ -0,0 +1,27 @@
+FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
+
+ARG TARGETARCH
+
+WORKDIR /go/src/app
+COPY go.mod .
+COPY go.sum .
+RUN go mod download
+COPY . .
+
+ENV CGO_ENABLED=0
+RUN make system-controller GOARCH=$TARGETARCH
+
+FROM --platform=$TARGETPLATFORM registry.access.redhat.com/ubi9-minimal
+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 --from=builder /go/src/app/system-controller .
+ENTRYPOINT ["/app/system-controller"]
diff --git a/Makefile b/Makefile
index 5b3d26bb..f6114801 100644
--- a/Makefile
+++ b/Makefile
@@ -11,7 +11,7 @@ REGISTRY := quay.io/skupper
 IMAGE_TAG := v2-dev
 ROUTER_IMAGE_TAG := main
 PLATFORMS ?= linux/amd64,linux/arm64
-CONTAINERFILES := Dockerfile.cli Dockerfile.kube-adaptor Dockerfile.controller Dockerfile.network-observer
+CONTAINERFILES := Dockerfile.cli Dockerfile.kube-adaptor Dockerfile.controller Dockerfile.network-observer Dockerfile.system-controller
 SHARED_IMAGE_LABELS = \
     --label "org.opencontainers.image.created=$(shell TZ=GMT date --iso-8601=seconds)" \
 	--label "org.opencontainers.image.url=https://skupper.io/" \
@@ -26,7 +26,7 @@ DOCKER := docker
 SKOPEO := skopeo
 PODMAN := podman
 
-all: skupper controller kube-adaptor network-observer
+all: skupper controller kube-adaptor network-observer system-controller
 
 basepkg = github.com/skupperproject/skupper
 # This lists non-test Go files inside each directory corresponding
@@ -57,6 +57,10 @@ build-doc-generator: generate-doc
 generate-doc: $(call pkgdeps,./internal/cmd/generate-doc)
 	GOOS=${GOOS} GOARCH=${GOARCH} go build -ldflags="${LDFLAGS}"  -o $@ ./internal/cmd/generate-doc
 
+build-system-controller: system-controller
+system-controller: $(call pkgdeps,./cmd/system-controller)
+	GOOS=${GOOS} GOARCH=${GOARCH} go build -ldflags="${LDFLAGS}"  -o $@ ./cmd/system-controller
+
 ## native/default container image builds
 docker-build: $(patsubst Dockerfile.%,docker-build-%,$(CONTAINERFILES))
 docker-build-%: Dockerfile.%
diff --git a/cmd/system-controller/main.go b/cmd/system-controller/main.go
new file mode 100644
index 00000000..35569a1a
--- /dev/null
+++ b/cmd/system-controller/main.go
@@ -0,0 +1,78 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"os/signal"
+	"sync"
+	"syscall"
+	"time"
+
+	"github.com/skupperproject/skupper/internal/nonkube/controller"
+	"github.com/skupperproject/skupper/internal/version"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+)
+
+func main() {
+	parseFlags()
+	log.Printf("Version: %s", version.Version)
+	namespacesPath := api.GetDefaultOutputNamespacesPath()
+	log.Printf("Skupper System Controller watching %s", namespacesPath)
+	if api.IsRunningInContainer() {
+		log.Printf("Host path %s", api.GetHostNamespacesPath())
+	}
+	if err := os.MkdirAll(namespacesPath, 0755); err != nil {
+		log.Fatalf("Error creating skupper namespaces directory %q: %v", namespacesPath, err)
+	}
+
+	c, err := controller.NewController()
+	if err != nil {
+		log.Fatalf("Error creating controller: %v", err)
+	}
+	stop, wait := c.Start()
+
+	handleShutdown(stop, wait)
+}
+
+func handleShutdown(stop chan struct{}, wait *sync.WaitGroup) {
+	sigs := make(chan os.Signal, 2)
+	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
+
+	<-sigs
+	log.Println("Shutting down system controller")
+
+	close(stop)
+
+	graceful := make(chan struct{})
+	go func() {
+		wait.Wait()
+		close(graceful)
+	}()
+
+	gracefulTimeout := time.NewTicker(10 * time.Second)
+	for {
+		select {
+		case <-sigs:
+			log.Println("Second interrupt, forcing system controller shutdown")
+			os.Exit(1)
+		case <-gracefulTimeout.C:
+			log.Println("Graceful shutdown timed out, exiting now")
+			os.Exit(1)
+		case <-graceful:
+			log.Println("System controller shutdown completed")
+			os.Exit(0)
+		}
+	}
+}
+
+func parseFlags() {
+	flags := flag.NewFlagSet("", flag.ExitOnError)
+	isVersion := flags.Bool("version", false, "Report the version of the Skupper System Controller")
+	flags.Parse(os.Args[1:])
+	if *isVersion {
+		fmt.Println(version.Version)
+		os.Exit(0)
+	}
+}
diff --git a/cmd/system-controller/system-controller.sh b/cmd/system-controller/system-controller.sh
new file mode 100755
index 00000000..9b6ec38a
--- /dev/null
+++ b/cmd/system-controller/system-controller.sh
@@ -0,0 +1,95 @@
+#!/bin/sh
+
+set -Ceufxo
+
+IMAGE="${SKUPPER_SYSTEM_CONTROLLER_IMAGE:-quay.io/skupper/system-controller:v2-dev}"
+SKUPPER_OUTPUT_PATH="${XDG_DATA_HOME:-${HOME}/.local/share}/skupper"
+if [ -z "${UID:-}" ]; then
+    UID="$(id -u)"
+    export UID
+fi
+
+exit_error() {
+    echo "$*"
+    exit 1
+}
+
+is_sock_endpoint() {
+    case "${CONTAINER_ENDPOINT}" in
+        "/"*)
+            return 0
+            ;;
+        "unix://"*)
+            return 0
+            ;;
+    esac
+    return 1
+}
+
+container_env() {
+    export CONTAINER_ENGINE="${CONTAINER_ENGINE:-podman}"
+    # Determining if the respective binaries are available
+    case "${CONTAINER_ENGINE}" in
+        docker)
+            command -v docker > /dev/null || exit_error "docker not found"
+            export CONTAINER_ENGINE=docker
+            ;;
+        *)
+            command -v podman > /dev/null || exit_error "podman not found"
+            export CONTAINER_ENGINE=podman
+            ;;
+    esac
+    export CONTAINER_ENDPOINT_DEFAULT="unix://${XDG_RUNTIME_DIR:-/run/user/${UID}}/podman/podman.sock"
+    GID=$(id -g "${UID}")
+    export RUNAS="${UID}:${GID}"
+    export USERNS="keep-id"
+    if [ "${CONTAINER_ENGINE}" = "docker" ]; then
+        export CONTAINER_ENDPOINT_DEFAULT="unix:///run/docker.sock"
+        export USERNS="host"
+        DOCKER_GID=$(getent group docker | cut -d: -f3)
+        export RUNAS="${UID}:${DOCKER_GID}"
+    fi
+    if [ "${UID}" -eq 0 ]; then
+        if [ "${CONTAINER_ENGINE}" = "podman" ]; then
+            export CONTAINER_ENDPOINT_DEFAULT="unix:///run/podman/podman.sock"
+        fi
+        export USERNS=""
+        export SKUPPER_OUTPUT_PATH="/var/lib/skupper"
+    fi
+    mkdir -p "${SKUPPER_OUTPUT_PATH}/namespaces"
+    export CONTAINER_ENDPOINT="${CONTAINER_ENDPOINT:-${CONTAINER_ENDPOINT_DEFAULT}}"
+}
+
+main() {
+    # Parse Container Engine settings
+    container_env
+
+    # Must be mounted into the container
+    MOUNTS=""
+    ENV_VARS=""
+    
+    # Mounts
+    if is_sock_endpoint; then
+        file_container_endpoint=$(echo "${CONTAINER_ENDPOINT}" | sed -e "s#unix://##g")
+        MOUNTS="${MOUNTS} -v '${file_container_endpoint}:/${CONTAINER_ENGINE}.sock:z'"
+    fi
+    MOUNTS="${MOUNTS} -v '${SKUPPER_OUTPUT_PATH}:/output:z'"
+
+    # Env vars
+    if is_sock_endpoint; then
+        ENV_VARS="${ENV_VARS} -e 'CONTAINER_ENDPOINT=/${CONTAINER_ENGINE}.sock'"
+    else
+        ENV_VARS="${ENV_VARS} -e 'CONTAINER_ENDPOINT=${CONTAINER_ENDPOINT}'"
+    fi
+    ENV_VARS="${ENV_VARS} -e 'SKUPPER_OUTPUT_PATH=${SKUPPER_OUTPUT_PATH}'"
+
+    # Running the system-controller
+    ${CONTAINER_ENGINE} pull "${IMAGE}"
+    eval "${CONTAINER_ENGINE}" run --rm --name "${USER}-skupper-controller" \
+        --network host --security-opt label=disable -u \""${RUNAS}"\" --userns=\""${USERNS}"\" \
+        "${MOUNTS}" \
+        "${ENV_VARS}" \
+        "${IMAGE}" 2>&1
+}
+
+main "$@"
diff --git a/internal/cmd/skupper/common/constants.go b/internal/cmd/skupper/common/constants.go
index fe0357f2..5a04ce73 100644
--- a/internal/cmd/skupper/common/constants.go
+++ b/internal/cmd/skupper/common/constants.go
@@ -18,6 +18,7 @@ const (
 	Links          string = "Link"
 	AccessTokens   string = "AccessToken"
 	Secrets        string = "Secret"
+	ConfigMaps     string = "ConfigMap"
 )
 
 const (
diff --git a/internal/cmd/skupper/link/nonkube/link_generate.go b/internal/cmd/skupper/link/nonkube/link_generate.go
index 6c14d97f..13dd4232 100644
--- a/internal/cmd/skupper/link/nonkube/link_generate.go
+++ b/internal/cmd/skupper/link/nonkube/link_generate.go
@@ -6,12 +6,13 @@ package nonkube
 import (
 	"errors"
 	"fmt"
+	"os"
+
 	"github.com/skupperproject/skupper/internal/cmd/skupper/common"
 	"github.com/skupperproject/skupper/internal/nonkube/client/fs"
 	nonkubecommon "github.com/skupperproject/skupper/internal/nonkube/common"
 	"github.com/skupperproject/skupper/pkg/nonkube/api"
 	"github.com/spf13/cobra"
-	"os"
 )
 
 type CmdLinkGenerate struct {
@@ -73,7 +74,7 @@ func (cmd *CmdLinkGenerate) InputToOptions() {
 }
 func (cmd *CmdLinkGenerate) Run() error {
 
-	hostTokenPath := api.GetHostSiteInternalPath(cmd.siteState.Site, api.RuntimeTokenPath)
+	hostTokenPath := api.GetInternalOutputPath(cmd.siteState.Site.Namespace, api.RuntimeTokenPath)
 
 	opts := fs.GetOptions{
 		LogWarning: false,
diff --git a/internal/filesystem/file_watcher.go b/internal/filesystem/file_watcher.go
new file mode 100644
index 00000000..dd955b5e
--- /dev/null
+++ b/internal/filesystem/file_watcher.go
@@ -0,0 +1,280 @@
+package filesystem
+
+import (
+	"log/slog"
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/fsnotify/fsnotify"
+)
+
+// FSChangeHandler provides a callback mechanism used by the FileWatcher
+// to notify about changes to monitored directory or file.
+type FSChangeHandler interface {
+	OnBasePathAdded(basePath string)
+	OnCreate(string)
+	OnUpdate(string)
+	OnRemove(string)
+	Filter(string) bool
+}
+
+type eventTrigger struct {
+	operation func(string)
+	name      string
+}
+
+// FileWatcher uses fsnotify to watch file system changes done to
+// files or directories, notifying the respective handlers. It is
+// recommended to watch directories over files (you can add filters
+// to limit the scope of files to be observed by your handler).
+type FileWatcher struct {
+	runningLock sync.Mutex
+	handlerLock sync.RWMutex
+	watcherLock sync.Mutex
+	logger      *slog.Logger
+	started     bool
+	watcher     *fsnotify.Watcher
+	refresh     chan bool
+	triggerCh   chan eventTrigger
+	handlerMap  map[string][]FSChangeHandler
+}
+
+func NewWatcher(attrs ...slog.Attr) (*FileWatcher, error) {
+	watcher, err := fsnotify.NewWatcher()
+	if err != nil {
+		return nil, err
+	}
+	logger := slog.Default().With(slog.String("component", "pkg.fs.FileWatcher"))
+	for _, attr := range attrs {
+		logger = logger.With(slog.Any(attr.Key, attr.Value))
+	}
+	return &FileWatcher{
+		watcher:    watcher,
+		logger:     logger,
+		refresh:    make(chan bool),
+		triggerCh:  make(chan eventTrigger),
+		handlerMap: map[string][]FSChangeHandler{},
+	}, nil
+}
+
+func (w *FileWatcher) filterHandlers(name string) []FSChangeHandler {
+	w.handlerLock.RLock()
+	defer w.handlerLock.RUnlock()
+	var filteredHandlers []FSChangeHandler
+
+	for baseName, handlers := range w.handlerMap {
+		if !strings.HasPrefix(name, baseName) {
+			continue
+		}
+		for _, handler := range handlers {
+			if handler.Filter(name) {
+				filteredHandlers = append(filteredHandlers, handler)
+			}
+		}
+	}
+	return filteredHandlers
+}
+
+func (w *FileWatcher) Start(stopCh <-chan struct{}) {
+	w.runningLock.Lock()
+	defer w.runningLock.Unlock()
+	if w.started {
+		return
+	}
+	w.started = true
+	go w.monitorPaths(stopCh)
+	go w.processEvents(stopCh)
+	go w.dispatchTriggers(stopCh)
+}
+
+func (w *FileWatcher) processEvents(stopCh <-chan struct{}) {
+	for {
+		select {
+		case event := <-w.watcher.Events:
+			handlers := w.filterHandlers(event.Name)
+			switch {
+			case event.Has(fsnotify.Create):
+				for _, handler := range handlers {
+					//go handler.OnCreate(event.Name)
+					w.logger.Info("OnCreate", slog.String("name", event.Name))
+					w.triggerCh <- eventTrigger{
+						operation: handler.OnCreate,
+						name:      event.Name,
+					}
+				}
+			case event.Has(fsnotify.Write):
+				for _, handler := range handlers {
+					w.logger.Info("OnUpdate", slog.String("name", event.Name))
+					//go handler.OnUpdate(event.Name)
+					w.triggerCh <- eventTrigger{
+						operation: handler.OnUpdate,
+						name:      event.Name,
+					}
+				}
+			case event.Has(fsnotify.Remove):
+				for _, handler := range handlers {
+					w.logger.Info("OnRemove", slog.String("name", event.Name))
+					//go handler.OnRemove(event.Name)
+					w.triggerCh <- eventTrigger{
+						operation: handler.OnRemove,
+						name:      event.Name,
+					}
+				}
+				// if object being watched is removed, watch for it to show up again
+				w.handlerLock.RLock()
+				if _, ok := w.handlerMap[event.Name]; ok {
+					w.refresh <- true
+				}
+				w.handlerLock.RUnlock()
+			}
+		case <-stopCh:
+			_ = w.watcher.Close()
+			w.started = false
+			return
+		}
+	}
+}
+
+func (w *FileWatcher) dispatchTriggers(stopCh <-chan struct{}) {
+	triggerTimeout := time.Millisecond * 100
+	var timeoutTicker *time.Ticker
+
+	for {
+		select {
+		case event := <-w.triggerCh:
+			done := make(chan bool)
+			go func() {
+				event.operation(event.name)
+				close(done)
+			}()
+			timeoutTicker = time.NewTicker(triggerTimeout)
+			select {
+			case <-done:
+				timeoutTicker.Stop()
+				continue
+			case <-timeoutTicker.C:
+				w.logger.Warn("event trigger timed out",
+					slog.String("name", event.name),
+					slog.Any("handler", event.operation))
+			}
+		case <-stopCh:
+			return
+		}
+	}
+}
+
+// monitorPaths monitors paths added to the handlers map, adding watchers
+// when those paths exist (fsNotify does not accept non-existing paths)
+// and removing them from fsNotify, if they no longer exist.
+func (w *FileWatcher) monitorPaths(stopCh <-chan struct{}) {
+	w.logger.Info("Start monitoring paths")
+	interval := time.Second
+	ticker := time.NewTicker(interval)
+	w.handlerLock.RLock()
+	if len(w.handlerMap) > 0 {
+		w.manageWatchers()
+	}
+	w.handlerLock.RUnlock()
+	for {
+		select {
+		case <-w.refresh:
+			w.manageWatchers()
+		case <-ticker.C:
+			w.manageWatchers()
+		case <-stopCh:
+			w.logger.Info("Stop monitoring paths")
+			return
+		}
+	}
+}
+
+func (w *FileWatcher) manageWatchers() {
+	w.watcherLock.Lock()
+	defer w.watcherLock.Unlock()
+	w.handlerLock.RLock()
+	defer w.handlerLock.RUnlock()
+	w.logger.Debug("entering manageWatchers()")
+	for path, handlers := range w.handlerMap {
+		stat, err := os.Stat(path)
+		if err != nil {
+			if !os.IsNotExist(err) {
+				w.logger.Error("error verifying monitored path",
+					slog.String("path", path),
+					slog.String("error", err.Error()))
+				continue
+			}
+			if slices.Contains(w.watcher.WatchList(), path) {
+				if err := w.watcher.Remove(path); err != nil {
+					w.logger.Error("error removing monitored path",
+						slog.String("path", path),
+						slog.String("error", err.Error()))
+				}
+				w.logger.Debug("Monitored path removed",
+					slog.String("path", path))
+			}
+			continue
+		}
+		if slices.Contains(w.watcher.WatchList(), path) {
+			continue
+		}
+		w.logger.Debug("Monitored path added",
+			slog.String("path", path))
+		if err = w.watcher.Add(path); err != nil {
+			w.logger.Error("error adding monitored path",
+				slog.String("path", path),
+				slog.String("error", err.Error()))
+			continue
+		}
+		var existingFilesAndDirectories []string
+		if stat.IsDir() {
+			pathEntries, err := os.ReadDir(path)
+			if err != nil {
+				w.logger.Error("error reading monitored path",
+					slog.String("path", path),
+					slog.String("error", err.Error()))
+			}
+			for _, entry := range pathEntries {
+				entryName := filepath.Join(path, entry.Name())
+				existingFilesAndDirectories = append(existingFilesAndDirectories, entryName)
+			}
+		} else {
+			existingFilesAndDirectories = append(existingFilesAndDirectories, path)
+		}
+		for _, handler := range handlers {
+			w.triggerCh <- eventTrigger{
+				operation: handler.OnBasePathAdded,
+				name:      path,
+			}
+			for _, existingPath := range existingFilesAndDirectories {
+				if handler.Filter(existingPath) {
+					w.triggerCh <- eventTrigger{
+						operation: handler.OnCreate,
+						name:      existingPath,
+					}
+				}
+			}
+		}
+	}
+}
+
+func (w *FileWatcher) Add(name string, handler FSChangeHandler) {
+	w.runningLock.Lock()
+	defer w.runningLock.Unlock()
+	w.handlerLock.Lock()
+	defer w.handlerLock.Unlock()
+	handlers, ok := w.handlerMap[name]
+	if !ok {
+		w.handlerMap[name] = []FSChangeHandler{handler}
+	}
+	w.logger.Info("Adding new handler",
+		slog.String("path", name))
+
+	w.handlerMap[name] = append(handlers, handler)
+	if w.started {
+		w.refresh <- true
+	}
+}
diff --git a/internal/kube/flow/indexing.go b/internal/flow/indexing.go
similarity index 100%
rename from internal/kube/flow/indexing.go
rename to internal/flow/indexing.go
diff --git a/internal/kube/flow/status.go b/internal/flow/status.go
similarity index 96%
rename from internal/kube/flow/status.go
rename to internal/flow/status.go
index 6a26991a..dbedfb0b 100644
--- a/internal/kube/flow/status.go
+++ b/internal/flow/status.go
@@ -18,8 +18,7 @@ import (
 	"github.com/skupperproject/skupper/pkg/vanflow/session"
 	"github.com/skupperproject/skupper/pkg/vanflow/store"
 	"golang.org/x/time/rate"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/client-go/kubernetes/typed/core/v1"
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/client-go/util/retry"
 )
 
@@ -39,7 +38,7 @@ type StatusSync struct {
 
 	session       session.Container
 	discovery     *eventsource.Discovery
-	client        v1.ConfigMapInterface
+	client        StatusSyncClient
 	configMapName string
 
 	logger *slog.Logger
@@ -54,12 +53,15 @@ type StatusSync struct {
 	burst      int
 }
 
-func NewStatusSync(factory session.ContainerFactory, localSources map[string][]store.Interface, client v1.ConfigMapInterface, configMap string) *StatusSync {
+type StatusSyncClient interface {
+	Logger() *slog.Logger
+	Get(ctx context.Context) (*corev1.ConfigMap, error)
+	Update(ctx context.Context, latest *corev1.ConfigMap) error
+}
 
-	// TODO ignore local sources and use their stores
-	logger := slog.New(slog.Default().Handler()).With(
-		slog.String("component", "kube.flow.statusSync"),
-	)
+func NewStatusSync(factory session.ContainerFactory, localSources map[string][]store.Interface, client StatusSyncClient, configMap string) *StatusSync {
+
+	logger := client.Logger()
 	sessionCtr := factory.Create()
 	sessionCtr.OnSessionError(func(err error) {
 		logger.Error("session error on discovery container", slog.Any("error", err))
@@ -316,12 +318,15 @@ func (s *StatusSync) publish(info network.NetworkStatusInfo) error {
 	networkStatus := string(bs)
 	data := map[string]string{"NetworkStatus": networkStatus}
 	err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
-		current, err := s.client.Get(ctx, s.configMapName, metav1.GetOptions{})
+		current, err := s.client.Get(ctx)
 		if err != nil {
 			return err
 		}
 		current.Data = data
-		_, err = s.client.Update(ctx, current, metav1.UpdateOptions{})
+		err = s.client.Update(ctx, current)
+		if err != nil {
+			s.logger.Error("updating network status", slog.Any("error", err))
+		}
 		return err
 	})
 	if err != nil {
diff --git a/internal/images/image_utils.go b/internal/images/image_utils.go
index ecf9aad2..2fcc6196 100644
--- a/internal/images/image_utils.go
+++ b/internal/images/image_utils.go
@@ -2,14 +2,15 @@ package images
 
 import (
 	"bytes"
-	"github.com/skupperproject/skupper/api/types"
-	corev1 "k8s.io/api/core/v1"
-	"k8s.io/utils/strings/slices"
 	"log"
 	"os"
 	"os/exec"
 	"strings"
 	"sync"
+
+	"github.com/skupperproject/skupper/api/types"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/utils/strings/slices"
 )
 
 type SkupperImage struct {
@@ -23,6 +24,7 @@ const (
 	KubeAdaptorImageEnvKey        string = "SKUPPER_KUBE_ADAPTOR_IMAGE"
 	NetworkObserverImageEnvKey    string = "SKUPPER_NETWORK_OBSERVER_IMAGE"
 	CliImageEnvKey                string = "SKUPPER_CLI_IMAGE"
+	SystemControllerImageEnvKey   string = "SKUPPER_SYSTEM_CONTROLLER_IMAGE"
 	PrometheusServerImageEnvKey   string = "PROMETHEUS_SERVER_IMAGE"
 	OauthProxyImageEnvKey         string = "OAUTH_PROXY_IMAGE"
 	RouterPullPolicyEnvKey        string = "SKUPPER_ROUTER_IMAGE_PULL_POLICY"
@@ -137,6 +139,16 @@ func GetPrometheusServerImageName() string {
 	}
 }
 
+func GetSystemControllerImageName() string {
+	image := os.Getenv(SystemControllerImageEnvKey)
+	if image == "" {
+		imageRegistry := GetImageRegistry()
+		return strings.Join([]string{imageRegistry, SystemControllerImageName}, "/")
+	} else {
+		return image
+	}
+}
+
 func GetImageRegistry() string {
 	imageRegistry := os.Getenv(SkupperImageRegistryEnvKey)
 	if imageRegistry == "" {
@@ -160,6 +172,7 @@ func CreateMapImageDigest(runningPods map[string]string) map[string]string {
 		"kube-adaptor":       GetKubeAdaptorImageName(),
 		"network-observer":   GetNetworkObserverImageName(),
 		"cli":                GetCliImageName(),
+		"system-controller":  GetSystemControllerImageName(),
 		"prometheus":         GetPrometheusServerImageName(),
 		"origin-oauth-proxy": GetOauthProxyImageName(),
 	}
@@ -334,6 +347,9 @@ func GetImages(component string, digestMap map[string]string) []SkupperImage {
 	case "cli":
 		names[CliImageEnvKey] = CliImageName
 		registry = GetImageRegistry()
+	case "system-controller":
+		names[SystemControllerImageEnvKey] = SystemControllerImageName
+		registry = GetImageRegistry()
 	case "prometheus":
 		names[PrometheusServerImageEnvKey] = PrometheusServerImageName
 		registry = GetPrometheusImageRegistry()
@@ -382,10 +398,15 @@ func GetImageVersion(component string) string {
 			image = NetworkObserverImageName
 		}
 	case "cli":
-		image = os.Getenv(CliImageName)
+		image = os.Getenv(CliImageEnvKey)
 		if image == "" {
 			image = ControllerImageName
 		}
+	case "system-controller":
+		image = os.Getenv(SystemControllerImageEnvKey)
+		if image == "" {
+			image = SystemControllerImageName
+		}
 	case "prometheus":
 		image = os.Getenv(PrometheusServerImageEnvKey)
 		if image == "" {
diff --git a/internal/images/images.go b/internal/images/images.go
index aefaa7ec..85278d3c 100644
--- a/internal/images/images.go
+++ b/internal/images/images.go
@@ -1,12 +1,13 @@
 package images
 
 const (
-	DefaultImageRegistry     string = "quay.io/skupper"
-	RouterImageName          string = "skupper-router:main"
-	ControllerImageName      string = "controller:v2-dev"
-	KubeAdaptorImageName     string = "kube-adaptor:v2-dev"
-	NetworkObserverImageName string = "network-observer:v2-dev"
-	CliImageName             string = "cli:v2-dev"
+	DefaultImageRegistry      string = "quay.io/skupper"
+	RouterImageName           string = "skupper-router:main"
+	ControllerImageName       string = "controller:v2-dev"
+	KubeAdaptorImageName      string = "kube-adaptor:v2-dev"
+	NetworkObserverImageName  string = "network-observer:v2-dev"
+	CliImageName              string = "cli:v2-dev"
+	SystemControllerImageName string = "system-controller:v2-dev"
 
 	PrometheusImageRegistry   string = "quay.io/prometheus"
 	PrometheusServerImageName string = "prometheus:v2.42.0"
@@ -16,7 +17,7 @@ const (
 
 var (
 	KubeComponents       = []string{"router", "controller", "network-observer", "cli", "prometheus", "origin-oauth-proxy"}
-	NonKubeComponents    = []string{"router", "network-observer", "cli", "prometheus", "origin-oauth-proxy"}
+	NonKubeComponents    = []string{"router", "network-observer", "cli", "system-controller", "prometheus", "origin-oauth-proxy"}
 	DefaultComponents    = []string{"router", "prometheus", "origin-oauth-proxy"}
 	DevelopmentImageTags = []string{"main", "v2-dev"}
 )
diff --git a/internal/kube/adaptor/collector.go b/internal/kube/adaptor/collector.go
index 2c3a7b03..a89fceac 100644
--- a/internal/kube/adaptor/collector.go
+++ b/internal/kube/adaptor/collector.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/skupperproject/skupper/api/types"
 	"github.com/skupperproject/skupper/internal/config"
+	"github.com/skupperproject/skupper/internal/flow"
 	internalclient "github.com/skupperproject/skupper/internal/kube/client"
 	kubeflow "github.com/skupperproject/skupper/internal/kube/flow"
 	"github.com/skupperproject/skupper/internal/version"
@@ -20,11 +21,32 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	corev1informer "k8s.io/client-go/informers/core/v1"
 	"k8s.io/client-go/kubernetes"
+	typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/tools/leaderelection"
 	"k8s.io/client-go/tools/leaderelection/resourcelock"
 )
 
+type StatusSyncClient struct {
+	client typedcorev1.ConfigMapInterface
+}
+
+func (s *StatusSyncClient) Logger() *slog.Logger {
+	logger := slog.New(slog.Default().Handler()).With(
+		slog.String("component", "kube.flow.statusSync"),
+	)
+	return logger
+}
+
+func (s *StatusSyncClient) Get(ctx context.Context) (*corev1.ConfigMap, error) {
+	return s.client.Get(ctx, types.NetworkStatusConfigMapName, metav1.GetOptions{})
+}
+
+func (s *StatusSyncClient) Update(ctx context.Context, latest *corev1.ConfigMap) error {
+	_, err := s.client.Update(ctx, latest, metav1.UpdateOptions{})
+	return err
+}
+
 func updateLockOwner(lockname, namespace string, owner *metav1.OwnerReference, cli *internalclient.KubeClient) error {
 	current, err := cli.Kube.CoordinationV1().Leases(namespace).Get(context.TODO(), lockname, metav1.GetOptions{})
 	if err != nil {
@@ -68,7 +90,10 @@ func siteCollector(ctx context.Context, cli *internalclient.KubeClient) {
 	}
 
 	factory := session.NewContainerFactory("amqp://localhost:5672", session.ContainerConfig{ContainerID: "kube-flow-collector"})
-	statusSync := kubeflow.NewStatusSync(factory, nil, cli.Kube.CoreV1().ConfigMaps(cli.Namespace), types.NetworkStatusConfigMapName)
+	statusSyncClient := &StatusSyncClient{
+		client: cli.Kube.CoreV1().ConfigMaps(cli.Namespace),
+	}
+	statusSync := flow.NewStatusSync(factory, nil, statusSyncClient, types.NetworkStatusConfigMapName)
 	go statusSync.Run(ctx)
 
 }
diff --git a/internal/kube/controller/controller.go b/internal/kube/controller/controller.go
index 956fd2ec..fc918031 100644
--- a/internal/kube/controller/controller.go
+++ b/internal/kube/controller/controller.go
@@ -8,7 +8,6 @@ import (
 	"log/slog"
 	"os"
 	"regexp"
-	"strings"
 
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
@@ -462,78 +461,7 @@ func (c *Controller) networkStatusUpdate(key string, cm *corev1.ConfigMap) error
 		return nil
 	}
 	c.log.Debug("Updating network status", slog.String("site", key))
-	return c.getSite(cm.ObjectMeta.Namespace).NetworkStatusUpdated(extractSiteRecords(status))
-}
-
-func extractSiteRecords(status network.NetworkStatusInfo) []skupperv2alpha1.SiteRecord {
-	var records []skupperv2alpha1.SiteRecord
-	routerAPs := map[string]string{} // router access point ID -> site ID
-	siteNames := map[string]string{} // site ID -> site name
-	for _, site := range status.SiteStatus {
-		siteNames[site.Site.Identity] = site.Site.Name
-		for _, router := range site.RouterStatus {
-			for _, ap := range router.AccessPoints {
-				routerAPs[ap.Identity] = site.Site.Identity
-			}
-		}
-	}
-	for _, site := range status.SiteStatus {
-		record := skupperv2alpha1.SiteRecord{
-			Id:        site.Site.Identity,
-			Name:      site.Site.Name,
-			Platform:  site.Site.Platform,
-			Namespace: site.Site.Namespace,
-			Version:   site.Site.Version,
-		}
-		services := map[string]*skupperv2alpha1.ServiceRecord{}
-		for _, router := range site.RouterStatus {
-			for _, link := range router.Links {
-				if link.Name == "" || link.Peer == "" {
-					continue
-				}
-
-				if site, ok := routerAPs[link.Peer]; ok {
-					record.Links = append(record.Links, skupperv2alpha1.LinkRecord{
-						Name:           link.Name,
-						RemoteSiteId:   site,
-						RemoteSiteName: siteNames[site],
-						Operational:    strings.EqualFold(link.Status, "up"),
-					})
-				}
-			}
-			for _, connector := range router.Connectors {
-				if connector.Address != "" && connector.DestHost != "" {
-					address := connector.Address
-					service, ok := services[address]
-					if !ok {
-						service = &skupperv2alpha1.ServiceRecord{
-							RoutingKey: address,
-						}
-						services[address] = service
-					}
-					service.Connectors = append(service.Connectors, connector.DestHost)
-				}
-			}
-			for _, listener := range router.Listeners {
-				if listener.Address != "" && listener.Name != "" {
-					address := listener.Address
-					service, ok := services[address]
-					if !ok {
-						service = &skupperv2alpha1.ServiceRecord{
-							RoutingKey: address,
-						}
-						services[address] = service
-					}
-					service.Listeners = append(service.Listeners, listener.Name)
-				}
-			}
-		}
-		for _, service := range services {
-			record.Services = append(record.Services, *service)
-		}
-		records = append(records, record)
-	}
-	return records
+	return c.getSite(cm.ObjectMeta.Namespace).NetworkStatusUpdated(network.ExtractSiteRecords(status))
 }
 
 func filter[V any](controller *Controller, handler func(string, V) error) func(string, V) error {
diff --git a/internal/kube/site/site.go b/internal/kube/site/site.go
index c3c255af..755ef263 100644
--- a/internal/kube/site/site.go
+++ b/internal/kube/site/site.go
@@ -8,6 +8,7 @@ import (
 	"reflect"
 	"strings"
 
+	internalnetwork "github.com/skupperproject/skupper/internal/network"
 	corev1 "k8s.io/api/core/v1"
 	rbacv1 "k8s.io/api/rbac/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
@@ -1066,15 +1067,6 @@ func (s *Site) updateLinkOperationalCondition(link *skupperv2alpha1.Link, operat
 	return nil
 }
 
-func getLinkRecordsForSite(siteId string, network []skupperv2alpha1.SiteRecord) []skupperv2alpha1.LinkRecord {
-	for _, siteRecord := range network {
-		if siteRecord.Id == siteId {
-			return siteRecord.Links
-		}
-	}
-	return nil
-}
-
 func (s *Site) NetworkStatusUpdated(network []skupperv2alpha1.SiteRecord) error {
 	if s.site == nil || reflect.DeepEqual(s.site.Status.Network, network) {
 		return nil
@@ -1088,7 +1080,7 @@ func (s *Site) NetworkStatusUpdated(network []skupperv2alpha1.SiteRecord) error
 	s.site = updated
 
 	// find the site record for this site, then process the link records it contains
-	linkRecords := getLinkRecordsForSite(s.site.GetSiteId(), network)
+	linkRecords := internalnetwork.GetLinkRecordsForSite(s.site.GetSiteId(), network)
 	for _, linkRecord := range linkRecords {
 		if link, ok := s.links[linkRecord.Name]; ok {
 			if err := s.updateLinkOperationalCondition(link.Definition(), linkRecord.Operational, linkRecord.RemoteSiteId, linkRecord.RemoteSiteName); err != nil {
diff --git a/internal/network/network.go b/internal/network/network.go
index a07cfd33..363dbfb2 100644
--- a/internal/network/network.go
+++ b/internal/network/network.go
@@ -4,6 +4,8 @@ import (
 	"encoding/json"
 	"fmt"
 	"strings"
+
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1"
 )
 
 const MINIMUM_VERSION string = "1.5.0"
@@ -178,3 +180,92 @@ func sliceContainsSite(sites []SiteStatusInfo, site SiteStatusInfo) bool {
 
 	return false
 }
+
+func ExtractSiteRecords(status NetworkStatusInfo) []v2alpha1.SiteRecord {
+	var records []v2alpha1.SiteRecord
+	routerAPs := map[string]string{} // router access point ID -> site ID
+	siteNames := map[string]string{} // site ID -> site name
+	for _, site := range status.SiteStatus {
+		siteNames[site.Site.Identity] = site.Site.Name
+		for _, router := range site.RouterStatus {
+			for _, ap := range router.AccessPoints {
+				routerAPs[ap.Identity] = site.Site.Identity
+			}
+		}
+	}
+	for _, site := range status.SiteStatus {
+		record := v2alpha1.SiteRecord{
+			Id:        site.Site.Identity,
+			Name:      site.Site.Name,
+			Platform:  site.Site.Platform,
+			Namespace: site.Site.Namespace,
+			Version:   site.Site.Version,
+		}
+		services := map[string]*v2alpha1.ServiceRecord{}
+		for _, router := range site.RouterStatus {
+			for _, link := range router.Links {
+				if link.Name == "" || link.Peer == "" {
+					continue
+				}
+
+				if site, ok := routerAPs[link.Peer]; ok {
+					record.Links = append(record.Links, v2alpha1.LinkRecord{
+						Name:           link.Name,
+						RemoteSiteId:   site,
+						RemoteSiteName: siteNames[site],
+						Operational:    strings.EqualFold(link.Status, "up"),
+					})
+				}
+			}
+			for _, connector := range router.Connectors {
+				if connector.Address != "" && connector.DestHost != "" {
+					address := connector.Address
+					service, ok := services[address]
+					if !ok {
+						service = &v2alpha1.ServiceRecord{
+							RoutingKey: address,
+						}
+						services[address] = service
+					}
+					service.Connectors = append(service.Connectors, connector.DestHost)
+				}
+			}
+			for _, listener := range router.Listeners {
+				if listener.Address != "" && listener.Name != "" {
+					address := listener.Address
+					service, ok := services[address]
+					if !ok {
+						service = &v2alpha1.ServiceRecord{
+							RoutingKey: address,
+						}
+						services[address] = service
+					}
+					service.Listeners = append(service.Listeners, listener.Name)
+				}
+			}
+		}
+		for _, service := range services {
+			record.Services = append(record.Services, *service)
+		}
+		records = append(records, record)
+	}
+	return records
+}
+
+func GetLinkRecordsForSite(siteId string, network []v2alpha1.SiteRecord) []v2alpha1.LinkRecord {
+	for _, siteRecord := range network {
+		if siteRecord.Id == siteId {
+			return siteRecord.Links
+		}
+	}
+	return nil
+}
+
+func HasMatchingPair(networkStatus NetworkStatusInfo, address string) bool {
+	for _, addressInfo := range networkStatus.Addresses {
+		if addressInfo.Name == address {
+			return addressInfo.ListenerCount > 0 && addressInfo.ConnectorCount > 0
+		}
+	}
+	return false
+}
diff --git a/internal/nonkube/bundle/install.sh.template b/internal/nonkube/bundle/install.sh.template
index 9a447103..dfa7f772 100644
--- a/internal/nonkube/bundle/install.sh.template
+++ b/internal/nonkube/bundle/install.sh.template
@@ -42,6 +42,7 @@ export PLATFORM_FILE="${NAMESPACES_PATH}/${NAMESPACE}/internal/platform.yaml"
 export USER="${USER:-$(id -un)}"
 SITE_ID="$(hostname -s)-${USER}-$(date +%s)"
 export SITE_ID
+export NORMAL_PORT=""
 
 exit_error() {
     echo "$*"
@@ -197,7 +198,10 @@ dump_tokens() {
 
 set_router_access_port() {
     config_file="${NAMESPACES_PATH}/${NAMESPACE}/runtime/router/skrouterd.json"
-    python "${NAMESPACES_PATH:?}/${NAMESPACE:?}/internal/scripts/router_free_port.py" "${config_file}"
+    NORMAL_PORT=$(python "${NAMESPACES_PATH:?}/${NAMESPACE:?}/internal/scripts/router_free_port.py" "${config_file}")
+    export NORMAL_PORT
+    router_access="${NAMESPACES_PATH}/${NAMESPACE}/runtime/resources/RouterAccess-skupper-local.yaml"
+    sed -i "s/port: .*/port: ${NORMAL_PORT}/g" "${router_access}"
 }
 
 create_sed_script() {
@@ -222,6 +226,7 @@ create_sed_script() {
         echo "s#{{"{{"}}.SiteConfigPath{{"}}"}}#${NAMESPACES_PATH}/${NAMESPACE}/runtime/router#g"
         echo "s#{{"{{"}}.RunAs{{"}}"}}#${RUNAS}#g"
         echo "s#{{"{{"}}.UserNamespace{{"}}"}}#${USERNS}#g"
+        echo "s#{{"{{"}}.SkupperLocalPort{{"}}"}}#${NORMAL_PORT}#g"
     } >> script.sed
 }
 
diff --git a/internal/nonkube/bundle/router_free_port.py b/internal/nonkube/bundle/router_free_port.py
index d4634ea3..ff2b53e8 100644
--- a/internal/nonkube/bundle/router_free_port.py
+++ b/internal/nonkube/bundle/router_free_port.py
@@ -33,7 +33,7 @@ def usage():
     """
     Shows usage and exit with return code 1
     """
-    print("Usage: %s skrouterd.json" %(sys.argv[0]))
+    print("Usage: {} skrouterd.json".format(sys.argv[0]), file=sys.stderr)
     sys.exit(1)
 
 
@@ -45,7 +45,7 @@ def main():
         usage()
     config_file = sys.argv[1]
     if not os.path.isfile(config_file):
-        print("Invalid router config file: %s" %(config_file))
+        print("Invalid router config file: {}".format(config_file), file=sys.stderr)
         usage()
 
     data = {}
@@ -56,10 +56,11 @@ def main():
         if entry[0] != 'listener':
             continue
         listener = entry[1]
-        if listener['role'] == 'normal':
+        if listener['name'] == "skupper-local-normal" and listener['role'] == 'normal':
             free_port = next_free_port()
             listener['port'] = free_port
-
+            print("{}".format(free_port))
+            break
     f.close()
 
     with open(config_file, "w") as f:
diff --git a/internal/nonkube/bundle/site_state_renderer.go b/internal/nonkube/bundle/site_state_renderer.go
index 19c6b2f9..674b56ad 100644
--- a/internal/nonkube/bundle/site_state_renderer.go
+++ b/internal/nonkube/bundle/site_state_renderer.go
@@ -41,6 +41,7 @@ func (s *SiteStateRenderer) Render(loadedSiteState *api.SiteState, reload bool)
 	s.loadedSiteState = loadedSiteState
 	// active (runtime) SiteState
 	s.siteState = common.CopySiteState(s.loadedSiteState)
+	s.siteState.SiteId = "{{.SiteId}}"
 	err = common.RedeemClaims(s.siteState)
 	if err != nil {
 		return fmt.Errorf("failed to redeem claims: %v", err)
diff --git a/internal/nonkube/client/fs/configmap_handler.go b/internal/nonkube/client/fs/configmap_handler.go
new file mode 100644
index 00000000..52f6c1d9
--- /dev/null
+++ b/internal/nonkube/client/fs/configmap_handler.go
@@ -0,0 +1,130 @@
+package fs
+
+import (
+	"os"
+
+	"github.com/skupperproject/skupper/internal/cmd/skupper/common"
+	corev1 "k8s.io/api/core/v1"
+)
+
+type ConfigMapHandler struct {
+	BaseCustomResourceHandler
+	pathProvider PathProvider
+}
+
+func NewConfigMapHandler(namespace string) *ConfigMapHandler {
+	return &ConfigMapHandler{
+		pathProvider: PathProvider{
+			Namespace: namespace,
+		},
+	}
+}
+
+func (s *ConfigMapHandler) getPath(runtime bool) string {
+	if runtime {
+		return s.pathProvider.GetRuntimeNamespace()
+	}
+	return s.pathProvider.GetNamespace()
+}
+
+func (s *ConfigMapHandler) Add(resource corev1.ConfigMap, runtime bool) error {
+
+	fileName := resource.Name + ".yaml"
+	content, err := s.EncodeToYaml(resource)
+	if err != nil {
+		return err
+	}
+
+	err = s.WriteFile(s.getPath(runtime), fileName, content, common.ConfigMaps)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (s *ConfigMapHandler) Update(resource corev1.ConfigMap, runtime bool) error {
+	cm, err := s.Get(resource.Name, GetOptions{
+		RuntimeFirst: runtime,
+	})
+	if err != nil {
+		return err
+	}
+	cm.Data = resource.Data
+	return s.Add(resource, runtime)
+}
+
+func (s *ConfigMapHandler) Get(name string, opts GetOptions) (*corev1.ConfigMap, error) {
+	var context corev1.ConfigMap
+	fileName := name + ".yaml"
+
+	if opts.RuntimeFirst == true {
+		// First read from runtime directory, where output is found after bootstrap
+		// has run.  If no runtime configmaps try and display configured configmaps
+		err, file := s.ReadFile(s.pathProvider.GetRuntimeNamespace(), fileName, common.ConfigMaps)
+		if err != nil {
+			if opts.LogWarning {
+				os.Stderr.WriteString("Site not initialized yet\n")
+			}
+			err, file = s.ReadFile(s.pathProvider.GetNamespace(), fileName, common.ConfigMaps)
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		if err = s.DecodeYaml(file, &context); err != nil {
+			return nil, err
+		}
+	} else {
+		// read from input directory to get lastest config
+		err, file := s.ReadFile(s.pathProvider.GetNamespace(), fileName, common.ConfigMaps)
+		if err != nil {
+			return nil, err
+		}
+		if err := s.DecodeYaml(file, &context); err != nil {
+			return nil, err
+		}
+	}
+
+	return &context, nil
+}
+
+func (s *ConfigMapHandler) Delete(name string, runtime bool) error {
+	fileName := name + ".yaml"
+
+	if err := s.DeleteFile(s.getPath(runtime), fileName, common.ConfigMaps); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (s *ConfigMapHandler) List() ([]*corev1.ConfigMap, error) {
+	var configmaps []*corev1.ConfigMap
+
+	// First read from runtime directory, where output is found after bootstrap
+	// has run.  If no runtime configmaps try and display configured configmaps
+	path := s.pathProvider.GetRuntimeNamespace()
+	err, files := s.ReadDir(path, common.ConfigMaps)
+	if err != nil {
+		os.Stderr.WriteString("Site not initialized yet\n")
+		path = s.pathProvider.GetNamespace()
+		err, files = s.ReadDir(path, common.ConfigMaps)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	for _, file := range files {
+		err, configmap := s.ReadFile(path, file.Name(), common.ConfigMaps)
+		if err != nil {
+			return nil, err
+		}
+		var context corev1.ConfigMap
+		if err = s.DecodeYaml(configmap, &context); err != nil {
+			return nil, err
+		}
+		configmaps = append(configmaps, &context)
+	}
+	return configmaps, nil
+}
diff --git a/internal/nonkube/client/fs/custom_resource_handler.go b/internal/nonkube/client/fs/custom_resource_handler.go
index e542bb9d..ab62eab7 100644
--- a/internal/nonkube/client/fs/custom_resource_handler.go
+++ b/internal/nonkube/client/fs/custom_resource_handler.go
@@ -8,6 +8,7 @@ import (
 	"strings"
 
 	"github.com/skupperproject/skupper/internal/cmd/skupper/common/utils"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
 
 	"sigs.k8s.io/yaml"
 )
@@ -71,7 +72,11 @@ func (b *BaseCustomResourceHandler) WriteFile(path string, name string, content
 
 	defer file.Sync()
 
-	fmt.Println("File written to", completeFilePath)
+	writtenPath := completeFilePath
+	if api.IsRunningInContainer() {
+		writtenPath = strings.Replace(completeFilePath, "/output", api.GetHostDataHome(), 1)
+	}
+	fmt.Println("File written to", writtenPath)
 
 	return nil
 }
diff --git a/internal/nonkube/client/fs/path_provider.go b/internal/nonkube/client/fs/path_provider.go
index a2fdff72..38b2a7e8 100644
--- a/internal/nonkube/client/fs/path_provider.go
+++ b/internal/nonkube/client/fs/path_provider.go
@@ -9,9 +9,9 @@ type PathProvider struct {
 }
 
 func (p *PathProvider) GetNamespace() string {
-	return api.GetHostNamespaceHome(p.Namespace) + "/" + string(api.InputSiteStatePath)
+	return api.GetDefaultOutputPath(p.Namespace) + "/" + string(api.InputSiteStatePath)
 }
 
 func (p *PathProvider) GetRuntimeNamespace() string {
-	return api.GetHostNamespaceHome(p.Namespace) + "/" + string(api.RuntimeSiteStatePath)
+	return api.GetDefaultOutputPath(p.Namespace) + "/" + string(api.RuntimeSiteStatePath)
 }
diff --git a/internal/nonkube/client/runtime/certs.go b/internal/nonkube/client/runtime/certs.go
new file mode 100644
index 00000000..584b148e
--- /dev/null
+++ b/internal/nonkube/client/runtime/certs.go
@@ -0,0 +1,104 @@
+package runtime
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"os"
+	"path"
+
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+)
+
+var (
+	NamespacesPath string
+)
+
+type TlsCert struct {
+	CaPath   string
+	CertPath string
+	KeyPath  string
+	Verify   bool
+	config   *tls.Config
+}
+
+func (t *TlsCert) GetTlsConfig() (*tls.Config, error) {
+	var err error
+	if t.config == nil {
+		err = t.LoadTlsConfig()
+	}
+	return t.config, err
+}
+
+func (t *TlsCert) LoadTlsConfig() error {
+	retriever := &TlsConfigRetriever{
+		Cert:   t.CertPath,
+		Key:    t.KeyPath,
+		Ca:     t.CaPath,
+		Verify: t.Verify,
+	}
+	var err error
+	t.config, err = retriever.GetTlsConfig()
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func GetRuntimeTlsCert(namespace, certificateName string) *TlsCert {
+	namespacesPath := NamespacesPath
+	if namespacesPath == "" {
+		namespacesPath = api.GetDefaultOutputNamespacesPath()
+	}
+	tlsCertPath := path.Join(namespacesPath, namespace, string(api.CertificatesPath), certificateName)
+	tlsCert := &TlsCert{
+		CaPath:   path.Join(tlsCertPath, "ca.crt"),
+		CertPath: path.Join(tlsCertPath, "tls.crt"),
+		KeyPath:  path.Join(tlsCertPath, "tls.key"),
+		Verify:   true,
+	}
+	return tlsCert
+}
+
+type TlsConfigRetriever struct {
+	Cert   string
+	Key    string
+	Ca     string
+	Verify bool
+}
+
+func (paths *TlsConfigRetriever) GetTlsConfig() (*tls.Config, error) {
+	tlsConfig, err := getTlsConfig(paths.Verify, paths.Cert, paths.Key, paths.Ca)
+	if err != nil {
+		return nil, err
+	}
+	return tlsConfig, nil
+}
+
+func getTlsConfig(verify bool, cert, key, ca string) (*tls.Config, error) {
+	var config tls.Config
+	config.InsecureSkipVerify = true
+	if verify {
+		certPool := x509.NewCertPool()
+		file, err := os.ReadFile(ca)
+		if err != nil {
+			return nil, err
+		}
+		certPool.AppendCertsFromPEM(file)
+		config.RootCAs = certPool
+		config.InsecureSkipVerify = false
+	}
+
+	_, errCert := os.Stat(cert)
+	_, errKey := os.Stat(key)
+	if errCert == nil || errKey == nil {
+		tlsCert, err := tls.LoadX509KeyPair(cert, key)
+		if err != nil {
+			return nil, fmt.Errorf("could not load x509 key pair - %w", err)
+		}
+		config.Certificates = []tls.Certificate{tlsCert}
+	}
+	config.MinVersion = tls.VersionTLS10
+
+	return &config, nil
+}
diff --git a/internal/nonkube/client/runtime/info.go b/internal/nonkube/client/runtime/info.go
new file mode 100644
index 00000000..6c8608c9
--- /dev/null
+++ b/internal/nonkube/client/runtime/info.go
@@ -0,0 +1,25 @@
+package runtime
+
+import (
+	"fmt"
+
+	"github.com/skupperproject/skupper/internal/nonkube/client/fs"
+)
+
+func GetLocalRouterAddress(namespace string) (string, error) {
+	port, err := GetLocalRouterPort(namespace)
+	if err != nil {
+		return "", err
+	}
+	address := fmt.Sprintf("amqps://127.0.0.1:%d", port)
+	return address, nil
+}
+
+func GetLocalRouterPort(namespace string) (int, error) {
+	client := fs.NewRouterAccessHandler(namespace)
+	ra, err := client.Get("skupper-local")
+	if err != nil {
+		return 0, fmt.Errorf("unable to determine router port: %w", err)
+	}
+	return ra.Spec.Roles[0].Port, nil
+}
diff --git a/internal/nonkube/common/site_state_loader.go b/internal/nonkube/common/site_state_loader.go
index c3774391..6073b456 100644
--- a/internal/nonkube/common/site_state_loader.go
+++ b/internal/nonkube/common/site_state_loader.go
@@ -52,6 +52,7 @@ func (f *FileSystemSiteStateLoader) Load() (*api.SiteState, error) {
 	if siteState.Site == nil || siteState.Site.Name == "" {
 		return nil, fmt.Errorf("no valid site definition has been found")
 	}
+	siteState.SiteId = string(siteState.Site.ObjectMeta.UID)
 	namespacesFound := GetNamespacesFound(siteState)
 	if len(namespacesFound) > 1 {
 		return nil, fmt.Errorf("multiple namespaces found, but only a unique namespace must be used across all "+
@@ -84,6 +85,7 @@ func GetNamespacesFound(s *api.SiteState) []string {
 	addNamespacesFromMap(s.Claims, nsMap)
 	addNamespacesFromMap(s.Certificates, nsMap)
 	addNamespacesFromMap(s.SecuredAccesses, nsMap)
+	addNamespacesFromMap(s.ConfigMaps, nsMap)
 	for ns := range nsMap {
 		namespaces = append(namespaces, ns)
 	}
@@ -161,6 +163,10 @@ func LoadIntoSiteState(reader *bufio.Reader, siteState *api.SiteState) error {
 				var secret corev1.Secret
 				runtime.DefaultUnstructuredConverter.FromUnstructured(obj.(runtime.Unstructured).UnstructuredContent(), &secret)
 				siteState.Secrets[secret.Name] = &secret
+			case "ConfigMap":
+				var configMap corev1.ConfigMap
+				runtime.DefaultUnstructuredConverter.FromUnstructured(obj.(runtime.Unstructured).UnstructuredContent(), &configMap)
+				siteState.ConfigMaps[configMap.Name] = &configMap
 			default:
 				logInvalidResource(gvk)
 			}
diff --git a/internal/nonkube/common/site_state_renderer_common.go b/internal/nonkube/common/site_state_renderer_common.go
index 40b834c6..237d0c8d 100644
--- a/internal/nonkube/common/site_state_renderer_common.go
+++ b/internal/nonkube/common/site_state_renderer_common.go
@@ -23,6 +23,7 @@ func CopySiteState(siteState *api.SiteState) *api.SiteState {
 	activeSiteState.SecuredAccesses = copySiteStateMap(siteState.SecuredAccesses)
 	activeSiteState.Certificates = copySiteStateMap(siteState.Certificates)
 	activeSiteState.Secrets = copySiteStateMap(siteState.Secrets)
+	activeSiteState.ConfigMaps = copySiteStateMap(siteState.ConfigMaps)
 	return activeSiteState
 }
 
diff --git a/internal/nonkube/controller/activation.go b/internal/nonkube/controller/activation.go
new file mode 100644
index 00000000..38b584b8
--- /dev/null
+++ b/internal/nonkube/controller/activation.go
@@ -0,0 +1,7 @@
+package controller
+
+type ActivationCallback interface {
+	Start(stopCh <-chan struct{})
+	Stop()
+	Id() string
+}
diff --git a/internal/nonkube/controller/collector_handler.go b/internal/nonkube/controller/collector_handler.go
new file mode 100644
index 00000000..8d59249a
--- /dev/null
+++ b/internal/nonkube/controller/collector_handler.go
@@ -0,0 +1,82 @@
+package controller
+
+import (
+	"context"
+	"log/slog"
+	"sync"
+
+	"github.com/skupperproject/skupper/internal/nonkube/flow"
+)
+
+func NewCollectorLifecycleHandler(namespace string) *CollectorLifecycleHandler {
+	c := &CollectorLifecycleHandler{
+		namespace: namespace,
+	}
+	c.logger = slog.New(slog.Default().Handler()).
+		With("component", "collector.lifecycle.handler").
+		With("namespace", namespace)
+	return c
+}
+
+type CollectorLifecycleHandler struct {
+	ctx         context.Context
+	cancel      context.CancelFunc
+	namespace   string
+	logger      *slog.Logger
+	running     bool
+	mux         sync.Mutex
+	startMethod func()
+}
+
+func (c *CollectorLifecycleHandler) Start(stopCh <-chan struct{}) {
+	c.mux.Lock()
+	defer c.mux.Unlock()
+	if c.running {
+		return
+	}
+	c.logger.Info("Starting collector lifecycle handler")
+	c.ctx, c.cancel = context.WithCancel(context.Background())
+	c.running = true
+	if c.startMethod == nil {
+		c.startMethod = c.startCollectorLite
+	}
+	go c.startMethod()
+	go c.handleShutdown(stopCh)
+}
+
+func (c *CollectorLifecycleHandler) handleShutdown(stopCh <-chan struct{}) {
+	const stopMsg = "Stopping collector lifecycle handler"
+	select {
+	case <-stopCh:
+		c.logger.Info(stopMsg)
+		c.Stop()
+	case <-c.ctx.Done():
+		c.logger.Info(stopMsg)
+	}
+}
+
+func (c *CollectorLifecycleHandler) Stop() {
+	c.mux.Lock()
+	defer c.mux.Unlock()
+	if !c.running {
+		return
+	}
+	c.running = false
+	if c.cancel != nil {
+		c.logger.Info("Stopping collector lite")
+		c.cancel()
+	}
+}
+
+func (c *CollectorLifecycleHandler) Id() string {
+	return "collector.handler"
+}
+
+func (c *CollectorLifecycleHandler) startCollectorLite() {
+	c.logger.Info("Starting collector lite")
+	err := flow.StartCollector(c.ctx, c.namespace)
+	if err != nil {
+		c.logger.Error("error starting collector", slog.Any("error", err.Error()))
+		return
+	}
+}
diff --git a/internal/nonkube/controller/controller.go b/internal/nonkube/controller/controller.go
new file mode 100644
index 00000000..ac1d2aea
--- /dev/null
+++ b/internal/nonkube/controller/controller.go
@@ -0,0 +1,55 @@
+package controller
+
+import (
+	"log"
+	"os"
+	"path"
+	"sync"
+	"syscall"
+
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+)
+
+const (
+	lockFileName = "controller.lock"
+)
+
+type Controller struct {
+	nsHandler *NamespacesHandler
+}
+
+func NewController() (*Controller, error) {
+	var err error
+	c := &Controller{}
+	c.nsHandler, err = NewNamespacesHandler()
+	return c, err
+}
+
+func (c *Controller) Start() (chan struct{}, *sync.WaitGroup) {
+	log.Println("Starting controller")
+	wg := &sync.WaitGroup{}
+	stop := make(chan struct{})
+	c.ensureSingleInstance(stop)
+	if err := c.nsHandler.Start(stop, wg); err != nil {
+		log.Fatalf("error starting controller: %v", err)
+	}
+	log.Println("Controller started")
+	return stop, wg
+}
+
+func (c *Controller) ensureSingleInstance(stop chan struct{}) {
+	internalLockFile := path.Join(api.GetDefaultOutputNamespacesPath(), lockFileName)
+	lock, err := os.OpenFile(internalLockFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
+	if err != nil {
+		log.Fatalf("Unable to create lock file: %v", err)
+	}
+	if err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+		log.Fatalf("System controller is already running, exiting")
+	}
+	go func() {
+		<-stop
+		if err = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); err != nil {
+			log.Fatalf("Error releasing lock file: %v", err)
+		}
+	}()
+}
diff --git a/internal/nonkube/controller/namespace_controller.go b/internal/nonkube/controller/namespace_controller.go
new file mode 100644
index 00000000..d8c483b0
--- /dev/null
+++ b/internal/nonkube/controller/namespace_controller.go
@@ -0,0 +1,59 @@
+package controller
+
+import (
+	"fmt"
+	"log/slog"
+
+	"github.com/skupperproject/skupper/internal/filesystem"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+)
+
+type NamespaceController struct {
+	ns      string
+	stopCh  chan struct{}
+	logger  *slog.Logger
+	watcher *filesystem.FileWatcher
+	prepare func()
+}
+
+func NewNamespaceController(namespace string) (*NamespaceController, error) {
+	nsw := &NamespaceController{
+		ns:     namespace,
+		stopCh: make(chan struct{}),
+	}
+	watcher, err := filesystem.NewWatcher(slog.String("namespace", namespace))
+	if err != nil {
+		return nil, fmt.Errorf("error creating watcher on namespace %s: %v", namespace, err)
+	}
+	nsw.watcher = watcher
+	nsw.logger = slog.New(slog.Default().Handler()).
+		With("component", "namespace.watcher").
+		With("namespace", namespace)
+	return nsw, nil
+}
+
+func (w *NamespaceController) Start() {
+	if w.prepare == nil {
+		routerConfigHandler := NewRouterConfigHandler(w.stopCh, w.ns)
+		routerStateHandler := NewRouterStateHandler(w.ns)
+		routerConfigHandler.AddCallback(routerStateHandler)
+		collectorLifecycleHandler := NewCollectorLifecycleHandler(w.ns)
+		routerStateHandler.SetCallback(collectorLifecycleHandler)
+		w.watcher.Add(api.GetInternalOutputPath(w.ns, api.RouterConfigPath), routerConfigHandler)
+		w.watcher.Add(api.GetInternalOutputPath(w.ns, api.RuntimeSiteStatePath), NewNetworkStatusHandler(w.ns))
+	} else {
+		w.prepare()
+	}
+	w.watcher.Start(w.stopCh)
+	go w.run()
+}
+
+func (w *NamespaceController) run() {
+	<-w.stopCh
+	w.logger.Info("stopped namespace watcher")
+	return
+}
+
+func (w *NamespaceController) Stop() {
+	close(w.stopCh)
+}
diff --git a/internal/nonkube/controller/namespaces.go b/internal/nonkube/controller/namespaces.go
new file mode 100644
index 00000000..edd06655
--- /dev/null
+++ b/internal/nonkube/controller/namespaces.go
@@ -0,0 +1,155 @@
+package controller
+
+import (
+	"fmt"
+	"log/slog"
+	"os"
+	"path"
+	"strings"
+	"sync"
+
+	"github.com/skupperproject/skupper/internal/filesystem"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+)
+
+type NamespacesHandler struct {
+	logger     *slog.Logger
+	basePath   string
+	watcher    *filesystem.FileWatcher
+	namespaces map[string]*NamespaceController
+	mutex      sync.Mutex
+}
+
+func (n *NamespacesHandler) OnBasePathAdded(basePath string) {
+}
+
+func NewNamespacesHandler() (*NamespacesHandler, error) {
+	var err error
+	basePath := api.GetDefaultOutputNamespacesPath()
+	basePath = strings.TrimRight(basePath, string(os.PathSeparator))
+	componentName := "namespaces.handler"
+	nsh := &NamespacesHandler{
+		basePath:   basePath,
+		namespaces: make(map[string]*NamespaceController),
+		logger: slog.Default().
+			With("component", componentName),
+	}
+	nsh.watcher, err = filesystem.NewWatcher(slog.String("owner", componentName))
+	if err != nil {
+		return nil, fmt.Errorf("failed to create namespaces watcher: %v", err)
+	}
+	return nsh, nil
+}
+
+func (n *NamespacesHandler) Start(stop chan struct{}, wg *sync.WaitGroup) error {
+	w := n.watcher
+	w.Add(n.basePath, n)
+	err := n.loadExistingNamespaces()
+	if err != nil {
+		return err
+	}
+	wg.Add(1)
+	w.Start(stop)
+	go n.wait(stop, wg)
+	return nil
+}
+
+func (n *NamespacesHandler) wait(stop chan struct{}, wg *sync.WaitGroup) {
+	<-stop
+	n.mutex.Lock()
+	defer n.mutex.Unlock()
+	n.logger.Info("Stopping namespaces watcher")
+	for _, nsh := range n.namespaces {
+		nsh.Stop()
+	}
+	wg.Done()
+}
+
+func (n *NamespacesHandler) loadExistingNamespaces() error {
+	entries, err := os.ReadDir(n.basePath)
+	if err != nil {
+		return fmt.Errorf("failed to read namespaces directory: %w", err)
+	}
+	for _, entry := range entries {
+		if entry.IsDir() {
+			n.OnCreate(path.Join(n.basePath, entry.Name()))
+		}
+	}
+	return nil
+}
+
+func (n *NamespacesHandler) namespace(name string) (string, bool) {
+	ns := name[len(n.basePath):]
+	ns = strings.Trim(ns, string(os.PathSeparator))
+	stat, err := os.Stat(name)
+	if err != nil {
+		return ns, false
+	}
+	return ns, stat.IsDir()
+}
+
+func (n *NamespacesHandler) OnCreate(name string) {
+	n.mutex.Lock()
+	defer n.mutex.Unlock()
+	if name == n.basePath {
+		n.logger.Info("Base path created, starting namespaces watcher")
+		if err := n.loadExistingNamespaces(); err != nil {
+			n.logger.Error("failed to create watchers for existing namespaces", slog.Any("error", err))
+		}
+		return
+	}
+	ns, isDir := n.namespace(name)
+	if !isDir {
+		slog.Debug("ignoring non-namespace file", slog.Any("name", name))
+		return
+	}
+	if _, ok := n.namespaces[ns]; !ok {
+		n.logger.Info("Starting namespace controller", slog.String("namespace", ns))
+		nsc, err := NewNamespaceController(ns)
+		if err != nil {
+			n.logger.Error("Unable to start namespace controller",
+				slog.String("namespace", ns),
+				slog.Any("error", err))
+		}
+		n.namespaces[ns] = nsc
+		nsc.Start()
+	}
+
+}
+
+func (n *NamespacesHandler) OnRemove(name string) {
+	n.mutex.Lock()
+	defer n.mutex.Unlock()
+	if n.basePath == name {
+		n.logger.Info("Base namespace path removed, stopping all namespace watchers", slog.String("path", n.basePath))
+		return
+	}
+	ns, _ := n.namespace(name)
+
+	if ns == "" {
+		return
+	}
+
+	if nsc, ok := n.namespaces[ns]; ok {
+		n.logger.Info("Namespace removed",
+			slog.String("ns", ns),
+			slog.String("name", name))
+
+		n.logger.Info("Stopping namespace controller", slog.Any("namespace", ns))
+		nsc.Stop()
+		delete(n.namespaces, ns)
+	}
+}
+
+func (n *NamespacesHandler) Filter(name string) bool {
+	stat, err := os.Stat(name)
+	if err == nil && stat.IsDir() {
+		return true
+	} else if os.IsNotExist(err) {
+		return true
+	}
+	return false
+}
+
+func (n *NamespacesHandler) OnUpdate(name string) {
+}
diff --git a/internal/nonkube/controller/network_status_handler.go b/internal/nonkube/controller/network_status_handler.go
new file mode 100644
index 00000000..16f71069
--- /dev/null
+++ b/internal/nonkube/controller/network_status_handler.go
@@ -0,0 +1,156 @@
+package controller
+
+import (
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os"
+	"strings"
+	"sync"
+
+	"github.com/skupperproject/skupper/internal/network"
+	"github.com/skupperproject/skupper/internal/nonkube/common"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/util/yaml"
+)
+
+type NetworkStatusHandler struct {
+	Namespace string
+	basePath  string
+	logger    *slog.Logger
+	doneCh    chan struct{}
+	events    chan network.NetworkStatusInfo
+	mutex     sync.RWMutex
+}
+
+func NewNetworkStatusHandler(namespace string) *NetworkStatusHandler {
+	logger := slog.Default().
+		With("component", "network.status.handler").
+		With("namespace", namespace)
+
+	return &NetworkStatusHandler{
+		Namespace: namespace,
+		logger:    logger,
+		events:    make(chan network.NetworkStatusInfo),
+	}
+}
+
+func (n *NetworkStatusHandler) OnUpdate(name string) {
+	n.startProcessingEvents()
+	n.processConfigMapUpdate(name)
+}
+
+func (n *NetworkStatusHandler) processConfigMapUpdate(name string) {
+	networkStatusInfo, err := n.loadNetworkStatusInfo(name)
+	if err != nil {
+		n.logger.Warn("ignoring network status update", slog.Any("error", err))
+		return
+	}
+	n.logger.Debug("Dispatching network status info event")
+	n.events <- *networkStatusInfo
+}
+
+func (n *NetworkStatusHandler) processEvents(done chan struct{}) {
+	n.resetStatus()
+	for {
+		select {
+		case networkStatusInfo := <-n.events:
+			n.logger.Debug("Processing network status event", slog.Any("event", networkStatusInfo))
+			n.updateRuntimeSiteState(networkStatusInfo)
+		case <-done:
+			n.resetStatus()
+			n.logger.Info("Stop event processing")
+			return
+		}
+	}
+}
+
+func (n *NetworkStatusHandler) updateRuntimeSiteState(networkStatusInfo network.NetworkStatusInfo) {
+	runtimeSiteStatePath := api.GetInternalOutputPath(n.Namespace, api.RuntimeSiteStatePath)
+	siteStateLoader := &common.FileSystemSiteStateLoader{
+		Path: runtimeSiteStatePath,
+	}
+	siteState, err := siteStateLoader.Load()
+	if err != nil {
+		n.logger.Warn("Error loading runtime site state", slog.Any("error", err))
+		return
+	}
+	delete(siteState.ConfigMaps, "skupper-network-status")
+	siteState.UpdateStatus(networkStatusInfo)
+	if err = api.MarshalSiteState(*siteState, runtimeSiteStatePath); err != nil {
+		n.logger.Error("Error marshaling runtime site state", slog.Any("error", err))
+		return
+	}
+	n.logger.Debug("Runtime site state updated")
+}
+
+func (n *NetworkStatusHandler) resetStatus() {
+	n.updateRuntimeSiteState(network.NetworkStatusInfo{})
+}
+
+func (n *NetworkStatusHandler) loadNetworkStatusInfo(name string) (*network.NetworkStatusInfo, error) {
+	cm, err := n.loadCm(name)
+	if err != nil {
+		return nil, err
+	}
+	if cm.Data == nil {
+		return nil, fmt.Errorf("skupper-network-status ConfigMap has no data")
+	}
+	networkStatus, ok := cm.Data["NetworkStatus"]
+	if !ok {
+		return nil, fmt.Errorf("skupper-network-status ConfigMap has no 'NetworkStatus' key")
+	}
+	nsi := &network.NetworkStatusInfo{}
+	err = json.Unmarshal([]byte(networkStatus), nsi)
+	if err != nil {
+		n.logger.Error("Failed to unmarshal network status info", slog.Any("networkStatus", networkStatus))
+		return nil, fmt.Errorf("error unmarshalling NetworkStatusInfo: %v", err)
+	}
+	return nsi, nil
+}
+
+func (n *NetworkStatusHandler) loadCm(name string) (*corev1.ConfigMap, error) {
+	data, err := os.ReadFile(name)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read file %s: %w", name, err)
+	}
+	cm := &corev1.ConfigMap{}
+	err = yaml.Unmarshal(data, cm)
+	if err != nil {
+		return nil, fmt.Errorf("failed to unmarshal configmap %s: %w", name, err)
+	}
+	return cm, nil
+
+}
+
+func (n *NetworkStatusHandler) OnBasePathAdded(basePath string) {
+}
+
+func (n *NetworkStatusHandler) startProcessingEvents() {
+	n.mutex.Lock()
+	defer n.mutex.Unlock()
+	if n.doneCh != nil {
+		return
+	}
+	n.logger.Info("Start event processing")
+	n.doneCh = make(chan struct{})
+	go n.processEvents(n.doneCh)
+}
+
+func (n *NetworkStatusHandler) OnRemove(name string) {
+	n.mutex.Lock()
+	defer n.mutex.Unlock()
+
+	if n.doneCh != nil {
+		close(n.doneCh)
+		n.doneCh = nil
+	}
+}
+
+func (n *NetworkStatusHandler) Filter(name string) bool {
+	return strings.HasSuffix(name, "/ConfigMap-skupper-network-status.yaml")
+}
+
+func (n *NetworkStatusHandler) OnCreate(name string) {
+}
diff --git a/internal/nonkube/controller/router_config_handler.go b/internal/nonkube/controller/router_config_handler.go
new file mode 100644
index 00000000..277ab5ec
--- /dev/null
+++ b/internal/nonkube/controller/router_config_handler.go
@@ -0,0 +1,50 @@
+package controller
+
+import (
+	"log/slog"
+	"strings"
+)
+
+type RouterConfigHandler struct {
+	logger    *slog.Logger
+	stopCh    chan struct{}
+	callbacks []ActivationCallback
+	namespace string
+}
+
+func NewRouterConfigHandler(stopCh chan struct{}, namespace string) *RouterConfigHandler {
+	handler := &RouterConfigHandler{
+		stopCh:    stopCh,
+		namespace: namespace,
+	}
+	handler.logger = slog.Default().With("component", "router.config.handler", "namespace", namespace)
+	return handler
+}
+
+func (r *RouterConfigHandler) AddCallback(callback ActivationCallback) {
+	r.callbacks = append(r.callbacks, callback)
+}
+
+func (r *RouterConfigHandler) OnCreate(name string) {
+	r.logger.Info("Router config has been created, starting callbacks")
+	for _, cb := range r.callbacks {
+		cb.Start(r.stopCh)
+	}
+}
+
+func (r *RouterConfigHandler) OnRemove(name string) {
+	r.logger.Info("Router config has been removed, stopping callbacks")
+	for _, cb := range r.callbacks {
+		cb.Stop()
+	}
+}
+
+func (r *RouterConfigHandler) OnBasePathAdded(basePath string) {
+}
+
+func (r *RouterConfigHandler) OnUpdate(name string) {
+}
+
+func (r *RouterConfigHandler) Filter(name string) bool {
+	return strings.HasSuffix(name, "/skrouterd.json")
+}
diff --git a/internal/nonkube/controller/router_state_handler.go b/internal/nonkube/controller/router_state_handler.go
new file mode 100644
index 00000000..05bec076
--- /dev/null
+++ b/internal/nonkube/controller/router_state_handler.go
@@ -0,0 +1,232 @@
+package controller
+
+import (
+	"fmt"
+	"log/slog"
+	"sync"
+	"time"
+
+	"github.com/skupperproject/skupper/internal/messaging"
+	"github.com/skupperproject/skupper/internal/nonkube/client/runtime"
+	"github.com/skupperproject/skupper/internal/qdr"
+)
+
+type RouterStateHandler struct {
+	running   bool
+	namespace string
+	siteId    string
+	logger    *slog.Logger
+	mux       sync.Mutex
+	callback  ActivationCallback
+	heartbeat *heartBeatsClient
+}
+
+func NewRouterStateHandler(namespace string) *RouterStateHandler {
+	handler := &RouterStateHandler{
+		namespace: namespace,
+		heartbeat: newHeartBeatsClient(namespace),
+	}
+	handler.logger = slog.Default().
+		With("component", handler.Id()).
+		With("namespace", namespace)
+	return handler
+}
+
+func (h *RouterStateHandler) SetCallback(callback ActivationCallback) {
+	h.callback = callback
+}
+
+func (h *RouterStateHandler) Start(stopCh <-chan struct{}) {
+	h.mux.Lock()
+	defer h.mux.Unlock()
+	if h.running {
+		return
+	}
+	h.logger.Info("Starting")
+	h.running = true
+	go h.heartbeat.Start(stopCh, h.callback)
+	go h.handleParentStop(stopCh)
+}
+
+func (h *RouterStateHandler) Stop() {
+	h.mux.Lock()
+	defer h.mux.Unlock()
+	if h.running {
+		h.logger.Info("Stopping")
+		h.heartbeat.Stop()
+		h.running = false
+	}
+}
+
+func (h *RouterStateHandler) Id() string {
+	return "router.state.handler"
+}
+
+func (h *RouterStateHandler) handleParentStop(stopCh <-chan struct{}) {
+	t := time.NewTicker(time.Second)
+	for {
+		h.mux.Lock()
+		if !h.running {
+			h.mux.Unlock()
+			break
+		}
+		h.mux.Unlock()
+		select {
+		case <-stopCh:
+			h.logger.Info("Parent channel closed")
+			h.Stop()
+		case <-t.C:
+		}
+	}
+	h.logger.Info("Stopped")
+}
+
+func newHeartBeatsClient(namespace string) *heartBeatsClient {
+	c := &heartBeatsClient{
+		Namespace: namespace,
+	}
+	c.logger = slog.Default().
+		With("component", "heartbeat.client").
+		With("namespace", namespace)
+	return c
+}
+
+type heartBeatsClient struct {
+	Namespace  string
+	logger     *slog.Logger
+	url        string
+	address    string
+	mutex      sync.Mutex
+	running    bool
+	isRouterUp bool
+	callback   ActivationCallback
+	receiver   messaging.Receiver
+	factory    func(string, qdr.TlsConfigRetriever) messaging.ConnectionFactory
+}
+
+func (h *heartBeatsClient) Start(stopCh <-chan struct{}, callback ActivationCallback) {
+	h.mutex.Lock()
+	defer h.mutex.Unlock()
+
+	if h.running {
+		return
+	}
+
+	h.logger.Info("Starting")
+	h.running = true
+	h.callback = callback
+	go h.run(stopCh)
+}
+
+func (h *heartBeatsClient) Stop() {
+	h.mutex.Lock()
+	defer h.mutex.Unlock()
+	h.running = false
+	if h.receiver != nil {
+		_ = h.receiver.Close()
+		h.receiver = nil
+	}
+}
+
+func (h *heartBeatsClient) routerDown(reason string) {
+	h.mutex.Lock()
+	defer h.mutex.Unlock()
+	if h.isRouterUp {
+		h.logger.Info("Router is DOWN", slog.Any("reason", reason))
+		h.isRouterUp = false
+		h.callback.Stop()
+	}
+}
+
+func (h *heartBeatsClient) routerUp(stopCh <-chan struct{}) {
+	h.mutex.Lock()
+	defer h.mutex.Unlock()
+	if !h.isRouterUp {
+		h.logger.Info("Router is UP")
+		h.isRouterUp = true
+		h.callback.Start(stopCh)
+	}
+}
+
+func (h *heartBeatsClient) run(stopCh <-chan struct{}) {
+	const address = "mc/sfe.all"
+
+	h.logger.Debug("Watching for router availability")
+	ticker := time.NewTicker(time.Second)
+	for {
+		h.mutex.Lock()
+		if !h.running {
+			h.mutex.Unlock()
+			h.routerDown("Stopped")
+			break
+		}
+		h.mutex.Unlock()
+		<-ticker.C
+
+		// connection info
+		url, err := h.getUrl()
+		if err != nil {
+			h.routerDown(fmt.Sprintf("Unable to retrieve heartbeat url: %s", err))
+			continue
+		}
+		tls := runtime.GetRuntimeTlsCert(h.Namespace, "skupper-local-client")
+
+		// connect
+		var connFactory messaging.ConnectionFactory
+		if h.factory == nil {
+			connFactory = qdr.NewConnectionFactory(url, tls)
+		} else {
+			connFactory = h.factory(url, tls)
+		}
+		conn, err := connFactory.Connect()
+		if err != nil {
+			h.routerDown(fmt.Sprintf("unable to connect with router through: %s", url))
+			continue
+		}
+		h.mutex.Lock()
+		h.receiver, err = conn.Receiver(address, 1)
+		receiver := h.receiver
+		h.mutex.Unlock()
+		if err != nil {
+			h.logger.Error(err.Error())
+			h.routerDown("unable to create receiver")
+			continue
+		}
+		h.routerUp(stopCh)
+		for {
+			h.mutex.Lock()
+			if !h.running {
+				h.mutex.Unlock()
+				_ = receiver.Close()
+				conn.Close()
+				break
+			}
+			h.mutex.Unlock()
+			_, err = receiver.Receive()
+			if err == nil {
+				h.routerUp(stopCh)
+				continue
+			}
+			h.routerDown(fmt.Sprintf("receive error: %s", err))
+			_ = receiver.Close()
+			conn.Close()
+			break
+		}
+	}
+	h.logger.Info("Exiting")
+}
+
+func (h *heartBeatsClient) getUrl() (string, error) {
+	h.mutex.Lock()
+	defer h.mutex.Unlock()
+	if h.url != "" {
+		return h.url, nil
+	}
+	port, err := runtime.GetLocalRouterPort(h.Namespace)
+	if err != nil {
+		return "", fmt.Errorf("unable to determine local router url: %w", err)
+	}
+	h.url = fmt.Sprintf("amqps://127.0.0.1:%d", port)
+	return h.url, nil
+
+}
diff --git a/internal/nonkube/flow/collector.go b/internal/nonkube/flow/collector.go
new file mode 100644
index 00000000..7b871934
--- /dev/null
+++ b/internal/nonkube/flow/collector.go
@@ -0,0 +1,111 @@
+package flow
+
+import (
+	"context"
+	"crypto/tls"
+	"fmt"
+	"log"
+	"log/slog"
+
+	"github.com/skupperproject/skupper/api/types"
+	"github.com/skupperproject/skupper/internal/flow"
+	"github.com/skupperproject/skupper/internal/nonkube/client/fs"
+	"github.com/skupperproject/skupper/internal/nonkube/client/runtime"
+	"github.com/skupperproject/skupper/internal/nonkube/common"
+	"github.com/skupperproject/skupper/pkg/vanflow/session"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+type StatusSyncClient struct {
+	client    *fs.ConfigMapHandler
+	namespace string
+}
+
+func (s *StatusSyncClient) Logger() *slog.Logger {
+	logger := slog.New(slog.Default().Handler()).With(
+		slog.String("component", "nonkube.flow.statusSync"),
+		slog.String("namespace", s.namespace),
+	)
+	return logger
+}
+
+func (s *StatusSyncClient) Get(ctx context.Context) (*corev1.ConfigMap, error) {
+	return s.client.Get(types.NetworkStatusConfigMapName, fs.GetOptions{
+		RuntimeFirst: true,
+		LogWarning:   false,
+	})
+}
+
+func (s *StatusSyncClient) Update(ctx context.Context, latest *corev1.ConfigMap) error {
+	return s.client.Update(*latest, true)
+}
+
+func siteCollector(ctx context.Context, namespace string) error {
+	platformLoader := &common.NamespacePlatformLoader{}
+	platform, err := platformLoader.Load(namespace)
+	if err != nil {
+		return err
+	}
+	if platform == string(types.PlatformKubernetes) {
+		return fmt.Errorf("Invalid platform: %s", types.PlatformKubernetes)
+	}
+
+	client := fs.NewConfigMapHandler(namespace)
+	cm := corev1.ConfigMap{
+		TypeMeta: metav1.TypeMeta{
+			Kind:       "ConfigMap",
+			APIVersion: "v1",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      types.NetworkStatusConfigMapName,
+			Namespace: namespace,
+		},
+		Data: map[string]string{},
+	}
+	if err := client.Add(cm, true); err != nil {
+		return err
+	}
+
+	address, err := runtime.GetLocalRouterAddress(namespace)
+	if err != nil {
+		return err
+	}
+	tlsConfig, err := getLocalTLSConfig(namespace)
+	if err != nil {
+		return err
+	}
+	factory := session.NewContainerFactory(address, session.ContainerConfig{
+		ContainerID: "nonkube-flow-collector",
+		TLSConfig:   tlsConfig,
+		SASLType:    session.SASLTypeExternal,
+	})
+	statusSyncClient := &StatusSyncClient{
+		client:    fs.NewConfigMapHandler(namespace),
+		namespace: namespace,
+	}
+	statusSync := flow.NewStatusSync(factory, nil, statusSyncClient, types.NetworkStatusConfigMapName)
+	go statusSync.Run(ctx)
+	go func() {
+		<-ctx.Done()
+		_ = client.Delete(cm.Name, true)
+	}()
+	return nil
+}
+
+func getLocalTLSConfig(namespace string) (*tls.Config, error) {
+	tlsCert := runtime.GetRuntimeTlsCert(namespace, "skupper-local-client")
+	config, err := tlsCert.GetTlsConfig()
+	if err == nil {
+		config.MinVersion = tls.VersionTLS13
+	}
+	return config, err
+}
+
+func StartCollector(ctx context.Context, namespace string) error {
+	log.Println("COLLECTOR: Starting site collection for:", namespace)
+	if err := siteCollector(ctx, namespace); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/pkg/fs/watcher.go b/pkg/fs/watcher.go
deleted file mode 100644
index c6b60836..00000000
--- a/pkg/fs/watcher.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package fs
-
-import (
-	"os"
-	"regexp"
-	"time"
-
-	"github.com/fsnotify/fsnotify"
-)
-
-// FSChangeHandler provides a callback mechanism used by the FileWatcher
-// to notify about changes to monitored directory or file.
-type FSChangeHandler interface {
-	OnCreate(string)
-	OnUpdate(string)
-	OnRemove(string)
-}
-
-// FileWatcher uses fsnotify to watch file system changes done to
-// files or directories, notifying the respective handlers. It is
-// recommended to watch directories over files (you can add filters
-// to limit the scope of files to be observed).
-type FileWatcher struct {
-	watcher    *fsnotify.Watcher
-	handlerMap map[string]FSChangeHandler
-	filterMap  map[FSChangeHandler][]*regexp.Regexp
-}
-
-func NewWatcher() (*FileWatcher, error) {
-	watcher, err := fsnotify.NewWatcher()
-	if err != nil {
-		return nil, err
-	}
-	return &FileWatcher{
-		watcher:    watcher,
-		handlerMap: map[string]FSChangeHandler{},
-		filterMap:  map[FSChangeHandler][]*regexp.Regexp{},
-	}, nil
-}
-
-func (w *FileWatcher) filterHandlers(name string) []FSChangeHandler {
-	var handlers []FSChangeHandler
-
-	for _, handler := range w.handlerMap {
-		if filters, ok := w.filterMap[handler]; !ok || len(filters) == 0 {
-			handlers = append(handlers, handler)
-		} else {
-			for _, f := range filters {
-				if f.MatchString(name) {
-					handlers = append(handlers, handler)
-					break
-				}
-			}
-		}
-	}
-	return handlers
-}
-
-func (w *FileWatcher) Start(stopCh <-chan struct{}) {
-	go func() {
-		for {
-			select {
-			case event := <-w.watcher.Events:
-				handlers := w.filterHandlers(event.Name)
-				if len(handlers) == 0 {
-					continue
-				}
-				switch {
-				case event.Has(fsnotify.Create):
-					for _, handler := range handlers {
-						handler.OnCreate(event.Name)
-					}
-				case event.Has(fsnotify.Write):
-					for _, handler := range handlers {
-						handler.OnUpdate(event.Name)
-					}
-				case event.Has(fsnotify.Remove):
-					for _, handler := range handlers {
-						handler.OnRemove(event.Name)
-					}
-					// object being watched removed, watch for it to show up again
-					if handler, ok := w.handlerMap[event.Name]; ok {
-						_ = w.watcher.Remove(event.Name)
-						w.watchCreated(event.Name, handler)
-					}
-				}
-			case <-stopCh:
-				_ = w.watcher.Close()
-				return
-			}
-		}
-	}()
-}
-
-// watchCreated waits for a file or directory to exist, then it
-// start watching the respective resource. It is recommended to
-// watch directories and filter the desired files, as watching
-// non-existing files directly might lead to missing events.
-func (w *FileWatcher) watchCreated(name string, handler FSChangeHandler) {
-	go func() {
-		ticker := time.Tick(time.Second)
-		for {
-			select {
-			case <-ticker:
-				if err := w.watcher.Add(name); err == nil {
-					handler.OnCreate(name)
-					return
-				}
-			}
-		}
-	}()
-	return
-}
-
-func (w *FileWatcher) Add(name string, handler FSChangeHandler, filters ...*regexp.Regexp) {
-	w.handlerMap[name] = handler
-	w.filterMap[handler] = filters
-	if _, err := os.Stat(name); err != nil && os.IsNotExist(err) {
-		w.watchCreated(name, handler)
-	} else {
-		_ = w.watcher.Add(name)
-	}
-}
diff --git a/pkg/nonkube/api/environment.go b/pkg/nonkube/api/environment.go
index 790f80c1..acf9cfaa 100644
--- a/pkg/nonkube/api/environment.go
+++ b/pkg/nonkube/api/environment.go
@@ -29,10 +29,11 @@ const (
 type IdGetter func() int
 
 var getUid IdGetter = os.Getuid
+var DefaultRootDataHome = "/var/lib/skupper"
 
 func GetDataHome() string {
 	if getUid() == 0 {
-		return "/var/lib/skupper"
+		return DefaultRootDataHome
 	}
 	dataHome, ok := os.LookupEnv("XDG_DATA_HOME")
 	if !ok {
diff --git a/pkg/nonkube/api/site_state.go b/pkg/nonkube/api/site_state.go
index 299750e8..cae2cf83 100644
--- a/pkg/nonkube/api/site_state.go
+++ b/pkg/nonkube/api/site_state.go
@@ -5,9 +5,12 @@ import (
 	"fmt"
 	"os"
 	"path"
+	"reflect"
+	"slices"
 	"time"
 
 	"github.com/google/uuid"
+	"github.com/skupperproject/skupper/internal/network"
 	"github.com/skupperproject/skupper/internal/qdr"
 	"github.com/skupperproject/skupper/internal/site"
 	"github.com/skupperproject/skupper/internal/utils"
@@ -17,6 +20,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/serializer/json"
+	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/kubernetes/scheme"
 )
 
@@ -32,10 +36,11 @@ type SiteState struct {
 	RouterAccesses  map[string]*v2alpha1.RouterAccess
 	Grants          map[string]*v2alpha1.AccessGrant
 	Links           map[string]*v2alpha1.Link
-	Secrets         map[string]*corev1.Secret
 	Claims          map[string]*v2alpha1.AccessToken
 	Certificates    map[string]*v2alpha1.Certificate
 	SecuredAccesses map[string]*v2alpha1.SecuredAccess
+	Secrets         map[string]*corev1.Secret
+	ConfigMaps      map[string]*corev1.ConfigMap
 	bundle          bool
 }
 
@@ -44,13 +49,14 @@ func NewSiteState(bundle bool) *SiteState {
 		Site:            &v2alpha1.Site{},
 		Listeners:       make(map[string]*v2alpha1.Listener),
 		Connectors:      make(map[string]*v2alpha1.Connector),
-		RouterAccesses:  map[string]*v2alpha1.RouterAccess{},
+		RouterAccesses:  make(map[string]*v2alpha1.RouterAccess),
 		Grants:          make(map[string]*v2alpha1.AccessGrant),
 		Links:           make(map[string]*v2alpha1.Link),
-		Secrets:         make(map[string]*corev1.Secret),
 		Claims:          make(map[string]*v2alpha1.AccessToken),
-		Certificates:    map[string]*v2alpha1.Certificate{},
-		SecuredAccesses: map[string]*v2alpha1.SecuredAccess{},
+		Certificates:    make(map[string]*v2alpha1.Certificate),
+		SecuredAccesses: make(map[string]*v2alpha1.SecuredAccess),
+		Secrets:         make(map[string]*corev1.Secret),
+		ConfigMaps:      make(map[string]*corev1.ConfigMap),
 		bundle:          bundle,
 	}
 }
@@ -74,7 +80,7 @@ func (s *SiteState) IsInterior() bool {
 func (s *SiteState) HasRouterAccess() bool {
 	for _, la := range s.RouterAccesses {
 		for _, role := range la.Spec.Roles {
-			if role.Name == "normal" {
+			if la.Name == "skupper-local" && role.Name == "normal" {
 				return true
 			}
 		}
@@ -336,6 +342,39 @@ func (s *SiteState) SetNamespace(namespace string) {
 	setNamespaceOnMap(s.Claims, namespace)
 	setNamespaceOnMap(s.Certificates, namespace)
 	setNamespaceOnMap(s.SecuredAccesses, namespace)
+	setNamespaceOnMap(s.ConfigMaps, namespace)
+}
+
+func (s *SiteState) UpdateStatus(networkStatus network.NetworkStatusInfo) {
+	siteRecords := network.ExtractSiteRecords(networkStatus)
+	if reflect.DeepEqual(s.Site.Status.Network, siteRecords) {
+		return
+	}
+	s.Site.Status.Network = siteRecords
+	s.Site.Status.SitesInNetwork = len(siteRecords)
+	linkRecords := network.GetLinkRecordsForSite(s.SiteId, siteRecords)
+
+	for _, linkRecord := range linkRecords {
+		if link, ok := s.Links[linkRecord.Name]; ok {
+			link.SetOperational(linkRecord.Operational, linkRecord.RemoteSiteId, linkRecord.RemoteSiteName)
+		}
+	}
+	for linkName, existingLink := range s.Links {
+		exists := slices.ContainsFunc(linkRecords, func(record v2alpha1.LinkRecord) bool {
+			return record.Name == linkName
+		})
+		if !exists {
+			existingLink.SetOperational(false, "", "")
+		}
+	}
+
+	// updating listeners and connectors
+	for _, listener := range s.Listeners {
+		listener.SetHasMatchingConnector(network.HasMatchingPair(networkStatus, listener.Spec.RoutingKey))
+	}
+	for _, connector := range s.Connectors {
+		connector.SetHasMatchingListener(network.HasMatchingPair(networkStatus, connector.Spec.RoutingKey))
+	}
 }
 
 func marshal(outputDirectory, resourceType, resourceName string, resource interface{}) error {
@@ -370,6 +409,9 @@ func marshalMap[V any](outputDirectory, resourceType string, resourceMap map[str
 
 func MarshalSiteState(siteState SiteState, outputDirectory string) error {
 	var err error
+	if siteState.Site != nil && siteState.Site.ObjectMeta.UID == "" {
+		siteState.Site.ObjectMeta.UID = types.UID(siteState.SiteId)
+	}
 	if err = marshal(outputDirectory, "Site", siteState.Site.Name, siteState.Site); err != nil {
 		return err
 	}
@@ -400,6 +442,9 @@ func MarshalSiteState(siteState SiteState, outputDirectory string) error {
 	if err = marshalMap(outputDirectory, "Secret", siteState.Secrets); err != nil {
 		return err
 	}
+	if err = marshalMap(outputDirectory, "ConfigMap", siteState.ConfigMaps); err != nil {
+		return err
+	}
 	return nil
 }
 
diff --git a/pkg/vanflow/eventsource/manager.go b/pkg/vanflow/eventsource/manager.go
index 79a1c516..72d2f8fc 100644
--- a/pkg/vanflow/eventsource/manager.go
+++ b/pkg/vanflow/eventsource/manager.go
@@ -258,6 +258,11 @@ func (m *Manager) sendKeepalives(ctx context.Context) {
 	for {
 		if err := sendWithTimeout(ctx, beaconInterval, beaconSender, beaconMessage.Encode()); err != nil {
 			m.logger.Error("error sending initial beacon message", slog.Any("error", err))
+			if ctx.Err() != nil {
+				m.logger.Error("gave up on sending initial beacon message as context has been closed",
+					slog.Any("error", err))
+				return
+			}
 			continue
 		}
 		break


Test patch:
diff --git a/internal/cmd/skupper/link/nonkube/link_generate_test.go b/internal/cmd/skupper/link/nonkube/link_generate_test.go
index d1ceaaf1..e6cf9ccd 100644
--- a/internal/cmd/skupper/link/nonkube/link_generate_test.go
+++ b/internal/cmd/skupper/link/nonkube/link_generate_test.go
@@ -1,6 +1,10 @@
 package nonkube
 
 import (
+	"os"
+	"path/filepath"
+	"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"
@@ -9,9 +13,6 @@ import (
 	"github.com/skupperproject/skupper/pkg/nonkube/api"
 	"gotest.tools/v3/assert"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"os"
-	"path/filepath"
-	"testing"
 )
 
 func TestCmdLinkGenerate_ValidateInput(t *testing.T) {
@@ -49,10 +50,12 @@ func TestCmdLinkGenerate_ValidateInput(t *testing.T) {
 
 	for _, test := range testTable {
 		t.Run(test.name, func(t *testing.T) {
-
-			tmpDir := filepath.Join(t.TempDir(), "/skupper")
-			err := os.Setenv("SKUPPER_OUTPUT_PATH", tmpDir)
-			assert.Check(t, err == nil)
+			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.RuntimeSiteStatePath))
 
 			if test.createSite {
@@ -95,9 +98,12 @@ func TestCmdLinkGenerate_Run(t *testing.T) {
 
 		t.Run(test.name, func(t *testing.T) {
 
-			tmpDir := filepath.Join(t.TempDir(), "/skupper")
-			err := os.Setenv("SKUPPER_OUTPUT_PATH", tmpDir)
-			assert.Check(t, err == nil)
+			if os.Getuid() == 0 {
+				api.DefaultRootDataHome = t.TempDir()
+			} else {
+				t.Setenv("XDG_DATA_HOME", t.TempDir())
+			}
+			tmpDir := api.GetDataHome()
 			sitePath := filepath.Join(tmpDir, "/namespaces/test/", string(api.RuntimeSiteStatePath))
 			linkPath := filepath.Join(tmpDir, "/namespaces/test", string(api.RuntimeTokenPath))
 
diff --git a/internal/cmd/skupper/site/nonkube/site_delete_test.go b/internal/cmd/skupper/site/nonkube/site_delete_test.go
index 212689ab..560d7e0f 100644
--- a/internal/cmd/skupper/site/nonkube/site_delete_test.go
+++ b/internal/cmd/skupper/site/nonkube/site_delete_test.go
@@ -27,9 +27,12 @@ func TestCmdSiteDelete_ValidateInput(t *testing.T) {
 		expectedError     string
 	}
 
-	tmpDir := filepath.Join(t.TempDir(), "/skupper")
-	err := os.Setenv("SKUPPER_OUTPUT_PATH", tmpDir)
-	assert.Check(t, err == nil)
+	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{
@@ -191,9 +194,12 @@ func TestCmdSiteDelete_Run(t *testing.T) {
 		},
 	}
 
-	tmpDir := filepath.Join(t.TempDir(), "/skupper")
-	err := os.Setenv("SKUPPER_OUTPUT_PATH", tmpDir)
-	assert.Check(t, err == nil)
+	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 := &CmdSiteDelete{Flags: &common.CommandSiteDeleteFlags{}}
diff --git a/internal/cmd/skupper/site/nonkube/site_status_test.go b/internal/cmd/skupper/site/nonkube/site_status_test.go
index 71929af1..de8ec664 100644
--- a/internal/cmd/skupper/site/nonkube/site_status_test.go
+++ b/internal/cmd/skupper/site/nonkube/site_status_test.go
@@ -26,9 +26,12 @@ func TestCmdSiteStatus_ValidateInput(t *testing.T) {
 		expectedError     string
 	}
 
-	tmpDir := filepath.Join(t.TempDir(), "/skupper")
-	err := os.Setenv("SKUPPER_OUTPUT_PATH", tmpDir)
-	assert.Check(t, err == nil)
+	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{
@@ -121,9 +124,12 @@ func TestCmdSiteStatus_Run(t *testing.T) {
 		output              string
 	}
 
-	tmpDir := filepath.Join(t.TempDir(), "/skupper")
-	err := os.Setenv("SKUPPER_OUTPUT_PATH", tmpDir)
-	assert.Check(t, err == nil)
+	if os.Getuid() == 0 {
+		api.DefaultRootDataHome = t.TempDir()
+	} else {
+		t.Setenv("XDG_DATA_HOME", t.TempDir())
+	}
+	tmpDir := api.GetDataHome()
 	path := filepath.Join(tmpDir, "/namespaces/test3/", string(api.InputSiteStatePath))
 	testTable := []test{
 		{
@@ -209,9 +215,12 @@ func TestCmdSiteStatus_RunNoDirectory(t *testing.T) {
 		errorMessage        string
 	}
 
-	tmpDir := filepath.Join(t.TempDir(), "/skupper")
-	err := os.Setenv("SKUPPER_OUTPUT_PATH", tmpDir)
-	assert.Check(t, err == nil)
+	if os.Getuid() == 0 {
+		api.DefaultRootDataHome = t.TempDir()
+	} else {
+		t.Setenv("XDG_DATA_HOME", t.TempDir())
+	}
+	tmpDir := api.GetDataHome()
 	path := filepath.Join(tmpDir, "/namespaces/test3/", string(api.InputSiteStatePath))
 
 	testTable := []test{
diff --git a/internal/cmd/skupper/site/nonkube/site_update_test.go b/internal/cmd/skupper/site/nonkube/site_update_test.go
index 651a18b9..7bfdedf1 100644
--- a/internal/cmd/skupper/site/nonkube/site_update_test.go
+++ b/internal/cmd/skupper/site/nonkube/site_update_test.go
@@ -25,9 +25,12 @@ func TestCmdSiteUpdate_ValidateInput(t *testing.T) {
 		expectedError     string
 	}
 
-	tmpDir := filepath.Join(t.TempDir(), "/skupper")
-	err := os.Setenv("SKUPPER_OUTPUT_PATH", tmpDir)
-	assert.Check(t, err == nil)
+	if os.Getuid() == 0 {
+		api.DefaultRootDataHome = t.TempDir()
+	} else {
+		t.Setenv("XDG_DATA_HOME", t.TempDir())
+	}
+	tmpDir := api.GetDataHome()
 	path := filepath.Join(tmpDir, "/namespaces/test4/", string(api.InputSiteStatePath))
 
 	testTable := []test{
diff --git a/internal/cmd/skupper/version/nonkube/version_test.go b/internal/cmd/skupper/version/nonkube/version_test.go
index 1721c3c0..f3ff37b5 100644
--- a/internal/cmd/skupper/version/nonkube/version_test.go
+++ b/internal/cmd/skupper/version/nonkube/version_test.go
@@ -8,6 +8,7 @@ import (
 	"github.com/skupperproject/skupper/internal/cmd/skupper/common"
 	"github.com/skupperproject/skupper/internal/cmd/skupper/common/testutils"
 	"github.com/skupperproject/skupper/internal/utils/configs"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
 	"gotest.tools/v3/assert"
 	"k8s.io/apimachinery/pkg/runtime"
 )
@@ -58,11 +59,15 @@ func TestCmdVersion_ValidateInput(t *testing.T) {
 	for _, test := range testTable {
 		t.Run(test.name, func(t *testing.T) {
 
-			tmpDir := filepath.Join(t.TempDir(), "/skupper")
-			err := os.Setenv("SKUPPER_OUTPUT_PATH", tmpDir)
+			if os.Getuid() == 0 {
+				api.DefaultRootDataHome = t.TempDir()
+			} else {
+				t.Setenv("XDG_DATA_HOME", t.TempDir())
+			}
+			tmpDir := api.GetDataHome()
 
 			nestedDir := filepath.Join(tmpDir, "namespaces", "test")
-			err = os.MkdirAll(nestedDir, os.ModePerm)
+			err := os.MkdirAll(nestedDir, os.ModePerm)
 			if err != nil {
 				t.Fatalf("failed to create directories: %v", err)
 			}
diff --git a/internal/filesystem/file_watcher_test.go b/internal/filesystem/file_watcher_test.go
new file mode 100644
index 00000000..d6f356df
--- /dev/null
+++ b/internal/filesystem/file_watcher_test.go
@@ -0,0 +1,394 @@
+package filesystem
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/skupperproject/skupper/internal/utils"
+	"gotest.tools/v3/assert"
+	"k8s.io/apimachinery/pkg/util/rand"
+	"k8s.io/utils/strings/slices"
+)
+
+const (
+	tempDirPattern        = "test.filewatcher.*"
+	counterHandlerTimeout = 101 * time.Millisecond
+)
+
+type operation int
+
+const (
+	created operation = iota
+	updated
+	removed
+)
+
+func TestFileWatcher(t *testing.T) {
+	stop := make(chan struct{})
+	var w *FileWatcher
+	var err error
+	var filterTxt = func(name string) bool {
+		return strings.HasSuffix(name, ".txt")
+	}
+	var pathBefore = &pathTester{handler: newCounterHandler(filterTxt)}
+	var pathAfter = &pathTester{handler: newCounterHandler(filterTxt)}
+	var pathAfterClone = &pathTester{handler: newCounterHandler(filterTxt)}
+
+	defer func() {
+		var err error
+		for _, p := range []*pathTester{
+			pathBefore,
+			pathAfter,
+		} {
+			err = errors.Join(os.RemoveAll(p.path))
+		}
+		assert.Assert(t, err)
+	}()
+
+	t.Run("create-watcher", func(t *testing.T) {
+		w, err = NewWatcher(slog.String("owner", "test.watcher"))
+		assert.Assert(t, err)
+	})
+
+	// path is added into watcher before watcher is started
+	// with files already present in the file-system
+	t.Run("add-path-before-starting", func(t *testing.T) {
+		assert.Assert(t, pathBefore.mkBasePath())
+		pathBefore.generateFileNames(10, ".txt")
+		pathBefore.generateFileNames(10, ".invalid")
+		assert.Assert(t, pathBefore.create())
+		w.Add(pathBefore.path, pathBefore.handler)
+	})
+
+	t.Run("start-watcher", func(t *testing.T) {
+		w.Start(stop)
+	})
+
+	t.Run("validate-path-before-created", func(t *testing.T) {
+		validateOperation(t, pathBefore, created, 1)
+	})
+
+	// path is added into watcher after watcher is started
+	t.Run("add-path-after-started", func(t *testing.T) {
+		// path to be created later
+		pathAfter.path = filepath.Join(os.TempDir(), uuid.NewString())
+		w.Add(pathAfter.path, pathAfter.handler)
+		assert.Assert(t, pathAfter.mkBasePath())
+
+		// ensure base path added (otherwise unable to determine the amount of calls to OnCreate)
+		validateBasedPathAdded(t, pathAfter, 1)
+
+		pathAfter.generateFileNames(10, ".txt")
+		pathAfter.generateFileNames(10, ".invalid")
+		assert.Assert(t, pathAfter.create())
+		assert.Assert(t, pathAfter.update())
+		assert.Assert(t, pathAfter.update())
+	})
+
+	t.Run("validate-path-after", func(t *testing.T) {
+		validateOperation(t, pathAfter, created, 1)
+		validateOperation(t, pathAfter, updated, 2)
+	})
+
+	t.Run("delete-files-path-after", func(t *testing.T) {
+		assert.Assert(t, pathAfter.remove())
+		validateOperation(t, pathAfter, removed, 1)
+	})
+
+	t.Run("add-second-handler-to-path-after", func(t *testing.T) {
+		pathAfterClone.path = pathAfter.path
+		pathAfterClone.files = pathAfter.files
+		w.Add(pathAfterClone.path, pathAfterClone.handler)
+		assert.Assert(t, pathAfter.create())
+		assert.Assert(t, pathAfter.update())
+	})
+
+	t.Run("validate-path-after-handlers", func(t *testing.T) {
+		validateOperation(t, pathAfterClone, created, 1)
+		validateOperation(t, pathAfter, created, 2)
+		validateOperation(t, pathAfterClone, updated, 1)
+		validateOperation(t, pathAfter, updated, 3)
+	})
+
+	t.Run("remove-files-path-after", func(t *testing.T) {
+		assert.Assert(t, pathAfter.remove())
+		validateOperation(t, pathAfter, removed, 2)
+		validateOperation(t, pathAfterClone, removed, 1)
+	})
+
+	t.Run("multi-add-delete-path-after", func(t *testing.T) {
+		pathAfter.handler.reset()
+		pathAfterClone.handler.reset()
+		expectedCalled := 100
+		for i := 0; i < expectedCalled; i++ {
+			assert.Assert(t, pathAfter.create())
+			assert.Assert(t, pathAfter.remove())
+		}
+		validateOperation(t, pathAfter, created, expectedCalled)
+		validateOperation(t, pathAfterClone, created, expectedCalled)
+		validateOperation(t, pathAfter, removed, expectedCalled)
+		validateOperation(t, pathAfterClone, removed, expectedCalled)
+	})
+
+	t.Run("remove-path-before-basedir", func(t *testing.T) {
+		assert.Assert(t, pathBefore.remove())
+		validateOperation(t, pathBefore, removed, 1)
+		// removing basedir and adding
+		assert.Assert(t, pathBefore.rmBasePath())
+		validateOperation(t, pathBefore, removed, 1)
+	})
+
+	t.Run("add-path-before-basedir", func(t *testing.T) {
+		pathBefore.handler.reset()
+		assert.Assert(t, pathBefore.mkBasePath())
+		assert.Assert(t, pathBefore.create())
+		validateOperation(t, pathBefore, created, 1)
+	})
+
+	t.Run("path-before-timeout", func(t *testing.T) {
+		pathBefore.handler.setTimeout(true)
+		assert.Assert(t, pathBefore.remove())
+		validateOperation(t, pathBefore, removed, 1)
+	})
+
+	t.Run("stop-watcher", func(t *testing.T) {
+		close(stop)
+	})
+}
+
+func validateOperation(t *testing.T, pt *pathTester, op operation, expectCalled int) {
+	t.Helper()
+	var m func(string) int
+	var method string
+	var verb string
+	switch op {
+	case created:
+		m = pt.handler.GetCreatedCount
+		method = "OnCreate"
+		verb = "created"
+	case updated:
+		m = pt.handler.GetUpdatedCount
+		method = "OnUpdate"
+		verb = "updated"
+	case removed:
+		m = pt.handler.GetRemovedCount
+		method = "OnRemove"
+		verb = "removed"
+	}
+	assert.Assert(t, utils.RetryError(time.Millisecond*100, 10, func() error {
+		totalFiltered := 0
+		expectedFiles := slices.Filter(nil, pt.files, pt.handler.filter)
+		for _, fileName := range expectedFiles {
+			totalFiltered++
+			found := m(fileName)
+			if expectCalled != found {
+				return fmt.Errorf("expected %s to be called %d times for %q, but got: %d", method, expectCalled, fileName, found)
+			}
+		}
+		if len(expectedFiles) != totalFiltered {
+			return fmt.Errorf("expected %d files %s, but got %d", len(expectedFiles), verb, totalFiltered)
+		}
+		return nil
+	}))
+
+	// assert not filtered files did not trigger operation
+	ignoredFiles := slices.Filter(nil, pt.files, func(s string) bool {
+		return !pt.handler.filter(s)
+	})
+	for _, fileName := range ignoredFiles {
+		found := m(fileName)
+		assert.Equal(t, 0, found, "expected %s to be ignored, but %s was triggered", fileName, method)
+	}
+}
+
+func validateBasedPathAdded(t *testing.T, pt *pathTester, expectedCalled int) {
+	assert.Assert(t, utils.RetryError(time.Millisecond*200, 10, func() error {
+		found := pt.handler.GetBasePathAddedCount(pt.path)
+		if found != expectedCalled {
+			return fmt.Errorf("expected OnBasePathAdded to be called %d times for %q, but got: %d",
+				expectedCalled, pt.path, found)
+		}
+		return nil
+	}))
+}
+
+type pathTester struct {
+	path    string
+	handler *counterHandler
+	files   []string
+}
+
+func (p *pathTester) generateFileNames(count int, suffix string) {
+	for i := 0; i < count; i++ {
+		name := filepath.Join(p.path, rand.String(16)+suffix)
+		p.files = append(p.files, name)
+	}
+}
+
+func (p *pathTester) mkBasePath() error {
+	var err error
+	if p.path == "" {
+		p.path, err = os.MkdirTemp("", tempDirPattern)
+	} else {
+		err = os.Mkdir(p.path, 0777)
+	}
+	return err
+}
+
+func (p *pathTester) rmBasePath() error {
+	return os.RemoveAll(p.path)
+}
+
+func (p *pathTester) create() error {
+	for _, file := range p.files {
+		if _, err := os.Stat(file); err == nil {
+			return fmt.Errorf("file %q already exists and cannot be created", file)
+		}
+		f, err := os.Create(file)
+		if err != nil {
+			return err
+		}
+		if err = f.Close(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (p *pathTester) update() error {
+	for _, file := range p.files {
+		stat, err := os.Stat(file)
+		if err == nil && stat.Mode().IsRegular() {
+			f, err := os.OpenFile(file, os.O_WRONLY|os.O_APPEND, 0644)
+			if err != nil {
+				return err
+			}
+			if _, err = f.WriteString("hello world\n"); err != nil {
+				return err
+			}
+			if err = f.Sync(); err != nil {
+				return err
+			}
+			if err = f.Close(); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func (p *pathTester) remove() error {
+	for _, filePath := range p.files {
+		if err := os.RemoveAll(filePath); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+type counterHandler struct {
+	basePathMap map[string]int
+	createMap   map[string]int
+	updateMap   map[string]int
+	removeMap   map[string]int
+	filter      func(string) bool
+	m           sync.RWMutex
+	timeout     bool
+}
+
+func newCounterHandler(filter func(string) bool) *counterHandler {
+	return &counterHandler{
+		basePathMap: make(map[string]int),
+		createMap:   make(map[string]int),
+		updateMap:   make(map[string]int),
+		removeMap:   make(map[string]int),
+		filter:      filter,
+	}
+}
+
+func (t *counterHandler) setTimeout(timeout bool) {
+	t.m.Lock()
+	defer t.m.Unlock()
+	t.timeout = timeout
+}
+
+func (t *counterHandler) reset() {
+	t.m.Lock()
+	defer t.m.Unlock()
+	t.basePathMap = map[string]int{}
+	t.createMap = map[string]int{}
+	t.updateMap = map[string]int{}
+	t.removeMap = map[string]int{}
+}
+
+func (t *counterHandler) OnBasePathAdded(basePath string) {
+	t.m.Lock()
+	defer t.m.Unlock()
+	t.basePathMap[basePath] += 1
+	if t.timeout {
+		time.Sleep(counterHandlerTimeout)
+	}
+}
+
+func (t *counterHandler) GetBasePathAddedCount(basePath string) int {
+	t.m.RLock()
+	defer t.m.RUnlock()
+	return t.basePathMap[basePath]
+}
+
+func (t *counterHandler) OnCreate(name string) {
+	t.m.Lock()
+	defer t.m.Unlock()
+	t.createMap[name] += 1
+	if t.timeout {
+		time.Sleep(counterHandlerTimeout)
+	}
+}
+
+func (t *counterHandler) GetCreatedCount(basePath string) int {
+	t.m.RLock()
+	defer t.m.RUnlock()
+	return t.createMap[basePath]
+}
+
+func (t *counterHandler) OnUpdate(name string) {
+	t.m.Lock()
+	defer t.m.Unlock()
+	t.updateMap[name] += 1
+	if t.timeout {
+		time.Sleep(counterHandlerTimeout)
+	}
+}
+
+func (t *counterHandler) GetUpdatedCount(basePath string) int {
+	t.m.RLock()
+	defer t.m.RUnlock()
+	return t.updateMap[basePath]
+}
+
+func (t *counterHandler) OnRemove(name string) {
+	t.m.Lock()
+	defer t.m.Unlock()
+	t.removeMap[name] += 1
+	if t.timeout {
+		time.Sleep(counterHandlerTimeout)
+	}
+}
+
+func (t *counterHandler) GetRemovedCount(basePath string) int {
+	t.m.RLock()
+	defer t.m.RUnlock()
+	return t.removeMap[basePath]
+}
+
+func (t *counterHandler) Filter(name string) bool {
+	return t.filter(name)
+}
diff --git a/internal/kube/flow/status_test.go b/internal/flow/status_test.go
similarity index 87%
rename from internal/kube/flow/status_test.go
rename to internal/flow/status_test.go
index 647f050e..a3486e13 100644
--- a/internal/kube/flow/status_test.go
+++ b/internal/flow/status_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"log/slog"
 	"testing"
 	"time"
 
@@ -19,16 +20,12 @@ import (
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes/fake"
+	corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
 )
 
 func TestStatusSyncEndToEnd(t *testing.T) {
 	ctx := context.Background()
-	cm := fake.NewSimpleClientset(&v1.ConfigMap{
-		ObjectMeta: metav1.ObjectMeta{
-			Name:      "test",
-			Namespace: "test",
-		},
-	}).CoreV1().ConfigMaps("test")
+	cm := fakeCmClient()
 
 	factory := session.NewMockContainerFactory()
 	conn := factory.Create()
@@ -148,7 +145,7 @@ func TestStatusSyncEndToEnd(t *testing.T) {
 			go ss.Run(tstCtx)
 
 			poll.WaitOn(t, func(log poll.LogT) poll.Result {
-				actualCM, err := cm.Get(tstCtx, "test", metav1.GetOptions{})
+				actualCM, err := cm.Get(tstCtx)
 				if err != nil {
 					return poll.Error(fmt.Errorf("unexpected error condition: %s", err))
 				}
@@ -172,6 +169,37 @@ func TestStatusSyncEndToEnd(t *testing.T) {
 	}
 }
 
+type fakeKubeStatusSyncClient struct {
+	cm corev1.ConfigMapInterface
+}
+
+func (f *fakeKubeStatusSyncClient) Logger() *slog.Logger {
+	logger := slog.New(slog.Default().Handler()).With(
+		slog.String("component", "kube.flow.statusSync"),
+	)
+	return logger
+}
+
+func (f *fakeKubeStatusSyncClient) Get(ctx context.Context) (*v1.ConfigMap, error) {
+	return f.cm.Get(ctx, "test", metav1.GetOptions{})
+}
+
+func (f *fakeKubeStatusSyncClient) Update(ctx context.Context, latest *v1.ConfigMap) error {
+	_, err := f.cm.Update(ctx, latest, metav1.UpdateOptions{})
+	return err
+}
+
+func fakeCmClient() StatusSyncClient {
+	client := &fakeKubeStatusSyncClient{}
+	client.cm = fake.NewSimpleClientset(&v1.ConfigMap{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "test",
+			Namespace: "test",
+		},
+	}).CoreV1().ConfigMaps("test")
+	return client
+}
+
 func asEntries(r []vanflow.Record) []store.Entry {
 	entries := make([]store.Entry, len(r))
 	for i := range r {
diff --git a/internal/kube/controller/controller_test.go b/internal/kube/controller/controller_test.go
index 246f5ab0..431824dc 100644
--- a/internal/kube/controller/controller_test.go
+++ b/internal/kube/controller/controller_test.go
@@ -1344,7 +1344,7 @@ func (*factory) podCondition(conditionType corev1.PodConditionType, status corev
 
 func (*factory) addNetworkStatus(site *skupperv2alpha1.Site, status *NetworkStatus) *skupperv2alpha1.Site {
 	//add equivalent data to that generated in networkStatusInfo
-	site.Status.Network = extractSiteRecords(*status.info())
+	site.Status.Network = network.ExtractSiteRecords(*status.info())
 	return site
 }
 
diff --git a/internal/network/network_test.go b/internal/network/network_test.go
index ce9d7ddf..c32bef20 100644
--- a/internal/network/network_test.go
+++ b/internal/network/network_test.go
@@ -2,8 +2,9 @@ package network
 
 import (
 	"encoding/json"
-	"gotest.tools/v3/assert"
 	"testing"
+
+	"gotest.tools/v3/assert"
 )
 
 func createTestSkupperStatus() *SkupperStatus {
@@ -161,3 +162,49 @@ func TestRemoveLinksFromSameSite(t *testing.T) {
 	assert.DeepEqual(t, result, []LinkInfo{link})
 
 }
+
+func TestHasMatchingPair(t *testing.T) {
+	networkStatus := NetworkStatusInfo{
+		Addresses: []AddressInfo{
+			{
+				Name:           "backend-match",
+				ListenerCount:  1,
+				ConnectorCount: 1,
+			},
+			{
+				Name:           "backend-no-listener",
+				ListenerCount:  0,
+				ConnectorCount: 1,
+			},
+			{
+				Name:           "backend-no-connector",
+				ListenerCount:  1,
+				ConnectorCount: 0,
+			},
+		},
+	}
+	scenarios := []struct {
+		address       string
+		expectedMatch bool
+	}{
+		{
+			address:       "backend-match",
+			expectedMatch: true,
+		},
+		{
+			address:       "backend-no-listener",
+			expectedMatch: false,
+		},
+		{
+			address:       "backend-no-connector",
+			expectedMatch: false,
+		},
+		{
+			address:       "invalid-address",
+			expectedMatch: false,
+		},
+	}
+	for _, scenario := range scenarios {
+		assert.Equal(t, scenario.expectedMatch, HasMatchingPair(networkStatus, scenario.address))
+	}
+}
diff --git a/internal/nonkube/client/runtime/certs_test.go b/internal/nonkube/client/runtime/certs_test.go
new file mode 100644
index 00000000..82b2564b
--- /dev/null
+++ b/internal/nonkube/client/runtime/certs_test.go
@@ -0,0 +1,57 @@
+package runtime
+
+import (
+	"errors"
+	"io/fs"
+	"os"
+	"path"
+	"testing"
+	"time"
+
+	"github.com/skupperproject/skupper/internal/certs"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	"gotest.tools/v3/assert"
+)
+
+func TestTlsConfigRetriever(t *testing.T) {
+	var err error
+	NamespacesPath, err = os.MkdirTemp(os.TempDir(), "tls-config-retriever-*")
+	assert.Assert(t, err)
+
+	defer func() {
+		t.Logf("Cleaning up %s", NamespacesPath)
+		//err = os.RemoveAll(NamespacesPath)
+		NamespacesPath = ""
+	}()
+
+	baseCertsPath := path.Join(NamespacesPath, "default", string(api.CertificatesPath))
+	t.Logf("Creating %s", baseCertsPath)
+	err = os.MkdirAll(baseCertsPath, 0755)
+	assert.Assert(t, err)
+
+	t.Run("tls-config-retriever-no-certs", func(t *testing.T) {
+		tlsCert := GetRuntimeTlsCert("default", "invalid-certificate")
+		err = tlsCert.LoadTlsConfig()
+		assert.Assert(t, errors.Is(err, fs.ErrNotExist))
+	})
+
+	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)
+
+		certPath := path.Join(baseCertsPath, "valid-certificate")
+		assert.Assert(t, os.MkdirAll(certPath, 0755))
+		assert.Assert(t, os.WriteFile(path.Join(certPath, "ca.crt"), ca.Data["ca.crt"], 0644))
+		assert.Assert(t, os.WriteFile(path.Join(certPath, "tls.crt"), ca.Data["tls.crt"], 0644))
+		assert.Assert(t, os.WriteFile(path.Join(certPath, "tls.key"), ca.Data["tls.key"], 0644))
+
+		tlsCert := GetRuntimeTlsCert("default", "valid-certificate")
+		assert.Equal(t, path.Join(certPath, "ca.crt"), tlsCert.CaPath)
+		assert.Equal(t, path.Join(certPath, "tls.crt"), tlsCert.CertPath)
+		assert.Equal(t, path.Join(certPath, "tls.key"), tlsCert.KeyPath)
+		tlsCfg, err := tlsCert.GetTlsConfig()
+		assert.Assert(t, err)
+		assert.Assert(t, tlsCfg != nil)
+	})
+}
diff --git a/internal/nonkube/client/runtime/info_test.go b/internal/nonkube/client/runtime/info_test.go
new file mode 100644
index 00000000..b79f3342
--- /dev/null
+++ b/internal/nonkube/client/runtime/info_test.go
@@ -0,0 +1,57 @@
+package runtime
+
+import (
+	"os"
+	"testing"
+
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	"gotest.tools/v3/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestGetLocalRouterAddress(t *testing.T) {
+	tempDir := t.TempDir()
+	if os.Getuid() == 0 {
+		api.DefaultRootDataHome = tempDir
+	} else {
+		t.Setenv("XDG_DATA_HOME", tempDir)
+	}
+
+	namespace := "test-get-local-router-address"
+	var ss = api.NewSiteState(false)
+	ss.RouterAccesses["skupper-local"] = fakeSkupperLocalRouterAccess(namespace)
+	outputDir := api.GetInternalOutputPath(namespace, api.RuntimeSiteStatePath)
+
+	// Try to get without skupper-local RouterAccess
+	routerAddress, err := GetLocalRouterAddress(namespace)
+	assert.ErrorContains(t, err, "unable to determine router port:")
+
+	// Try again, now with skupper-local RouterAccess present
+	assert.Assert(t, api.MarshalSiteState(*ss, outputDir))
+	routerAddress, err = GetLocalRouterAddress(namespace)
+	assert.Assert(t, err)
+	assert.Equal(t, routerAddress, "amqps://127.0.0.1:5671")
+}
+
+func fakeSkupperLocalRouterAccess(namespace string) *v2alpha1.RouterAccess {
+	return &v2alpha1.RouterAccess{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v2alpha1",
+			Kind:       "RouterAccess",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "skupper-local",
+			Namespace: namespace,
+		},
+		Spec: v2alpha1.RouterAccessSpec{
+			Roles: []v2alpha1.RouterAccessRole{
+				{
+					Name: "normal",
+					Port: 5671,
+				},
+			},
+			BindHost: "127.0.0.1",
+		},
+	}
+}
diff --git a/internal/nonkube/common/fs_config_renderer_test.go b/internal/nonkube/common/fs_config_renderer_test.go
index 23514af5..32cd0df6 100644
--- a/internal/nonkube/common/fs_config_renderer_test.go
+++ b/internal/nonkube/common/fs_config_renderer_test.go
@@ -286,5 +286,6 @@ func fakeSiteState() *api.SiteState {
 		Claims:          make(map[string]*v2alpha1.AccessToken),
 		Certificates:    make(map[string]*v2alpha1.Certificate),
 		SecuredAccesses: make(map[string]*v2alpha1.SecuredAccess),
+		ConfigMaps:      make(map[string]*corev1.ConfigMap),
 	}
 }
diff --git a/internal/nonkube/common/site_state_loader_test.go b/internal/nonkube/common/site_state_loader_test.go
index 0ee02c53..3c20afb9 100644
--- a/internal/nonkube/common/site_state_loader_test.go
+++ b/internal/nonkube/common/site_state_loader_test.go
@@ -27,4 +27,5 @@ func TestFileSystemSiteStateLoder(t *testing.T) {
 	loadedSiteState, err := fsStateLoader.Load()
 	assert.Assert(t, err)
 	assert.Assert(t, loadedSiteState != nil)
+	assert.Equal(t, string(loadedSiteState.Site.ObjectMeta.UID), loadedSiteState.SiteId)
 }
diff --git a/internal/nonkube/common/site_state_renderer_common_test.go b/internal/nonkube/common/site_state_renderer_common_test.go
index 71d48048..f2f8a5c5 100644
--- a/internal/nonkube/common/site_state_renderer_common_test.go
+++ b/internal/nonkube/common/site_state_renderer_common_test.go
@@ -21,6 +21,7 @@ func TestCopySiteState(t *testing.T) {
 	assert.DeepEqual(t, ss.Claims, newSs.Claims)
 	assert.DeepEqual(t, ss.Certificates, newSs.Certificates)
 	assert.DeepEqual(t, ss.SecuredAccesses, newSs.SecuredAccesses)
+	assert.DeepEqual(t, ss.ConfigMaps, newSs.ConfigMaps)
 	assert.Assert(t, equalsButNotShallowCopy(ss.Listeners, newSs.Listeners))
 	assert.Assert(t, equalsButNotShallowCopy(ss.Connectors, newSs.Connectors))
 	assert.Assert(t, equalsButNotShallowCopy(ss.RouterAccesses, newSs.RouterAccesses))
@@ -30,6 +31,7 @@ func TestCopySiteState(t *testing.T) {
 	assert.Assert(t, equalsButNotShallowCopy(ss.Claims, newSs.Claims))
 	assert.Assert(t, equalsButNotShallowCopy(ss.Certificates, newSs.Certificates))
 	assert.Assert(t, equalsButNotShallowCopy(ss.SecuredAccesses, newSs.SecuredAccesses))
+	assert.Assert(t, equalsButNotShallowCopy(ss.ConfigMaps, newSs.ConfigMaps))
 }
 
 func equalsButNotShallowCopy[T comparable](oldMap, newMap map[string]T) bool {
diff --git a/internal/nonkube/controller/collector_handler_test.go b/internal/nonkube/controller/collector_handler_test.go
new file mode 100644
index 00000000..4b8eecbd
--- /dev/null
+++ b/internal/nonkube/controller/collector_handler_test.go
@@ -0,0 +1,70 @@
+package controller
+
+import (
+	"testing"
+	"time"
+
+	"gotest.tools/v3/assert"
+)
+
+func TestCollectorHandler(t *testing.T) {
+	started := make(chan struct{})
+	done := make(chan struct{})
+	stopCh := make(chan struct{})
+
+	ch := NewCollectorLifecycleHandler("sample-namespace")
+	fakeStart := func() {
+		close(started)
+		go func() {
+			<-ch.ctx.Done()
+			close(done)
+		}()
+	}
+	ch.startMethod = fakeStart
+
+	t.Run("start-collector", func(t *testing.T) {
+		ch.Start(stopCh)
+		timeout := time.NewTicker(time.Second)
+		select {
+		case <-timeout.C:
+			assert.Assert(t, false, "Timeout waiting for collector to start")
+		case <-started:
+			t.Logf("fake collector started")
+		}
+	})
+
+	t.Run("stop-collector", func(t *testing.T) {
+		ch.Stop()
+		timeout := time.NewTicker(time.Second)
+		select {
+		case <-timeout.C:
+			assert.Assert(t, false, "Timeout waiting for collector to stop")
+		case <-done:
+			t.Logf("fake collector stopped")
+		}
+	})
+
+	t.Run("restart-collector", func(t *testing.T) {
+		started = make(chan struct{})
+		done = make(chan struct{})
+		ch.Start(stopCh)
+		timeout := time.NewTicker(time.Second)
+		select {
+		case <-timeout.C:
+			assert.Assert(t, false, "Timeout waiting for collector to restart")
+		case <-started:
+			t.Logf("fake collector restarted")
+		}
+	})
+
+	t.Run("parent-stopped", func(t *testing.T) {
+		close(stopCh)
+		timeout := time.NewTicker(time.Second)
+		select {
+		case <-timeout.C:
+			assert.Assert(t, false, "Timeout waiting for collector to stop")
+		case <-done:
+			t.Logf("fake collector stopped")
+		}
+	})
+}
diff --git a/internal/nonkube/controller/namespaces_test.go b/internal/nonkube/controller/namespaces_test.go
new file mode 100644
index 00000000..d0101c2b
--- /dev/null
+++ b/internal/nonkube/controller/namespaces_test.go
@@ -0,0 +1,88 @@
+package controller
+
+import (
+	"os"
+	"path"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/skupperproject/skupper/internal/utils"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	"gotest.tools/v3/assert"
+)
+
+func TestNamespacesHandler(t *testing.T) {
+	var m sync.Mutex
+
+	tempDir := t.TempDir()
+	if os.Getuid() == 0 {
+		api.DefaultRootDataHome = tempDir
+	} else {
+		t.Setenv("XDG_DATA_HOME", tempDir)
+	}
+	namespacesPath := api.GetDefaultOutputNamespacesPath()
+	assert.Assert(t, os.MkdirAll(namespacesPath, 0755))
+	assert.Assert(t, os.MkdirAll(path.Join(namespacesPath, "before"), 0755))
+
+	nsHandler, err := NewNamespacesHandler()
+	assert.Assert(t, err)
+	assert.Assert(t, nsHandler != nil)
+
+	stopCh := make(chan struct{})
+	wg := &sync.WaitGroup{}
+
+	assert.Assert(t, nsHandler.Start(stopCh, wg))
+
+	t.Run("assert-running", func(t *testing.T) {
+		assert.Assert(t, utils.Retry(time.Millisecond*100, 10, func() (bool, error) {
+			return len(nsHandler.namespaces) == 1, nil
+		}), "namespace controller did not start for namespace 'before'")
+	})
+
+	var nsCtrlAfter *NamespaceController
+	t.Run("create-namespace", func(t *testing.T) {
+		assert.Assert(t, os.Mkdir(path.Join(namespacesPath, "after"), 0755))
+		assert.Assert(t, utils.Retry(time.Millisecond*100, 10, func() (bool, error) {
+			nsHandler.mutex.Lock()
+			defer nsHandler.mutex.Unlock()
+			return len(nsHandler.namespaces) == 2, nil
+		}), "namespace controller did not start for namespace 'after'")
+		nsCtrlAfter = nsHandler.namespaces["after"]
+		assert.Assert(t, nsCtrlAfter != nil)
+	})
+
+	t.Run("delete-namespace", func(t *testing.T) {
+		nsCtrlAfterStopped := false
+		go func() {
+			<-nsCtrlAfter.stopCh
+			m.Lock()
+			defer m.Unlock()
+			nsCtrlAfterStopped = true
+		}()
+		assert.Assert(t, os.Remove(path.Join(namespacesPath, "after")))
+		assert.Assert(t, utils.Retry(time.Millisecond*100, 10, func() (bool, error) {
+			m.Lock()
+			defer m.Unlock()
+			nsHandler.mutex.Lock()
+			defer nsHandler.mutex.Unlock()
+			return nsCtrlAfterStopped && len(nsHandler.namespaces) == 1, nil
+		}), "namespace controller did not stop")
+	})
+
+	t.Run("stopped", func(t *testing.T) {
+		stopped := false
+		go func() {
+			wg.Wait()
+			m.Lock()
+			defer m.Unlock()
+			stopped = true
+		}()
+		close(stopCh)
+		assert.Assert(t, utils.Retry(time.Millisecond*100, 10, func() (bool, error) {
+			m.Lock()
+			defer m.Unlock()
+			return stopped, nil
+		}), "controller did not stop")
+	})
+}
diff --git a/internal/nonkube/controller/network_status_handler_test.go b/internal/nonkube/controller/network_status_handler_test.go
new file mode 100644
index 00000000..a63ff8b1
--- /dev/null
+++ b/internal/nonkube/controller/network_status_handler_test.go
@@ -0,0 +1,490 @@
+package controller
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"log/slog"
+	"os"
+	"path"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/skupperproject/skupper/internal/network"
+	"github.com/skupperproject/skupper/internal/nonkube/client/fs"
+	"github.com/skupperproject/skupper/internal/utils"
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	"gotest.tools/v3/assert"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	utilyaml "k8s.io/apimachinery/pkg/util/yaml"
+	"k8s.io/client-go/util/jsonpath"
+	"k8s.io/utils/strings/slices"
+	"sigs.k8s.io/yaml"
+)
+
+const (
+	delaySecs = 1
+	attempts  = 5
+)
+
+func TestNetworkStatusHandler(t *testing.T) {
+	var err error
+	var nsCtrl *NamespaceController
+	var nsHandler *NetworkStatusHandler
+	var logHandler *testLogHandler
+
+	tempDir := t.TempDir()
+	if os.Getuid() == 0 {
+		api.DefaultRootDataHome = tempDir
+	} else {
+		t.Setenv("XDG_DATA_HOME", tempDir)
+	}
+	namespacesPath := api.GetDefaultOutputNamespacesPath()
+	assert.Assert(t, os.MkdirAll(namespacesPath, 0755))
+	namespace := "test-network-status-handler"
+	runtimePath := api.GetInternalOutputPath(namespace, api.RuntimeSiteStatePath)
+	assert.Assert(t, os.MkdirAll(runtimePath, 0755))
+
+	// Site state and network status
+	siteState := fakeSiteState()
+	networkStatus := fakeNetworkStatusInfo()
+
+	t.Run("serialize-site-state", func(t *testing.T) {
+		assert.Assert(t, api.MarshalSiteState(*siteState, runtimePath))
+	})
+
+	t.Run("start-network-status-handler", func(t *testing.T) {
+		nsHandler = NewNetworkStatusHandler(namespace)
+		nsCtrl, err = NewNamespaceController(namespace)
+		assert.Assert(t, err)
+		nsCtrl.prepare = func() {
+			nsCtrl.watcher.Add(runtimePath, nsHandler)
+		}
+
+		// Custom Log Handler (intercept messages)
+		logHandler = &testLogHandler{
+			handler: nsHandler.logger.Handler(),
+		}
+		nsHandler.logger = slog.New(logHandler)
+
+		nsCtrl.Start()
+	})
+
+	t.Run("update-network-status", func(t *testing.T) {
+		cmHandler := fs.NewConfigMapHandler(namespace)
+		cmStr, err := fakeConfigMap(networkStatus)
+		assert.Assert(t, err)
+		err = cmHandler.WriteFile(runtimePath, "skupper-network-status.yaml", cmStr, "ConfigMap")
+		assert.Assert(t, err)
+	})
+
+	t.Run("verify-runtime-resources", func(t *testing.T) {
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Site", "site-name", "{.status.sitesInNetwork}", "2"))
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Connector", "connector-one", "{.status.hasMatchingListener}", "true"))
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Listener", "listener-one", "{.status.hasMatchingConnector}", "true"))
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Listener", "listener-one", "{.status.conditions[?(@.type=='Matched')].status}", "True"))
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Listener", "listener-two", "{.status.conditions[?(@.type=='Matched')].status}", "False"))
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Link", "link-one", "{.status.conditions[?(@.type=='Operational')].status}", "True"))
+	})
+
+	t.Run("remove-namespace", func(t *testing.T) {
+		cmHandler := fs.NewConfigMapHandler(namespace)
+		assert.Assert(t, cmHandler.Delete("skupper-network-status", true))
+	})
+
+	t.Run("verify-runtime-resources-reset", func(t *testing.T) {
+		assert.Assert(t, verifyJsonPathNotFound(namespace, "Site", "site-name", "{.status.sitesInNetwork}"), "sitesInNetwork found, but not expected")
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Site", "site-name", "{.status}", "{}"), "status expected as empty")
+		assert.Assert(t, verifyJsonPathNotFound(namespace, "Connector", "connector-one", "{.status.hasMatchingListener}"), "hasMatchingListener found, but not expected")
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Connector", "connector-one", "{.status.conditions[?(@.type=='Matched')].status}", "False"))
+		assert.Assert(t, verifyJsonPathNotFound(namespace, "Listener", "listener-one", "{.status.hasMatchingConnector}"), "hasMatchingConnector found, but not expected")
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Listener", "listener-one", "{.status.conditions[?(@.type=='Matched')].status}", "False"))
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Listener", "listener-two", "{.status.conditions[?(@.type=='Matched')].status}", "False"))
+		assert.Assert(t, verifyJsonPathExpected(namespace, "Link", "link-one", "{.status.conditions[?(@.type=='Operational')].status}", "False"))
+	})
+
+	t.Run("stop-network-status-handler", func(t *testing.T) {
+		nsCtrl.Stop()
+		err = utils.Retry(time.Second*delaySecs, attempts, func() (bool, error) {
+			t.Log(logHandler.messages)
+			return slices.Contains(logHandler.messages, "Stop event processing"), nil
+		})
+		assert.Assert(t, err)
+	})
+
+}
+
+type testLogHandler struct {
+	handler  slog.Handler
+	messages []string
+	mux      sync.Mutex
+}
+
+func (t *testLogHandler) Enabled(ctx context.Context, level slog.Level) bool {
+	return true
+}
+
+func (t *testLogHandler) Handle(ctx context.Context, record slog.Record) error {
+	t.mux.Lock()
+	defer t.mux.Unlock()
+	t.messages = append(t.messages, record.Message)
+	return t.handler.Handle(ctx, record)
+}
+
+func (t *testLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+	return t.handler.WithAttrs(attrs)
+}
+
+func (t *testLogHandler) WithGroup(name string) slog.Handler {
+	return t.handler.WithGroup(name)
+}
+
+func (t *testLogHandler) Count(message string) int {
+	t.mux.Lock()
+	defer t.mux.Unlock()
+	count := 0
+	for _, msg := range t.messages {
+		if msg == message {
+			count++
+		}
+	}
+	return count
+}
+
+func verifyJsonPathExpected(namespace, kind, name, jsonPath, expected string) error {
+	verifyExpected := func(s string, err error) (bool, error) {
+		if s != expected {
+			return false, nil
+		}
+		return true, nil
+	}
+	return verifyJsonPath(namespace, kind, name, jsonPath, verifyExpected)
+}
+
+func verifyJsonPathNotFound(namespace, kind, name, jsonPath string) error {
+	verifyExpected := func(s string, err error) (bool, error) {
+		if err == nil {
+			return false, nil
+		}
+		return strings.Contains(err.Error(), "is not found"), nil
+	}
+	return verifyJsonPath(namespace, kind, name, jsonPath, verifyExpected)
+}
+
+func verifyJsonPath(namespace, kind, name, jsonPath string, validate func(s string, err error) (bool, error)) error {
+	return utils.Retry(time.Second*delaySecs, attempts, func() (bool, error) {
+		var data interface{}
+		buf := new(bytes.Buffer)
+
+		fileName := kind + "-" + name + ".yaml"
+		fqFileName := path.Join(api.GetInternalOutputPath(namespace, api.RuntimeSiteStatePath), fileName)
+		f, err := os.Open(fqFileName)
+		if err != nil {
+			return validate("", err)
+		}
+		defer f.Close()
+
+		err = utilyaml.NewYAMLToJSONDecoder(f).Decode(&data)
+		if err != nil {
+			return validate("", err)
+		}
+
+		j := jsonpath.New(jsonPath)
+		err = j.Parse(jsonPath)
+		if err != nil {
+			return validate("", err)
+		}
+
+		err = j.Execute(buf, data)
+		if err != nil {
+			return validate("", err)
+		}
+
+		return validate(buf.String(), err)
+	})
+}
+
+func fakeConfigMap(status network.NetworkStatusInfo) (string, error) {
+	statusJson, err := json.Marshal(status)
+	if err != nil {
+		return "", err
+	}
+	cm := &corev1.ConfigMap{
+		TypeMeta: metav1.TypeMeta{
+			Kind:       "ConfigMap",
+			APIVersion: "v1",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "skupper-network-status",
+		},
+		Data: map[string]string{
+			"NetworkStatus": string(statusJson),
+		},
+	}
+	cmYaml, err := yaml.Marshal(cm)
+	if err != nil {
+		return "", err
+	}
+	return string(cmYaml), nil
+}
+
+func fakeNetworkStatusInfo() network.NetworkStatusInfo {
+	return network.NetworkStatusInfo{
+		Addresses: []network.AddressInfo{
+			{
+				Name:           "listener-one-key",
+				Protocol:       "tcp",
+				ListenerCount:  1,
+				ConnectorCount: 1,
+			},
+			{
+				Name:           "connector-one-key",
+				Protocol:       "tcp",
+				ListenerCount:  1,
+				ConnectorCount: 1,
+			},
+		},
+		SiteStatus: []network.SiteStatusInfo{
+			{
+				Site: network.SiteInfo{
+					Identity:  "site-id",
+					Name:      "site-name",
+					Namespace: "default",
+					Platform:  "podman",
+					Version:   "version",
+				},
+				RouterStatus: []network.RouterStatusInfo{
+					{
+						Links: []network.LinkInfo{
+							{
+								Name:     "link-one",
+								LinkCost: 1,
+								Status:   "up",
+								Role:     "inter-router",
+								Peer:     "other-site-link-access-identity-inter-router",
+							},
+						},
+						AccessPoints: []network.RouterAccessInfo{
+							{Identity: "link-access-one-identity-inter-router"},
+							{Identity: "link-access-one-identity-edge"},
+						},
+						Listeners: []network.ListenerInfo{
+							{
+								Name:    "listener-one",
+								Address: "listener-one-key",
+							},
+							{
+								Name:    "listener-two",
+								Address: "listener-two-key",
+							},
+						},
+						Connectors: []network.ConnectorInfo{
+							{
+								DestHost: "connector-one-host",
+								Address:  "connector-one-key",
+							},
+						},
+					},
+				},
+			}, {
+				Site: network.SiteInfo{
+					Identity:  "other-site-id",
+					Name:      "other-site-name",
+					Namespace: "default",
+					Platform:  "linux",
+					Version:   "version",
+				},
+				RouterStatus: []network.RouterStatusInfo{
+					{
+						AccessPoints: []network.RouterAccessInfo{
+							{Identity: "other-site-link-access-identity-inter-router"},
+							{Identity: "other-site-link-access-identity-edge"},
+						},
+						Listeners: []network.ListenerInfo{
+							{
+								Name:    "listener-one",
+								Address: "connector-one-key",
+							},
+						},
+						Connectors: []network.ConnectorInfo{
+							{
+								DestHost: "connector-one-host",
+								Address:  "listener-one-key",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+func fakeSiteState() *api.SiteState {
+	return &api.SiteState{
+		SiteId: "site-id",
+		Site: &v2alpha1.Site{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "Site",
+				APIVersion: "skupper.io/v2alpha1",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "site-name",
+			},
+			Spec: v2alpha1.SiteSpec{},
+		},
+		Listeners: map[string]*v2alpha1.Listener{
+			"listener-one": {
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "Listener",
+					APIVersion: "skupper.io/v2alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "listener-one",
+				},
+				Spec: v2alpha1.ListenerSpec{
+					RoutingKey:     "listener-one-key",
+					Host:           "listener-one-host",
+					Port:           1234,
+					TlsCredentials: "listener-one-credentials",
+					Type:           "tcp",
+				},
+			},
+			"listener-two": {
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "Listener",
+					APIVersion: "skupper.io/v2alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "listener-two",
+				},
+				Spec: v2alpha1.ListenerSpec{
+					RoutingKey:     "listener-two-key",
+					Host:           "listener-two-host",
+					Port:           1234,
+					TlsCredentials: "listener-two-credentials",
+					Type:           "tcp",
+				},
+			},
+		},
+		Connectors: map[string]*v2alpha1.Connector{
+			"connector-one": {
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "Connector",
+					APIVersion: "skupper.io/v2alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "connector-one",
+				},
+				Spec: v2alpha1.ConnectorSpec{
+					RoutingKey:     "connector-one-key",
+					Host:           "connector-one-host",
+					Port:           1234,
+					TlsCredentials: "connector-one-credentials",
+					Type:           "tcp",
+				},
+			},
+		},
+		RouterAccesses: map[string]*v2alpha1.RouterAccess{
+			"link-access-one": {
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "RouterAccess",
+					APIVersion: "skupper.io/v2alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "link-access-one",
+				},
+				Spec: v2alpha1.RouterAccessSpec{
+					Roles: []v2alpha1.RouterAccessRole{
+						{
+							Name: "inter-router",
+							Port: 55671,
+						},
+						{
+							Name: "edge",
+							Port: 45671,
+						},
+					},
+					TlsCredentials: "link-access-one",
+					BindHost:       "127.0.0.1",
+					SubjectAlternativeNames: []string{
+						"localhost",
+					},
+				},
+			},
+			"skupper-local": {
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "RouterAccess",
+					APIVersion: "skupper.io/v2alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "skupper-local",
+				},
+				Spec: v2alpha1.RouterAccessSpec{
+					Roles: []v2alpha1.RouterAccessRole{
+						{
+							Name: "normal",
+							Port: 5671,
+						},
+					},
+					TlsCredentials: "skupper-local",
+					BindHost:       "127.0.0.1",
+					SubjectAlternativeNames: []string{
+						"localhost",
+					},
+				},
+			},
+		},
+		Grants: make(map[string]*v2alpha1.AccessGrant),
+		Links: map[string]*v2alpha1.Link{
+			"link-one": {
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "Link",
+					APIVersion: "skupper.io/v2alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "link-one",
+				},
+				Spec: v2alpha1.LinkSpec{
+					Endpoints: []v2alpha1.Endpoint{
+						{
+							Name: "inter-router",
+							Host: "127.0.0.1",
+							Port: "55671",
+						},
+						{
+							Name: "edge",
+							Host: "127.0.0.1",
+							Port: "45671",
+						},
+					},
+					TlsCredentials: "link-one",
+					Cost:           1,
+				},
+			},
+		},
+		Secrets: map[string]*corev1.Secret{
+			"link-one-profile": {
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "Secret",
+					APIVersion: "v1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "link-one-profile",
+				},
+				Data: map[string][]byte{
+					"ca.crt":  []byte("ca.crt"),
+					"tls.crt": []byte("tls.crt"),
+					"tls.key": []byte("tls.key"),
+				},
+			},
+		},
+		ConfigMaps:      map[string]*corev1.ConfigMap{},
+		Claims:          make(map[string]*v2alpha1.AccessToken),
+		Certificates:    make(map[string]*v2alpha1.Certificate),
+		SecuredAccesses: make(map[string]*v2alpha1.SecuredAccess),
+	}
+}
diff --git a/internal/nonkube/controller/router_config_handler_test.go b/internal/nonkube/controller/router_config_handler_test.go
new file mode 100644
index 00000000..878a8da7
--- /dev/null
+++ b/internal/nonkube/controller/router_config_handler_test.go
@@ -0,0 +1,111 @@
+package controller
+
+import (
+	"os"
+	"path"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/skupperproject/skupper/internal/utils"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	"gotest.tools/v3/assert"
+)
+
+func TestNewRouterConfigHandler(t *testing.T) {
+	var err error
+	var nsCtrl *NamespaceController
+
+	tempDir := t.TempDir()
+	if os.Getuid() == 0 {
+		api.DefaultRootDataHome = tempDir
+	} else {
+		t.Setenv("XDG_DATA_HOME", tempDir)
+	}
+
+	stopCh := make(chan struct{})
+	namespace := "test-router-config-handler"
+	routerConfigHandler := NewRouterConfigHandler(stopCh, namespace)
+	routerConfigPath := api.GetInternalOutputPath(namespace, api.RouterConfigPath)
+	routerConfigFile := path.Join(routerConfigPath, "skrouterd.json")
+	assert.Assert(t, os.MkdirAll(routerConfigPath, 0755))
+	f, err := os.Create(routerConfigFile)
+	assert.Assert(t, err)
+	defer f.Close()
+	callback := &testCallback{}
+	routerConfigHandler.AddCallback(callback)
+
+	t.Run("start-namespace-controller", func(t *testing.T) {
+		nsCtrl, err = NewNamespaceController(namespace)
+		assert.Assert(t, err)
+		nsCtrl.prepare = func() {
+			nsCtrl.watcher.Add(routerConfigPath, routerConfigHandler)
+		}
+		nsCtrl.Start()
+	})
+
+	t.Run("create-router-config-file", func(t *testing.T) {
+	})
+
+	t.Run("assert-started-once", func(t *testing.T) {
+		err = utils.Retry(time.Second, 5, func() (bool, error) {
+			t.Log(callback.started.Load())
+			return callback.started.Load() == 1, nil
+		})
+		assert.Assert(t, err)
+	})
+
+	t.Run("assert-stopped-once", func(t *testing.T) {
+		assert.Assert(t, os.Remove(routerConfigFile))
+		err = utils.Retry(time.Second, 5, func() (bool, error) {
+			t.Log(callback.stopped.Load())
+			return callback.stopped.Load() == 1, nil
+		})
+		assert.Assert(t, err)
+	})
+
+	t.Run("recreate-router-config-file", func(t *testing.T) {
+		f, err := os.Create(routerConfigFile)
+		assert.Assert(t, err)
+		defer f.Close()
+
+		err = utils.Retry(time.Second, 5, func() (bool, error) {
+			t.Log(callback.started.Load())
+			return callback.started.Load() == 2, nil
+		})
+		assert.Assert(t, err)
+	})
+
+	t.Run("remove-router-config-path", func(t *testing.T) {
+		assert.Assert(t, os.RemoveAll(routerConfigPath))
+		err = utils.Retry(time.Second, 5, func() (bool, error) {
+			t.Log(callback.stopped.Load())
+			return callback.stopped.Load() == 2, nil
+		})
+		assert.Assert(t, err)
+	})
+
+}
+
+type testCallback struct {
+	started atomic.Int32
+	stopped atomic.Int32
+}
+
+func (t *testCallback) Start(stopCh <-chan struct{}) {
+	go t.wait(stopCh)
+	t.started.Add(1)
+}
+
+func (t *testCallback) Stop() {
+	t.stopped.Add(1)
+}
+
+func (t *testCallback) wait(stopCh <-chan struct{}) {
+	<-stopCh
+	t.Stop()
+}
+
+func (t *testCallback) Id() string {
+	return "test-callback"
+}
diff --git a/internal/nonkube/controller/router_state_handler_test.go b/internal/nonkube/controller/router_state_handler_test.go
new file mode 100644
index 00000000..754bad44
--- /dev/null
+++ b/internal/nonkube/controller/router_state_handler_test.go
@@ -0,0 +1,264 @@
+package controller
+
+import (
+	"fmt"
+	"log/slog"
+	"os"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	amqp "github.com/interconnectedcloud/go-amqp"
+	"github.com/skupperproject/skupper/internal/messaging"
+	"github.com/skupperproject/skupper/internal/qdr"
+	"github.com/skupperproject/skupper/internal/utils"
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1"
+	"github.com/skupperproject/skupper/pkg/nonkube/api"
+	"gotest.tools/v3/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestRouterStateHandler(t *testing.T) {
+	tempDir := t.TempDir()
+	if os.Getuid() == 0 {
+		api.DefaultRootDataHome = tempDir
+	} else {
+		t.Setenv("XDG_DATA_HOME", tempDir)
+	}
+	namespacesPath := api.GetDefaultOutputNamespacesPath()
+	assert.Assert(t, os.MkdirAll(namespacesPath, 0755))
+	namespace := "test-router-state-handler"
+	runtimePath := api.GetInternalOutputPath(namespace, api.RuntimeSiteStatePath)
+	assert.Assert(t, os.MkdirAll(runtimePath, 0755))
+
+	// Site state
+	siteState := &api.SiteState{
+		SiteId: "site-id",
+		Site: &v2alpha1.Site{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "Site",
+				APIVersion: "skupper.io/v2alpha1",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "site-name",
+			},
+			Spec: v2alpha1.SiteSpec{},
+		},
+		RouterAccesses: map[string]*v2alpha1.RouterAccess{
+			"skupper-local": {
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "RouterAccess",
+					APIVersion: "skupper.io/v2alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "skupper-local",
+				},
+				Spec: v2alpha1.RouterAccessSpec{
+					Roles: []v2alpha1.RouterAccessRole{
+						{
+							Name: "normal",
+							Port: 5671,
+						},
+					},
+					TlsCredentials: "skupper-local",
+					BindHost:       "127.0.0.1",
+					SubjectAlternativeNames: []string{
+						"localhost",
+					},
+				},
+			},
+		},
+	}
+	assert.Assert(t, api.MarshalSiteState(*siteState, runtimePath))
+
+	routerStateHandler := NewRouterStateHandler(namespace)
+	routerLogHandler := &testLogHandler{
+		handler: routerStateHandler.logger.Handler(),
+	}
+	routerStateHandler.logger = slog.New(routerLogHandler)
+	routerStateHandler.heartbeat = newHeartBeatsClient(namespace)
+	heartbeatLogHandler := &testLogHandler{
+		handler: routerStateHandler.heartbeat.logger.Handler(),
+	}
+	routerStateHandler.heartbeat.logger = slog.New(heartbeatLogHandler)
+	var mockFactory *mockConnectionFactory
+	routerStateHandler.heartbeat.factory = func(s string, tls qdr.TlsConfigRetriever) messaging.ConnectionFactory {
+		mockFactory = &mockConnectionFactory{
+			url: s,
+			tls: tls,
+		}
+		return mockFactory
+	}
+	callback := &routerStateCallback{}
+	routerStateHandler.SetCallback(callback)
+	stopCh := make(chan struct{})
+
+	t.Run("start-router-state-handler", func(t *testing.T) {
+		routerStateHandler.Start(stopCh)
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return callback.started.Load() == int32(1), nil
+		}), "callback Start() method not called")
+		assert.Equal(t, len(mockFactory.connection.receivers), 1)
+		assert.Equal(t, routerLogHandler.Count("Starting"), 1)
+		assert.Equal(t, heartbeatLogHandler.Count("Starting"), 1)
+	})
+
+	t.Run("force-receiver-error", func(t *testing.T) {
+		for _, r := range mockFactory.connection.receivers {
+			mr := r.(*mockReceiver)
+			mr.channel <- nil
+		}
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return callback.stopped.Load() == int32(1), nil
+		}))
+	})
+
+	t.Run("receiver-recovered", func(t *testing.T) {
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return callback.started.Load() == int32(2), nil
+		}))
+	})
+
+	t.Run("stop-router-state-handler", func(t *testing.T) {
+		routerStateHandler.Stop()
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			h := routerStateHandler.heartbeat
+			h.mutex.Lock()
+			defer h.mutex.Unlock()
+			return !h.running, nil
+		}))
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return callback.stopped.Load() == int32(2), nil
+		}))
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return routerLogHandler.Count("Stopping") == 1, nil
+		}))
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return routerLogHandler.Count("Stopped") == 1, nil
+		}))
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return heartbeatLogHandler.Count("Exiting") == 1, nil
+		}))
+	})
+
+	t.Run("restart-router-state-handler", func(t *testing.T) {
+		routerStateHandler.Start(stopCh)
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return callback.started.Load() == int32(3), nil
+		}))
+		assert.Equal(t, routerLogHandler.Count("Starting"), 2)
+		assert.Equal(t, heartbeatLogHandler.Count("Starting"), 2)
+	})
+
+	t.Run("closing-stop-channel", func(t *testing.T) {
+		assert.Equal(t, callback.stopped.Load(), int32(2))
+		close(stopCh)
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			h := routerStateHandler.heartbeat
+			h.mutex.Lock()
+			defer h.mutex.Unlock()
+			return !h.running, nil
+		}))
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return callback.stopped.Load() == int32(3), nil
+		}))
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return routerLogHandler.Count("Parent channel closed") == 1, nil
+		}))
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return routerLogHandler.Count("Stopping") == 2, nil
+		}))
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return routerLogHandler.Count("Stopped") == 2, nil
+		}))
+		assert.Assert(t, utils.Retry(delaySecs*time.Second, attempts, func() (bool, error) {
+			return heartbeatLogHandler.Count("Exiting") == 2, nil
+		}))
+	})
+}
+
+type mockConnectionFactory struct {
+	url        string
+	tls        qdr.TlsConfigRetriever
+	connection *mockConnection
+}
+
+func (m *mockConnectionFactory) Connect() (messaging.Connection, error) {
+	m.connection = &mockConnection{
+		senders:   map[string]messaging.Sender{},
+		receivers: map[string]messaging.Receiver{},
+		closed:    false,
+	}
+	return m.connection, nil
+}
+
+func (m *mockConnectionFactory) Url() string {
+	return m.url
+}
+
+type mockConnection struct {
+	senders   map[string]messaging.Sender
+	receivers map[string]messaging.Receiver
+	closed    bool
+}
+
+func (m *mockConnection) Sender(address string) (messaging.Sender, error) {
+	return nil, nil
+}
+
+func (m *mockConnection) Receiver(address string, credit uint32) (messaging.Receiver, error) {
+	if r, ok := m.receivers[address]; ok {
+		return r, nil
+	}
+	r := &mockReceiver{
+		channel: make(chan *amqp.Message),
+	}
+	m.receivers[address] = r
+	return r, nil
+}
+
+func (m *mockConnection) Close() {
+}
+
+type mockReceiver struct {
+	channel chan *amqp.Message
+	closed  bool
+}
+
+func (m *mockReceiver) Receive() (*amqp.Message, error) {
+	msg := <-m.channel
+	if msg == nil {
+		return nil, fmt.Errorf("channel closed")
+	}
+	return msg, nil
+}
+
+func (m *mockReceiver) Accept(message *amqp.Message) error {
+	return nil
+}
+
+func (m *mockReceiver) Close() error {
+	if m.closed {
+		return fmt.Errorf("receiver already closed")
+	}
+	close(m.channel)
+	m.closed = true
+	return nil
+}
+
+type routerStateCallback struct {
+	started atomic.Int32
+	stopped atomic.Int32
+}
+
+func (t *routerStateCallback) Start(stopCh <-chan struct{}) {
+	t.started.Add(1)
+}
+
+func (t *routerStateCallback) Stop() {
+	t.stopped.Add(1)
+}
+
+func (t *routerStateCallback) Id() string {
+	return "router-state-callback"
+}
diff --git a/pkg/fs/watcher_test.go b/pkg/fs/watcher_test.go
deleted file mode 100644
index e62e6aa5..00000000
--- a/pkg/fs/watcher_test.go
+++ /dev/null
@@ -1,218 +0,0 @@
-package fs
-
-import (
-	"fmt"
-	"os"
-	"reflect"
-	"regexp"
-	"sync"
-	"testing"
-	"time"
-
-	"github.com/skupperproject/skupper/internal/utils"
-	"gotest.tools/v3/assert"
-)
-
-func TestFileWatcher(t *testing.T) {
-	tests := []struct {
-		name      string
-		fileCount int
-		actions   []idAction
-		expected  modificationResult
-	}{
-		{
-			name:      "watch-existing-files",
-			fileCount: 10,
-			actions: []idAction{
-				{
-					update: []int{0, 1, 2, 3, 4},
-					delete: []int{5, 6, 7, 8, 9},
-				},
-				{
-					create: []int{5, 6, 7, 8, 9},
-					update: []int{5, 6, 7, 8, 9},
-				},
-			},
-			expected: modificationResult{
-				created: map[int]int{5: 1, 6: 1, 7: 1, 8: 1, 9: 1},
-				updated: map[int]int{0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1},
-				deleted: map[int]int{5: 1, 6: 1, 7: 1, 8: 1, 9: 1},
-			},
-		},
-	}
-
-	for _, test := range tests {
-		t.Run(test.name, func(t *testing.T) {
-			stopCh := make(chan struct{})
-			defer close(stopCh)
-
-			// create the files first
-			fileIdMap, err := createTempFiles(test.fileCount)
-			assert.Assert(t, err)
-			defer func() {
-				assert.Assert(t, cleanupTemp(fileIdMap))
-			}()
-
-			watcher := &ModificationHandler{
-				fileMap: fileIdMap,
-				results: modificationResult{},
-			}
-
-			// defining watchers
-			w, err := NewWatcher()
-			assert.Assert(t, err)
-			w.Add(os.TempDir(), watcher, fileNamesFilters(fileIdMap)...)
-			w.Start(stopCh)
-
-			// process actions
-			assert.Assert(t, processActions(fileIdMap, os.TempDir(), test.actions))
-
-			// validate results
-			watcher.waitDone(test.expected)
-			watcher.mutex.Lock()
-			defer watcher.mutex.Unlock()
-			assert.DeepEqual(t, test.expected.created, watcher.results.created)
-			assert.DeepEqual(t, test.expected.updated, watcher.results.updated)
-			assert.DeepEqual(t, test.expected.deleted, watcher.results.deleted)
-		})
-	}
-}
-
-func createTempFiles(count int) (idMap, error) {
-	fileNames := idMap{}
-	for i := 0; i < count; i++ {
-		f, err := os.CreateTemp(os.TempDir(), "test-file.*")
-		if err != nil {
-			return nil, err
-		}
-		_ = f.Close()
-		fileNames[i] = f.Name()
-	}
-	return fileNames, nil
-}
-
-func fileNamesFilters(fileIdMap idMap) []*regexp.Regexp {
-	res := []*regexp.Regexp{}
-	for _, fileName := range fileIdMap {
-		res = append(res, regexp.MustCompile(fmt.Sprintf(`%s$`, fileName)))
-	}
-	return res
-}
-
-func getFileId(fileMap idMap, fileName string) int {
-	for id, name := range fileMap {
-		if name == fileName {
-			return id
-		}
-	}
-	return -1
-}
-
-type idMap map[int]string
-
-type modificationResult struct {
-	created map[int]int
-	updated map[int]int
-	deleted map[int]int
-}
-type idAction struct {
-	create []int
-	update []int
-	delete []int
-}
-
-type ModificationHandler struct {
-	fileMap idMap
-	results modificationResult
-	mutex   sync.Mutex
-}
-
-func (m *ModificationHandler) OnCreate(s string) {
-	m.mutex.Lock()
-	defer m.mutex.Unlock()
-	if m.results.created == nil {
-		m.results.created = map[int]int{}
-	}
-	m.results.created[getFileId(m.fileMap, s)] += 1
-}
-
-func (m *ModificationHandler) OnUpdate(s string) {
-	m.mutex.Lock()
-	defer m.mutex.Unlock()
-	if m.results.updated == nil {
-		m.results.updated = map[int]int{}
-	}
-	m.results.updated[getFileId(m.fileMap, s)] += 1
-}
-
-func (m *ModificationHandler) OnRemove(s string) {
-	m.mutex.Lock()
-	defer m.mutex.Unlock()
-	if m.results.deleted == nil {
-		m.results.deleted = map[int]int{}
-	}
-	m.results.deleted[getFileId(m.fileMap, s)] += 1
-}
-
-func (m *ModificationHandler) waitDone(expected modificationResult) {
-	_ = utils.RetryError(time.Millisecond*200, 10, func() error {
-		m.mutex.Lock()
-		defer m.mutex.Unlock()
-		if reflect.DeepEqual(m.results.created, expected.created) &&
-			reflect.DeepEqual(m.results.updated, expected.updated) &&
-			reflect.DeepEqual(m.results.deleted, expected.deleted) {
-			return nil
-		}
-		return fmt.Errorf("results to not match")
-	})
-}
-
-func processActions(fileIdMap idMap, baseDir string, actions []idAction) error {
-	var err error
-	var f *os.File
-
-	for _, action := range actions {
-		// creating files
-		for _, id := range action.create {
-			name := fileIdMap[id]
-			f, err = os.Create(name)
-			if err != nil {
-				return err
-			}
-			if err = f.Close(); err != nil {
-				return err
-			}
-		}
-		// updating files
-		for _, id := range action.update {
-			name := fileIdMap[id]
-			f, err = os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
-			if err != nil {
-				return err
-			}
-			if _, err = f.WriteString("sample-content\n"); err != nil {
-				return err
-			}
-			if err = f.Close(); err != nil {
-				return err
-			}
-		}
-		// deleting files
-		for _, id := range action.delete {
-			name := fileIdMap[id]
-			if err = os.RemoveAll(name); err != nil {
-				return err
-			}
-		}
-	}
-	return nil
-}
-
-func cleanupTemp(fileIdMap idMap) error {
-	for _, fileName := range fileIdMap {
-		if err := os.RemoveAll(fileName); err != nil && !os.IsNotExist(err) {
-			return err
-		}
-	}
-	return nil
-}
diff --git a/pkg/nonkube/api/site_state_test.go b/pkg/nonkube/api/site_state_test.go
index d718da50..96938bd8 100644
--- a/pkg/nonkube/api/site_state_test.go
+++ b/pkg/nonkube/api/site_state_test.go
@@ -6,9 +6,11 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/skupperproject/skupper/internal/network"
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1"
 	"gotest.tools/v3/assert"
 	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
@@ -109,11 +111,11 @@ func TestSiteState_ToRouterConfig(t *testing.T) {
 				rolesFound[string(listener.Role)] = true
 			}
 			assert.Equal(t, len(rolesFound), 3, "expecting normal, inter-router and edge, found: %s", rolesFound)
-			assert.Equal(t, len(routerConfig.Connectors), 1)
-			assert.Equal(t, len(routerConfig.SslProfiles), 6)
+			assert.Equal(t, len(routerConfig.Connectors), 2)
+			assert.Equal(t, len(routerConfig.SslProfiles), 7)
 			assert.Assert(t, strings.HasPrefix(routerConfig.SslProfiles["link-access-one"].CaCertFile, sslProfileBasePath))
 			assert.Assert(t, strings.HasPrefix(routerConfig.SslProfiles["link-one-profile"].CaCertFile, sslProfileBasePath))
-			assert.Assert(t, strings.HasPrefix(routerConfig.SslProfiles["local-access-one"].CaCertFile, sslProfileBasePath))
+			assert.Assert(t, strings.HasPrefix(routerConfig.SslProfiles["skupper-local"].CaCertFile, sslProfileBasePath))
 			assert.Equal(t, len(routerConfig.Bridges.TcpListeners), 2)
 			assert.Equal(t, len(routerConfig.Bridges.TcpConnectors), 1)
 			assert.Assert(t, routerConfig.SiteConfig != nil)
@@ -155,6 +157,7 @@ func TestMarshalSiteState(t *testing.T) {
 		"Certificate-link-access-one.yaml",
 		"Certificate-client-link-access-one.yaml",
 		"Secret-link-one-profile.yaml",
+		"ConfigMap-configmap.yaml",
 	}
 	for _, expectedFile := range expectedFiles {
 		info, err := os.Stat(path.Join(dir, expectedFile))
@@ -164,6 +167,111 @@ func TestMarshalSiteState(t *testing.T) {
 	}
 }
 
+func TestUpdateStatus(t *testing.T) {
+	networkStatus := fakeNetworkStatusInfo()
+	ss := fakeSiteState()
+	ss.Links["link-one"].Status.RemoteSiteName = "other-site-name"
+	ss.Links["link-one"].Status.RemoteSiteId = "other-site-id"
+	ss.UpdateStatus(networkStatus)
+	assert.Equal(t, ss.Site.Status.SitesInNetwork, 2)
+	assert.Equal(t, ss.Listeners["listener-one"].Status.HasMatchingConnector, true)
+	assert.Equal(t, ss.Listeners["listener-two"].Status.HasMatchingConnector, false)
+	assert.Equal(t, ss.Connectors["connector-one"].Status.HasMatchingListener, true)
+	assert.Equal(t, meta.IsStatusConditionTrue(ss.Links["link-one"].Status.Conditions, v2alpha1.CONDITION_TYPE_OPERATIONAL), true)
+	assert.Equal(t, meta.IsStatusConditionTrue(ss.Links["link-broken"].Status.Conditions, v2alpha1.CONDITION_TYPE_OPERATIONAL), false)
+}
+
+func fakeNetworkStatusInfo() network.NetworkStatusInfo {
+	return network.NetworkStatusInfo{
+		Addresses: []network.AddressInfo{
+			{
+				Name:           "listener-one-key",
+				Protocol:       "tcp",
+				ListenerCount:  1,
+				ConnectorCount: 1,
+			},
+			{
+				Name:           "connector-one-key",
+				Protocol:       "tcp",
+				ListenerCount:  1,
+				ConnectorCount: 1,
+			},
+		},
+		SiteStatus: []network.SiteStatusInfo{
+			{
+				Site: network.SiteInfo{
+					Identity:  "site-id",
+					Name:      "site-name",
+					Namespace: "default",
+					Platform:  "podman",
+					Version:   "version",
+				},
+				RouterStatus: []network.RouterStatusInfo{
+					{
+						Links: []network.LinkInfo{
+							{
+								Name:     "link-one",
+								LinkCost: 1,
+								Status:   "up",
+								Role:     "inter-router",
+								Peer:     "other-site-link-access-identity-inter-router",
+							},
+						},
+						AccessPoints: []network.RouterAccessInfo{
+							{Identity: "link-access-one-identity-inter-router"},
+							{Identity: "link-access-one-identity-edge"},
+						},
+						Listeners: []network.ListenerInfo{
+							{
+								Name:    "listener-one",
+								Address: "listener-one-key",
+							},
+							{
+								Name:    "listener-two",
+								Address: "listener-two-key",
+							},
+						},
+						Connectors: []network.ConnectorInfo{
+							{
+								DestHost: "connector-one-host",
+								Address:  "connector-one-key",
+							},
+						},
+					},
+				},
+			}, {
+				Site: network.SiteInfo{
+					Identity:  "other-site-id",
+					Name:      "other-site-name",
+					Namespace: "default",
+					Platform:  "linux",
+					Version:   "version",
+				},
+				RouterStatus: []network.RouterStatusInfo{
+					{
+						AccessPoints: []network.RouterAccessInfo{
+							{Identity: "other-site-link-access-identity-inter-router"},
+							{Identity: "other-site-link-access-identity-edge"},
+						},
+						Listeners: []network.ListenerInfo{
+							{
+								Name:    "listener-one",
+								Address: "connector-one-key",
+							},
+						},
+						Connectors: []network.ConnectorInfo{
+							{
+								DestHost: "connector-one-host",
+								Address:  "listener-one-key",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
 func fakeSiteState() *SiteState {
 	return &SiteState{
 		SiteId: "site-id",
@@ -236,9 +344,9 @@ func fakeSiteState() *SiteState {
 					},
 				},
 			},
-			"local-access-one": {
+			"skupper-local": {
 				ObjectMeta: metav1.ObjectMeta{
-					Name: "local-access-one",
+					Name: "skupper-local",
 				},
 				Spec: v2alpha1.RouterAccessSpec{
 					Roles: []v2alpha1.RouterAccessRole{
@@ -247,7 +355,7 @@ func fakeSiteState() *SiteState {
 							Port: 5671,
 						},
 					},
-					TlsCredentials: "local-access-one",
+					TlsCredentials: "skupper-local",
 					BindHost:       "127.0.0.1",
 					SubjectAlternativeNames: []string{
 						"localhost",
@@ -278,6 +386,27 @@ func fakeSiteState() *SiteState {
 					Cost:           1,
 				},
 			},
+			"link-broken": {
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "link-broken",
+				},
+				Spec: v2alpha1.LinkSpec{
+					Endpoints: []v2alpha1.Endpoint{
+						{
+							Name: "inter-router",
+							Host: "127.0.0.1",
+							Port: "55671",
+						},
+						{
+							Name: "edge",
+							Host: "127.0.0.1",
+							Port: "45671",
+						},
+					},
+					TlsCredentials: "link-broken",
+					Cost:           1,
+				},
+			},
 		},
 		Secrets: map[string]*corev1.Secret{
 			"link-one-profile": {
@@ -291,6 +420,16 @@ func fakeSiteState() *SiteState {
 				},
 			},
 		},
+		ConfigMaps: map[string]*corev1.ConfigMap{
+			"configmap": {
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "configmap",
+				},
+				Data: map[string]string{
+					"data": "data",
+				},
+			},
+		},
 		Claims:          make(map[string]*v2alpha1.AccessToken),
 		Certificates:    make(map[string]*v2alpha1.Certificate),
 		SecuredAccesses: make(map[string]*v2alpha1.SecuredAccess),
@@ -361,6 +500,7 @@ func assertNamespaceOnSiteState(t *testing.T, ss *SiteState, namespace string) {
 	assert.Assert(t, assertNamespaceOnMap(ss.Claims, namespace))
 	assert.Assert(t, assertNamespaceOnMap(ss.Certificates, namespace))
 	assert.Assert(t, assertNamespaceOnMap(ss.SecuredAccesses, namespace))
+	assert.Assert(t, assertNamespaceOnMap(ss.ConfigMaps, namespace))
 }
 
 func assertNamespaceOnMap[T metav1.Object](objMap map[string]T, namespace string) bool {