diff --git a/config/cosmos.config.js b/config/cosmos.config.js index c9e461ba0..f72e920d9 100644 --- a/config/cosmos.config.js +++ b/config/cosmos.config.js @@ -9,7 +9,12 @@ module.exports = { fileMatch: '**/*.fixture.js', // additional modules to load along with every component - globalImports: ['@babel/polyfill'], + globalImports: [ + '@babel/polyfill', + 'patternfly-react/dist/css/patternfly-react.css', + 'patternfly/dist/css/patternfly.css', + 'patternfly/dist/css/patternfly-additions.css' + ], // path to Cosmos proxies proxiesPath: `${paths.src}/cosmos/proxies.js`, diff --git a/config/cosmos.webpack.config.js b/config/cosmos.webpack.config.js index e860838ed..80aa5cb70 100644 --- a/config/cosmos.webpack.config.js +++ b/config/cosmos.webpack.config.js @@ -17,6 +17,14 @@ module.exports = { include: paths.src, loader: 'babel-loader', options: babelOptions + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'] + }, + { + test: /\.(eot|svg|ttf|woff|woff2|gif|jpg|png)$/, + loader: 'url-loader' } ] } diff --git a/package.json b/package.json index 175ed05ef..2ae85742c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "test:watch": "yarn test --watch", "prebuild": "yarn test --ci && shx rm -rf dist", "build": "yarn build:js && yarn build:sass", - "build:js": "babel src --config-file ./config/babel.config.js --out-dir dist/js --only 'src/components/**/*.js,src/index.js' --ignore '**/tests/**,**/fixtures/**'", + "build:js": "babel src --config-file ./config/babel.config.js --out-dir dist/js --only 'src/**/*.js' --ignore 'src/jest,src/cosmos,**/tests/**,**/fixtures/**'", "build:sass": "shx cp -r sass dist", "cosmos": "cosmos --config config/cosmos.config.js", "cosmos:export": "shx rm -rf cosmos && NODE_ENV=production cosmos-export --config config/cosmos.config.js", @@ -49,6 +49,7 @@ "babel-loader": "8.x", "chalk": "2.x", "coveralls": "3.x", + "css-loader": "1.0.x", "enzyme": "3.x", "enzyme-adapter-react-16": "1.x", "enzyme-to-json": "3.x", @@ -68,6 +69,7 @@ "glob": "7.x", "html-webpack-plugin": "3.x", "jest": "23.x", + "lodash": "4.17.x", "patternfly-react": "2.x", "prettier": "1.x", "prop-types": "15.x", @@ -77,10 +79,13 @@ "react-dom": "16.5.x", "require-all": "3.x", "shx": "0.x", + "style-loader": "0.23.x", "stylelint": "9.x", "stylelint-config-standard": "18.x", "stylelint-scss": "3.x", - "webpack": "4.x" + "url-loader": "1.1.x", + "webpack": "4.x", + "js-yaml": "3.12.x" }, "engines": { "node": ">=8.6.x", diff --git a/sass/_components.scss b/sass/_components.scss index 6efac55da..e10f1b1e1 100644 --- a/sass/_components.scss +++ b/sass/_components.scss @@ -1 +1,6 @@ @import './components/HelloWorld'; +@import './components/ButtonWithIcon'; +@import './components/CreateVmWizard'; +@import './components/Dropdown'; +@import './components/FormFactory'; +@import './components/NewVmWizard'; diff --git a/sass/_dependencies.scss b/sass/_dependencies.scss index 1615ae137..6ae539f4c 100644 --- a/sass/_dependencies.scss +++ b/sass/_dependencies.scss @@ -1,2 +1,2 @@ -@import 'patternfly/dist/sass/patternfly'; -@import 'patternfly-react/dist/sass/patternfly-react'; +@import '~patternfly/dist/sass/patternfly'; +@import '~patternfly-react/dist/sass/patternfly-react'; diff --git a/sass/components/_ButtonWithIcon.scss b/sass/components/_ButtonWithIcon.scss new file mode 100644 index 000000000..99e878e1a --- /dev/null +++ b/sass/components/_ButtonWithIcon.scss @@ -0,0 +1,19 @@ +.modal .btn-with-icon { + text-align: center; + display: inline-block; + padding: 10px; + margin: 50px; + border: 2px solid #f5f5f5; + height: 200px; + width: 200px; + white-space: pre-line; +} + +.modal .btn-with-icon:hover { + border: 2px solid; + text-decoration: none; +} + +.modal .label-column { + margin-top: 15px; +} diff --git a/sass/components/_CreateVmWizard.scss b/sass/components/_CreateVmWizard.scss new file mode 100644 index 000000000..59c058c0c --- /dev/null +++ b/sass/components/_CreateVmWizard.scss @@ -0,0 +1,3 @@ +.modal .wizard-pf-main { + padding-top: 10px; +} diff --git a/sass/components/_Dropdown.scss b/sass/components/_Dropdown.scss new file mode 100644 index 000000000..9b2947986 --- /dev/null +++ b/sass/components/_Dropdown.scss @@ -0,0 +1,18 @@ +.modal .form-dropdown { + text-align: left; +} + +.modal .form-dropdown .caret { + position: absolute; + right: 5px; + top: 5px; +} + +.modal .dropdown-menu { + width: 100%; + display: none; +} + +.modal .open > .dropdown-menu { + display: block; +} diff --git a/sass/components/_FormFactory.scss b/sass/components/_FormFactory.scss new file mode 100644 index 000000000..bf4f7feba --- /dev/null +++ b/sass/components/_FormFactory.scss @@ -0,0 +1,8 @@ +.modal .form-group-no-bottom { + margin-bottom: 2px; +} + +.modal .popover { + max-width: 400px; +} + diff --git a/sass/components/_NewVmWizard.scss b/sass/components/_NewVmWizard.scss new file mode 100644 index 000000000..8f604d974 --- /dev/null +++ b/sass/components/_NewVmWizard.scss @@ -0,0 +1,5 @@ +.modal .wizard-content { + align-items: center; + display: flex; + justify-content: center; +} diff --git a/src/components/Buttons/ButtonWithIcon.js b/src/components/Buttons/ButtonWithIcon.js new file mode 100644 index 000000000..2430fec46 --- /dev/null +++ b/src/components/Buttons/ButtonWithIcon.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Icon, Col } from 'patternfly-react'; + +export const ButtonWithIcon = ({ onClick, iconType, icon, label }) => ( + +); + +ButtonWithIcon.propTypes = { + onClick: PropTypes.func.isRequired, + iconType: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + label: PropTypes.string.isRequired +}; diff --git a/src/components/Buttons/fixtures/ButtonWithIcon.fixture.js b/src/components/Buttons/fixtures/ButtonWithIcon.fixture.js new file mode 100644 index 000000000..8897776d6 --- /dev/null +++ b/src/components/Buttons/fixtures/ButtonWithIcon.fixture.js @@ -0,0 +1,11 @@ +import { ButtonWithIcon } from '..'; + +export default { + component: ButtonWithIcon, + props: { + onClick: () => {}, + iconType: 'pf', + icon: 'virtual-machine', + label: 'Button With Icon' + } +}; diff --git a/src/components/Buttons/index.js b/src/components/Buttons/index.js new file mode 100644 index 000000000..608f30f13 --- /dev/null +++ b/src/components/Buttons/index.js @@ -0,0 +1 @@ +export { ButtonWithIcon } from './ButtonWithIcon'; diff --git a/src/components/Buttons/tests/ButtonWithIcon.test.js b/src/components/Buttons/tests/ButtonWithIcon.test.js new file mode 100644 index 000000000..a52848ce0 --- /dev/null +++ b/src/components/Buttons/tests/ButtonWithIcon.test.js @@ -0,0 +1,14 @@ +import createTestContext from '../../../cosmos/enzyme'; +import fixture from '../fixtures/ButtonWithIcon.fixture'; + +const { mount, getWrapper } = createTestContext({ fixture }); + +beforeEach(mount); + +test('renders Button With Icon', () => { + expect(getWrapper().text()).toEqual('Button With Icon'); +}); + +test('matches snapshot', () => { + expect(getWrapper()).toMatchSnapshot(); +}); diff --git a/src/components/Buttons/tests/__snapshots__/ButtonWithIcon.test.js.snap b/src/components/Buttons/tests/__snapshots__/ButtonWithIcon.test.js.snap new file mode 100644 index 000000000..74c903c86 --- /dev/null +++ b/src/components/Buttons/tests/__snapshots__/ButtonWithIcon.test.js.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`matches snapshot 1`] = ` + + + + +`; diff --git a/src/components/Forms/Checkbox.js b/src/components/Forms/Checkbox.js new file mode 100644 index 000000000..80fd10537 --- /dev/null +++ b/src/components/Forms/Checkbox.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Checkbox as PfCheckbox } from 'patternfly-react'; + +export const Checkbox = ({ fieldKey, checked, title, onChange }) => ( + onChange(event.target.checked, fieldKey)}> + {title} + +); + +Checkbox.propTypes = { + fieldKey: PropTypes.string.isRequired, + checked: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired +}; diff --git a/src/components/Forms/Dropdown.js b/src/components/Forms/Dropdown.js new file mode 100644 index 000000000..5e52c137f --- /dev/null +++ b/src/components/Forms/Dropdown.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ButtonGroup, DropdownButton, MenuItem } from 'patternfly-react'; + +export const Dropdown = ({ fieldKey, value, choices, onChange }) => ( + + onChange(v, fieldKey)} + > + {choices.map(choice => ( + + {choice} + + ))} + + +); + +Dropdown.propTypes = { + fieldKey: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + choices: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired +}; diff --git a/src/components/Forms/FormFactory.js b/src/components/Forms/FormFactory.js new file mode 100644 index 000000000..fe64c6338 --- /dev/null +++ b/src/components/Forms/FormFactory.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormGroup, Col, ControlLabel, HelpBlock, Form, FieldLevelHelp } from 'patternfly-react'; +import { get, has } from 'lodash'; +import { TextArea, Dropdown, Checkbox, Text } from '.'; + +export const FormFactory = ({ fields, fieldsValues, onFormChange }) => { + const formGroups = Object.keys(fields) + .filter(key => !fields[key].isVisible || fields[key].isVisible(fieldsValues)) + .map(key => { + let child; + const validMsg = get(fieldsValues[key], 'validMsg'); + switch (fields[key].type) { + case 'textarea': + child = + + + + + + + + +
+ +
+ + +
+ + +
+ +
+
+
+ + + +
+ +
+ + + + +`; diff --git a/src/components/Forms/tests/__snapshots__/Text.test.js.snap b/src/components/Forms/tests/__snapshots__/Text.test.js.snap new file mode 100644 index 000000000..f83fdad38 --- /dev/null +++ b/src/components/Forms/tests/__snapshots__/Text.test.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` + +`; diff --git a/src/components/Forms/tests/__snapshots__/TextArea.test.js.snap b/src/components/Forms/tests/__snapshots__/TextArea.test.js.snap new file mode 100644 index 000000000..e3a8dcd5f --- /dev/null +++ b/src/components/Forms/tests/__snapshots__/TextArea.test.js.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` + +`; diff --git a/src/components/Wizards/CreateVmWizard/CreateVmWizard.js b/src/components/Wizards/CreateVmWizard/CreateVmWizard.js new file mode 100644 index 000000000..7d5026903 --- /dev/null +++ b/src/components/Wizards/CreateVmWizard/CreateVmWizard.js @@ -0,0 +1,313 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Wizard } from 'patternfly-react'; +import { get, has } from 'lodash'; +import { createVM } from '../../../k8s/request'; +import { FormFactory } from '../../Forms/FormFactory'; +import { isPositiveNumber } from '../../../utils/validation'; +import { + CUSTOM_FLAVOR, + TEMPLATE_FLAVOR_LABEL, + TEMPLATE_OS_LABEL, + TEMPLATE_WORKLOAD_LABEL, + PROVISION_SOURCE_PXE, + PROVISION_SOURCE_URL, + PROVISION_SOURCE_REGISTRY, + templates as predefinedTemplates +} from '../../../constants'; +import { getTemplatesWithLabels, getTemplatesLabelValues } from '../../../utils/template'; + +export class CreateVmWizard extends React.Component { + state = { + activeStepIndex: 0, + basicVmSettings: {}, + wizardValid: false, + namespaces: this.props.namespaces, + templates: this.props.templates.length === 0 ? predefinedTemplates : this.props.templates + }; + + static getDerivedStateFromProps(props, state) { + const newState = {}; + if (props.namespaces !== state.namespaces) { + newState.namespaces = props.namespaces; + } + if (props.templates.length !== 0 && props.templates !== state.templates) { + newState.templates = props.templates; + } + return newState; + } + + onFormChange = (newValue, target) => { + let validMsg; + + if (this.basicFormFields[target].validate) { + validMsg = this.basicFormFields[target].validate(newValue); + } + if (this.basicFormFields[target].required && newValue.trim().length === 0) { + validMsg = 'is required'; + } + + if (validMsg) { + validMsg = `${this.basicFormFields[target].title} ${validMsg}`; + } + + const basicVmSettings = { + ...this.state.basicVmSettings, + [target]: { + value: newValue, + validMsg + } + }; + + this.setState(state => ({ + basicVmSettings: { + ...state.basicVmSettings, + [target]: { + value: newValue, + validMsg + } + } + })); + this.validateWizard(basicVmSettings); + }; + + validateWizard = values => { + let wizardValid = true; + + // check if all required fields are defined + const requiredKeys = Object.keys(this.basicFormFields).filter(key => this.isFieldRequired(key, values)); + const requiredKeysInValues = Object.keys(values).filter(key => this.isFieldRequired(key, values)); + if (requiredKeys.length !== requiredKeysInValues.length) { + wizardValid = false; + } + + // check if all fields are valid + for (const key in values) { + if ( + values[key].validMsg && + (this.basicFormFields[key].isVisible ? this.basicFormFields[key].isVisible(values) : true) + ) { + wizardValid = false; + break; + } + } + + this.setState({ + wizardValid + }); + }; + + getValueFromState = key => this.state[key].map(value => value.metadata.name); + + isFieldRequired = (key, basicVmSettings) => { + const field = this.basicFormFields[key]; + if (field.required) { + return field.isVisible ? field.isVisible(basicVmSettings) : true; + } + return false; + }; + + getFlavorLabel = () => { + if (has(this.state.basicVmSettings, 'flavor.value')) { + const flavorValue = this.state.basicVmSettings.flavor.value; + if (flavorValue !== CUSTOM_FLAVOR) { + return `${TEMPLATE_FLAVOR_LABEL}/${this.state.basicVmSettings.flavor.value}`; + } + } + return undefined; + }; + + getWorkloadLabel = () => this.getLabel(TEMPLATE_WORKLOAD_LABEL, 'workloadProfile'); + + getOsLabel = () => this.getLabel(TEMPLATE_OS_LABEL, 'operatingSystem'); + + getLabel = (labelPrefix, value) => + has(this.state.basicVmSettings, value) + ? `${labelPrefix}/${get(this.state.basicVmSettings, [value, 'value'])}` + : undefined; + + getOperatingSystems = () => { + const templates = getTemplatesWithLabels(this.state.templates, [this.getWorkloadLabel(), this.getFlavorLabel()]); + return getTemplatesLabelValues(templates, TEMPLATE_OS_LABEL); + }; + + getWorkloadProfiles = () => { + const templates = getTemplatesWithLabels(this.state.templates, [this.getOsLabel(), this.getFlavorLabel()]); + return getTemplatesLabelValues(templates, TEMPLATE_WORKLOAD_LABEL); + }; + + getFlavors = () => { + const templates = getTemplatesWithLabels(this.state.templates, [this.getWorkloadLabel(), this.getOsLabel()]); + const flavors = getTemplatesLabelValues(templates, TEMPLATE_FLAVOR_LABEL); + flavors.push(CUSTOM_FLAVOR); + return flavors; + }; + + basicFormFields = { + name: { + title: 'Name', + required: true + }, + description: { + title: 'Description', + type: 'textarea' + }, + namespace: { + title: 'Namespace', + type: 'dropdown', + default: '--- Select Namespace ---', + values: () => this.getValueFromState('namespaces'), + required: true + }, + imageSourceType: { + title: 'Provision Source', + type: 'dropdown', + default: '--- Select Provision Source ---', + values: [PROVISION_SOURCE_PXE, PROVISION_SOURCE_URL, PROVISION_SOURCE_REGISTRY], + required: true + }, + registryImage: { + title: 'Registry Image', + required: true, + isVisible: basicVmSettings => get(basicVmSettings, 'imageSourceType.value') === PROVISION_SOURCE_REGISTRY + }, + imageURL: { + title: 'URL', + required: true, + isVisible: basicVmSettings => get(basicVmSettings, 'imageSourceType.value') === PROVISION_SOURCE_URL + }, + operatingSystem: { + title: 'Operating System', + type: 'dropdown', + default: '--- Select Operating System ---', + values: this.getOperatingSystems, + required: true + }, + flavor: { + title: 'Flavor', + type: 'dropdown', + default: '--- Select Flavor ---', + values: this.getFlavors, + required: true + }, + memory: { + title: 'Memory (GB)', + required: true, + isVisible: basicVmSettings => get(basicVmSettings, 'flavor.value', '') === CUSTOM_FLAVOR, + validate: currentValue => (isPositiveNumber(currentValue) ? undefined : 'must be a number') + }, + cpu: { + title: 'CPUs', + required: true, + isVisible: basicVmSettings => get(basicVmSettings, 'flavor.value', '') === CUSTOM_FLAVOR, + validate: currentValue => (isPositiveNumber(currentValue) ? undefined : 'must be a number') + }, + workloadProfile: { + title: 'Workload Profile', + type: 'dropdown', + default: '--- Select Workload Profile ---', + values: this.getWorkloadProfiles, + required: true, + help: () => + this.getWorkloadProfiles().map(profile => ( +

+ {profile}: {profile} +

+ )) + }, + startVM: { + title: 'Start virtual machine on creation', + type: 'checkbox', + noBottom: true + }, + /* + createTemplate: { + title: 'Create new template from configuration', + type: 'checkbox', + noBottom: true + }, + */ + cloudInit: { + title: 'Use cloud-init', + type: 'checkbox' + }, + hostname: { + title: 'Hostname', + isVisible: basicVmSettings => get(basicVmSettings, 'cloudInit.value', false), + required: true + }, + authKeys: { + title: 'Authenticated SSH Keys', + type: 'textarea', + isVisible: basicVmSettings => get(basicVmSettings, 'cloudInit.value', false), + required: true + } + }; + + wizardStepsNewVM = [ + { + title: 'Basic Settings', + render: () => ( + + ) + }, + { + title: 'Result', + render: () => { + const result = this.state.result ? this.state.result : 'creating VM...'; + return

{result}

; + } + } + ]; + + onStepChanged = index => { + this.setState({ activeStepIndex: index }); + if (index === 1) { + const basicSettings = { + ...this.state.basicVmSettings + }; + const availableTemplates = getTemplatesWithLabels(this.state.templates, [ + this.getOsLabel(), + this.getWorkloadLabel(), + this.getFlavorLabel() + ]); + [basicSettings.chosenTemplate] = availableTemplates; + createVM(this.props.k8sCreate, basicSettings, this.state.network, this.state.storage) + .then(result => + this.setState({ + result: `VM ${result.metadata.name} created` + }) + ) + .catch(error => + this.setState({ + result: error.message + }) + ); + } + }; + + render() { + return ( + this.onStepChanged(index)} + nextStepDisabled={!this.state.wizardValid} + nextText={this.state.activeStepIndex === 0 ? 'Create Virtual Machine' : 'Next'} + /> + ); + } +} + +CreateVmWizard.propTypes = { + onHide: PropTypes.func.isRequired, + templates: PropTypes.array.isRequired, + namespaces: PropTypes.array.isRequired, + k8sCreate: PropTypes.func.isRequired +}; diff --git a/src/components/Wizards/CreateVmWizard/fixtures/CreateVmWizard.fixture.js b/src/components/Wizards/CreateVmWizard/fixtures/CreateVmWizard.fixture.js new file mode 100644 index 000000000..28df85b85 --- /dev/null +++ b/src/components/Wizards/CreateVmWizard/fixtures/CreateVmWizard.fixture.js @@ -0,0 +1,12 @@ +import { CreateVmWizard } from '..'; +import { templates, namespaces, k8sCreate } from '../../NewVmWizard/fixtures/NewVmWizard.fixture'; + +export default { + component: CreateVmWizard, + props: { + onHide: () => {}, + templates, + namespaces, + k8sCreate + } +}; diff --git a/src/components/Wizards/CreateVmWizard/index.js b/src/components/Wizards/CreateVmWizard/index.js new file mode 100644 index 000000000..e741558a1 --- /dev/null +++ b/src/components/Wizards/CreateVmWizard/index.js @@ -0,0 +1 @@ +export { CreateVmWizard } from './CreateVmWizard'; diff --git a/src/components/Wizards/CreateVmWizard/tests/CreateVmWizard.test.js b/src/components/Wizards/CreateVmWizard/tests/CreateVmWizard.test.js new file mode 100644 index 000000000..c8f629d17 --- /dev/null +++ b/src/components/Wizards/CreateVmWizard/tests/CreateVmWizard.test.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { WizardPattern } from 'patternfly-react'; +import { CreateVmWizard } from '../CreateVmWizard'; +import { templates, namespaces } from '../../NewVmWizard/fixtures/NewVmWizard.fixture'; + +const onHide = () => {}; + +const testCreateVmWizard = () => ( + {}} /> +); + +describe('', () => { + it('renders correctly', () => { + const component = shallow(testCreateVmWizard()); + expect(component).toMatchSnapshot(); + }); + it('is visible when mounted', () => { + const component = shallow(testCreateVmWizard()); + expect(component.find(WizardPattern).props().show).toBeTruthy(); + }); + it('is not valid when initially open', () => { + const component = shallow(testCreateVmWizard()); + expect(component.state().wizardValid).toBeFalsy(); + expect(component.find(WizardPattern).props().nextStepDisabled).toBeTruthy(); + }); + it('onFormChange updates state', () => { + const nameValue = 'someName'; + const component = shallow(testCreateVmWizard()); + component.instance().onFormChange(nameValue, 'name'); + expect(component.state().basicVmSettings.name).toEqual({ value: nameValue }); + }); + it('required property is validated', () => { + const component = shallow(testCreateVmWizard()); + component.instance().onFormChange('', 'name'); + expect(component.state().basicVmSettings.name).toEqual({ value: '', validMsg: 'Name is required' }); + }); + it('cpu field validation is triggered', () => { + const component = shallow(testCreateVmWizard()); + component.instance().onFormChange('someCpu', 'cpu'); + expect(component.state().basicVmSettings.cpu).toEqual({ value: 'someCpu', validMsg: 'CPUs must be a number' }); + }); + it('memory field validation is triggered', () => { + const component = shallow(testCreateVmWizard()); + component.instance().onFormChange('someMemory', 'memory'); + expect(component.state().basicVmSettings.memory).toEqual({ + value: 'someMemory', + validMsg: 'Memory (GB) must be a number' + }); + }); + it('is valid when all required fields are filled', () => { + const component = shallow(testCreateVmWizard()); + expect(component.state().wizardValid).toBeFalsy(); + expect(component.find(WizardPattern).props().nextStepDisabled).toBeTruthy(); + + component.instance().onFormChange('name', 'name'); + component.instance().onFormChange('namespace', 'namespace'); + component.instance().onFormChange('PXE', 'imageSourceType'); + component.instance().onFormChange('operatingSystem', 'operatingSystem'); + component.instance().onFormChange('flavor', 'flavor'); + component.instance().onFormChange('workloadProfile', 'workloadProfile'); + + expect(component.state().wizardValid).toBeTruthy(); + expect(component.find(WizardPattern).props().nextStepDisabled).toBeFalsy(); + + // new required field will become visible + component.instance().onFormChange('URL', 'imageSourceType'); + + expect(component.state().wizardValid).toBeFalsy(); + expect(component.find(WizardPattern).props().nextStepDisabled).toBeTruthy(); + }); +}); diff --git a/src/components/Wizards/CreateVmWizard/tests/__snapshots__/CreateVmWizard.test.js.snap b/src/components/Wizards/CreateVmWizard/tests/__snapshots__/CreateVmWizard.test.js.snap new file mode 100644 index 000000000..9e15aec2b --- /dev/null +++ b/src/components/Wizards/CreateVmWizard/tests/__snapshots__/CreateVmWizard.test.js.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` + +`; diff --git a/src/components/Wizards/NewVmWizard/NewVmWizard.js b/src/components/Wizards/NewVmWizard/NewVmWizard.js new file mode 100644 index 000000000..a064bdaf5 --- /dev/null +++ b/src/components/Wizards/NewVmWizard/NewVmWizard.js @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Wizard, Button } from 'patternfly-react'; +import { CreateVmWizard } from '../CreateVmWizard/CreateVmWizard'; +import { ButtonWithIcon } from '../../Buttons/ButtonWithIcon'; + +export class NewVmWizard extends React.Component { + state = { + createVM: false + }; + + openCreateVmWizard = () => this.setState({ createVM: true }); + + render() { + const wizard = this.state.createVM ? : undefined; + return ( + + + + + + + + + {}} + /> + + + + + + + + + {wizard} + + ); + } +} + +NewVmWizard.propTypes = { + onHide: PropTypes.func.isRequired, + templates: PropTypes.array.isRequired, + namespaces: PropTypes.array.isRequired, + k8sCreate: PropTypes.func.isRequired +}; diff --git a/src/components/Wizards/NewVmWizard/fixtures/NewVmWizard.fixture.js b/src/components/Wizards/NewVmWizard/fixtures/NewVmWizard.fixture.js new file mode 100644 index 000000000..84b8d2fbb --- /dev/null +++ b/src/components/Wizards/NewVmWizard/fixtures/NewVmWizard.fixture.js @@ -0,0 +1,45 @@ +import { NewVmWizard } from '..'; +import { fedora28 } from '../../../../k8s/mock_templates/fedora28.template'; +import { rhel75 } from '../../../../k8s/mock_templates/rhel75.template'; +import { ubuntu1804 } from '../../../../k8s/mock_templates/ubuntu1804.template'; +import { rhelHighPerformance } from '../../../../k8s/mock_templates/rhel-high-p.template'; +import { ProcessedTemplatesModel } from '../../../../models'; + +export const templates = [fedora28, ubuntu1804, rhel75, rhelHighPerformance]; + +export const namespaces = [ + { + metadata: { + name: 'default' + } + }, + { + metadata: { + name: 'myproject' + } + } +]; + +const processTemplate = template => + new Promise((resolve, reject) => { + const nameParam = template.parameters.find(param => param.name === 'NAME'); + template.objects[0].metadata.name = nameParam.value; + resolve(template); + }); + +export const k8sCreate = (model, resource) => { + if (model === ProcessedTemplatesModel) { + return processTemplate(resource); + } + return new Promise(resolve => resolve(resource)); +}; + +export default { + component: NewVmWizard, + props: { + onHide: () => {}, + templates, + namespaces, + k8sCreate + } +}; diff --git a/src/components/Wizards/NewVmWizard/index.js b/src/components/Wizards/NewVmWizard/index.js new file mode 100644 index 000000000..0a9c30eb9 --- /dev/null +++ b/src/components/Wizards/NewVmWizard/index.js @@ -0,0 +1 @@ +export { NewVmWizard } from './NewVmWizard'; diff --git a/src/components/Wizards/NewVmWizard/tests/NewVmWizard.test.js b/src/components/Wizards/NewVmWizard/tests/NewVmWizard.test.js new file mode 100644 index 000000000..047da9bde --- /dev/null +++ b/src/components/Wizards/NewVmWizard/tests/NewVmWizard.test.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { NewVmWizard } from '../NewVmWizard'; +import { templates, namespaces } from '../fixtures/NewVmWizard.fixture'; +import { CreateVmWizard } from '../../CreateVmWizard/CreateVmWizard'; + +const testNewVmWizard = () => ( + {}} templates={templates} namespaces={namespaces} k8sCreate={() => {}} /> +); + +describe('', () => { + it('renders correctly', () => { + const component = shallow(testNewVmWizard()); + expect(component).toMatchSnapshot(); + }); + it('CreateVMWizard is not rendered ', () => { + const component = shallow(testNewVmWizard()); + expect(component.find(CreateVmWizard)).toHaveLength(0); + }); + it('opens CreateVMWizard when button is clicked', () => { + const component = shallow(testNewVmWizard()); + component.findWhere(c => c.props().label === 'Create New Virtual Machine').simulate('click'); + expect(component.find(CreateVmWizard)).toHaveLength(1); + }); +}); diff --git a/src/components/Wizards/NewVmWizard/tests/__snapshots__/NewVmWizard.test.js.snap b/src/components/Wizards/NewVmWizard/tests/__snapshots__/NewVmWizard.test.js.snap new file mode 100644 index 000000000..1e86d3c79 --- /dev/null +++ b/src/components/Wizards/NewVmWizard/tests/__snapshots__/NewVmWizard.test.js.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` + + + + + + + + + + + + + + + + + + +`; diff --git a/src/constants/index.js b/src/constants/index.js new file mode 100644 index 000000000..0bba3573c --- /dev/null +++ b/src/constants/index.js @@ -0,0 +1,28 @@ +import { fedora28 } from '../k8s/mock_templates/fedora28.template'; +import { rhel75 } from '../k8s/mock_templates/rhel75.template'; +import { ubuntu1804 } from '../k8s/mock_templates/ubuntu1804.template'; +import { rhelHighPerformance } from '../k8s/mock_templates/rhel-high-p.template'; + +export const API_VERSION = 'kubevirt.io/v1alpha2'; +export const VM_KIND = 'VirtualMachine'; +export const OS_LABEL = 'kubevirt.io/os'; +export const FLAVOR_LABEL = 'kubevirt.io/flavor'; +export const CLOUDINIT_VOLUME = 'cloudinitvolume'; +export const CLOUDINIT_DISK = 'cloudinitdisk'; +export const REGISTRY_VOLUME = 'registryvolume'; +export const REGISTRY_DISK = 'registrydisk'; +export const VIRTIO_BUS = 'virtio'; +export const TEMPLATE_OS_LABEL = 'os.template.cnv.io'; +export const TEMPLATE_FLAVOR_LABEL = 'flavor.template.cnv.io'; +export const TEMPLATE_WORKLOAD_LABEL = 'workload.template.cnv.io'; +export const CUSTOM_FLAVOR = 'Custom'; +export const ANNOTATION_DEFAULT_DISK = 'defaults.template.cnv.io/disk'; +export const ANNOTATION_DEFAULT_NETWORK = 'defaults.template.cnv.io/network'; +export const PARAM_VM_NAME = 'NAME'; +export const PARAM_CPU_CORES = 'CPU_CORES'; +export const PARAM_MEMORY = 'MEMORY'; +export const PROVISION_SOURCE_URL = 'URL'; +export const PROVISION_SOURCE_PXE = 'PXE'; +export const PROVISION_SOURCE_REGISTRY = 'Registry'; + +export const templates = [fedora28, ubuntu1804, rhel75, rhelHighPerformance]; diff --git a/src/index.js b/src/index.js index 1242fbc13..57163f86a 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,7 @@ export * from './components/HelloWorld'; +export * from './components/Buttons'; +export * from './components/Forms'; +export * from './components/Wizards/CreateVmWizard'; +export * from './components/Wizards/NewVmWizard'; +export * from './k8s/request'; +export { templates } from './constants'; diff --git a/src/k8s/mock_templates/fedora28.template.js b/src/k8s/mock_templates/fedora28.template.js new file mode 100644 index 000000000..b2b3797f6 --- /dev/null +++ b/src/k8s/mock_templates/fedora28.template.js @@ -0,0 +1,98 @@ +export const fedora28 = { + apiVersion: 'template.openshift.io/v1', + kind: 'Template', + metadata: { + name: 'fedora-generic', + annotations: { + 'openshift.io/display-name': 'Fedora 23+ VM', + description: + 'This template can be used to create a VM suitable for Fedora 23 and newer. The template assumes that a PVC is available which is providing the necessary Fedora disk image. Recommended disk image (needs to be converted to raw) https://download.fedoraproject.org/pub/fedora/linux/releases/28/Cloud/x86_64/images/Fedora-Cloud-Base-28-1.1.x86_64.qcow2', + tags: 'kubevirt,virtualmachine,fedora,rhel', + iconClass: 'icon-fedora', + 'openshift.io/provider-display-name': 'KubeVirt', + 'openshift.io/documentation-url': 'https://github.com/fabiand/common-templates', + 'openshift.io/support-url': 'https://github.com/fabiand/common-templates/issues', + 'template.openshift.io/bindable': 'false', + 'defaults.template.cnv.io/disk': 'rootdisk' + }, + labels: { + 'os.template.cnv.io/fedora29': 'true', + 'os.template.cnv.io/fedora28': 'true', + 'os.template.cnv.io/fedora27': 'true', + 'os.template.cnv.io/fedora26': 'true', + 'os.template.cnv.io/fedora25': 'true', + 'os.template.cnv.io/fedora24': 'true', + 'os.template.cnv.io/fedora23': 'true', + 'workload.template.cnv.io/generic': 'true', + 'flavor.template.cnv.io/small': 'true' + } + }, + objects: [ + { + apiVersion: 'kubevirt.io/v1alpha2', + kind: 'VirtualMachine', + metadata: { + // eslint-disable-next-line no-template-curly-in-string + name: '${NAME}' + }, + spec: { + running: false, + template: { + spec: { + domain: { + cpu: { + cores: 2 + }, + resources: { + requests: { + memory: '2G' + } + }, + devices: { + rng: {}, + disks: [ + { + disk: { + bus: 'virtio' + }, + name: 'rootdisk', + volumeName: 'rootvolume' + } + ] + } + }, + terminationGracePeriodSeconds: 0, + volumes: [ + { + name: 'rootvolume', + persistentVolumeClaim: { + // eslint-disable-next-line no-template-curly-in-string + claimName: '${PVCNAME}' + } + }, + { + cloudInitNoCloud: { + userData: '# configure default password\npassword: fedora\nchpasswd: { expire: False }' + }, + name: 'cloudinitvolume' + } + ] + } + } + } + } + ], + parameters: [ + { + description: 'VM name', + from: '[A-Za-z0-9]{1,16}', + generate: 'expression', + name: 'NAME' + }, + { + name: 'PVCNAME', + description: 'Name of the PVC with the disk image', + required: true + } + ] +}; diff --git a/src/k8s/mock_templates/rhel-high-p.template.js b/src/k8s/mock_templates/rhel-high-p.template.js new file mode 100644 index 000000000..5dbe43406 --- /dev/null +++ b/src/k8s/mock_templates/rhel-high-p.template.js @@ -0,0 +1,95 @@ +export const rhelHighPerformance = { + apiVersion: 'template.openshift.io/v1', + kind: 'Template', + metadata: { + name: 'rhel-high-performance', + annotations: { + 'openshift.io/display-name': 'Red Hat Enterprise Linux 7.0+ VM High Performance', + description: + 'This template can be used to create a VM suitable for Red Hat Enterprise Linux 7 and newer and sets configuration for high performance. That means for example CPU passhtrough, CPU pinning and disabled graphics device. The template assumes that a PVC is available which is providing the necessary RHEL disk image.', + tags: 'kubevirt,virtualmachine,linux,rhel,high-performance', + iconClass: 'icon-rhel', + 'openshift.io/provider-display-name': 'KubeVirt', + 'openshift.io/documentation-url': 'https://github.com/fabiand/common-templates', + 'openshift.io/support-url': 'https://github.com/fabiand/common-templates/issues', + 'template.openshift.io/bindable': 'false', + 'defaults.template.cnv.io/disk': 'rootdisk' + }, + labels: { + 'os.template.cnv.io/rhel7.0': 'true', + 'workload.template.cnv.io/high-performance': 'true', + 'flavor.template.cnv.io/medium': 'true' + } + }, + objects: [ + { + apiVersion: 'kubevirt.io/v1alpha2', + kind: 'VirtualMachine', + metadata: { + // eslint-disable-next-line no-template-curly-in-string + name: '${NAME}' + }, + spec: { + running: false, + template: { + spec: { + domain: { + cpu: { + cores: 2, + dedicatedCpuPlacement: true, + model: 'host-model' + }, + resources: { + requests: { + memory: '4G' + } + }, + devices: { + autoattachGraphicsDevice: false, + rng: {}, + disks: [ + { + disk: { + bus: 'virtio' + }, + name: 'rootdisk', + volumeName: 'rootvolume' + } + ] + } + }, + terminationGracePeriodSeconds: 0, + volumes: [ + { + name: 'rootvolume', + persistentVolumeClaim: { + // eslint-disable-next-line no-template-curly-in-string + claimName: '${PVCNAME}' + } + }, + { + cloudInitNoCloud: { + userData: '# configure default password\npassword: fedora\nchpasswd: { expire: False }' + }, + name: 'cloudinitvolume' + } + ] + } + } + } + } + ], + parameters: [ + { + description: 'Name of the new VM', + from: '[A-Za-z0-9]{1,16}', + generate: 'expression', + name: 'NAME' + }, + { + name: 'PVCNAME', + description: 'Name of the PVC with the disk image', + required: true + } + ] +}; diff --git a/src/k8s/mock_templates/rhel75.template.js b/src/k8s/mock_templates/rhel75.template.js new file mode 100644 index 000000000..e4c9b90c1 --- /dev/null +++ b/src/k8s/mock_templates/rhel75.template.js @@ -0,0 +1,92 @@ +export const rhel75 = { + apiVersion: 'template.openshift.io/v1', + kind: 'Template', + metadata: { + name: 'rhel-generic', + annotations: { + 'openshift.io/display-name': 'Red Hat Enterprise Linux 7.0+ VM', + description: + 'This template can be used to create a VM suitable for Red Hat Enterprise Linux 7 and newer. The template assumes that a PVC is available which is providing the necessary RHEL disk image.', + tags: 'kubevirt,virtualmachine,linux,rhel', + iconClass: 'icon-rhel', + 'openshift.io/provider-display-name': 'KubeVirt', + 'openshift.io/documentation-url': 'https://github.com/fabiand/common-templates', + 'openshift.io/support-url': 'https://github.com/fabiand/common-templates/issues', + 'template.openshift.io/bindable': 'false', + 'defaults.template.cnv.io/disk': 'rootdisk' + }, + labels: { + 'os.template.cnv.io/rhel7.0': 'true', + 'workload.template.cnv.io/generic': 'true', + 'flavor.template.cnv.io/small': 'true' + } + }, + objects: [ + { + apiVersion: 'kubevirt.io/v1alpha2', + kind: 'VirtualMachine', + metadata: { + // eslint-disable-next-line no-template-curly-in-string + name: '${NAME}' + }, + spec: { + running: false, + template: { + spec: { + domain: { + cpu: { + cores: 2 + }, + resources: { + requests: { + memory: '2G' + } + }, + devices: { + rng: {}, + disks: [ + { + disk: { + bus: 'virtio' + }, + name: 'rootdisk', + volumeName: 'rootvolume' + } + ] + } + }, + terminationGracePeriodSeconds: 0, + volumes: [ + { + name: 'rootvolume', + persistentVolumeClaim: { + // eslint-disable-next-line no-template-curly-in-string + claimName: '${PVCNAME}' + } + }, + { + cloudInitNoCloud: { + userData: '# configure default password\npassword: fedora\nchpasswd: { expire: False }' + }, + name: 'cloudinitvolume' + } + ] + } + } + } + } + ], + parameters: [ + { + description: 'Name of the new VM', + from: '[A-Za-z0-9]{1,16}', + generate: 'expression', + name: 'NAME' + }, + { + name: 'PVCNAME', + description: 'Name of the PVC with the disk image', + required: true + } + ] +}; diff --git a/src/k8s/mock_templates/ubuntu1804.template.js b/src/k8s/mock_templates/ubuntu1804.template.js new file mode 100644 index 000000000..7382ef417 --- /dev/null +++ b/src/k8s/mock_templates/ubuntu1804.template.js @@ -0,0 +1,107 @@ +export const ubuntu1804 = { + apiVersion: 'template.openshift.io/v1', + kind: 'Template', + metadata: { + name: 'ubuntu1804', + annotations: { + 'openshift.io/display-name': 'Ubuntu 18.04 (Xenial Xerus) VM', + description: + 'This template can be used to create a VM suitable for Ubuntu 18.04 (Xenial Xerus). The template assumes that a PVC is available which is providing the necessary Ubuntu disk image. Recommended disk image (needs to be converted to raw) http://cloud-images.ubuntu.com/xenial/current/xenial-server-cloudimg-amd64-disk1.img', + tags: 'kubevirt,virtualmachine,ubuntu', + iconClass: 'icon-ubuntu', + 'openshift.io/provider-display-name': 'KubeVirt', + 'openshift.io/documentation-url': 'https://github.com/fabiand/common-templates', + 'openshift.io/support-url': 'https://github.com/fabiand/common-templates/issues', + 'template.openshift.io/bindable': 'false', + 'defaults.template.cnv.io/disk': 'rootdisk' + }, + labels: { + 'os.template.cnv.io/ubuntu18.04': 'true', + 'workload.template.cnv.io/generic': 'true', + 'flavor.template.cnv.io/small': 'true' + } + }, + objects: [ + { + apiVersion: 'kubevirt.io/v1alpha2', + kind: 'VirtualMachineInstancePreset', + metadata: { + name: 'ubuntu1804' + }, + spec: { + selector: { + matchLabels: { + 'kubevirt.io/os': 'ubuntu1804' + } + } + } + }, + { + apiVersion: 'kubevirt.io/v1alpha2', + kind: 'VirtualMachine', + metadata: { + // eslint-disable-next-line no-template-curly-in-string + name: '${NAME}' + }, + spec: { + running: false, + template: { + spec: { + domain: { + cpu: { + cores: 2, + model: 'Conroe' + }, + devices: { + disks: [ + { + disk: { + bus: 'virtio' + }, + name: 'rootdisk', + volumeName: 'rootvolume' + } + ] + }, + resources: { + requests: { + memory: '2G' + } + } + }, + terminationGracePeriodSeconds: 0, + volumes: [ + { + name: 'rootvolume', + persistentVolumeClaim: { + // eslint-disable-next-line no-template-curly-in-string + claimName: '${PVCNAME}' + } + }, + { + cloudInitNoCloud: { + userData: '#cloud-config\npassword: ubuntu\nchpasswd: { expire: False }' + }, + name: 'cloudinitvolume' + } + ] + } + } + } + } + ], + parameters: [ + { + name: 'NAME', + description: 'Name of the new VM', + generate: 'expression', + from: 'ubuntu1804-[a-z0-9]{6}', + required: true + }, + { + name: 'PVCNAME', + description: 'Name of the PVC with the disk image', + required: true + } + ] +}; diff --git a/src/k8s/request.js b/src/k8s/request.js new file mode 100644 index 000000000..0dc4af4e5 --- /dev/null +++ b/src/k8s/request.js @@ -0,0 +1,216 @@ +import { get, remove } from 'lodash'; +import { safeDump } from 'js-yaml'; +import { + VM_KIND, + CLOUDINIT_DISK, + CLOUDINIT_VOLUME, + VIRTIO_BUS, + ANNOTATION_DEFAULT_DISK, + ANNOTATION_DEFAULT_NETWORK, + PARAM_VM_NAME, + PARAM_CPU_CORES, + PARAM_MEMORY, + CUSTOM_FLAVOR, + PROVISION_SOURCE_REGISTRY, + PROVISION_SOURCE_URL +} from '../constants'; +import { VirtualMachineModel, ProcessedTemplatesModel } from '../models'; + +export const createVM = (k8sCreate, basicSettings, network, storage) => { + setParameterValue(basicSettings.chosenTemplate, PARAM_VM_NAME, basicSettings.name.value); + + // no more required parameters + basicSettings.chosenTemplate.parameters.forEach(param => { + if (param.name !== PARAM_VM_NAME && param.required) { + delete param.required; + } + }); + + setFlavor(basicSettings); + return k8sCreate(ProcessedTemplatesModel, basicSettings.chosenTemplate).then(response => { + const vm = response.objects.find(obj => obj.kind === VM_KIND); + modifyVmObject(vm, basicSettings, network, storage); + return k8sCreate(VirtualMachineModel, vm); + }); +}; + +const setFlavor = basicSettings => { + if (basicSettings.flavor.value === CUSTOM_FLAVOR) { + setParameterValue(basicSettings.chosenTemplate, PARAM_CPU_CORES, basicSettings.cpu.value); + setParameterValue(basicSettings.chosenTemplate, PARAM_MEMORY, basicSettings.memory.value); + } +}; + +const setParameterValue = (template, paramName, paramValue) => { + const parameter = template.parameters.find(param => param.name === paramName); + parameter.value = paramValue; +}; + +const modifyVmObject = (vm, basicSettings, network, storage) => { + setSourceType(vm, basicSettings); + + // add running status + vm.spec.running = basicSettings.startVM ? basicSettings.startVM.value : false; + + // add namespace + if (basicSettings.namespace) { + vm.metadata.namespace = basicSettings.namespace.value; + } + + // add description + if (basicSettings.description) { + addAnnotation(vm, 'description', basicSettings.description.value); + } + + addCloudInit(vm, basicSettings); +}; + +const setSourceType = (vm, basicSettings) => { + const defaultDiskName = get(basicSettings.chosenTemplate.metadata.annotations, [ANNOTATION_DEFAULT_DISK]); + const defaultNetworkName = get(basicSettings.chosenTemplate.metadata.annotations, [ANNOTATION_DEFAULT_NETWORK]); + + const defaultDisk = getDefaultDevice(vm, 'disks', defaultDiskName); + let defaultNetwork = getDefaultDevice(vm, 'interfaces', defaultNetworkName); + + remove(vm.spec.template.spec.volumes, volume => volume.name === defaultDisk.volumeName); + + switch (get(basicSettings.imageSourceType, 'value')) { + case PROVISION_SOURCE_REGISTRY: { + const volume = { + name: defaultDisk.volumeName, + registryDisk: { + image: basicSettings.registryImage.value + } + }; + addVolume(vm, volume); + break; + } + case PROVISION_SOURCE_URL: { + const dataVolumeName = `datavolume-${basicSettings.name.value}`; + const volume = { + name: defaultDisk.volumeName, + dataVolume: { + name: dataVolumeName + } + }; + const dataVolume = { + metadata: { + name: dataVolumeName + }, + spec: { + pvc: { + accessModes: ['ReadWriteOnce'], + resources: { + requests: { + storage: '2Gi' + } + } + }, + source: { + http: { + url: basicSettings.imageURL.value + } + } + } + }; + addDataVolume(vm, dataVolume); + addVolume(vm, volume); + break; + } + // PXE + default: { + if (!defaultNetwork) { + defaultNetwork = { + type: 'pod-network', + name: 'default-network', + model: 'virtio' + }; + addInterface(vm, defaultNetwork); + } + defaultNetwork.bootOrder = 1; + addAnnotation(vm, 'firstRun', 'true'); + break; + } + } +}; + +const getDefaultDevice = (vm, deviceType, deviceName) => + get(vm.spec.template.spec.domain.devices, deviceType, []).find(device => device.name === deviceName); + +const addCloudInit = (vm, basicSettings) => { + // remove existing config + const volumes = get(vm.spec.template.spec, 'volumes', []); + remove(volumes, volume => volume.hasOwnProperty('cloudInitNoCloud')); + + if (get(basicSettings.cloudInit, 'value', false)) { + const cloudInitDisk = { + name: CLOUDINIT_DISK, + volumeName: CLOUDINIT_VOLUME, + disk: { + bus: VIRTIO_BUS + } + }; + addDisk(vm, cloudInitDisk); + + const userDataObject = { + users: [ + { + name: 'root', + 'ssh-authorized-keys': basicSettings.authKeys.value + } + ], + hostname: basicSettings.hostname.value + }; + + const userData = safeDump(userDataObject); + + const userDataWithMagicHeader = `#cloud-config\n${userData}`; + + const cloudInitVolume = { + name: CLOUDINIT_VOLUME, + cloudInitNoCloud: { + userData: userDataWithMagicHeader + } + }; + + addVolume(vm, cloudInitVolume); + } +}; + +const addDisk = (vm, diskSpec) => { + const domain = get(vm.spec.template.spec, 'domain', {}); + const devices = get(domain, 'devices', {}); + const disks = get(devices, 'disks', []); + disks.push(diskSpec); + devices.disks = disks; + domain.devices = devices; + vm.spec.template.spec.domain = domain; +}; + +const addVolume = (vm, volumeSpec) => { + const volumes = get(vm.spec.template.spec, 'volumes', []); + volumes.push(volumeSpec); + vm.spec.template.spec.volumes = volumes; +}; + +const addDataVolume = (vm, dataVolumeSpec) => { + const dataVolumes = get(vm.spec, 'dataVolumeTemplates', []); + dataVolumes.push(dataVolumeSpec); + vm.spec.dataVolumeTemplates = dataVolumes; +}; + +const addInterface = (vm, interfaceSpec) => { + const domain = get(vm.spec.template.spec, 'domain', {}); + const devices = get(domain, 'devices', {}); + const interfaces = get(devices, 'interfaces', []); + interfaces.push(interfaceSpec); + devices.interfaces = interfaces; + domain.devices = devices; + vm.spec.template.spec.domain = domain; +}; + +const addAnnotation = (vm, key, value) => { + const annotations = get(vm.metadata, 'annotations', {}); + annotations[key] = value; + vm.metadata.annotations = annotations; +}; diff --git a/src/k8s/tests/request.test.js b/src/k8s/tests/request.test.js new file mode 100644 index 000000000..fcc73f1ae --- /dev/null +++ b/src/k8s/tests/request.test.js @@ -0,0 +1,154 @@ +import { createVM } from '../request'; +import { rhel75 } from '../mock_templates/rhel75.template'; +import { ProcessedTemplatesModel } from '../../models'; + +const basicSettings = { + name: { + value: 'name' + }, + namespace: { + value: 'namespace' + }, + chosenTemplate: rhel75, + imageSourceType: { + value: 'Registry' + }, + registryImage: { + value: 'imageURL' + }, + flavor: { + value: 'small' + } +}; + +const basicSettingsCloudInit = { + name: { + value: 'name' + }, + namespace: { + value: 'namespace' + }, + chosenTemplate: rhel75, + imageSourceType: { + value: 'Registry' + }, + registryImage: { + value: 'imageURL' + }, + flavor: { + value: 'small' + }, + cloudInit: { + value: true + }, + hostname: { + value: 'hostname' + }, + authKeys: { + value: 'keys' + } +}; + +const vmFromURL = { + name: { + value: 'name' + }, + namespace: { + value: 'namespace' + }, + description: { + value: 'desc' + }, + chosenTemplate: rhel75, + imageSourceType: { + value: 'URL' + }, + imageURL: { + value: 'httpURL' + }, + flavor: { + value: 'small' + } +}; + +const vmPXE = { + name: { + value: 'name' + }, + namespace: { + value: 'namespace' + }, + description: { + value: 'desc' + }, + chosenTemplate: rhel75, + imageSourceType: { + value: 'PXE' + }, + flavor: { + value: 'small' + }, + startVM: { + value: true + } +}; + +const processTemplate = template => + new Promise((resolve, reject) => { + const nameParam = template.parameters.find(param => param.name === 'NAME'); + template.objects[0].metadata.name = nameParam.value; + resolve(template); + }); + +export const k8sCreate = (model, resource) => { + if (model === ProcessedTemplatesModel) { + return processTemplate(resource); + } + return new Promise(resolve => resolve(resource)); +}; + +describe('request.js', () => { + it('registryImage', () => + createVM(k8sCreate, basicSettings).then(vm => { + expect(vm.metadata.name).toBe(basicSettings.name.value); + expect(vm.metadata.namespace).toBe(basicSettings.namespace.value); + expect(vm.spec.template.spec.domain.devices.disks[0].name).toBe('rootdisk'); + expect(vm.spec.template.spec.domain.devices.disks[0].volumeName).toBe('rootvolume'); + + expect(vm.spec.template.spec.volumes[0].name).toBe('rootvolume'); + expect(vm.spec.template.spec.volumes[0].registryDisk.image).toBe('imageURL'); + return vm; + })); + it('from URL', () => + createVM(k8sCreate, vmFromURL).then(vm => { + expect(vm.metadata.name).toBe(basicSettings.name.value); + expect(vm.metadata.namespace).toBe(basicSettings.namespace.value); + expect(vm.spec.template.spec.domain.devices.disks[0].name).toBe('rootdisk'); + expect(vm.spec.template.spec.domain.devices.disks[0].volumeName).toBe('rootvolume'); + + expect(vm.spec.template.spec.volumes[0].name).toBe('rootvolume'); + const dataVolumeName = `datavolume-${vmFromURL.name.value}`; + expect(vm.spec.template.spec.volumes[0].dataVolume.name).toBe(dataVolumeName); + + expect(vm.spec.dataVolumeTemplates[0].metadata.name).toBe(dataVolumeName); + expect(vm.spec.dataVolumeTemplates[0].spec.source.http.url).toBe(vmFromURL.imageURL.value); + return vm; + })); + it('from PXE', () => + createVM(k8sCreate, vmPXE).then(vm => { + expect(vm.metadata.name).toBe(basicSettings.name.value); + expect(vm.metadata.namespace).toBe(basicSettings.namespace.value); + expect(vm.spec.template.spec.domain.devices.interfaces[0].bootOrder).toBe(1); + return vm; + })); + it('with CloudInit', () => + createVM(k8sCreate, basicSettingsCloudInit).then(vm => { + expect(vm.metadata.name).toBe(basicSettings.name.value); + expect(vm.metadata.namespace).toBe(basicSettings.namespace.value); + expect(vm.spec.template.spec.domain.devices.disks[1].name).toBe('cloudinitdisk'); + expect(vm.spec.template.spec.domain.devices.disks[1].volumeName).toBe('cloudinitvolume'); + + expect(vm.spec.template.spec.volumes[1].name).toBe('cloudinitvolume'); + return vm; + })); +}); diff --git a/src/models/index.js b/src/models/index.js new file mode 100644 index 000000000..02edb3e12 --- /dev/null +++ b/src/models/index.js @@ -0,0 +1,18 @@ +export const VirtualMachineModel = { + label: 'Virtual Machine', + labelPlural: 'Virtual Machines', + apiVersion: 'v1alpha2', + path: 'virtualmachines', + apiGroup: 'kubevirt.io', + plural: 'virtualmachines', + abbr: 'VM', + namespaced: true, + kind: 'VirtualMachine', + id: 'virtualmachine' +}; + +export const ProcessedTemplatesModel = { + apiVersion: 'v1', + path: 'processedtemplates', + apiGroup: 'template.openshift.io' +}; diff --git a/src/utils/template.js b/src/utils/template.js new file mode 100644 index 000000000..b74d6fb3a --- /dev/null +++ b/src/utils/template.js @@ -0,0 +1,32 @@ +import { remove, pull } from 'lodash'; + +export const getTemplatesWithLabels = (templates, labels) => { + const filteredTemplates = [...templates]; + labels.forEach(label => { + if (label !== undefined) { + pull( + filteredTemplates, + remove( + filteredTemplates, + template => Object.keys(template.metadata.labels).find(l => l === label) === undefined + ) + ); + } + }); + return filteredTemplates; +}; + +export const getTemplatesLabelValues = (templates, label) => { + const labelValues = []; + templates.forEach(t => { + const labels = Object.keys(t.metadata.labels).filter(l => l.startsWith(label)); + labels.forEach(l => { + const lArray = l.split('/'); + const lName = lArray[lArray.length - 1]; + if (labelValues.indexOf(lName) === -1) { + labelValues.push(lName); + } + }); + }); + return labelValues; +}; diff --git a/src/utils/tests/validation.test.js b/src/utils/tests/validation.test.js new file mode 100644 index 000000000..9f682c435 --- /dev/null +++ b/src/utils/tests/validation.test.js @@ -0,0 +1,24 @@ +import { isPositiveNumber } from '../validation'; + +describe('validation.js tests', () => { + it('returns false for NaN', () => { + expect(isPositiveNumber('abc')).toBeFalsy(); + }); + it('returns false for undefined and null', () => { + expect(isPositiveNumber()).toBeFalsy(); + expect(isPositiveNumber(null)).toBeFalsy(); + }); + it('returns false for negative number', () => { + expect(isPositiveNumber('-1')).toBeFalsy(); + }); + it('returns false for 0', () => { + expect(isPositiveNumber('0')).toBeFalsy(); + }); + it('returns false for float', () => { + expect(isPositiveNumber('1.2')).toBeFalsy(); + expect(isPositiveNumber('1,2')).toBeFalsy(); + }); + it('returns true for positive number', () => { + expect(isPositiveNumber('1')).toBeTruthy(); + }); +}); diff --git a/src/utils/validation.js b/src/utils/validation.js new file mode 100644 index 000000000..105bac256 --- /dev/null +++ b/src/utils/validation.js @@ -0,0 +1 @@ +export const isPositiveNumber = value => value && value.match(/^[1-9]\d*$/); diff --git a/yarn.lock b/yarn.lock index 5df1b5764..b56991926 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1073,6 +1073,10 @@ after@0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" +ajv-errors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.0.tgz#ecf021fa108fd17dfb5e6b383f2dd233e31ffc59" + ajv-keywords@^3.0.0, ajv-keywords@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.2.0.tgz#e86b819c602cf8821ad637413698f1dec021847a" @@ -2250,6 +2254,23 @@ css-element-queries@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/css-element-queries/-/css-element-queries-1.0.2.tgz#a32edfee4a5023688ef4d45f402077306d51d44c" +css-loader@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-1.0.0.tgz#9f46aaa5ca41dbe31860e3b62b8e23c42916bf56" + dependencies: + babel-code-frame "^6.26.0" + css-selector-tokenizer "^0.7.0" + icss-utils "^2.1.0" + loader-utils "^1.0.2" + lodash.camelcase "^4.3.0" + postcss "^6.0.23" + postcss-modules-extract-imports "^1.2.0" + postcss-modules-local-by-default "^1.2.0" + postcss-modules-scope "^1.1.0" + postcss-modules-values "^1.3.0" + postcss-value-parser "^3.3.0" + source-list-map "^2.0.0" + css-select@^1.1.0, css-select@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" @@ -2259,10 +2280,22 @@ css-select@^1.1.0, css-select@~1.2.0: domutils "1.5.1" nth-check "~1.0.1" +css-selector-tokenizer@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz#e6988474ae8c953477bf5e7efecfceccd9cf4c86" + dependencies: + cssesc "^0.1.0" + fastparse "^1.1.1" + regexpu-core "^1.0.0" + css-what@2.1: version "2.1.0" resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.0.tgz#9467d032c38cfaefb9f2d79501253062f87fa1bd" +cssesc@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" + cssesc@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-1.0.1.tgz#ef7bd8d0229ed6a3a7051ff7771265fe7330e0a8" @@ -3320,6 +3353,10 @@ fast-levenshtein@~2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" +fastparse@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8" + faye-websocket@~0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.1.tgz#f0efe18c4f56e4f40afc7e06c719fd5ee6188f38" @@ -3970,6 +4007,16 @@ iconv-lite@^0.4.17, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: dependencies: safer-buffer ">= 2.1.2 < 3" +icss-replace-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" + +icss-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-2.1.0.tgz#83f0a0ec378bf3246178b6c2ad9136f135b1c962" + dependencies: + postcss "^6.0.1" + ieee754@^1.1.4: version "1.1.12" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" @@ -5057,6 +5104,10 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -5093,7 +5144,7 @@ lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" -lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0: +lodash@4.17.x, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" @@ -5329,7 +5380,7 @@ mime@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" -mime@^2.1.0: +mime@^2.0.3, mime@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369" @@ -6209,6 +6260,33 @@ postcss-media-query-parser@^0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz#27b39c6f4d94f81b1a73b8f76351c609e5cef244" +postcss-modules-extract-imports@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.0.tgz#66140ecece38ef06bf0d3e355d69bf59d141ea85" + dependencies: + postcss "^6.0.1" + +postcss-modules-local-by-default@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069" + dependencies: + css-selector-tokenizer "^0.7.0" + postcss "^6.0.1" + +postcss-modules-scope@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90" + dependencies: + css-selector-tokenizer "^0.7.0" + postcss "^6.0.1" + +postcss-modules-values@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20" + dependencies: + icss-replace-symbols "^1.1.0" + postcss "^6.0.1" + postcss-reporter@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/postcss-reporter/-/postcss-reporter-5.0.0.tgz#a14177fd1342829d291653f2786efd67110332c3" @@ -6278,7 +6356,7 @@ postcss@^5.2.16: source-map "^0.5.6" supports-color "^3.2.3" -postcss@^6.0.8: +postcss@^6.0.1, postcss@^6.0.23, postcss@^6.0.8: version "6.0.23" resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" dependencies: @@ -6901,7 +6979,7 @@ regenerate-unicode-properties@^7.0.0: dependencies: regenerate "^1.4.0" -regenerate@^1.4.0: +regenerate@^1.2.1, regenerate@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11" @@ -6936,6 +7014,14 @@ regexpp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.0.tgz#b2a7534a85ca1b033bcf5ce9ff8e56d4e0755365" +regexpu-core@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" + dependencies: + regenerate "^1.2.1" + regjsgen "^0.2.0" + regjsparser "^0.1.4" + regexpu-core@^4.1.3, regexpu-core@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.2.0.tgz#a3744fa03806cffe146dea4421a3e73bdcc47b1d" @@ -6947,10 +7033,20 @@ regexpu-core@^4.1.3, regexpu-core@^4.2.0: unicode-match-property-ecmascript "^1.0.4" unicode-match-property-value-ecmascript "^1.0.2" +regjsgen@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" + regjsgen@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.4.0.tgz#c1eb4c89a209263f8717c782591523913ede2561" +regjsparser@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" + dependencies: + jsesc "~0.5.0" + regjsparser@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.3.0.tgz#3c326da7fcfd69fa0d332575a41c8c0cdf588c96" @@ -7260,6 +7356,14 @@ schema-utils@^0.4.4, schema-utils@^0.4.5: ajv "^6.1.0" ajv-keywords "^3.1.0" +schema-utils@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" + dependencies: + ajv "^6.1.0" + ajv-errors "^1.0.0" + ajv-keywords "^3.1.0" + "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1: version "5.5.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" @@ -7715,6 +7819,13 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" +style-loader@0.23.x: + version "0.23.0" + resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.23.0.tgz#8377fefab68416a2e05f1cabd8c3a3acfcce74f1" + dependencies: + loader-utils "^1.1.0" + schema-utils "^0.4.5" + style-search@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/style-search/-/style-search-0.1.0.tgz#7958c793e47e32e07d2b5cafe5c0bf8e12e77902" @@ -8193,6 +8304,14 @@ url-join@^2.0.2: version "2.0.5" resolved "https://registry.yarnpkg.com/url-join/-/url-join-2.0.5.tgz#5af22f18c052a000a48d7b82c5e9c2e2feeda728" +url-loader@1.1.x: + version "1.1.1" + resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-1.1.1.tgz#4d1f3b4f90dde89f02c008e662d604d7511167c1" + dependencies: + loader-utils "^1.1.0" + mime "^2.0.3" + schema-utils "^1.0.0" + url-parse@^1.1.8, url-parse@^1.4.1, url-parse@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.3.tgz#bfaee455c889023219d757e045fa6a684ec36c15"