diff --git a/src/Containers.jsx b/src/Containers.jsx index 07b3effbc..cdda7163a 100644 --- a/src/Containers.jsx +++ b/src/Containers.jsx @@ -41,7 +41,7 @@ import PruneUnusedContainersModal from './PruneUnusedContainersModal.jsx'; const _ = cockpit.gettext; -const ContainerActions = ({ container, healthcheck, onAddNotification, version, localImages, updateContainerAfterEvent }) => { +const ContainerActions = ({ container, containerDetail, healthcheck, onAddNotification, version, localImages, updateContainerAfterEvent, copyContainer }) => { const Dialogs = useDialogs(); const [isActionsKebabOpen, setActionsKebabOpen] = useState(false); const isRunning = container.State == "running"; @@ -256,6 +256,15 @@ const ContainerActions = ({ container, healthcheck, onAddNotification, version, } } + if (container && containerDetail && localImages) { + actions.push( + copyContainer(container, containerDetail, localImages)}> + {_("Clone")} + + ); + } + actions.push(); actions.push( , props: { className: "pf-c-table__action" } }); + columns.push({ + title: , + props: { className: "pf-c-table__action" } + }); } const tty = containerDetail ? !!containerDetail.Config.Tty : undefined; @@ -575,6 +590,23 @@ class Containers extends React.Component { this.setState({ showPruneUnusedContainersModal: true }); }; + copyContainer(container, containerDetail, localImages) { + this.context.show(); + } + render() { const Dialogs = this.context; const columnTitles = [ diff --git a/src/DynamicListForm.jsx b/src/DynamicListForm.jsx index 70976f794..d6e80876c 100644 --- a/src/DynamicListForm.jsx +++ b/src/DynamicListForm.jsx @@ -11,7 +11,7 @@ export class DynamicListForm extends React.Component { constructor(props) { super(props); this.state = { - list: [], + list: this.props.prefill ?? [], }; this.keyCounter = 0; this.removeItem = this.removeItem.bind(this); diff --git a/src/ImageRunModal.jsx b/src/ImageRunModal.jsx index 3ee6d729b..d9f5890b8 100644 --- a/src/ImageRunModal.jsx +++ b/src/ImageRunModal.jsx @@ -18,7 +18,7 @@ import { Popover } from "@patternfly/react-core/dist/esm/components/Popover"; import { MinusIcon, OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; import * as dockerNames from 'docker-names'; -import { ErrorNotification } from './Notification.jsx'; +import { ErrorNotification, WarningNotification } from './Notification.jsx'; import * as utils from './util.js'; import * as client from './client.js'; import rest from './rest.js'; @@ -54,10 +54,10 @@ const units = { // healthchecks.go HealthCheckOnFailureAction const HealthCheckOnFailureActionOrder = [ - { value: 0, label: _("No action") }, - { value: 3, label: _("Restart") }, - { value: 4, label: _("Stop") }, - { value: 2, label: _("Force stop") }, + { value: 0, label: _("No action"), apiName: "none" }, + { value: 3, label: _("Restart"), apiName: "restart" }, + { value: 4, label: _("Stop"), apiName: "stop" }, + { value: 2, label: _("Force stop"), apiName: "kill" }, ]; const handleEnvValue = (key, value, idx, onChange, additem, itemCount, companionField) => { @@ -175,6 +175,9 @@ export class ImageRunModal extends React.Component { componentDidMount() { this._isMounted = true; this.onSearchTriggered(this.state.searchText); + + if (this.props.prefill) + this.prefillModal(); } componentWillUnmount() { @@ -635,6 +638,75 @@ export class ImageRunModal extends React.Component { return owner === systemOwner; }; + prefillModal() { + const container = this.props.container; + const containerDetail = this.props.containerDetail; + const image = this.props.localImages.find(img => img.Id === container.ImageID); + const owner = container.isSystem ? 'system' : this.props.user; + + if (containerDetail.Config.CreateCommand) { + this.setState({ + dialogWarning: _("This container was not created by cockpit"), + dialogWarningDetail: _("Some options may not be copied to the new container."), + }); + } + + const env = containerDetail.Config.Env.filter(variable => { + if (image.Env.includes(variable)) { + return false; + } + + return !variable.match(/((HOME|TERM|HOSTNAME)=.*)|container=podman/); + }).map((variable, index) => { + const split = variable.split('='); + return { key: index, envKey: split[0], envValue: split[1] }; + }); + + const publish = container.Ports + ? container.Ports.map((port, index) => { + return { key: index, IP: port.hostIP || port.host_ip, containerPort: port.containerPort || port.container_port, hostPort: port.hostPort || port.host_port, protocol: port.protocol }; + }) + : []; + + const volumes = containerDetail.Mounts.map((mount, index) => { + // podman does not expose SELinux labels + return { key: index, containerPath: mount.Destination, hostPath: mount.Source, mode: (mount.RW ? 'rw' : 'ro'), selinux: '' }; + }); + + // check if memory and cpu limitations or healthcheck are used + const memoryConfigure = containerDetail.HostConfig.Memory > 0; + const cpuSharesConfigure = containerDetail.HostConfig.CpuShares > 0; + const healthcheck = !!containerDetail.Config.Healthcheck; + const healthCheckOnFailureAction = (this.props.version.split(".")) >= [4, 3, 0] + ? HealthCheckOnFailureActionOrder.find(item => item.apiName === containerDetail.Config.HealthcheckOnFailureAction).value + : null; + + this.setState({ + command: container.Command ? container.Command.join(' ') : "", + containerName: container.Names[0] + "_copy", + env, + hasTTY: containerDetail.Config.Tty, + publish, + // memory in MB + memory: memoryConfigure ? (containerDetail.HostConfig.Memory / 1000000) : 512, + cpuShares: cpuSharesConfigure ? containerDetail.HostConfig.CpuShares : 1024, + memoryConfigure, + cpuSharesConfigure, + volumes, + owner, + // unless-stopped: Identical to always + restartPolicy: containerDetail.HostConfig.RestartPolicy.Name === 'unless-stopped' ? 'always' : containerDetail.HostConfig.RestartPolicy.Name, + selectedImage: image, + healthcheck_command: healthcheck ? containerDetail.Config.Healthcheck.Test.join(' ') : "", + // convert to seconds + healthcheck_interval: healthcheck ? (containerDetail.Config.Healthcheck.Interval / 1000000000) : 30, + healthcheck_timeout: healthcheck ? (containerDetail.Config.Healthcheck.Timeout / 1000000000) : 30, + healthcheck_start_period: healthcheck ? (containerDetail.Config.Healthcheck.StartPeriod / 1000000000) : 0, + healthcheck_retries: healthcheck ? containerDetail.Config.Healthcheck.Retries : 3, + healthcheck_action: healthcheck ? healthCheckOnFailureAction : 0, + }); + } + render() { const Dialogs = this.context; const { image } = this.props; @@ -688,6 +760,7 @@ export class ImageRunModal extends React.Component { const defaultBody = (
+ {this.state.dialogWarning && } {this.state.dialogError && } this.onValueChanged('publish', value)} default={{ IP: null, containerPort: null, hostPort: null, protocol: 'tcp' }} + prefill={this.state.publish} itemcomponent={ } /> this.onValueChanged('volumes', value)} default={{ containerPath: null, hostPath: null, mode: 'rw' }} options={{ selinuxAvailable: this.props.selinuxAvailable }} + prefill={this.state.volumes} itemcomponent={ } /> this.onValueChanged('env', value)} default={{ envKey: null, envValue: null }} helperText={_("Paste one or more lines of key=value pairs into any field for bulk import")} + prefill={this.state.env} itemcomponent={ } /> {_("Health check")}} id="create-image-dialog-tab-healthcheck" className="pf-c-form pf-m-horizontal"> @@ -1089,6 +1165,44 @@ export class ImageRunModal extends React.Component { ); + + const cardFooter = () => { + let createRunText = _("Create and run"); + let createText = _("Create"); + + if (this.props.prefill) { + createRunText = _("Clone and run"); + createText = _("Clone"); + } + + return ( + <> + + + + + ); + }; + + const modalTitle = () => { + let titleText = _("Create container"); + + if (this.props.prefill && this.props.pod) + titleText = _("Clone container in $0"); + else if (this.props.prefill) + titleText = _("Clone container"); + else if (this.props.pod) + titleText = _("Create container in $0"); + + return this.props.pod ? cockpit.format(titleText, this.props.pod.Name) : titleText; + }; + return ( - - - - } + title={modalTitle()} + footer={cardFooter()} > {defaultBody} diff --git a/src/Notification.jsx b/src/Notification.jsx index 3fc3ff2f5..8333a15a4 100644 --- a/src/Notification.jsx +++ b/src/Notification.jsx @@ -43,3 +43,11 @@ export const ErrorNotification = ({ errorMessage, errorDetail, onDismiss }) => { ); }; + +export const WarningNotification = ({ warningMessage, warningDetail }) => { + return ( + + { warningDetail &&

{_("Warning message")}: {warningDetail}

} +
+ ); +}; diff --git a/test/check-application b/test/check-application index 3f4f77904..29e87d584 100755 --- a/test/check-application +++ b/test/check-application @@ -2578,6 +2578,216 @@ class TestApplication(testlib.MachineCase): else: self.assertIn("Podman", b.text("#host-apps")) + def testCloneContainerSystem(self): + self._testCloneContainer(True) + + def testCloneContainerUser(self): + self._testCloneContainer(False) + + def _testCloneContainer(self, auth): + b = self.browser + + self.login(auth) + + # Create a simple container + self.execute(auth, f"podman run -d --name busybox --memory 256000000 --env MYVAR=simple_container {IMG_BUSYBOX} sleep 1000") + self.performContainerAction("busybox", "Clone") + b.wait_visible("div.pf-c-modal-box") + + # container was not created through cockpit, warning should be present + b.wait_visible(".pf-c-form .pf-c-alert") + # Details tab + if auth: + b.wait_visible("#run-image-dialog-owner-system:checked") + + b.wait_visible("#run-image-dialog-name[value='busybox_copy']") + b.wait_visible("#create-image-image-select-typeahead[value='localhost/test-busybox:latest']") + b.wait_visible("#run-image-dialog-command[value='sleep 1000']") + b.wait_visible("#run-image-dialog-pull-latest-image:not(:checked)") + + if auth or self.has_cgroupsV2: + b.wait_visible("#run-image-dialog-memory-limit-checkbox:checked") + b.wait_visible("#run-image-dialog-memory input[value='256']") + + if auth: + b.wait_visible("#run-image-dialog-cpu-priority-checkbox:not(:checked)") + b.wait_visible("#run-image-cpu-priority input[value='1024']") + + # Integration tab + b.click("#pf-tab-1-create-image-dialog-tab-integration") + b.wait_in_text("#pf-tab-section-1-create-image-dialog-tab-integration .publish-port-form", "No ports exposed") + b.wait_in_text("#pf-tab-section-1-create-image-dialog-tab-integration .volume-form", "No volumes specified") + + b.wait_visible("#run-image-dialog-env-0-key[value='MYVAR']") + b.wait_visible("#run-image-dialog-env-0-value[value='simple_container']") + # only one env is set + b.wait_not_present("#run-image-dialog-env-1") + + # Healthcheck tab - has default values + b.click("#pf-tab-2-create-image-dialog-tab-healthcheck") + b.wait_visible("#run-image-dialog-healthcheck-command[value='']") + b.wait_visible("#run-image-healthcheck-interval input[value='30']") + b.wait_visible("#run-image-healthcheck-timeout input[value='30']") + b.wait_visible("#run-image-healthcheck-start-period input[value='0']") + b.wait_visible("#run-image-healthcheck-retries input[value='3']") + + b.click("#create-image-create-run-btn") + self.waitContainerRow("busybox_copy") + + ports = self.execute(auth, "podman inspect --format '{{.NetworkSettings.Ports}}' busybox_copy") + self.assertEqual(ports, 'map[]\n') + + env = self.execute(auth, "podman exec busybox_copy env") + self.assertIn('MYVAR=simple_container', env) + self.assertIn('container=podman', env) + self.assertIn('TERM=xterm', env) + self.assertIn('HOME=/root', env) + self.assertIn('PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', env) + + # Clone container and set different values + # Switch to Details tab + self.performContainerAction("busybox_copy", "Clone") + + # Change memory configuration + if auth or self.has_cgroupsV2: + b.set_input_text("#run-image-dialog-memory-limit input[type=number]", "1024") + + if auth: + # Set cpu shares + b.set_checked("#run-image-dialog-cpu-priority-checkbox", True) + b.set_input_text("#run-image-dialog-cpu-priority input[type=number]", "2048") + + # Enable tty + b.set_checked("#run-image-dialog-tty", True) + + # Change command + b.set_input_text('#run-image-dialog-command', "sleep 900") + + if auth: + # Add restart policy + b.set_val("#run-image-dialog-restart-policy", "on-failure") + b.set_input_text('#run-image-dialog-restart-retries input', '2') + + # Switch to Integration tab + b.click("#pf-tab-1-create-image-dialog-tab-integration") + + # Configure published ports + b.click(".publish-port-form .btn-add") + b.set_input_text("#run-image-dialog-publish-0-host-port", "6000") + b.set_input_text("#run-image-dialog-publish-0-container-port", "5000") + b.click(".publish-port-form .btn-add") + b.set_input_text("#run-image-dialog-publish-1-ip-address", "127.0.0.1") + b.set_input_text("#run-image-dialog-publish-1-host-port", "6001") + b.set_input_text("#run-image-dialog-publish-1-container-port", "5001") + b.set_val("#run-image-dialog-publish-1-protocol", "udp") + + # Add environment variables + b.click(".env-form .btn-add") + b.set_input_text("#run-image-dialog-env-1-key", "APPLE") + b.set_input_text("#run-image-dialog-env-1-value", "ORANGE") + b.click(".env-form .btn-add") + b.set_input_text("#run-image-dialog-env-2-key", "PEAR") + b.set_input_text("#run-image-dialog-env-2-value", "BANANA") + + # Configure volumes + b.click(".volume-form .btn-add") + b.set_checked("#run-image-dialog-volume-0-mode", False) + b.set_file_autocomplete_val("#run-image-dialog-volume-0 .pf-c-select", "/tmp/") + b.key_press(["\r"]) + b.set_input_text("#run-image-dialog-volume-0-container-path", "/host/tmp") + b.key_press(["\r"]) + b.click(".volume-form .btn-add") + b.set_checked("#run-image-dialog-volume-1-mode", True) + b.set_file_autocomplete_val("#run-image-dialog-volume-1 .pf-c-select", "/mnt/") + b.key_press(["\r"]) + b.set_input_text("#run-image-dialog-volume-1-container-path", "/mounts") + b.key_press(["\r"]) + + # Switch to healthcheck tab + b.click("#pf-tab-2-create-image-dialog-tab-healthcheck") + b.set_input_text("#run-image-dialog-healthcheck-command", "true") + b.set_input_text("#run-image-healthcheck-interval input", "90") + b.set_input_text("#run-image-healthcheck-timeout input", "10") + b.set_input_text("#run-image-healthcheck-start-period input", "3") + b.click("#run-image-healthcheck-retries button:nth-child(1)") + if podman_version(self) >= (4, 3, 0): + b.click("#run-image-healthcheck-action-3") + + # change container name + b.set_input_text("#run-image-dialog-name", "busybox_edited") + b.click("#create-image-create-run-btn") + self.waitContainerRow("busybox_edited") + + ports = self.execute(auth, "podman inspect --format '{{.NetworkSettings.Ports}}' busybox_edited") + self.assertIn('5000/tcp:[{ 6000}]', ports) + self.assertIn('5001/udp:[{127.0.0.1 6001}]', ports) + + env = self.execute(auth, "podman exec busybox_edited env") + self.assertIn('APPLE=ORANGE', env) + self.assertIn('PEAR=BANANA', env) + self.assertIn('MYVAR=simple_container', env) + self.assertIn('container=podman', env) + self.assertIn('TERM=xterm', env) + self.assertIn('HOME=/root', env) + self.assertIn('PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', env) + + healthcheck = self.execute(auth, "podman inspect --format '{{.Config.Healthcheck}}' busybox_edited") + self.assertIn('true', healthcheck) # healthcheck command + self.assertIn('1m30s', healthcheck) # interval + self.assertIn('3s', healthcheck) # start period + self.assertIn('10s', healthcheck) # timeout + self.assertIn('2', healthcheck) # retries + if podman_version(self) >= (4, 3, 0): + self.assertEqual('restart', self.execute(auth, "podman inspect --format '{{.Config.HealthcheckOnFailureAction}}' busybox_edited").strip()) + + self.performContainerAction("busybox_edited", "Force stop") + self.waitContainer(self.execute(auth, "podman inspect --format '{{.Id}}' busybox_edited").strip(), auth, state="ExitedHealthy") + + # create 1:1 clone of container above + self.performContainerAction("busybox_edited", "Clone") + b.click("#create-image-create-run-btn") + self.waitContainerRow("busybox_edited_copy") + + cmd = self.execute(auth, "podman inspect --format '{{.Config.Cmd}}' busybox_edited_copy").strip() + self.assertEqual('[sleep 900]', cmd) + + if self.has_cgroupsV2: + memory_limit = int(self.execute(auth, "podman inspect --format '{{.HostConfig.Memory}}' busybox_edited_copy")) + self.assertEqual(1024_000_000, memory_limit) + + if auth: + cpu_shares = int(self.execute(auth, "podman inspect --format '{{.HostConfig.CpuShares}}' busybox_edited_copy")) + self.assertEqual(2048, cpu_shares) + + restart_policy = self.execute(auth, "podman inspect --format '{{.HostConfig.RestartPolicy}}' busybox_edited_copy").strip() + self.assertEqual('{on-failure 5}', restart_policy) + + ports = self.execute(auth, "podman inspect --format '{{.NetworkSettings.Ports}}' busybox_edited_copy") + self.assertIn('5000/tcp:[{ 6000}]', ports) + self.assertIn('5001/udp:[{127.0.0.1 6001}]', ports) + + binds = self.execute(auth, "podman inspect --format '{{.HostConfig.Binds}}' busybox_edited_copy") + self.assertIn('/tmp:/host/tmp:ro', binds) + self.assertIn('/mnt:/mounts:rw', binds) + + env = self.execute(auth, "podman exec busybox_edited_copy env") + self.assertIn('APPLE=ORANGE', env) + self.assertIn('PEAR=BANANA', env) + self.assertIn('MYVAR=simple_container', env) + self.assertIn('container=podman', env) + self.assertIn('TERM=xterm', env) + self.assertIn('HOME=/root', env) + self.assertIn('PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', env) + + healthcheck = self.execute(auth, "podman inspect --format '{{.Config.Healthcheck}}' busybox_edited_copy") + self.assertIn('true', healthcheck) # healthcheck command + self.assertIn('1m30s', healthcheck) # interval + self.assertIn('3s', healthcheck) # start period + self.assertIn('10s', healthcheck) # timeout + self.assertIn('2', healthcheck) # retries + if podman_version(self) >= (4, 3, 0): + self.assertEqual('restart', self.execute(auth, "podman inspect --format '{{.Config.HealthcheckOnFailureAction}}' busybox_edited_copy").strip()) + if __name__ == '__main__': testlib.test_main()