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 = (
);
+
+ 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()