Files
cozystack/internal/fluxshardoperator/webhook.go
T
Andrei KvapilandClaude 11e85bd1b3 fix(apps-api): preserve live shard label on Application update
The Update handler rebuilds the HelmRelease from the Application, which
re-stamped sharding.fluxcd.io/key with the ApplicationDefinition default
and bounced the HelmRelease off its operator-assigned shard on every
update. Carry the live label over from the current HelmRelease instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
2026-06-11 13:44:24 +03:00

110 lines
4.3 KiB
Go

package fluxshardoperator
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"gomodules.xyz/jsonpatch/v2"
admissionv1 "k8s.io/api/admission/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)
// WebhookPath is the HTTP path the shard-stamping webhook is served on.
const WebhookPath = "/mutate-helmrelease-shard"
// ShardWebhook stamps the owning tenant's shard onto every tenant HelmRelease
// at CREATE time, so each HelmRelease is born on the correct shard regardless
// of creation path (API-created, tenant chart children, "extra" HelmReleases
// rendered inside other releases).
//
// It is registered with operations: ["CREATE"] and failurePolicy: Ignore only:
// helm-controller status patches are a firehose, so UPDATE is never
// intercepted, and a webhook outage must degrade to the catch-all path (the
// HelmRelease keeps its legacy "tenants" key until the placement controller
// relabels it) instead of blocking creation.
//
// UPDATE does not need interception: the only full-object writer of tenant
// HelmReleases is the cozystack-api Update handler, which carries the live
// shard label over (pkg/registry/apps/application/rest.go); helm-controller
// itself applies child HelmReleases with server-side apply, which leaves
// labels owned by other field managers untouched.
type ShardWebhook struct {
// Reader resolves tenant namespaces; backed by the manager's metadata
// cache, so lookups are in-memory.
Reader client.Reader
}
// SetupWithManager registers the admission handler on the webhook server of
// every replica (webhooks are not leader-gated).
func (h *ShardWebhook) SetupWithManager(mgr ctrl.Manager) error {
mgr.GetWebhookServer().Register(WebhookPath, &admission.Webhook{Handler: h})
return nil
}
// Handle stamps the recorded shard assignment onto a newly created
// HelmRelease. Every miss is permissive: correctness is restored by the
// placement controller, the webhook only removes the handoff gap.
func (h *ShardWebhook) Handle(ctx context.Context, req admission.Request) admission.Response {
if req.Operation != admissionv1.Create {
return admission.Allowed("only CREATE is mutated")
}
obj := &metav1.PartialObjectMetadata{}
if err := json.Unmarshal(req.Object.Raw, obj); err != nil {
return admission.Errored(http.StatusBadRequest, fmt.Errorf("decoding object metadata: %w", err))
}
// Admission objects may omit metadata.namespace before defaulting.
if obj.Namespace == "" {
obj.Namespace = req.Namespace
}
tenantNS, ok := TenantNamespaceForHR(obj)
if !ok {
return admission.Allowed("not a tenant HelmRelease")
}
ns := NamespaceMeta()
if err := h.Reader.Get(ctx, types.NamespacedName{Name: tenantNS}, ns); err != nil {
if apierrors.IsNotFound(err) {
// Rare: the tenant namespace is not created yet (e.g. the parent
// tenant HelmRelease arrives before its chart renders the
// namespace). The placement controller assigns on first sight.
return admission.Allowed("tenant namespace not found, deferring to the placement controller")
}
log.FromContext(ctx).Error(err, "resolving tenant namespace", "tenant", tenantNS)
return admission.Allowed("tenant namespace lookup failed, deferring to the placement controller")
}
shard := ns.Labels[TenantShardLabel]
if _, ok := ParseShardIndex(shard); !ok {
return admission.Allowed("tenant has no recorded shard assignment yet")
}
if obj.Labels[ShardKeyLabel] == shard {
return admission.Allowed("already on the assigned shard")
}
// JSON Patch "add" both creates and overwrites object members.
var op jsonpatch.JsonPatchOperation
if obj.Labels == nil {
op = jsonpatch.NewOperation("add", "/metadata/labels", map[string]string{ShardKeyLabel: shard})
} else {
op = jsonpatch.NewOperation("add", "/metadata/labels/"+escapeJSONPointer(ShardKeyLabel), shard)
}
return admission.Patched("stamped shard "+shard+" for tenant "+tenantNS, op)
}
// escapeJSONPointer escapes a JSON pointer path segment (RFC 6901).
func escapeJSONPointer(s string) string {
return strings.NewReplacer("~", "~0", "/", "~1").Replace(s)
}