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
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,42 @@ spec:

The operator will clone the repository and use the specified path as the function root directory.

### Configuring Automatic Middleware Updates

The operators main responsibility it to rebuild functions when outdated middleware is detected. Anyhow this behavior can be enabled/disabled at two levels:

#### Operator-Level Default

Configure the operator-wide default by editing the `func-operator-controller-config` ConfigMap in the operators namespace (`func-operator-system` by default):

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: func-operator-controller-config
namespace: func-operator-system
data:
autoUpdateMiddleware: "true" # or "false" to disable by default
```

#### Per-Function Override

Individual functions can override the operator default using the `autoUpdateMiddleware` field:

```yaml
apiVersion: functions.dev/v1alpha1
kind: Function
metadata:
name: my-function
namespace: default
spec:
repository:
url: https://github.com/your-org/your-function.git
autoUpdateMiddleware: false # Disable middleware updates for this function
```

**Precedence:** Function-level settings always take priority over the operator default.

## Development

### Local Development Cluster
Expand Down Expand Up @@ -243,7 +279,7 @@ make lint
| `repository.path` | string | No | Path to the function inside the repository. Defaults to "." |
| `repository.authSecretRef` | object | No | Reference to the auth secret for private repository authentication |
| `registry.authSecretRef` | object | No | Reference to the secret containing credentials for registry authentication |
| `autoUpdateMiddleware` | boolean | No | Defines if the operator should rebuild when outdated middleware is detected. Defaults to global operator config |
| `autoUpdateMiddleware` | boolean | No | Defines if the operator should rebuild when outdated middleware is detected. When not specified, defaults to the operator-wide setting in the `func-operator-controller-config` ConfigMap (default: `true`). Function-level setting takes precedence over operator default |

### Function Status

Expand Down
10 changes: 10 additions & 0 deletions api/v1alpha1/function_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,13 @@ func (f *Function) MarkMiddlewareNotUpToDate(reason, messageFormat string, messa
ObservedGeneration: f.Generation,
})
}

func (f *Function) MarkMiddlewareNotUpToDateIntentionally(reason, messageFormat string, messageA ...interface{}) bool {
return meta.SetStatusCondition(&f.Status.Conditions, metav1.Condition{
Type: TypeMiddlewareUpToDate,
Status: metav1.ConditionTrue,
Reason: reason,
Message: fmt.Sprintf(messageFormat, messageA...),
ObservedGeneration: f.Generation,
})
}
1 change: 0 additions & 1 deletion api/v1alpha1/function_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ type FunctionSpec struct {

// AutoUpdateMiddleware defines if the operator should rebuild the function when an outdated middleware is detected.
// Defaults to the global operator config.
// TODO: implement logic
AutoUpdateMiddleware *bool `json:"autoUpdateMiddleware,omitempty"`
}

Expand Down
29 changes: 24 additions & 5 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ import (
"github.com/functions-dev/func-operator/internal/git"
"github.com/functions-dev/func-operator/internal/monitoring"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"

// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
v1 "k8s.io/api/core/v1"
_ "k8s.io/client-go/plugin/pkg/client/auth"

"k8s.io/apimachinery/pkg/runtime"
Expand Down Expand Up @@ -196,6 +198,12 @@ func main() {
})
}

operatorNamespace := os.Getenv("SYSTEM_NAMESPACE")
if operatorNamespace == "" {
setupLog.Info("Operator namespace not set, defaulting to func-operator-system")
operatorNamespace = "func-operator-system"
}

watchNamespaces := getWatchNamespaces()
var cacheOpts cache.Options
if len(watchNamespaces) > 0 {
Expand All @@ -210,6 +218,16 @@ func main() {
setupLog.Info("Operator watching all namespaces")
}

// Always watch ConfigMaps in the operator's namespace so it can access the controller-config ConfigMap,
// without affecting which namespaces Functions are watched in
cacheOpts.ByObject = map[client.Object]cache.ByObject{
&v1.ConfigMap{}: {
Namespaces: map[string]cache.Config{
operatorNamespace: {},
},
},
}

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
Expand Down Expand Up @@ -252,11 +270,12 @@ func main() {
setupLog.Info("Func CLI is ready")

if err := (&controller.FunctionReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorder("functions-controller"),
FuncCliManager: funcCLIManager,
GitManager: git.NewManager(),
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorder("functions-controller"),
FuncCliManager: funcCLIManager,
GitManager: git.NewManager(),
OperatorNamespace: operatorNamespace,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Function")
os.Exit(1)
Expand Down
17 changes: 17 additions & 0 deletions config/manager/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ metadata:
app.kubernetes.io/managed-by: kustomize
name: system
---
apiVersion: v1
kind: ConfigMap
metadata:
name: controller-config
namespace: system
labels:
control-plane: controller-manager
app.kubernetes.io/name: func-operator
app.kubernetes.io/managed-by: kustomize
data:
# default to enable middleware updates
autoUpdateMiddleware: "true"
---
apiVersion: apps/v1
kind: Deployment
metadata:
Expand Down Expand Up @@ -70,6 +83,10 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.annotations['olm.targetNamespaces']
- name: SYSTEM_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
image: controller:latest
imagePullPolicy: Always
name: manager
Expand Down
8 changes: 8 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ kind: ClusterRole
metadata:
name: manager-role
rules:
- apiGroups:
- ""
resources:
- configmaps
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
Expand Down
144 changes: 123 additions & 21 deletions internal/controller/function_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"fmt"
"os"
"strconv"
"strings"

"github.com/functions-dev/func-operator/internal/funccli"
Expand All @@ -34,10 +35,13 @@ import (
"k8s.io/utils/ptr"
funcfn "knative.dev/func/pkg/functions"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

"github.com/functions-dev/func-operator/api/v1alpha1"
rbacv1 "k8s.io/api/rbac/v1"
Expand All @@ -46,21 +50,24 @@ import (

const (
deployFunctionRoleName = "func-operator-deploy-function"
controllerConfigName = "func-operator-controller-config"
)

// FunctionReconciler reconciles a Function object
type FunctionReconciler struct {
client.Client
Scheme *runtime.Scheme
Recorder events.EventRecorder
FuncCliManager funccli.Manager
GitManager git.Manager
Scheme *runtime.Scheme
Recorder events.EventRecorder
FuncCliManager funccli.Manager
GitManager git.Manager
OperatorNamespace string
}

// +kubebuilder:rbac:groups=functions.dev,resources=functions,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=functions.dev,resources=functions/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=functions.dev,resources=functions/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=pods;pods/attach;secrets;services;persistentvolumeclaims,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch
// +kubebuilder:rbac:groups="apps",resources=deployments;replicasets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="serving.knative.dev",resources=services;routes,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="eventing.knative.dev",resources=triggers,verbs=get;list;watch;create;update;patch;delete
Expand Down Expand Up @@ -214,36 +221,52 @@ func (r *FunctionReconciler) handleMiddlewareUpdate(ctx context.Context, functio
}

if !isOnLatestMiddleware {
logger.Info("Function is not on latest middleware. Will redeploy")
function.MarkMiddlewareNotUpToDate("MiddlewareOutdated", "Middleware is outdated, redeploying")

// update function image in status before long redeploy operation
functionDescribe, err := r.FuncCliManager.Describe(ctx, metadata.Name, function.Namespace)
isMiddlewareUpdateEnabled, source, err := r.isMiddlewareUpdateEnabled(ctx, function)
if err != nil {
return fmt.Errorf("failed to describe function to get image details: %w", err)
function.MarkMiddlewareNotUpToDate("MiddlewareCheckFailed", "Failed to check if middleware should be updated: %s", err)
return fmt.Errorf("failed to check if middleware should be updated: %w", err)
}
function.Status.Deployment.Image = functionDescribe.Image

// Flush status before long deploy operation
if err := FlushStatus(ctx, function); err != nil {
logger.Error(err, "Failed to update status before redeployment")
}
if !isMiddlewareUpdateEnabled {
logger.Info("Skipping middleware update, as middleware update is disabled")
function.MarkMiddlewareNotUpToDateIntentionally("SkipMiddlewareUpdate", "Skipping middleware update as update is disabled (source: %s)", source)
// Don't return - continue to update deployment status
} else {
logger.Info("Function is not on latest middleware and middleware update is enabled. Will redeploy")
function.MarkMiddlewareNotUpToDate("MiddlewareOutdated", "Middleware is outdated, redeploying")

// update function image in status before long redeploy operation
functionDescribe, err := r.FuncCliManager.Describe(ctx, metadata.Name, function.Namespace)
if err != nil {
return fmt.Errorf("failed to describe function to get image details: %w", err)
}
function.Status.Deployment.Image = functionDescribe.Image

// Flush status before long deploy operation
if err := FlushStatus(ctx, function); err != nil {
logger.Error(err, "Failed to update status before redeployment")
}

if err := r.deploy(ctx, function, repo); err != nil {
function.MarkDeployNotReady("DeployFailed", "Redeployment failed: %s", err.Error())
return fmt.Errorf("failed to redeploy function: %w", err)
}

if err := r.deploy(ctx, function, repo); err != nil {
function.MarkDeployNotReady("DeployFailed", "Redeployment failed: %s", err.Error())
return fmt.Errorf("failed to redeploy function: %w", err)
// After successful deployment, middleware is now up-to-date
function.MarkMiddlewareUpToDate()
}
} else {
logger.Info("Function is deployed with latest middleware. No need to redeploy")
function.MarkMiddlewareUpToDate()
}

// Update deployment status
functionDescribe, err := r.FuncCliManager.Describe(ctx, metadata.Name, function.Namespace)
if err != nil {
return fmt.Errorf("failed to describe function to get image details: %w", err)
}
function.Status.Deployment.Image = functionDescribe.Image

function.MarkMiddlewareUpToDate()
function.MarkDeployReady()
return nil
}
Expand Down Expand Up @@ -469,15 +492,62 @@ func (r *FunctionReconciler) isDeployed(ctx context.Context, name, namespace str
// SetupWithManager sets up the controller with the Manager.
func (r *FunctionReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1alpha1.Function{}).
WithEventFilter(predicate.GenerationChangedPredicate{}). // only reconcile when the spec changed (e.g. not on status updates)
// Only reconcile Functions when their spec changes (not on status updates).
// This predicate is applied to For() instead of WithEventFilter() to ensure
// it doesn't filter out ConfigMap-triggered reconciliations.
For(&v1alpha1.Function{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Watches(
&v1.ConfigMap{},
handler.EnqueueRequestsFromMapFunc(r.findFunctionsForConfigMap),
builder.WithPredicates(predicate.NewPredicateFuncs(func(obj client.Object) bool {
// Only watch the controller-config ConfigMap in the operator namespace
return obj.GetName() == controllerConfigName && obj.GetNamespace() == r.OperatorNamespace
})),
).
Named("function").
WithOptions(controller.Options{
MaxConcurrentReconciles: 100, // TODO: find a good value
}).
Complete(r)
}

// findFunctionsForConfigMap returns reconcile requests for all Functions that should be
// reconciled when the controller-config ConfigMap changes. This triggers reconciliation
// for Functions that rely on the operator-wide default (i.e., those without an explicit
// autoUpdateMiddleware setting).
//
// Note: This function is safe for multi-controller setups. The List() call uses the manager's
// cached client, which is already scoped to the namespaces this controller is watching
// (via WATCH_NAMESPACE env var). Each controller instance only reconciles Functions in its
// own watched namespaces.
func (r *FunctionReconciler) findFunctionsForConfigMap(ctx context.Context, _ client.Object) []reconcile.Request {
logger := log.FromContext(ctx)

// List all Functions in the watched namespaces (scoped by the manager's cache)
functionList := &v1alpha1.FunctionList{}
if err := r.List(ctx, functionList); err != nil {
logger.Error(err, "Failed to list Functions for ConfigMap watch")
return []reconcile.Request{}
}

requests := make([]reconcile.Request, 0, len(functionList.Items))
for _, function := range functionList.Items {
// Only enqueue Functions that rely on the operator default
// (i.e., those without an explicit autoUpdateMiddleware setting)
if function.Spec.AutoUpdateMiddleware == nil {
requests = append(requests, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: function.Name,
Namespace: function.Namespace,
},
})
}
}

logger.Info("Enqueueing Functions for reconciliation due to ConfigMap change", "count", len(requests))
return requests
}

func (r *FunctionReconciler) isMiddlewareLatest(ctx context.Context, metadata *funcfn.Function, namespace string) (bool, error) {
latestMiddleware, err := r.FuncCliManager.GetLatestMiddlewareVersion(ctx, metadata.Runtime, metadata.Invoke)
if err != nil {
Expand All @@ -491,3 +561,35 @@ func (r *FunctionReconciler) isMiddlewareLatest(ctx context.Context, metadata *f

return latestMiddleware == functionMiddleware, nil
}

// isMiddlewareUpdateEnabled returns if the middleware should be updated given by the functions spec or the operators
// default.
func (r *FunctionReconciler) isMiddlewareUpdateEnabled(ctx context.Context, function *v1alpha1.Function) (bool, string, error) {
logger := log.FromContext(ctx)

// setting from function overrides operator default
if function.Spec.AutoUpdateMiddleware != nil {
return *function.Spec.AutoUpdateMiddleware, "function", nil
}

// nothing defined in function spec --> check operator config
cm := &v1.ConfigMap{}
err := r.Get(ctx, types.NamespacedName{Namespace: r.OperatorNamespace, Name: controllerConfigName}, cm)
if err != nil {
return false, "", fmt.Errorf("failed to get operator config configmap: %w", err)
}

val, ok := cm.Data["autoUpdateMiddleware"]
if !ok {
logger.Info("No autoUpdateMiddleware field in configmap found. Fallback to hardcoded autoUpdateMiddleware=true")
// TODO: check if returning an error would be better here
return true, "operator", nil
}

boolVal, err := strconv.ParseBool(val)
if err != nil {
return false, "", fmt.Errorf("failed to parse autoUpdateMiddleware value from configmap: %w", err)
}

return boolVal, "operator", nil
}
Loading
Loading