Skip to content

Commit 33eb336

Browse files
committed
Preserve distinctEndpoint and metadataMapping when editing K8s endpoint aggregators
Motivation: The xDS Kubernetes endpoint aggregator editor rebuilds the aggregator YAML from its form model on every save. Because the form modeled only a subset of the schema, saving from the console silently dropped distinctEndpoint, metadataMapping, and the aggregator-level policy - fields the form has no editor for. An operator who edited an aggregator through the console would unknowingly discard them; losing distinctEndpoint, for example, re-introduces duplicate endpoints on the next rolling restart. Modifications: - Add distinctEndpoint to the watcher form as a checkbox next to "Trust certificates", read in parseToFormData and written in buildBody. - Preserve metadataMapping verbatim across the load/save round-trip (carried as a react-hook-form field-array item property) and show a read-only note with the number of configured rules, since the form has no editor for them. - Preserve the aggregator-level policy verbatim. - Guard the new emissions so aggregators without these fields serialize exactly as before. - Add regression tests that load an aggregator carrying these fields, perform an edit, and assert the committed YAML still contains them, including a multi-watcher removal case that verifies each mapping stays with its own watcher. Result: Editing a Kubernetes endpoint aggregator through the web console no longer drops distinctEndpoint, metadataMapping, or policy. distinctEndpoint is now viewable and editable in the form.
1 parent 5afa110 commit 33eb336

2 files changed

Lines changed: 256 additions & 2 deletions

File tree

webapp/src/dogma/features/xds/K8sAggregatorEditor.tsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,15 @@ import * as jsYaml from 'js-yaml';
3939
import { default as RouteLink } from 'next/link';
4040
import Router from 'next/router';
4141
import { useEffect, useState } from 'react';
42-
import { Control, Controller, FieldErrors, useFieldArray, useForm, UseFormRegister } from 'react-hook-form';
42+
import {
43+
Control,
44+
Controller,
45+
FieldErrors,
46+
useFieldArray,
47+
useForm,
48+
UseFormRegister,
49+
useWatch,
50+
} from 'react-hook-form';
4351
import { OptionBase, Select } from 'chakra-react-select';
4452
import { AiOutlineClose, AiOutlineDelete, AiOutlineEdit, AiOutlineEye } from 'react-icons/ai';
4553
import { FiSave } from 'react-icons/fi';
@@ -72,24 +80,41 @@ interface PropertyForm {
7280
value: string;
7381
}
7482

83+
// A ServiceEndpointWatcher.metadata_mapping entry. The editor does not expose a UI for editing these yet,
84+
// but carries them verbatim so a Save from the form cannot silently drop them (see parseToFormData/buildBody).
85+
interface MetadataMappingForm {
86+
resourceType?: string;
87+
entryType?: string;
88+
sourceKey?: string;
89+
sourceKeyPrefix?: string;
90+
metadataNamespace?: string;
91+
metadataKey?: string;
92+
}
93+
7594
interface WatcherForm {
7695
serviceName: string;
7796
portName: string;
7897
controlPlaneUrl: string;
7998
namespace: string;
8099
credentialId: string;
81100
trustCerts: boolean;
101+
distinctEndpoint: boolean;
82102
priority: string;
83103
loadBalancingWeight: string;
84104
region: string;
85105
zone: string;
86106
subZone: string;
87107
additionalProperties: PropertyForm[];
108+
// Preserved as-is across a load/save round-trip; not editable in the form.
109+
metadataMapping: MetadataMappingForm[];
88110
}
89111

90112
interface FormData {
91113
aggregatorId: string;
92114
watchers: WatcherForm[];
115+
// The aggregator-level ClusterLoadAssignment policy. The form has no editor for it; it is preserved as-is so
116+
// a Save cannot silently drop it (see parseToFormData/buildBody).
117+
policy?: unknown;
93118
}
94119

95120
const emptyWatcher: WatcherForm = {
@@ -99,12 +124,14 @@ const emptyWatcher: WatcherForm = {
99124
namespace: '',
100125
credentialId: '',
101126
trustCerts: false,
127+
distinctEndpoint: false,
102128
priority: '',
103129
loadBalancingWeight: '',
104130
region: '',
105131
zone: '',
106132
subZone: '',
107133
additionalProperties: [],
134+
metadataMapping: [],
108135
};
109136

110137
// Parses a numeric form field, rejecting non-numeric input instead of silently serializing it as null
@@ -142,6 +169,14 @@ function buildBody(data: FormData, name?: string): string {
142169
if (Object.keys(additionalProperties).length > 0) {
143170
watcher.additionalProperties = additionalProperties;
144171
}
172+
if (w.distinctEndpoint) {
173+
watcher.distinctEndpoint = true;
174+
}
175+
// Re-emit metadata_mapping verbatim. The form has no editor for it, so dropping it here would silently
176+
// discard the mappings whenever the aggregator is saved from the console.
177+
if (w.metadataMapping && w.metadataMapping.length > 0) {
178+
watcher.metadataMapping = w.metadataMapping;
179+
}
145180
const entry: Record<string, unknown> = { watcher };
146181
const locality: Record<string, string> = {};
147182
if (w.region.trim()) {
@@ -165,6 +200,11 @@ function buildBody(data: FormData, name?: string): string {
165200
return entry;
166201
});
167202
const body: Record<string, unknown> = { localityLbEndpoints };
203+
// Re-emit the aggregator-level policy verbatim; the form does not model it, so omitting it here would drop it
204+
// from the stored file on every Save.
205+
if (data.policy !== undefined && data.policy !== null) {
206+
body.policy = data.policy;
207+
}
168208
if (name) {
169209
body.name = name;
170210
}
@@ -189,6 +229,7 @@ function parseToFormData(aggregatorId: string, raw: any): FormData {
189229
namespace: e?.watcher?.kubeconfig?.namespace ?? '',
190230
credentialId: e?.watcher?.kubeconfig?.credentialId ?? '',
191231
trustCerts: !!e?.watcher?.kubeconfig?.trustCerts,
232+
distinctEndpoint: !!e?.watcher?.distinctEndpoint,
192233
priority: e?.priority != null ? String(e.priority) : '',
193234
loadBalancingWeight: e?.loadBalancingWeight != null ? String(e.loadBalancingWeight) : '',
194235
region: e?.locality?.region ?? '',
@@ -198,9 +239,14 @@ function parseToFormData(aggregatorId: string, raw: any): FormData {
198239
key,
199240
value: String(value),
200241
})),
242+
metadataMapping: Array.isArray(e?.watcher?.metadataMapping) ? e.watcher.metadataMapping : [],
201243
}),
202244
);
203-
return { aggregatorId, watchers: watchers.length > 0 ? watchers : [{ ...emptyWatcher }] };
245+
return {
246+
aggregatorId,
247+
watchers: watchers.length > 0 ? watchers : [{ ...emptyWatcher }],
248+
policy: content?.policy,
249+
};
204250
}
205251

206252
interface CredentialOption extends OptionBase {
@@ -238,6 +284,9 @@ const WatcherFields = ({
238284
control,
239285
name: `watchers.${index}.additionalProperties` as `watchers.${number}.additionalProperties`,
240286
});
287+
// metadata_mapping is preserved but not editable here; surface its presence so the carried value is visible.
288+
const metadataMapping = useWatch({ control, name: `watchers.${index}.metadataMapping` });
289+
const metadataMappingCount = Array.isArray(metadataMapping) ? metadataMapping.length : 0;
241290
return (
242291
<Box borderWidth="1px" borderRadius="md" p={4} mb={4} maxW="3xl">
243292
<Flex mb={2} align="center">
@@ -349,6 +398,11 @@ const WatcherFields = ({
349398
Trust certificates
350399
</Checkbox>
351400
</FormControl>
401+
<FormControl display="flex" alignItems="center" pt={6}>
402+
<Checkbox isReadOnly={readOnly} {...register(`watchers.${index}.distinctEndpoint`)}>
403+
Distinct endpoint
404+
</Checkbox>
405+
</FormControl>
352406
</SimpleGrid>
353407

354408
<Text mt={4} mb={1} fontSize="sm" fontWeight="semibold" color="gray.500">
@@ -423,6 +477,12 @@ const WatcherFields = ({
423477
Add property
424478
</Button>
425479
)}
480+
{metadataMappingCount > 0 && (
481+
<Text mt={3} fontSize="xs" color="gray.500">
482+
{metadataMappingCount} metadata mapping rule(s) configured for this watcher — preserved on save (not
483+
editable in this form).
484+
</Text>
485+
)}
426486
</Box>
427487
);
428488
};

webapp/tests/dogma/features/xds/K8sAggregatorEditor.test.tsx

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,197 @@ describe('K8sAggregatorEditor – aggregator ID pattern validation', () => {
191191
});
192192
});
193193
});
194+
195+
describe('K8sAggregatorEditor – round-trips fields the form has no editor for', () => {
196+
// Metadata mapping in its fuller shape: an exact-key rule and a prefix rule carrying namespace + key. These
197+
// must survive a load→save round-trip verbatim even though the form cannot edit them.
198+
const METADATA_MAPPING = [
199+
{ resourceType: 'NODE', entryType: 'LABEL', sourceKey: 'topology.kubernetes.io/zone' },
200+
{
201+
resourceType: 'POD',
202+
entryType: 'ANNOTATION',
203+
sourceKeyPrefix: 'topology.kubernetes.io/',
204+
metadataNamespace: 'envoy.lb',
205+
metadataKey: 'zone',
206+
},
207+
];
208+
// A watcher carrying distinctEndpoint + metadataMapping, plus an aggregator-level policy — all fields the form
209+
// does not model. Before the fix, saving from the form silently dropped every one of them.
210+
const ADVANCED_CONTENT = {
211+
policy: { overprovisioningFactor: 140 },
212+
localityLbEndpoints: [
213+
{
214+
watcher: {
215+
serviceName: 'my-service',
216+
kubeconfig: { controlPlaneUrl: 'https://kubernetes.default.svc' },
217+
additionalProperties: { nodeIpLabel: 'private-ip' },
218+
distinctEndpoint: true,
219+
metadataMapping: METADATA_MAPPING,
220+
},
221+
},
222+
],
223+
};
224+
225+
let mockUpdate: jest.Mock;
226+
227+
const setupMocks = (content: unknown) => {
228+
mockUpdate = jest.fn().mockReturnValue({ unwrap: () => Promise.resolve({}) });
229+
jest
230+
.mocked(xdsApiSlice.useCreateK8sAggregatorMutation)
231+
.mockReturnValue([
232+
jest.fn().mockReturnValue({ unwrap: () => Promise.resolve({}) }),
233+
{ isLoading: false },
234+
] as any);
235+
jest
236+
.mocked(xdsApiSlice.useUpdateK8sAggregatorMutation)
237+
.mockReturnValue([mockUpdate, { isLoading: false }] as any);
238+
jest
239+
.mocked(xdsApiSlice.useDeleteK8sAggregatorMutation)
240+
.mockReturnValue([jest.fn(), { isLoading: false }] as any);
241+
jest
242+
.mocked(xdsApiSlice.usePreviewK8sAggregatorMutation)
243+
.mockReturnValue([jest.fn(), { isLoading: false }] as any);
244+
jest.mocked(xdsApiSlice.useGetK8sAggregatorQuery).mockReturnValue({
245+
data: { content: jsYaml.dump(content) },
246+
isLoading: false,
247+
error: undefined,
248+
} as any);
249+
jest.mocked(xdsApiSlice.useListCredentialsQuery).mockReturnValue({ data: [], error: null } as any);
250+
};
251+
252+
beforeEach(() => {
253+
setupMocks(ADVANCED_CONTENT);
254+
});
255+
256+
const savedBody = () => jsYaml.load(mockUpdate.mock.calls[0][0].body) as any;
257+
const savedWatcher = () => savedBody().localityLbEndpoints[0].watcher;
258+
259+
it('preserves distinctEndpoint, metadataMapping, and policy across an unrelated edit', async () => {
260+
const user = userEvent.setup();
261+
renderWithProviders(<K8sAggregatorEditor group="foo" id="my-agg" isNew={false} />);
262+
await waitFor(() => expect(screen.getByDisplayValue('my-agg')).toBeInTheDocument());
263+
264+
await user.click(screen.getByRole('button', { name: /^edit$/i }));
265+
// Make a genuinely unrelated change (rename the service) so the preserved fields ride through a real edit.
266+
const serviceInput = screen.getByDisplayValue('my-service');
267+
await user.clear(serviceInput);
268+
await user.type(serviceInput, 'renamed-service');
269+
await user.click(screen.getByRole('button', { name: /^save$/i }));
270+
271+
await waitFor(() => expect(mockUpdate).toHaveBeenCalled());
272+
const watcher = savedWatcher();
273+
expect(watcher.serviceName).toBe('renamed-service');
274+
expect(watcher.distinctEndpoint).toBe(true);
275+
// The full metadata_mapping shape (both rules, all sub-fields) is preserved verbatim.
276+
expect(watcher.metadataMapping).toEqual(METADATA_MAPPING);
277+
expect(savedBody().policy).toEqual({ overprovisioningFactor: 140 });
278+
});
279+
280+
it('reflects the stored Distinct endpoint value and lets the user turn it off while preserving metadataMapping', async () => {
281+
const user = userEvent.setup();
282+
renderWithProviders(<K8sAggregatorEditor group="foo" id="my-agg" isNew={false} />);
283+
await waitFor(() => expect(screen.getByDisplayValue('my-agg')).toBeInTheDocument());
284+
285+
expect(screen.getByRole('checkbox', { name: /distinct endpoint/i })).toBeChecked();
286+
287+
await user.click(screen.getByRole('button', { name: /^edit$/i }));
288+
await user.click(screen.getByRole('checkbox', { name: /distinct endpoint/i }));
289+
await user.click(screen.getByRole('button', { name: /^save$/i }));
290+
291+
await waitFor(() => expect(mockUpdate).toHaveBeenCalled());
292+
const watcher = savedWatcher();
293+
// Turned off → omitted from the committed body …
294+
expect(watcher.distinctEndpoint).toBeUndefined();
295+
// … while the metadata_mapping the form never touched is still preserved.
296+
expect(watcher.metadataMapping).toEqual(METADATA_MAPPING);
297+
});
298+
299+
it('writes distinctEndpoint when enabled on an aggregator that lacked it, and emits no policy key', async () => {
300+
setupMocks({
301+
localityLbEndpoints: [
302+
{
303+
watcher: {
304+
serviceName: 'my-service',
305+
kubeconfig: { controlPlaneUrl: 'https://kubernetes.default.svc' },
306+
},
307+
},
308+
],
309+
});
310+
const user = userEvent.setup();
311+
renderWithProviders(<K8sAggregatorEditor group="foo" id="my-agg" isNew={false} />);
312+
await waitFor(() => expect(screen.getByDisplayValue('my-agg')).toBeInTheDocument());
313+
314+
expect(screen.getByRole('checkbox', { name: /distinct endpoint/i })).not.toBeChecked();
315+
316+
await user.click(screen.getByRole('button', { name: /^edit$/i }));
317+
await user.click(screen.getByRole('checkbox', { name: /distinct endpoint/i }));
318+
await user.click(screen.getByRole('button', { name: /^save$/i }));
319+
320+
await waitFor(() => expect(mockUpdate).toHaveBeenCalled());
321+
expect(savedWatcher().distinctEndpoint).toBe(true);
322+
// An aggregator that had no policy must not gain one (no diff noise / no schema change).
323+
expect(savedBody()).not.toHaveProperty('policy');
324+
});
325+
326+
it('keeps each metadataMapping with its own watcher after removing another watcher', async () => {
327+
setupMocks({
328+
localityLbEndpoints: [
329+
{
330+
watcher: {
331+
serviceName: 'svc-1',
332+
kubeconfig: { controlPlaneUrl: 'https://kubernetes.default.svc' },
333+
distinctEndpoint: true,
334+
metadataMapping: [{ resourceType: 'NODE', entryType: 'LABEL', sourceKey: 'zone-1' }],
335+
},
336+
},
337+
{
338+
watcher: {
339+
serviceName: 'svc-2',
340+
kubeconfig: { controlPlaneUrl: 'https://kubernetes.default.svc' },
341+
metadataMapping: [{ resourceType: 'POD', entryType: 'ANNOTATION', sourceKey: 'rack-2' }],
342+
},
343+
},
344+
],
345+
});
346+
const user = userEvent.setup();
347+
renderWithProviders(<K8sAggregatorEditor group="foo" id="my-agg" isNew={false} />);
348+
await waitFor(() => expect(screen.getByDisplayValue('svc-1')).toBeInTheDocument());
349+
350+
await user.click(screen.getByRole('button', { name: /^edit$/i }));
351+
// Remove the first watcher; the second must keep its own mapping and not inherit the first's.
352+
await user.click(screen.getAllByRole('button', { name: /^remove$/i })[0]);
353+
await user.click(screen.getByRole('button', { name: /^save$/i }));
354+
355+
await waitFor(() => expect(mockUpdate).toHaveBeenCalled());
356+
const endpoints = savedBody().localityLbEndpoints;
357+
expect(endpoints).toHaveLength(1);
358+
expect(endpoints[0].watcher.serviceName).toBe('svc-2');
359+
expect(endpoints[0].watcher.metadataMapping).toEqual([
360+
{ resourceType: 'POD', entryType: 'ANNOTATION', sourceKey: 'rack-2' },
361+
]);
362+
// svc-2 had no distinctEndpoint; removing svc-1 must not leak svc-1's onto it.
363+
expect(endpoints[0].watcher.distinctEndpoint).toBeUndefined();
364+
});
365+
366+
it('surfaces preserved metadata mapping rules as a read-only note', async () => {
367+
renderWithProviders(<K8sAggregatorEditor group="foo" id="my-agg" isNew={false} />);
368+
await waitFor(() => expect(screen.getByDisplayValue('my-agg')).toBeInTheDocument());
369+
expect(screen.getByText(/2 metadata mapping rule/i)).toBeInTheDocument();
370+
});
371+
372+
it('shows no metadata mapping note when the watcher has none', async () => {
373+
setupMocks({
374+
localityLbEndpoints: [
375+
{
376+
watcher: {
377+
serviceName: 'my-service',
378+
kubeconfig: { controlPlaneUrl: 'https://kubernetes.default.svc' },
379+
},
380+
},
381+
],
382+
});
383+
renderWithProviders(<K8sAggregatorEditor group="foo" id="my-agg" isNew={false} />);
384+
await waitFor(() => expect(screen.getByDisplayValue('my-agg')).toBeInTheDocument());
385+
expect(screen.queryByText(/metadata mapping rule/i)).not.toBeInTheDocument();
386+
});
387+
});

0 commit comments

Comments
 (0)