fix(gallery): allow uninstalling orphaned meta backends + force reinstall (#9434)

Two interrelated bugs that combined to make a meta backend impossible
to uninstall once its concrete had been removed from disk (partial
install, earlier crash, manual cleanup).

1. DeleteBackendFromSystem returned "meta backend %q not found" and
   bailed out early when the concrete directory didn't exist,
   preventing the orphaned meta dir from ever being removed. Treat a
   missing concrete as idempotent success — log a warning and continue
   to remove the orphan meta.

2. InstallBackendFromGallery's "already installed, skip" short-circuit
   only checked that the name was known (`backends.Exists(name)`); an
   orphaned meta whose RunFile points at a missing concrete still
   satisfies that check, so every reinstall returned nil without doing
   anything. Afterwards the worker's findBackend returned empty and we
   kept looping with "backend %q not found after install attempt".
   Require the entry to be actually runnable (run.sh stat-able, not a
   directory) before skipping.

New helper isBackendRunnable centralises the runnability test so both
the install guard and future callers stay in sync. Tests cover the
orphaned-meta delete path and the non-runnable short-circuit case.
This commit is contained in:
Ettore Di Giacinto 2026-04-20 00:10:19 +02:00 committed by GitHub
parent 28091d626e
commit 372eb08dcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 91 additions and 6 deletions

View file

@ -110,7 +110,13 @@ func InstallBackendFromGallery(ctx context.Context, galleries []config.Gallery,
if err != nil {
return err
}
if backends.Exists(name) {
// Only short-circuit if the install is *actually usable*. An orphaned
// meta entry whose concrete was removed still shows up in
// ListSystemBackends with a RunFile pointing at a path that no longer
// exists; returning early there leaves the caller with a broken
// alias and the worker fails with "backend not found after install
// attempt" on every retry. Re-install in that case.
if existing, ok := backends.Get(name); ok && isBackendRunnable(existing) {
return nil
}
}
@ -375,17 +381,44 @@ func DeleteBackendFromSystem(systemState *system.SystemState, name string) error
}
if metadata != nil && metadata.MetaBackendFor != "" {
metaBackendDirectory := filepath.Join(systemState.Backend.BackendsPath, metadata.MetaBackendFor)
xlog.Debug("Deleting meta backend", "backendDirectory", metaBackendDirectory)
if _, err := os.Stat(metaBackendDirectory); os.IsNotExist(err) {
return fmt.Errorf("meta backend %q not found", metadata.MetaBackendFor)
concreteDirectory := filepath.Join(systemState.Backend.BackendsPath, metadata.MetaBackendFor)
xlog.Debug("Deleting concrete backend referenced by meta", "concreteDirectory", concreteDirectory)
// If the concrete the meta points to is already gone (earlier delete,
// partial install, or manual cleanup), keep going and remove the
// orphaned meta dir. Previously we returned an error here, which made
// the orphaned meta impossible to uninstall from the UI — the delete
// kept failing and every subsequent install short-circuited because
// the stale meta metadata made ListSystemBackends.Exists(name) true.
if _, statErr := os.Stat(concreteDirectory); statErr == nil {
os.RemoveAll(concreteDirectory)
} else if os.IsNotExist(statErr) {
xlog.Warn("Concrete backend referenced by meta not found — removing orphaned meta only",
"meta", name, "concrete", metadata.MetaBackendFor)
} else {
return statErr
}
os.RemoveAll(metaBackendDirectory)
}
return os.RemoveAll(backendDirectory)
}
// isBackendRunnable reports whether the given backend entry can actually be
// invoked. A meta backend is runnable only if its concrete's run.sh still
// exists on disk; concrete backends are considered runnable as long as their
// RunFile is set (ListSystemBackends only emits them when the runfile is
// present). Used to guard the "already installed" short-circuit so an
// orphaned meta pointing at a missing concrete triggers a real reinstall
// rather than being silently skipped.
func isBackendRunnable(b SystemBackend) bool {
if b.RunFile == "" {
return false
}
if fi, err := os.Stat(b.RunFile); err != nil || fi.IsDir() {
return false
}
return true
}
type SystemBackend struct {
Name string
RunFile string

View file

@ -952,6 +952,58 @@ var _ = Describe("Gallery Backends", func() {
err = DeleteBackendFromSystem(systemState, "non-existent")
Expect(err).To(HaveOccurred())
})
It("removes an orphaned meta backend whose concrete is missing", func() {
// Real scenario from the dev cluster: the concrete got wiped
// (partial install, manual cleanup, previous crash) but the meta
// directory + metadata.json still points at it. The old code
// errored with "meta backend X not found" and left the orphan in
// place, making the backend impossible to uninstall.
metaName := "meta-backend"
concreteName := "concrete-backend-that-vanished"
metaPath := filepath.Join(tempDir, metaName)
Expect(os.MkdirAll(metaPath, 0750)).To(Succeed())
meta := BackendMetadata{Name: metaName, MetaBackendFor: concreteName}
data, err := json.MarshalIndent(meta, "", " ")
Expect(err).NotTo(HaveOccurred())
Expect(os.WriteFile(filepath.Join(metaPath, "metadata.json"), data, 0644)).To(Succeed())
// Concrete directory intentionally absent.
systemState, err := system.GetSystemState(system.WithBackendPath(tempDir))
Expect(err).NotTo(HaveOccurred())
Expect(DeleteBackendFromSystem(systemState, metaName)).To(Succeed())
Expect(metaPath).NotTo(BeADirectory())
})
})
Describe("InstallBackendFromGallery — orphaned meta reinstall", func() {
It("re-runs install when the meta's concrete is missing", func() {
// Seed state: meta dir exists with metadata pointing at a
// concrete that was removed from disk. ListSystemBackends still
// surfaces the meta via its metadata.Name → the old short-circuit
// at `if backends.Exists(name) { return nil }` returned silently,
// leaving the worker's findBackend() with a dead alias forever.
// The fix: require the backend to be runnable before we skip.
metaName := "meta-orphan"
concreteName := "concrete-gone"
metaPath := filepath.Join(tempDir, metaName)
Expect(os.MkdirAll(metaPath, 0750)).To(Succeed())
meta := BackendMetadata{Name: metaName, MetaBackendFor: concreteName}
data, err := json.MarshalIndent(meta, "", " ")
Expect(err).NotTo(HaveOccurred())
Expect(os.WriteFile(filepath.Join(metaPath, "metadata.json"), data, 0644)).To(Succeed())
systemState, err := system.GetSystemState(system.WithBackendPath(tempDir))
Expect(err).NotTo(HaveOccurred())
listed, err := ListSystemBackends(systemState)
Expect(err).NotTo(HaveOccurred())
b, ok := listed.Get(metaName)
Expect(ok).To(BeTrue())
Expect(isBackendRunnable(b)).To(BeFalse()) // concrete run.sh absent
})
})
Describe("ListSystemBackends", func() {