diff --git a/public/locales/en.json b/public/locales/en.json index a1eba822..8f8c31bf 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -37,11 +37,15 @@ "tableHeaderReady": "Ready", "tableHeaderDelete": "Delete", "deleteAction": "Delete resource", + "editAction": "Edit resource", "deleteDialogTitle": "Delete resource", "advancedOptions": "Advanced options", "forceDeletion": "Force deletion", "forceWarningLine": "Force deletion removes finalizers. Related resources may not be deleted and cleanup may be skipped.", "deleteStarted": "Deleting {{resourceName}} initialized", + "patchStarted": "Updating {{resourceName}} initialized", + "patchSuccess": "Updated {{resourceName}}", + "patchError": "Failed to update {{resourceName}}", "actionColumnHeader": " " }, "ProvidersConfig": { @@ -373,7 +377,8 @@ "installError": "Install error", "syncError": "Sync error", "error": "Error", - "notHealthy": "Not healthy" + "notHealthy": "Not healthy", + "notReady": "Not ready" }, "buttons": { "viewResource": "View resource", @@ -384,11 +389,20 @@ "close": "Close", "back": "Back", "cancel": "Cancel", - "update": "Update" + "update": "Update", + "applyChanges": "Apply changes" }, "yaml": { "YAML": "File", - "showOnlyImportant": "Show only important fields" + "showOnlyImportant": "Show only important fields", + "panelTitle": "YAML", + "editorTitle": "YAML Editor", + "applySuccess2": "The Managed Control Plane will reconcile this resource shortly.", + "applySuccess": "Update submitted ", + "diffConfirmTitle": "Review changes", + "diffConfirmMessage": "Are you sure that you want to apply these changes?", + "diffNo": "No, go back", + "diffYes": "Yes" }, "createMCP": { "dialogTitle": "Create Managed Control Plane", diff --git a/src/components/ControlPlane/ActionsMenu.tsx b/src/components/ControlPlane/ActionsMenu.tsx new file mode 100644 index 00000000..7a911df0 --- /dev/null +++ b/src/components/ControlPlane/ActionsMenu.tsx @@ -0,0 +1,53 @@ +import { useRef, useState } from 'react'; +import { Button, Menu, MenuItem, MenuDomRef } from '@ui5/webcomponents-react'; +import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js'; +import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react'; + +export type ActionItem = { + key: string; + text: string; + icon?: string; + disabled?: boolean; + onClick: (item: T) => void; +}; + +export type ActionsMenuProps = { + item: T; + actions: ActionItem[]; + buttonIcon?: string; +}; + +export function ActionsMenu({ item, actions, buttonIcon = 'overflow' }: ActionsMenuProps) { + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleOpenerClick = (e: Ui5CustomEvent) => { + if (popoverRef.current && e.currentTarget) { + popoverRef.current.opener = e.currentTarget as unknown as HTMLElement; + setOpen((prev) => !prev); + } + }; + + return ( + <> + )} - + ); }; diff --git a/src/components/Yaml/YamlSidePanel.module.css b/src/components/Yaml/YamlSidePanel.module.css index 7112b16d..49ec6095 100644 --- a/src/components/Yaml/YamlSidePanel.module.css +++ b/src/components/Yaml/YamlSidePanel.module.css @@ -8,3 +8,39 @@ height: 100%; width: 100%; } + +.successContainer { + gap: 1rem; + padding: 1rem; + align-items: center; +} + +.reviewContainer { + gap: 1rem; + min-height: 100%; + width: 100%; + position: relative; +} + +.stickyHeader { + position: sticky; + top: 0; + height: 6rem; + padding: 1rem; + padding-bottom: 1.5rem; + z-index: 1; + background: var(--sapBackgroundColor); +} + +.stickyHeaderInner { + padding: 0 1rem; +} + +.diffConfirmMessage { + margin-top: 0.5rem; +} + +.reviewButtons { + gap: 0.5rem; + padding: 0 1rem 1rem; +} diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 6f686bfc..5d67c873 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -7,36 +7,49 @@ import { ToolbarButton, ToolbarSeparator, ToolbarSpacer, + Button, } from '@ui5/webcomponents-react'; - +import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js'; import { useTranslation } from 'react-i18next'; import { YamlViewer } from './YamlViewer.tsx'; import { useSplitter } from '../Splitter/SplitterContext.tsx'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useCallback } from 'react'; import { stringify } from 'yaml'; +import { convertToResourceConfig } from '../../utils/convertToResourceConfig.ts'; import { removeManagedFieldsAndFilterData, Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts'; import styles from './YamlSidePanel.module.css'; +import { IllustratedBanner } from '../Ui/IllustratedBanner/IllustratedBanner.tsx'; +import { YamlDiff } from '../Wizards/CreateManagedControlPlane/YamlDiff.tsx'; export const SHOW_DOWNLOAD_BUTTON = false; // Download button is hidden now due to stakeholder request export interface YamlSidePanelProps { resource: Resource; filename: string; + onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; + isEdit?: boolean; } -export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { +export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSidePanelProps) { const [showOnlyImportantData, setShowOnlyImportantData] = useState(true); + const [mode, setMode] = useState<'edit' | 'review' | 'success'>('edit'); + const [editedYaml, setEditedYaml] = useState(null); + const [parsedObject, setParsedObject] = useState(null); + const { closeAside } = useSplitter(); const { t } = useTranslation(); - const yamlStringToDisplay = useMemo(() => { - return stringify(removeManagedFieldsAndFilterData(resource, showOnlyImportantData)); - }, [resource, showOnlyImportantData]); - const yamlStringToCopy = useMemo(() => { - return stringify(removeManagedFieldsAndFilterData(resource, false)); - }, [resource]); - + const originalYaml = useMemo( + () => + isEdit + ? stringify(convertToResourceConfig(resource)) + : stringify(removeManagedFieldsAndFilterData(resource, showOnlyImportantData)), + [isEdit, resource, showOnlyImportantData], + ); + const yamlStringToDisplay = useMemo(() => editedYaml ?? originalYaml, [editedYaml, originalYaml]); + const yamlStringToCopy = useMemo(() => originalYaml, [originalYaml]); const { copyToClipboard } = useCopyToClipboard(); + const handleDownloadClick = () => { const blob = new Blob([yamlStringToCopy], { type: 'text/yaml' }); const url = window.URL.createObjectURL(blob); @@ -49,26 +62,45 @@ export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { window.URL.revokeObjectURL(url); }; + const handleApplyFromEditor = useCallback(async (parsed: unknown, yaml: string) => { + setParsedObject(parsed); + setEditedYaml(yaml); + setMode('review'); + }, []); + + const handleConfirmPatch = useCallback(async () => { + if (!onApply || !editedYaml) return; + + const result = await onApply(parsedObject, editedYaml); + if (result === true) { + setMode('success'); + } + }, [onApply, editedYaml, parsedObject]); + + const handleGoBack = () => setMode('edit'); + return ( - YAML + {t('yaml.panelTitle')} - setShowOnlyImportantData(!showOnlyImportantData)} - /> + {!isEdit && ( + setShowOnlyImportantData(!showOnlyImportantData)} + /> + )} copyToClipboard(yamlStringToCopy)} + onClick={() => copyToClipboard(yamlStringToDisplay)} /> {SHOW_DOWNLOAD_BUTTON ? (
- + {mode === 'success' && ( + + + + + )} + {mode === 'review' && ( + +
+
+ {t('yaml.diffConfirmTitle')} +

{t('yaml.diffConfirmMessage')}

+
+ + + + +
+
+ +
+
+ )} + {mode === 'edit' && ( + + )}
); diff --git a/src/components/Yaml/YamlSidePanelWithLoader.tsx b/src/components/Yaml/YamlSidePanelWithLoader.tsx index 48a3d10a..fb606f32 100644 --- a/src/components/Yaml/YamlSidePanelWithLoader.tsx +++ b/src/components/Yaml/YamlSidePanelWithLoader.tsx @@ -11,8 +11,14 @@ export interface YamlSidePanelWithLoaderProps { workspaceName?: string; resourceType: 'projects' | 'workspaces' | 'managedcontrolplanes'; resourceName: string; + isEdit?: boolean; } -export function YamlSidePanelWithLoader({ workspaceName, resourceType, resourceName }: YamlSidePanelWithLoaderProps) { +export function YamlSidePanelWithLoader({ + workspaceName, + resourceType, + resourceName, + isEdit = false, +}: YamlSidePanelWithLoaderProps) { const { t } = useTranslation(); const { isLoading, data, error } = useApiResource( ResourceObject(workspaceName ?? '', resourceType, resourceName), @@ -25,5 +31,5 @@ export function YamlSidePanelWithLoader({ workspaceName, resourceType, resourceN const filename = `${workspaceName ? `${workspaceName}_` : ''}${resourceType}_${resourceName}`; - return ; + return ; } diff --git a/src/components/Yaml/YamlViewButton.tsx b/src/components/Yaml/YamlViewButton.tsx index 87636dc0..3d0e4edb 100644 --- a/src/components/Yaml/YamlViewButton.tsx +++ b/src/components/Yaml/YamlViewButton.tsx @@ -30,6 +30,7 @@ export function YamlViewButton(props: YamlViewButtonProps) { const { resource } = props; openInAside( , @@ -41,6 +42,7 @@ export function YamlViewButton(props: YamlViewButtonProps) { const { workspaceName, resourceType, resourceName } = props; openInAside( void | boolean | Promise; }; -export const YamlViewer: FC = ({ yamlString, filename }) => { +export const YamlViewer: FC = ({ yamlString, filename, isEdit = false, onApply }) => { return (
- +
); }; diff --git a/src/components/YamlEditor/YamlDiffEditor.tsx b/src/components/YamlEditor/YamlDiffEditor.tsx index 3bd4b43d..0abd7f31 100644 --- a/src/components/YamlEditor/YamlDiffEditor.tsx +++ b/src/components/YamlEditor/YamlDiffEditor.tsx @@ -3,7 +3,6 @@ import type { ComponentProps } from 'react'; import { useTheme } from '../../hooks/useTheme'; import { GITHUB_DARK_DEFAULT, GITHUB_LIGHT_DEFAULT } from '../../lib/monaco.ts'; -// Reuse all props from the underlying Monaco DiffEditor component, except language (we force YAML) export type YamlDiffEditorProps = Omit< ComponentProps, 'language' | 'defaultLanguage' | 'originalLanguage' | 'modifiedLanguage' @@ -15,8 +14,7 @@ export const YamlDiffEditor = (props: YamlDiffEditorProps) => { const { theme, options, ...rest } = props; const computedTheme = theme ?? (isDarkTheme ? GITHUB_DARK_DEFAULT : GITHUB_LIGHT_DEFAULT); - const simplifiedOptions = { - // Start from consumer-provided options, then enforce our simplified look + const diffEditorOptions = { ...options, scrollbar: { ...(options?.scrollbar ?? {}), @@ -37,5 +35,5 @@ export const YamlDiffEditor = (props: YamlDiffEditorProps) => { readOnly: true, }; - return ; + return ; }; diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index be56a6f4..eafa5a7a 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -1,30 +1,115 @@ import { Editor } from '@monaco-editor/react'; import type { ComponentProps } from 'react'; +import { Button, Panel, Toolbar, ToolbarSpacer, Title } from '@ui5/webcomponents-react'; +import { parseDocument } from 'yaml'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTheme } from '../../hooks/useTheme'; import { GITHUB_DARK_DEFAULT, GITHUB_LIGHT_DEFAULT } from '../../lib/monaco.ts'; +import { useTranslation } from 'react-i18next'; +import * as monaco from 'monaco-editor'; -// Reuse all props from the underlying Monaco Editor component, except language (we force YAML) -export type YamlEditorProps = Omit, 'language'>; +export type YamlEditorProps = Omit, 'language'> & { + isEdit?: boolean; + onApply?: (parsed: unknown, yaml: string) => void; +}; -// Simple wrapper that forwards all props to Monaco Editor export const YamlEditor = (props: YamlEditorProps) => { const { isDarkTheme } = useTheme(); - const { theme, options, ...rest } = props; + const { t } = useTranslation(); + const { theme, options, value, defaultValue, onChange, isEdit = false, onApply, ...rest } = props; const computedTheme = theme ?? (isDarkTheme ? GITHUB_DARK_DEFAULT : GITHUB_LIGHT_DEFAULT); - const enforcedOptions = { - ...options, - minimap: { enabled: false }, - }; + const [editorContent, setEditorContent] = useState(value?.toString() ?? defaultValue?.toString() ?? ''); + const [validationErrors, setValidationErrors] = useState([]); + const [applyAttempted, setApplyAttempted] = useState(false); + + useEffect(() => { + if (typeof value !== 'undefined') { + setEditorContent(value.toString()); + } + }, [value]); + + const enforcedOptions: monaco.editor.IStandaloneEditorConstructionOptions = useMemo( + () => ({ + ...(options as monaco.editor.IStandaloneEditorConstructionOptions), + readOnly: isEdit ? false : (options?.readOnly ?? true), + minimap: { enabled: false }, + wordWrap: 'on' as const, + scrollBeyondLastLine: false, + }), + [options, isEdit], + ); + + const handleEditorChange = useCallback( + (val: string | undefined, event?: monaco.editor.IModelContentChangedEvent) => { + if (isEdit) { + setEditorContent(val ?? ''); + } + onChange?.(val ?? '', event); + }, + [isEdit, onChange], + ); + + const handleApply = useCallback(() => { + const run = async () => { + setApplyAttempted(true); + try { + const doc = parseDocument(editorContent); + if (doc.errors && doc.errors.length) { + setValidationErrors(doc.errors.map((e) => e.message)); + return; + } + setValidationErrors([]); + const jsObj = doc.toJS(); + if (onApply) { + await onApply(jsObj, editorContent); + } + } catch (e: unknown) { + if (e instanceof Error) { + setValidationErrors([e.message]); + } else { + setValidationErrors(['Unknown YAML parse error']); + } + } + }; + run(); + }, [editorContent, onApply]); + + const showValidationErrors = isEdit && applyAttempted && validationErrors.length > 0; return ( - +
+ {isEdit && ( + + {t('yaml.editorTitle')} + + + + )} +
+ +
+ {showValidationErrors && ( + +
    + {validationErrors.map((err, idx) => ( +
  • + {err} +
  • + ))} +
+
+ )} +
); }; diff --git a/src/lib/api/types/crate/createManagedControlPlane.ts b/src/lib/api/types/crate/createManagedControlPlane.ts index 315a5fa4..eb8f421d 100644 --- a/src/lib/api/types/crate/createManagedControlPlane.ts +++ b/src/lib/api/types/crate/createManagedControlPlane.ts @@ -57,13 +57,13 @@ export interface CreateManagedControlPlaneType { spec: Spec; } -// rename is used to make creation of MCP working properly -const replaceComponentsName: Record = { +const componentNameMap: Record = { 'sap-btp-service-operator': 'btpServiceOperator', 'external-secrets': 'externalSecretsOperator', }; -export const removeComponents = ['cert-manager']; +export const removedComponents = ['cert-manager']; +export const removeComponents = removedComponents; // backward compatibility alias export const CreateManagedControlPlane = ( name: string, @@ -77,7 +77,7 @@ export const CreateManagedControlPlane = ( }, idpPrefix?: string, ): CreateManagedControlPlaneType => { - const selectedComponentsListObject: Components = + const selectedComponents: Components = optional?.componentsList ?.filter( (component) => @@ -85,8 +85,8 @@ export const CreateManagedControlPlane = ( ) .map((component) => ({ ...component, - name: Object.prototype.hasOwnProperty.call(replaceComponentsName, component.name) - ? replaceComponentsName[component.name] + name: Object.prototype.hasOwnProperty.call(componentNameMap, component.name) + ? componentNameMap[component.name] : component.name, })) .reduce((acc, item) => { @@ -97,17 +97,17 @@ export const CreateManagedControlPlane = ( ({ name, isSelected }) => name === 'crossplane' && isSelected, ); - const providersListObject: Provider[] = + const selectedProviders: Provider[] = optional?.componentsList ?.filter(({ name, isSelected }) => name.includes('provider') && isSelected) .map(({ name, selectedVersion }) => ({ name: name, version: selectedVersion, })) ?? []; - const crossplaneWithProvidersListObject = { + const crossplaneWithProviders = { crossplane: { version: crossplaneComponent?.selectedVersion ?? '', - providers: providersListObject, + providers: selectedProviders, }, }; @@ -128,9 +128,9 @@ export const CreateManagedControlPlane = ( spec: { authentication: { enableSystemIdentityProvider: true }, components: { - ...selectedComponentsListObject, + ...selectedComponents, apiServer: { type: 'GardenerDedicated' }, - ...(crossplaneComponent ? crossplaneWithProvidersListObject : {}), + ...(crossplaneComponent ? crossplaneWithProviders : {}), }, authorization: { roleBindings: diff --git a/src/lib/api/types/crossplane/handleResourcePatch.ts b/src/lib/api/types/crossplane/handleResourcePatch.ts new file mode 100644 index 00000000..a9fd19f2 --- /dev/null +++ b/src/lib/api/types/crossplane/handleResourcePatch.ts @@ -0,0 +1,44 @@ +import { ManagedResourceItem } from '../../../shared/types.ts'; +import type { ApiConfig } from '../apiConfig.ts'; +import type { TFunction } from 'i18next'; +import type { RefObject } from 'react'; +import { ErrorDialogHandle } from '../../../../components/Shared/ErrorMessageBox.tsx'; +import { fetchApiServerJson } from '../../fetch.ts'; +import { APIError } from '../../error.ts'; + +export const handleResourcePatch = async (args: { + item: ManagedResourceItem; + parsed: unknown; + getPluralKind: (kind: string) => string; + apiConfig: ApiConfig; + t: TFunction; + toast: { show: (message: string, duration?: number) => void }; + errorDialogRef?: RefObject; +}): Promise => { + const { item, parsed, getPluralKind, apiConfig, t, toast, errorDialogRef } = args; + + const resourceName = item?.metadata?.name ?? ''; + const apiVersion = item?.apiVersion ?? ''; + const pluralKind = getPluralKind(item.kind); + const namespace = item?.metadata?.namespace; + + toast.show(t('ManagedResources.patchStarted', { resourceName })); + + try { + const basePath = `/apis/${apiVersion}`; + const path = namespace + ? `${basePath}/namespaces/${namespace}/${pluralKind}/${resourceName}` + : `${basePath}/${pluralKind}/${resourceName}`; + + await fetchApiServerJson(path, apiConfig, undefined, 'PATCH', JSON.stringify(parsed)); + toast.show(t('ManagedResources.patchSuccess', { resourceName })); + return true; + } catch (e) { + toast.show(t('ManagedResources.patchError', { resourceName })); + if (e instanceof APIError && errorDialogRef?.current) { + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); + } + console.error('Failed to patch resource', e); + return false; + } +}; diff --git a/src/lib/api/types/crossplane/useHandleResourcePatch.ts b/src/lib/api/types/crossplane/useHandleResourcePatch.ts new file mode 100644 index 00000000..856f8c9e --- /dev/null +++ b/src/lib/api/types/crossplane/useHandleResourcePatch.ts @@ -0,0 +1,52 @@ +import { useContext } from 'react'; +import type { RefObject } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ApiConfigContext } from '../../../../components/Shared/k8s'; +import { useToast } from '../../../../context/ToastContext.tsx'; +import { useResourcePluralNames } from '../../../../hooks/useResourcePluralNames'; +import { ErrorDialogHandle } from '../../../../components/Shared/ErrorMessageBox.tsx'; +import { fetchApiServerJson } from '../../fetch.ts'; +import { APIError } from '../../error.ts'; + +export type PatchableResourceRef = { + kind: string; + apiVersion?: string; + metadata: { + name: string; + namespace?: string; + }; +}; + +export const useHandleResourcePatch = (errorDialogRef?: RefObject) => { + const { t } = useTranslation(); + const toast = useToast(); + const apiConfig = useContext(ApiConfigContext); + const { getPluralKind } = useResourcePluralNames(); + + return async (item: PatchableResourceRef, parsed: unknown): Promise => { + const resourceName = item?.metadata?.name ?? ''; + const apiVersion = item?.apiVersion ?? ''; + const pluralKind = getPluralKind(item.kind); + const namespace = item?.metadata?.namespace; + + toast.show(t('ManagedResources.patchStarted', { resourceName })); + + try { + const basePath = `/apis/${apiVersion}`; + const path = namespace + ? `${basePath}/namespaces/${namespace}/${pluralKind}/${resourceName}` + : `${basePath}/${pluralKind}/${resourceName}`; + + await fetchApiServerJson(path, apiConfig, undefined, 'PATCH', JSON.stringify(parsed)); + toast.show(t('ManagedResources.patchSuccess', { resourceName })); + return true; + } catch (e) { + toast.show(t('ManagedResources.patchError', { resourceName })); + if (e instanceof APIError && errorDialogRef?.current) { + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); + } + console.error('Failed to patch resource', e); + return false; + } + }; +}; diff --git a/src/utils/convertToResourceConfig.spec.ts b/src/utils/convertToResourceConfig.spec.ts new file mode 100644 index 00000000..12cc57f7 --- /dev/null +++ b/src/utils/convertToResourceConfig.spec.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { convertToResourceConfig } from './convertToResourceConfig'; +import { LAST_APPLIED_CONFIGURATION_ANNOTATION } from '../lib/api/types/shared/keyNames'; +import type { Resource } from './removeManagedFieldsAndFilterData'; + +const baseResource = (): Resource => ({ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: 'example', + namespace: 'demo-ns', + labels: { app: 'demo' }, + annotations: { + [LAST_APPLIED_CONFIGURATION_ANNOTATION]: '{"dummy":"config"}', + 'custom/anno': 'keep-me', + }, + managedFields: [{ manager: 'kube-controller' }], + creationTimestamp: '2025-01-01T00:00:00Z', + finalizers: ['protect'], + generation: 3, + resourceVersion: '12345', + uid: 'abcdef', + }, + spec: { foo: 'bar' }, + status: { observedGeneration: 3 }, +}); + +describe('convertToResourceConfig', () => { + it('produces a lean manifest without status & server-only metadata', () => { + const input = baseResource(); + const output = convertToResourceConfig(input); + + // Keep essentials + expect(output.apiVersion).toEqual('v1'); + expect(output.kind).toEqual('ConfigMap'); + expect(output.metadata.name).toEqual('example'); + expect(output.metadata.namespace).toEqual('demo-ns'); + expect(output.metadata.labels).toEqual({ app: 'demo' }); + expect(output.metadata).not.toHaveProperty('finalizers'); + expect(output.spec).toEqual({ foo: 'bar' }); + + // Remove unwanted + expect(output.metadata).not.toHaveProperty('managedFields'); + expect(output.metadata).not.toHaveProperty('resourceVersion'); + expect(output.metadata).not.toHaveProperty('uid'); + expect(output.metadata).not.toHaveProperty('generation'); + expect(output.metadata).not.toHaveProperty('creationTimestamp'); + // Removed annotation + expect(output.metadata.annotations?.[LAST_APPLIED_CONFIGURATION_ANNOTATION]).toBeUndefined(); + // Custom annotation kept + expect(output.metadata.annotations?.['custom/anno']).toEqual('keep-me'); + // Status removed + expect(output.status).toBeUndefined(); + }); + + it('handles list resources recursively', () => { + const list: Resource = { + apiVersion: 'v1', + kind: 'ConfigMapList', + metadata: { name: 'ignored-list-meta' }, + items: [baseResource(), baseResource()], + }; + + const out = convertToResourceConfig(list); + expect(out.items).toBeDefined(); + expect(out.items?.length).toEqual(2); + out.items?.forEach((item) => { + expect(item.metadata.annotations?.[LAST_APPLIED_CONFIGURATION_ANNOTATION]).toBeUndefined(); + expect(item.metadata.labels).toEqual({ app: 'demo' }); + expect(item.status).toBeUndefined(); + }); + }); + + it('returns empty object shape when input is null/undefined', () => { + const out = convertToResourceConfig(null); + expect(out).toBeInstanceOf(Object); + }); +}); diff --git a/src/utils/convertToResourceConfig.ts b/src/utils/convertToResourceConfig.ts new file mode 100644 index 00000000..8c5fb0a0 --- /dev/null +++ b/src/utils/convertToResourceConfig.ts @@ -0,0 +1,54 @@ +import { LAST_APPLIED_CONFIGURATION_ANNOTATION } from '../lib/api/types/shared/keyNames'; +import type { Resource } from './removeManagedFieldsAndFilterData'; + +/** + * Convert an in-cluster Resource (which may contain status and server-populated metadata) + * into a lean manifest suitable for applying with kubectl. + * Rules: + * - Keep: apiVersion, kind, metadata.name, metadata.namespace, metadata.labels, metadata.annotations (except LAST_APPLIED_CONFIGURATION_ANNOTATION), spec. + * - Remove: metadata.managedFields, metadata.resourceVersion, metadata.uid, metadata.generation, metadata.creationTimestamp, + * LAST_APPLIED_CONFIGURATION_ANNOTATION annotation, status, metadata.finalizers. + * - If a List (has items), convert each item recursively. + */ +export const convertToResourceConfig = (resourceObject: Resource | undefined | null): Resource => { + if (!resourceObject) return {} as Resource; + + const base: Resource = { + apiVersion: resourceObject.apiVersion, + kind: resourceObject.kind, + metadata: { + name: resourceObject.metadata?.name || '', + }, + } as Resource; + + if (resourceObject.metadata?.namespace) { + base.metadata.namespace = resourceObject.metadata.namespace; + } + if (resourceObject.metadata?.labels && Object.keys(resourceObject.metadata.labels).length > 0) { + base.metadata.labels = { ...resourceObject.metadata.labels }; + } + if (resourceObject.metadata?.annotations) { + const filtered = { ...resourceObject.metadata.annotations }; + delete filtered[LAST_APPLIED_CONFIGURATION_ANNOTATION]; + // Remove empty annotation object + const keys = Object.keys(filtered).filter((k) => filtered[k] !== undefined && filtered[k] !== ''); + if (keys.length > 0) { + base.metadata.annotations = keys.reduce>((acc, k) => { + const v = filtered[k]; + if (typeof v === 'string') acc[k] = v; + return acc; + }, {}); + } + } + + if (resourceObject.spec !== undefined) { + base.spec = resourceObject.spec; + } + + // If list: map items + if (resourceObject.items) { + base.items = resourceObject.items.map((it) => convertToResourceConfig(it)); + } + + return base; +};