Stored artifacts are neither garbage-collected nor invalidated when an object leaves a shard, masking sharding misconfiguration until the next pod restart
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 45/100
Research direction
Reproduce the shard-label drift with the included kind and Flux script, then inspect main.go, HelmChartReconciler.getSource, and the reconcileDelete → garbageCollect path. Confirm how a filtered-cache eviction is handled and whether stored artifacts remain served. Done means the behavior is covered by tests and artifacts are no longer silently served after an object leaves a shard, or a visible failure signal is provided.
Written by the indexing model from the issue text.
Description
Describe the bug
When running sharded source-controllers per the sharding guide (--watch-label-selector), an object that leaves a shard — its sharding.fluxcd.io/key label changed or removed during normal GitOps refactoring — leaves its stored artifact behind on the old shard instance, and that instance keeps serving the artifact at the advertised URL even though it no longer owns (or even sees) the object.
The combination of "no GC / keep serving on shard eviction" + ephemeral artifact storage (emptyDir in the reference distribution) turns a small labeling mistake into a delayed failure: the workload keeps running off the stale artifact for as long as the old pod lives, and the release breaks on the next source-controller restart — typically the next Flux upgrade, far from the change that actually caused it.
Up front, to be precise about confidence levels: the timeline below is reproduced step by step (repro included), and we also hit it in production; the code-level explanation at the end is my reading of the code — a hypothesis I'd ask you to confirm or correct. Trying to help narrow this down, not to assert how the controller works internally.
Steps to reproduce (verified on kind)
Full self-contained repro script (kind + flux CLI + jq)
#!/usr/bin/env bash
# Repro: source-controller keeps serving (and never GCs) artifacts of objects that left its shard;
# with emptyDir storage the failure detonates on the next pod restart.
# Verified with: kind v0.33.0, flux CLI v2.8.8 (source-controller v1.8.5, helm-controller v1.5.5)
set -euo pipefail
CLUSTER=shard-gc-repro
CTX=kind-$CLUSTER
kind create cluster --name $CLUSTER --wait 120s
flux install --context $CTX --components=source-controller,helm-controller
# --- shard the controllers per https://fluxcd.io/flux/installation/configuration/sharding/ ---
kubectl --context $CTX patch deploy -n flux-system source-controller --type=json \
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--watch-label-selector=!sharding.fluxcd.io/key"}]'
kubectl --context $CTX patch deploy -n flux-system helm-controller --type=json \
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--watch-label-selector=!sharding.fluxcd.io/key"}]'
kubectl --context $CTX get deploy -n flux-system source-controller -o json | jq '
.metadata = {name:"source-controller-shard1", namespace:"flux-system", labels:(.metadata.labels + {app:"source-controller-shard1"})} |
.spec.selector.matchLabels.app = "source-controller-shard1" |
.spec.template.metadata.labels.app = "source-controller-shard1" |
del(.status) |
.spec.template.spec.containers[0].args = (.spec.template.spec.containers[0].args
| map(select(startswith("--watch-label-selector") or startswith("--storage-adv-addr") | not))
+ ["--watch-label-selector=sharding.fluxcd.io/key=shard1",
"--storage-adv-addr=source-controller-shard1.$(RUNTIME_NAMESPACE).svc.cluster.local."])
' | kubectl --context $CTX apply -f -
kubectl --context $CTX get svc -n flux-system source-controller -o json | jq '
.metadata = {name:"source-controller-shard1", namespace:"flux-system"} |
.spec.selector.app = "source-controller-shard1" |
del(.spec.clusterIP, .spec.clusterIPs, .status)
' | kubectl --context $CTX apply -f -
kubectl --context $CTX get deploy -n flux-system helm-controller -o json | jq '
.metadata = {name:"helm-controller-shard1", namespace:"flux-system", labels:(.metadata.labels + {app:"helm-controller-shard1"})} |
.spec.selector.matchLabels.app = "helm-controller-shard1" |
.spec.template.metadata.labels.app = "helm-controller-shard1" |
del(.status) |
.spec.template.spec.containers[0].args = (.spec.template.spec.containers[0].args
| map(select(startswith("--watch-label-selector") | not))
+ ["--watch-label-selector=sharding.fluxcd.io/key=shard1"])
' | kubectl --context $CTX apply -f -
kubectl --context $CTX rollout status -n flux-system deploy/source-controller-shard1 --timeout=120s
kubectl --context $CTX rollout status -n flux-system deploy/helm-controller-shard1 --timeout=120s
kubectl --context $CTX rollout status -n flux-system deploy/source-controller --timeout=120s
# --- everything on shard1, all three labels in sync ---
kubectl --context $CTX apply -f - <<'EOF'
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: podinfo
namespace: default
labels:
sharding.fluxcd.io/key: shard1
spec:
interval: 1m
url: https://stefanprodan.github.io/podinfo
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: podinfo
namespace: default
labels:
sharding.fluxcd.io/key: shard1
spec:
interval: 1m
chart:
metadata:
labels:
sharding.fluxcd.io/key: shard1
spec:
chart: podinfo
version: "6.7.0"
sourceRef:
kind: HelmRepository
name: podinfo
interval: 1m
EOF
sleep 60
kubectl --context $CTX get helmrepository,helmchart,helmrelease -n default # all Ready=True
CHART_URL=$(kubectl --context $CTX get helmchart -n default default-podinfo -o jsonpath='{.status.artifact.url}')
echo "chart artifact: $CHART_URL" # points at source-controller-shard1
# curl helper inside flux-system (NetworkPolicy blocks ingress from other namespaces)
kubectl --context $CTX run curl -n flux-system --image=curlimages/curl:8.10.1 --restart=Never -q -- sleep 3600
kubectl --context $CTX wait pod/curl -n flux-system --for=condition=Ready --timeout=60s
# --- THE DRIFT: repo leaves shard1 ---
kubectl --context $CTX label helmrepository -n default podinfo sharding.fluxcd.io/key-
sleep 150
kubectl --context $CTX get helmrepository,helmchart,helmrelease -n default
# helmrepository: Ready=True (now owned by the default instance, status URL rewritten)
# helmchart: Ready=False SourceUnavailable: HelmRepository "podinfo" not found
# helmrelease: Ready=True <-- still green, workload running
# stale artifacts still served by shard1, which no longer owns the object:
kubectl --context $CTX exec -n flux-system curl -- curl -s -o /dev/null -w "chart tgz on shard1: HTTP %{http_code}\n" "$CHART_URL" # HTTP 200
# --- THE DETONATION: routine restart (what a Flux upgrade does) ---
kubectl --context $CTX rollout restart -n flux-system deploy/source-controller-shard1
kubectl --context $CTX rollout status -n flux-system deploy/source-controller-shard1 --timeout=120s
sleep 90
kubectl --context $CTX exec -n flux-system curl -- curl -s -o /dev/null -w "chart tgz on shard1: HTTP %{http_code}\n" "$CHART_URL" # HTTP 404
kubectl --context $CTX get helmrelease -n default podinfo
# helmrelease: Ready=False HelmChart 'default/default-podinfo' is not ready: does not have an artifact
Controllers: source-controller v1.8.5, helm-controller v1.5.5 (flux CLI v2.8.8); also observed on v1.9.5 in EKS.
-
kind create cluster --name shard-gc-repro && flux install --components=source-controller,helm-controller -
Shard the controllers per the guide: add
--watch-label-selector=!sharding.fluxcd.io/keyto the default source-controller and helm-controller; create copiessource-controller-shard1/helm-controller-shard1with--watch-label-selector=sharding.fluxcd.io/key=shard1, a dedicatedsource-controller-shard1Service and--storage-adv-addr=source-controller-shard1.$(RUNTIME_NAMESPACE).svc.cluster.local.. Artifact storage stays onemptyDiras shipped. -
Apply a
HelmRepository(podinfo) +HelmRelease, all labeledsharding.fluxcd.io/key: shard1(including.spec.chart.metadata.labels). Everything goes Ready; the HelmChart artifact URL points atsource-controller-shard1.…:helmrepository/podinfo True stored artifact: revision 'sha256:e7dc68a4…' helmchart/default-podinfo True pulled 'podinfo' chart with version '6.7.0' helmrelease/podinfo True Helm install succeeded for release default/podinfo.v1 -
The drift: remove the
sharding.fluxcd.io/keylabel from theHelmRepositoryonly (simulating a partial refactoring — the label has to be kept in sync by hand in three places, see below). Observed a couple of minutes later:- the default instance takes the
HelmRepositoryover and rewrites itsstatus.artifact.urlfromsource-controller-shard1.…tosource-controller.…— the object itself stays Ready; - the
HelmChart(still shard1) goesReady=False/FetchFailed=TruewithSourceUnavailable: failed to get source: HelmRepository.source.toolkit.fluxcd.io "podinfo" not found— the repository exists in the API but is invisible through shard1's label-filtered cache; - but the chart keeps
ArtifactInStorage=True, the stale artifacts are still served by shard1 (HTTP 200on both the chart tgz and the old repo index at the shard1 URLs), and theHelmReleasestaysReady=Truewith the workload running. If your alerting watches HelmReleases — and that is what most people alert on — this is invisible. It can stay in this state for weeks.
- the default instance takes the
-
The detonation:
kubectl rollout restart -n flux-system deploy/source-controller-shard1(this is what a routine Flux upgrade does). TheemptyDiris wiped; observed immediately after:- the chart tgz URL now returns
HTTP 404; - shard1's controller re-reconciles the
HelmChartagainst empty storage, cannot rebuild it (its source is still invisible), and clearsstatus.artifact; - the
HelmReleasegoes down on its own, with no change applied to it:
helmrelease/podinfo False HelmChart 'default/default-podinfo' is not ready: does not have an artifactThe failure surfaces during/after a routine upgrade, with nothing in the upgrade diff pointing at the label change made long before.
- the chart tgz URL now returns
Why label drift is easy
Sharding correctness for a Helm release requires the same label to be maintained by hand in three places: the HelmRelease's own labels (for helm-controller), .spec.chart.metadata.labels (helm-controller's buildHelmChartFromTemplate copies only the chart template metadata to the generated HelmChart — the release's own labels are not propagated), and the HelmRepository. The docs acknowledge the constraint ("Source object kinds which have a dependency on another kind (i.e. HelmChart on a HelmRepository) need to have the same labels applied to work as expected"), but nothing enforces or reports a violation, and — per the above — as long as a previously stored artifact survives on some instance's disk, the release level looks healthy.
Proposed mechanism (hypothesis — please verify)
As far as I can tell from reading the code:
- the shard selector is a cache-level label filter on all source kinds (
main.go,Cache.ByObject[...]{Label: watchSelector}), which is whyHelmChartReconciler.getSourcegets NotFound for a repository owned by another shard; - when an object leaves the shard, the reconciler gets NotFound from the filtered cache and returns via
client.IgnoreNotFound; garbage collection of stored artifacts appears to run only through the finalizer path on actual object deletion (reconcileDelete→garbageCollect), which never executes here because the object still exists in the API — it is merely invisible to this instance. Hence: file stays on disk, HTTP server keeps serving it.
If I've misread the GC/eviction path, corrections are very welcome — the reproduced behavior above stands either way.
Expected behavior
One or more of:
- when an object leaves a shard (delete event from the label-filtered cache while the object still exists in the API), the instance garbage-collects its stored artifacts for that object, or at minimum stops serving them — so that a sharding misconfiguration fails fast and next to the change that caused it, instead of at the next restart;
- a visible signal that an object's
status.artifact.urlis advertised by an instance that no longer watches the object (condition on the object, event, or a metric); - (harder, likely out of scope here) validation/warning when dependent objects carry different shard labels.
Environment
- Reproduced on kind: source-controller v1.8.5, helm-controller v1.5.5 (flux CLI v2.8.8), sharding exactly per the official guide, artifact storage on
emptyDir. - Originally hit on EKS with source-controller v1.9.5 / helm-controller v1.6.4 (flux-operator-managed shards).
I understand fail-fast vs. keep-serving is a trade-off (the current behavior accidentally provides "grace" during transient label flaps). But the current combination — no GC, no signal, ephemeral storage — makes the failure land during an unrelated upgrade, which is close to the worst possible time to debug it.
- Dominant language
- Go
- Stars
- 283
- Forks
- 252
- Avg merge
- 1h 6m
- Merged PRs (30d)
- 12
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from fluxcd/source-controller
-
area/docs good first issue help wanted
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
fluxcd/source-controller#666 · 2 comments ·
-
area/git bug
Difficulty 4/5 3-5 days Newbie friendliness 55/100
fluxcd/source-controller#2165 · 3 comments ·
-
GitRepository `.spec.ref.commit` + `.spec.ref.branch` does not shallow clone, contrary to the docs Open
Difficulty 5/5 Over a week Newbie friendliness 42/100
fluxcd/source-controller#2146 · 2 comments ·
-
Difficulty 4/5 3-5 days Newbie friendliness 55/100
fluxcd/source-controller#2136 · 1 comment ·
-
HelmRepository: use conditional HTTP requests (ETag / If-Modified-Since) when fetching index.yaml Open
Difficulty 4/5 3-5 days Newbie friendliness 48/100
fluxcd/source-controller#2113 · 1 comment ·
All issues in fluxcd/source-controller
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
-
bug group: validation priority: low
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
codecheckers/chekhov#51 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100