-
Notifications
You must be signed in to change notification settings - Fork 90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix searching on non-searchable image registries #1821
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -62,6 +62,8 @@ const HealthCheckOnFailureActionOrder = [ | |
{ value: 2, label: _("Force stop") }, | ||
]; | ||
|
||
const RE_CONTAINER_TAG = /:[\w\-\d]+$/; | ||
|
||
export class ImageRunModal extends React.Component { | ||
constructor(props) { | ||
super(props); | ||
|
@@ -351,68 +353,119 @@ export class ImageRunModal extends React.Component { | |
}); | ||
}; | ||
|
||
buildFilterRegex(searchText, includeRegistry) { | ||
// Strip out all non-allowed container image characters when filtering. | ||
let regexString = searchText.replace(/[^/\w_.:-]/g, ""); | ||
// drop registry from regex to allow filtering only by container names | ||
if (!includeRegistry && regexString.includes('/')) { | ||
regexString = '/' + searchText.split('/') | ||
.slice(1) | ||
.join('/'); | ||
} | ||
|
||
return new RegExp(regexString, 'i'); | ||
} | ||
|
||
onSearchTriggered = value => { | ||
let dialogError = ""; | ||
let dialogErrorDetail = ""; | ||
|
||
const changeDialogError = (reason) => { | ||
// Only report first encountered error | ||
if (dialogError === "" && dialogErrorDetail === "") { | ||
dialogError = _("Failed to search for new images"); | ||
// TODO: add registry context, podman does not include it in the reply. | ||
dialogErrorDetail = reason ? cockpit.format(_("Failed to search for images: $0"), reason.message) : _("Failed to search for images."); | ||
} | ||
}; | ||
|
||
const imageExistsLocally = (searchText, localImages) => { | ||
const regex = this.buildFilterRegex(searchText, true); | ||
return localImages.some(localImage => localImage.Name.search(regex) !== -1); | ||
martinpitt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}; | ||
|
||
const handleManifestsQuery = (result) => { | ||
if (result.status === "fulfilled") { | ||
return JSON.parse(result.value); | ||
} else if (result.reason.status !== 404) { | ||
changeDialogError(result.reason); | ||
} | ||
|
||
return null; | ||
}; | ||
|
||
// Do not call the SearchImage API if the input string is not at least 2 chars, | ||
// The comparison was done considering the fact that we miss always one letter due to delayed setState | ||
if (value.length < 2) | ||
return; | ||
|
||
// Don't search for a value with a tag specified | ||
const patt = /:[\w|\d]+$/; | ||
tomasmatus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (patt.test(value)) { | ||
return; | ||
} | ||
|
||
if (this.activeConnection) | ||
this.activeConnection.close(); | ||
|
||
this.setState({ searchFinished: false, searchInProgress: true }); | ||
this.activeConnection = rest.connect(client.getAddress(this.isSystem()), this.isSystem()); | ||
let searches = []; | ||
|
||
// If there are registries configured search in them, or if a user searches for `docker.io/cockpit` let | ||
// podman search in the user specified registry. | ||
if (Object.keys(this.props.podmanInfo.registries).length !== 0 || value.includes('/')) { | ||
searches.push(this.activeConnection.call({ | ||
method: "GET", | ||
path: client.VERSION + "libpod/images/search", | ||
body: "", | ||
params: { | ||
term: value, | ||
} | ||
})); | ||
} else { | ||
searches = searches.concat(utils.fallbackRegistries.map(registry => | ||
this.activeConnection.call({ | ||
const searches = []; | ||
|
||
// Try to get specified image manifest | ||
searches.push(this.activeConnection.call({ | ||
method: "GET", | ||
path: client.VERSION + "libpod/manifests/" + value + "/json", | ||
body: "", | ||
})); | ||
|
||
// Don't start search queries when tag is specified as search API doesn't support | ||
// searching for specific image tags | ||
// instead only rely on manifests query (requires image:tag name) | ||
if (!RE_CONTAINER_TAG.test(value)) { | ||
// If there are registries configured search in them, or if a user searches for `docker.io/cockpit` let | ||
// podman search in the user specified registry. | ||
if (Object.keys(this.props.podmanInfo.registries).length !== 0 || value.includes('/')) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This added line is not executed by any test. |
||
searches.push(this.activeConnection.call({ | ||
method: "GET", | ||
path: client.VERSION + "libpod/images/search", | ||
body: "", | ||
params: { | ||
term: registry + "/" + value | ||
term: value, | ||
} | ||
}))); | ||
})); | ||
} else { | ||
searches.push(...utils.fallbackRegistries.map(registry => | ||
this.activeConnection.call({ | ||
method: "GET", | ||
path: client.VERSION + "libpod/images/search", | ||
body: "", | ||
params: { | ||
term: registry + "/" + value | ||
Comment on lines
+432
to
+438
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a significant piece of code, please add a test for fallback registries. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (moved code) I think this wasn't tested before since
Comment on lines
+432
to
+438
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These 7 added lines are not executed by any test. |
||
} | ||
}))); | ||
} | ||
} | ||
|
||
Promise.allSettled(searches) | ||
.then(reply => { | ||
if (reply && this._isMounted) { | ||
let imageResults = []; | ||
let dialogError = ""; | ||
let dialogErrorDetail = ""; | ||
const manifestResult = handleManifestsQuery(reply[0]); | ||
martinpitt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
for (const result of reply) { | ||
for (let i = 1; i < reply.length; i++) { | ||
const result = reply[i]; | ||
if (result.status === "fulfilled") { | ||
imageResults = imageResults.concat(JSON.parse(result.value)); | ||
} else { | ||
dialogError = _("Failed to search for new images"); | ||
// TODO: add registry context, podman does not include it in the reply. | ||
dialogErrorDetail = result.reason ? cockpit.format(_("Failed to search for images: $0"), result.reason.message) : _("Failed to search for images."); | ||
} else if (!manifestResult && !imageExistsLocally(value, this.props.localImages)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This added line is not executed by any test. |
||
changeDialogError(result.reason); | ||
martinpitt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} | ||
|
||
// Add manifest query result if search query did not find the same image | ||
if (manifestResult && !imageResults.find(image => image.Name === value)) { | ||
manifestResult.Name = value; | ||
martinpitt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
imageResults.push(manifestResult); | ||
} | ||
|
||
// Group images on registry | ||
const images = {}; | ||
imageResults.forEach(image => { | ||
// Add Tag is it's there | ||
// Add Tag if it's there | ||
image.toString = function imageToString() { | ||
if (this.Tag) { | ||
return this.Name + ':' + this.Tag; | ||
|
@@ -434,6 +487,7 @@ export class ImageRunModal extends React.Component { | |
images[index] = [image]; | ||
} | ||
}); | ||
|
||
this.setState({ | ||
imageResults: images || {}, | ||
searchFinished: true, | ||
|
@@ -484,17 +538,20 @@ export class ImageRunModal extends React.Component { | |
isImageSelectOpen: false, | ||
command, | ||
entrypoint, | ||
dialogError: "", | ||
dialogErrorDetail: "", | ||
}); | ||
}; | ||
|
||
handleImageSelectInput = value => { | ||
const trimmedValue = value.trim(); | ||
this.setState({ | ||
searchText: value, | ||
searchText: trimmedValue, | ||
// Reset searchFinished status when text input changes | ||
searchFinished: false, | ||
selectedImage: "", | ||
}); | ||
this.onSearchTriggered(value); | ||
this.onSearchTriggered(trimmedValue); | ||
}; | ||
|
||
debouncedInputChanged = debounce(300, this.handleImageSelectInput); | ||
|
@@ -524,14 +581,7 @@ export class ImageRunModal extends React.Component { | |
imageRegistries.push(this.state.searchByRegistry); | ||
} | ||
|
||
// Strip out all non-allowed container image characters when filtering. | ||
let regexString = searchText.replace(/[^/\w_.:-]/g, ""); | ||
// Strip image registry option if set for comparing results for docker.io searching for docker.io/fedora | ||
// returns docker.io/$username/fedora for example. | ||
if (regexString.includes('/')) { | ||
regexString = searchText.replace(searchText.split('/')[0], ''); | ||
} | ||
const input = new RegExp(regexString, 'i'); | ||
const input = this.buildFilterRegex(searchText, false); | ||
|
||
const results = imageRegistries | ||
.map((reg, index) => { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ require: | |
- cockpit-ws | ||
- cockpit-system | ||
- criu | ||
- nginx | ||
duration: 30m | ||
|
||
/system: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,7 +15,7 @@ REGISTRIES_CONF = """ | |
registries = ['localhost:5000', 'localhost:6000'] | ||
|
||
[registries.insecure] | ||
registries = ['localhost:5000', 'localhost:6000'] | ||
registries = ['localhost:80', 'localhost:5000', 'localhost:6000'] | ||
""" | ||
|
||
NOT_RUNNING = ["Exited", "Stopped"] | ||
|
@@ -28,6 +28,24 @@ IMG_BUSYBOX_LATEST = IMG_BUSYBOX + ":latest" | |
IMG_REGISTRY = "localhost/test-registry" | ||
IMG_REGISTRY_LATEST = IMG_REGISTRY + ":latest" | ||
|
||
# nginx configuration to simulate a registry without search API (like ghcr.io) | ||
NGINX_DEFAULT_CONF = """ | ||
server { | ||
listen 80; | ||
listen [::]:80; | ||
server_name localhost; | ||
|
||
location / { | ||
proxy_pass http://localhost:5000/; | ||
} | ||
|
||
# Simulate a registry without search API, like ghcr.io | ||
location ^~ /v2/_catalog { | ||
tomasmatus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return 403; | ||
} | ||
} | ||
""" | ||
|
||
|
||
def podman_version(cls): | ||
version = cls.execute(False, "podman -v").strip().split(' ')[-1] | ||
|
@@ -3049,6 +3067,92 @@ class TestApplication(testlib.MachineCase): | |
else: | ||
self.assertNotIn("Podman", b.text("#host-apps")) | ||
|
||
@testlib.skipImage("nginx not installed", "centos-*", "rhel-*", "debian-*", "ubuntu-*", "arch") | ||
@testlib.skipOstree("nginx not installed") | ||
def testNonSearchableRegistry(self): | ||
b = self.browser | ||
m = self.machine | ||
|
||
self.setupRegistry() | ||
|
||
# Nginx config simulates behavior of ghcr.io in terms of `podman search` and `podman manifest inspect` | ||
self.write_file("/etc/nginx/conf.d/default.conf", NGINX_DEFAULT_CONF, | ||
post_restore_action="systemctl stop nginx.service; setsebool -P httpd_can_network_connect 0") | ||
self.execute(True, """ | ||
setsebool -P httpd_can_network_connect 1 | ||
systemctl start nginx.service | ||
""") | ||
|
||
container_name = "localhost:80/my-busybox" | ||
m.execute(f""" | ||
podman manifest create my-busybox | ||
podman manifest add my-busybox {IMG_BUSYBOX} | ||
podman manifest push my-busybox {container_name} | ||
podman manifest push my-busybox {container_name + ":nosearch-tag"} | ||
podman manifest push my-busybox "localhost:5000/my-busybox:search-tag" | ||
""") | ||
# Untag busybox image which duplicates the image we are about to download | ||
m.execute(f""" | ||
podman manifest rm localhost/my-busybox | ||
podman rmi -f {IMG_BUSYBOX} | ||
""") | ||
|
||
self.login() | ||
|
||
b.click("#containers-containers button.pf-v5-c-button.pf-m-primary") | ||
b.set_input_text("#run-image-dialog-name", "new-busybox") | ||
# Searching for container by prefix fails | ||
b.set_input_text("#create-image-image-select-typeahead", "localhost:80/my-busy") | ||
b.wait_text("button.pf-v5-c-select__menu-item.pf-m-disabled", "No images found") | ||
b.wait_in_text(".pf-v5-c-alert.pf-m-danger", "couldn't search registry") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shouldn't be "danger", but "info" or "default". (It needs to be changed in the code, but it's visible here) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can't think of a way how to set it to "info" for this specific error message. I've learnt from the previous reviews that |
||
|
||
# Giving full image name finds valid manifest therefore image is pullable | ||
b.set_input_text("#create-image-image-select-typeahead", container_name) | ||
b.wait_text("button.pf-v5-c-select__menu-item:not(.pf-m-disabled)", container_name) | ||
# Select image and create a container | ||
b.click(f"button.pf-v5-c-select__menu-item:contains({container_name})") | ||
b.click("#create-image-create-btn") | ||
|
||
# Wait for download to finish | ||
sel = "span:not(.downloading)" | ||
b.wait(lambda: self.getContainerAttr(container_name, "State", sel) in "Created") | ||
|
||
checkImage(b, f"{container_name}:latest", "system") | ||
|
||
b.click("#containers-containers button.pf-v5-c-button.pf-m-primary") | ||
# "couldn't search registry" error is hidden when some local image is found | ||
b.set_input_text("#create-image-image-select-typeahead", "") | ||
b.set_input_text("#create-image-image-select-typeahead", "localhost:80/my-busy") | ||
b.wait_text("button.pf-v5-c-select__menu-item:not(.pf-m-disabled)", f"{container_name}:latest") | ||
b.wait_not_present(".pf-v5-c-alert.pf-m-danger") | ||
tomasmatus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
b.click('button.pf-v5-c-toggle-group__button:contains("Local")') | ||
|
||
# Error is shown again when no image was found locally | ||
b.set_input_text("#create-image-image-select-typeahead", "localhost:80/their-busy") | ||
b.wait_text("button.pf-v5-c-select__menu-item.pf-m-disabled", "No images found") | ||
b.wait_in_text(".pf-v5-c-alert.pf-m-danger", "couldn't search registry") | ||
|
||
# Searching for full container name with tag finds the image | ||
b.set_input_text("#run-image-dialog-name", "tagged-busybox") | ||
# Search should still work with spaces around image name | ||
b.set_input_text("#create-image-image-select-typeahead", " localhost:80/my-busybox:nosearch-tag ") | ||
b.click('button.pf-v5-c-toggle-group__button:contains("All")') | ||
b.wait_text("button.pf-v5-c-select__menu-item:not(.pf-m-disabled)", container_name + ":nosearch-tag") | ||
b.wait_js_cond("document.getElementsByClassName('pf-v5-c-select__menu-item').length === 1") | ||
b.click(f"button.pf-v5-c-select__menu-item:contains({container_name + ":nosearch-tag"})") | ||
b.click("#create-image-create-btn") | ||
|
||
martinpitt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Wait for download to finish | ||
sel = "span:not(.downloading)" | ||
b.wait(lambda: self.getContainerAttr("tagged-busybox", "State", sel) in "Created") | ||
checkImage(b, f"{container_name}:nosearch-tag", "system") | ||
|
||
# Check that manifest search with tag also works on searchable repository | ||
b.click("#containers-containers button.pf-v5-c-button.pf-m-primary") | ||
b.set_input_text("#create-image-image-select-typeahead", "localhost:5000/my-busybox:search-tag") | ||
b.wait_text("button.pf-v5-c-select__menu-item:not(.pf-m-disabled)", "localhost:5000/my-busybox:search-tag") | ||
b.wait_js_cond("document.getElementsByClassName('pf-v5-c-select__menu-item').length === 1") | ||
|
||
|
||
if __name__ == '__main__': | ||
testlib.test_main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.