Skip to content

Commit 81ffae5

Browse files
committed
fix(reset): dedupe image removal and match by labels
1 parent 7f76e7a commit 81ffae5

1 file changed

Lines changed: 114 additions & 28 deletions

File tree

packages/core/src/docker/image.rs

Lines changed: 114 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ pub async fn image_exists(
7070
}
7171
}
7272

73-
/// Remove all images whose tags or digests contain the provided name fragment
73+
/// Remove all images whose tags, digests, or labels match the provided name fragment
7474
///
75-
/// Returns the number of image references removed.
75+
/// Returns the number of images removed.
7676
pub async fn remove_images_by_name(
7777
client: &DockerClient,
7878
name_fragment: &str,
@@ -82,8 +82,8 @@ pub async fn remove_images_by_name(
8282

8383
let images = list_docker_images(client).await?;
8484

85-
let references = collect_image_references(&images, name_fragment);
86-
remove_image_references(client, references, force).await
85+
let image_ids = collect_image_ids(&images, name_fragment);
86+
remove_image_ids(client, image_ids, force).await
8787
}
8888

8989
/// List all local Docker images (including intermediate layers).
@@ -98,47 +98,87 @@ async fn list_docker_images(
9898
.map_err(|e| DockerError::Image(format!("Failed to list images: {e}")))
9999
}
100100

101-
/// Collect tags and digests that contain the provided name fragment.
102-
fn collect_image_references(
101+
const LABEL_TITLE: &str = "org.opencontainers.image.title";
102+
const LABEL_SOURCE: &str = "org.opencontainers.image.source";
103+
const LABEL_URL: &str = "org.opencontainers.image.url";
104+
105+
const LABEL_TITLE_VALUE: &str = "opencode-cloud";
106+
const LABEL_SOURCE_VALUE: &str = "https://github.com/pRizz/opencode-cloud";
107+
const LABEL_URL_VALUE: &str = "https://github.com/pRizz/opencode-cloud";
108+
109+
/// Collect image IDs that contain the provided name fragment or match opencode labels.
110+
fn collect_image_ids(
103111
images: &[bollard::models::ImageSummary],
104112
name_fragment: &str,
105113
) -> HashSet<String> {
106-
let mut references = HashSet::new();
114+
let mut image_ids = HashSet::new();
107115
for image in images {
108-
for tag in &image.repo_tags {
109-
if tag != "<none>:<none>" && tag.contains(name_fragment) {
110-
references.insert(tag.to_string());
111-
}
112-
}
113-
114-
for digest in &image.repo_digests {
115-
if digest.contains(name_fragment) {
116-
references.insert(digest.to_string());
117-
}
116+
if image_matches_fragment_or_labels(image, name_fragment) {
117+
image_ids.insert(image.id.clone());
118118
}
119119
}
120-
references
120+
image_ids
121+
}
122+
123+
fn image_matches_fragment_or_labels(
124+
image: &bollard::models::ImageSummary,
125+
name_fragment: &str,
126+
) -> bool {
127+
let tag_match = image
128+
.repo_tags
129+
.iter()
130+
.any(|tag| tag != "<none>:<none>" && tag.contains(name_fragment));
131+
let digest_match = image
132+
.repo_digests
133+
.iter()
134+
.any(|digest| digest.contains(name_fragment));
135+
let label_match = image_labels_match(&image.labels);
136+
137+
tag_match || digest_match || label_match
121138
}
122139

123-
/// Remove image references (tags/digests), returning the number removed.
124-
async fn remove_image_references(
140+
fn image_labels_match(labels: &HashMap<String, String>) -> bool {
141+
labels
142+
.get(LABEL_SOURCE)
143+
.is_some_and(|value| value == LABEL_SOURCE_VALUE)
144+
|| labels
145+
.get(LABEL_URL)
146+
.is_some_and(|value| value == LABEL_URL_VALUE)
147+
|| labels
148+
.get(LABEL_TITLE)
149+
.is_some_and(|value| value == LABEL_TITLE_VALUE)
150+
}
151+
152+
/// Remove image IDs, returning the number removed.
153+
async fn remove_image_ids(
125154
client: &DockerClient,
126-
references: HashSet<String>,
155+
image_ids: HashSet<String>,
127156
force: bool,
128157
) -> Result<usize, DockerError> {
129-
if references.is_empty() {
158+
if image_ids.is_empty() {
130159
return Ok(0);
131160
}
132161

133162
let remove_options = RemoveImageOptionsBuilder::new().force(force).build();
134163
let mut removed = 0usize;
135-
for reference in references {
136-
client
164+
for image_id in image_ids {
165+
let result = client
137166
.inner()
138-
.remove_image(&reference, Some(remove_options.clone()), None)
139-
.await
140-
.map_err(|e| DockerError::Image(format!("Failed to remove image {reference}: {e}")))?;
141-
removed += 1;
167+
.remove_image(&image_id, Some(remove_options.clone()), None)
168+
.await;
169+
match result {
170+
Ok(_) => removed += 1,
171+
Err(bollard::errors::Error::DockerResponseServerError {
172+
status_code: 404, ..
173+
}) => {
174+
debug!("Docker image already removed: {}", image_id);
175+
}
176+
Err(err) => {
177+
return Err(DockerError::Image(format!(
178+
"Failed to remove image {image_id}: {err}"
179+
)));
180+
}
181+
}
142182
}
143183

144184
Ok(removed)
@@ -867,6 +907,32 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
867907
#[cfg(test)]
868908
mod tests {
869909
use super::*;
910+
use bollard::models::ImageSummary;
911+
use std::collections::HashMap;
912+
913+
fn make_image_summary(
914+
id: &str,
915+
tags: Vec<&str>,
916+
digests: Vec<&str>,
917+
labels: HashMap<String, String>,
918+
) -> ImageSummary {
919+
ImageSummary {
920+
id: id.to_string(),
921+
parent_id: String::new(),
922+
repo_tags: tags.into_iter().map(|tag| tag.to_string()).collect(),
923+
repo_digests: digests
924+
.into_iter()
925+
.map(|digest| digest.to_string())
926+
.collect(),
927+
created: 0,
928+
size: 0,
929+
shared_size: -1,
930+
labels,
931+
containers: 0,
932+
manifests: None,
933+
descriptor: None,
934+
}
935+
}
870936

871937
#[test]
872938
fn create_build_context_succeeds() {
@@ -980,4 +1046,24 @@ mod tests {
9801046
assert!(!is_error_line("Compiling foo v1.0"));
9811047
assert!(!is_error_line("Successfully installed"));
9821048
}
1049+
1050+
#[test]
1051+
fn collect_image_ids_matches_labels() {
1052+
let mut labels = HashMap::new();
1053+
labels.insert(LABEL_SOURCE.to_string(), LABEL_SOURCE_VALUE.to_string());
1054+
1055+
let images = vec![
1056+
make_image_summary("sha256:opencode", vec![], vec![], labels),
1057+
make_image_summary(
1058+
"sha256:other",
1059+
vec!["busybox:latest"],
1060+
vec![],
1061+
HashMap::new(),
1062+
),
1063+
];
1064+
1065+
let ids = collect_image_ids(&images, "opencode-cloud-sandbox");
1066+
assert!(ids.contains("sha256:opencode"));
1067+
assert!(!ids.contains("sha256:other"));
1068+
}
9831069
}

0 commit comments

Comments
 (0)