Skip to content

Commit 98975fb

Browse files
authored
Resolve Istio hosts only within the observed service domain (#2086)
Istio destination.host is not always a Kubernetes Service. Treating any dotted host as a local service can connect an external address or an incomplete DNS name to an unrelated cluster object. The graph and renderers share the same rule: a short name resolves in the resource's namespace; a fully qualified name resolves only under the service domain observed from istiod. External, wildcard, incomplete and conflicting-domain hosts remain unresolved. Resource and traffic modes use the same host resolution. API change: Relationships adds optional string istioServiceDomain, also carried in Istio node metadata. This supplies standalone resource drawers with the observation used by topology; it is present only when a domain is known. An Istio relationships response may therefore be non-null even when it contains no resource edges. Conflicting or invalid explicit observations do not authorize FQDN navigation. When no istiod Deployment is observed (including an external or unobserved control plane), the existing `cluster.local` default applies; custom domains require the control plane to be in Radar's cache. Review focus: domain observation and consistent graph/drawer interpretation. This is reference resolution, not proof of reachability or mesh routing configuration. Validation: full root and relevant shared-module Go suites; resource/traffic host and observed/conflicting-domain tests; four rendered host navigation cases; web/shared UI type checks; production frontend/embed/backend build. Existing renderers and link presentation are retained. Visual-test: skipped because these corrections retain the existing layouts; rendered navigation/evidence tests and real REST/MCP checks cover the changed behavior.
1 parent 45400b8 commit 98975fb

13 files changed

Lines changed: 302 additions & 61 deletions

‎docs/integrations.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,8 @@ See the main [README](../README.md#gitops) for the user-facing overview. This se
692692

693693
**Topology:** Full Istio traffic path — IstioGateway → VirtualService → Service, and DestinationRule → Service configuration edges. See how traffic flows through gateway listeners, virtual service routing rules, and into backend services.
694694

695+
Service links accept short names in the rule's namespace or full `name.namespace.svc.<domain>` names. Radar reads a unique registry domain from standard observed Istiod Deployments' `--domain` argument, using Istio's `cluster.local` default when no override is observed. Conflicting or unresolved control-plane arguments leave FQDN links unresolved. External control planes and nonstandard launch configurations may therefore need additional domain discovery; arbitrary dotted hosts are not treated as local Services.
696+
695697
**VirtualService Detail View:**
696698
- HTTP/TCP/TLS routing rules with match conditions
697699
- Destinations with weight distribution bars

‎packages/k8s-ui/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,43 @@ import { Network } from 'lucide-react'
22
import { Section, PropertyList, Property, ConditionsSection, ResourceLink, KeyValueBadgeList } from '../../ui/drawer-components'
33
import {
44
getDestinationRuleHost,
5+
resolveIstioServiceHost,
56
getDestinationRuleSubsets,
67
getDestinationRuleTrafficPolicy,
78
getDestinationRuleLoadBalancer,
89
} from '../resource-utils-istio'
910

1011
interface IstioDestinationRuleRendererProps {
1112
data: any
13+
serviceDomain?: string
1214
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
1315
}
1416

15-
export function IstioDestinationRuleRenderer({ data, onNavigate }: IstioDestinationRuleRendererProps) {
17+
export function IstioDestinationRuleRenderer({ data, onNavigate, serviceDomain }: IstioDestinationRuleRendererProps) {
1618
const host = getDestinationRuleHost(data)
1719
const subsets = getDestinationRuleSubsets(data)
1820
const trafficPolicy = getDestinationRuleTrafficPolicy(data)
1921
const loadBalancer = getDestinationRuleLoadBalancer(data)
2022
const ns = data.metadata?.namespace || ''
2123

22-
// Parse host to create service link
23-
const hostParts = host.split('.')
24-
const svcName = hostParts[0]
25-
const svcNs = hostParts.length >= 2 ? hostParts[1] : ns
24+
const service = resolveIstioServiceHost(host, ns, serviceDomain)
2625

2726
return (
2827
<>
2928
{/* Host reference */}
3029
<Section title="Destination" icon={Network} defaultExpanded>
3130
<PropertyList>
3231
<Property label="Host" value={
33-
host !== '-' ? (
32+
service ? (
3433
<ResourceLink
35-
name={svcName}
34+
name={service.name}
3635
kind="services"
37-
namespace={svcNs}
36+
group=""
37+
namespace={service.namespace}
3838
label={host}
3939
onNavigate={onNavigate}
4040
/>
41-
) : '-'
41+
) : host
4242
} />
4343
<Property label="Load Balancer" value={loadBalancer} />
4444
</PropertyList>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// @vitest-environment jsdom
2+
import { act, type ReactNode } from 'react'
3+
import { createRoot } from 'react-dom/client'
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { IstioDestinationRuleRenderer } from './IstioDestinationRuleRenderer'
6+
7+
async function rendered(element: ReactNode, check: (container: HTMLDivElement) => Promise<void> | void) {
8+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
9+
const container = document.createElement('div')
10+
const root = createRoot(container)
11+
try { await act(async () => root.render(element)); await check(container) }
12+
finally { await act(async () => root.unmount()); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false }) }
13+
}
14+
async function click(container: HTMLDivElement, text: string) {
15+
const button = [...container.querySelectorAll('button')].find(button => button.textContent === text)
16+
expect(button, `missing link ${text}`).toBeDefined()
17+
await act(async () => button!.click())
18+
}
19+
20+
describe('exact reference navigation', () => {
21+
it.each([
22+
{host:'api.prod.svc.mesh.internal',domain:'mesh.internal',linked:true},
23+
{host:'api.prod.svc.cluster.local',domain:undefined,linked:false},
24+
{host:'api.prod.svc.external.test',domain:'mesh.internal',linked:false},
25+
{host:'api.prod',domain:'mesh.internal',linked:false},
26+
])('links only observed Istio service domains: $host',async ({host,domain,linked})=>{
27+
const navigate=vi.fn()
28+
await rendered(<IstioDestinationRuleRenderer data={{metadata:{namespace:'app'},spec:{host}}} serviceDomain={domain} onNavigate={navigate}/>,async container=>{
29+
const link=[...container.querySelectorAll('button')].find(button=>button.textContent===host)
30+
expect(Boolean(link)).toBe(linked)
31+
if(link) {await click(container,host);expect(navigate).toHaveBeenCalledWith({kind:'services',group:'',namespace:'prod',name:'api'})}
32+
})
33+
})
34+
})

‎packages/k8s-ui/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@ import {
77
getVirtualServiceHostsList,
88
getVirtualServiceGatewaysList,
99
getVirtualServiceHttpRoutes,
10+
resolveIstioServiceHost,
1011
getVirtualServiceTcpRoutes,
1112
getVirtualServiceTlsRoutes,
1213
getVirtualServiceRouteCount,
1314
} from '../resource-utils-istio'
1415

1516
interface IstioVirtualServiceRendererProps {
1617
data: any
18+
serviceDomain?: string
1719
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
1820
}
1921

20-
export function IstioVirtualServiceRenderer({ data, onNavigate }: IstioVirtualServiceRendererProps) {
22+
export function IstioVirtualServiceRenderer({ data, onNavigate, serviceDomain }: IstioVirtualServiceRendererProps) {
2123
const status = getVirtualServiceStatus(data)
2224
const hosts = getVirtualServiceHostsList(data)
2325
const gateways = getVirtualServiceGatewaysList(data)
@@ -148,6 +150,7 @@ export function IstioVirtualServiceRenderer({ data, onNavigate }: IstioVirtualSe
148150
{route.route.map((dest: any, di: number) => {
149151
const weight = dest.weight ?? (route.route.length === 1 ? 100 : undefined)
150152
const destHost = dest.destination?.host || ''
153+
const service = resolveIstioServiceHost(destHost, ns, serviceDomain)
151154
const destPort = dest.destination?.port?.number
152155
const destSubset = dest.destination?.subset
153156

@@ -164,13 +167,7 @@ export function IstioVirtualServiceRenderer({ data, onNavigate }: IstioVirtualSe
164167
<span className="text-theme-text-secondary font-medium">{weight}%</span>
165168
</div>
166169
)}
167-
<ResourceLink
168-
name={destHost.split('.')[0]}
169-
kind="services"
170-
namespace={destHost.includes('.') ? destHost.split('.')[1] : ns}
171-
label={destHost}
172-
onNavigate={onNavigate}
173-
/>
170+
{service ? <ResourceLink name={service.name} kind="services" group="" namespace={service.namespace} label={destHost} onNavigate={onNavigate} /> : <span className="text-theme-text-primary break-all">{destHost}</span>}
174171
{destPort && (
175172
<span className="text-theme-text-tertiary">:{destPort}</span>
176173
)}

‎packages/k8s-ui/src/components/resources/resource-utils-istio.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ import { pluralize } from '../../utils/pluralize'
88
// SHARED HELPERS
99
// ============================================================================
1010

11-
function parseIstioHost(host: string): { name: string; namespace: string } {
12-
// Istio hosts can be: "reviews", "reviews.default", "reviews.default.svc.cluster.local"
11+
// FQDNs require the registry domain supplied by the producer. Undotted
12+
// names use Istio's documented namespace-relative expansion.
13+
export function resolveIstioServiceHost(host: string, defaultNamespace: string, serviceDomain = ''): { name: string; namespace: string } | undefined {
14+
const label = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
1315
const parts = host.split('.')
14-
return {
15-
name: parts[0] || host,
16-
namespace: parts.length >= 2 ? parts[1] : '',
16+
if (parts.length === 1 && host.length <= 63 && label.test(host)) return { name: host, namespace: defaultNamespace }
17+
if (serviceDomain && host.endsWith(`.svc.${serviceDomain}`)) {
18+
const service = host.slice(0, -(`.svc.${serviceDomain}`.length)).split('.')
19+
if (service.length === 2 && service.every(part => part.length <= 63 && label.test(part))) return { name: service[0], namespace: service[1] }
1720
}
21+
return undefined
1822
}
1923

2024
// ============================================================================
@@ -103,16 +107,16 @@ export function getVirtualServiceTlsRoutes(resource: any): any[] {
103107
return resource.spec?.tls || []
104108
}
105109

106-
export function getVirtualServiceDestinations(resource: any): Array<{ host: string; namespace: string; port?: number; subset?: string; weight?: number }> {
110+
export function getVirtualServiceDestinations(resource: any, serviceDomain = ''): Array<{ host: string; namespace: string; port?: number; subset?: string; weight?: number }> {
107111
const destinations: Array<{ host: string; namespace: string; port?: number; subset?: string; weight?: number }> = []
108112
const httpRoutes = resource.spec?.http || []
109113
for (const route of httpRoutes) {
110114
for (const dest of (route.route || [])) {
111115
if (dest.destination?.host) {
112-
const parsed = parseIstioHost(dest.destination.host)
116+
const parsed = resolveIstioServiceHost(dest.destination.host, resource.metadata?.namespace || '', serviceDomain)
113117
destinations.push({
114118
host: dest.destination.host,
115-
namespace: parsed.namespace || resource.metadata?.namespace || '',
119+
namespace: parsed?.namespace || '',
116120
port: dest.destination.port?.number,
117121
subset: dest.destination.subset,
118122
weight: dest.weight,

‎packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -972,8 +972,8 @@ export function ResourceRendererDispatch({
972972
{kind === 'azuremanagedcontrolplanes' && <AzureManagedControlPlaneRenderer data={data} onNavigate={onNavigate} />}
973973
{kind === 'azuremanagedmachinepools' && <AzureManagedMachinePoolRenderer data={data} onNavigate={onNavigate} />}
974974
{kind === 'azuremachines' && <AzureMachineRenderer data={data} onNavigate={onNavigate} />}
975-
{kind === 'virtualservices' && <IstioVirtualServiceRenderer data={data} onNavigate={onNavigate} />}
976-
{kind === 'destinationrules' && <IstioDestinationRuleRenderer data={data} onNavigate={onNavigate} />}
975+
{kind === 'virtualservices' && <IstioVirtualServiceRenderer data={data} onNavigate={onNavigate} serviceDomain={relationships?.istioServiceDomain} />}
976+
{kind === 'destinationrules' && <IstioDestinationRuleRenderer data={data} onNavigate={onNavigate} serviceDomain={relationships?.istioServiceDomain} />}
977977
{kind === 'serviceentries' && <IstioServiceEntryRenderer data={data} />}
978978
{kind === 'peerauthentications' && <IstioPeerAuthenticationRenderer data={data} />}
979979
{kind === 'authorizationpolicies' && <IstioAuthorizationPolicyRenderer data={data} />}

‎packages/k8s-ui/src/types/core.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,8 @@ export interface ResourceRef {
641641

642642
// Computed relationships for a resource
643643
export interface Relationships {
644+
/** Istio registry domain supplied by the producer; omitted when unknown. */
645+
istioServiceDomain?: string
644646
reflection?: {
645647
source?: ResourceRef
646648
sourceResourceVersion?: string

‎pkg/topology/builder.go‎

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1770,6 +1770,14 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
17701770

17711771
// 1k. Add Istio VirtualService nodes (CRD - fetched via dynamic cache)
17721772
// VirtualService edges are created in a second pass after service IDs are populated
1773+
istioDomain := ""
1774+
istioDomainLoaded := false
1775+
loadIstioDomain := func() {
1776+
if !istioDomainLoaded {
1777+
istioDomain = b.istioServiceDomain()
1778+
istioDomainLoaded = true
1779+
}
1780+
}
17731781
var virtualServiceGVR schema.GroupVersionResource
17741782
hasVirtualServices := false
17751783
if resourceDiscovery != nil {
@@ -1778,6 +1786,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
17781786
virtualServiceIDs := make(map[string]string) // ns/name -> vsID
17791787
var virtualServiceResources []*unstructured.Unstructured // Store for second pass
17801788
if hasVirtualServices && dynamicCache != nil {
1789+
loadIstioDomain()
17811790
virtualServices, vsErr := dynamicCache.ListNamespaces(virtualServiceGVR, opts.Namespaces)
17821791
if vsErr != nil {
17831792
log.Printf("WARNING [topology] Failed to list Istio VirtualServices: %v", vsErr)
@@ -1814,12 +1823,13 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
18141823
Name: name,
18151824
Status: nodeStatus,
18161825
Data: map[string]any{
1817-
"namespace": ns,
1818-
"hosts": hosts,
1819-
"gateways": gateways,
1820-
"routeCount": routeCount,
1821-
"labels": vs.GetLabels(),
1822-
"apiVersion": vs.GetAPIVersion(),
1826+
"namespace": ns,
1827+
"hosts": hosts,
1828+
"gateways": gateways,
1829+
"routeCount": routeCount,
1830+
"labels": vs.GetLabels(),
1831+
"apiVersion": vs.GetAPIVersion(),
1832+
"istioServiceDomain": istioDomain,
18231833
},
18241834
})
18251835

@@ -1837,6 +1847,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
18371847
destinationRuleIDs := make(map[string]string) // ns/name -> drID
18381848
var destinationRuleResources []*unstructured.Unstructured // Store for second pass
18391849
if hasDestinationRules && dynamicCache != nil {
1850+
loadIstioDomain()
18401851
destinationRules, drErr := dynamicCache.ListNamespaces(destinationRuleGVR, opts.Namespaces)
18411852
if drErr != nil {
18421853
log.Printf("WARNING [topology] Failed to list Istio DestinationRules: %v", drErr)
@@ -1866,11 +1877,12 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
18661877
Name: name,
18671878
Status: nodeStatus,
18681879
Data: map[string]any{
1869-
"namespace": ns,
1870-
"host": host,
1871-
"subsetCount": len(subsets),
1872-
"labels": dr.GetLabels(),
1873-
"apiVersion": dr.GetAPIVersion(),
1880+
"namespace": ns,
1881+
"host": host,
1882+
"subsetCount": len(subsets),
1883+
"labels": dr.GetLabels(),
1884+
"apiVersion": dr.GetAPIVersion(),
1885+
"istioServiceDomain": istioDomain,
18741886
},
18751887
})
18761888

@@ -4746,7 +4758,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
47464758

47474759
// addVSToSvcEdge is a helper that deduplicates VS→Service edges
47484760
addVSToSvcEdge := func(protocol string, destHost string) {
4749-
svcName, svcNs := parseIstioHost(destHost, vsNs)
4761+
svcName, svcNs := parseIstioHost(destHost, vsNs, istioDomain)
47504762
svcID, ok := serviceIDs[svcNs+"/"+svcName]
47514763
if !ok {
47524764
return
@@ -4840,7 +4852,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
48404852
continue
48414853
}
48424854

4843-
svcName, svcNs := parseIstioHost(host, drNs)
4855+
svcName, svcNs := parseIstioHost(host, drNs, istioDomain)
48444856
if svcID, ok := serviceIDs[svcNs+"/"+svcName]; ok {
48454857
edges = append(edges, Edge{
48464858
ID: fmt.Sprintf("%s-to-%s", drID, svcID),
@@ -6022,6 +6034,11 @@ func (b *Builder) buildTrafficTopology(opts BuildOptions) (*Topology, error) {
60226034
}
60236035
}
60246036

6037+
istioDomain := "cluster.local"
6038+
if len(trafficVirtualServices) > 0 {
6039+
istioDomain = b.istioServiceDomain()
6040+
}
6041+
60256042
// Step 1: Find services referenced by ingresses
60266043
for _, ing := range ingresses {
60276044
if !opts.MatchesNamespaceFilter(ing.Namespace) {
@@ -6087,7 +6104,7 @@ func (b *Builder) buildTrafficTopology(opts BuildOptions) (*Topology, error) {
60876104
if destHost == "" {
60886105
continue
60896106
}
6090-
svcName, svcNs := parseIstioHost(destHost, vsNs)
6107+
svcName, svcNs := parseIstioHost(destHost, vsNs, istioDomain)
60916108
svcKey := svcNs + "/" + svcName
60926109
servicesFromIstio[svcKey] = true
60936110
}
@@ -6590,12 +6607,13 @@ func (b *Builder) buildTrafficTopology(opts BuildOptions) (*Topology, error) {
65906607
Name: vsName,
65916608
Status: nodeStatus,
65926609
Data: map[string]any{
6593-
"namespace": vsNs,
6594-
"hosts": hosts,
6595-
"gateways": gateways,
6596-
"routeCount": routeCount,
6597-
"labels": vs.GetLabels(),
6598-
"apiVersion": vs.GetAPIVersion(),
6610+
"namespace": vsNs,
6611+
"hosts": hosts,
6612+
"gateways": gateways,
6613+
"routeCount": routeCount,
6614+
"labels": vs.GetLabels(),
6615+
"apiVersion": vs.GetAPIVersion(),
6616+
"istioServiceDomain": istioDomain,
65996617
},
66006618
})
66016619

@@ -6638,7 +6656,7 @@ func (b *Builder) buildTrafficTopology(opts BuildOptions) (*Topology, error) {
66386656
if destHost == "" {
66396657
continue
66406658
}
6641-
svcName, svcNs := parseIstioHost(destHost, vsNs)
6659+
svcName, svcNs := parseIstioHost(destHost, vsNs, istioDomain)
66426660
svcKey := svcNs + "/" + svcName
66436661
if _, ok := servicesToInclude[svcKey]; !ok {
66446662
continue
@@ -8082,18 +8100,6 @@ func matchesHelmRelease(labels map[string]string, hrName, hrNamespace string) bo
80828100
return false
80838101
}
80848102

8085-
// parseIstioHost parses an Istio service host reference into service name and namespace.
8086-
// Istio hosts can be: "reviews", "reviews.default", "reviews.default.svc.cluster.local"
8087-
// Returns (serviceName, namespace). If no namespace is found, defaultNs is used.
8088-
func parseIstioHost(host, defaultNs string) (string, string) {
8089-
parts := strings.Split(host, ".")
8090-
if len(parts) == 1 {
8091-
return parts[0], defaultNs
8092-
}
8093-
// "reviews.default" or "reviews.default.svc.cluster.local"
8094-
return parts[0], parts[1]
8095-
}
8096-
80978103
type workloadRefs struct {
80988104
configMaps map[string]bool
80998105
secrets map[string]bool

0 commit comments

Comments
 (0)