Skip to content

Commit

Permalink
fix searching on non-searchable image registries
Browse files Browse the repository at this point in the history
fixes: cockpit-project#1220

Add support to pull images from registries which do not support search
API.
  • Loading branch information
tomasmatus committed Sep 30, 2024
1 parent 5347a5f commit c0c87a7
Show file tree
Hide file tree
Showing 2 changed files with 197 additions and 43 deletions.
134 changes: 92 additions & 42 deletions src/ImageRunModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
};

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]+$/;
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('/')) {
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
}
})));
}
}

Promise.allSettled(searches)
.then(reply => {
if (reply && this._isMounted) {
let imageResults = [];
let dialogError = "";
let dialogErrorDetail = "";
const manifestResult = handleManifestsQuery(reply[0]);

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)) {
changeDialogError(result.reason);
}
}

// Add manifest query result if search query did not find the same image
if (manifestResult && !imageResults.find(image => image.Name === value)) {
manifestResult.Name = value;
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;
Expand All @@ -434,6 +487,7 @@ export class ImageRunModal extends React.Component {
images[index] = [image];
}
});

this.setState({
imageResults: images || {},
searchFinished: true,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down
106 changes: 105 additions & 1 deletion test/check-application
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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 {
return 403;
}
}
"""


def podman_version(cls):
version = cls.execute(False, "podman -v").strip().split(' ')[-1]
Expand Down Expand Up @@ -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")

# 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")
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")

# 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()

0 comments on commit c0c87a7

Please sign in to comment.