Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [M3-6146] - Add User Data to Linode Rebuild flow #8850

5 changes: 3 additions & 2 deletions packages/api-v4/src/linodes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,8 @@ export interface IPAllocationRequest {
public: boolean;
}

interface UserData {
user_data: string;
export interface UserData {
user_data: string | null;
}

export interface CreateLinodeRequest {
Expand Down Expand Up @@ -345,6 +345,7 @@ export interface LinodeCloneData {
export interface RebuildRequest {
image: string;
root_pass: string;
metadata?: UserData;
authorized_keys?: SSHKey[];
authorized_users?: string[];
stackscript_id?: number;
Expand Down
5 changes: 4 additions & 1 deletion packages/manager/src/components/CheckBox/CheckBox.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SxProps } from '@mui/system';
import classNames from 'classnames';
import * as React from 'react';
import CheckboxIcon from 'src/assets/icons/checkbox.svg';
Expand Down Expand Up @@ -39,10 +40,11 @@ interface Props extends CheckboxProps {
text?: string | JSX.Element;
toolTipText?: string | JSX.Element;
toolTipInteractive?: boolean;
sxFormLabel?: SxProps;
}

const LinodeCheckBox = (props: Props) => {
const { toolTipInteractive, toolTipText, text, ...rest } = props;
const { toolTipInteractive, toolTipText, text, sxFormLabel, ...rest } = props;
const classes = useStyles();

const classnames = classNames({
Expand All @@ -67,6 +69,7 @@ const LinodeCheckBox = (props: Props) => {
/>
}
label={text}
sx={sxFormLabel}
/>
{toolTipText ? (
<HelpIcon interactive={toolTipInteractive} text={toolTipText} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Notice from 'src/components/Notice';
import { styled } from '@mui/material/styles';

export const StyledNotice = styled(Notice)({
// @TODO: Remove the !important's once Notice.tsx has been refactored to use MUI's styled()
padding: '8px !important',
marginBottom: '0px !important',
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { rebuildLinode, RebuildRequest } from '@linode/api-v4/lib/linodes';
import {
rebuildLinode,
RebuildRequest,
UserData,
} from '@linode/api-v4/lib/linodes';
import { RebuildLinodeSchema } from '@linode/validation/lib/linodes.schema';
import { Formik, FormikProps } from 'formik';
import { useSnackbar } from 'notistack';
Expand All @@ -8,11 +12,15 @@ import { compose } from 'recompose';
import AccessPanel from 'src/components/AccessPanel';
import ActionsPanel from 'src/components/ActionsPanel';
import Button from 'src/components/Button';
import CheckBox from 'src/components/CheckBox';
import Box from 'src/components/core/Box';
import Divider from 'src/components/core/Divider';
import { makeStyles, Theme } from 'src/components/core/styles';
import Grid from 'src/components/Grid';
import ImageSelect from 'src/components/ImageSelect';
import TypeToConfirm from 'src/components/TypeToConfirm';
import { resetEventsPolling } from 'src/eventsPolling';
import UserDataAccordion from 'src/features/linodes/UserDataAccordion';
import userSSHKeyHoc, {
UserSSHKeyProps,
} from 'src/features/linodes/userSSHKeyHoc';
Expand All @@ -25,6 +33,7 @@ import {
} from 'src/utilities/formikErrorUtils';
import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView';
import { extendValidationSchema } from 'src/utilities/validatePassword';
import { StyledNotice } from './RebuildFromImage.styles';

const useStyles = makeStyles((theme: Theme) => ({
root: {
Expand Down Expand Up @@ -55,11 +64,15 @@ export type CombinedProps = Props & UserSSHKeyProps;
interface RebuildFromImageForm {
image: string;
root_pass: string;
metadata?: UserData;
}

const initialValues: RebuildFromImageForm = {
image: '',
root_pass: '',
metadata: {
user_data: '',
},
};

export const RebuildFromImage: React.FC<CombinedProps> = (props) => {
Expand All @@ -79,12 +92,30 @@ export const RebuildFromImage: React.FC<CombinedProps> = (props) => {

const classes = useStyles();
const { enqueueSnackbar } = useSnackbar();
const { data: _imagesData, error: imagesError } = useAllImagesQuery();

const RebuildSchema = () => extendValidationSchema(RebuildLinodeSchema);

const [confirmationText, setConfirmationText] = React.useState<string>('');

const { data: _imagesData, error: imagesError } = useAllImagesQuery();
const [userData, setUserData] = React.useState<string | undefined>('');
const [shouldReuseUserData, setShouldReuseUserData] = React.useState<boolean>(
false
);

const handleUserDataChange = (userData: string) => {
setUserData(userData);
};

const handleShouldReuseUserDataChange = () => {
setShouldReuseUserData((shouldReuseUserData) => !shouldReuseUserData);
};

React.useEffect(() => {
if (shouldReuseUserData) {
setUserData('');
}
}, [shouldReuseUserData]);

const submitButtonDisabled =
preferences?.type_to_confirm !== false && confirmationText !== linodeLabel;
Expand All @@ -101,11 +132,29 @@ export const RebuildFromImage: React.FC<CombinedProps> = (props) => {
const params: RebuildRequest = {
image,
root_pass,
metadata: {
user_data: userData
? window.btoa(userData)
: !userData && !shouldReuseUserData
? null
: '',
},
authorized_users: userSSHKeys
.filter((u) => u.selected)
.map((u) => u.username),
};

/*
User Data logic:
1) if user data has been provided, encode it and include it in the payload
2) if user data has not been provided and the Reuse User Data checkbox is
not checked, send null in the payload
3) if the Reuse User Data checkbox is checked, remove the Metadata property from the payload.
*/
if (shouldReuseUserData) {
delete params['metadata'];
}

// @todo: eventually this should be a dispatched action instead of a services library call
rebuildLinode(linodeId, params)
.then((_) => {
Expand Down Expand Up @@ -168,6 +217,13 @@ export const RebuildFromImage: React.FC<CombinedProps> = (props) => {
handleRebuildError(status.generalError);
}

const shouldDisplayUserDataAccordion = Boolean(
values.image &&
_imagesData
?.find((image) => image.id === values.image)
?.capabilities?.includes('cloud-init')
);

cpathipa marked this conversation as resolved.
Show resolved Hide resolved
return (
<Grid item className={classes.root}>
<form>
Expand Down Expand Up @@ -203,6 +259,32 @@ export const RebuildFromImage: React.FC<CombinedProps> = (props) => {
disabled={disabled}
passwordHelperText={passwordHelperText}
/>
{shouldDisplayUserDataAccordion ? (
<>
<Divider spacingTop={40} />
<UserDataAccordion
userData={userData}
onChange={handleUserDataChange}
disabled={shouldReuseUserData}
renderNotice={
<StyledNotice
success
text="Adding new user data is recommended as part of the rebuild process."
/>
}
renderCheckbox={
<Box>
<CheckBox
checked={shouldReuseUserData}
onChange={handleShouldReuseUserDataChange}
text="Reuse user data previously provided for this Linode."
sxFormLabel={{ paddingLeft: '2px' }}
/>
</Box>
}
/>
</>
) : null}
<ActionsPanel className={classes.actionPanel}>
<TypeToConfirm
confirmationText={
Expand Down
10 changes: 10 additions & 0 deletions packages/manager/src/features/linodes/UserDataAccordion.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { styled } from '@mui/material/styles';
import HelpIcon from 'src/components/HelpIcon';

export const StyledHelpIcon = styled(HelpIcon)({
padding: '0px 0px 4px 8px',
'& svg': {
fill: 'currentColor',
stroke: 'none',
},
});
13 changes: 13 additions & 0 deletions packages/manager/src/features/linodes/UserDataAccordion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from 'react';
import { renderWithTheme } from 'src/utilities/testHelpers';
import UserDataAccordion from 'src/features/linodes/UserDataAccordion';

describe('UserDataAccordion', () => {
it('should NOT have a notice when a renderNotice prop is not passed in', () => {
const { queryByTestId } = renderWithTheme(
<UserDataAccordion userData={''} onChange={() => null} />
);

expect(queryByTestId('render-notice')).toBeNull();
});
});
109 changes: 55 additions & 54 deletions packages/manager/src/features/linodes/UserDataAccordion.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,23 @@
import * as React from 'react';
import Accordion from 'src/components/Accordion';
import { makeStyles } from 'src/components/core/styles';
import Typography from 'src/components/core/Typography';
import HelpIcon from 'src/components/HelpIcon';
import Notice from 'src/components/Notice';
import Link from 'src/components/Link';
import Notice from 'src/components/Notice';
import TextField from 'src/components/TextField';
import { StyledHelpIcon } from './UserDataAccordion.styles';

interface Props {
userData: string | undefined;
onChange: (userData: string) => void;
disabled?: boolean;
renderNotice?: JSX.Element;
renderCheckbox?: JSX.Element;
}

const useStyles = makeStyles(() => ({
helpIcon: {
padding: '0px 0px 4px 8px',
'& svg': {
fill: 'currentColor',
stroke: 'none',
},
},
accordionSummary: {
padding: '5px 24px 0px 24px',
},
accordionDetail: {
padding: '0px 24px 24px 24px',
},
}));

const UserDataAccordion = (props: Props) => {
const { disabled, userData, onChange } = props;
const { disabled, userData, onChange, renderNotice, renderCheckbox } = props;
const [formatWarning, setFormatWarning] = React.useState(false);

const classes = useStyles();

const checkFormat = ({
userData,
hasInputValueChanged,
Expand All @@ -56,50 +39,33 @@ const UserDataAccordion = (props: Props) => {
}
};

const accordionHeading = (
<>
Add User Data{' '}
<HelpIcon
text={
<>
User data is part of a virtual machine&rsquo;s cloud-init metadata
containing information related to a user&rsquo;s local account.{' '}
<Link to="/">Learn more.</Link>
</>
}
className={classes.helpIcon}
interactive
/>
</>
);
const sxDetails = {
padding: `0px 24px 24px ${renderNotice ? 0 : 24}px`,
};

return (
<Accordion
heading={accordionHeading}
style={{ marginTop: 24 }}
style={{ marginTop: renderNotice && renderCheckbox ? 0 : 24 }} // for now, these props can be taken as an indicator we're in the Rebuild flow.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also access props within Styled components to conditionally apply styles.
Example:

const StyledAccordion = styled(Accordion)`
  margin-top: ${({ renderNotice, renderCheckbox }) => (renderNotice && renderCheckbox ? 0 : 24)};
  &:before {
    display: none;
  }
`;
<StyledAccordion
  heading={accordionHeading}
  renderNotice={renderNotice}
  renderCheckbox={renderCheckbox}
....
>

Copy link
Contributor

@jaalah-akamai jaalah-akamai Mar 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do down that route, for code readability I recommend using only one top-level function, especially if in the future we need to add to this:

const Accordion = styled(AccordionComponent)(({ renderNotice, renderCheckbox }) => ({
  marginTop: renderNotice && renderCheckbox ? 0 : 24,
  '&:before': {
    display: 'none',
  },
}));

headingProps={{
variant: 'h2',
}}
summaryProps={{
classes: {
root: classes.accordionSummary,
},
sx: { padding: '5px 24px 0px 24px' },
}}
detailProps={{
classes: {
root: classes.accordionDetail,
detailProps={{ sx: sxDetails }}
sx={{
'&:before': {
display: 'none',
},
}}
>
<Typography>
<Link to="https://cloudinit.readthedocs.io/en/latest/reference/examples.html">
User Data
</Link>{' '}
is part of a virtual machine&rsquo;s cloud-init metadata that contains
anything related to a user&rsquo;s local account, including username and
user group(s). <br /> Accepted formats are YAML and bash.{' '}
<Link to="https://www.linode.com/docs">Learn more.</Link>
</Typography>
{renderNotice ? (
<div data-testid="render-notice">{renderNotice}</div>
) : (
userDataExplanatoryCopy
dwiley-akamai marked this conversation as resolved.
Show resolved Hide resolved
)}
{acceptedFormatsCopy}
dwiley-akamai marked this conversation as resolved.
Show resolved Hide resolved
{formatWarning ? (
<Notice warning spacingTop={16} spacingBottom={16}>
This user data may not be in a format accepted by cloud-init.
Expand All @@ -121,8 +87,43 @@ const UserDataAccordion = (props: Props) => {
}
data-qa-user-data-input
/>
{renderCheckbox ?? null}
</Accordion>
);
};

export default UserDataAccordion;

const accordionHeading = (
dwiley-akamai marked this conversation as resolved.
Show resolved Hide resolved
<>
Add User Data{' '}
<StyledHelpIcon
text={
<>
User data is part of a virtual machine&rsquo;s cloud-init metadata
containing information related to a user&rsquo;s local account.{' '}
<Link to="/">Learn more.</Link>
</>
}
interactive
/>
</>
);

const userDataExplanatoryCopy = (
dwiley-akamai marked this conversation as resolved.
Show resolved Hide resolved
<Typography>
<Link to="https://cloudinit.readthedocs.io/en/latest/reference/examples.html">
User Data
</Link>{' '}
is part of a virtual machine&rsquo;s cloud-init metadata that contains
anything related to a user&rsquo;s local account, including username and
user group(s).
</Typography>
);
dwiley-akamai marked this conversation as resolved.
Show resolved Hide resolved

const acceptedFormatsCopy = (
dwiley-akamai marked this conversation as resolved.
Show resolved Hide resolved
<Typography>
<br /> Accepted formats are YAML and bash.{' '}
<Link to="https://www.linode.com/docs">Learn more.</Link>
</Typography>
);
cpathipa marked this conversation as resolved.
Show resolved Hide resolved
Loading