mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 05:25:14 +09:00
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>
110 lines
4.3 KiB
Go
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)
|
|
}
|