Skip to content

Commit

Permalink
Add option to clone containers
Browse files Browse the repository at this point in the history
Adds 'Clone' button to container actions which opens
prefilled create container modal.
  • Loading branch information
tomasmatus committed Apr 21, 2023
1 parent 0ecb544 commit 41f541e
Show file tree
Hide file tree
Showing 5 changed files with 312 additions and 19 deletions.
36 changes: 34 additions & 2 deletions src/Containers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { PodCreateModal } from './PodCreateModal.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";
Expand Down Expand Up @@ -255,6 +255,15 @@ const ContainerActions = ({ container, healthcheck, onAddNotification, version,
}
}

if (container && containerDetail && localImages) {
actions.push(
<DropdownItem key="clone-container"
onClick={() => copyContainer(container, containerDetail, localImages)}>
{_("Clone")}
</DropdownItem>
);
}

actions.push(<DropdownSeparator key="separator-1" />);
actions.push(
<DropdownItem key="commit"
Expand Down Expand Up @@ -335,6 +344,8 @@ class Containers extends React.Component {
onDownloadContainer = onDownloadContainer.bind(this);
onDownloadContainerFinished = onDownloadContainerFinished.bind(this);

this.copyContainer = this.copyContainer.bind(this);

window.addEventListener('resize', this.onWindowResize);
}

Expand Down Expand Up @@ -403,7 +414,11 @@ class Containers extends React.Component {
];

if (!container.isDownloading) {
columns.push({ title: <ContainerActions version={this.props.version} container={container} healthcheck={healthcheck} onAddNotification={this.props.onAddNotification} localImages={localImages} updateContainerAfterEvent={this.props.updateContainerAfterEvent} />, props: { className: "pf-c-table__action" } });
columns.push({
title: <ContainerActions version={this.props.version} container={container} containerDetail={containerDetail} healthcheck={healthcheck} onAddNotification={this.props.onAddNotification}
localImages={localImages} updateContainerAfterEvent={this.props.updateContainerAfterEvent} copyContainer={this.copyContainer} />,
props: { className: "pf-c-table__action" }
});
}

const tty = containerDetail ? !!containerDetail.Config.Tty : undefined;
Expand Down Expand Up @@ -545,6 +560,23 @@ class Containers extends React.Component {
);
}

copyContainer(container, containerDetail, localImages) {
this.context.show(<ImageRunModal user={this.props.user}
localImages={localImages}
pod={this.props.pods[container.Pod + container.isSystem]}
registries={this.props.registries}
selinuxAvailable={this.props.selinuxAvailable}
podmanRestartAvailable={this.props.podmanRestartAvailable}
userServiceAvailable={this.props.userServiceAvailable}
systemServiceAvailable={this.props.systemServiceAvailable}
onAddNotification={this.props.onAddNotification}
version={this.props.version}
container={container}
containerDetail={containerDetail}
prefill
/>);
}

render() {
const Dialogs = this.context;
const columnTitles = [
Expand Down
2 changes: 1 addition & 1 deletion src/DynamicListForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
123 changes: 107 additions & 16 deletions src/ImageRunModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -688,6 +760,7 @@ export class ImageRunModal extends React.Component {

const defaultBody = (
<Form>
{this.state.dialogWarning && <WarningNotification warningMessage={this.state.dialogWarning} warningDetail={this.state.dialogWarningDetail} />}
{this.state.dialogError && <ErrorNotification errorMessage={this.state.dialogError} errorDetail={this.state.dialogErrorDetail} />}
<FormGroup fieldId='run-image-dialog-name' label={_("Name")} className="ct-m-horizontal">
<TextInput id='run-image-dialog-name'
Expand Down Expand Up @@ -938,6 +1011,7 @@ export class ImageRunModal extends React.Component {
actionLabel={_("Add port mapping")}
onChange={value => this.onValueChanged('publish', value)}
default={{ IP: null, containerPort: null, hostPort: null, protocol: 'tcp' }}
prefill={this.state.publish}
itemcomponent={ <PublishPort />} />

<DynamicListForm id='run-image-dialog-volume'
Expand All @@ -948,6 +1022,7 @@ export class ImageRunModal extends React.Component {
onChange={value => this.onValueChanged('volumes', value)}
default={{ containerPath: null, hostPath: null, mode: 'rw' }}
options={{ selinuxAvailable: this.props.selinuxAvailable }}
prefill={this.state.volumes}
itemcomponent={ <Volume />} />

<DynamicListForm id='run-image-dialog-env'
Expand All @@ -958,6 +1033,7 @@ export class ImageRunModal extends React.Component {
onChange={value => 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={ <EnvVar />} />
</Tab>
<Tab eventKey={2} title={<TabTitleText>{_("Health check")}</TabTitleText>} id="create-image-dialog-tab-healthcheck" className="pf-c-form pf-m-horizontal">
Expand Down Expand Up @@ -1089,6 +1165,31 @@ export class ImageRunModal extends React.Component {
</Tabs>
</Form>
);

const cardFooter = () => {
let createRunText = _("Create and run");
let createText = _("Create");

if (this.props.prefill) {
createRunText = _("Clone and run");
createText = _("Clone");
}

return (
<>
<Button variant='primary' id="create-image-create-run-btn" onClick={() => this.onCreateClicked(true)} isDisabled={(!image && selectedImage === "")}>
{createRunText}
</Button>
<Button variant='secondary' id="create-image-create-btn" onClick={() => this.onCreateClicked(false)} isDisabled={(!image && selectedImage === "")}>
{createText}
</Button>
<Button variant='link' className='btn-cancel' onClick={Dialogs.close}>
{_("Cancel")}
</Button>
</>
);
};

return (
<Modal isOpen
position="top" variant="medium"
Expand All @@ -1102,17 +1203,7 @@ export class ImageRunModal extends React.Component {
}
}}
title={this.props.pod ? cockpit.format(_("Create container in $0"), this.props.pod.Name) : _("Create container")}
footer={<>
<Button variant='primary' id="create-image-create-run-btn" onClick={() => this.onCreateClicked(true)} isDisabled={!image && selectedImage === ""}>
{_("Create and run")}
</Button>
<Button variant='secondary' id="create-image-create-btn" onClick={() => this.onCreateClicked(false)} isDisabled={!image && selectedImage === ""}>
{_("Create")}
</Button>
<Button variant='link' className='btn-cancel' onClick={Dialogs.close}>
{_("Cancel")}
</Button>
</>}
footer={cardFooter()}
>
{defaultBody}
</Modal>
Expand Down
8 changes: 8 additions & 0 deletions src/Notification.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,11 @@ export const ErrorNotification = ({ errorMessage, errorDetail, onDismiss }) => {
</Alert>
);
};

export const WarningNotification = ({ warningMessage, warningDetail }) => {
return (
<Alert isInline variant='warning' title={warningMessage}>
{ warningDetail && <p> {_("Warning message")}: <samp>{warningDetail}</samp> </p> }
</Alert>
);
};
Loading

0 comments on commit 41f541e

Please sign in to comment.