Repository: crossplane/crossplane

Issue:
Unable to build composition function using `--embed-runtime-image`
<!--
Thank you for helping to improve Crossplane!

Please be sure to search for open issues before raising a new one. We use issues
for bug reports and feature requests. Please find us at https://slack.crossplane.io
for questions, support, and discussion.
-->

### What happened?
<!--
Please let us know what behaviour you expected and how Crossplane diverged from
that behaviour.
-->

I tried to follow https://docs.crossplane.io/v1.20/guides/write-a-composition-function-in-go/#build-and-push-the-function-to-a-package-registry:

```console
$ docker build . --quiet --platform=linux/amd64 --tag runtime-amd64

sha256:de1cd5f8e6f500a688e3389a7889d1d03a4b3e8ca9c10ab06d50a601159390f0
$ docker build . --quiet --platform=linux/arm64 --tag runtime-arm64

sha256:e1e500c9e83ad5c1d74712152a2e46c7442046f31ca2a322718c1e20a0ffa1ef
$ crossplane xpkg build \
    --package-root=package \
    --embed-runtime-image=runtime-amd64 \
    --package-file=function-amd64.xpkg

crossplane: error: failed to get runtime base image options: failed to parse runtime image reference: could not parse reference: runtime-amd64
```


### How can we reproduce it?
<!--
Help us to reproduce your bug as succinctly and precisely as possible. Artifacts
such as example manifests or a script that triggers the issue are highly
appreciated!
-->

### What environment did it happen in?
Crossplane version: 

<!--
Include at least the version or commit of Crossplane you were running. Consider
also including your:

* Cloud provider or hardware configuration
* Kubernetes version (use `kubectl version`)
* Kubernetes distribution (e.g. Tectonic, GKE, OpenShift)
* OS (e.g. from /etc/os-release)
* Kernel (e.g. `uname -a`)
-->

```
$ crossplane version
Client Version: v2.0.0-rc.0.232.gc36311690
```


Hints from issue comments:
@negz: It's because we added the strict validation flag to where we parse the ref. This works:

```
$ docker build . --quiet --platform=linux/arm64 --tag index.docker.io/negz/runtime-arm64:local
sha256:e1e500c9e83ad5c1d74712152a2e46c7442046f31ca2a322718c1e20a0ffa1ef

$ crossplane xpkg build \
    --package-root=package \
    --embed-runtime-image=index.docker.io/negz/runtime-arm64:local \
    --package-file=function-arm64.xpkg
```

I think we should drop strict validation there - generally you're not going to push the runtime image anywhere.

Solution patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ebac64765..607f8152d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -257,6 +257,10 @@ jobs:
          - test-area: pkg
            test-suite: package-signature-verification
 
+        # Operations feature flags
+         - test-area: ops
+           test-suite: ops 
+
     steps:
       - name: Checkout
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
diff --git a/cmd/crank/xpkg/build.go b/cmd/crank/xpkg/build.go
index 364d58b9e..e6e16dc2f 100644
--- a/cmd/crank/xpkg/build.go
+++ b/cmd/crank/xpkg/build.go
@@ -135,7 +135,11 @@ func (c *buildCmd) GetRuntimeBaseImageOpts() ([]xpkg.BuildOpt, error) {
 
 		return []xpkg.BuildOpt{xpkg.WithBase(img)}, nil
 	case c.EmbedRuntimeImage != "":
-		ref, err := name.ParseReference(c.EmbedRuntimeImage, name.StrictValidation)
+		// We intentionally don't use strict validation here. Usually
+		// we'll tag the runtime image as something like
+		// 'runtime-amd64', and never actually push it to an OCI
+		// registry. That tag wouldn't pass strict validation.
+		ref, err := name.ParseReference(c.EmbedRuntimeImage)
 		if err != nil {
 			return nil, errors.Wrap(err, errParseRuntimeImageRef)
 		}
diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go
index 459a5d55f..0c8f22e4e 100644
--- a/cmd/crossplane/main.go
+++ b/cmd/crossplane/main.go
@@ -86,12 +86,6 @@ func main() {
 	zl := zap.New().WithName("crossplane")
 	logging.SetFilteredKlogLogger(zl)
 
-	// Setting the controller-runtime logger to a no-op logger by default,
-	// unless debug mode is enabled. This is because the controller-runtime
-	// logger is *very* verbose even at info level. This is not really needed,
-	// but otherwise we get a warning from the controller-runtime.
-	ctrl.SetLogger(zap.New(zap.WriteTo(io.Discard)))
-
 	// Note that the controller managers scheme must be a superset of the
 	// package manager's object scheme; it must contain all object types that
 	// may appear in a Crossplane package. This is because the package manager
@@ -99,13 +93,24 @@ func main() {
 	// objects.
 	s := runtime.NewScheme()
 
-	ctx := kong.Parse(&cli{},
+	c := &cli{}
+
+	ctx := kong.Parse(c,
 		kong.Name("crossplane"),
 		kong.Description("An open source multicloud control plane."),
 		kong.BindTo(logging.NewLogrLogger(zl), (*logging.Logger)(nil)),
 		kong.UsageOnError(),
 		core.KongVars,
 	)
+
+	if !c.Debug {
+		// Setting the controller-runtime logger to a no-op logger by default,
+		// unless debug mode is enabled. This is because the controller-runtime
+		// logger is *very* verbose even at info level. This is not really needed,
+		// but otherwise we get a warning from the controller-runtime.
+		ctrl.SetLogger(zap.New(zap.WriteTo(io.Discard)))
+	}
+
 	ctx.FatalIfErrorf(corev1.AddToScheme(s), "cannot add core v1 Kubernetes API types to scheme")
 	ctx.FatalIfErrorf(appsv1.AddToScheme(s), "cannot add apps v1 Kubernetes API types to scheme")
 	ctx.FatalIfErrorf(rbacv1.AddToScheme(s), "cannot add rbac v1 Kubernetes API types to scheme")
diff --git a/internal/controller/ops/operation/constructor.go b/internal/controller/ops/operation/constructor.go
index 385f322ce..474d417c8 100644
--- a/internal/controller/ops/operation/constructor.go
+++ b/internal/controller/ops/operation/constructor.go
@@ -32,7 +32,6 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/ratelimiter"
 
 	"github.com/crossplane/crossplane/apis/ops/v1alpha1"
-	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
 	opscontroller "github.com/crossplane/crossplane/internal/controller/ops/controller"
 	"github.com/crossplane/crossplane/internal/xfn"
 )
@@ -50,7 +49,6 @@ func Setup(mgr ctrl.Manager, o opscontroller.Options) error {
 	return ctrl.NewControllerManagedBy(mgr).
 		Named(name).
 		For(&v1alpha1.Operation{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
-		Watches(&pkgv1.FunctionRevision{}, EnqueueOperationsForFunctionRevision(mgr.GetClient(), o.Logger)).
 		WithOptions(o.ForControllerRuntime()).
 		Complete(ratelimiter.NewReconciler(name, errors.WithSilentRequeueOnConflict(r), o.GlobalRateLimiter))
 }
diff --git a/internal/controller/ops/operation/reconciler.go b/internal/controller/ops/operation/reconciler.go
index 0b8e22d7d..a49c87912 100644
--- a/internal/controller/ops/operation/reconciler.go
+++ b/internal/controller/ops/operation/reconciler.go
@@ -139,9 +139,14 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 		names = append(names, fn.FunctionRef.Name)
 	}
 
-	// This'll usually be a case that needs human intervention to fix, but
-	// it could be that a newly created function hasn't written its
-	// capabilities to status yet, so we do retry.
+	// This could need human intervention to fix. It could also be a new
+	// function that hasn't written its capabilities to its FunctionRevision
+	// status yet, so we retry.
+	//
+	// We don't watch FunctionRevisions because the watch would trigger
+	// instant reconciles whenever the FunctionRevisions change. We always
+	// want to retry Operations with a predictable exponential backoff, so
+	// we just return an error and let controller-runtime requeue us.
 	if err := r.functions.CheckCapabilities(ctx, []string{pkgmetav1.FunctionCapabilityOperation}, names...); err != nil {
 		op.Status.Failures++
 
diff --git a/internal/controller/ops/operation/watch.go b/internal/controller/ops/operation/watch.go
deleted file mode 100644
index ff40b285e..000000000
--- a/internal/controller/ops/operation/watch.go
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
-Copyright 2025 The Crossplane Authors.
-
-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 operation
-
-import (
-	"context"
-
-	"k8s.io/apimachinery/pkg/types"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-	"sigs.k8s.io/controller-runtime/pkg/handler"
-	"sigs.k8s.io/controller-runtime/pkg/reconcile"
-
-	"github.com/crossplane/crossplane-runtime/pkg/logging"
-
-	"github.com/crossplane/crossplane/apis/ops/v1alpha1"
-	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
-)
-
-// EnqueueOperationsForFunctionRevision enqueues a reconcile for all Operations
-// that reference a Function when a FunctionRevision changes.
-func EnqueueOperationsForFunctionRevision(kube client.Reader, log logging.Logger) handler.EventHandler {
-	return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
-		fr, ok := o.(*pkgv1.FunctionRevision)
-		if !ok {
-			return nil
-		}
-
-		// Get the function name from the revision's parent package label
-		name := fr.GetLabels()[pkgv1.LabelParentPackage]
-		if name == "" {
-			log.Debug("FunctionRevision has no parent package label", "revision", fr.GetName())
-			return nil
-		}
-
-		// List all Operations to find those that reference this function
-		ops := &v1alpha1.OperationList{}
-		if err := kube.List(ctx, ops); err != nil {
-			log.Debug("Cannot list Operations while attempting to enqueue from FunctionRevision", "error", err)
-			return nil
-		}
-
-		var matches []reconcile.Request
-		for _, op := range ops.Items {
-			for _, fn := range op.Spec.Pipeline {
-				if fn.FunctionRef.Name != name {
-					continue
-				}
-
-				log.Debug("Enqueuing Operation for FunctionRevision change",
-					"operation", op.GetName(),
-					"function-revision", fr.GetName(),
-					"function", name)
-				matches = append(matches, reconcile.Request{NamespacedName: types.NamespacedName{
-					Namespace: op.GetNamespace(),
-					Name:      op.GetName(),
-				}})
-
-				// Functions are unique within the pipeline.
-				break
-			}
-		}
-
-		return matches
-	})
-}



Test patch:
diff --git a/cmd/crank/render/load_test.go b/cmd/crank/render/load_test.go
index c8989b5b7..76b1e4df7 100644
--- a/cmd/crank/render/load_test.go
+++ b/cmd/crank/render/load_test.go
@@ -274,7 +274,7 @@ func TestLoadFunctions(t *testing.T) {
 						},
 						Spec: pkgv1.FunctionSpec{
 							PackageSpec: pkgv1.PackageSpec{
-								Package: "xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0",
+								Package: "xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1",
 							},
 						},
 					},
diff --git a/cmd/crank/render/testdata/functions.yaml b/cmd/crank/render/testdata/functions.yaml
index 28e444936..08d601ba4 100644
--- a/cmd/crank/render/testdata/functions.yaml
+++ b/cmd/crank/render/testdata/functions.yaml
@@ -16,7 +16,7 @@ metadata:
     render.crossplane.io/runtime: Development
     render.crossplane.io/runtime-development-target: localhost:9444
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
 ---
 apiVersion: pkg.crossplane.io/v1
 kind: Function
diff --git a/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml
index 2dbbba638..c93f023f1 100644
--- a/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml
@@ -5,4 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-basic
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
diff --git a/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml
index 2dbbba638..c93f023f1 100644
--- a/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml
@@ -5,4 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-basic
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
diff --git a/test/e2e/manifests/apiextensions/composition/composition-selection/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/composition-selection/setup/functions.yaml
index 7dff67686..e26358836 100644
--- a/test/e2e/manifests/apiextensions/composition/composition-selection/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/composition-selection/setup/functions.yaml
@@ -5,4 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-selection
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
diff --git a/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/functions.yaml
index 0cd22d563..10371ab81 100644
--- a/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/functions.yaml
@@ -5,4 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-bind-existing-xr
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
diff --git a/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/functions.yaml
index a5df54c7e..8d214e0b8 100644
--- a/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/functions.yaml
@@ -6,4 +6,4 @@ metadata:
   name: function-dummy-ext-removals
   name: function-dummy-ext-removals
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
diff --git a/test/e2e/manifests/apiextensions/composition/namespaced-xr-no-cluster-scoped-resource/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/namespaced-xr-no-cluster-scoped-resource/setup/functions.yaml
index 578ea9ee6..19b258ad3 100644
--- a/test/e2e/manifests/apiextensions/composition/namespaced-xr-no-cluster-scoped-resource/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/namespaced-xr-no-cluster-scoped-resource/setup/functions.yaml
@@ -4,4 +4,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-basic
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
diff --git a/test/e2e/manifests/apiextensions/composition/realtime-revision-selection/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/realtime-revision-selection/setup/functions.yaml
index e92369e75..be2d5933e 100644
--- a/test/e2e/manifests/apiextensions/composition/realtime-revision-selection/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/realtime-revision-selection/setup/functions.yaml
@@ -5,4 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-realtime-revision
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
diff --git a/test/e2e/manifests/apiextensions/usage/composition/setup/functions.yaml b/test/e2e/manifests/apiextensions/usage/composition/setup/functions.yaml
index 6126d5570..c6d3e5dd1 100644
--- a/test/e2e/manifests/apiextensions/usage/composition/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/usage/composition/setup/functions.yaml
@@ -5,7 +5,7 @@ kind: Function
 metadata:
   name: function-dummy-apiextensions-usage
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
 ---
 apiVersion: pkg.crossplane.io/v1
 kind: Function
diff --git a/test/e2e/manifests/lifecycle/upgrade/setup/functions.yaml b/test/e2e/manifests/lifecycle/upgrade/setup/functions.yaml
index 4e276cfe2..cf7cf7746 100644
--- a/test/e2e/manifests/lifecycle/upgrade/setup/functions.yaml
+++ b/test/e2e/manifests/lifecycle/upgrade/setup/functions.yaml
@@ -5,7 +5,7 @@ kind: Function
 metadata:
   name: function-dummy-lifecycle-upgrade
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
 ---
 apiVersion: pkg.crossplane.io/v1
 kind: Function
diff --git a/test/e2e/manifests/ops/operations/basic/setup/functions.yaml b/test/e2e/manifests/ops/operations/basic/setup/functions.yaml
index 93fb1b0fa..b40a39ccb 100644
--- a/test/e2e/manifests/ops/operations/basic/setup/functions.yaml
+++ b/test/e2e/manifests/ops/operations/basic/setup/functions.yaml
@@ -5,4 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ops-basic
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
diff --git a/test/e2e/manifests/pkg/deployment-runtime-config/setup/functions.yaml b/test/e2e/manifests/pkg/deployment-runtime-config/setup/functions.yaml
index d41dda938..70cb58c3d 100644
--- a/test/e2e/manifests/pkg/deployment-runtime-config/setup/functions.yaml
+++ b/test/e2e/manifests/pkg/deployment-runtime-config/setup/functions.yaml
@@ -4,7 +4,7 @@ kind: Function
 metadata:
   name: function-dummy-pkg-drc
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
   runtimeConfigRef:
     name: function-dummy-rc
 
diff --git a/test/e2e/manifests/pkg/externally-managed-service-account/setup/functions.yaml b/test/e2e/manifests/pkg/externally-managed-service-account/setup/functions.yaml
index 247d70244..5639e1f96 100644
--- a/test/e2e/manifests/pkg/externally-managed-service-account/setup/functions.yaml
+++ b/test/e2e/manifests/pkg/externally-managed-service-account/setup/functions.yaml
@@ -4,5 +4,5 @@ kind: Function
 metadata:
   name: function-dummy-pkg-external-sa
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1
 
diff --git a/test/e2e/ops_operations_test.go b/test/e2e/ops_operations_test.go
index 0634d77fc..ed2fc026e 100644
--- a/test/e2e/ops_operations_test.go
+++ b/test/e2e/ops_operations_test.go
@@ -67,7 +67,11 @@ func TestBasicOperation(t *testing.T) {
 				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "operation.yaml"),
 			)).
 			Assess("OperationSucceeded", funcs.AllOf(
-				funcs.ResourcesHaveConditionWithin(30*time.Second, manifests, "operation.yaml", v1alpha1.Complete()),
+				// This takes a little longer because it needs
+				// the FunctionRevision above to become ready,
+				// but it strictly retries with exponential
+				// backoff. It doesn't watch FunctionRevisions.
+				funcs.ResourcesHaveConditionWithin(60*time.Second, manifests, "operation.yaml", v1alpha1.Complete()),
 				funcs.ResourcesHaveFieldValueWithin(30*time.Second, manifests, "operation.yaml", "status.appliedResourceRefs[0].name", "cool-map"),
 				funcs.ResourceHasFieldValueWithin(30*time.Second, cm, "data[coolData]", "I'm cool!"),
 				// TODO(negz): Test function output when we have