Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions api/v1alpha1/decofile_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ type DecofileSpec struct {
// +optional
GitHub *GitHubSource `json:"github,omitempty"`

// Silent disables pod notifications when ConfigMap changes
// If true, pods will not be notified and must poll or restart to get updates
// DeploymentId is used for pod label matching (defaults to metadata.name if absent)
// Pods are queried using the app.deco/deploymentId label
// +optional
Silent bool `json:"silent,omitempty"`
DeploymentId string `json:"deploymentId,omitempty"`
}

// InlineSource contains direct JSON configuration data
Expand Down
10 changes: 9 additions & 1 deletion chart/templates/clusterrole-operator-manager-role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,12 @@ rules:
verbs:
- get
- patch
- update
- update
- apiGroups:
- serving.knative.dev
resources:
- services
verbs:
- get
- list
- watch
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ spec:
spec:
description: DecofileSpec defines the desired state of Decofile.
properties:
deploymentId:
description: |-
DeploymentId is used for pod label matching (defaults to metadata.name if absent)
Pods are queried using the app.deco/deploymentId label
type: string
github:
description: GitHub contains repository information (used when source=github)
properties:
Expand Down Expand Up @@ -78,11 +83,6 @@ spec:
required:
- value
type: object
silent:
description: |-
Silent disables pod notifications when ConfigMap changes
If true, pods will not be notified and must poll or restart to get updates
type: boolean
source:
description: Source specifies where to get the configuration data
enum:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ metadata:
cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ .Release.Name }}-serving-cert
name: {{ .Release.Name }}-validating-webhook-configuration
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: {{ .Release.Name }}-webhook-service
namespace: {{ .Release.Namespace }}
path: /validate-deco-sites-v1alpha1-decofile
failurePolicy: Fail
name: vdecofile.kb.io
rules:
- apiGroups:
- deco.sites
apiVersions:
- v1alpha1
operations:
- DELETE
resources:
- decofiles
sideEffects: None
- admissionReviewVersions:
- v1
clientConfig:
Expand Down
4 changes: 4 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ func main() {
setupLog.Error(err, "unable to create webhook", "webhook", "Service")
os.Exit(1)
}
if err := webhookv1.SetupDecofileWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "Decofile")
os.Exit(1)
}
}
// +kubebuilder:scaffold:builder

Expand Down
10 changes: 5 additions & 5 deletions config/crd/bases/deco.sites_decofiles.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ spec:
spec:
description: DecofileSpec defines the desired state of Decofile.
properties:
deploymentId:
description: |-
DeploymentId is used for pod label matching (defaults to metadata.name if absent)
Pods are queried using the app.deco/deploymentId label
type: string
github:
description: GitHub contains repository information (used when source=github)
properties:
Expand Down Expand Up @@ -79,11 +84,6 @@ spec:
required:
- value
type: object
silent:
description: |-
Silent disables pod notifications when ConfigMap changes
If true, pods will not be notified and must poll or restart to get updates
type: boolean
source:
description: Source specifies where to get the configuration data
enum:
Expand Down
8 changes: 8 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,11 @@ rules:
- get
- patch
- update
- apiGroups:
- serving.knative.dev
resources:
- services
verbs:
- get
- list
- watch
19 changes: 19 additions & 0 deletions config/webhook/manifests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ kind: ValidatingWebhookConfiguration
metadata:
name: validating-webhook-configuration
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /validate-deco-sites-v1alpha1-decofile
failurePolicy: Fail
name: vdecofile.kb.io
rules:
- apiGroups:
- deco.sites
apiVersions:
- v1alpha1
operations:
- DELETE
resources:
- decofiles
sideEffects: None
- admissionReviewVersions:
- v1
clientConfig:
Expand Down
41 changes: 21 additions & 20 deletions internal/controller/decofile_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,14 @@ func (r *DecofileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
}
}

// Determine deploymentId (default to decofile name if not specified)
deploymentId := decofile.Spec.DeploymentId
if deploymentId == "" {
deploymentId = decofile.Name
}

// Reset PodsNotified condition when change is detected (before notifying)
if dataChanged && !decofile.Spec.Silent {
if dataChanged {
// Set condition to InProgress before attempting notification
tempDecofile := &decositesv1alpha1.Decofile{}
err = r.Get(ctx, req.NamespacedName, tempDecofile)
Expand All @@ -262,28 +268,23 @@ func (r *DecofileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
}
}

// Notify pods if ConfigMap data changed (unless silent mode is enabled)
// Notify pods if ConfigMap data changed
var podsNotified bool
var notificationError string

if dataChanged {
if decofile.Spec.Silent {
log.Info("ConfigMap data changed but notifications disabled (silent mode)", "timestamp", timestamp)
log.Info("ConfigMap data changed, notifying pods", "timestamp", timestamp, "deploymentId", deploymentId)

notifier := NewNotifier(r.Client)
err = notifier.NotifyPodsForDecofile(ctx, decofile.Namespace, deploymentId, timestamp, jsonContent)
if err != nil {
log.Error(err, "Failed to notify pods", "deploymentId", deploymentId)
notificationError = err.Error()
podsNotified = false
// Don't return error - update status with failure condition
} else {
log.Info("ConfigMap data changed, notifying pods", "timestamp", timestamp)

notifier := NewNotifier(r.Client)
err = notifier.NotifyPodsForDecofile(ctx, decofile.Namespace, decofile.Name, timestamp, jsonContent)
if err != nil {
log.Error(err, "Failed to notify pods", "decofile", decofile.Name)
notificationError = err.Error()
podsNotified = false
// Don't return error - update status with failure condition
} else {
log.Info("Successfully notified all pods", "timestamp", timestamp)
podsNotified = true
}
log.Info("Successfully notified all pods", "timestamp", timestamp, "deploymentId", deploymentId)
podsNotified = true
}
}

Expand Down Expand Up @@ -316,8 +317,8 @@ func (r *DecofileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
}
updateCondition(freshDecofile, readyCondition)

// Update PodsNotified condition (only when not silent)
if !freshDecofile.Spec.Silent && dataChanged {
// Update PodsNotified condition
if dataChanged {
var podsNotifiedCondition metav1.Condition

// Include commit or timestamp in message for matching
Expand Down Expand Up @@ -357,7 +358,7 @@ func (r *DecofileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
log.Info("Successfully reconciled Decofile")

// Return error if notifications failed (will requeue)
if dataChanged && !freshDecofile.Spec.Silent && !podsNotified {
if dataChanged && !podsNotified {
return ctrl.Result{}, fmt.Errorf("failed to notify pods: %s", notificationError)
}

Expand Down
16 changes: 8 additions & 8 deletions internal/controller/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const (
reloadTimeout = 30 * time.Second // 30s per pod (simple POST, no long-polling)
maxRetries = 3 // 3 attempts per pod
initialBackoff = 2 * time.Second
decofileLabel = "deco.sites/decofile"
deploymentIdLabel = "app.deco/deploymentId"
maxNotificationTime = 2 * time.Minute // 2 min for entire batch
notificationBatchSize = 10 // Parallel notification batch size (reduced to save memory)
appContainerName = "app"
Expand Down Expand Up @@ -72,30 +72,30 @@ func extractReloadToken(pod *corev1.Pod) string {
return ""
}

// NotifyPodsForDecofile notifies all pods using the given Decofile
// NotifyPodsForDecofile notifies all pods using the given deploymentId
// that the ConfigMap has changed and they should reload.
// Uses parallel batch processing with 2-minute timeout.
func (n *Notifier) NotifyPodsForDecofile(ctx context.Context, namespace, decofileName, timestamp, decofileContent string) error {
func (n *Notifier) NotifyPodsForDecofile(ctx context.Context, namespace, deploymentId, timestamp, decofileContent string) error {
log := logf.FromContext(ctx)

log.Info("Notifying pods for Decofile", "decofile", decofileName, "namespace", namespace)
log.Info("Notifying pods for deploymentId", "deploymentId", deploymentId, "namespace", namespace)

// Create timeout context for entire operation
notifyCtx, cancel := context.WithTimeout(ctx, maxNotificationTime)
defer cancel()

// List pods with the decofile label
// List pods with the deploymentId label
podList := &corev1.PodList{}
err := n.Client.List(notifyCtx, podList,
client.InNamespace(namespace),
client.MatchingLabels{decofileLabel: decofileName})
client.MatchingLabels{deploymentIdLabel: deploymentId})

if err != nil {
return fmt.Errorf("failed to list pods for decofile %s: %w", decofileName, err)
return fmt.Errorf("failed to list pods for deploymentId %s: %w", deploymentId, err)
}

if len(podList.Items) == 0 {
log.V(1).Info("No pods found for Decofile", "decofile", decofileName)
log.V(1).Info("No pods found for deploymentId", "deploymentId", deploymentId)
return nil
}

Expand Down
122 changes: 122 additions & 0 deletions internal/webhook/v1/decofile_webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
Copyright 2025.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v1

import (
"context"
"fmt"

"k8s.io/apimachinery/pkg/runtime"
servingknativedevv1 "knative.dev/serving/pkg/apis/serving/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1"
)

// nolint:unused
var decofilelog = logf.Log.WithName("decofile-resource")

// +kubebuilder:rbac:groups=serving.knative.dev,resources=services,verbs=get;list;watch

// SetupDecofileWebhookWithManager registers the webhook for Decofile in the manager.
func SetupDecofileWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).For(&decositesv1alpha1.Decofile{}).
WithValidator(&DecofileCustomValidator{Client: mgr.GetClient()}).
Complete()
}

// +kubebuilder:webhook:path=/validate-deco-sites-v1alpha1-decofile,mutating=false,failurePolicy=fail,sideEffects=None,groups=deco.sites,resources=decofiles,verbs=delete,versions=v1alpha1,name=vdecofile.kb.io,admissionReviewVersions=v1

// DecofileCustomValidator struct is responsible for validating the Decofile resource
// when it is deleted.
//
// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods,
// as this struct is used only for temporary operations and does not need to be deeply copied.
type DecofileCustomValidator struct {
Client client.Client
}

var _ webhook.CustomValidator = &DecofileCustomValidator{}

// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type Decofile.
func (v *DecofileCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
// No validation on create
return nil, nil
}

// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type Decofile.
func (v *DecofileCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
// No validation on update
return nil, nil
}

// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type Decofile.
func (v *DecofileCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
decofile, ok := obj.(*decositesv1alpha1.Decofile)
if !ok {
return nil, fmt.Errorf("expected a Decofile object but got %T", obj)
}

decofilelog.Info("Validating Decofile deletion", "name", decofile.Name, "namespace", decofile.Namespace)

// Determine deploymentId for this Decofile
deploymentId := decofile.Spec.DeploymentId
if deploymentId == "" {
deploymentId = decofile.Name
}

// Check if any Knative Services are using this Decofile
serviceList := &servingknativedevv1.ServiceList{}
err := v.Client.List(ctx, serviceList, client.InNamespace(decofile.Namespace))
if err != nil {
// If we can't list services, allow deletion (fail-open to avoid blocking operations)
decofilelog.Error(err, "Failed to list Services during Decofile validation, allowing deletion")
return nil, nil
}

// Check each Service for matching deploymentId and injection annotation
var usingServices []string
for i := range serviceList.Items {
svc := &serviceList.Items[i]

// Check if Service has injection enabled
if svc.Annotations != nil && svc.Annotations[decofileInjectAnnot] == "true" {
// Check if Service's deploymentId matches this Decofile
if svc.Labels != nil {
svcDeploymentId := svc.Labels[deploymentIdLabel]
if svcDeploymentId == deploymentId {
usingServices = append(usingServices, svc.Name)
}
}
}
}

if len(usingServices) > 0 {
return admission.Warnings{
fmt.Sprintf("Decofile %s is currently in use by %d Service(s)", decofile.Name, len(usingServices)),
},
fmt.Errorf("cannot delete Decofile %s: still in use by Service(s): %v. Remove deco.sites/decofile-inject annotation or delete the Service(s) first",
decofile.Name, usingServices)
}

decofilelog.Info("Decofile deletion allowed - not in use", "name", decofile.Name)
return nil, nil
}
Loading
Loading