Skip to content

Commit 5e7d26d

Browse files
patrickleetclaude
andcommitted
feat: robust local/published config switching in config install
- Always delete stale render Function packages before pushing new images, not just with --reload. Fixes ImagePullBackOff when local registry has a different digest than the previously installed Function. - Fix config install --path naming: use org-repo (e.g. hops-ops-aws-secret-stack) instead of just repo name, matching published Configuration names. - Fix Docker build cache corruption: replace multi-stage Dockerfile patching (FROM source AS src / COPY --from=src) with docker create + export approach. The old method broke when Docker's snapshot cache was stale for images loaded via docker load. - When installing a published --version, clean up local install artifacts: delete stale render Functions, local ImageConfig rewrites, and inactive ConfigurationRevisions pointing at the local registry. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 123204e commit 5e7d26d

1 file changed

Lines changed: 127 additions & 11 deletions

File tree

src/commands/config/install.rs

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,37 @@ fn apply_repo_version_spec(
329329
sanitize_name_component(&spec.org),
330330
sanitize_name_component(&spec.repo)
331331
);
332+
333+
// Delete any existing render Function so Crossplane re-resolves with the
334+
// correct digest for this version (avoids conflicts when switching between
335+
// local and published builds).
336+
let render_source = format!("ghcr.io/{}/{}_render", spec.org, spec.repo);
337+
let sources: HashSet<String> = [render_source.clone()].into_iter().collect();
338+
let removed = delete_package_resources_by_source("function.pkg.crossplane.io", &sources)?;
339+
if removed > 0 {
340+
log::info!("Deleted {} stale Function package(s) before version install", removed);
341+
}
342+
343+
// Delete any local-registry ImageConfig rewrite left over from a previous
344+
// `config install --path` so Crossplane pulls from ghcr.io.
345+
let ic_name = image_config_name(&render_source);
346+
let ic_check = Command::new("kubectl")
347+
.args(["get", "imageconfig.pkg.crossplane.io", &ic_name])
348+
.stdout(Stdio::null())
349+
.stderr(Stdio::null())
350+
.status();
351+
if ic_check.map(|s| s.success()).unwrap_or(false) {
352+
run_cmd(
353+
"kubectl",
354+
&["delete", "imageconfig.pkg.crossplane.io", &ic_name],
355+
)?;
356+
log::info!("Deleted local ImageConfig rewrite '{}'", ic_name);
357+
}
358+
359+
// Delete stale inactive ConfigurationRevisions pointing at the local
360+
// registry so they don't block dependency resolution for the published version.
361+
delete_local_registry_config_revisions(&config_name)?;
362+
332363
apply_configuration(
333364
&config_name,
334365
&package_ref,
@@ -516,7 +547,12 @@ fn run_local_path(
516547
let mut seen = HashSet::new();
517548
loaded.retain(|img| seen.insert(img.source.clone()));
518549

519-
if reload {
550+
// Always delete existing Function packages whose source matches the render
551+
// images we are about to push. Without this, Crossplane's dependency
552+
// resolution keeps the old Function with a stale digest from the published
553+
// version, causing ImagePullBackOff when the local registry has a different
554+
// digest. With --reload we also delete ConfigurationRevisions.
555+
{
520556
let function_sources: HashSet<String> = loaded
521557
.iter()
522558
.filter(|img| !is_configuration_image(&img.source))
@@ -534,7 +570,7 @@ fn run_local_path(
534570
)?;
535571
if removed_functions > 0 || removed_function_revisions > 0 {
536572
log::info!(
537-
"Reload requested; deleted {} Function package(s) and {} FunctionRevision(s) from matching sources before re-apply",
573+
"Deleted {} Function package(s) and {} FunctionRevision(s) from matching sources before re-apply",
538574
removed_functions,
539575
removed_function_revisions
540576
);
@@ -639,8 +675,9 @@ spec:
639675
// dependencies (skipDependencyResolution is intentionally not set).
640676
for pull_ref in &config_pull_refs {
641677
let (img_path, _) = split_ref(pull_ref);
642-
let name = img_path.rsplit('/').next().unwrap_or(img_path);
643-
apply_configuration(name, pull_ref, skip_dependency_resolution, reload)?;
678+
let path = strip_registry(img_path);
679+
let name = path.replace('/', "-");
680+
apply_configuration(&name, pull_ref, skip_dependency_resolution, reload)?;
644681
}
645682

646683
Ok(())
@@ -1051,15 +1088,53 @@ fn build_patched_configuration_image(
10511088
unique_suffix()
10521089
));
10531090
fs::create_dir_all(&build_dir)?;
1054-
fs::write(build_dir.join("package.yaml"), package_yaml)?;
1091+
1092+
// Extract the source image's filesystem via docker create + export,
1093+
// avoiding multi-stage FROM which breaks when Docker's snapshot cache
1094+
// is stale for images loaded via `docker load`.
1095+
let container_name = format!("hops-extract-{}", unique_suffix());
1096+
let create_out = Command::new("docker")
1097+
.args(["create", "--name", &container_name, source_image, "true"])
1098+
.output()?;
1099+
if !create_out.status.success() {
1100+
return Err(format!(
1101+
"docker create failed: {}",
1102+
String::from_utf8_lossy(&create_out.stderr)
1103+
)
1104+
.into());
1105+
}
1106+
1107+
let content_dir = build_dir.join("content");
1108+
fs::create_dir_all(&content_dir)?;
1109+
1110+
let export_status = Command::new("sh")
1111+
.args([
1112+
"-c",
1113+
&format!(
1114+
"docker export {} | tar -xf - -C {}",
1115+
container_name,
1116+
content_dir.to_string_lossy()
1117+
),
1118+
])
1119+
.status()?;
1120+
1121+
// Always remove the temp container.
1122+
let _ = Command::new("docker")
1123+
.args(["rm", "-f", &container_name])
1124+
.output();
1125+
1126+
if !export_status.success() {
1127+
let _ = fs::remove_dir_all(&build_dir);
1128+
return Err("docker export failed".into());
1129+
}
1130+
1131+
// Replace package.yaml with the patched version.
1132+
fs::write(content_dir.join("package.yaml"), package_yaml)?;
1133+
1134+
// Build from scratch using the extracted + patched content.
10551135
fs::write(
10561136
build_dir.join("Dockerfile"),
1057-
format!(
1058-
"FROM {source_image} AS src\n\
1059-
FROM scratch\n\
1060-
COPY --from=src / /\n\
1061-
COPY package.yaml /package.yaml\n"
1062-
),
1137+
"FROM scratch\nCOPY content/ /\n",
10631138
)?;
10641139

10651140
let target_tag = format!(
@@ -1220,6 +1295,47 @@ fn docker_build_from(src: &str, tag: &str) -> Result<(), Box<dyn Error>> {
12201295
Ok(())
12211296
}
12221297

1298+
/// Delete inactive ConfigurationRevisions whose package points at the local
1299+
/// registry. These are left over from `config install --path` and can block
1300+
/// dependency resolution when switching to a published version.
1301+
fn delete_local_registry_config_revisions(config_name: &str) -> Result<(), Box<dyn Error>> {
1302+
let output = run_cmd_output(
1303+
"kubectl",
1304+
&[
1305+
"get",
1306+
"configurationrevision.pkg.crossplane.io",
1307+
"-o",
1308+
"jsonpath={range .items[*]}{.metadata.name}|{.spec.package}|{.spec.desiredState}\\n{end}",
1309+
],
1310+
)?;
1311+
1312+
for line in output.lines() {
1313+
let parts: Vec<&str> = line.split('|').collect();
1314+
if parts.len() < 3 {
1315+
continue;
1316+
}
1317+
let rev_name = parts[0].trim();
1318+
let package = parts[1].trim();
1319+
let state = parts[2].trim();
1320+
1321+
if !rev_name.starts_with(config_name) {
1322+
continue;
1323+
}
1324+
if package.contains(REGISTRY_PULL) && state == "Inactive" {
1325+
run_cmd(
1326+
"kubectl",
1327+
&[
1328+
"delete",
1329+
"configurationrevision.pkg.crossplane.io",
1330+
rev_name,
1331+
],
1332+
)?;
1333+
log::info!("Deleted stale local ConfigurationRevision '{}'", rev_name);
1334+
}
1335+
}
1336+
Ok(())
1337+
}
1338+
12231339
#[cfg(test)]
12241340
mod tests {
12251341
use super::*;

0 commit comments

Comments
 (0)