Repository: skupperproject/skupper (go)

Issue:
[v2] Collector Mishandles Attached Connectors
**Describe the bug**

With an attached connector, the collector correctly will add a "site-service" process to act as a placeholder for the real workload, but it is not associating network traffic with it, and the connector API endpoint references process IDs that do not exist.

**Expected**

Attached connectors should work like connectors using a host name instead of a selector. Flow traffic should be associated with the site-service process, and the connector api should correctly reference the site service as well.

Pull request description:
Improve Collector relations Connector to Processes
Fixes #1787

* Renamed the collector graph's ConnectorTarget to SiteHost to better capture its role in the relation graph.
* Fixed connector.Target() method so that when the process matching the connector.ProcessID is not found, it will fallback to looking for a process based on the connector's Site parent and DestHost in order to match with the inferred "site-server" processes.

Patch:
diff --git a/cmd/bootstrap/PROVIDED_CERTIFICATES.md b/cmd/bootstrap/PROVIDED_CERTIFICATES.md
index 60a0c35e..630d7eba 100644
--- a/cmd/bootstrap/PROVIDED_CERTIFICATES.md
+++ b/cmd/bootstrap/PROVIDED_CERTIFICATES.md
@@ -13,15 +13,14 @@ The certificates generated by Skupper are stored under the namespace home direct
 file system, using the following tree:
 
 ```shell
-runtime/certs/
-├── ca
-├── client
-└── server
+runtime/
+├── certs
+└── issuers
 ```
 
-As the CA `skupper-site-ca` is generated, it will be stored under the `runtime/certs/ca` path
-as a directory named `skupper-site-ca`. Inside a certificate folder, the following files are
-expected: `tls.key`, `tls.crt` and `ca.crt` (when dealing with a CA, `ca.crt` and `tls.crt`
+As the CA `skupper-site-ca` is generated, it will be stored under the `runtime/issuers` path
+as a directory named `skupper-site-ca`. Inside the issuers or certs folder, the following files are
+expected: `tls.key`, `tls.crt` and `ca.crt` (when dealing with a CA or issuer, `ca.crt` and `tls.crt`
 have the same content).
 
 If a `RouterAccess` is defined as part of the non kube site definition, Skupper will also
@@ -29,7 +28,7 @@ generate a server certificate that is valid for the provided `RouterAccess.spec.
 for each entry in the `RouterAccess.spec.subjectAlternativeName` list. The respective certificate
 is signed by the CA mentioned earlier (skupper-site-ca) and it will be named according to the
 provided `spec.tlsCredentials` field, or when it is omitted, the certificate will be named as the
-RouterAccess and stored as a directory under `runtime/certs/server`.
+RouterAccess and stored as a directory under `runtime/certs`.
 
 A set of static links are also created for the provided `RouterAccess.spec.bindHost` and for each
 entry in the `RouterAccess.spec.subjectAlternativeName` list as separate YAML files.
@@ -38,7 +37,7 @@ These static links are composed by a `Link` and a `Secret`, which is basically t
 signed by `skupper-site-ca`, that will be used by other sites to establish Skupper links. The client
 certificate is also named based on the RouterAccess `spec.tlsCredentials` field or, when it is omitted,
 the certificate will be named as the RouterAccess, prefixed with `client-` and stored under
-`runtime/certs/client`.
+`runtime/certs`.
 
 After a site bundle has been produced, it contains the whole site definition that can be installed
 at a remote location. Thus, all certificates and static links are already part of the bundle and no
@@ -49,9 +48,9 @@ new certificate is signed at the moment a bundle is installed.
 You can provide your own certificates to be used by Skupper for site linking.
 
 The provided certificates must be placed in a similar directory tree, as shown before,
-but its path under the namespace home is located at `input/certs` instead.
+but its path under the namespace home is located at `input/issuers` and `input/certs` instead.
 
-Certificates placed under this path will be used by Skupper primarily and
+Certificates placed under these paths will be used by Skupper primarily and
 all the other certificates expected by Skupper, will be generated when not provided. 
 
 Depending on your goals, some certificates should be supplied at certain phases,
@@ -108,12 +107,11 @@ certificates used for site linking, you can simply create the following structur
 the namespace home of your choice, for example:
 
 ```shell
-${HOME}/.local/share/skupper/namespaces/default/input/certs/
-└── ca
-    └── skupper-site-ca
-        ├── ca.crt
-        ├── tls.crt
-        └── tls.key
+${HOME}/.local/share/skupper/namespaces/default/input/issuers/
+└── skupper-site-ca
+    ├── ca.crt
+    ├── tls.crt
+    └── tls.key
 ```
 
 With that, if you bootstrap a site to run in the default namespace, the CA certificates above will be
@@ -138,16 +136,14 @@ must be provided under the namespace home of your choice, for example:
 
 ```shell
 ${HOME}/.local/share/skupper/namespaces/default/input/certs/
-├── client
-│   └── client-my-router-access
-│       ├── ca.crt
-│       ├── tls.crt
-│       └── tls.key
-└── server
-    └── my-router-access
-        ├── ca.crt
-        ├── tls.crt
-        └── tls.key
+├── client-my-router-access
+│   ├── ca.crt
+│   ├── tls.crt
+│   └── tls.key
+└── my-router-access
+    ├── ca.crt
+    ├── tls.crt
+    └── tls.key
 ```
 
 At bootstrap or bundle installation times, you should see a message saying that the
diff --git a/cmd/network-console-collector/internal/api/types_gen.go b/cmd/network-console-collector/internal/api/types_gen.go
index bf88b08d..d5afe9ef 100644
--- a/cmd/network-console-collector/internal/api/types_gen.go
+++ b/cmd/network-console-collector/internal/api/types_gen.go
@@ -222,6 +222,8 @@ type ConnectorRecord struct {
 	Parent    string `json:"parent"`
 	ProcessId string `json:"processId"`
 	Protocol  string `json:"protocol"`
+	SiteId    string `json:"siteId"`
+	SiteName  string `json:"siteName"`
 
 	// StartTime The creation time in microseconds of the record in Unix timestamp format. The value 0 means that the record is not terminated
 	StartTime uint64  `json:"startTime"`
@@ -338,6 +340,8 @@ type ListenerRecord struct {
 	Name     string `json:"name"`
 	Parent   string `json:"parent"`
 	Protocol string `json:"protocol"`
+	SiteId   string `json:"siteId"`
+	SiteName string `json:"siteName"`
 
 	// StartTime The creation time in microseconds of the record in Unix timestamp format. The value 0 means that the record is not terminated
 	StartTime uint64 `json:"startTime"`
diff --git a/cmd/network-console-collector/internal/collector/connections.go b/cmd/network-console-collector/internal/collector/connections.go
index cbe8bbd6..42c1f4f9 100644
--- a/cmd/network-console-collector/internal/collector/connections.go
+++ b/cmd/network-console-collector/internal/collector/connections.go
@@ -330,7 +330,7 @@ func (c *connectionManager) reconcile(state transportState) (ConnectionRecord, r
 	sourceSiteID := lRouterNode.Parent().ID()
 	sourceSiteHost := dref(record.SourceHost)
 	if sourceSiteID != "" && sourceSiteHost != "" {
-		sourceNode = c.graph.ConnectorTarget(ConnectorTargetID(sourceSiteID, sourceSiteHost)).Process()
+		sourceNode = c.graph.SiteHost(SiteHostID(sourceSiteID, sourceSiteHost)).Process()
 	}
 	sourceproc, ok := c.processAttrs(sourceNode.ID())
 	if !ok {
diff --git a/cmd/network-console-collector/internal/collector/graph.go b/cmd/network-console-collector/internal/collector/graph.go
index b8077d50..3ad5c94a 100644
--- a/cmd/network-console-collector/internal/collector/graph.go
+++ b/cmd/network-console-collector/internal/collector/graph.go
@@ -21,7 +21,7 @@ import (
 type Graph interface {
 	Address(id string) Address
 	Connector(id string) Connector
-	ConnectorTarget(id string) ConnectorTarget
+	SiteHost(id string) SiteHost
 	Link(id string) Link
 	Listener(id string) Listener
 	Process(id string) Process
@@ -69,8 +69,8 @@ func (g *graph) Listener(id string) Listener {
 func (g *graph) Address(id string) Address {
 	return vertexByType[Address](g.dag, id)
 }
-func (g *graph) ConnectorTarget(id string) ConnectorTarget {
-	return vertexByType[ConnectorTarget](g.dag, id)
+func (g *graph) SiteHost(id string) SiteHost {
+	return vertexByType[SiteHost](g.dag, id)
 }
 
 func vertexByType[T Node](dag *dag.DAG, id string) T {
@@ -142,8 +142,8 @@ func (g *graph) Reindex(in vanflow.Record) {
 		g.ensureParents(id, edges)
 
 		if siteID, sourceHost := record.Parent, record.SourceHost; siteID != nil && sourceHost != nil {
-			ctID := ConnectorTargetID(*siteID, *sourceHost)
-			g.dag.AddVertex(ConnectorTarget{g.newBase(ctID)})
+			ctID := SiteHostID(*siteID, *sourceHost)
+			g.dag.AddVertex(SiteHost{g.newBase(ctID)})
 			g.ensureParents(ctID, []Node{processVtx})
 		}
 	case vanflow.ConnectorRecord:
@@ -158,11 +158,12 @@ func (g *graph) Reindex(in vanflow.Record) {
 		}
 		if record.ProcessID != nil {
 			edges = append(edges, Process{g.newBase(*record.ProcessID)})
-		} else if record.DestHost != nil && record.Parent != nil {
+		}
+		if record.DestHost != nil && record.Parent != nil {
 			// TODO(ck) Fix race - if a connector came in before its router parent record we'd miss this relation
 			snode := vertexByType[Router](g.dag, *record.Parent).Parent()
 			if snode.IsKnown() {
-				edges = append(edges, ConnectorTarget{g.newBase(ConnectorTargetID(snode.ID(), *record.DestHost))})
+				edges = append(edges, SiteHost{g.newBase(SiteHostID(snode.ID(), *record.DestHost))})
 			}
 		}
 		g.ensureParents(id, edges)
@@ -336,8 +337,8 @@ func (n Listener) GetRecord() (record vanflow.ListenerRecord, found bool) {
 	return getrecord[vanflow.ListenerRecord](n)
 }
 
-func (n Listener) Parent() Router { return parentOfType[Router](n.dag, n.identity) }
-func (n Listener) Address() Node  { return parentOfType[RoutingKey](n.dag, n.identity).Parent() }
+func (n Listener) Parent() Router   { return parentOfType[Router](n.dag, n.identity) }
+func (n Listener) Address() Address { return parentOfType[RoutingKey](n.dag, n.identity).Parent() }
 
 type Connector struct {
 	baseNode
@@ -351,10 +352,11 @@ func (n Connector) Address() Address {
 
 func (n Connector) Target() Process {
 	target := parentOfType[Process](n.dag, n.identity)
-	if target.IsKnown() {
+	if _, found := target.GetRecord(); found {
 		return target
 	}
-	return parentOfType[ConnectorTarget](n.dag, n.identity).Process()
+
+	return parentOfType[SiteHost](n.dag, n.identity).Process()
 }
 
 type Process struct {
@@ -368,7 +370,7 @@ func (n Process) Parent() Site         { return parentOfType[Site](n.dag, n.iden
 func (n Process) Addresses() []Address { return nil }
 func (n Process) Connectors() []Connector {
 	connectors := childrenByType[Connector](n.dag, n.identity)
-	for _, target := range childrenByType[ConnectorTarget](n.dag, n.identity) {
+	for _, target := range childrenByType[SiteHost](n.dag, n.identity) {
 		for _, cn := range target.Connectors() {
 			if cn.IsKnown() {
 				connectors = append(connectors, cn)
@@ -392,18 +394,20 @@ func (n RoutingKey) Connectors() []Connector {
 }
 func (n RoutingKey) Listeners() []Listener { return childrenByType[Listener](n.dag, n.identity) }
 
-func ConnectorTargetID(site, host string) string {
+func SiteHostID(site, host string) string {
 	return fmt.Sprintf("%s:%s", site, host)
 }
 
-type ConnectorTarget struct {
+// SiteHost references a siteID+Host combo. Serves as a relation between
+// network traffic and processes.
+type SiteHost struct {
 	baseNode
 }
 
-func (n ConnectorTarget) Connectors() []Connector {
+func (n SiteHost) Connectors() []Connector {
 	return childrenByType[Connector](n.dag, n.identity)
 }
-func (n ConnectorTarget) Process() Process { return parentOfType[Process](n.dag, n.identity) }
+func (n SiteHost) Process() Process { return parentOfType[Process](n.dag, n.identity) }
 
 type Address struct {
 	baseNode
diff --git a/cmd/network-console-collector/internal/server/query.go b/cmd/network-console-collector/internal/server/query.go
index 8ae7f309..ea352b8a 100644
--- a/cmd/network-console-collector/internal/server/query.go
+++ b/cmd/network-console-collector/internal/server/query.go
@@ -181,6 +181,16 @@ func (m fieldIndex[T]) Compare(x, y T) int {
 	case reflect.Int64:
 		xx, yy := vx.Int(), vy.Int()
 		return int(xx - yy)
+	case reflect.Bool:
+		xx, yy := vx.Bool(), vy.Bool()
+		switch {
+		case xx == yy:
+			return 0
+		case xx && !yy:
+			return 1
+		default:
+			return -1
+		}
 	}
 	return 0
 }
diff --git a/cmd/network-console-collector/internal/server/views/views.go b/cmd/network-console-collector/internal/server/views/views.go
index 2c622c08..c5ff952f 100644
--- a/cmd/network-console-collector/internal/server/views/views.go
+++ b/cmd/network-console-collector/internal/server/views/views.go
@@ -150,6 +150,10 @@ func NewListenerProvider(graph collector.Graph) func(vanflow.ListenerRecord) api
 		if addressID := node.Address().ID(); addressID != "" {
 			out.AddressId = &addressID
 		}
+		if site, ok := node.Parent().Parent().GetRecord(); ok {
+			out.SiteId = site.ID
+			setOpt(&out.SiteName, site.Name)
+		}
 		return out
 	}
 }
@@ -163,6 +167,8 @@ func defaultListener(id string) api.ListenerRecord {
 		Address:  unknownStr,
 		DestHost: unknownStr,
 		DestPort: unknownStr,
+		SiteId:   unknownStr,
+		SiteName: unknownStr,
 	}
 }
 
@@ -199,6 +205,11 @@ func NewConnectorProvider(graph collector.Graph) func(vanflow.ConnectorRecord) a
 		}
 		if proc, ok := node.Target().GetRecord(); ok {
 			out.Target = proc.Name
+			out.ProcessId = proc.ID
+		}
+		if site, ok := node.Parent().Parent().GetRecord(); ok {
+			out.SiteId = site.ID
+			setOpt(&out.SiteName, site.Name)
 		}
 		return out
 	}
@@ -213,6 +224,8 @@ func defaultConnector(id string) api.ConnectorRecord {
 		Address:  unknownStr,
 		DestHost: unknownStr,
 		DestPort: unknownStr,
+		SiteId:   unknownStr,
+		SiteName: unknownStr,
 	}
 }
 
diff --git a/cmd/network-console-collector/spec/openapi.yaml b/cmd/network-console-collector/spec/openapi.yaml
index a335c86c..cf6424ee 100644
--- a/cmd/network-console-collector/spec/openapi.yaml
+++ b/cmd/network-console-collector/spec/openapi.yaml
@@ -976,6 +976,8 @@ components:
             - destPort
             - protocol
             - address
+            - siteId
+            - siteName
           properties:
             parent:
               type: string
@@ -991,6 +993,10 @@ components:
               type: string
             addressId:
               type: string
+            siteId:
+              type: string
+            siteName:
+              type: string
     ConnectorRecord:
       allOf:
         - $ref: '#/components/schemas/baseRecord'
@@ -1004,6 +1010,8 @@ components:
             - address
             - processId
             - target
+            - siteId
+            - siteName
           properties:
             parent:
               type: string
@@ -1024,6 +1032,10 @@ components:
             target:
               type: string
               nullable: true
+            siteId:
+              type: string
+            siteName:
+              type: string
     AddressRecord:
       allOf:
         - $ref: '#/components/schemas/baseRecord'
diff --git a/internal/nonkube/bundle/install.sh.template b/internal/nonkube/bundle/install.sh.template
index f3f12cc1..ca36e1de 100644
--- a/internal/nonkube/bundle/install.sh.template
+++ b/internal/nonkube/bundle/install.sh.template
@@ -313,41 +313,33 @@ list_valid_certificates() {
     done
 }
 
-handle_provided_certificates() {
-    export USER_CERTIFICATES_PATH="${NAMESPACES_PATH}/${NAMESPACE}/input/certs"
-    [ ! -d "${USER_CERTIFICATES_PATH}" ] && return
-    cas=""
-    clients=""
-    servers=""
-    if [ -d "${USER_CERTIFICATES_PATH}/ca" ]; then
-        cas=$(list_valid_certificates "${USER_CERTIFICATES_PATH}/ca")
-    fi
-    if [ -d "${USER_CERTIFICATES_PATH}/client" ]; then
-        clients=$(list_valid_certificates "${USER_CERTIFICATES_PATH}/client")
-    fi
-    if [ -d "${USER_CERTIFICATES_PATH}/server" ]; then
-        servers=$(list_valid_certificates "${USER_CERTIFICATES_PATH}/server")
-    fi
-    if [ -z "${cas}" ] && [ -z "${clients}" ] && [ -z "${servers}" ]; then
+handle_provided_issuers() {
+    export USER_ISSUERS_PATH="${NAMESPACES_PATH}/${NAMESPACE}/input/issuers"
+    [ ! -d "${USER_ISSUERS_PATH}" ] && return
+    cas=$(list_valid_certificates "${USER_ISSUERS_PATH}")
+    if [ -z "${cas}" ]; then
         return
     fi
-    echo "User provided certificates found:"
+    echo "User provided issuers found:"
     if [ -n "${cas}" ]; then
-        echo "- CA certificates:"
         for ca in ${cas}; do
             echo "  - ${ca}"
         done
     fi
-    if [ -n "${clients}" ]; then
-        echo "- Client certificates:"
-        for client in ${clients}; do
-            echo "  - ${client}"
-        done
+    cp -r "${USER_ISSUERS_PATH}"/* "./${SOURCE_NAMESPACE}/runtime/issuers"
+}
+
+handle_provided_certificates() {
+    export USER_CERTIFICATES_PATH="${NAMESPACES_PATH}/${NAMESPACE}/input/certs"
+    [ ! -d "${USER_CERTIFICATES_PATH}" ] && return
+    certs=$(list_valid_certificates "${USER_CERTIFICATES_PATH}")
+    if [ -z "${certs}" ]; then
+        return
     fi
-    if [ -n "${servers}" ]; then
-        echo "- Server certificates:"
-        for server in ${servers}; do
-            echo "  - ${server}"
+    echo "User provided certificates found:"
+    if [ -n "${certs}" ]; then
+        for cert in ${certs}; do
+            echo "  - ${cert}"
         done
     fi
     cp -r "${USER_CERTIFICATES_PATH}"/* "./${SOURCE_NAMESPACE}/runtime/certs"
@@ -364,7 +356,7 @@ update_static_links() {
         ra_name=$(grep name: "${ra}" | grep -v '\- name:' | awk '{print $NF}' | sed 's/"//g')
         tls_credential=$(grep tlsCredentials: "${ra}" | awk '{print $NF}' | sed 's/"//g')
         bind_host=$(grep bindHost: "${ra}" | awk '{print $NF}' | sed 's/"//g')
-        client_cert_path="${USER_CERTIFICATES_PATH}/client/client-${tls_credential:-${ra_name}}"
+        client_cert_path="${USER_CERTIFICATES_PATH}/client-${tls_credential:-${ra_name}}"
 
         # no client certificate provided, skip
         [ ! -d "${client_cert_path}" ] && continue
@@ -409,7 +401,7 @@ generate_static_links_for_sans() {
         bind_host=$(grep bindHost: "${ra}" | awk '{print $NF}' | sed 's/"//g')
 
         # discovering SANS
-        server_cert="${DIR}/runtime/certs/server/${tls_credential:-${ra_name}}/tls.crt"
+        server_cert="${DIR}/runtime/certs/${tls_credential:-${ra_name}}/tls.crt"
         [ ! -f "${server_cert}" ] && continue
         subjects="$(openssl x509 -noout -ext subjectAltName -in "${server_cert}" |grep -v 'Subject Alternative Name:' | \
                     sed 's/ //g;s/,/\n/g' |  awk -F: '{if ($1 == "DNS" || $1 == "IPAddress") print $2}')"
@@ -436,6 +428,7 @@ main() {
         return
     fi
 
+    handle_provided_issuers
     handle_provided_certificates
 
     if ${DUMP_TOKENS}; then
diff --git a/pkg/nonkube/api/environment.go b/pkg/nonkube/api/environment.go
index 66951036..0b1fa705 100644
--- a/pkg/nonkube/api/environment.go
+++ b/pkg/nonkube/api/environment.go
@@ -12,22 +12,17 @@ type InternalPath string
 type InternalPathProvider func(namespace string, internalPath InternalPath) string
 
 const (
-	InputCertificatesBasePath   InternalPath = "input/certs"
-	InputCertificatesCaPath     InternalPath = "input/certs/ca"
-	InputCertificatesClientPath InternalPath = "input/certs/client"
-	InputCertificatesServerPath InternalPath = "input/certs/server"
-	InputSiteStatePath          InternalPath = "input/resources"
-	RouterConfigPath            InternalPath = "runtime/router"
-	CertificatesBasePath        InternalPath = "runtime/certs"
-	CertificatesCaPath          InternalPath = "runtime/certs/ca"
-	CertificatesClientPath      InternalPath = "runtime/certs/client"
-	CertificatesServerPath      InternalPath = "runtime/certs/server"
-	CertificatesLinkPath        InternalPath = "runtime/certs/link"
-	RuntimePath                 InternalPath = "runtime"
-	RuntimeSiteStatePath        InternalPath = "runtime/resources"
-	RuntimeTokenPath            InternalPath = "runtime/links"
-	LoadedSiteStatePath         InternalPath = "internal/snapshot"
-	ScriptsPath                 InternalPath = "internal/scripts"
+	InputIssuersPath      InternalPath = "input/issuers"
+	InputCertificatesPath InternalPath = "input/certs"
+	InputSiteStatePath    InternalPath = "input/resources"
+	RouterConfigPath      InternalPath = "runtime/router"
+	CertificatesPath      InternalPath = "runtime/certs"
+	IssuersPath           InternalPath = "runtime/issuers"
+	RuntimePath           InternalPath = "runtime"
+	RuntimeSiteStatePath  InternalPath = "runtime/resources"
+	RuntimeTokenPath      InternalPath = "runtime/links"
+	LoadedSiteStatePath   InternalPath = "internal/snapshot"
+	ScriptsPath           InternalPath = "internal/scripts"
 )
 
 type IdGetter func() int
diff --git a/pkg/nonkube/api/site_state.go b/pkg/nonkube/api/site_state.go
index 4d26b847..8a6522ce 100644
--- a/pkg/nonkube/api/site_state.go
+++ b/pkg/nonkube/api/site_state.go
@@ -239,7 +239,7 @@ func (s *SiteState) linkAccessMap() site.RouterAccessMap {
 func (s *SiteState) linkMap(sslProfileBasePath string) site.LinkMap {
 	linkMap := site.LinkMap{}
 	for name, link := range s.Links {
-		siteLink := site.NewLink(name, path.Join(sslProfileBasePath, string(CertificatesLinkPath)))
+		siteLink := site.NewLink(name, path.Join(sslProfileBasePath, string(CertificatesPath)))
 		link.SetConfigured(nil)
 		siteLink.Update(link)
 		linkMap[name] = siteLink
@@ -248,7 +248,7 @@ func (s *SiteState) linkMap(sslProfileBasePath string) site.LinkMap {
 }
 
 func (s *SiteState) bindings(sslProfileBasePath string) *site.Bindings {
-	b := site.NewBindings(path.Join(sslProfileBasePath, "certificates/bindings"))
+	b := site.NewBindings(path.Join(sslProfileBasePath, string(CertificatesPath)))
 	for name, connector := range s.Connectors {
 		connector.SetConfigured(nil)
 		_ = b.UpdateConnector(name, connector)
@@ -289,7 +289,7 @@ func (s *SiteState) ToRouterConfig(sslProfileBasePath string, platform string) q
 		routerConfig.SiteConfig.Platform = "{{.Platform}}"
 	}
 	// LinkAccess
-	s.linkAccessMap().DesiredConfig(nil, path.Join(sslProfileBasePath, string(CertificatesServerPath))).Apply(&routerConfig)
+	s.linkAccessMap().DesiredConfig(nil, path.Join(sslProfileBasePath, string(CertificatesPath))).Apply(&routerConfig)
 	// Link
 	s.linkMap(sslProfileBasePath).Apply(&routerConfig)
 	// Bindings
diff --git a/pkg/nonkube/bundle/site_state_renderer.go b/pkg/nonkube/bundle/site_state_renderer.go
index 37efe815..c3f8e728 100644
--- a/pkg/nonkube/bundle/site_state_renderer.go
+++ b/pkg/nonkube/bundle/site_state_renderer.go
@@ -117,7 +117,7 @@ func (s *SiteStateRenderer) prepareContainers() error {
 				Options:     []string{"z"},
 			},
 			{
-				Source:      path.Join("{{.NamespacesPath}}", "{{.Namespace}}", string(api.CertificatesBasePath)),
+				Source:      path.Join("{{.NamespacesPath}}", "{{.Namespace}}", string(api.CertificatesPath)),
 				Destination: "/etc/skupper-router/runtime/certs",
 				Options:     []string{"z"},
 			},
diff --git a/pkg/nonkube/common/fs_config_renderer.go b/pkg/nonkube/common/fs_config_renderer.go
index d0745792..2163ae62 100644
--- a/pkg/nonkube/common/fs_config_renderer.go
+++ b/pkg/nonkube/common/fs_config_renderer.go
@@ -22,13 +22,10 @@ import (
 var (
 	configurationDirectories = []api.InternalPath{
 		api.RouterConfigPath,
-		api.CertificatesCaPath,
-		api.CertificatesClientPath,
-		api.CertificatesServerPath,
-		api.CertificatesLinkPath,
-		api.InputCertificatesCaPath,
-		api.InputCertificatesClientPath,
-		api.InputCertificatesServerPath,
+		api.IssuersPath,
+		api.CertificatesPath,
+		api.InputIssuersPath,
+		api.InputCertificatesPath,
 		api.InputSiteStatePath,
 		api.LoadedSiteStatePath,
 		api.RuntimeSiteStatePath,
@@ -37,9 +34,7 @@ var (
 	}
 	reloadDirectories = []api.InternalPath{
 		api.RouterConfigPath,
-		api.CertificatesClientPath,
-		api.CertificatesServerPath,
-		api.CertificatesLinkPath,
+		api.CertificatesPath,
 		api.RuntimeSiteStatePath,
 		api.RuntimeTokenPath,
 		api.ScriptsPath,
@@ -52,6 +47,7 @@ type InputPathType string
 const (
 	InputPathResources InputPathType = "resources"
 	InputPathCerts     InputPathType = "certs"
+	InputPathIssuers   InputPathType = "issuers"
 )
 
 const (
@@ -384,7 +380,7 @@ func (c *FileSystemConfigurationRenderer) createTlsCertificates(siteState *api.S
 			secret = *userCaSecret
 			fmt.Printf("-> User provided CA found: %s\n", name)
 		}
-		caPath := path.Join(outputPath, string(api.CertificatesCaPath), name)
+		caPath := path.Join(outputPath, string(api.IssuersPath), name)
 		err = writeSecretFilesIgnore(caPath, &secret, ignoreExisting)
 		if err != nil {
 			return err
@@ -420,7 +416,7 @@ func (c *FileSystemConfigurationRenderer) createTlsCertificates(siteState *api.S
 			secret = *userSecret
 			fmt.Printf("-> User provided %s certificate found: %s\n", purpose, name)
 		}
-		certPath := path.Join(outputPath, string(api.CertificatesBasePath), purpose, name)
+		certPath := path.Join(outputPath, string(api.CertificatesPath), name)
 		err = writeSecretFiles(certPath, &secret)
 		if err != nil {
 			return err
@@ -433,7 +429,7 @@ func (c *FileSystemConfigurationRenderer) createTlsCertificates(siteState *api.S
 		if !ok {
 			return fmt.Errorf("secret %s not found", secretName)
 		}
-		certPath := path.Join(outputPath, string(api.CertificatesLinkPath), secretName+"-profile")
+		certPath := path.Join(outputPath, string(api.CertificatesPath), secretName+"-profile")
 		err = writeSecretFiles(certPath, secret)
 		if err != nil {
 			return err
@@ -484,12 +480,18 @@ func (c *FileSystemConfigurationRenderer) loadClientSecret(siteState *api.SiteSt
 }
 
 func (c *FileSystemConfigurationRenderer) loadCertAsSecret(siteState *api.SiteState, purpose, name string) (*corev1.Secret, error) {
-	outputPath := path.Join(c.GetOutputPath(siteState), string(api.CertificatesBasePath))
+	outputPath := path.Join(c.GetOutputPath(siteState), string(api.CertificatesPath))
+	if purpose == "ca" {
+		outputPath = path.Join(c.GetOutputPath(siteState), string(api.IssuersPath))
+	}
 	return c.loadCertAsSecretFrom(outputPath, siteState, purpose, name)
 }
 
 func (c *FileSystemConfigurationRenderer) loadUserCertAsSecret(siteState *api.SiteState, purpose, name string) (*corev1.Secret, error) {
 	userInputPath := path.Join(c.GetInputPath(siteState, InputPathCerts))
+	if purpose == "ca" {
+		userInputPath = path.Join(c.GetInputPath(siteState, InputPathIssuers))
+	}
 	secret, err := c.loadCertAsSecretFrom(userInputPath, siteState, purpose, name)
 	if err != nil {
 		if os.IsNotExist(err) {
@@ -514,7 +516,7 @@ func (c *FileSystemConfigurationRenderer) loadUserCertAsSecret(siteState *api.Si
 }
 
 func (c *FileSystemConfigurationRenderer) loadCertAsSecretFrom(basePath string, siteState *api.SiteState, purpose, name string) (*corev1.Secret, error) {
-	certPath := path.Join(basePath, purpose, name)
+	certPath := path.Join(basePath, name)
 	var secret *corev1.Secret
 	certDir, err := os.Open(certPath)
 	if err != nil {
diff --git a/pkg/nonkube/compat/site_state_renderer.go b/pkg/nonkube/compat/site_state_renderer.go
index cab9b754..f15bc349 100644
--- a/pkg/nonkube/compat/site_state_renderer.go
+++ b/pkg/nonkube/compat/site_state_renderer.go
@@ -217,7 +217,7 @@ func (s *SiteStateRenderer) prepareContainers() error {
 				Options:     []string{"z"},
 			},
 			{
-				Source:      path.Join(siteConfigPath, string(api.CertificatesBasePath)),
+				Source:      path.Join(siteConfigPath, string(api.CertificatesPath)),
 				Destination: "/etc/skupper-router/runtime/certs",
 				Options:     []string{"z"},
 			},


Test patch:
diff --git a/cmd/network-console-collector/internal/collector/graph_test.go b/cmd/network-console-collector/internal/collector/graph_test.go
new file mode 100644
index 00000000..39e3b894
--- /dev/null
+++ b/cmd/network-console-collector/internal/collector/graph_test.go
@@ -0,0 +1,105 @@
+package collector
+
+import (
+	"testing"
+
+	"github.com/skupperproject/skupper/pkg/vanflow"
+	"github.com/skupperproject/skupper/pkg/vanflow/store"
+	"gotest.tools/assert"
+)
+
+func TestGraphRelations(t *testing.T) {
+	testCases := []struct {
+		Name    string
+		Records []vanflow.Record
+		Expect  func(t *testing.T, graf Graph)
+	}{
+		{
+			Name: "Connector::processID::Process",
+			Records: []vanflow.Record{
+				vanflow.ConnectorRecord{BaseRecord: vanflow.NewBase("c1"), ProcessID: ptrTo("p1")},
+				vanflow.ProcessRecord{BaseRecord: vanflow.NewBase("p1")},
+			},
+			Expect: func(t *testing.T, graf Graph) {
+				assert.Equal(t, graf.Connector("c1").Target().ID(), "p1")
+
+			},
+		}, {
+			Name: "Connector::SiteHost::Process",
+			Records: []vanflow.Record{
+				vanflow.SiteRecord{BaseRecord: vanflow.NewBase("s1")},
+				vanflow.RouterRecord{BaseRecord: vanflow.NewBase("r1"), Parent: ptrTo("s1")},
+				vanflow.ConnectorRecord{
+					BaseRecord: vanflow.NewBase("c1"),
+					DestHost:   ptrTo("10.x.x.1"),
+					Parent:     ptrTo("r1"), Protocol: ptrTo("test")},
+				vanflow.ProcessRecord{BaseRecord: vanflow.NewBase("p1"),
+					Parent: ptrTo("s1"), SourceHost: ptrTo("10.x.x.1")},
+			},
+			Expect: func(t *testing.T, graf Graph) {
+				assert.Equal(t, graf.Connector("c1").Target().ID(), "p1")
+				assert.Equal(t, graf.SiteHost(SiteHostID("s1", "10.x.x.1")).Process().ID(), "p1")
+				assert.Equal(t, len(graf.SiteHost(SiteHostID("s1", "10.x.x.1")).Connectors()), 1)
+			},
+		}, {
+			Name: "Connector::SiteHost::Process Ignores ProcessID",
+			Records: []vanflow.Record{
+				vanflow.SiteRecord{BaseRecord: vanflow.NewBase("s1")},
+				vanflow.RouterRecord{BaseRecord: vanflow.NewBase("r1"), Parent: ptrTo("s1")},
+				vanflow.ConnectorRecord{
+					BaseRecord: vanflow.NewBase("c1"),
+					ProcessID:  ptrTo("IGNORE_ME"),
+					DestHost:   ptrTo("10.x.x.1"),
+					Parent:     ptrTo("r1"), Protocol: ptrTo("test")},
+				vanflow.ProcessRecord{BaseRecord: vanflow.NewBase("p1"),
+					Parent: ptrTo("s1"), SourceHost: ptrTo("10.x.x.1")},
+			},
+			Expect: func(t *testing.T, graf Graph) {
+				assert.Equal(t, graf.Connector("c1").Target().ID(), "p1")
+				assert.Equal(t, graf.SiteHost(SiteHostID("s1", "10.x.x.1")).Process().ID(), "p1")
+				assert.Equal(t, len(graf.SiteHost(SiteHostID("s1", "10.x.x.1")).Connectors()), 1)
+			},
+		}, {
+			Name: "Listener::SiteHost::Process",
+			Records: []vanflow.Record{
+				vanflow.SiteRecord{BaseRecord: vanflow.NewBase("s1")},
+				vanflow.RouterRecord{BaseRecord: vanflow.NewBase("r1"), Parent: ptrTo("s1")},
+				vanflow.ListenerRecord{
+					BaseRecord: vanflow.NewBase("l1"),
+					Parent:     ptrTo("r1"), Protocol: ptrTo("test")},
+				vanflow.ProcessRecord{BaseRecord: vanflow.NewBase("p1"),
+					Parent: ptrTo("s1"), SourceHost: ptrTo("10.x.x.1")},
+				vanflow.ProcessRecord{BaseRecord: vanflow.NewBase("p2"),
+					Parent: ptrTo("OTHER_SITE"), SourceHost: ptrTo("10.x.x.1")},
+			},
+			Expect: func(t *testing.T, graf Graph) {
+				siteID := graf.Listener("l1").Parent().Parent().ID()
+				assert.Equal(t, siteID, "s1")
+				proc, ok := graf.SiteHost(SiteHostID(siteID, "10.x.x.1")).Process().GetRecord()
+				assert.Assert(t, ok)
+				assert.Equal(t, proc.ID, "p1")
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.Name, func(t *testing.T) {
+			stor := store.NewSyncMapStore(store.SyncMapStoreConfig{Indexers: RecordIndexers()})
+			stor.Replace(wrapRecords(tc.Records...))
+			graf := NewGraph(stor).(*graph)
+			graf.Reset()
+			for _, r := range stor.List() {
+				graf.Reindex(r.Record)
+			}
+			tc.Expect(t, graf)
+		})
+	}
+}
+
+func wrapRecords(records ...vanflow.Record) []store.Entry {
+	entries := make([]store.Entry, len(records))
+	for i := range records {
+		entries[i].Record = records[i]
+	}
+	return entries
+}
diff --git a/cmd/network-console-collector/internal/server/address_test.go b/cmd/network-console-collector/internal/server/address_test.go
index a4ae775d..19fe2804 100644
--- a/cmd/network-console-collector/internal/server/address_test.go
+++ b/cmd/network-console-collector/internal/server/address_test.go
@@ -23,6 +23,7 @@ func TestAddresses(t *testing.T) {
 	begin := time.Now()
 	testcases := []struct {
 		Records              []store.Entry
+		Parameters           map[string][]string
 		ExpectOK             bool
 		ExpectCount          int
 		ExpectTimeRangeCount int
@@ -62,6 +63,42 @@ func TestAddresses(t *testing.T) {
 				t.Error("expected to find address record for addr-1")
 			},
 		},
+		{
+			Records: wrapRecords(
+				collector.AddressRecord{ID: "addr-1", Name: "pizza", Protocol: "tcp", Start: begin},
+				collector.AddressRecord{ID: "addr-2", Name: "icecream", Protocol: "tcp", Start: begin},
+				collector.AddressRecord{ID: "addr-3", Name: "waffles", Protocol: "tcp", Start: begin},
+				vanflow.ConnectorRecord{BaseRecord: vanflow.NewBase("c1"), Address: ptrTo("pizza"), Protocol: ptrTo("tcp")},
+				vanflow.ListenerRecord{BaseRecord: vanflow.NewBase("l1"), Address: ptrTo("pizza"), Protocol: ptrTo("tcp")},
+			),
+			Parameters: map[string][]string{
+				"sortBy": {"isBound.asc"},
+			},
+			ExpectOK:    true,
+			ExpectCount: 3,
+			ExpectResults: func(t *testing.T, results []api.AddressRecord) {
+				assert.Equal(t, results[2].Identity, "addr-1", "Expected addr-1 to be last with sortBy isBound.asc")
+				assert.Equal(t, results[2].IsBound, true, "Expected addr-1 to be bound")
+			},
+		},
+		{
+			Records: wrapRecords(
+				collector.AddressRecord{ID: "addr-1", Name: "pizza", Protocol: "tcp", Start: begin},
+				collector.AddressRecord{ID: "addr-2", Name: "icecream", Protocol: "tcp", Start: begin},
+				collector.AddressRecord{ID: "addr-3", Name: "waffles", Protocol: "tcp", Start: begin},
+				vanflow.ConnectorRecord{BaseRecord: vanflow.NewBase("c1"), Address: ptrTo("pizza"), Protocol: ptrTo("tcp")},
+				vanflow.ListenerRecord{BaseRecord: vanflow.NewBase("l1"), Address: ptrTo("pizza"), Protocol: ptrTo("tcp")},
+			),
+			Parameters: map[string][]string{
+				"sortBy": {"isBound.desc"},
+			},
+			ExpectOK:    true,
+			ExpectCount: 3,
+			ExpectResults: func(t *testing.T, results []api.AddressRecord) {
+				assert.Equal(t, results[0].Identity, "addr-1", "Expected addr-1 to be first with sortBy isBound.asc")
+				assert.Equal(t, results[0].IsBound, true, "Expected addr-1 to be bound")
+			},
+		},
 	}
 
 	for _, tc := range testcases {
@@ -71,7 +108,7 @@ func TestAddresses(t *testing.T) {
 			for _, r := range tc.Records {
 				graph.(reset).Reindex(r.Record)
 			}
-			resp, err := c.AddressesWithResponse(context.TODO())
+			resp, err := c.AddressesWithResponse(context.TODO(), WithParameters(tc.Parameters))
 			assert.Check(t, err)
 			if tc.ExpectOK {
 				assert.Equal(t, resp.StatusCode(), 200)
diff --git a/cmd/network-console-collector/internal/server/connections_test.go b/cmd/network-console-collector/internal/server/connections_test.go
index c9b52198..6ab421e7 100644
--- a/cmd/network-console-collector/internal/server/connections_test.go
+++ b/cmd/network-console-collector/internal/server/connections_test.go
@@ -40,15 +40,7 @@ func TestConnections(t *testing.T) {
 		t0.Add(4 * time.Minute),
 	}
 
-	testcases := []struct {
-		Records              []store.Entry
-		Flows                []store.Entry
-		Parameters           map[string][]string
-		ExpectOK             bool
-		ExpectCount          int
-		ExpectTimeRangeCount int
-		ExpectResults        func(t *testing.T, results []api.ConnectionRecord)
-	}{
+	testcases := []collectionTestCase[api.ConnectionRecord]{
 		{ExpectOK: true},
 		{
 			Records: wrapRecords(
@@ -159,7 +151,7 @@ func TestConnections(t *testing.T) {
 			for _, r := range tc.Records {
 				graph.(reset).Reindex(r.Record)
 			}
-			resp, err := c.ConnectionsWithResponse(context.TODO(), WithParameters(tc.Parameters))
+			resp, err := c.ConnectionsWithResponse(context.TODO(), withParameters(tc.Parameters))
 			assert.Check(t, err)
 			if tc.ExpectOK {
 				assert.Equal(t, resp.StatusCode(), 200)
diff --git a/cmd/network-console-collector/internal/server/connector_test.go b/cmd/network-console-collector/internal/server/connector_test.go
new file mode 100644
index 00000000..0c82f135
--- /dev/null
+++ b/cmd/network-console-collector/internal/server/connector_test.go
@@ -0,0 +1,124 @@
+package server
+
+import (
+	"context"
+	"log/slog"
+	"testing"
+
+	"github.com/skupperproject/skupper/cmd/network-console-collector/internal/api"
+	"github.com/skupperproject/skupper/cmd/network-console-collector/internal/collector"
+	"github.com/skupperproject/skupper/pkg/vanflow"
+	"github.com/skupperproject/skupper/pkg/vanflow/store"
+	"gotest.tools/assert"
+)
+
+func TestConnectors(t *testing.T) {
+	tlog := slog.Default()
+	stor := store.NewSyncMapStore(store.SyncMapStoreConfig{Indexers: collector.RecordIndexers()})
+	graph := collector.NewGraph(stor)
+	srv, c := requireTestClient(t, New(tlog, stor, graph))
+	defer srv.Close()
+	testcases := []collectionTestCase[api.ConnectorRecord]{
+		{ExpectOK: true},
+		{
+			ExpectOK: true,
+			Records: wrapRecords( // totally empty
+				vanflow.ConnectorRecord{
+					BaseRecord: vanflow.NewBase("c1"),
+				},
+			),
+			ExpectCount: 1,
+			ExpectResults: func(t *testing.T, results []api.ConnectorRecord) {
+				assert.Equal(t, len(results), 1)
+				result := results[0]
+				assert.DeepEqual(t, result, api.ConnectorRecord{
+					Identity: "c1", Name: "unknown", Parent: "unknown",
+					SiteName: "unknown", SiteId: "unknown",
+					Protocol: "unknown", Address: "unknown", DestHost: "unknown",
+					ProcessId: "", DestPort: "unknown",
+				})
+			},
+		}, {
+			Records: wrapRecords(
+				vanflow.SiteRecord{BaseRecord: vanflow.NewBase("s1"), Name: ptrTo("one")},
+				vanflow.RouterRecord{BaseRecord: vanflow.NewBase("r1"), Parent: ptrTo("s1")},
+				vanflow.ConnectorRecord{
+					BaseRecord: vanflow.NewBase("c1"), Name: ptrTo("conn-one"),
+					Parent:    ptrTo("r1"),
+					ProcessID: ptrTo("p1"),
+					Protocol:  ptrTo("tp"),
+					Address:   ptrTo("trombone"),
+					DestHost:  ptrTo("10.0.four.two"), DestPort: ptrTo("amqp"),
+				},
+				vanflow.ProcessRecord{BaseRecord: vanflow.NewBase("p1"), Name: ptrTo("proc-one")},
+				vanflow.ListenerRecord{
+					BaseRecord: vanflow.NewBase("l1"),
+					Protocol:   ptrTo("tp"),
+					Address:    ptrTo("trombone"),
+				},
+			),
+			ExpectOK:    true,
+			ExpectCount: 1,
+			ExpectResults: func(t *testing.T, results []api.ConnectorRecord) {
+				assert.Equal(t, len(results), 1)
+				result := results[0]
+				assert.DeepEqual(t, result, api.ConnectorRecord{
+					Identity: "c1", Name: "conn-one", Parent: "r1",
+					SiteName: "one", SiteId: "s1",
+					Protocol: "tp", Address: "trombone", DestHost: "10.0.four.two",
+					ProcessId: "p1", Target: ptrTo("proc-one"), DestPort: "amqp",
+				})
+			},
+		}, {
+			Records: wrapRecords(
+				vanflow.SiteRecord{BaseRecord: vanflow.NewBase("s1"), Name: ptrTo("one")},
+				vanflow.RouterRecord{BaseRecord: vanflow.NewBase("r1"), Parent: ptrTo("s1")},
+				vanflow.ConnectorRecord{
+					BaseRecord: vanflow.NewBase("c1"), Name: ptrTo("conn-one"),
+					Parent:    ptrTo("r1"),
+					ProcessID: ptrTo("does-not-exist"),
+					Protocol:  ptrTo("tp"),
+					Address:   ptrTo("trombone"),
+					DestHost:  ptrTo("10.0.four.two"), DestPort: ptrTo("amqp"),
+				},
+				vanflow.ProcessRecord{
+					BaseRecord: vanflow.NewBase("proc-site"),
+					Name:       ptrTo("my site service"),
+					Parent:     ptrTo("s1"),
+					SourceHost: ptrTo("10.0.four.two")},
+			),
+			ExpectOK:    true,
+			ExpectCount: 1,
+			ExpectResults: func(t *testing.T, results []api.ConnectorRecord) {
+				assert.Equal(t, len(results), 1)
+				result := results[0]
+				assert.Equal(t, result.ProcessId, "proc-site")
+				assert.DeepEqual(t, result.Target, ptrTo("my site service"))
+			},
+		},
+	}
+	for _, tc := range testcases {
+		t.Run("", func(t *testing.T) {
+			stor.Replace(tc.Records)
+			graph.(reset).Reset()
+			for _, r := range tc.Records {
+				graph.(reset).Reindex(r.Record)
+			}
+			resp, err := c.ConnectorsWithResponse(context.TODO(), withParameters(tc.Parameters))
+			assert.Check(t, err)
+			if tc.ExpectOK {
+				assert.Equal(t, resp.StatusCode(), 200)
+				assert.Equal(t, resp.JSON200.Count, int64(tc.ExpectCount))
+				assert.Equal(t, len(resp.JSON200.Results), tc.ExpectCount)
+				if tc.ExpectTimeRangeCount != 0 {
+					assert.Equal(t, resp.JSON200.TimeRangeCount, int64(tc.ExpectTimeRangeCount))
+				}
+				if tc.ExpectResults != nil {
+					tc.ExpectResults(t, resp.JSON200.Results)
+				}
+			} else {
+				assert.Check(t, resp.JSON400 != nil)
+			}
+		})
+	}
+}
diff --git a/cmd/network-console-collector/internal/server/links_test.go b/cmd/network-console-collector/internal/server/links_test.go
index ee44ff01..657aaa93 100644
--- a/cmd/network-console-collector/internal/server/links_test.go
+++ b/cmd/network-console-collector/internal/server/links_test.go
@@ -31,14 +31,7 @@ func TestRouterlinks(t *testing.T) {
 		vanflow.RouterAccessRecord{BaseRecord: vanflow.NewBase("routeraccess-b-2"), Name: ptrTo("routeraccess b.2"), Parent: ptrTo("router-b-2")},
 	}
 
-	testcases := []struct {
-		Records              []store.Entry
-		Parameters           map[string][]string
-		ExpectOK             bool
-		ExpectCount          int
-		ExpectTimeRangeCount int
-		ExpectResults        func(t *testing.T, results []api.RouterLinkRecord)
-	}{
+	testcases := []collectionTestCase[api.RouterLinkRecord]{
 		{ExpectOK: true},
 		{
 			ExpectOK: true,
@@ -96,7 +89,7 @@ func TestRouterlinks(t *testing.T) {
 			for _, r := range tc.Records {
 				graph.(reset).Reindex(r.Record)
 			}
-			resp, err := c.RouterlinksWithResponse(context.TODO(), WithParameters(tc.Parameters))
+			resp, err := c.RouterlinksWithResponse(context.TODO(), withParameters(tc.Parameters))
 			assert.Check(t, err)
 			if tc.ExpectOK {
 				assert.Equal(t, resp.StatusCode(), 200)
diff --git a/cmd/network-console-collector/internal/server/process_test.go b/cmd/network-console-collector/internal/server/process_test.go
index 45e3cc12..d9888f73 100644
--- a/cmd/network-console-collector/internal/server/process_test.go
+++ b/cmd/network-console-collector/internal/server/process_test.go
@@ -19,14 +19,7 @@ func TestProcesses(t *testing.T) {
 	srv, c := requireTestClient(t, New(tlog, stor, graph))
 	defer srv.Close()
 
-	testcases := []struct {
-		Records              []store.Entry
-		Parameters           map[string][]string
-		ExpectOK             bool
-		ExpectCount          int
-		ExpectTimeRangeCount int
-		ExpectResults        func(t *testing.T, results []api.ProcessRecord)
-	}{
+	testcases := []collectionTestCase[api.ProcessRecord]{
 		{ExpectOK: true},
 		{
 			Records: wrapRecords(
@@ -110,7 +103,7 @@ func TestProcesses(t *testing.T) {
 			for _, r := range tc.Records {
 				graph.(reset).Reindex(r.Record)
 			}
-			resp, err := c.ProcessesWithResponse(context.TODO(), WithParameters(tc.Parameters))
+			resp, err := c.ProcessesWithResponse(context.TODO(), withParameters(tc.Parameters))
 			assert.Check(t, err)
 			if tc.ExpectOK {
 				assert.Equal(t, resp.StatusCode(), 200)
diff --git a/cmd/network-console-collector/internal/server/processpairs_test.go b/cmd/network-console-collector/internal/server/processpairs_test.go
index af58eba7..77122f61 100644
--- a/cmd/network-console-collector/internal/server/processpairs_test.go
+++ b/cmd/network-console-collector/internal/server/processpairs_test.go
@@ -42,7 +42,7 @@ func TestProcessPairsByAddress(t *testing.T) {
 			for _, r := range tc.Records {
 				graph.(reset).Reindex(r.Record)
 			}
-			resp, err := c.ProcessPairsByAddressWithResponse(context.TODO(), tc.ID, WithParameters(tc.Parameters))
+			resp, err := c.ProcessPairsByAddressWithResponse(context.TODO(), tc.ID, withParameters(tc.Parameters))
 			assert.Check(t, err)
 			if tc.ExpectOK {
 				assert.Equal(t, resp.StatusCode(), 200)
diff --git a/cmd/network-console-collector/internal/server/router_test.go b/cmd/network-console-collector/internal/server/router_test.go
index 8473ef0e..240b7c06 100644
--- a/cmd/network-console-collector/internal/server/router_test.go
+++ b/cmd/network-console-collector/internal/server/router_test.go
@@ -30,14 +30,7 @@ func TestRouters(t *testing.T) {
 		vanflow.RouterRecord{BaseRecord: vanflow.NewBase("router-b-2"), Name: ptrTo("router b.2"), Parent: ptrTo("site-b")},
 	}
 
-	testcases := []struct {
-		Records              []store.Entry
-		Parameters           map[string][]string
-		ExpectOK             bool
-		ExpectCount          int
-		ExpectTimeRangeCount int
-		ExpectResults        func(t *testing.T, results []api.RouterRecord)
-	}{
+	testcases := []collectionTestCase[api.RouterRecord]{
 		{ExpectOK: true},
 		{
 			ExpectOK:    true,
@@ -73,7 +66,7 @@ func TestRouters(t *testing.T) {
 			for _, r := range tc.Records {
 				graph.(reset).Reindex(r.Record)
 			}
-			resp, err := c.RoutersWithResponse(context.TODO(), WithParameters(tc.Parameters))
+			resp, err := c.RoutersWithResponse(context.TODO(), withParameters(tc.Parameters))
 			assert.Check(t, err)
 			if tc.ExpectOK {
 				assert.Equal(t, resp.StatusCode(), 200)
diff --git a/cmd/network-console-collector/internal/server/shared_test.go b/cmd/network-console-collector/internal/server/shared_test.go
index 2131a631..a5223326 100644
--- a/cmd/network-console-collector/internal/server/shared_test.go
+++ b/cmd/network-console-collector/internal/server/shared_test.go
@@ -21,6 +21,17 @@ func requireTestClient(t *testing.T, impl api.ServerInterface) (*httptest.Server
 	return htsrv, client
 }
 
+type collectionTestCase[T api.Record] struct {
+	Records              []store.Entry
+	Flows                []store.Entry
+	Parameters           map[string][]string
+	ExpectOK             bool
+	ExpectCount          int
+	ExpectTimeRangeCount int
+	ExpectError          string
+	ExpectResults        func(t *testing.T, results []T)
+}
+
 func ptrTo[T any](c T) *T {
 	return &c
 }
@@ -38,7 +49,11 @@ type reset interface {
 	Reindex(vanflow.Record)
 }
 
-func WithParameters(params map[string][]string) func(context.Context, *http.Request) error {
+type statusCoder interface {
+	StatusCode() int
+}
+
+func withParameters(params map[string][]string) func(context.Context, *http.Request) error {
 	return func(ctx context.Context, r *http.Request) error {
 		values := r.URL.Query()
 		for k, vs := range params {
diff --git a/cmd/network-console-collector/internal/server/sites_test.go b/cmd/network-console-collector/internal/server/sites_test.go
index f7b2092e..fe87667f 100644
--- a/cmd/network-console-collector/internal/server/sites_test.go
+++ b/cmd/network-console-collector/internal/server/sites_test.go
@@ -20,15 +20,7 @@ func TestSites(t *testing.T) {
 	srv, c := requireTestClient(t, New(tlog, stor, graph))
 	defer srv.Close()
 
-	testcases := []struct {
-		Records              []store.Entry
-		Parameters           map[string][]string
-		ExpectOK             bool
-		ExpectCount          int
-		ExpectTimeRangeCount int
-		ExpectResults        func(t *testing.T, results []api.SiteRecord)
-		ExpectError          string
-	}{
+	testcases := []collectionTestCase[api.SiteRecord]{
 		{ExpectOK: true},
 		{
 			Records: wrapRecords(
@@ -79,7 +71,7 @@ func TestSites(t *testing.T) {
 		t.Run("", func(t *testing.T) {
 			stor.Replace(tc.Records)
 			graph.(reset).Reset()
-			resp, err := c.SitesWithResponse(context.TODO(), WithParameters(tc.Parameters))
+			resp, err := c.SitesWithResponse(context.TODO(), withParameters(tc.Parameters))
 			assert.Check(t, err)
 			if tc.ExpectOK {
 				assert.Equal(t, resp.StatusCode(), 200)
diff --git a/pkg/nonkube/common/fs_config_renderer_test.go b/pkg/nonkube/common/fs_config_renderer_test.go
index 519d3ae4..e34d6c77 100644
--- a/pkg/nonkube/common/fs_config_renderer_test.go
+++ b/pkg/nonkube/common/fs_config_renderer_test.go
@@ -51,28 +51,28 @@ func testFileSystemConfigurationRendererRender(t *testing.T, addInputCertificate
 	}
 	expectedFiles := []string{
 		string(api.RouterConfigPath) + "/skrouterd.json",
-		string(api.CertificatesCaPath) + "/skupper-site-ca/tls.crt",
-		string(api.CertificatesCaPath) + "/skupper-site-ca/tls.key",
-		string(api.CertificatesCaPath) + "/skupper-service-ca/tls.crt",
-		string(api.CertificatesCaPath) + "/skupper-service-ca/tls.key",
-		string(api.CertificatesClientPath) + "/client-link-access-one/tls.crt",
-		string(api.CertificatesClientPath) + "/client-link-access-one/tls.key",
-		string(api.CertificatesClientPath) + "/client-link-access-one/ca.crt",
-		string(api.CertificatesServerPath) + "/link-access-one/tls.crt",
-		string(api.CertificatesServerPath) + "/link-access-one/tls.key",
-		string(api.CertificatesServerPath) + "/link-access-one/ca.crt",
-		string(api.CertificatesServerPath) + "/listener-one-credentials/tls.crt",
-		string(api.CertificatesServerPath) + "/listener-one-credentials/tls.key",
-		string(api.CertificatesServerPath) + "/listener-one-credentials/ca.crt",
-		string(api.CertificatesServerPath) + "/listener-two-credentials/tls.crt",
-		string(api.CertificatesServerPath) + "/listener-two-credentials/tls.key",
-		string(api.CertificatesServerPath) + "/listener-two-credentials/ca.crt",
-		string(api.CertificatesServerPath) + "/connector-one-credentials/tls.key",
-		string(api.CertificatesServerPath) + "/connector-one-credentials/ca.crt",
-		string(api.CertificatesServerPath) + "/connector-one-credentials/tls.crt",
-		string(api.CertificatesLinkPath) + "/link-one-profile/ca.crt",
-		string(api.CertificatesLinkPath) + "/link-one-profile/tls.crt",
-		string(api.CertificatesLinkPath) + "/link-one-profile/tls.key",
+		string(api.IssuersPath) + "/skupper-site-ca/tls.crt",
+		string(api.IssuersPath) + "/skupper-site-ca/tls.key",
+		string(api.IssuersPath) + "/skupper-service-ca/tls.crt",
+		string(api.IssuersPath) + "/skupper-service-ca/tls.key",
+		string(api.CertificatesPath) + "/client-link-access-one/tls.crt",
+		string(api.CertificatesPath) + "/client-link-access-one/tls.key",
+		string(api.CertificatesPath) + "/client-link-access-one/ca.crt",
+		string(api.CertificatesPath) + "/link-access-one/tls.crt",
+		string(api.CertificatesPath) + "/link-access-one/tls.key",
+		string(api.CertificatesPath) + "/link-access-one/ca.crt",
+		string(api.CertificatesPath) + "/listener-one-credentials/tls.crt",
+		string(api.CertificatesPath) + "/listener-one-credentials/tls.key",
+		string(api.CertificatesPath) + "/listener-one-credentials/ca.crt",
+		string(api.CertificatesPath) + "/listener-two-credentials/tls.crt",
+		string(api.CertificatesPath) + "/listener-two-credentials/tls.key",
+		string(api.CertificatesPath) + "/listener-two-credentials/ca.crt",
+		string(api.CertificatesPath) + "/connector-one-credentials/tls.key",
+		string(api.CertificatesPath) + "/connector-one-credentials/ca.crt",
+		string(api.CertificatesPath) + "/connector-one-credentials/tls.crt",
+		string(api.CertificatesPath) + "/link-one-profile/ca.crt",
+		string(api.CertificatesPath) + "/link-one-profile/tls.crt",
+		string(api.CertificatesPath) + "/link-one-profile/tls.key",
 		string(api.RuntimePath) + "/platform.yaml",
 		string(api.RuntimeTokenPath) + "/link-link-access-one-127.0.0.1.yaml",
 	}
@@ -95,12 +95,12 @@ func testFileSystemConfigurationRendererRender(t *testing.T, addInputCertificate
 }
 
 func compareCertificates(t *testing.T, customOutputPath string) {
-	caPath := path.Join(customOutputPath, string(api.CertificatesCaPath), "skupper-site-ca")
-	serverPath := path.Join(customOutputPath, string(api.CertificatesServerPath), "link-access-one")
-	clientPath := path.Join(customOutputPath, string(api.CertificatesClientPath), "client-link-access-one")
-	inputCaPath := path.Join(customOutputPath, string(api.InputCertificatesCaPath), "skupper-site-ca")
-	inputServerPath := path.Join(customOutputPath, string(api.InputCertificatesServerPath), "link-access-one")
-	inputClientPath := path.Join(customOutputPath, string(api.InputCertificatesClientPath), "client-link-access-one")
+	caPath := path.Join(customOutputPath, string(api.IssuersPath), "skupper-site-ca")
+	serverPath := path.Join(customOutputPath, string(api.CertificatesPath), "link-access-one")
+	clientPath := path.Join(customOutputPath, string(api.CertificatesPath), "client-link-access-one")
+	inputCaPath := path.Join(customOutputPath, string(api.InputIssuersPath), "skupper-site-ca")
+	inputServerPath := path.Join(customOutputPath, string(api.InputCertificatesPath), "link-access-one")
+	inputClientPath := path.Join(customOutputPath, string(api.InputCertificatesPath), "client-link-access-one")
 	pathsToCompare := map[string]string{
 		caPath:     inputCaPath,
 		serverPath: inputServerPath,
@@ -128,9 +128,9 @@ func createInputCertificates(t *testing.T, customOutputPath string) {
 	client := certs.GenerateSecret("fake-client-cert", "fake-client-cert", "", &ca)
 
 	// paths for each provided certificate
-	caPath := path.Join(customOutputPath, "namespaces/default", string(api.InputCertificatesCaPath), "skupper-site-ca")
-	serverPath := path.Join(customOutputPath, "namespaces/default", string(api.InputCertificatesServerPath), "link-access-one")
-	clientPath := path.Join(customOutputPath, "namespaces/default", string(api.InputCertificatesClientPath), "client-link-access-one")
+	caPath := path.Join(customOutputPath, "namespaces/default", string(api.InputIssuersPath), "skupper-site-ca")
+	serverPath := path.Join(customOutputPath, "namespaces/default", string(api.InputCertificatesPath), "link-access-one")
+	clientPath := path.Join(customOutputPath, "namespaces/default", string(api.InputCertificatesPath), "client-link-access-one")
 	certsMap := map[string]corev1.Secret{
 		caPath:     ca,
 		serverPath: server,