diff --git a/src/ContainerCommitModal.jsx b/src/ContainerCommitModal.jsx index 6ab0dcc20..f18ecd5fb 100644 --- a/src/ContainerCommitModal.jsx +++ b/src/ContainerCommitModal.jsx @@ -97,7 +97,7 @@ class ContainerCommitModal extends React.Component { if (reply && 'logs' in reply && Array.isArray(reply.logs) && reply.logs.length > 0) console.log("Container commit:", message.parameters.reply.logs.join("\n")); - }, this.props.onHide) + }, this.props.onHide, this.props.container.isSystem) .then(() => this.props.onHide()) .catch(ex => { this.setState({ diff --git a/src/ContainerTerminal.jsx b/src/ContainerTerminal.jsx index ef390ea71..c6d010d40 100644 --- a/src/ContainerTerminal.jsx +++ b/src/ContainerTerminal.jsx @@ -76,7 +76,7 @@ class ContainerTerminal extends React.Component { var realWidth = this.state.term._core._renderCoordinator.dimensions.actualCellWidth; var cols = Math.floor((width - padding) / realWidth); this.state.term.resize(cols, 24); - cockpit.spawn(["sh", "-c", "echo '1 24 " + cols.toString() + "'>" + this.state.control_channel], { superuser: true }); + cockpit.spawn(["sh", "-c", "echo '1 24 " + cols.toString() + "'>" + this.state.control_channel], { superuser: this.props.system ? "require" : null }); this.setState({ cols: cols }); } @@ -91,12 +91,12 @@ class ContainerTerminal extends React.Component { return; } - utils.podmanCall("GetAttachSockets", { name: this.state.container }) + utils.podmanCall("GetAttachSockets", { name: this.state.container }, this.props.system) .then(out => { let opts = { payload: "packet", unix: out.sockets.io_socket, - superuser: "require", + superuser: this.props.system ? "require" : null, binary: false }; diff --git a/src/Containers.jsx b/src/Containers.jsx index e7e10d77e..be97ec310 100644 --- a/src/Containers.jsx +++ b/src/Containers.jsx @@ -65,7 +65,7 @@ class Containers extends React.Component { if (force) args.timeout = 0; - utils.podmanCall("StopContainer", args) + utils.podmanCall("StopContainer", args, container.isSystem) .catch(ex => this.setState({ actionError: cockpit.format(_("Failed to stop container $0"), container.names), actionErrorDetail: ex.parameters && ex.parameters.reason @@ -73,7 +73,7 @@ class Containers extends React.Component { } startContainer(container) { - utils.podmanCall("StartContainer", { name: container.names }) + utils.podmanCall("StartContainer", { name: container.names }, container.isSystem) .catch(ex => this.setState({ actionError: cockpit.format(_("Failed to start container $0"), container.names), actionErrorDetail: ex.parameters && ex.parameters.reason @@ -85,7 +85,7 @@ class Containers extends React.Component { if (force) args.timeout = 0; - utils.podmanCall("RestartContainer", args) + utils.podmanCall("RestartContainer", args, container.isSystem) .catch(ex => this.setState({ actionError: cockpit.format(_("Failed to restart container $0"), container.names), actionErrorDetail: ex.parameters && ex.parameters.reason @@ -93,16 +93,23 @@ class Containers extends React.Component { } renderRow(containersStats, container) { - const containerStats = containersStats[container.id]; + const containerStats = containersStats[container.id + container.isSystem.toString()]; const isRunning = container.status == "running"; const image = container.image; + let proc = ""; + let mem = ""; + if (containerStats) { + proc = containerStats.cpu ? utils.format_cpu_percent(containerStats.cpu * 100) : {_("n/a")}; + mem = containerStats.mem_usage ? utils.format_memory_and_limit(containerStats.mem_usage, containerStats.mem_limit) : {_("n/a")}; + } let columns = [ { name: container.names, header: true }, image, utils.quote_cmdline(container.command), - isRunning ? utils.format_cpu_percent(containerStats.cpu * 100) : "", - containerStats ? utils.format_memory_and_limit(containerStats.mem_usage, containerStats.mem_limit) : "", + proc, + mem, + container.isSystem ? _("system") : this.props.user, container.status /* TODO: i18n */, ]; let tabs = [{ @@ -112,7 +119,7 @@ class Containers extends React.Component { }, { name: _("Console"), renderer: ContainerTerminal, - data: { containerId: container.id, containerStatus: container.status, width:this.state.width } + data: { containerId: container.id, containerStatus: container.status, width:this.state.width, system:container.isSystem } }]; var actions = [ @@ -152,7 +159,8 @@ class Containers extends React.Component { return ( console.error("Failed to do RemoveContainer call:", JSON.stringify(ex))); } @@ -185,7 +193,7 @@ class Containers extends React.Component { // TODO: force handleForceRemoveContainer() { const id = this.state.containerWillDelete ? this.state.containerWillDelete.id : ""; - utils.podmanCall("RemoveContainer", { name: id, force: true }) + utils.podmanCall("RemoveContainer", { name: id, force: true }, this.state.containerWillDelete.isSystem) .then(reply => { this.setState({ setContainerRemoveErrorModal: false @@ -201,7 +209,7 @@ class Containers extends React.Component { } render() { - const columnTitles = [_("Name"), _("Image"), _("Command"), _("CPU"), _("Memory"), _("State")]; + const columnTitles = [_("Name"), _("Image"), _("Command"), _("CPU"), _("Memory"), _("Owner"), _("State")]; let emptyCaption = _("No containers"); if (this.props.containers === null) diff --git a/src/ImageRunModal.jsx b/src/ImageRunModal.jsx index 164d1da54..a6900b38e 100644 --- a/src/ImageRunModal.jsx +++ b/src/ImageRunModal.jsx @@ -253,8 +253,8 @@ export class ImageRunModal extends React.Component { onRunClicked() { const createConfig = this.getCreateConfig(); - utils.podmanCall("CreateContainer", { create: createConfig }) - .then(reply => utils.podmanCall("StartContainer", { name: reply.container })) + utils.podmanCall("CreateContainer", { create: createConfig }, this.state.image.isSystem) + .then(reply => utils.podmanCall("StartContainer", { name: reply.container }, this.state.image.isSystem)) .then(() => this.props.close()) .catch(ex => { this.setState({ diff --git a/src/ImageSearchModal.css b/src/ImageSearchModal.css index 2bdeb531c..6990a02ed 100644 --- a/src/ImageSearchModal.css +++ b/src/ImageSearchModal.css @@ -109,4 +109,9 @@ padding-left: 0.25rem; padding-right: 0.25rem; } -} \ No newline at end of file +} + +.ct-form .radio { + margin-top: 0px; + margin-bottom: 0px; +} diff --git a/src/ImageSearchModal.jsx b/src/ImageSearchModal.jsx index bc7b70c7a..d0b0c0756 100644 --- a/src/ImageSearchModal.jsx +++ b/src/ImageSearchModal.jsx @@ -19,12 +19,14 @@ export class ImageSearchModal extends React.Component { imageList: [], searchInProgress: false, searchFinished: false, + isSystem: props.systemServiceAvailable, }; this.onDownloadClicked = this.onDownloadClicked.bind(this); this.onItemSelected = this.onItemSelected.bind(this); this.onSearchTriggered = this.onSearchTriggered.bind(this); this.onValueChanged = this.onValueChanged.bind(this); this.onKeyPress = this.onKeyPress.bind(this); + this.onToggleUser = this.onToggleUser.bind(this); } componentDidMount() { @@ -38,11 +40,15 @@ export class ImageSearchModal extends React.Component { this.activeConnection.close(); } + onToggleUser(ev) { + this.setState({ isSystem: ev.target.id === "system" }); + } + onDownloadClicked() { let selectedImageName = this.state.imageList[this.state.selected].name; this.props.close(); - this.props.downloadImage(selectedImageName, this.state.imageTag); + this.props.downloadImage(selectedImageName, this.state.imageTag, this.state.isSystem); } onItemSelected(key) { @@ -63,7 +69,7 @@ export class ImageSearchModal extends React.Component { this.setState({ searchInProgress: true }); - varlink.connect(utils.PODMAN_SYSTEM_ADDRESS) + varlink.connect(utils.getAddress(this.state.isSystem), this.state.isSystem) .then(connection => { this.activeConnection = connection; @@ -110,6 +116,17 @@ export class ImageSearchModal extends React.Component { render() { let defaultBody = ( + { this.props.userServiceAvailable && this.props.systemServiceAvailable && +
+ +
+ + + + +
+
+ }
diff --git a/src/ImageUsedBy.jsx b/src/ImageUsedBy.jsx index 7f73ad9f6..42f9f156c 100644 --- a/src/ImageUsedBy.jsx +++ b/src/ImageUsedBy.jsx @@ -7,12 +7,18 @@ const _ = cockpit.gettext; const renderRow = (containerStats, container, showAll) => { const isRunning = container.status == "running"; + let proc = ""; + let mem = ""; + if (containerStats) { + proc = containerStats.cpu ? utils.format_cpu_percent(containerStats.cpu * 100) : {_("n/a")}; + mem = containerStats.mem_usage ? utils.format_memory_and_limit(containerStats.mem_usage, containerStats.mem_limit) : {_("n/a")}; + } const columns = [ { name: container.names, header: true }, utils.quote_cmdline(container.command), - isRunning ? utils.format_cpu_percent(containerStats.cpu * 100) : "", - containerStats ? utils.format_memory_and_limit(containerStats.mem_usage, containerStats.mem_limit) : "", + proc, + mem, container.status /* TODO: i18n */, ]; diff --git a/src/Images.jsx b/src/Images.jsx index a36ab227e..51268c5a7 100644 --- a/src/Images.jsx +++ b/src/Images.jsx @@ -58,13 +58,13 @@ class Images extends React.Component { })); } - downloadImage(imageName, imageTag) { + downloadImage(imageName, imageTag, system) { let pullImageId = imageName; if (imageTag) pullImageId += ":" + imageTag; this.setState({ imageDownloadInProgress: imageName }); - utils.podmanCall("PullImage", { name: pullImageId }) + utils.podmanCall("PullImage", { name: pullImageId }, system) .then(() => { this.setState({ imageDownloadInProgress: undefined }); }) @@ -95,7 +95,7 @@ class Images extends React.Component { this.setState({ selectImageDeleteModal: false, }); - utils.podmanCall("RemoveImage", { name: image }) + utils.podmanCall("RemoveImage", { name: image }, this.state.imageWillDelete.isSystem) .catch(ex => { this.imageRemoveErrorMsg = ex.parameters.reason; this.setState({ @@ -106,7 +106,7 @@ class Images extends React.Component { handleForceRemoveImage() { const id = this.state.imageWillDelete ? this.state.imageWillDelete.id : ""; - utils.podmanCall("RemoveImage", { name: id, force: true }) + utils.podmanCall("RemoveImage", { name: id, force: true }, this.state.imageWillDelete.isSystem) .then(reply => { this.setState({ setImageRemoveErrorModal: false @@ -147,6 +147,7 @@ class Images extends React.Component { vulnerabilityColumn, moment(image.created, utils.GOLANG_TIME_FORMAT).calendar(), cockpit.format_bytes(image.size), + image.isSystem ? _("system") : this.props.user, { element: runImage, tight: true @@ -172,7 +173,7 @@ class Images extends React.Component { name: _("Used By"), renderer: ImageUsedBy, data: { - containers: this.props.imageContainerList !== null ? this.props.imageContainerList[image.id] : null, + containers: this.props.imageContainerList !== null ? this.props.imageContainerList[image.id + image.isSystem.toString()] : null, showAll: this.props.showAll, } }); @@ -186,8 +187,8 @@ class Images extends React.Component { ]; return ( @@ -201,7 +202,7 @@ class Images extends React.Component { } render() { - const columnTitles = [ _("Name"), _(''), _("Created"), _("Size"), _('') ]; + const columnTitles = [ _("Name"), '', _("Created"), _("Size"), _("Owner"), '' ]; let emptyCaption = _("No images"); if (this.props.images === null) emptyCaption = "Loading..."; @@ -216,17 +217,18 @@ class Images extends React.Component { ]; let filtered = []; - if (this.props.images !== null) - filtered = Object.keys(this.props.images).filter(id => id === this.props.images[id].id); - if (this.props.textFilter.length > 0) - filtered = filtered.filter(id => { - for (let i = 0; i < this.props.images[id].repoTags.length; i++) { - let tag = this.props.images[id].repoTags[i].toLowerCase(); - if (tag.indexOf(this.props.textFilter.toLowerCase()) >= 0) - return true; - } - return false; - }); + if (this.props.images !== null) { + filtered = Object.keys(this.props.images); + if (this.props.textFilter.length > 0) + filtered = filtered.filter(id => { + for (let i = 0; i < this.props.images[id].repoTags.length; i++) { + let tag = this.props.images[id].repoTags[i].toLowerCase(); + if (tag.indexOf(this.props.textFilter.toLowerCase()) >= 0) + return true; + } + return false; + }); + } let imageRows = filtered.map(id => this.renderRow(this.props.images[id])); const imageDeleteModal = this.setState({ showSearchImageModal: false })} - downloadImage={this.downloadImage} /> } + downloadImage={this.downloadImage} + user={this.props.user} + userServiceAvailable={this.props.userServiceAvailable} + systemServiceAvailable={this.props.systemServiceAvailable} /> } {this.state.imageDownloadInProgress &&
{_("Pulling")} {this.state.imageDownloadInProgress}...
}
); diff --git a/src/app.jsx b/src/app.jsx index 104cb3110..952d26f6f 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -27,32 +27,42 @@ import Images from './Images.jsx'; import * as utils from './util.js'; const _ = cockpit.gettext; +const permission = cockpit.permission({ admin: true }); class Application extends React.Component { constructor(props) { super(props); this.state = { - serviceAvailable: null, + systemServiceAvailable: null, + userServiceAvailable: null, enableService: true, - images: null, /* images[Id]: detail info of image with Id from InspectImage */ - containers: null, /* containers[Id] detail info of container with Id from InspectContainer */ - containersStats:{}, /* containersStats[Id] memory usage of running container with Id */ + images: null, + userImagesLoaded: false, + systemImagesLoaded: false, + containers: null, + containersStats: {}, + userContainersLoaded: null, + systemContainersLoaded: null, + userServiceExists: false, onlyShowRunning: true, textFilter: "", dropDownValue: 'Everything', notifications: [], }; this.onAddNotification = this.onAddNotification.bind(this); + this.updateState = this.updateState.bind(this); this.onDismissNotification = this.onDismissNotification.bind(this); this.onChange = this.onChange.bind(this); this.onFilterChanged = this.onFilterChanged.bind(this); this.updateImagesAfterEvent = this.updateImagesAfterEvent.bind(this); this.updateContainerAfterEvent = this.updateContainerAfterEvent.bind(this); + this.updateContainerStats = this.updateContainerStats.bind(this); this.startService = this.startService.bind(this); this.showAll = this.showAll.bind(this); this.goToServicePage = this.goToServicePage.bind(this); this.handleImageEvent = this.handleImageEvent.bind(this); this.handleContainerEvent = this.handleContainerEvent.bind(this); + this.checkUserService = this.checkUserService.bind(this); } onAddNotification(notification) { @@ -88,87 +98,134 @@ class Application extends React.Component { }); } - updateContainersAfterEvent() { - utils.updateContainers() - .then((reply) => { - this.setState({ - containers: reply.newContainers, - containersStats: reply.newContainersStats - }); + updateState(state, id, newValue) { + this.setState(prevState => { + let copyState = Object.assign({}, prevState[state]); + + copyState[id] = newValue; + + return { + [state]: copyState, + }; + }); + } + + updateContainerStats(id, system) { + utils.podmanCall("GetContainerStats", { name: id }, system) + .then(reply => { + this.updateState("containersStats", reply.container.id + system.toString(), reply.container); }) .catch(ex => { - console.warn("Failed to do Update Container:", JSON.stringify(ex)); + if (ex.error === "io.podman.ErrRequiresCgroupsV2ForRootless") { + console.log("This OS does not support CgroupsV2. Some information may be missing."); + this.updateState("containersStats", id + system.toString(), -1); + } else + console.warn("Failed to update container stats:", JSON.stringify(ex)); }); } - updateImagesAfterEvent() { - utils.updateImages() + updateContainersAfterEvent(system) { + utils.podmanCall("ListContainers", {}, system) .then(reply => { - this.setState({ - images: reply + this.setState(prevState => { + // Copy only containers that could not be deleted with this event + // So when event from system come, only copy user containers and vice versa + let copyContainers = {}; + Object.entries(prevState.containers || {}).forEach(([id, container]) => { + if (container.isSystem !== system) + copyContainers[id] = container; + }); + for (let container of reply.containers || []) { + container.isSystem = system; + copyContainers[container.id + system.toString()] = container; + if (container.status === "running") + this.updateContainerStats(container.id, system); + } + + return { + containers: copyContainers, + [system ? "systemContainersLoaded" : "userContainersLoaded"]: true, + }; }); }) - .catch(ex => { - console.warn("Failed to do Update Images:", JSON.stringify(ex)); - }); + .catch(e => console.log(e)); } - updateContainerAfterEvent(id) { - utils.updateContainer(id) + updateImagesAfterEvent(system) { + utils.updateImages(system) .then(reply => { this.setState(prevState => { - let containersCopy = Object.assign({}, prevState.containers); - let containersCopyStats = Object.assign({}, prevState.containersStats); - - containersCopy[reply.container.id] = reply.container; - containersCopyStats[reply.container.id] = reply.containerStats; + // Copy only images that could not be deleted with this event + // So when event from system come, only copy user images and vice versa + let copyImages = {}; + Object.entries(prevState.images || {}).forEach(([id, image]) => { + if (image.isSystem !== system) + copyImages[id] = image; + }); + Object.entries(reply).forEach(([id, image]) => { + image.isSystem = system; + copyImages[id + system.toString()] = image; + }); return { - containers: containersCopy, - containersStats: containersCopyStats, + images: copyImages, + [system ? "systemImagesLoaded" : "userImagesLoaded"]: true }; }); }) .catch(ex => { - console.warn("Failed to do Update Container:", JSON.stringify(ex)); + console.warn("Failed to do Update Images:", JSON.stringify(ex)); }); } - updateImageAfterEvent(id) { - utils.updateImage(id) + updateContainerAfterEvent(id, system) { + utils.podmanCall("GetContainer", { id: id }, system) .then(reply => { - this.setState(prevState => { - let imagesCopy = Object.assign({}, prevState.images); - - imagesCopy[reply.image.id] = reply.image; + reply.container.isSystem = system; + this.updateState("containers", reply.container.id + system.toString(), reply.container); + if (reply.container.status == "running") + this.updateContainerStats(reply.container.id, system); + else { + this.setState(prevState => { + let copyStats = Object.assign({}, prevState.containersStats); + delete copyStats[reply.container.id + system.toString()]; + return { containersStats: copyStats }; + }); + } + }) + .catch(e => console.log(e)); + } - return { images: imagesCopy }; - }); + updateImageAfterEvent(id, system) { + utils.updateImage(id, system) + .then(reply => { + reply.image.isSystem = system; + this.updateState("images", reply.image.id + system.toString(), reply.image); }) .catch(ex => { console.warn("Failed to do Update Image:", JSON.stringify(ex)); }); } - handleImageEvent(event) { + handleImageEvent(event, system) { switch (event.status) { case 'push': case 'save': case 'tag': - this.updateImageAfterEvent(event.id); + this.updateImageAfterEvent(event.id, system); break; case 'pull': // Pull event has not event.id case 'untag': case 'remove': case 'prune': - this.updateImagesAfterEvent(); + this.updateImagesAfterEvent(system); break; default: console.warn('Unhandled event type ', event.type, event.status); } } - handleContainerEvent(event) { + handleContainerEvent(event, system) { switch (event.status) { /* The following events do not need to trigger any state updates */ case 'attach': @@ -196,59 +253,83 @@ class Application extends React.Component { case 'sync': case 'unmount': case 'unpause': - this.updateContainerAfterEvent(event.id); + this.updateContainerAfterEvent(event.id, system); break; case 'remove': case 'cleanup': - this.updateContainersAfterEvent(); + this.updateContainersAfterEvent(system); break; /* The following events need only to update the Image list */ case 'commit': - this.updateImagesAfterEvent(); + this.updateImagesAfterEvent(system); break; default: console.warn('Unhandled event type ', event.type, event.status); } } - handleEvent(event) { + handleEvent(event, system) { switch (event.type) { case 'container': - this.handleContainerEvent(event); + this.handleContainerEvent(event, system); break; case 'image': - this.handleImageEvent(event); + this.handleImageEvent(event, system); break; default: console.warn('Unhandled event type ', event.type); } } - init() { - utils.podmanCall("GetVersion") + init(system) { + utils.podmanCall("GetVersion", {}, system) .then(reply => { - this.setState({ serviceAvailable: true }); - this.updateImagesAfterEvent(); - this.updateContainersAfterEvent(); + this.setState({ [system ? "systemServiceAvailable" : "userServiceAvailable"]: true }); + this.updateImagesAfterEvent(system); + this.updateContainersAfterEvent(system); utils.monitor("GetEvents", {}, message => { - message.parameters && message.parameters.events && this.handleEvent(message.parameters.events); - }, - () => { - this.setState({ serviceAvailable: false }); + message.parameters && message.parameters.events && this.handleEvent(message.parameters.events, system); + }, isSystem => { + this.setState({ [isSystem ? "systemServiceAvailable" : "userServiceAvailable"]: false + }); }, + system ); }) .catch(error => { - if (error.name === "ConnectionClosed") - this.setState({ serviceAvailable: false }); - else + if (error.name === "ConnectionClosed") { + this.setState({ [system ? "systemServiceAvailable" : "userServiceAvailable"]: false, + [system ? "systemContainersLoaded" : "userContainersLoaded"]: true, + [system ? "systemImagesLoaded" : "userImagesLoaded"]: true + }); + } else console.error("Failed to call GetVersion():", error); }); } componentDidMount() { - this.init(); + this.checkUserService(); + this.init(true); + cockpit.script("echo $XDG_RUNTIME_DIR") + .done(xrd => { + sessionStorage.setItem('XDG_RUNTIME_DIR', xrd.trim()); + this.init(false); + }) + .fail(e => console.log("Could not read $XDG_RUNTIME_DIR: ", e.message)); + } + + checkUserService() { + let argv = ["systemctl", "--user", "is-enabled", "io.podman.socket"]; + + cockpit.spawn(argv, { environ: ["LC_ALL=C"], err: "out" }) + .then(() => this.setState({ userServiceExists: true })) + .catch((_, response) => { + if (response.trim() !== "disabled") + this.setState({ userServiceExists: false }); + else + this.setState({ userServiceExists: true }); + }); } startService(e) { @@ -262,8 +343,27 @@ class Application extends React.Component { argv = ["systemctl", "start", "io.podman.socket"]; cockpit.spawn(argv, { superuser: "require", err: "message" }) - .then(() => this.init()) - .catch(err => console.error("Failed to start io.podman.socket:", JSON.stringify(err))); + .then(() => this.init(true)) + .catch(err => { + this.setState({ systemServiceAvailable: false, + systemContainersLoaded: true, + systemImagesLoaded: true }); + console.warn("Failed to start system io.podman.socket:", JSON.stringify(err)); + }); + + if (this.state.enableService) + argv = ["systemctl", "--user", "enable", "--now", "io.podman.socket"]; + else + argv = ["systemctl", "--user", "start", "io.podman.socket"]; + + cockpit.spawn(argv, { err: "message" }) + .then(() => this.init(false)) + .catch(err => { + this.setState({ userServiceAvailable: false, + userContainersLoaded: true, + userImagesLoaded: true }); + console.warn("Failed to start user io.podman.socket:", JSON.stringify(err)); + }); } showAll() { @@ -277,10 +377,10 @@ class Application extends React.Component { } render() { - if (this.state.serviceAvailable === null) // not detected yet + if (this.state.systemServiceAvailable === null && this.state.userServiceAvailable === null) // not detected yet return null; - if (!this.state.serviceAvailable) { + if (!this.state.systemServiceAvailable && !this.state.userServiceAvailable) { return (
@@ -317,40 +417,62 @@ class Application extends React.Component { if (this.state.containers !== null) { Object.keys(this.state.containers).forEach(c => { const container = this.state.containers[c]; - const image = container.imageid; + const image = container.imageid + container.isSystem.toString(); if (imageContainerList[image]) { imageContainerList[image].push({ container: container, - stats: this.state.containersStats[container.id], + stats: this.state.containersStats[container.id + container.isSystem.toString()], }); } else { imageContainerList[image] = [ { container: container, - stats: this.state.containersStats[container.id] + stats: this.state.containersStats[container.id + container.isSystem.toString()] } ]; } }); } else imageContainerList = null; - let imageList; - let containerList; - imageList = + let startService = ""; + if (!this.state.systemServiceAvailable && permission.allowed) { + startService =
+
+ + {_("System Podman service is also available")} +
+ +
; + } + if (!this.state.userServiceAvailable && this.state.userServiceExists) { + startService =
+
+ + {_("User Podman service is also available")} +
+ +
; + } + + const imageList = ; - containerList = + const containerList = ; const notificationList = ( @@ -364,6 +486,7 @@ class Application extends React.Component { })} ); + return (
@@ -373,6 +496,7 @@ class Application extends React.Component { onFilterChanged={this.onFilterChanged} />
+ { startService }
{containerList}
diff --git a/src/podman.scss b/src/podman.scss index 5b8bf75b4..18600faed 100644 --- a/src/podman.scss +++ b/src/podman.scss @@ -618,4 +618,17 @@ table.drive-list td:nth-child(2) img { .run-image-dialog-actions .btn { padding: 0.25rem 0.75rem; -} \ No newline at end of file +} + +.alert .fa { + padding-right: 10px; +} + +.alert { + display: flex; + justify-content: space-between; +} + +.info-message { + margin-top: 5px; +} diff --git a/src/util.js b/src/util.js index ea724e9ef..50d259a3e 100644 --- a/src/util.js +++ b/src/util.js @@ -53,80 +53,50 @@ export function format_memory_and_limit(usage, limit) { } } +export function getAddress(system) { + if (system) + return PODMAN_SYSTEM_ADDRESS; + const xrd = sessionStorage.getItem('XDG_RUNTIME_DIR'); + if (xrd) + return ("unix:" + xrd + "/podman/io.podman"); + console.warn("$XDG_RUNTIME_DIR is not present. Cannot use user service."); + return ""; +} + // TODO: handle different kinds of errors function handleVarlinkCallError(ex) { - console.warn("Failed to do varlinkcall:", JSON.stringify(ex)); + if (ex.error === "io.podman.ErrRequiresCgroupsV2ForRootless") + console.log("This OS does not support CgroupsV2. Some information may be missing."); + else + console.warn("Failed to do varlinkcall:", JSON.stringify(ex)); } -export function podmanCall(name, args) { - return varlink.call(PODMAN_SYSTEM_ADDRESS, "io.podman." + name, args); +export function podmanCall(name, args, system) { + return varlink.call(getAddress(system), "io.podman." + name, args, system); } -export function monitor(name, args, callback, on_close) { - return varlink.connect(PODMAN_SYSTEM_ADDRESS) +export function monitor(name, args, callback, on_close, system) { + return varlink.connect(getAddress(system), system) .then(connection => connection.monitor("io.podman." + name, args, callback)) .catch(e => { if (e.name === "ConnectionClosed") - on_close(); + on_close(system); else console.log(e); }); } -export function updateContainer(id) { - let container = {}; - let containerStats = {}; - return podmanCall("GetContainer", { id: id }) - .then(reply => { - container = reply.container; - if (container.status == "running") - return podmanCall("GetContainerStats", { name: id }); - }) - .then(reply => { - if (reply) - containerStats = reply.container; - return { container, containerStats }; - }); -} - -export function updateImage(id) { +export function updateImage(id, system) { let image = {}; - return podmanCall("GetImage", { id: id }) + return podmanCall("GetImage", { id: id }, system) .then(reply => { image = reply.image; - return podmanCall("InspectImage", { name: id }); + return podmanCall("InspectImage", { name: id }, system); }) .then(reply => Object.assign(image, parseImageInfo(JSON.parse(reply.image)))); } -export function updateContainers() { - return podmanCall("ListContainers") - .then(reply => { - let containers = {}; - let promises = []; - - for (let container of reply.containers || []) { - containers[container.id] = container; - if (container.status === "running") - promises.push(podmanCall("GetContainerStats", { name: container.id })); - } - - return Promise.all(promises) - .then(replies => { - let stats = {}; - for (let reply of replies) - stats[reply.container.id] = reply.container || {}; - - return { newContainers: containers, newContainersStats: stats }; - }); - }) - .catch(ex => { - handleVarlinkCallError(ex); - return Promise.reject(ex); - }); -} - function parseImageInfo(info) { let image = {}; @@ -140,8 +110,8 @@ function parseImageInfo(info) { return image; } -export function updateImages() { - return podmanCall("ListImages") +export function updateImages(system) { + return podmanCall("ListImages", {}, system) .then(reply => { // Some information about images is only available in the OCI // data. Grab what we need and add it to the image itself until @@ -152,7 +122,7 @@ export function updateImages() { for (let image of reply.images || []) { images[image.id] = image; - promises.push(podmanCall("InspectImage", { name: image.id })); + promises.push(podmanCall("InspectImage", { name: image.id }, system)); } return Promise.all(promises) @@ -161,6 +131,7 @@ export function updateImages() { let info = JSON.parse(reply.image); // Update image with information from InspectImage API images[info.Id] = Object.assign(images[info.Id], parseImageInfo(info)); + images[info.Id].isSystem = system; } return images; }); diff --git a/src/varlink.js b/src/varlink.js index c4eda74e6..d97bcbef9 100644 --- a/src/varlink.js +++ b/src/varlink.js @@ -30,7 +30,7 @@ class VarlinkError extends Error { * * https://varlink.org */ -function connect(address) { +function connect(address, system) { if (!address.startsWith("unix:")) throw new Error("Only unix varlink connections supported"); @@ -42,7 +42,7 @@ function connect(address) { unix: address.slice(5), binary: true, payload: "stream", - superuser: "require" + superuser: system ? "require" : null }); channel.addEventListener("message", (event, data) => { @@ -129,8 +129,8 @@ function connect(address) { * Connects to a varlink service, performs a single call, and closes the * connection. */ -async function call (address, method, parameters, more) { - let connection = await connect(address); +async function call (address, method, parameters, system, more) { + let connection = await connect(address, system); let result = await connection.call(method, parameters, more); connection.close(); return result; diff --git a/test/check-application b/test/check-application index b002b5015..fdd220f5a 100755 --- a/test/check-application +++ b/test/check-application @@ -12,7 +12,9 @@ import unittest TEST_DIR = os.path.dirname(__file__) sys.path.append(os.path.join(TEST_DIR, "common")) sys.path.append(os.path.join(os.path.dirname(TEST_DIR), "bots/machine")) + import testlib +from machine_core import ssh_connection REGISTRIES_CONF=""" [registries.search] @@ -22,6 +24,16 @@ registries = ['localhost:5000'] registries = ['localhost:5000'] """ +def checkImage(browser, name, owner): + browser.wait_present("#containers-images > section > table") + browser.wait_js_func("""(function (first, last) { + let items = ph_select("#containers-images > section > table tbody"); + for (i = 0; i < items.length; i++) + if (items[i].innerText.trim().startsWith(first) && items[i].innerText.trim().endsWith(last)) + return true; + return false; + })""", name, owner) + class TestApplication(testlib.MachineCase): def setUp(self): @@ -29,39 +41,116 @@ class TestApplication(testlib.MachineCase): # HACK: sometimes podman leaks mounts super().setUp() self.machine.execute("cp -a /var/lib/containers /var/lib/containers.orig") + + # Create admin session + self.machine.execute(""" + mkdir /home/admin/.ssh + cp /root/.ssh/* /home/admin/.ssh + chown -R admin:admin /home/admin/.ssh + chmod -R go-wx /home/admin/.ssh + """) + self.admin_s = ssh_connection.SSHConnection(user="admin", + address=self.machine.ssh_address, + ssh_port=self.machine.ssh_port, + identity_file=self.machine.identity_file) + + # Enable user service as well + if self.machine.image != "rhel-8-1": + self.admin_s.execute("systemctl --now --user enable io.podman.socket") + else: + self.allow_journal_messages("Unit io.podman.socket could not be found.") + + self.allow_journal_messages("/run.*/podman/io.podman: couldn't connect.*") self.addCleanup(self.machine.execute, "podman rm --force --all && " "findmnt --list -otarget | grep /var/lib/containers | xargs -r umount && " "rm -r /var/lib/containers && " "mv /var/lib/containers.orig /var/lib/containers") - def testBasic(self): + def execute(self, system, cmd): + if system: + return self.machine.execute(cmd) + else: + return self.admin_s.execute(cmd) + + def testBasicSystem(self): + self._testBasic(True) + + @testlib.skipImage("No user service", "rhel-8-1") + def testBasicUser(self): + self._testBasic(False) + + def _testBasic(self, auth): b = self.browser m = self.machine - self.login_and_go("/podman") + if not auth: + self.allow_authorize_journal_messages() + self.allow_browser_errors("Failed to start system io.podman.socket.*") + + self.login_and_go("/podman", authorized=auth) b.wait_present("#app") + b.wait_present(".content-filter input") - b.wait_in_text("#containers-images", "busybox:latest") + + # Check all containers + if auth: + checkImage(b, "docker.io/library/busybox:latest", "system") + checkImage(b, "docker.io/library/alpine:latest", "system") + checkImage(b, "docker.io/library/registry:2", "system") + + if m.image != "rhel-8-1": + checkImage(b, "docker.io/library/busybox:latest", "admin") + checkImage(b, "docker.io/library/alpine:latest", "admin") + checkImage(b, "docker.io/library/registry:2", "admin") + + # prepare image ids - much easier to pick a specific container + images = {} + for image in self.execute(auth, "podman images --noheading --no-trunc").strip().split("\n"): + # sha256: + items = image.split() + images["{0}:{1}".format(items[0], items[1])] = items[2].split(":")[1] # show image listing toggle - b.wait_present('#containers-images tr:contains("busybox:latest")') - b.click('#containers-images tbody tr:contains("busybox:latest") td.listing-ct-toggle') - b.wait_present('#containers-images tbody tr:contains("busybox:latest") + tr button.btn-delete') - b.wait_in_text('#containers-images tbody tr .image-details:first-child:contains("busybox:latest")', "Commandsh") + busybox_sel = "#containers-images tbody tr[data-row-id={0}{1}]".format(images["docker.io/library/busybox:latest"], auth).lower() + b.wait_present(busybox_sel) + b.click(busybox_sel + " td.listing-ct-toggle") + b.wait_present(busybox_sel + " + tr button.btn-delete") + b.wait_in_text("#containers-images tbody tr .image-details:first-child:contains('busybox:latest')", "Commandsh") + b.click(busybox_sel + " td.listing-ct-toggle") # make sure no running containers shown self.filter_containers('running') b.wait_in_text("#containers-containers", "No running containers") - # run a container (will exit immediately) - m.execute("podman run -d --name test-sh alpine sh") - # run a container - m.execute("podman run -d --name swamped-crate busybox sleep 1000") + if auth: + # Run two containers as system (first exits immediately) + self.execute(auth, "podman run -d --name test-sh-system alpine sh") + self.execute(auth, "podman run -d --name swamped-crate-system busybox sleep 1000") + + if m.image != "rhel-8-1": + # Run two containers as admin (first exits immediately) + self.execute(False, "podman run -d --name test-sh-user alpine sh") + self.execute(False, "podman run -d --name swamped-crate-user busybox sleep 1000") + + user_containers = {} + system_containers = {} + for container in self.execute(True, "podman ps --all --no-trunc").strip().split("\n")[1:]: + # + items = container.split() + system_containers[items[-1]] = items[0] + if m.image != "rhel-8-1": + for container in self.execute(False, "podman ps --all --no-trunc").strip().split("\n")[1:]: + # + items = container.split() + user_containers[items[-1]] = items[0] # running busybox shown - b.wait_present("#containers-containers") - b.wait_present('#containers-containers tr:contains("swamped-crate")') - self.check_container('swamped-crate', ['swamped-crate', 'busybox:latest', 'sleep 1000', 'running']) + if auth: + b.wait_present("#containers-containers tr th:contains('swamped-crate-system')") + self.check_container(system_containers["swamped-crate-system"], True, ['swamped-crate-system', 'busybox:latest', 'sleep 1000', 'running']) + if m.image != "rhel-8-1": + b.wait_present("#containers-containers tr:contains('swamped-crate-user')") + self.check_container(user_containers["swamped-crate-user"], False, ['swamped-crate-user', 'busybox:latest', 'sleep 1000', 'running']) # exited alpine not shown b.wait_not_in_text("#containers-containers", "alpine:latest") @@ -71,34 +160,53 @@ class TestApplication(testlib.MachineCase): # exited alpine under everything list b.wait_present("#containers-containers") - b.wait_present('#containers-containers tr:contains("test-sh")') - self.check_container('test-sh', ['test-sh', 'alpine:latest', 'sh', 'exited']) + if auth: + self.check_container(system_containers["test-sh-system"], True, ['test-sh-system', 'alpine:latest', 'sh', 'exited']) + if m.image != "rhel-8-1": + self.check_container(user_containers["test-sh-user"], False, ['test-sh-user', 'alpine:latest', 'sh', 'exited']) - # show container listing toggle - b.click('#containers-containers tbody tr:contains("busybox:latest") td.listing-ct-toggle') - b.wait_present('#containers-containers tbody tr:contains("busybox:latest") + tr button.btn-delete') + b.click('#containers-containers tbody tr:contains("swamped-crate-user") td.listing-ct-toggle') + b.wait_present('#containers-containers tbody tr:contains("swamped-crate-user") + tr button.btn-delete') + + if auth: + b.click('#containers-containers tbody tr:contains("swamped-crate-system") td.listing-ct-toggle') + b.wait_present('#containers-containers tbody tr:contains("swamped-crate-system") + tr button.btn-delete') # show running container self.filter_containers('running') - b.wait_present('#containers-containers tr:contains("busybox:latest")') - self.check_container('swamped-crate', ['swamped-crate', 'busybox:latest', 'sleep 1000', 'running']) + if auth: + self.check_container(system_containers["swamped-crate-system"], True, ['swamped-crate-system', 'busybox:latest', 'sleep 1000', 'running']) + if m.image != "rhel-8-1": + self.check_container(user_containers["swamped-crate-user"], False, ['swamped-crate-user', 'busybox:latest', 'sleep 1000', 'running']) # check exited alpine not in running list b.wait_not_in_text("#containers-containers", "alpine:latest") # delete running container busybox using force delete - b.click('#containers-containers tbody tr:contains("busybox:latest") + tr button.btn-delete') - self.confirm_modal("btn-ctr-forcedelete") - b.wait_not_in_text("#containers-containers", "busybox:latest") + if auth: + b.click('#containers-containers tbody tr:contains("swamped-crate-system") + tr button.btn-delete') + self.confirm_modal("btn-ctr-forcedelete") + b.wait_not_in_text("#containers-containers", "swamped-crate-system") - # delete the exited alpine self.filter_containers("all") - b.wait_present('#containers-containers tr:contains("alpine:latest")') - b.click('#containers-containers tbody tr:contains("alpine:latest") td.listing-ct-toggle') - b.click('#containers-containers tbody tr:contains("alpine:latest") + tr button.btn-delete') - self.confirm_modal("btn-ctr-delete") - b.wait_not_in_text("#containers-containers", "alpine:latest") - - def container_commit(container_name, image_name="testimg", image_tag="testtag", image_author="tester", image_command="sleep 6000"): + if m.image != "rhel-8-1": + b.click('#containers-containers tbody tr:contains("swamped-crate-user") + tr button.btn-delete') + self.confirm_modal("btn-ctr-forcedelete") + b.wait_not_in_text("#containers-containers", "swamped-crate-user") + + b.wait_present('#containers-containers tr:contains("test-sh-user")') + b.click('#containers-containers tbody tr:contains("test-sh-user") td.listing-ct-toggle') + b.click('#containers-containers tbody tr:contains("test-sh-user") + tr button.btn-delete') + self.confirm_modal("btn-ctr-delete") + b.wait_not_in_text("#containers-containers", "test-sh-user") + + if auth: + b.wait_present('#containers-containers tr:contains("test-sh-system")') + b.click('#containers-containers tbody tr:contains("test-sh-system") td.listing-ct-toggle') + b.click('#containers-containers tbody tr:contains("test-sh-system") + tr button.btn-delete') + self.confirm_modal("btn-ctr-delete") + b.wait_not_in_text("#containers-containers", "test-sh-system") + + def container_commit(container_name, image_name="testimg", image_tag="testtag", image_author="tester", image_command="sleep 6000", owner="system"): self.filter_containers("all") b.wait_present('#containers-containers tr:contains({0})'.format(container_name)) b.click('#containers-containers tbody tr:contains({0}) td.listing-ct-toggle'.format(container_name)) @@ -116,26 +224,39 @@ class TestApplication(testlib.MachineCase): b.click(".modal-dialog div .btn-ctr-commit") b.wait_not_present(".modal-dialog div") b.wait_present('#containers-images tr:contains("{0}:{1}")'.format(image_name, image_tag)) - b.click('#containers-images tbody tr:contains("{0}:{1}") td.listing-ct-toggle'.format(image_name, image_tag)) + checkImage(b, "localhost/{0}:{1}".format(image_name, image_tag), owner) # open the listing toggle of testimg and check the commit paramerters + b.click('#containers-images tbody tr:contains("{0}:{1}") td.listing-ct-toggle'.format(image_name, image_tag)) b.wait_present('#containers-images tbody tr:contains("{0}:{1}"):has(dd:contains("localhost/{0}:{1}"))'.format(image_name, image_tag)) b.wait_present('#containers-images tbody tr:contains("{0}:{1}"):has(dd:contains({2}))'.format(image_name, image_tag, image_command)) b.wait_present('#containers-images tbody tr:contains("{0}:{1}"):has(dd:contains({2}))'.format(image_name, image_tag, image_author)) - # Cleanup - m.execute("podman rm -f {0}; podman rmi {1}:{2}".format(container_name, image_name, image_tag)) - # run a container (will exit immediately) and test the display of commit modal - m.execute("podman run -d --name test-sh alpine sh") - self.filter_containers("all") - container_commit("test-sh") + if auth: + self.execute(True, "podman run -d --name test-sh alpine sh") + container_commit("test-sh") + self.execute(True, "podman rm -f test-sh; podman rmi testimg:testtag") - # test commit of a running container - m.execute("podman run -d --name test-sh busybox sleep 1000") - self.check_container('test-sh', ['test-sh', 'busybox:latest', 'sleep 1000', 'running']) - container_commit("test-sh") + if m.image != "rhel-8-1": + self.execute(False, "podman run -d --name test-sh alpine sh") + container_commit("test-sh", owner="admin") + self.execute(False, "podman rm -f test-sh; podman rmi testimg:testtag") - m.execute("podman run -d --name test-sh alpine sh") + # test commit of a running container + if auth: + self.execute(True, "podman run -d --name test-sh busybox sleep 1000") + container_commit("test-sh") + self.execute(True, "podman rm -f test-sh; podman rmi testimg:testtag") + + # HACK - this does not work, see https://github.com/containers/libpod/issues/3970 + # self.execute(False, "podman run -d --name test-sh alpine sleep 1000") + # container_commit("test-sh", owner="admin") + # self.execute(False, "podman rm -f test-sh; podman rmi testimg:testtag") + + if auth: + self.execute(True, "podman run -d --name test-sh alpine sh") + else: + self.execute(False, "podman run -d --name test-sh alpine sh") b.wait_present('#containers-containers tr:contains("alpine:latest")') b.click('#containers-containers tbody tr:contains("alpine:latest") td.listing-ct-toggle') # open commit modal and check error modal @@ -147,6 +268,7 @@ class TestApplication(testlib.MachineCase): b.wait_not_present(".modal-dialog div .alert") # HACK: Disable checking for varlink error since it makes podman to coredump + # When hack removed, don't forget to also do the check for user containers # See https://github.com/containers/libpod/issues/3897 # check varlink error # b.set_input_text("#commit-dialog-image-name", "TEST") @@ -156,42 +278,49 @@ class TestApplication(testlib.MachineCase): # b.click(".modal-dialog div .alert .close") # b.wait_not_present(".modal-dialog div .alert") + b.click(".btn-ctr-cancel-commit") + # delete image busybox that hasn't been used - b.wait_present('#containers-images tr:contains("busybox:latest")') - b.click('#containers-images tbody tr:contains("busybox:latest") + tr button.btn-delete') + b.wait_present(busybox_sel) + b.click(busybox_sel + " td.listing-ct-toggle") + b.click(busybox_sel + " + tr button.btn-delete") b.click(".modal-dialog div #btn-img-delete") b.wait_not_present("modal-dialog div #btn-img-delete") - b.wait_not_in_text("#containers-images", "busybox:latest") + b.wait_not_in_text("#containers-images", busybox_sel) # delete image alpine that has been used by a container - b.wait_present('#containers-images tr:contains("alpine:latest")') - b.click('#containers-images tbody tr:contains("alpine:latest") td.listing-ct-toggle') - b.wait_in_text('#containers-images tbody tr .image-details:first-child:contains("alpine:latest")', "Command/bin/sh") - b.click('#containers-images tbody tr:contains("alpine:latest") + tr button.btn-delete') + alpine_sel = "#containers-images tbody tr[data-row-id={0}{1}]".format(images["docker.io/library/alpine:latest"], auth).lower() + b.wait_present(alpine_sel) + b.click(alpine_sel + " td.listing-ct-toggle") + b.click(alpine_sel + " + tr button.btn-delete") b.click(".modal-dialog div #btn-img-delete") b.wait_not_present("modal-dialog div #btn-img-delete") b.click(".modal-dialog div #btn-img-deleteerror") b.wait_not_present("modal-dialog div #btn-img-deleteerror") - b.wait_not_in_text("#containers-images", "alpine:latest") + b.wait_not_in_text("#containers-images", alpine_sel) def testDownloadImage(self): b = self.browser m = self.machine + execute = self.execute def prepare(): # Create and start registry container - m.execute("podman run -d -p 5000:5000 --name registry registry:2") + self.execute(True, "podman run -d -p 5000:5000 --name registry registry:2") # Add local insecure registry into resgitries conf - m.execute("echo \"{0}\" > /etc/containers/registries.conf && systemctl stop io.podman.service".format(REGISTRIES_CONF)) + self.execute(True, "echo \"{0}\" > /etc/containers/registries.conf && systemctl stop io.podman.service".format(REGISTRIES_CONF)) # Push busybox image to the local registry - m.execute("podman tag busybox localhost:5000/my-busybox && podman push localhost:5000/my-busybox") + self.execute(True, "podman tag busybox localhost:5000/my-busybox && podman push localhost:5000/my-busybox") # Untag busybox image which duplicates the image we are about to download - m.execute("podman rmi -f busybox") + self.execute(True, "podman rmi -f busybox") + self.execute(False, "podman rmi -f busybox") class DownloadImageDialog: - def __init__(self, imageName, imageTag=None): + def __init__(self, imageName, imageTag=None, user="system"): self.imageName = imageName self.imageTag = imageTag + self.user = user + self.imageSha = "" def openDialog(self): # Open get new image modal @@ -203,6 +332,9 @@ class TestApplication(testlib.MachineCase): def fillDialog(self): # Search for image specied with self.imageName and self.imageTag + if m.image != "rhel-8-1": + # Only show select when both services are available + b.click("#{0}".format(self.user)) b.set_input_text("#search-image-dialog-name", self.imageName) if self.imageTag: b.set_input_text(".image-tag-entry", self.imageTag) @@ -234,6 +366,10 @@ class TestApplication(testlib.MachineCase): b.wait_not_present('div.modal-dialog') # Confirm that the image got downloaded b.wait_present('#containers-images tr:contains("{0}")'.format(self.imageName)) + checkImage(b, "localhost:5000/{0}:{1}".format(self.imageName, self.imageTag or "latest"), "system" if self.user == "system" else "admin") + + # Find out this image ID + self.imageSha = execute(self.user == "system", "podman inspect --format '{{{{.Id}}}}' {0}:{1}".format(self.imageName, self.imageTag or "latest")).strip() return self @@ -244,16 +380,20 @@ class TestApplication(testlib.MachineCase): imageTagSuffix = "" # Select the image row - b.click('#containers-images tbody tr:contains("{0}{1}") td.listing-ct-toggle'.format(self.imageName, imageTagSuffix)) + + # show image listing toggle + sel = "#containers-images tbody tr[data-row-id={0}{1}]".format(self.imageSha, "true" if self.user == "system" else "false") + b.wait_present(sel) + b.click(sel + " td.listing-ct-toggle") # Click the delete icon on the image row - b.wait_present('#containers-images tbody tr:contains("{0}{1}") + tr button.btn-delete'.format(self.imageName, imageTagSuffix)) - b.click('#containers-images tbody tr:contains("{0}{1}") + tr button.btn-delete'.format(self.imageName, imageTagSuffix)) + b.wait_present(sel + ' + tr button.btn-delete') + b.click(sel +' + tr button.btn-delete') # Confirm deletion in the delete dialog b.click(".modal-dialog div #btn-img-delete") - b.wait_not_present('#containers-images tr:contains("{0}")'.format(self.imageName)) + b.wait_not_present(sel) return self @@ -262,14 +402,23 @@ class TestApplication(testlib.MachineCase): self.login_and_go("/podman") b.wait_present("#app") - dialog = DownloadImageDialog('my-busybox') - dialog.openDialog() \ + dialog0 = DownloadImageDialog('my-busybox', user="system") + dialog0.openDialog() \ .fillDialog() \ .selectImageAndDownload() \ - .expectDownloadSuccess() \ - .deleteImage() + .expectDownloadSuccess() - dialog = DownloadImageDialog('my-busybox', 'latest') + if m.image != "rhel-8-1": + dialog1 = DownloadImageDialog('my-busybox', user="user") + dialog1.openDialog() \ + .fillDialog() \ + .selectImageAndDownload() \ + .expectDownloadSuccess() + dialog1.deleteImage() + + dialog0.deleteImage() + + dialog = DownloadImageDialog('my-busybox', 'latest', user="system") dialog.openDialog() \ .fillDialog() \ .selectImageAndDownload() \ @@ -287,17 +436,27 @@ class TestApplication(testlib.MachineCase): .selectImageAndDownload() \ .expectDownloadErrorForNonExistingTag() - def testLifecycleOperations(self): + @testlib.skipImage("No user service", "rhel-8-1") + def testLifecycleOperationsUser(self): + self._testLifecycleOperations(False) + + def testLifecycleOperationsSystem(self): + self._testLifecycleOperations(True) + + def _testLifecycleOperations(self, auth): b = self.browser m = self.machine + if not auth: + self.allow_authorize_journal_messages() + self.allow_browser_errors("Failed to start system io.podman.socket.*") + # run a container - m.execute("podman run -dit --name swamped-crate busybox sh; podman stop swamped-crate") - b.wait(lambda: m.execute("podman ps --all | grep -e swamped-crate -e Exited")) + self.execute(auth, "podman run -dit --name swamped-crate busybox sh; podman stop swamped-crate") + b.wait(lambda: self.execute(auth, "podman ps --all | grep -e swamped-crate -e Exited")) - self.login_and_go("/podman") + self.login_and_go("/podman", authorized=auth) b.wait_present("#app") - b.wait_present(".content-filter input") self.filter_containers('all') b.wait_present("#containers-containers") @@ -307,18 +466,39 @@ class TestApplication(testlib.MachineCase): # Start the container b.click('#containers-containers tbody tr:contains("busybox:latest") + tr button:contains(Start)') + container_sha = self.execute(auth, "podman inspect --format '{{.Id}}' swamped-crate").strip() + with b.wait_timeout(5): - self.check_container('swamped-crate', ['swamped-crate', 'busybox:latest', 'sh', 'running']) + self.check_container(container_sha, auth, ['swamped-crate', 'busybox:latest', 'sh', 'running', "system" if auth else "admin"]) + # Check we show usage + b.wait(lambda: b.text("#containers-containers tbody tr:contains('busybox:latest') > td:nth-child(5)") != "") + cpu = b.text("#containers-containers tbody tr:contains('busybox:latest') > td:nth-child(5)") + memory = b.text("#containers-containers tbody tr:contains('busybox:latest') > td:nth-child(6)") + if auth or m.image not in ["fedora-29", "fedora-30", "rhel-8-1"]: + self.assertIn('%', cpu) + num = cpu[:-1] + self.assertTrue(num.replace('.', '', 1).isdigit()) + + self.assertIn('/', memory) + numbers = memory.split('/') + self.assertTrue(numbers[0].strip().replace('.', '', 1).isdigit()) + full = numbers[1].strip().split() + self.assertTrue(full[0].replace('.', '', 1).isdigit()) + self.assertIn(full[1], ["GiB", "MiB"]) + else: + # No support for CGroupsV2 + self.assertEqual(cpu, "n/a") + self.assertEqual(memory, "n/a") # Restart the container - old_pid = m.execute("podman inspect --format '{{.State.Pid}}' swamped-crate".strip()) + old_pid = self.execute(auth, "podman inspect --format '{{.State.Pid}}' swamped-crate") b.click('#containers-containers tbody tr:contains("busybox:latest") + tr button:contains(Restart)') b.click('#containers-containers tbody tr:contains("busybox:latest") + tr ul.dropdown-menu li a:contains(Force Restart)') - new_pid = m.execute("podman inspect --format '{{.State.Pid}}' swamped-crate".strip()) + new_pid = self.execute(auth, "podman inspect --format '{{.State.Pid}}' swamped-crate".strip()) self.assertNotEqual(old_pid, new_pid) with b.wait_timeout(5): - self.check_container('swamped-crate', ['swamped-crate', 'busybox:latest', 'sh', 'running']) + self.check_container(container_sha, auth, ['swamped-crate', 'busybox:latest', 'sh', 'running']) self.filter_containers('all') b.wait_present("#containers-containers") @@ -328,15 +508,47 @@ class TestApplication(testlib.MachineCase): b.click('#containers-containers tbody tr:contains("busybox:latest") + tr button:contains(Stop)') b.click('#containers-containers tbody tr:contains("busybox:latest") + tr ul.dropdown-menu li a:contains(Force Stop)') - self.check_container('swamped-crate', ['swamped-crate', 'busybox:latest', 'sh']) - b.wait(lambda: b.text('#containers-containers tr:contains(swamped-crate) td:nth-of-type(6)') in ['stopped', 'exited']) + self.check_container(container_sha, auth, ['swamped-crate', 'busybox:latest', 'sh']) + b.wait(lambda: b.text('#containers-containers tr:contains(swamped-crate) td:nth-of-type(7)') in ['stopped', 'exited']) + b.wait_text("#containers-containers tbody tr:contains('busybox:latest') > td:nth-child(5)", "") + b.wait_text("#containers-containers tbody tr:contains('busybox:latest') > td:nth-child(6)", "") def testNotRunning(self): b = self.browser m = self.machine - m.execute("systemctl disable --now io.podman.socket") + def disable_system(): + self.execute(True, "systemctl disable --now io.podman.socket") + self.execute(True, "killall podman || true") + def enable_system(): + self.execute(True, "systemctl enable --now io.podman.socket") + + def enable_user(): + if m.image != "rhel-8-1": + self.execute(False, "systemctl --user enable --now io.podman.socket") + + def disable_user(): + if m.image != "rhel-8-1": + self.execute(False, "systemctl --user disable --now io.podman.socket") + self.execute(False, "killall podman || true") + + def is_active_system(string): + b.wait(lambda: self.execute(True, "systemctl is-active io.podman.socket || true").strip() == string) + + def is_enabled_system(string): + b.wait(lambda: self.execute(True, "systemctl is-enabled io.podman.socket || true").strip() == string) + + def is_active_user(string): + if m.image != "rhel-8-1": + b.wait(lambda: self.execute(False, "systemctl --user is-active io.podman.socket || true").strip() == string) + + def is_enabled_user(string): + if m.image != "rhel-8-1": + b.wait(lambda: self.execute(False, "systemctl --user is-enabled io.podman.socket || true").strip() == string) + + disable_system() + disable_user() self.login_and_go("/podman") # Troubleshoot action @@ -350,28 +562,84 @@ class TestApplication(testlib.MachineCase): b.click("#app .blank-slate-pf button.btn-primary") b.wait_present("#containers-containers") + b.wait_not_present("#overview > div.alert.alert-info.dialog-info") - self.assertEqual(m.execute("systemctl is-enabled io.podman.socket").strip(), "enabled") - self.assertEqual(m.execute("systemctl is-active io.podman.socket").strip(), "active") + is_active_system("active") + is_active_user("active") + is_enabled_system("enabled") + is_enabled_user("enabled") # Start action, without enabling - m.execute("systemctl disable --now io.podman.socket") - m.execute("killall podman || true") + disable_system() + disable_user() b.click("#app .blank-slate-pf input[type=checkbox]") b.click("#app .blank-slate-pf button.btn-primary") b.wait_present("#containers-containers") - self.assertEqual(m.execute("! systemctl is-enabled io.podman.socket").strip(), "disabled") - self.assertEqual(m.execute("systemctl is-active io.podman.socket").strip(), "active") + is_enabled_system("disabled") + is_enabled_user("disabled") + is_active_system("active") + is_active_user("active") + + if m.image == "rhel-8-1": + return + b.logout() + disable_system() + enable_user() + self.login_and_go("/podman") + b.wait_text("#overview > div.alert.alert-info.dialog-info span:nth-child(2)", "System Podman service is also available") + b.click("#overview > div.alert.alert-info.dialog-info > button") + b.wait_not_present("#overview > div.alert.alert-info.dialog-info") + is_active_system("active") + is_active_user("active") + is_enabled_user("enabled") + is_enabled_system("enabled") + + b.logout() + disable_user() + enable_system() + self.login_and_go("/podman") + b.wait_text("#overview > div.alert.alert-info.dialog-info span:nth-child(2)", "User Podman service is also available") + b.click("#overview > div.alert.alert-info.dialog-info > button") + b.wait_not_present("#overview > div.alert.alert-info.dialog-info") + is_active_system("active") + is_active_user("active") + is_enabled_user("enabled") + is_enabled_system("enabled") + + b.logout() + disable_user() + disable_system() + self.login_and_go("/podman", authorized=False) + b.click("#app .blank-slate-pf button.btn-primary") + + b.wait_present("#containers-containers") + b.wait_not_present("#overview > div.alert.alert-info.dialog-info") + is_active_system("inactive") + is_active_user("active") + is_enabled_user("enabled") + is_enabled_system("disabled") - self.allow_journal_messages("/run/podman/io.podman: couldn't connect.*") self.allow_restart_journal_messages() + self.allow_authorize_journal_messages() + + def testRunImageSystem(self): + self._testRunImage(True) - def testRunImage(self): + @testlib.skipImage("No user service", "rhel-8-1") + def testRunImageUser(self): + self.allow_authorize_journal_messages() + self._testRunImage(False) + + def _testRunImage(self, auth): b = self.browser m = self.machine - self.login_and_go("/podman") + if auth and m.image != "rhel-8-1": + # Just drop user containers so we can user simpler selectors + self.execute(False, "podman rmi alpine busybox registry:2") + + self.login_and_go("/podman", authorized=auth) b.wait_in_text("#containers-images", "busybox:latest") b.wait_in_text("#containers-images", "alpine:latest") @@ -397,11 +665,13 @@ class TestApplication(testlib.MachineCase): b.wait_present("#run-image-dialog-command[value='sh']") # Check memory configuration - b.set_checked("#run-image-dialog-memory-limit-checkbox", True) - b.wait_present("#run-image-dialog-memory-limit-checkbox:checked") - b.wait_present('div.modal-body label:contains("Memory Limit") + div.form-inline > input[value="512"]') - b.set_input_text("#run-image-dialog-memory-limit input.form-control", "0.5") - b.set_val('#memory-unit-select', "GiB") + # Only works with CGroupsV2 + if auth or m.image not in ["fedora-29", "fedora-30", "rhel-8-1"]: + b.set_checked("#run-image-dialog-memory-limit-checkbox", True) + b.wait_present("#run-image-dialog-memory-limit-checkbox:checked") + b.wait_present('div.modal-body label:contains("Memory Limit") + div.form-inline > input[value="512"]') + b.set_input_text("#run-image-dialog-memory-limit input.form-control", "0.5") + b.set_val('#memory-unit-select', "GiB") # Enable tty b.set_checked("#run-image-dialog-tty", True) @@ -457,36 +727,38 @@ class TestApplication(testlib.MachineCase): b.click('div.modal-footer button:contains("Run")') b.wait_not_present("div.modal-dialog") b.wait_present('#containers-containers tr:contains("busybox:latest")') - self.check_container('busybox-with-tty', ['busybox-with-tty', 'busybox:latest', 'sh -c "while echo Hello World; do sleep 1; done"', 'running']) - - hasTTY = m.execute("podman inspect --format '{{.Config.Tty}}' busybox-with-tty").strip() + sha = self.execute(auth, "podman inspect --format '{{.Id}}' busybox-with-tty").strip() + self.check_container(sha, auth, ['busybox-with-tty', 'busybox:latest', 'sh -c "while echo Hello World; do sleep 1; done"', 'running', "system" if auth else "admin"]) + hasTTY = self.execute(auth, "podman inspect --format '{{.Config.Tty}}' busybox-with-tty").strip() self.assertEqual(hasTTY, 'true') - memory = m.execute("podman inspect --format '{{.HostConfig.Memory}}' busybox-with-tty").strip() - self.assertEqual(memory, '536870912') + # Only works with CGroupsV2 + if auth or m.image not in ["fedora-29", "fedora-30", "rhel-8-1"]: + memory = self.execute(auth, "podman inspect --format '{{.HostConfig.Memory}}' busybox-with-tty").strip() + self.assertEqual(memory, '536870912') - b.wait(lambda: "Hello World" in m.execute("podman logs busybox-with-tty")) + b.wait(lambda: "Hello World" in self.execute(auth, "podman logs busybox-with-tty")) b.click('#containers-containers tbody tr:contains("busybox:latest") td.listing-ct-toggle') b.wait_in_text('#containers-containers tr:contains("busybox:latest") dt:contains("Ports") + dd', '0.0.0.0:6000 \u2192 5000/tcp') b.wait_in_text('#containers-containers tr:contains("busybox:latest") dt:contains("Ports") + dd', '0.0.0.0:6001 \u2192 5001/udp') b.wait_in_text('#containers-containers tr:contains("busybox:latest") dt:contains("Ports") + dd', '0.0.0.0:8001 \u2192 8001/tcp') b.wait_not_in_text('#containers-containers tr:contains("busybox:latest") dt:contains("Ports") + dd', '0.0.0.0:7001 \u2192 7001/tcp') - ports = m.execute("podman inspect --format '{{.NetworkSettings.Ports}}' busybox-with-tty") + ports = self.execute(auth, "podman inspect --format '{{.NetworkSettings.Ports}}' busybox-with-tty") self.assertIn('6000 5000 tcp', ports) self.assertIn('6001 5001 udp', ports) self.assertIn('8001 8001 tcp', ports) self.assertNotIn('7001 7001 tcp', ports) - env = m.execute("podman exec busybox-with-tty env") + env = self.execute(auth, "podman exec busybox-with-tty env") self.assertIn('APPLE=ORANGE', env) self.assertIn('PEAR=BANANA', env) self.assertIn('RHUBARB=STRAWBERRY', env) self.assertNotIn('MELON=GRAPE', env) - romnt = m.execute("podman exec busybox-with-tty cat /proc/self/mountinfo | grep /tmp/ro") + romnt = self.execute(auth, "podman exec busybox-with-tty cat /proc/self/mountinfo | grep /tmp/ro") self.assertIn('ro', romnt) self.assertIn(rodir[4:], romnt) - rwmnt = m.execute("podman exec busybox-with-tty cat /proc/self/mountinfo | grep /tmp/rw") + rwmnt = self.execute(auth, "podman exec busybox-with-tty cat /proc/self/mountinfo | grep /tmp/rw") self.assertIn('rw', rwmnt) self.assertIn(rwdir[4:], rwmnt) @@ -532,13 +804,14 @@ class TestApplication(testlib.MachineCase): b.click('#containers-containers tbody tr:contains("busybox-without-publish") + tr button:contains(Stop)') b.click("div.dropdown.open ul li:nth-child(1) a") b.wait_text(".xterm-accessibility-tree > div:nth-child(3)", "/ # disconnected ") - self.check_container('busybox-without-publish', ['busybox-without-publish', 'busybox:latest', '/bin/sh', 'exited']) + sha = self.execute(auth, "podman inspect --format '{{.Id}}' busybox-without-publish").strip() + self.check_container(sha, auth, ['busybox-without-publish', 'busybox:latest', '/bin/sh', 'exited']) b.click('#containers-containers tbody tr:contains("busybox-without-publish") + tr button:contains(Start)') - self.check_container('busybox-without-publish', ['busybox-without-publish', 'busybox:latest', '/bin/sh', 'running']) + self.check_container(sha, auth, ['busybox-without-publish', 'busybox:latest', '/bin/sh', 'running']) b.wait_text(".xterm-accessibility-tree > div:nth-child(1)", "/ # ") b.click('#containers-containers tbody tr:contains("busybox-without-publish") + tr button:contains(Stop)') b.click("div.dropdown.open ul li:nth-child(1) a") - self.check_container('busybox-without-publish', ['busybox-without-publish', 'busybox:latest', '/bin/sh', 'exited']) + self.check_container(sha, auth, ['busybox-without-publish', 'busybox:latest', '/bin/sh', 'exited']) b.click('#containers-containers tr:contains("busybox-without-publish")') b.set_input_text('#containers-filter', 'tty') @@ -556,7 +829,7 @@ class TestApplication(testlib.MachineCase): if m.image != "fedora-29": b.set_val("#containers-containers-filter", "all") - self.check_container('busybox-without-publish', ['busybox-without-publish', 'busybox:latest', '/bin/sh', 'exited']) + self.check_container(sha, auth, ['busybox-without-publish', 'busybox:latest', '/bin/sh', 'exited']) b.click('#containers-containers tr:contains("busybox-without-publish")') b.click("a:contains('Console')") b.wait_text("span.empty-message", "Container is not running") @@ -574,7 +847,7 @@ class TestApplication(testlib.MachineCase): b.wait_present('#containers-containers thead tr td:contains("No containers that match the current filter")') b.wait_present('#containers-images thead tr td:contains("No images that match the current filter")') b.set_input_text('#containers-filter', '') - m.execute("podman rmi -f $(podman images -q)") + self.execute(auth, "podman rmi -f $(podman images -q)") b.wait_present('#containers-containers thead tr td:contains("No containers")') b.set_val("#containers-containers-filter", "running") b.wait_present('#containers-containers thead tr td:contains("No running containers")') @@ -593,11 +866,12 @@ class TestApplication(testlib.MachineCase): def check_images(self, present, not_present): self.check_content("images", present, not_present) - def check_container(self, row_name, expected_strings): + def check_container(self, row_id, auth, expected_strings): """Check the container with row_name has the expected_string shown in the row""" + sel = "#containers-containers tbody tr[data-row-id={0}{1}]".format(row_id, auth).lower() b = self.browser for str in expected_strings: - b.wait_in_text('#containers-containers tr:contains(%s)' % row_name, str) + b.wait_in_text(sel, str) def filter_containers(self, value): """Use dropdown menu in the header to filter containers""" diff --git a/test/vm.install b/test/vm.install index 19249f5c0..b4c5b611a 100644 --- a/test/vm.install +++ b/test/vm.install @@ -16,3 +16,9 @@ fi podman pull busybox podman pull docker.io/alpine podman pull docker.io/registry:2 + +sudo -i -u admin bash << EOF +podman pull busybox +podman pull docker.io/alpine +podman pull docker.io/registry:2 +EOF