Skip to content

Commit 5ba1cf0

Browse files
authored
Feature/resource hashes api (#222)
* Add per-resource hash versions API and UI for sync tracking * Fix dev-update script to restart pods after helm upgrade
1 parent c26fe5b commit 5ba1cf0

File tree

10 files changed

+324
-0
lines changed

10 files changed

+324
-0
lines changed

internal/xds/api/v1/handlers/handlers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func RegisterRoutes(r *gin.Engine, cache *xdscache.SnapshotCache) {
3636

3737
routes.GET("/nodeIDs", h.getNodeIDs)
3838
routes.GET("/nodeIDs/versions", h.getNodeIDsWithResourceVersions)
39+
routes.GET("/resourceVersions", h.getResourceVersions)
3940

4041
// ********** Get Listeners **********
4142
// Get Listeners

internal/xds/api/v1/handlers/nodeIDs.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,28 @@ func (h *handler) getNodeIDsWithResourceVersions(ctx *gin.Context) {
5151
}
5252
ctx.JSON(200, result)
5353
}
54+
55+
// getResourceVersions retrieves per-resource versions (hashes) for a specific node ID.
56+
// This can be used to compare with Envoy's config_dump to detect unsynchronized resources.
57+
// @Summary Get per-resource versions for a node ID
58+
// @Tags nodeid
59+
// @Accept json
60+
// @Produce json
61+
// @Param nodeID query string true "Node ID"
62+
// @Success 200 {object} xdscache.ResourceVersions
63+
// @Router /api/v1/resourceVersions [get]
64+
func (h *handler) getResourceVersions(ctx *gin.Context) {
65+
nodeID := ctx.Query("nodeID")
66+
if nodeID == "" {
67+
ctx.JSON(400, gin.H{"error": "nodeID query parameter is required"})
68+
return
69+
}
70+
71+
versions, err := h.cache.GetResourceVersions(nodeID)
72+
if err != nil {
73+
ctx.JSON(500, gin.H{"error": err.Error()})
74+
return
75+
}
76+
77+
ctx.JSON(200, versions)
78+
}

internal/xds/cache/wrapper.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cache
22

33
import (
44
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
57
"fmt"
68
"sync"
79

@@ -11,6 +13,7 @@ import (
1113
listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
1214
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
1315
tlsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3"
16+
"github.com/envoyproxy/go-control-plane/pkg/cache/types"
1417
"github.com/envoyproxy/go-control-plane/pkg/cache/v3"
1518
resourcev3 "github.com/envoyproxy/go-control-plane/pkg/resource/v3"
1619
"golang.org/x/exp/maps"
@@ -173,3 +176,64 @@ func getListenersFromSnapshot(snapshot cache.ResourceSnapshot) []*listenerv3.Lis
173176
}
174177
return listeners
175178
}
179+
180+
// ResourceVersion contains version info for a single resource
181+
type ResourceVersion struct {
182+
Name string `json:"name"`
183+
Version string `json:"version"`
184+
}
185+
186+
// ResourceVersions contains per-resource versions for all resource types
187+
type ResourceVersions struct {
188+
Clusters []ResourceVersion `json:"clusters"`
189+
Listeners []ResourceVersion `json:"listeners"`
190+
Routes []ResourceVersion `json:"routes"`
191+
Secrets []ResourceVersion `json:"secrets"`
192+
}
193+
194+
// GetResourceVersions returns hash-based versions for each individual resource in the snapshot.
195+
// This can be used to compare with Envoy's config_dump to detect unsynchronized resources.
196+
func (c *SnapshotCache) GetResourceVersions(nodeID string) (*ResourceVersions, error) {
197+
c.mu.RLock()
198+
defer c.mu.RUnlock()
199+
200+
snapshot, err := c.SnapshotCache.GetSnapshot(nodeID)
201+
if err != nil {
202+
return nil, err
203+
}
204+
205+
result := &ResourceVersions{
206+
Clusters: computeResourceVersions(snapshot.GetResources(resourcev3.ClusterType)),
207+
Listeners: computeResourceVersions(snapshot.GetResources(resourcev3.ListenerType)),
208+
Routes: computeResourceVersions(snapshot.GetResources(resourcev3.RouteType)),
209+
Secrets: computeResourceVersions(snapshot.GetResources(resourcev3.SecretType)),
210+
}
211+
212+
return result, nil
213+
}
214+
215+
// computeResourceVersions computes a hash for each resource in the map
216+
func computeResourceVersions(resources map[string]types.Resource) []ResourceVersion {
217+
if len(resources) == 0 {
218+
return nil
219+
}
220+
221+
versions := make([]ResourceVersion, 0, len(resources))
222+
for name, res := range resources {
223+
versions = append(versions, ResourceVersion{
224+
Name: name,
225+
Version: computeResourceHash(res),
226+
})
227+
}
228+
return versions
229+
}
230+
231+
// computeResourceHash computes a SHA256 hash of a protobuf message
232+
func computeResourceHash(msg types.Resource) string {
233+
data, err := proto.MarshalOptions{Deterministic: true}.Marshal(msg)
234+
if err != nil {
235+
return "error"
236+
}
237+
hash := sha256.Sum256(data)
238+
return hex.EncodeToString(hash[:])
239+
}

scripts/dev-update.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,21 @@ helm upgrade exc \
8888
"$ROOT_DIR/helm/charts/envoy-xds-controller" \
8989
--timeout='5m' --wait
9090

91+
# Restart pods to pick up new images (needed when tag doesn't change)
92+
echo -e "${BLUE}Restarting pods to pick up new images...${NC}"
93+
if [ "$COMPONENTS" = "all" ] || [ "$COMPONENTS" = "backend" ]; then
94+
kubectl -n envoy-xds-controller rollout restart deployment -l app.kubernetes.io/name=envoy-xds-controller
95+
fi
96+
if [ "$COMPONENTS" = "all" ] || [ "$COMPONENTS" = "frontend" ]; then
97+
if [ "$UI_ENABLED" = "true" ]; then
98+
kubectl -n envoy-xds-controller rollout restart deployment -l app.kubernetes.io/name=envoy-xds-controller-ui
99+
fi
100+
fi
101+
102+
# Wait for rollout
103+
echo -e "${BLUE}Waiting for rollout to complete...${NC}"
104+
kubectl -n envoy-xds-controller rollout status deployment -l app.kubernetes.io/instance=exc --timeout=120s
105+
91106
echo ""
92107
echo -e "${GREEN}============================================${NC}"
93108
echo -e "${GREEN} Update complete!${NC}"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import GetResourceVersionsService from '../services/getResourceVersionsService'
3+
4+
export const useResourceVersions = (nodeId: string) => {
5+
return useQuery({
6+
queryKey: ['resourceVersions', nodeId],
7+
queryFn: () => GetResourceVersionsService.getResourceVersions(nodeId),
8+
enabled: !!nodeId,
9+
staleTime: 30 * 1000
10+
})
11+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ResourceHashVersions } from '../../common/types/overviewApiTypes'
2+
import axiosClient from '../axiosApiClient'
3+
4+
const GetResourceVersionsService = {
5+
getResourceVersions: async (nodeId: string): Promise<ResourceHashVersions | undefined> => {
6+
try {
7+
const { data } = await axiosClient.get<ResourceHashVersions>(
8+
`/resourceVersions?nodeID=${nodeId}`
9+
)
10+
return data
11+
} catch (error: unknown) {
12+
console.error('Error fetching resource versions: ', error)
13+
throw error
14+
}
15+
}
16+
}
17+
18+
export default GetResourceVersionsService

ui/src/common/types/overviewApiTypes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,16 @@ export interface CertificateInfo {
5252
}
5353

5454
export type CertificateStatus = 'ok' | 'warning' | 'critical' | 'expired'
55+
56+
// Per-resource hash versions (for sync detection)
57+
export interface ResourceHashVersion {
58+
name: string
59+
version: string
60+
}
61+
62+
export interface ResourceHashVersions {
63+
clusters: ResourceHashVersion[]
64+
listeners: ResourceHashVersion[]
65+
routes: ResourceHashVersion[]
66+
secrets: ResourceHashVersion[]
67+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { useMemo, useState } from 'react'
2+
import { MaterialReactTable, useMaterialReactTable, type MRT_ColumnDef } from 'material-react-table'
3+
import { Box, Chip, Tooltip, Snackbar } from '@mui/material'
4+
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
5+
import { ResourceHashVersions, ResourceHashVersion } from '../../common/types/overviewApiTypes'
6+
7+
interface ResourceHashesTableProps {
8+
data: ResourceHashVersions | undefined
9+
isLoading?: boolean
10+
}
11+
12+
interface FlatResourceHash {
13+
type: 'cluster' | 'listener' | 'route' | 'secret'
14+
name: string
15+
version: string
16+
}
17+
18+
const truncateHash = (hash: string): string => {
19+
if (hash.length <= 19) return hash
20+
return `${hash.slice(0, 8)}...${hash.slice(-8)}`
21+
}
22+
23+
const CopyableHash = ({ hash }: { hash: string }) => {
24+
const [showSnackbar, setShowSnackbar] = useState(false)
25+
26+
const handleCopy = async () => {
27+
try {
28+
await navigator.clipboard.writeText(hash)
29+
setShowSnackbar(true)
30+
} catch (err) {
31+
console.error('Failed to copy:', err)
32+
}
33+
}
34+
35+
return (
36+
<>
37+
<Tooltip title={`Click to copy: ${hash}`} arrow placement='top'>
38+
<Box
39+
component='code'
40+
onClick={handleCopy}
41+
sx={{
42+
fontFamily: 'monospace',
43+
fontSize: '0.85rem',
44+
backgroundColor: 'action.hover',
45+
px: 1,
46+
py: 0.5,
47+
borderRadius: 1,
48+
cursor: 'pointer',
49+
display: 'inline-flex',
50+
alignItems: 'center',
51+
gap: 0.5,
52+
'&:hover': {
53+
backgroundColor: 'action.selected'
54+
}
55+
}}
56+
>
57+
{truncateHash(hash)}
58+
<ContentCopyIcon sx={{ fontSize: '0.75rem', opacity: 0.5 }} />
59+
</Box>
60+
</Tooltip>
61+
<Snackbar
62+
open={showSnackbar}
63+
autoHideDuration={2000}
64+
onClose={() => setShowSnackbar(false)}
65+
message='Hash copied to clipboard'
66+
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
67+
/>
68+
</>
69+
)
70+
}
71+
72+
const TypeChip = ({ type }: { type: string }) => {
73+
const config: Record<string, { color: 'primary' | 'secondary' | 'success' | 'warning' }> = {
74+
listener: { color: 'primary' },
75+
cluster: { color: 'secondary' },
76+
route: { color: 'success' },
77+
secret: { color: 'warning' }
78+
}
79+
80+
const { color } = config[type] || { color: 'primary' as const }
81+
82+
return <Chip label={type} color={color} size='small' variant='outlined' />
83+
}
84+
85+
export const ResourceHashesTable = ({ data, isLoading = false }: ResourceHashesTableProps) => {
86+
const flatData = useMemo<FlatResourceHash[]>(() => {
87+
if (!data) return []
88+
89+
const result: FlatResourceHash[] = []
90+
91+
const addResources = (
92+
resources: ResourceHashVersion[] | null,
93+
type: FlatResourceHash['type']
94+
) => {
95+
if (resources) {
96+
resources.forEach(r => result.push({ type, name: r.name, version: r.version }))
97+
}
98+
}
99+
100+
addResources(data.listeners, 'listener')
101+
addResources(data.clusters, 'cluster')
102+
addResources(data.routes, 'route')
103+
addResources(data.secrets, 'secret')
104+
105+
return result
106+
}, [data])
107+
108+
const columns = useMemo<MRT_ColumnDef<FlatResourceHash>[]>(
109+
() => [
110+
{
111+
accessorKey: 'type',
112+
header: 'Type',
113+
size: 120,
114+
Cell: ({ cell }) => <TypeChip type={cell.getValue<string>()} />
115+
},
116+
{
117+
accessorKey: 'name',
118+
header: 'Resource Name',
119+
size: 350
120+
},
121+
{
122+
accessorKey: 'version',
123+
header: 'Hash',
124+
size: 180,
125+
Cell: ({ cell }) => <CopyableHash hash={cell.getValue<string>()} />
126+
}
127+
],
128+
[]
129+
)
130+
131+
const table = useMaterialReactTable({
132+
columns,
133+
data: flatData,
134+
enableColumnActions: false,
135+
enableColumnFilters: true,
136+
enablePagination: true,
137+
enableSorting: true,
138+
enableDensityToggle: false,
139+
enableFullScreenToggle: false,
140+
enableHiding: false,
141+
initialState: {
142+
density: 'compact',
143+
sorting: [{ id: 'type', desc: false }],
144+
pagination: { pageSize: 25, pageIndex: 0 }
145+
},
146+
state: {
147+
isLoading
148+
},
149+
muiTableContainerProps: {
150+
sx: { maxHeight: '500px' }
151+
},
152+
muiTablePaperProps: {
153+
elevation: 0,
154+
sx: { border: '1px solid', borderColor: 'divider' }
155+
}
156+
})
157+
158+
return <MaterialReactTable table={table} />
159+
}
160+
161+
export default ResourceHashesTable
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ResourceHashesTable } from './ResourceHashesTable'

ui/src/pages/nodeOverview/NodeOverview.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { Box, Typography, Tab, Tabs, IconButton, CircularProgress, Alert } from
44
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
55
import RefreshIcon from '@mui/icons-material/Refresh'
66
import { useOverview } from '../../api/hooks/useOverview'
7+
import { useResourceVersions } from '../../api/hooks/useResourceVersions'
78
import { OverviewSummary } from '../../components/overviewSummary'
89
import { ResourceVersions } from '../../components/resourceVersions'
910
import { EndpointsTable } from '../../components/endpointsTable'
1011
import { CertificatesTable } from '../../components/certificatesTable'
12+
import { ResourceHashesTable } from '../../components/resourceHashesTable'
1113
import { CustomTabPanel } from '../../components/customTabPanel'
1214

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

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

2831
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
2932
setTabValue(newValue)
@@ -94,6 +97,15 @@ const NodeOverview = () => {
9497
<Tabs value={tabValue} onChange={handleTabChange} aria-label='Overview tabs'>
9598
<Tab label={`Endpoints (${overview.endpoints.length})`} {...a11yProps(0)} />
9699
<Tab label={`Certificates (${overview.certificates.length})`} {...a11yProps(1)} />
100+
<Tab
101+
label={`Resource Hashes (${
102+
(resourceHashes?.listeners?.length || 0) +
103+
(resourceHashes?.clusters?.length || 0) +
104+
(resourceHashes?.routes?.length || 0) +
105+
(resourceHashes?.secrets?.length || 0)
106+
})`}
107+
{...a11yProps(2)}
108+
/>
97109
</Tabs>
98110
</Box>
99111

@@ -104,6 +116,9 @@ const NodeOverview = () => {
104116
<CustomTabPanel value={tabValue} index={1} variant='minimal'>
105117
<CertificatesTable certificates={overview.certificates} isLoading={isLoading} />
106118
</CustomTabPanel>
119+
<CustomTabPanel value={tabValue} index={2} variant='minimal'>
120+
<ResourceHashesTable data={resourceHashes} isLoading={isLoadingHashes} />
121+
</CustomTabPanel>
107122
</Box>
108123
)
109124
}

0 commit comments

Comments
 (0)