Skip to content

Commit a7c9c0b

Browse files
committed
Resolves: MTV-3641 | Added custom scripts to plan details via Automation tab
Signed-off-by: Jeff Puzzo <jpuzzo@redhat.com>
1 parent d74bcbe commit a7c9c0b

File tree

18 files changed

+758
-10
lines changed

18 files changed

+758
-10
lines changed

locales/en/plugin__forklift-console-plugin.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@
190190
"Authentication type": "Authentication type",
191191
"Automatic VM renaming": "Automatic VM renaming",
192192
"Automatically decrypt LUKS-encrypted disks using Tang servers during migration. The Tang servers must be accessible from the OpenShift cluster.": "Automatically decrypt LUKS-encrypted disks using Tang servers during migration. The Tang servers must be accessible from the OpenShift cluster.",
193+
"Automation": "Automation",
194+
"Automation (optional)": "Automation (optional)",
193195
"Back": "Back",
194196
"Bandwidth": "Bandwidth",
195197
"Basic setup": "Basic setup",
@@ -273,11 +275,13 @@
273275
"Configure certificate validation": "Configure certificate validation",
274276
"Configure certificate validation (Recommended):": "Configure certificate validation (Recommended):",
275277
"Configure disk decryption settings including passphrases for LUKS-encrypted devices or network-bound disk encryption (NBDE/Clevis) for the VMs you want to migrate.": "Configure disk decryption settings including passphrases for LUKS-encrypted devices or network-bound disk encryption (NBDE/Clevis) for the VMs you want to migrate.",
278+
"Configured": "Configured",
276279
"Confirm selections": "Confirm selections",
277280
"Confirm selections with critical issues": "Confirm selections with critical issues",
278281
"Connection Failed": "Connection Failed",
279282
"Considerations": "Considerations",
280283
"Container": "Container",
284+
"Content": "Content",
281285
"Controller inventory container memory limit": "Controller inventory container memory limit",
282286
"Controller main container CPU limit": "Controller main container CPU limit",
283287
"Controller main container memory limit": "Controller main container memory limit",
@@ -317,7 +321,7 @@
317321
"critical concerns impacting your migration plan": "critical concerns impacting your migration plan",
318322
"Critical issues detected in your selected VMs will cause the migration to fail. Resolve these issues or remove the VMs from the plan before starting the migration.": "Critical issues detected in your selected VMs will cause the migration to fail. Resolve these issues or remove the VMs from the plan before starting the migration.",
319323
"Custom name template": "Custom name template",
320-
"Customization scripts (optional)": "Customization scripts (optional)",
324+
"Customization scripts": "Customization scripts",
321325
"Cutover": "Cutover",
322326
"Data centers": "Data centers",
323327
"Data is loading, please wait.": "Data is loading, please wait.",
@@ -384,6 +388,7 @@
384388
"Edit convertor pod affinity rules": "Edit convertor pod affinity rules",
385389
"Edit convertor pod labels": "Edit convertor pod labels",
386390
"Edit convertor pod node selector": "Edit convertor pod node selector",
391+
"Edit customization scripts": "Edit customization scripts",
387392
"Edit cutover": "Edit cutover",
388393
"Edit default transfer network": "Edit default transfer network",
389394
"Edit description": "Edit description",
@@ -962,6 +967,7 @@
962967
"Region": "Region",
963968
"Regions": "Regions",
964969
"Remove cutover": "Remove cutover",
970+
"Remove script": "Remove script",
965971
"Reorder": "Reorder",
966972
"Requests persistent volume (PV) resources without having specific knowledge of the underlying storage infrastructure.": "Requests persistent volume (PV) resources without having specific knowledge of the underlying storage infrastructure.",
967973
"Required during scheduling": "Required during scheduling",
@@ -991,8 +997,10 @@
991997
"Save target power state": "Save target power state",
992998
"Schedule cutover": "Schedule cutover",
993999
"Script content": "Script content",
1000+
"Script content is required.": "Script content is required.",
9941001
"Script name": "Script name",
9951002
"Script name is required.": "Script name is required.",
1003+
"Script name must be unique.": "Script name must be unique.",
9961004
"Script name must contain only lowercase letters, numbers, hyphens, and underscores, and start with a letter or number.": "Script name must contain only lowercase letters, numbers, hyphens, and underscores, and start with a letter or number.",
9971005
"Script type": "Script type",
9981006
"Scripts": "Scripts",
@@ -1080,6 +1088,8 @@
10801088
"Short snapshot polling interval": "Short snapshot polling interval",
10811089
"Show archived": "Show archived",
10821090
"Show default projects": "Show default projects",
1091+
"Show less": "Show less",
1092+
"Show more": "Show more",
10831093
"Show variables": "Show variables",
10841094
"Since the VM is offline, there are no live changes to track, making the data copy a one-time, full-disk transfer.": "Since the VM is offline, there are no live changes to track, making the data copy a one-time, full-disk transfer.",
10851095
"Skip certificate validation": "Skip certificate validation",

src/plans/create/CreatePlanWizardInner.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ const CreatePlanWizardInner: FC<CreatePlanWizardInnerProps> = ({
118118
<OtherSettingsStep isLiveMigrationFeatureEnabled={isLiveMigrationFeatureEnabled} />
119119
</WizardStep>,
120120
<WizardStep
121-
key={PlanWizardStepId.CustomizationScripts}
122-
{...getStepProps(PlanWizardStepId.CustomizationScripts)}
121+
key={PlanWizardStepId.Automation}
122+
{...getStepProps(PlanWizardStepId.Automation)}
123123
>
124124
<CustomScriptsStep />
125125
</WizardStep>,

src/plans/create/constants.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,15 @@ export enum PlanWizardStepId {
2222
MigrationType = 'migration-type',
2323
AdditionalSetup = 'additional-setup',
2424
OtherSettings = 'other-settings',
25-
CustomizationScripts = 'customization-scripts',
25+
Automation = 'automation',
2626
Hooks = 'hooks',
2727
ReviewAndCreate = 'review-and-create',
2828
}
2929

3030
export const planStepNames: Record<PlanWizardStepId, ReturnType<typeof t>> = {
3131
[PlanWizardStepId.AdditionalSetup]: t('Additional setup'),
32+
[PlanWizardStepId.Automation]: t('Automation (optional)'),
3233
[PlanWizardStepId.BasicSetup]: t('Basic setup'),
33-
[PlanWizardStepId.CustomizationScripts]: t('Customization scripts (optional)'),
3434
[PlanWizardStepId.General]: t('General'),
3535
[PlanWizardStepId.Hooks]: t('Hooks (optional)'),
3636
[PlanWizardStepId.MigrationType]: t('Migration type'),
@@ -43,8 +43,8 @@ export const planStepNames: Record<PlanWizardStepId, ReturnType<typeof t>> = {
4343

4444
export const planStepOrder: Record<PlanWizardStepId, number> = {
4545
[PlanWizardStepId.AdditionalSetup]: 7,
46+
[PlanWizardStepId.Automation]: 9,
4647
[PlanWizardStepId.BasicSetup]: 1,
47-
[PlanWizardStepId.CustomizationScripts]: 9,
4848
[PlanWizardStepId.General]: 2,
4949
[PlanWizardStepId.Hooks]: 10,
5050
[PlanWizardStepId.MigrationType]: 6,
@@ -66,8 +66,8 @@ export const CreatePlanWizardContext = createContext({} as CreatePlanWizardConte
6666

6767
export const stepFieldMap: Record<PlanWizardStepId, string[]> = {
6868
[PlanWizardStepId.AdditionalSetup]: [],
69+
[PlanWizardStepId.Automation]: Object.values(CustomScriptsFieldId),
6970
[PlanWizardStepId.BasicSetup]: [],
70-
[PlanWizardStepId.CustomizationScripts]: Object.values(CustomScriptsFieldId),
7171
[PlanWizardStepId.General]: Object.values(GeneralFormFieldId),
7272
[PlanWizardStepId.Hooks]: Object.values(HooksFormFieldId),
7373
[PlanWizardStepId.MigrationType]: Object.values(MigrationTypeFieldId),

src/plans/create/steps/customization-scripts/CustomScriptsStep.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const CustomScriptsStep: FC = () => {
1919

2020
return (
2121
<WizardStepContainer
22-
title={planStepNames[PlanWizardStepId.CustomizationScripts]}
22+
title={planStepNames[PlanWizardStepId.Automation]}
2323
description={
2424
<Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
2525
<FlexItem>

src/plans/create/steps/review/CustomScriptsReviewSection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ const CustomScriptsReviewSection: FC = () => {
3838

3939
return (
4040
<ExpandableReviewSection
41-
title={planStepNames[PlanWizardStepId.CustomizationScripts]}
41+
title={planStepNames[PlanWizardStepId.Automation]}
4242
testId="review-custom-scripts-section"
4343
onEditClick={() => {
44-
goToStepById(PlanWizardStepId.CustomizationScripts);
44+
goToStepById(PlanWizardStepId.Automation);
4545
}}
4646
>
4747
<DescriptionList isHorizontal horizontalTermWidthModifier={{ default: '18ch' }}>

src/plans/details/hooks/usePlanPages.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
33
import type { NavPage } from '@openshift-console/dynamic-plugin-sdk';
44
import { useForkliftTranslation } from '@utils/i18n';
55

6+
import PlanAutomationPage from '../tabs/Automation/PlanAutomationPage';
67
import PlanDetailsPage from '../tabs/Details/PlanDetailsPage';
78
import PlanHooksPage from '../tabs/Hooks/PlanHooksPage';
89
import PlanMappingsPage from '../tabs/Mappings/PlanMappingsPage';
@@ -39,6 +40,11 @@ const usePlanPages = (name: string, namespace: string) => {
3940
href: 'mappings',
4041
name: t('Mappings'),
4142
},
43+
{
44+
component: () => <PlanAutomationPage name={name} namespace={namespace} />,
45+
href: 'automation',
46+
name: t('Automation'),
47+
},
4248
{
4349
component: () => <PlanHooksPage name={name} namespace={namespace} />,
4450
href: 'hooks',
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { FC } from 'react';
2+
3+
import LoadingSuspend from '@components/LoadingSuspend';
4+
import { Flex, PageSection } from '@patternfly/react-core';
5+
6+
import { usePlan } from '../../hooks/usePlan';
7+
import type { PlanPageProps } from '../../utils/types';
8+
9+
import ScriptsSection from './components/ScriptsSection/ScriptsSection';
10+
import { usePlanCustomScripts } from './hooks/usePlanCustomScripts';
11+
12+
const PlanAutomationPage: FC<PlanPageProps> = ({ name, namespace }) => {
13+
const { loaded: loadedPlan, loadError: planError, plan } = usePlan(name, namespace);
14+
const {
15+
configMap,
16+
error: scriptsError,
17+
loaded: loadedScripts,
18+
scripts,
19+
} = usePlanCustomScripts(plan);
20+
21+
return (
22+
<LoadingSuspend
23+
obj={plan}
24+
loaded={loadedPlan && loadedScripts}
25+
loadError={planError ?? scriptsError}
26+
>
27+
<PageSection hasBodyWrapper={false} className="pf-v6-u-h-100">
28+
<Flex direction={{ default: 'column' }}>
29+
<ScriptsSection configMap={configMap} plan={plan} scripts={scripts} />
30+
</Flex>
31+
</PageSection>
32+
</LoadingSuspend>
33+
);
34+
};
35+
36+
export default PlanAutomationPage;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { GuestType, ScriptType } from 'src/plans/create/steps/customization-scripts/constants';
2+
import type { CustomScript } from 'src/plans/create/steps/customization-scripts/types';
3+
4+
import type { IoK8sApiCoreV1ConfigMap, V1beta1Plan } from '@forklift-ui/types';
5+
import { beforeEach, describe, expect, it } from '@jest/globals';
6+
import { render, screen } from '@testing-library/react';
7+
import userEvent from '@testing-library/user-event';
8+
9+
import ScriptsSection from '../components/ScriptsSection/ScriptsSection';
10+
11+
const mockIsPlanEditable = jest.fn();
12+
jest.mock('src/plans/details/components/PlanStatus/utils/utils', () => ({
13+
isPlanEditable: jest.fn((...args) => mockIsPlanEditable(...args)),
14+
}));
15+
16+
const mockLaunchOverlay = jest.fn();
17+
jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({
18+
getGroupVersionKindForModel: jest.fn(() => ({ group: '', kind: 'ConfigMap', version: 'v1' })),
19+
ResourceLink: ({ name }: { name: string }) => <span data-testid="resource-link">{name}</span>,
20+
useOverlay: jest.fn(() => mockLaunchOverlay),
21+
}));
22+
23+
const mockPlan = {
24+
metadata: { name: 'test-plan', namespace: 'test-ns' },
25+
spec: {},
26+
} as unknown as V1beta1Plan;
27+
28+
const mockConfigMap = {
29+
data: {},
30+
metadata: { name: 'test-plan-scripts', namespace: 'test-ns' },
31+
} as unknown as IoK8sApiCoreV1ConfigMap;
32+
33+
const mockScripts: CustomScript[] = [
34+
{
35+
content: '#!/bin/bash\necho hello',
36+
guestType: GuestType.Linux,
37+
name: 'setup-network',
38+
scriptType: ScriptType.Firstboot,
39+
},
40+
{
41+
content: 'Remove-Item C:\\temp',
42+
guestType: GuestType.Windows,
43+
name: 'cleanup',
44+
scriptType: ScriptType.Firstboot,
45+
},
46+
];
47+
48+
describe('ScriptsSection', () => {
49+
beforeEach(() => {
50+
jest.clearAllMocks();
51+
});
52+
53+
it('shows "None" when no scripts are configured', () => {
54+
render(<ScriptsSection configMap={undefined} plan={mockPlan} scripts={[]} />);
55+
56+
expect(screen.getByText('Customization scripts')).toBeInTheDocument();
57+
expect(screen.getByText('None')).toBeInTheDocument();
58+
});
59+
60+
it('displays script details when scripts exist', () => {
61+
mockIsPlanEditable.mockReturnValue(true);
62+
63+
render(<ScriptsSection configMap={mockConfigMap} plan={mockPlan} scripts={mockScripts} />);
64+
65+
expect(screen.getByText('test-plan-scripts')).toBeInTheDocument();
66+
expect(screen.getByText('setup-network')).toBeInTheDocument();
67+
expect(screen.getByText('cleanup')).toBeInTheDocument();
68+
expect(screen.getByText('Linux')).toBeInTheDocument();
69+
expect(screen.getByText('Windows')).toBeInTheDocument();
70+
});
71+
72+
it('shows edit button and launches overlay on click', async () => {
73+
const user = userEvent.setup();
74+
mockIsPlanEditable.mockReturnValue(true);
75+
76+
render(<ScriptsSection configMap={mockConfigMap} plan={mockPlan} scripts={mockScripts} />);
77+
78+
const editButton = screen.getByRole('button', { name: /edit/i });
79+
await user.click(editButton);
80+
81+
expect(mockLaunchOverlay).toHaveBeenCalledTimes(1);
82+
});
83+
84+
it('disables edit button when plan is not editable', () => {
85+
mockIsPlanEditable.mockReturnValue(false);
86+
87+
render(<ScriptsSection configMap={mockConfigMap} plan={mockPlan} scripts={mockScripts} />);
88+
89+
const editButton = screen.getByRole('button', { name: /edit/i });
90+
expect(editButton).toBeDisabled();
91+
});
92+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { GuestType, ScriptType } from 'src/plans/create/steps/customization-scripts/constants';
2+
3+
import { describe, expect, it } from '@jest/globals';
4+
5+
import { parseConfigMapScripts } from '../utils/parseConfigMapScripts';
6+
7+
describe('parseConfigMapScripts', () => {
8+
it('parses a Linux firstboot script key', () => {
9+
const data = { '99999_linux_firstboot_setup-network.sh': '#!/bin/bash\necho hello' };
10+
const result = parseConfigMapScripts(data);
11+
12+
expect(result).toEqual([
13+
{
14+
content: '#!/bin/bash\necho hello',
15+
guestType: GuestType.Linux,
16+
name: 'setup-network',
17+
scriptType: ScriptType.Firstboot,
18+
},
19+
]);
20+
});
21+
22+
it('parses a Windows firstboot script key', () => {
23+
const data = { '99999_win_firstboot_cleanup.ps1': 'Remove-Item C:\\temp' };
24+
const result = parseConfigMapScripts(data);
25+
26+
expect(result).toEqual([
27+
{
28+
content: 'Remove-Item C:\\temp',
29+
guestType: GuestType.Windows,
30+
name: 'cleanup',
31+
scriptType: ScriptType.Firstboot,
32+
},
33+
]);
34+
});
35+
36+
it('parses a Linux run script key', () => {
37+
const data = { '99999_linux_run_remove-tools.sh': 'yum remove open-vm-tools' };
38+
const result = parseConfigMapScripts(data);
39+
40+
expect(result).toEqual([
41+
{
42+
content: 'yum remove open-vm-tools',
43+
guestType: GuestType.Linux,
44+
name: 'remove-tools',
45+
scriptType: ScriptType.Run,
46+
},
47+
]);
48+
});
49+
50+
it('skips non-script keys', () => {
51+
const data = {
52+
'99999_linux_firstboot_setup.sh': 'echo setup',
53+
'some-other-key': 'ignored',
54+
};
55+
const result = parseConfigMapScripts(data);
56+
57+
expect(result).toHaveLength(1);
58+
expect(result[0].name).toBe('setup');
59+
});
60+
61+
it('returns empty array for undefined data', () => {
62+
expect(parseConfigMapScripts(undefined)).toEqual([]);
63+
});
64+
65+
it('returns empty array for data with no script keys', () => {
66+
expect(parseConfigMapScripts({ 'not-a-script': 'value' })).toEqual([]);
67+
});
68+
69+
it('handles script names with underscores', () => {
70+
const data = { '99999_linux_firstboot_setup_my_network.sh': 'echo hi' };
71+
const result = parseConfigMapScripts(data);
72+
73+
expect(result[0].name).toBe('setup_my_network');
74+
});
75+
});

0 commit comments

Comments
 (0)