From be478c60573b59464d54cc452b9f0afa0b21cdf9 Mon Sep 17 00:00:00 2001 From: strzinek Date: Thu, 14 Apr 2022 08:59:55 +0200 Subject: [PATCH] Support creation of pods This implements the ability to create a podman pod for the user and admin session. A pod can have a volume and port mapping, note that volumes are only supported from podman 4.0. --- src/ContainerRenameModal.jsx | 3 +- src/Containers.jsx | 20 +++- src/DynamicListForm.jsx | 91 +++++++++++++++ src/DynamicListForm.scss | 39 +++++++ src/ImageRunModal.jsx | 209 ++--------------------------------- src/ImageRunModal.scss | 38 ------- src/PodCreateModal.jsx | 163 +++++++++++++++++++++++++++ src/PublishPort.jsx | 86 ++++++++++++++ src/Volume.jsx | 54 +++++++++ src/client.js | 2 + src/util.js | 4 + test/check-application | 104 ++++++++++++++++- test/reference | 2 +- test/vm.install | 17 ++- 14 files changed, 583 insertions(+), 249 deletions(-) create mode 100644 src/DynamicListForm.jsx create mode 100644 src/DynamicListForm.scss create mode 100644 src/PodCreateModal.jsx create mode 100644 src/PublishPort.jsx create mode 100644 src/Volume.jsx diff --git a/src/ContainerRenameModal.jsx b/src/ContainerRenameModal.jsx index 79445e88..e586b674 100644 --- a/src/ContainerRenameModal.jsx +++ b/src/ContainerRenameModal.jsx @@ -7,6 +7,7 @@ import { import cockpit from 'cockpit'; import * as client from './client.js'; +import * as utils from './util.js'; import { ErrorNotification } from './Notification.jsx'; import { useDialogs } from "dialogs.jsx"; @@ -24,7 +25,7 @@ const ContainerRenameModal = ({ container, version, updateContainerAfterEvent }) setName(value); if (value === "") { setNameError(_("Container name is required.")); - } else if (/^[a-zA-Z0-9][a-zA-Z0-9_\\.-]*$/.test(value)) { + } else if (utils.is_valid_container_name(value)) { setNameError(null); } else { setNameError(_("Invalid characters. Name can only contain letters, numbers, and certain punctuation (_ . -).")); diff --git a/src/Containers.jsx b/src/Containers.jsx index 44158922..37b71a64 100644 --- a/src/Containers.jsx +++ b/src/Containers.jsx @@ -37,6 +37,7 @@ import './Containers.scss'; import '@patternfly/patternfly/utilities/Accessibility/accessibility.css'; import { ImageRunModal } from './ImageRunModal.jsx'; import { PodActions } from './PodActions.jsx'; +import { PodCreateModal } from './PodCreateModal.jsx'; const _ = cockpit.gettext; @@ -668,6 +669,16 @@ class Containers extends React.Component { onAddNotification={this.props.onAddNotification} />); }; + const createPod = () => { + Dialogs.show(); + }; + const filterRunning = @@ -681,6 +692,13 @@ class Containers extends React.Component { + + + } + /> + } className={"dynamic-form-group " + formclass}> + { + dialogValues.list.length + ? <> + {dialogValues.list.map((item, idx) => { + return React.cloneElement(this.props.itemcomponent, { + idx: idx, item: item, id: id + "-" + idx, + key: idx, + onChange: this.onItemChange, removeitem: this.removeItem, additem: this.addItem, options: this.props.options, + itemCount: Object.keys(dialogValues.list).length, + }); + }) + } + {helperText && + + {helperText} + + } + + : + + {emptyStateString} + + + } + + ); + } +} +DynamicListForm.propTypes = { + emptyStateString: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + id: PropTypes.string.isRequired, + itemcomponent: PropTypes.object.isRequired, + formclass: PropTypes.string, + options: PropTypes.object, +}; diff --git a/src/DynamicListForm.scss b/src/DynamicListForm.scss new file mode 100644 index 00000000..70effa6c --- /dev/null +++ b/src/DynamicListForm.scss @@ -0,0 +1,39 @@ +@import "global-variables"; + +.dynamic-form-group { + .pf-c-empty-state { + padding: 0; + } + + .pf-c-form__label { + // Don't allow labels to wrap + white-space: nowrap; + } + + .remove-button-group { + // Move 'Remove' button the the end of the row + grid-column: -1; + // Move 'Remove' button to the bottom of the line so as to align with the other form fields + display: flex; + align-items: flex-end; + } + + // Set check to the same height as input widgets and vertically align + .pf-c-form__group-control > .pf-c-check { + // Set height to the same as inputs + // Font height is font size * line height (1rem * 1.5) + // Widgets have 5px padding, 1px border (top & bottom): (5 + 1) * 2 = 12 + // This all equals to 36px + height: calc(var(--pf-global--FontSize--md) * var(--pf-global--LineHeight--md) + 12px); + align-content: center; + } + + // We use FormFieldGroup PF component for the nested look and for ability to add buttons to the header + // However we want to save space and not add indent to the left so we need to override it + .pf-c-form__field-group-body { + // Stretch content fully + --pf-c-form__field-group-body--GridColumn: 1 / -1; + // Reduce padding at the top + --pf-c-form__field-group-body--PaddingTop: var(--pf-global--spacer--xs); + } +} diff --git a/src/ImageRunModal.jsx b/src/ImageRunModal.jsx index a739b655..a5fbdc4f 100644 --- a/src/ImageRunModal.jsx +++ b/src/ImageRunModal.jsx @@ -1,14 +1,12 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { Button, Checkbox, - EmptyState, EmptyStateBody, - Form, FormGroup, FormFieldGroup, FormFieldGroupHeader, + Form, FormGroup, FormSelect, FormSelectOption, Grid, GridItem, - HelperText, HelperTextItem, - InputGroup, Radio, InputGroupText, InputGroupTextVariant, - Modal, NumberInput, Select, SelectVariant, + Modal, Radio, Select, SelectVariant, + NumberInput, InputGroupTextVariant, + InputGroup, InputGroupText, SelectOption, SelectGroup, TextInput, Tabs, Tab, TabTitleText, ToggleGroup, ToggleGroupItem, @@ -19,13 +17,15 @@ import { MinusIcon, OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; import * as dockerNames from 'docker-names'; import { ErrorNotification } from './Notification.jsx'; -import { FileAutoComplete } from 'cockpit-components-file-autocomplete.jsx'; import * as utils from './util.js'; import * as client from './client.js'; import rest from './rest.js'; import cockpit from 'cockpit'; import { onDownloadContainer, onDownloadContainerFinished } from './Containers.jsx'; import { DialogsContext } from "dialogs.jsx"; +import { PublishPort } from './PublishPort.jsx'; +import { DynamicListForm } from './DynamicListForm.jsx'; +import { Volume } from './Volume.jsx'; import { debounce } from 'throttle-debounce'; @@ -50,79 +50,6 @@ const units = { }, }; -const PublishPort = ({ id, item, onChange, idx, removeitem, itemCount }) => - ( - - - - - }> - onChange(idx, 'IP', value)} /> - - - - - }> - onChange(idx, 'hostPort', value)} /> - - - onChange(idx, 'containerPort', value)} /> - - - onChange(idx, 'protocol', value)}> - - - - - - } - /> - } className={"dynamic-form-group " + formclass}> - { - dialogValues.list.length - ? <> - {dialogValues.list.map((item, idx) => { - return React.cloneElement(this.props.itemcomponent, { - idx: idx, item: item, id: id + "-" + idx, - key: idx, - onChange: this.onItemChange, removeitem: this.removeItem, additem: this.addItem, options: this.props.options, - itemCount: Object.keys(dialogValues.list).length, - }); - }) - } - {helperText && - - {helperText} - - } - - : - - {emptyStateString} - - - } - - ); - } -} -DynamicListForm.propTypes = { - emptyStateString: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - id: PropTypes.string.isRequired, - itemcomponent: PropTypes.object.isRequired, - formclass: PropTypes.string, - options: PropTypes.object, -}; - export class ImageRunModal extends React.Component { static contextType = DialogsContext; diff --git a/src/ImageRunModal.scss b/src/ImageRunModal.scss index 12598963..ccb09378 100644 --- a/src/ImageRunModal.scss +++ b/src/ImageRunModal.scss @@ -1,34 +1,5 @@ @import "global-variables"; -.dynamic-form-group { - .pf-c-empty-state { - padding: 0; - } - - .pf-c-form__label { - // Don't allow labels to wrap - white-space: nowrap; - } - - .remove-button-group { - // Move 'Remove' button the the end of the row - grid-column: -1; - // Move 'Remove' button to the bottom of the line so as to align with the other form fields - display: flex; - align-items: flex-end; - } - - // Set check to the same height as input widgets and vertically align - .pf-c-form__group-control > .pf-c-check { - // Set height to the same as inputs - // Font height is font size * line height (1rem * 1.5) - // Widgets have 5px padding, 1px border (top & bottom): (5 + 1) * 2 = 12 - // This all equals to 36px - height: calc(var(--pf-global--FontSize--md) * var(--pf-global--LineHeight--md) + 12px); - align-content: center; - } -} - // Ensure the width fits within the screen boundaries (with padding on the sides) .pf-c-select__menu { // 3xl is the left+right padding for an iPhone SE; @@ -65,15 +36,6 @@ } } -// We use FormFieldGroup PF component for the nested look and for ability to add buttons to the header -// However we want to save space and not add indent to the left so we need to override it -.dynamic-form-group .pf-c-form__field-group-body { - // Stretch content fully - --pf-c-form__field-group-body--GridColumn: 1 / -1; - // Reduce padding at the top - --pf-c-form__field-group-body--PaddingTop: var(--pf-global--spacer--xs); -} - // HACK: A local copy of pf-m-horizontal (as ct-m-horizontal), // but applied at the FormGroup level instead of Form @media (min-width: $pf-global--breakpoint--md) { diff --git a/src/PodCreateModal.jsx b/src/PodCreateModal.jsx new file mode 100644 index 00000000..ccef6abc --- /dev/null +++ b/src/PodCreateModal.jsx @@ -0,0 +1,163 @@ +import React, { useState } from 'react'; +import { + Button, + Form, FormGroup, Modal, Radio, + TextInput, +} from '@patternfly/react-core'; +import * as dockerNames from 'docker-names'; + +import { ErrorNotification } from './Notification.jsx'; +import { PublishPort } from './PublishPort.jsx'; +import { DynamicListForm } from './DynamicListForm.jsx'; +import { Volume } from './Volume.jsx'; +import * as client from './client.js'; +import * as utils from './util.js'; +import cockpit from 'cockpit'; +import { useDialogs } from "dialogs.jsx"; + +const _ = cockpit.gettext; + +const systemOwner = "system"; + +export const PodCreateModal = ({ user, selinuxAvailable, systemServiceAvailable, userServiceAvailable, version }) => { + const [podName, setPodName] = useState(dockerNames.getRandomName()); + const [nameError, setNameError] = useState(null); + const [publish, setPublish] = useState([]); + const [volumes, setVolumes] = useState([]); + const [owner, setOwner] = useState(systemServiceAvailable ? systemOwner : user); + const [dialogError, setDialogError] = useState(null); + const [dialogErrorDetail, setDialogErrorDetail] = useState(null); + const Dialogs = useDialogs(); + + const getCreateConfig = () => { + const createConfig = {}; + + if (podName) + createConfig.name = podName; + + if (publish.length > 0) + createConfig.portmappings = publish + .filter(port => port.containerPort) + .map(port => { + const pm = { container_port: parseInt(port.containerPort), protocol: port.protocol }; + if (port.hostPort !== null) + pm.host_port = parseInt(port.hostPort); + if (port.IP !== null) + pm.host_ip = port.IP; + return pm; + }); + + if (volumes.length > 0) { + createConfig.mounts = volumes + .filter(volume => volume.hostPath && volume.containerPath) + .map(volume => { + const record = { source: volume.hostPath, destination: volume.containerPath, type: "bind" }; + record.options = []; + if (volume.mode) + record.options.push(volume.mode); + if (volume.selinux) + record.options.push(volume.selinux); + return record; + }); + } + + return createConfig; + }; + + const createPod = (isSystem, createConfig) => { + client.createPod(isSystem, createConfig) + .then(() => Dialogs.close()) + .catch(ex => { + setDialogError(_("Pod failed to be created")); + setDialogErrorDetail(cockpit.format("$0: $1", ex.reason, ex.message)); + }); + }; + + const onCreateClicked = () => { + const createConfig = getCreateConfig(); + createPod(owner === systemOwner, createConfig); + }; + + const onValueChanged = (key, value) => { + if (key === "podName") { + setPodName(value); + } + if (utils.is_valid_container_name(value)) { + setNameError(null); + } else { + setNameError(_("Invalid characters. Name can only contain letters, numbers, and certain punctuation (_ . -).")); + } + }; + + const defaultBody = ( +
+ + onValueChanged('podName', value)} /> + + { userServiceAvailable && systemServiceAvailable && + + setOwner(systemOwner)} /> + setOwner(user)} /> + + } + setPublish(value)} + default={{ IP: null, containerPort: null, hostPort: null, protocol: 'tcp' }} + itemcomponent={ } /> + + {version.localeCompare("4", undefined, { numeric: true, sensitivity: 'base' }) >= 0 && + setVolumes(value)} + default={{ containerPath: null, hostPath: null, mode: 'rw' }} + options={{ selinuxAvailable }} + itemcomponent={ } /> + } + + + ); + + return ( + + {dialogError && } + + + } + > + {defaultBody} + + ); +}; diff --git a/src/PublishPort.jsx b/src/PublishPort.jsx new file mode 100644 index 00000000..173c93fc --- /dev/null +++ b/src/PublishPort.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { + Button, + FormGroup, + FormSelect, FormSelectOption, + Grid, + TextInput, + Popover, +} from '@patternfly/react-core'; +import { MinusIcon, OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import cockpit from 'cockpit'; + +const _ = cockpit.gettext; + +export const PublishPort = ({ id, item, onChange, idx, removeitem, itemCount }) => + ( + + + + + }> + onChange(idx, 'IP', value)} /> + + + + + }> + onChange(idx, 'hostPort', value)} /> + + + onChange(idx, 'containerPort', value)} /> + + + onChange(idx, 'protocol', value)}> + + + + + +