diff --git a/internal/xds/api/v1/handlers/handlers.go b/internal/xds/api/v1/handlers/handlers.go index 7c907fb4..099788bc 100644 --- a/internal/xds/api/v1/handlers/handlers.go +++ b/internal/xds/api/v1/handlers/handlers.go @@ -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 diff --git a/internal/xds/api/v1/handlers/nodeIDs.go b/internal/xds/api/v1/handlers/nodeIDs.go index dc1c280a..8f01b724 100644 --- a/internal/xds/api/v1/handlers/nodeIDs.go +++ b/internal/xds/api/v1/handlers/nodeIDs.go @@ -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) +} diff --git a/internal/xds/cache/wrapper.go b/internal/xds/cache/wrapper.go index 7c253062..95c86076 100644 --- a/internal/xds/cache/wrapper.go +++ b/internal/xds/cache/wrapper.go @@ -2,6 +2,8 @@ package cache import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "sync" @@ -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" @@ -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[:]) +} diff --git a/scripts/dev-update.sh b/scripts/dev-update.sh index 218f880d..26d38d5c 100755 --- a/scripts/dev-update.sh +++ b/scripts/dev-update.sh @@ -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}" diff --git a/ui/src/api/hooks/useResourceVersions.ts b/ui/src/api/hooks/useResourceVersions.ts new file mode 100644 index 00000000..4220bd41 --- /dev/null +++ b/ui/src/api/hooks/useResourceVersions.ts @@ -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 + }) +} diff --git a/ui/src/api/services/getResourceVersionsService.ts b/ui/src/api/services/getResourceVersionsService.ts new file mode 100644 index 00000000..3ae94614 --- /dev/null +++ b/ui/src/api/services/getResourceVersionsService.ts @@ -0,0 +1,18 @@ +import { ResourceHashVersions } from '../../common/types/overviewApiTypes' +import axiosClient from '../axiosApiClient' + +const GetResourceVersionsService = { + getResourceVersions: async (nodeId: string): Promise => { + try { + const { data } = await axiosClient.get( + `/resourceVersions?nodeID=${nodeId}` + ) + return data + } catch (error: unknown) { + console.error('Error fetching resource versions: ', error) + throw error + } + } +} + +export default GetResourceVersionsService diff --git a/ui/src/common/types/overviewApiTypes.ts b/ui/src/common/types/overviewApiTypes.ts index 389b3bd4..8c49b746 100644 --- a/ui/src/common/types/overviewApiTypes.ts +++ b/ui/src/common/types/overviewApiTypes.ts @@ -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[] +} diff --git a/ui/src/components/resourceHashesTable/ResourceHashesTable.tsx b/ui/src/components/resourceHashesTable/ResourceHashesTable.tsx new file mode 100644 index 00000000..787c6dc0 --- /dev/null +++ b/ui/src/components/resourceHashesTable/ResourceHashesTable.tsx @@ -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 ( + <> + + + {truncateHash(hash)} + + + + setShowSnackbar(false)} + message='Hash copied to clipboard' + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + /> + + ) +} + +const TypeChip = ({ type }: { type: string }) => { + const config: Record = { + listener: { color: 'primary' }, + cluster: { color: 'secondary' }, + route: { color: 'success' }, + secret: { color: 'warning' } + } + + const { color } = config[type] || { color: 'primary' as const } + + return +} + +export const ResourceHashesTable = ({ data, isLoading = false }: ResourceHashesTableProps) => { + const flatData = useMemo(() => { + 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[]>( + () => [ + { + accessorKey: 'type', + header: 'Type', + size: 120, + Cell: ({ cell }) => ()} /> + }, + { + accessorKey: 'name', + header: 'Resource Name', + size: 350 + }, + { + accessorKey: 'version', + header: 'Hash', + size: 180, + Cell: ({ cell }) => ()} /> + } + ], + [] + ) + + 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 +} + +export default ResourceHashesTable diff --git a/ui/src/components/resourceHashesTable/index.ts b/ui/src/components/resourceHashesTable/index.ts new file mode 100644 index 00000000..9286d8da --- /dev/null +++ b/ui/src/components/resourceHashesTable/index.ts @@ -0,0 +1 @@ +export { ResourceHashesTable } from './ResourceHashesTable' diff --git a/ui/src/pages/nodeOverview/NodeOverview.tsx b/ui/src/pages/nodeOverview/NodeOverview.tsx index fe86a929..74afcccb 100644 --- a/ui/src/pages/nodeOverview/NodeOverview.tsx +++ b/ui/src/pages/nodeOverview/NodeOverview.tsx @@ -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) { @@ -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) @@ -94,6 +97,15 @@ const NodeOverview = () => { + @@ -104,6 +116,9 @@ const NodeOverview = () => { + + + ) }