Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/xds/api/v1/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func RegisterRoutes(r *gin.Engine, cache *xdscache.SnapshotCache) {

routes.GET("/nodeIDs", h.getNodeIDs)
routes.GET("/nodeIDs/versions", h.getNodeIDsWithResourceVersions)
routes.GET("/resourceVersions", h.getResourceVersions)

// ********** Get Listeners **********
// Get Listeners
Expand Down
25 changes: 25 additions & 0 deletions internal/xds/api/v1/handlers/nodeIDs.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,28 @@ func (h *handler) getNodeIDsWithResourceVersions(ctx *gin.Context) {
}
ctx.JSON(200, result)
}

// getResourceVersions retrieves per-resource versions (hashes) for a specific node ID.
// This can be used to compare with Envoy's config_dump to detect unsynchronized resources.
// @Summary Get per-resource versions for a node ID
// @Tags nodeid
// @Accept json
// @Produce json
// @Param nodeID query string true "Node ID"
// @Success 200 {object} xdscache.ResourceVersions
// @Router /api/v1/resourceVersions [get]
func (h *handler) getResourceVersions(ctx *gin.Context) {
nodeID := ctx.Query("nodeID")
if nodeID == "" {
ctx.JSON(400, gin.H{"error": "nodeID query parameter is required"})
return
}

versions, err := h.cache.GetResourceVersions(nodeID)
if err != nil {
ctx.JSON(500, gin.H{"error": err.Error()})
return
}

ctx.JSON(200, versions)
}
64 changes: 64 additions & 0 deletions internal/xds/cache/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cache

import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"

Expand All @@ -11,6 +13,7 @@ import (
listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
tlsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3"
"github.com/envoyproxy/go-control-plane/pkg/cache/types"
"github.com/envoyproxy/go-control-plane/pkg/cache/v3"
resourcev3 "github.com/envoyproxy/go-control-plane/pkg/resource/v3"
"golang.org/x/exp/maps"
Expand Down Expand Up @@ -173,3 +176,64 @@ func getListenersFromSnapshot(snapshot cache.ResourceSnapshot) []*listenerv3.Lis
}
return listeners
}

// ResourceVersion contains version info for a single resource
type ResourceVersion struct {
Name string `json:"name"`
Version string `json:"version"`
}

// ResourceVersions contains per-resource versions for all resource types
type ResourceVersions struct {
Clusters []ResourceVersion `json:"clusters"`
Listeners []ResourceVersion `json:"listeners"`
Routes []ResourceVersion `json:"routes"`
Secrets []ResourceVersion `json:"secrets"`
}

// GetResourceVersions returns hash-based versions for each individual resource in the snapshot.
// This can be used to compare with Envoy's config_dump to detect unsynchronized resources.
func (c *SnapshotCache) GetResourceVersions(nodeID string) (*ResourceVersions, error) {
c.mu.RLock()
defer c.mu.RUnlock()

snapshot, err := c.SnapshotCache.GetSnapshot(nodeID)
if err != nil {
return nil, err
}

result := &ResourceVersions{
Clusters: computeResourceVersions(snapshot.GetResources(resourcev3.ClusterType)),
Listeners: computeResourceVersions(snapshot.GetResources(resourcev3.ListenerType)),
Routes: computeResourceVersions(snapshot.GetResources(resourcev3.RouteType)),
Secrets: computeResourceVersions(snapshot.GetResources(resourcev3.SecretType)),
}

return result, nil
}

// computeResourceVersions computes a hash for each resource in the map
func computeResourceVersions(resources map[string]types.Resource) []ResourceVersion {
if len(resources) == 0 {
return nil
}

versions := make([]ResourceVersion, 0, len(resources))
for name, res := range resources {
versions = append(versions, ResourceVersion{
Name: name,
Version: computeResourceHash(res),
})
}
return versions
}

// computeResourceHash computes a SHA256 hash of a protobuf message
func computeResourceHash(msg types.Resource) string {
data, err := proto.MarshalOptions{Deterministic: true}.Marshal(msg)
if err != nil {
return "error"
}
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}
15 changes: 15 additions & 0 deletions scripts/dev-update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ helm upgrade exc \
"$ROOT_DIR/helm/charts/envoy-xds-controller" \
--timeout='5m' --wait

# Restart pods to pick up new images (needed when tag doesn't change)
echo -e "${BLUE}Restarting pods to pick up new images...${NC}"
if [ "$COMPONENTS" = "all" ] || [ "$COMPONENTS" = "backend" ]; then
kubectl -n envoy-xds-controller rollout restart deployment -l app.kubernetes.io/name=envoy-xds-controller
fi
if [ "$COMPONENTS" = "all" ] || [ "$COMPONENTS" = "frontend" ]; then
if [ "$UI_ENABLED" = "true" ]; then
kubectl -n envoy-xds-controller rollout restart deployment -l app.kubernetes.io/name=envoy-xds-controller-ui
fi
fi

# Wait for rollout
echo -e "${BLUE}Waiting for rollout to complete...${NC}"
kubectl -n envoy-xds-controller rollout status deployment -l app.kubernetes.io/instance=exc --timeout=120s

echo ""
echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN} Update complete!${NC}"
Expand Down
11 changes: 11 additions & 0 deletions ui/src/api/hooks/useResourceVersions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import GetResourceVersionsService from '../services/getResourceVersionsService'

export const useResourceVersions = (nodeId: string) => {
return useQuery({
queryKey: ['resourceVersions', nodeId],
queryFn: () => GetResourceVersionsService.getResourceVersions(nodeId),
enabled: !!nodeId,
staleTime: 30 * 1000
})
}
18 changes: 18 additions & 0 deletions ui/src/api/services/getResourceVersionsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ResourceHashVersions } from '../../common/types/overviewApiTypes'
import axiosClient from '../axiosApiClient'

const GetResourceVersionsService = {
getResourceVersions: async (nodeId: string): Promise<ResourceHashVersions | undefined> => {
try {
const { data } = await axiosClient.get<ResourceHashVersions>(
`/resourceVersions?nodeID=${nodeId}`
)
return data
} catch (error: unknown) {
console.error('Error fetching resource versions: ', error)
throw error
}
}
}

export default GetResourceVersionsService
13 changes: 13 additions & 0 deletions ui/src/common/types/overviewApiTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,16 @@ export interface CertificateInfo {
}

export type CertificateStatus = 'ok' | 'warning' | 'critical' | 'expired'

// Per-resource hash versions (for sync detection)
export interface ResourceHashVersion {
name: string
version: string
}

export interface ResourceHashVersions {
clusters: ResourceHashVersion[]
listeners: ResourceHashVersion[]
routes: ResourceHashVersion[]
secrets: ResourceHashVersion[]
}
161 changes: 161 additions & 0 deletions ui/src/components/resourceHashesTable/ResourceHashesTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { useMemo, useState } from 'react'
import { MaterialReactTable, useMaterialReactTable, type MRT_ColumnDef } from 'material-react-table'
import { Box, Chip, Tooltip, Snackbar } from '@mui/material'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { ResourceHashVersions, ResourceHashVersion } from '../../common/types/overviewApiTypes'

interface ResourceHashesTableProps {
data: ResourceHashVersions | undefined
isLoading?: boolean
}

interface FlatResourceHash {
type: 'cluster' | 'listener' | 'route' | 'secret'
name: string
version: string
}

const truncateHash = (hash: string): string => {
if (hash.length <= 19) return hash
return `${hash.slice(0, 8)}...${hash.slice(-8)}`
}

const CopyableHash = ({ hash }: { hash: string }) => {
const [showSnackbar, setShowSnackbar] = useState(false)

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(hash)
setShowSnackbar(true)
} catch (err) {
console.error('Failed to copy:', err)
}
}

return (
<>
<Tooltip title={`Click to copy: ${hash}`} arrow placement='top'>
<Box
component='code'
onClick={handleCopy}
sx={{
fontFamily: 'monospace',
fontSize: '0.85rem',
backgroundColor: 'action.hover',
px: 1,
py: 0.5,
borderRadius: 1,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
'&:hover': {
backgroundColor: 'action.selected'
}
}}
>
{truncateHash(hash)}
<ContentCopyIcon sx={{ fontSize: '0.75rem', opacity: 0.5 }} />
</Box>
</Tooltip>
<Snackbar
open={showSnackbar}
autoHideDuration={2000}
onClose={() => setShowSnackbar(false)}
message='Hash copied to clipboard'
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</>
)
}

const TypeChip = ({ type }: { type: string }) => {
const config: Record<string, { color: 'primary' | 'secondary' | 'success' | 'warning' }> = {
listener: { color: 'primary' },
cluster: { color: 'secondary' },
route: { color: 'success' },
secret: { color: 'warning' }
}

const { color } = config[type] || { color: 'primary' as const }

return <Chip label={type} color={color} size='small' variant='outlined' />
}

export const ResourceHashesTable = ({ data, isLoading = false }: ResourceHashesTableProps) => {
const flatData = useMemo<FlatResourceHash[]>(() => {
if (!data) return []

const result: FlatResourceHash[] = []

const addResources = (
resources: ResourceHashVersion[] | null,
type: FlatResourceHash['type']
) => {
if (resources) {
resources.forEach(r => result.push({ type, name: r.name, version: r.version }))
}
}

addResources(data.listeners, 'listener')
addResources(data.clusters, 'cluster')
addResources(data.routes, 'route')
addResources(data.secrets, 'secret')

return result
}, [data])

const columns = useMemo<MRT_ColumnDef<FlatResourceHash>[]>(
() => [
{
accessorKey: 'type',
header: 'Type',
size: 120,
Cell: ({ cell }) => <TypeChip type={cell.getValue<string>()} />
},
{
accessorKey: 'name',
header: 'Resource Name',
size: 350
},
{
accessorKey: 'version',
header: 'Hash',
size: 180,
Cell: ({ cell }) => <CopyableHash hash={cell.getValue<string>()} />
}
],
[]
)

const table = useMaterialReactTable({
columns,
data: flatData,
enableColumnActions: false,
enableColumnFilters: true,
enablePagination: true,
enableSorting: true,
enableDensityToggle: false,
enableFullScreenToggle: false,
enableHiding: false,
initialState: {
density: 'compact',
sorting: [{ id: 'type', desc: false }],
pagination: { pageSize: 25, pageIndex: 0 }
},
state: {
isLoading
},
muiTableContainerProps: {
sx: { maxHeight: '500px' }
},
muiTablePaperProps: {
elevation: 0,
sx: { border: '1px solid', borderColor: 'divider' }
}
})

return <MaterialReactTable table={table} />
}

export default ResourceHashesTable
1 change: 1 addition & 0 deletions ui/src/components/resourceHashesTable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ResourceHashesTable } from './ResourceHashesTable'
15 changes: 15 additions & 0 deletions ui/src/pages/nodeOverview/NodeOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { Box, Typography, Tab, Tabs, IconButton, CircularProgress, Alert } from
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import RefreshIcon from '@mui/icons-material/Refresh'
import { useOverview } from '../../api/hooks/useOverview'
import { useResourceVersions } from '../../api/hooks/useResourceVersions'
import { OverviewSummary } from '../../components/overviewSummary'
import { ResourceVersions } from '../../components/resourceVersions'
import { EndpointsTable } from '../../components/endpointsTable'
import { CertificatesTable } from '../../components/certificatesTable'
import { ResourceHashesTable } from '../../components/resourceHashesTable'
import { CustomTabPanel } from '../../components/customTabPanel'

function a11yProps(index: number) {
Expand All @@ -24,6 +26,7 @@ const NodeOverview = () => {

// Hook must be called unconditionally, enabled flag handles missing nodeID
const { data: overview, isLoading, isError, error, refetch } = useOverview(nodeID ?? '')
const { data: resourceHashes, isLoading: isLoadingHashes } = useResourceVersions(nodeID ?? '')

const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue)
Expand Down Expand Up @@ -94,6 +97,15 @@ const NodeOverview = () => {
<Tabs value={tabValue} onChange={handleTabChange} aria-label='Overview tabs'>
<Tab label={`Endpoints (${overview.endpoints.length})`} {...a11yProps(0)} />
<Tab label={`Certificates (${overview.certificates.length})`} {...a11yProps(1)} />
<Tab
label={`Resource Hashes (${
(resourceHashes?.listeners?.length || 0) +
(resourceHashes?.clusters?.length || 0) +
(resourceHashes?.routes?.length || 0) +
(resourceHashes?.secrets?.length || 0)
})`}
{...a11yProps(2)}
/>
</Tabs>
</Box>

Expand All @@ -104,6 +116,9 @@ const NodeOverview = () => {
<CustomTabPanel value={tabValue} index={1} variant='minimal'>
<CertificatesTable certificates={overview.certificates} isLoading={isLoading} />
</CustomTabPanel>
<CustomTabPanel value={tabValue} index={2} variant='minimal'>
<ResourceHashesTable data={resourceHashes} isLoading={isLoadingHashes} />
</CustomTabPanel>
</Box>
)
}
Expand Down