From 96a4395c3d7c0b589c6baa53c801133bba106ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Wed, 29 Apr 2026 13:48:42 +0200 Subject: [PATCH 01/13] Add func-operator integration to deploy command When deploying a function, automatically create or update a Function CR (functions.dev/v1alpha1) if the func-operator CRD is installed on the cluster. On first deploy, the CR is created with the git repository URL, branch, and path. On subsequent deploys, only the functions.knative.dev/last-deployed annotation is updated. - Add pkg/git/ with go-git based resolution of remote URL and branch - Add pkg/operator/ with Function CR sync logic and k8s client setup - Add WithPostDeploy client option to run post-deploy hooks - Add --manage flag (default true) to opt out with --manage=false - Wire operator sync via WithPostDeploy in deploy command - Git URL cascade: --git-url > tracking remote > origin - Branch cascade: --git-branch > current branch > "main" - Silently skip if CRD not installed; warn if no git remote found --- cmd/deploy.go | 63 +++++++++- docs/reference/func_deploy.md | 1 + go.mod | 50 ++++---- go.sum | 119 +++++++++--------- pkg/functions/client.go | 14 +++ pkg/git/resolver.go | 82 ++++++++++++ pkg/git/resolver_test.go | 179 ++++++++++++++++++++++++++ pkg/operator/sync.go | 135 ++++++++++++++++++++ pkg/operator/sync_test.go | 228 ++++++++++++++++++++++++++++++++++ 9 files changed, 786 insertions(+), 85 deletions(-) create mode 100644 pkg/git/resolver.go create mode 100644 pkg/git/resolver_test.go create mode 100644 pkg/operator/sync.go create mode 100644 pkg/operator/sync_test.go diff --git a/cmd/deploy.go b/cmd/deploy.go index f7d7284647..472b9004a0 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -1,9 +1,11 @@ package cmd import ( + "context" "errors" "fmt" "io" + "os" "strconv" "strings" @@ -17,9 +19,11 @@ import ( "knative.dev/func/pkg/builders" "knative.dev/func/pkg/config" fn "knative.dev/func/pkg/functions" + funcgit "knative.dev/func/pkg/git" "knative.dev/func/pkg/k8s" "knative.dev/func/pkg/keda" "knative.dev/func/pkg/knative" + "knative.dev/func/pkg/operator" "knative.dev/func/pkg/utils" ) @@ -131,10 +135,10 @@ EXAMPLES SuggestFor: []string{"delpoy", "deplyo"}, PreRunE: bindEnv("build", "build-timestamp", "builder", "builder-image", "base-image", "confirm", "domain", "env", "git-branch", "git-dir", - "git-url", "image", "image-pull-secret", "namespace", "path", "platform", - "push", "pvc-size", "service-account", "deployer", "registry", - "registry-insecure", "registry-authfile", "remote", "username", "password", - "token", "verbose", "remote-storage-class"), + "git-url", "image", "image-pull-secret", "manage", "namespace", "path", "platform", "push", "pvc-size", + "service-account", "deployer", "registry", "registry-insecure", + "registry-authfile", "remote", "username", "password", "token", "verbose", + "remote-storage-class"), RunE: func(cmd *cobra.Command, args []string) error { return runDeploy(cmd, newClient) }, @@ -216,6 +220,8 @@ EXAMPLES cmd.Flags().StringP("token", "", "", "Token to use when pushing to the registry. ($FUNC_TOKEN)") cmd.Flags().BoolP("build-timestamp", "", false, "Use the actual time as the created time for the docker image. This is only useful for buildpacks builder.") + cmd.Flags().Bool("manage", true, + "Create/update a Function CR for operator management if the func-operator is installed ($FUNC_MANAGE)") cmd.Flags().StringP("namespace", "n", defaultNamespace(f, false), "Deploy into a specific namespace. Will use the function's current namespace by default if already deployed, and the currently active context if it can be determined. ($FUNC_NAMESPACE)") @@ -555,6 +561,10 @@ type deployConfig struct { // Timestamp the built container with the current date and time. // This is currently only supported by the Pack builder. Timestamp bool + + // Manage indicates whether to create/update a Function CR for the + // func-operator after deployment. + Manage bool } // newDeployConfig creates a buildConfig populated from command flags and @@ -576,6 +586,7 @@ func newDeployConfig(cmd *cobra.Command) deployConfig { ServiceAccountName: viper.GetString("service-account"), ImagePullSecret: viper.GetString("image-pull-secret"), Deployer: viper.GetString("deployer"), + Manage: viper.GetBool("manage"), } // NOTE: .Env should be viper.GetStringSlice, but this returns unparsed // results and appears to be an open issue since 2017: @@ -814,6 +825,15 @@ func (c deployConfig) clientOptions() ([]fn.Option, error) { return o, fmt.Errorf("unsupported deploy type: %s (supported: %s, %s, %s)", deployer, knative.KnativeDeployerName, k8s.KubernetesDeployerName, keda.KedaDeployerName) } + if c.Manage { + o = append(o, fn.WithPostDeploy(func(ctx context.Context, f fn.Function) error { + if err := syncFunctionCR(ctx, f, c); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to sync Function CR: %v\n", err) + } + return nil + })) + } + return o, nil } @@ -897,3 +917,38 @@ func isDigested(v string) (validDigest bool, err error) { _, ok := ref.(name.Digest) return ok, nil } + +func syncFunctionCR(ctx context.Context, f fn.Function, cfg deployConfig) error { + repoURL := cfg.GitURL + repoBranch := cfg.GitBranch + repoPath := cfg.GitDir + + if repoURL == "" { + var err error + repoURL, err = funcgit.ResolveRemoteURL(f.Root) + if err != nil { + return err + } + } + + if repoBranch == "" { + repoBranch = funcgit.ResolveBranch(f.Root) + } + + if repoPath == "" { + repoPath = "." + } + + namespace := f.Deploy.Namespace + if namespace == "" { + namespace = f.Namespace + } + + return operator.SyncFunctionCR(ctx, operator.SyncConfig{ + FunctionName: f.Name, + Namespace: namespace, + RepoURL: repoURL, + RepoBranch: repoBranch, + RepoPath: repoPath, + }) +} diff --git a/docs/reference/func_deploy.md b/docs/reference/func_deploy.md index 630542c342..5fbb6a7d3b 100644 --- a/docs/reference/func_deploy.md +++ b/docs/reference/func_deploy.md @@ -128,6 +128,7 @@ func deploy -h, --help help for deploy -i, --image string Full image name in the form [registry]/[namespace]/[name]:[tag]@[digest]. This option takes precedence over --registry. Specifying digest is optional, but if it is given, 'build' and 'push' phases are disabled. ($FUNC_IMAGE) --image-pull-secret string Image pull secret to use when the function's image is in a private registry ($FUNC_IMAGE_PULL_SECRET) + --manage Create/update a Function CR for operator management if the func-operator is installed ($FUNC_MANAGE) (default true) -n, --namespace string Deploy into a specific namespace. Will use the function's current namespace by default if already deployed, and the currently active context if it can be determined. ($FUNC_NAMESPACE) (default "default") --password string Password to use when pushing to the registry. ($FUNC_PASSWORD) -p, --path string Path to the function. Default is current directory ($FUNC_PATH) diff --git a/go.mod b/go.mod index b3100d561a..268f1dee61 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module knative.dev/func -go 1.25.7 +go 1.26 // this is required bacause of bad dep in github.com/openshift-pipelines/pipelines-as-code replace github.com/imdario/mergo => dario.cat/mergo v1.0.1 @@ -24,6 +24,7 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/docker/docker-credential-helpers v0.9.5 github.com/docker/go-connections v0.7.0 + github.com/functions-dev/func-operator v0.2.1 github.com/go-git/go-billy/v5 v5.8.0 github.com/go-git/go-git/v5 v5.18.0 github.com/google/go-cmp v0.7.0 @@ -67,12 +68,13 @@ require ( k8s.io/apimachinery v0.35.4 k8s.io/client-go v0.35.4 k8s.io/klog/v2 v2.140.0 - k8s.io/utils v0.0.0-20260108192941-914a6e750570 + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 knative.dev/client/pkg v0.0.0-20260508023525-0b520ca5254e knative.dev/eventing v0.49.1-0.20260506130425-5a46719f32de knative.dev/hack v0.0.0-20260428014158-b2a37f1b6e7b knative.dev/pkg v0.0.0-20260507212125-df317a52d112 knative.dev/serving v0.49.1-0.20260508141527-06f2ba70f0cc + sigs.k8s.io/controller-runtime v0.23.3 ) require ( @@ -92,7 +94,7 @@ require ( github.com/Azure/go-autorest/tracing v0.6.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect - github.com/ProtonMail/go-crypto v1.4.0 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -157,20 +159,20 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.4 // indirect - github.com/go-openapi/swag v0.25.4 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/swag v0.25.5 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect @@ -200,8 +202,9 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -241,7 +244,7 @@ require ( github.com/opencontainers/selinux v1.13.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect - github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect @@ -260,7 +263,7 @@ require ( github.com/segmentio/encoding v0.5.3 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/skeema/knownhosts v1.3.1 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect @@ -299,7 +302,7 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect @@ -314,14 +317,13 @@ require ( k8s.io/apiextensions-apiserver v0.35.4 // indirect k8s.io/apiserver v0.35.4 // indirect k8s.io/cli-runtime v0.34.1 // indirect - k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect knative.dev/networking v0.0.0-20260506015723-9b427f7c8091 // indirect - sigs.k8s.io/controller-runtime v0.22.1 // indirect sigs.k8s.io/gateway-api v1.4.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kustomize/api v0.21.0 // indirect sigs.k8s.io/kustomize/kyaml v0.21.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 2d1801c4f1..24a00f06e3 100644 --- a/go.sum +++ b/go.sum @@ -110,8 +110,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDe github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= -github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= @@ -365,6 +365,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/functions-dev/func-operator v0.2.1 h1:1KSMeRCG33uHj4+twhMDuVndV1aKESnrgxTywuWlx5M= +github.com/functions-dev/func-operator v0.2.1/go.mod h1:/vYXQKXG1+SXTQ8wzwLoC6c+OtAEOED2mEkrYtA0yN4= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= @@ -418,44 +420,44 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/jsonreference v0.20.1/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= -github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= -github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= -github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= -github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= -github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= -github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= -github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= -github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= -github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= -github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= -github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= -github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= -github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= -github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= -github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= @@ -579,8 +581,8 @@ github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= @@ -691,14 +693,16 @@ github.com/kedacore/http-add-on v0.12.0 h1:81Us4SmQfhwYfZf5rO/Thg/XC4GELVMjzvnP2 github.com/kedacore/http-add-on v0.12.0/go.mod h1:7JPTYBVR1Oo3VEMXJtKAXhsISb7mzv/H6a92hvB28jI= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -845,8 +849,8 @@ github.com/onsi/ginkgo/v2 v2.9.0/go.mod h1:4xkjoL/tZv4SMWeww56BU5kAt19mVB47gTWxm github.com/onsi/ginkgo/v2 v2.9.1/go.mod h1:FEcmzVcCHl+4o9bQZVab+4dC9+j+91t2FHSzmGAPfuo= github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= -github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= -github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= @@ -891,8 +895,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= -github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= +github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -1008,8 +1012,8 @@ github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= -github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= +github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= @@ -1047,8 +1051,9 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -1257,8 +1262,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -1861,13 +1866,13 @@ k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kms v0.27.7/go.mod h1:JspOc8g6+cDlZfgW5GqnHS+OV6tAVyg4iXytCrqfNPw= k8s.io/kube-openapi v0.0.0-20211109043538-20434351676c/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw= k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f/go.mod h1:byini6yhqGC14c3ebc/QwanvYwhuMWF6yz2F8uwW8eg= -k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= -k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20230209194617-a36077c30491/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= -k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= knative.dev/client/pkg v0.0.0-20260508023525-0b520ca5254e h1:j5Trs7dHRw9OGIlRTZHoi9qizdFwxMdAPXInVbojY3o= knative.dev/client/pkg v0.0.0-20260508023525-0b520ca5254e/go.mod h1:9706uEBfrZ0iFPWM6iCf8dfZmskKSLcmrjop+nazr/8= knative.dev/eventing v0.49.1-0.20260506130425-5a46719f32de h1:IMqOe0mpAq3hsLsO4vJ5QhT/a6A+sO498mQZ8YiUrxw= @@ -1887,8 +1892,8 @@ rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.1.2/go.mod h1:+qG7ISXqCDVVcyO8hLn12AKVYYUjM7ftlqsqmrhMZE0= sigs.k8s.io/controller-runtime v0.15.3/go.mod h1:kp4jckA4vTx281S/0Yk2LFEEQe67mjg+ev/yknv47Ds= -sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg= -sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/gateway-api v1.4.1 h1:NPxFutNkKNa8UfLd2CMlEuhIPMQgDQ6DXNKG9sHbJU8= sigs.k8s.io/gateway-api v1.4.1/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= @@ -1903,8 +1908,8 @@ sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxO sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4= sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/pkg/functions/client.go b/pkg/functions/client.go index 54b9c1d115..fb00e16485 100644 --- a/pkg/functions/client.go +++ b/pkg/functions/client.go @@ -82,6 +82,7 @@ type Client struct { pipelinesProvider PipelinesProvider // CI/CD pipelines management mcpServer MCPServer // MCP Server startTimeout time.Duration // default start timeout for all runs + postDeploy func(context.Context, Function) error } // Scaffolder wraps a function with a service scaffolding (entrypoint) @@ -311,6 +312,13 @@ func WithDeployer(d Deployer) Option { } } +// WithPostDeploy sets a handler that is called after a successful deploy. +func WithPostDeploy(handler func(context.Context, Function) error) Option { + return func(c *Client) { + c.postDeploy = handler + } +} + // WithRunner provides the concrete implementation of a deployer. func WithRunner(r Runner) Option { return func(c *Client) { @@ -884,6 +892,12 @@ func (c *Client) Deploy(ctx context.Context, f Function, oo ...DeployOption) (Fu default: } + if c.postDeploy != nil { + if err := c.postDeploy(ctx, f); err != nil { + return f, fmt.Errorf("post-deploy: %w", err) + } + } + return f, nil } diff --git a/pkg/git/resolver.go b/pkg/git/resolver.go new file mode 100644 index 0000000000..b0cfffb2d5 --- /dev/null +++ b/pkg/git/resolver.go @@ -0,0 +1,82 @@ +package git + +import ( + gogit "github.com/go-git/go-git/v5" +) + +// ResolveRemoteURL detects the git remote URL for the repository at the given +// path. It prefers the current branch's tracking remote, falling back to +// "origin". Returns an empty string if no remote can be determined (not a git +// repo, no remotes configured, etc.). The URL is returned as-is (SSH or HTTPS). +func ResolveRemoteURL(repoPath string) (string, error) { + repo, err := gogit.PlainOpenWithOptions(repoPath, &gogit.PlainOpenOptions{ + DetectDotGit: true, + }) + if err != nil { + return "", nil + } + + remoteName := trackingRemoteName(repo) + if remoteName == "" { + remoteName = "origin" + } + + remote, err := repo.Remote(remoteName) + if err != nil { + return "", nil + } + + urls := remote.Config().URLs + if len(urls) == 0 { + return "", nil + } + return urls[0], nil +} + +func trackingRemoteName(repo *gogit.Repository) string { + head, err := repo.Head() + if err != nil { + return "" + } + + if !head.Name().IsBranch() { + return "" + } + + branchName := head.Name().Short() + + cfg, err := repo.Config() + if err != nil { + return "" + } + + branch, ok := cfg.Branches[branchName] + if !ok { + return "" + } + + return branch.Remote +} + +// ResolveBranch returns the current branch name for the repository at the given +// path. Returns "main" if the branch cannot be determined (not a git repo, +// detached HEAD, etc.). +func ResolveBranch(repoPath string) string { + repo, err := gogit.PlainOpenWithOptions(repoPath, &gogit.PlainOpenOptions{ + DetectDotGit: true, + }) + if err != nil { + return "main" + } + + head, err := repo.Head() + if err != nil { + return "main" + } + + if !head.Name().IsBranch() { + return "main" + } + + return head.Name().Short() +} diff --git a/pkg/git/resolver_test.go b/pkg/git/resolver_test.go new file mode 100644 index 0000000000..8bd96ae70e --- /dev/null +++ b/pkg/git/resolver_test.go @@ -0,0 +1,179 @@ +package git + +import ( + "os" + "testing" + + gogit "github.com/go-git/go-git/v5" + gogitconfig "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" +) + +func TestResolveRemoteURL_OriginRemote(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + + _, err = repo.CreateRemote(&gogitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"https://github.com/alice/my-func.git"}, + }) + if err != nil { + t.Fatal(err) + } + + url, err := ResolveRemoteURL(dir) + if err != nil { + t.Fatal(err) + } + if url != "https://github.com/alice/my-func.git" { + t.Fatalf("expected origin URL, got %q", url) + } +} + +func TestResolveRemoteURL_TrackingRemote(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + + _, err = repo.CreateRemote(&gogitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"https://github.com/alice/my-func.git"}, + }) + if err != nil { + t.Fatal(err) + } + _, err = repo.CreateRemote(&gogitconfig.RemoteConfig{ + Name: "upstream", + URLs: []string{"https://github.com/upstream/my-func.git"}, + }) + if err != nil { + t.Fatal(err) + } + + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + f, err := os.Create(dir + "/README.md") + if err != nil { + t.Fatal(err) + } + f.Close() + if _, err = wt.Add("README.md"); err != nil { + t.Fatal(err) + } + if _, err = wt.Commit("initial", &gogit.CommitOptions{}); err != nil { + t.Fatal(err) + } + + cfg, _ := repo.Config() + cfg.Branches["master"] = &gogitconfig.Branch{ + Name: "master", + Remote: "upstream", + Merge: plumbing.ReferenceName("refs/heads/master"), + } + if err := repo.SetConfig(cfg); err != nil { + t.Fatal(err) + } + + url, err := ResolveRemoteURL(dir) + if err != nil { + t.Fatal(err) + } + if url != "https://github.com/upstream/my-func.git" { + t.Fatalf("expected tracking remote URL, got %q", url) + } +} + +func TestResolveRemoteURL_NoRemotes(t *testing.T) { + dir := t.TempDir() + if _, err := gogit.PlainInit(dir, false); err != nil { + t.Fatal(err) + } + + url, err := ResolveRemoteURL(dir) + if err != nil { + t.Fatal(err) + } + if url != "" { + t.Fatalf("expected empty URL, got %q", url) + } +} + +func TestResolveRemoteURL_NotAGitRepo(t *testing.T) { + dir := t.TempDir() + + url, err := ResolveRemoteURL(dir) + if err != nil { + t.Fatal(err) + } + if url != "" { + t.Fatalf("expected empty URL, got %q", url) + } +} + +func TestResolveRemoteURL_SSHPassThrough(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + + _, err = repo.CreateRemote(&gogitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"git@github.com:alice/my-func.git"}, + }) + if err != nil { + t.Fatal(err) + } + + url, err := ResolveRemoteURL(dir) + if err != nil { + t.Fatal(err) + } + if url != "git@github.com:alice/my-func.git" { + t.Fatalf("expected SSH URL passed through, got %q", url) + } +} + +func TestResolveBranch_CurrentBranch(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + f, err := os.Create(dir + "/README.md") + if err != nil { + t.Fatal(err) + } + f.Close() + if _, err = wt.Add("README.md"); err != nil { + t.Fatal(err) + } + if _, err = wt.Commit("initial", &gogit.CommitOptions{}); err != nil { + t.Fatal(err) + } + + branch := ResolveBranch(dir) + if branch != "master" { + t.Fatalf("expected 'master', got %q", branch) + } +} + +func TestResolveBranch_NotAGitRepo(t *testing.T) { + dir := t.TempDir() + branch := ResolveBranch(dir) + if branch != "main" { + t.Fatalf("expected default 'main', got %q", branch) + } +} diff --git a/pkg/operator/sync.go b/pkg/operator/sync.go new file mode 100644 index 0000000000..f961bc8f31 --- /dev/null +++ b/pkg/operator/sync.go @@ -0,0 +1,135 @@ +package operator + +import ( + "context" + "fmt" + "os" + "time" + + v1alpha1 "github.com/functions-dev/func-operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/discovery" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + + "knative.dev/func/pkg/k8s" +) + +// SyncConfig holds the parameters for creating/updating a Function CR. +type SyncConfig struct { + FunctionName string + Namespace string + RepoURL string + RepoBranch string + RepoPath string +} + +// SyncFunctionCR creates or updates a Function CR for the given function. +// It sets up Kubernetes clients, checks if the Function CRD exists on the +// cluster, and creates or updates the CR accordingly. +func SyncFunctionCR(ctx context.Context, cfg SyncConfig) error { + restCfg, err := k8s.GetClientConfig().ClientConfig() + if err != nil { + return fmt.Errorf("getting kubernetes config: %w", err) + } + + disc, err := discovery.NewDiscoveryClientForConfig(restCfg) + if err != nil { + return fmt.Errorf("creating discovery client: %w", err) + } + + scheme := runtime.NewScheme() + v1alpha1.AddToScheme(scheme) + + cl, err := ctrlclient.New(restCfg, ctrlclient.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("creating kubernetes client: %w", err) + } + + return syncFunctionCR(ctx, cl, disc, cfg) +} + +func syncFunctionCR(ctx context.Context, cl ctrlclient.Client, disc discovery.DiscoveryInterface, cfg SyncConfig) error { + hasCRD, err := hasFunctionCRD(disc) + if err != nil { + return fmt.Errorf("checking for Function CRD: %w", err) + } + if !hasCRD { + return nil + } + + if cfg.RepoURL == "" { + fmt.Fprintln(os.Stderr, "Function CR not created: no git remote found. Set --git-url to enable operator management.") + return nil + } + + existing, err := findExistingCR(ctx, cl, cfg.FunctionName, cfg.Namespace) + if err != nil { + return fmt.Errorf("looking up existing Function CR: %w", err) + } + + if existing != nil { + if existing.Annotations == nil { + existing.Annotations = map[string]string{} + } + existing.Annotations["functions.knative.dev/last-deployed"] = time.Now().UTC().Format(time.RFC3339) + if err := cl.Update(ctx, existing); err != nil { + return fmt.Errorf("updating Function CR: %w", err) + } + fmt.Fprintf(os.Stderr, "Function CR %q updated in namespace %q\n", existing.Name, cfg.Namespace) + return nil + } + + fn := &v1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: cfg.FunctionName, + Namespace: cfg.Namespace, + }, + Spec: v1alpha1.FunctionSpec{ + Repository: v1alpha1.FunctionSpecRepository{ + URL: cfg.RepoURL, + Branch: cfg.RepoBranch, + Path: cfg.RepoPath, + }, + }, + } + if err := cl.Create(ctx, fn); err != nil { + return fmt.Errorf("creating Function CR: %w", err) + } + fmt.Fprintf(os.Stderr, "Function CR %q created in namespace %q\n", cfg.FunctionName, cfg.Namespace) + return nil +} + +func hasFunctionCRD(disc discovery.DiscoveryInterface) (bool, error) { + resources, err := disc.ServerResourcesForGroupVersion("functions.dev/v1alpha1") + if err != nil { + return false, nil + } + for _, r := range resources.APIResources { + if r.Kind == "Function" { + return true, nil + } + } + return false, nil +} + +func findExistingCR(ctx context.Context, cl ctrlclient.Client, funcName, namespace string) (*v1alpha1.Function, error) { + var list v1alpha1.FunctionList + if err := cl.List(ctx, &list, ctrlclient.InNamespace(namespace)); err != nil { + return nil, err + } + + for i := range list.Items { + if list.Items[i].Status.Name == funcName { + return &list.Items[i], nil + } + } + + for i := range list.Items { + if list.Items[i].Name == funcName { + return &list.Items[i], nil + } + } + + return nil, nil +} diff --git a/pkg/operator/sync_test.go b/pkg/operator/sync_test.go new file mode 100644 index 0000000000..19d3a5455f --- /dev/null +++ b/pkg/operator/sync_test.go @@ -0,0 +1,228 @@ +package operator + +import ( + "context" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + fakediscovery "k8s.io/client-go/discovery/fake" + fakeclientset "k8s.io/client-go/kubernetes/fake" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1alpha1 "github.com/functions-dev/func-operator/api/v1alpha1" +) + +func newScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = v1alpha1.AddToScheme(s) + return s +} + +func fakeDiscoveryWithCRD() *fakediscovery.FakeDiscovery { + cs := fakeclientset.NewSimpleClientset() + fd := cs.Discovery().(*fakediscovery.FakeDiscovery) + fd.Resources = []*metav1.APIResourceList{ + { + GroupVersion: "functions.dev/v1alpha1", + APIResources: []metav1.APIResource{ + {Name: "functions", Kind: "Function"}, + }, + }, + } + return fd +} + +func fakeDiscoveryWithoutCRD() *fakediscovery.FakeDiscovery { + cs := fakeclientset.NewSimpleClientset() + return cs.Discovery().(*fakediscovery.FakeDiscovery) +} + +func TestSyncFunctionCR_CreateNew(t *testing.T) { + scheme := newScheme() + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + disc := fakeDiscoveryWithCRD() + + cfg := SyncConfig{ + FunctionName: "my-func", + Namespace: "default", + RepoURL: "https://github.com/alice/my-func.git", + RepoBranch: "main", + RepoPath: ".", + } + + err := syncFunctionCR(context.Background(), cl, disc, cfg) + if err != nil { + t.Fatal(err) + } + + var fn v1alpha1.Function + err = cl.Get(context.Background(), ctrlclient.ObjectKey{ + Name: "my-func", + Namespace: "default", + }, &fn) + if err != nil { + t.Fatalf("expected Function CR to be created: %v", err) + } + if fn.Spec.Repository.URL != "https://github.com/alice/my-func.git" { + t.Fatalf("expected repo URL, got %q", fn.Spec.Repository.URL) + } + if fn.Spec.Repository.Branch != "main" { + t.Fatalf("expected branch 'main', got %q", fn.Spec.Repository.Branch) + } + if fn.Spec.Repository.Path != "." { + t.Fatalf("expected path '.', got %q", fn.Spec.Repository.Path) + } +} + +func TestSyncFunctionCR_UpdateExistingByMetadataName(t *testing.T) { + scheme := newScheme() + existing := &v1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-func", + Namespace: "default", + }, + Spec: v1alpha1.FunctionSpec{ + Repository: v1alpha1.FunctionSpecRepository{ + URL: "https://github.com/old/repo.git", + Branch: "old-branch", + }, + }, + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build() + disc := fakeDiscoveryWithCRD() + + cfg := SyncConfig{ + FunctionName: "my-func", + Namespace: "default", + RepoURL: "https://github.com/alice/my-func.git", + RepoBranch: "main", + RepoPath: "subfolder", + } + + err := syncFunctionCR(context.Background(), cl, disc, cfg) + if err != nil { + t.Fatal(err) + } + + var fn v1alpha1.Function + err = cl.Get(context.Background(), ctrlclient.ObjectKey{ + Name: "my-func", + Namespace: "default", + }, &fn) + if err != nil { + t.Fatal(err) + } + ts, ok := fn.Annotations["functions.knative.dev/last-deployed"] + if !ok { + t.Fatal("expected last-deployed annotation to be set") + } + if _, err := time.Parse(time.RFC3339, ts); err != nil { + t.Fatalf("expected valid RFC3339 timestamp, got %q: %v", ts, err) + } + if fn.Spec.Repository.URL != "https://github.com/old/repo.git" { + t.Fatalf("expected spec to remain unchanged, but URL was %q", fn.Spec.Repository.URL) + } +} + +func TestSyncFunctionCR_UpdateExistingByStatusName(t *testing.T) { + scheme := newScheme() + existing := &v1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: "different-cr-name", + Namespace: "default", + }, + Spec: v1alpha1.FunctionSpec{ + Repository: v1alpha1.FunctionSpecRepository{ + URL: "https://github.com/old/repo.git", + }, + }, + Status: v1alpha1.FunctionStatus{ + Name: "my-func", + }, + } + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(existing). + WithStatusSubresource(existing). + Build() + disc := fakeDiscoveryWithCRD() + + cfg := SyncConfig{ + FunctionName: "my-func", + Namespace: "default", + RepoURL: "https://github.com/alice/my-func.git", + RepoBranch: "main", + RepoPath: ".", + } + + err := syncFunctionCR(context.Background(), cl, disc, cfg) + if err != nil { + t.Fatal(err) + } + + var fn v1alpha1.Function + err = cl.Get(context.Background(), ctrlclient.ObjectKey{ + Name: "different-cr-name", + Namespace: "default", + }, &fn) + if err != nil { + t.Fatal(err) + } + ts, ok := fn.Annotations["functions.knative.dev/last-deployed"] + if !ok { + t.Fatal("expected last-deployed annotation to be set") + } + if _, err := time.Parse(time.RFC3339, ts); err != nil { + t.Fatalf("expected valid RFC3339 timestamp, got %q: %v", ts, err) + } +} + +func TestSyncFunctionCR_NoCRD_SkipSilently(t *testing.T) { + scheme := newScheme() + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + disc := fakeDiscoveryWithoutCRD() + + cfg := SyncConfig{ + FunctionName: "my-func", + Namespace: "default", + RepoURL: "https://github.com/alice/my-func.git", + RepoBranch: "main", + RepoPath: ".", + } + + err := syncFunctionCR(context.Background(), cl, disc, cfg) + if err != nil { + t.Fatalf("expected no error when CRD missing, got: %v", err) + } +} + +func TestSyncFunctionCR_NoRepoURL_SkipsWithMessage(t *testing.T) { + scheme := newScheme() + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + disc := fakeDiscoveryWithCRD() + + cfg := SyncConfig{ + FunctionName: "my-func", + Namespace: "default", + RepoURL: "", + RepoBranch: "main", + RepoPath: ".", + } + + err := syncFunctionCR(context.Background(), cl, disc, cfg) + if err != nil { + t.Fatalf("expected no error when repo URL empty, got: %v", err) + } + + // Verify no CR was created + var list v1alpha1.FunctionList + if err := cl.List(context.Background(), &list); err != nil { + t.Fatal(err) + } + if len(list.Items) != 0 { + t.Fatalf("expected no Function CRs, got %d", len(list.Items)) + } +} From 94bd8190ba106ef1528a995fdb95542ad1a5b6be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Wed, 29 Apr 2026 15:34:19 +0200 Subject: [PATCH 02/13] Add integration test for operator sync across all deployers Add TestInt_OperatorSync to the shared deployer integration test helper. The test deploys a function with WithPostDeploy wired to operator.SyncFunctionCR, verifies the Function CR is created with the correct spec on first deploy, and confirms only the last-deployed annotation is updated on subsequent deploys. CR verification is skipped gracefully when the Function CRD is not installed. --- .../testing/integration_test_helper.go | 169 ++++++++++++++++++ pkg/k8s/deployer_int_test.go | 8 + pkg/keda/deployer_int_test.go | 8 + pkg/knative/deployer_int_test.go | 8 + 4 files changed, 193 insertions(+) diff --git a/pkg/deployer/testing/integration_test_helper.go b/pkg/deployer/testing/integration_test_helper.go index 7e694b5081..4a4a656dc5 100644 --- a/pkg/deployer/testing/integration_test_helper.go +++ b/pkg/deployer/testing/integration_test_helper.go @@ -13,17 +13,22 @@ import ( "testing" "time" + v1alpha1 "github.com/functions-dev/func-operator/api/v1alpha1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/rand" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/discovery" eventingv1 "knative.dev/eventing/pkg/apis/eventing/v1" "knative.dev/func/pkg/keda" "knative.dev/func/pkg/knative" "knative.dev/func/pkg/oci" + "knative.dev/func/pkg/operator" . "knative.dev/func/pkg/testing" . "knative.dev/func/pkg/testing/k8s" v1 "knative.dev/pkg/apis/duck/v1" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" fn "knative.dev/func/pkg/functions" "knative.dev/func/pkg/k8s" @@ -1196,6 +1201,170 @@ func (f *Function) Handle(w http.ResponseWriter, req *http.Request) { } ` +// TestInt_OperatorSync ensures that deploying with WithPostDeploy creates a +// Function CR on first deploy, and only annotates it on subsequent deploys. +func TestInt_OperatorSync(t *testing.T, deployer fn.Deployer, remover fn.Remover, describer fn.Describer, deployerName string) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*10) + name := "func-int-operator-sync-" + rand.String(5) + root := t.TempDir() + ns := Namespace(t, ctx) + + t.Cleanup(cancel) + + repoURL := "https://github.com/alice/my-func.git" + repoBranch := "main" + repoPath := "." + + postDeploy := func(ctx context.Context, f fn.Function) error { + namespace := f.Deploy.Namespace + if namespace == "" { + namespace = f.Namespace + } + return operator.SyncFunctionCR(ctx, operator.SyncConfig{ + FunctionName: f.Name, + Namespace: namespace, + RepoURL: repoURL, + RepoBranch: repoBranch, + RepoPath: repoPath, + }) + } + + client := fn.New( + fn.WithScaffolder(oci.NewScaffolder(true)), + fn.WithBuilder(oci.NewBuilder("", false)), + fn.WithPusher(oci.NewPusher(true, true, true)), + fn.WithDeployer(deployer), + fn.WithDescribers(describer), + fn.WithRemovers(remover), + fn.WithPostDeploy(postDeploy), + ) + + f, err := client.Init(fn.Function{ + Root: root, + Name: name, + Runtime: "go", + Namespace: ns, + Registry: Registry(), + }) + if err != nil { + t.Fatal(err) + } + + err = client.Scaffold(ctx, f, "") + if err != nil { + t.Fatal(err) + } + + f, err = client.Build(ctx, f) + if err != nil { + t.Fatal(err) + } + + f, _, err = client.Push(ctx, f) + if err != nil { + t.Fatal(err) + } + + // First deploy — SyncFunctionCR creates the CR if the CRD is installed + f, err = client.Deploy(ctx, f) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + err := client.Remove(ctx, "", "", f, true) + if err != nil { + t.Logf("error removing Function: %v", err) + } + }) + + // Verify CR state only if the Function CRD is installed + restCfg, err := k8s.GetClientConfig().ClientConfig() + if err != nil { + t.Fatalf("getting kubernetes config: %v", err) + } + disc, err := discovery.NewDiscoveryClientForConfig(restCfg) + if err != nil { + t.Fatalf("creating discovery client: %v", err) + } + if !hasFunctionCRD(t, disc) { + t.Log("Function CRD not installed, skipping CR verification") + return + } + + scheme := runtime.NewScheme() + _ = v1alpha1.AddToScheme(scheme) + cl, err := ctrlclient.New(restCfg, ctrlclient.Options{Scheme: scheme}) + if err != nil { + t.Fatalf("creating kubernetes client: %v", err) + } + + t.Cleanup(func() { + _ = cl.Delete(context.Background(), &v1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + }) + }) + + var funcCR v1alpha1.Function + err = cl.Get(ctx, ctrlclient.ObjectKey{Name: name, Namespace: ns}, &funcCR) + if err != nil { + t.Fatalf("expected Function CR to be created: %v", err) + } + if funcCR.Spec.Repository.URL != repoURL { + t.Fatalf("expected repo URL %q, got %q", repoURL, funcCR.Spec.Repository.URL) + } + if funcCR.Spec.Repository.Branch != repoBranch { + t.Fatalf("expected branch %q, got %q", repoBranch, funcCR.Spec.Repository.Branch) + } + if funcCR.Spec.Repository.Path != repoPath { + t.Fatalf("expected path %q, got %q", repoPath, funcCR.Spec.Repository.Path) + } + + // Second deploy — should only annotate, not change spec + f, err = client.Deploy(ctx, f, fn.WithDeploySkipBuildCheck(true)) + if err != nil { + t.Fatal(err) + } + + err = cl.Get(ctx, ctrlclient.ObjectKey{Name: name, Namespace: ns}, &funcCR) + if err != nil { + t.Fatalf("expected Function CR to still exist: %v", err) + } + + if funcCR.Spec.Repository.URL != repoURL { + t.Fatalf("expected spec URL unchanged %q, got %q", repoURL, funcCR.Spec.Repository.URL) + } + if funcCR.Spec.Repository.Branch != repoBranch { + t.Fatalf("expected spec branch unchanged %q, got %q", repoBranch, funcCR.Spec.Repository.Branch) + } + + ts, ok := funcCR.Annotations["functions.knative.dev/last-deployed"] + if !ok { + t.Fatal("expected last-deployed annotation to be set") + } + if _, err := time.Parse(time.RFC3339, ts); err != nil { + t.Fatalf("expected valid RFC3339 timestamp, got %q: %v", ts, err) + } +} + +func hasFunctionCRD(t *testing.T, disc discovery.DiscoveryInterface) bool { + t.Helper() + resources, err := disc.ServerResourcesForGroupVersion("functions.dev/v1alpha1") + if err != nil { + return false + } + for _, r := range resources.APIResources { + if r.Kind == "Function" { + return true + } + } + return false +} + func postText(ctx context.Context, url, reqBody, deployer string) (respBody string, err error) { req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(reqBody)) if err != nil { diff --git a/pkg/k8s/deployer_int_test.go b/pkg/k8s/deployer_int_test.go index 950d7822d0..57dac06983 100644 --- a/pkg/k8s/deployer_int_test.go +++ b/pkg/k8s/deployer_int_test.go @@ -67,3 +67,11 @@ func TestInt_ResourceValidationOnFirstDeploy(t *testing.T) { k8s.NewDescriber(false), k8s.KubernetesDeployerName) } + +func TestInt_OperatorSync(t *testing.T) { + deployertesting.TestInt_OperatorSync(t, + k8s.NewDeployer(k8s.WithDeployerVerbose(false)), + k8s.NewRemover(false), + k8s.NewDescriber(false), + k8s.KubernetesDeployerName) +} diff --git a/pkg/keda/deployer_int_test.go b/pkg/keda/deployer_int_test.go index 72277df803..6e22d09502 100644 --- a/pkg/keda/deployer_int_test.go +++ b/pkg/keda/deployer_int_test.go @@ -67,3 +67,11 @@ func TestInt_ResourceValidationOnFirstDeploy(t *testing.T) { keda.NewDescriber(false), keda.KedaDeployerName) } + +func TestInt_OperatorSync(t *testing.T) { + deployertesting.TestInt_OperatorSync(t, + keda.NewDeployer(keda.WithDeployerVerbose(false)), + keda.NewRemover(false), + keda.NewDescriber(false), + keda.KedaDeployerName) +} diff --git a/pkg/knative/deployer_int_test.go b/pkg/knative/deployer_int_test.go index f811e3fa6e..5a0745a626 100644 --- a/pkg/knative/deployer_int_test.go +++ b/pkg/knative/deployer_int_test.go @@ -65,3 +65,11 @@ func TestInt_ResourceValidationOnFirstDeploy(t *testing.T) { knative.NewDescriber(false), knative.KnativeDeployerName) } + +func TestInt_OperatorSync(t *testing.T) { + deployertesting.TestInt_OperatorSync(t, + knative.NewDeployer(knative.WithDeployerVerbose(true)), + knative.NewRemover(false), + knative.NewDescriber(false), + knative.KnativeDeployerName) +} From 7dd12e81667deacedbd5884091a2c75649e3635d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Wed, 29 Apr 2026 16:36:34 +0200 Subject: [PATCH 03/13] Install func-operator in test cluster setup Add func-operator v0.2.1 to the cluster setup script so the Function CRD is available for integration tests. The operator is installed in parallel with other components during cluster allocation. --- hack/cluster.sh | 14 ++++++++++++++ hack/cmd/components/main.go | 1 + hack/component-versions.json | 3 +++ hack/component-versions.sh | 1 + 4 files changed, 19 insertions(+) diff --git a/hack/cluster.sh b/hack/cluster.sh index 007a71eff4..06f365a7a0 100755 --- a/hack/cluster.sh +++ b/hack/cluster.sh @@ -82,6 +82,7 @@ allocate_cluster() { echo "dpr: Dapr Runtime" echo "tkt: Tekton Pipelines" echo "keda: Keda" + echo "fop: Func Operator" echo "" ( set -o pipefail; (serving && dns && networking) 2>&1 | sed -e 's/^/svr /')& @@ -90,6 +91,7 @@ allocate_cluster() { ( set -o pipefail; dapr_runtime 2>&1 | sed -e 's/^/dpr /')& ( set -o pipefail; (tekton && pac) 2>&1 | sed -e 's/^/tkt /')& ( set -o pipefail; (keda && keda_http_addon) 2>&1 | sed -e 's/^/keda /')& + ( set -o pipefail; func_operator 2>&1 | sed -e 's/^/fop /')& local job for job in $(jobs -p); do @@ -717,6 +719,18 @@ keda_http_addon() { echo "${green}✅ Keda HTTP add-on${reset}" } +func_operator() { + echo "${blue}Installing Func Operator${reset}" + echo "Version: ${func_operator_version}" + + $KUBECTL apply --server-side -f "https://github.com/functions-dev/func-operator/releases/download/${func_operator_version}/func-operator.yaml" + sleep 5 + $KUBECTL wait deployment --all --timeout=5m --for=condition=Available --namespace func-operator-system + + $KUBECTL get pod -n func-operator-system + echo "${green}✅ Func Operator${reset}" +} + next_steps() { echo -e "" echo -e "${blue}Next Steps${reset}" diff --git a/hack/cmd/components/main.go b/hack/cmd/components/main.go index e31ee3cfb4..1bf5008082 100644 --- a/hack/cmd/components/main.go +++ b/hack/cmd/components/main.go @@ -58,6 +58,7 @@ set_versions() { pac_version="{{.Pac.Version}}" keda_version="{{.Keda.Version}}" keda_http_addon_version="{{.KedaHTTPAddOn.Version}}" + func_operator_version="{{.FuncOperator.Version}}" } ` ) diff --git a/hack/component-versions.json b/hack/component-versions.json index 1d446952cb..0e55ee8f40 100644 --- a/hack/component-versions.json +++ b/hack/component-versions.json @@ -9,6 +9,9 @@ "owner": "knative", "repo": "eventing" }, + "FuncOperator": { + "version": "v0.2.1" + }, "Keda": { "version": "v2.17.0" }, diff --git a/hack/component-versions.sh b/hack/component-versions.sh index f8e37dde1b..041b371f61 100644 --- a/hack/component-versions.sh +++ b/hack/component-versions.sh @@ -18,4 +18,5 @@ set_versions() { pac_version="v0.35.2" keda_version="v2.17.0" keda_http_addon_version="v0.12.0" + func_operator_version="v0.2.1" } From b3b2cbd70635dd07154499897614949675ae1c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Wed, 29 Apr 2026 16:46:27 +0200 Subject: [PATCH 04/13] Strip credentials from git remote URLs in Function CR ResolveRemoteURL now removes userinfo (username and password) from HTTP/HTTPS remote URLs before returning them, preventing credentials from being stored in the Function CR spec. --- pkg/git/resolver.go | 18 ++++++++++++++- pkg/git/resolver_test.go | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/pkg/git/resolver.go b/pkg/git/resolver.go index b0cfffb2d5..266b287d58 100644 --- a/pkg/git/resolver.go +++ b/pkg/git/resolver.go @@ -1,6 +1,9 @@ package git import ( + "net/url" + "strings" + gogit "github.com/go-git/go-git/v5" ) @@ -30,7 +33,20 @@ func ResolveRemoteURL(repoPath string) (string, error) { if len(urls) == 0 { return "", nil } - return urls[0], nil + return stripUserinfo(urls[0]), nil +} + +func stripUserinfo(rawURL string) string { + if strings.Contains(rawURL, "@") && !strings.Contains(rawURL, "://") { + // SSH-style URL (git@host:path) — no userinfo to strip + return rawURL + } + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + u.User = nil + return u.String() } func trackingRemoteName(repo *gogit.Repository) string { diff --git a/pkg/git/resolver_test.go b/pkg/git/resolver_test.go index 8bd96ae70e..91f4708920 100644 --- a/pkg/git/resolver_test.go +++ b/pkg/git/resolver_test.go @@ -141,6 +141,54 @@ func TestResolveRemoteURL_SSHPassThrough(t *testing.T) { } } +func TestResolveRemoteURL_StripsUsername(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + + _, err = repo.CreateRemote(&gogitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"http://admin@172.18.0.2:30000/admin/my-func"}, + }) + if err != nil { + t.Fatal(err) + } + + url, err := ResolveRemoteURL(dir) + if err != nil { + t.Fatal(err) + } + if url != "http://172.18.0.2:30000/admin/my-func" { + t.Fatalf("expected userinfo stripped, got %q", url) + } +} + +func TestResolveRemoteURL_StripsUsernameAndPassword(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + + _, err = repo.CreateRemote(&gogitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"https://user:token@github.com/alice/my-func.git"}, + }) + if err != nil { + t.Fatal(err) + } + + url, err := ResolveRemoteURL(dir) + if err != nil { + t.Fatal(err) + } + if url != "https://github.com/alice/my-func.git" { + t.Fatalf("expected credentials stripped, got %q", url) + } +} + func TestResolveBranch_CurrentBranch(t *testing.T) { dir := t.TempDir() repo, err := gogit.PlainInit(dir, false) From a591aafb92194f425937b4b36eff732d7c83eb80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Thu, 30 Apr 2026 12:56:25 +0200 Subject: [PATCH 05/13] Add registry credential support to Function CR sync When deploying to a private registry, resolve credentials via the existing CredentialsProvider, create a docker-registry K8s Secret, and set .spec.registry.authSecretRef on the Function CR so the func-operator can authenticate with the registry. --- cmd/deploy.go | 32 +++++++++++++++++++++------ pkg/operator/sync.go | 44 ++++++++++++++++++++++++++++++++----- pkg/operator/sync_test.go | 46 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 472b9004a0..e753542fdf 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -18,11 +18,13 @@ import ( "knative.dev/func/cmd/common" "knative.dev/func/pkg/builders" "knative.dev/func/pkg/config" + "knative.dev/func/pkg/docker" fn "knative.dev/func/pkg/functions" funcgit "knative.dev/func/pkg/git" "knative.dev/func/pkg/k8s" "knative.dev/func/pkg/keda" "knative.dev/func/pkg/knative" + "knative.dev/func/pkg/oci" "knative.dev/func/pkg/operator" "knative.dev/func/pkg/utils" ) @@ -827,7 +829,7 @@ func (c deployConfig) clientOptions() ([]fn.Option, error) { if c.Manage { o = append(o, fn.WithPostDeploy(func(ctx context.Context, f fn.Function) error { - if err := syncFunctionCR(ctx, f, c); err != nil { + if err := syncFunctionCR(ctx, f, c, creds); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to sync Function CR: %v\n", err) } return nil @@ -918,7 +920,7 @@ func isDigested(v string) (validDigest bool, err error) { return ok, nil } -func syncFunctionCR(ctx context.Context, f fn.Function, cfg deployConfig) error { +func syncFunctionCR(ctx context.Context, f fn.Function, cfg deployConfig, credentialsProvider oci.CredentialsProvider) error { repoURL := cfg.GitURL repoBranch := cfg.GitBranch repoPath := cfg.GitDir @@ -944,11 +946,27 @@ func syncFunctionCR(ctx context.Context, f fn.Function, cfg deployConfig) error namespace = f.Namespace } + var registryCredentials *operator.RegistryCredentials + if credentialsProvider != nil && f.Deploy.Image != "" { + registry, err := docker.GetRegistry(f.Deploy.Image) + if err == nil { + creds, err := credentialsProvider(ctx, f.Deploy.Image) + if err == nil && creds.Username != "" { + registryCredentials = &operator.RegistryCredentials{ + Username: creds.Username, + Password: creds.Password, + Server: registry, + } + } + } + } + return operator.SyncFunctionCR(ctx, operator.SyncConfig{ - FunctionName: f.Name, - Namespace: namespace, - RepoURL: repoURL, - RepoBranch: repoBranch, - RepoPath: repoPath, + FunctionName: f.Name, + Namespace: namespace, + RepoURL: repoURL, + RepoBranch: repoBranch, + RepoPath: repoPath, + RegistryCredentials: registryCredentials, }) } diff --git a/pkg/operator/sync.go b/pkg/operator/sync.go index f961bc8f31..c56ccb6f12 100644 --- a/pkg/operator/sync.go +++ b/pkg/operator/sync.go @@ -7,6 +7,7 @@ import ( "time" v1alpha1 "github.com/functions-dev/func-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/discovery" @@ -15,6 +16,13 @@ import ( "knative.dev/func/pkg/k8s" ) +// RegistryCredentials holds resolved credentials for a container registry. +type RegistryCredentials struct { + Username string + Password string + Server string +} + // SyncConfig holds the parameters for creating/updating a Function CR. type SyncConfig struct { FunctionName string @@ -22,6 +30,9 @@ type SyncConfig struct { RepoURL string RepoBranch string RepoPath string + // If set, a docker-registry secret is created and referenced from + // the Function CR's .spec.registry.authSecretRef. + RegistryCredentials *RegistryCredentials } // SyncFunctionCR creates or updates a Function CR for the given function. @@ -63,12 +74,36 @@ func syncFunctionCR(ctx context.Context, cl ctrlclient.Client, disc discovery.Di return nil } + // Build desired spec + var registrySecretRef *v1.LocalObjectReference + if cfg.RegistryCredentials != nil { + secretName := cfg.FunctionName + "-registry-auth" + if err := k8s.EnsureDockerRegistrySecretExist(ctx, secretName, cfg.Namespace, nil, nil, + cfg.RegistryCredentials.Username, cfg.RegistryCredentials.Password, cfg.RegistryCredentials.Server); err != nil { + return fmt.Errorf("creating registry secret: %w", err) + } + registrySecretRef = &v1.LocalObjectReference{Name: secretName} + } + + desiredSpec := v1alpha1.FunctionSpec{ + Repository: v1alpha1.FunctionSpecRepository{ + URL: cfg.RepoURL, + Branch: cfg.RepoBranch, + Path: cfg.RepoPath, + }, + } + if registrySecretRef != nil { + desiredSpec.Registry.AuthSecretRef = registrySecretRef + } + + // Look up existing CR existing, err := findExistingCR(ctx, cl, cfg.FunctionName, cfg.Namespace) if err != nil { return fmt.Errorf("looking up existing Function CR: %w", err) } if existing != nil { + existing.Spec = desiredSpec if existing.Annotations == nil { existing.Annotations = map[string]string{} } @@ -84,14 +119,11 @@ func syncFunctionCR(ctx context.Context, cl ctrlclient.Client, disc discovery.Di ObjectMeta: metav1.ObjectMeta{ Name: cfg.FunctionName, Namespace: cfg.Namespace, - }, - Spec: v1alpha1.FunctionSpec{ - Repository: v1alpha1.FunctionSpecRepository{ - URL: cfg.RepoURL, - Branch: cfg.RepoBranch, - Path: cfg.RepoPath, + Annotations: map[string]string{ + "functions.knative.dev/last-deployed": time.Now().UTC().Format(time.RFC3339), }, }, + Spec: desiredSpec, } if err := cl.Create(ctx, fn); err != nil { return fmt.Errorf("creating Function CR: %w", err) diff --git a/pkg/operator/sync_test.go b/pkg/operator/sync_test.go index 19d3a5455f..c9a53c9640 100644 --- a/pkg/operator/sync_test.go +++ b/pkg/operator/sync_test.go @@ -122,8 +122,11 @@ func TestSyncFunctionCR_UpdateExistingByMetadataName(t *testing.T) { if _, err := time.Parse(time.RFC3339, ts); err != nil { t.Fatalf("expected valid RFC3339 timestamp, got %q: %v", ts, err) } - if fn.Spec.Repository.URL != "https://github.com/old/repo.git" { - t.Fatalf("expected spec to remain unchanged, but URL was %q", fn.Spec.Repository.URL) + if fn.Spec.Repository.URL != "https://github.com/alice/my-func.git" { + t.Fatalf("expected spec to be updated, but URL was %q", fn.Spec.Repository.URL) + } + if fn.Spec.Repository.Branch != "main" { + t.Fatalf("expected branch to be updated, but got %q", fn.Spec.Repository.Branch) } } @@ -199,6 +202,45 @@ func TestSyncFunctionCR_NoCRD_SkipSilently(t *testing.T) { } } +func TestSyncFunctionCR_WithRegistryCredentials(t *testing.T) { + scheme := newScheme() + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + disc := fakeDiscoveryWithCRD() + + cfg := SyncConfig{ + FunctionName: "my-func", + Namespace: "default", + RepoURL: "https://github.com/alice/my-func.git", + RepoBranch: "main", + RepoPath: ".", + RegistryCredentials: &RegistryCredentials{ + Username: "admin", + Password: "secret", + Server: "ghcr.io", + }, + } + + err := syncFunctionCR(context.Background(), cl, disc, cfg) + if err != nil { + t.Fatal(err) + } + + var fn v1alpha1.Function + err = cl.Get(context.Background(), ctrlclient.ObjectKey{ + Name: "my-func", + Namespace: "default", + }, &fn) + if err != nil { + t.Fatalf("expected Function CR to be created: %v", err) + } + if fn.Spec.Registry.AuthSecretRef == nil { + t.Fatal("expected registry authSecretRef to be set") + } + if fn.Spec.Registry.AuthSecretRef.Name != "my-func-registry-auth" { + t.Fatalf("expected secret name 'my-func-registry-auth', got %q", fn.Spec.Registry.AuthSecretRef.Name) + } +} + func TestSyncFunctionCR_NoRepoURL_SkipsWithMessage(t *testing.T) { scheme := newScheme() cl := fake.NewClientBuilder().WithScheme(scheme).Build() From 38650ee9f8e5d0b26e98b42a8028862b879dded7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Tue, 5 May 2026 14:47:37 +0200 Subject: [PATCH 06/13] Fix CI failures in linter and unit tests - Check return value of AddToScheme (errcheck lint) - Make registry secret creation injectable for unit tests - Add git commit author in resolver tests for CI - Add FuncOperator to component-versions test expectations --- hack/cmd/components/main_test.go | 7 +++++++ pkg/git/resolver_test.go | 10 ++++++++-- pkg/operator/sync.go | 10 ++++++++-- pkg/operator/sync_test.go | 6 ++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/hack/cmd/components/main_test.go b/hack/cmd/components/main_test.go index b82c673ebc..4d655dbbb9 100644 --- a/hack/cmd/components/main_test.go +++ b/hack/cmd/components/main_test.go @@ -29,6 +29,7 @@ set_versions() { pac_version="v0.35.2" keda_version="v2.17.0" keda_http_addon_version="v0.11.1" + func_operator_version="v0.2.1" } ` @@ -43,6 +44,9 @@ const expectedJson string = `{ "owner": "knative", "repo": "eventing" }, + "FuncOperator": { + "version": "v0.2.1" + }, "Keda": { "version": "v2.17.0" }, @@ -105,6 +109,9 @@ func TestWrite(t *testing.T) { Owner: "knative", Repo: "eventing", }, + "FuncOperator": { + Version: "v0.2.1", + }, "KindNode": { Version: "v1.32.0@sha256:c48c62eac5da28cdadcf560d1d8616cfa6783b58f0d94cf63ad1bf49600cb027", }, diff --git a/pkg/git/resolver_test.go b/pkg/git/resolver_test.go index 91f4708920..a3b0d98543 100644 --- a/pkg/git/resolver_test.go +++ b/pkg/git/resolver_test.go @@ -3,10 +3,12 @@ package git import ( "os" "testing" + "time" gogit "github.com/go-git/go-git/v5" gogitconfig "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" ) func TestResolveRemoteURL_OriginRemote(t *testing.T) { @@ -67,7 +69,9 @@ func TestResolveRemoteURL_TrackingRemote(t *testing.T) { if _, err = wt.Add("README.md"); err != nil { t.Fatal(err) } - if _, err = wt.Commit("initial", &gogit.CommitOptions{}); err != nil { + if _, err = wt.Commit("initial", &gogit.CommitOptions{ + Author: &object.Signature{Name: "test", Email: "test@test.com", When: time.Now()}, + }); err != nil { t.Fatal(err) } @@ -208,7 +212,9 @@ func TestResolveBranch_CurrentBranch(t *testing.T) { if _, err = wt.Add("README.md"); err != nil { t.Fatal(err) } - if _, err = wt.Commit("initial", &gogit.CommitOptions{}); err != nil { + if _, err = wt.Commit("initial", &gogit.CommitOptions{ + Author: &object.Signature{Name: "test", Email: "test@test.com", When: time.Now()}, + }); err != nil { t.Fatal(err) } diff --git a/pkg/operator/sync.go b/pkg/operator/sync.go index c56ccb6f12..8743d7f57a 100644 --- a/pkg/operator/sync.go +++ b/pkg/operator/sync.go @@ -35,6 +35,10 @@ type SyncConfig struct { RegistryCredentials *RegistryCredentials } +// ensureRegistrySecret creates or updates a docker-registry Secret. +// Defaults to k8s.EnsureDockerRegistrySecretExist; overridden in tests. +var ensureRegistrySecret = k8s.EnsureDockerRegistrySecretExist + // SyncFunctionCR creates or updates a Function CR for the given function. // It sets up Kubernetes clients, checks if the Function CRD exists on the // cluster, and creates or updates the CR accordingly. @@ -50,7 +54,9 @@ func SyncFunctionCR(ctx context.Context, cfg SyncConfig) error { } scheme := runtime.NewScheme() - v1alpha1.AddToScheme(scheme) + if err := v1alpha1.AddToScheme(scheme); err != nil { + return fmt.Errorf("registering Function scheme: %w", err) + } cl, err := ctrlclient.New(restCfg, ctrlclient.Options{Scheme: scheme}) if err != nil { @@ -78,7 +84,7 @@ func syncFunctionCR(ctx context.Context, cl ctrlclient.Client, disc discovery.Di var registrySecretRef *v1.LocalObjectReference if cfg.RegistryCredentials != nil { secretName := cfg.FunctionName + "-registry-auth" - if err := k8s.EnsureDockerRegistrySecretExist(ctx, secretName, cfg.Namespace, nil, nil, + if err := ensureRegistrySecret(ctx, secretName, cfg.Namespace, nil, nil, cfg.RegistryCredentials.Username, cfg.RegistryCredentials.Password, cfg.RegistryCredentials.Server); err != nil { return fmt.Errorf("creating registry secret: %w", err) } diff --git a/pkg/operator/sync_test.go b/pkg/operator/sync_test.go index c9a53c9640..320e445180 100644 --- a/pkg/operator/sync_test.go +++ b/pkg/operator/sync_test.go @@ -203,6 +203,12 @@ func TestSyncFunctionCR_NoCRD_SkipSilently(t *testing.T) { } func TestSyncFunctionCR_WithRegistryCredentials(t *testing.T) { + original := ensureRegistrySecret + ensureRegistrySecret = func(_ context.Context, _, _ string, _, _ map[string]string, _, _, _ string) error { + return nil + } + t.Cleanup(func() { ensureRegistrySecret = original }) + scheme := newScheme() cl := fake.NewClientBuilder().WithScheme(scheme).Build() disc := fakeDiscoveryWithCRD() From a052d6791cfc9412aafb15c1b7d3b1b565a20678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Mon, 11 May 2026 11:54:09 +0200 Subject: [PATCH 07/13] Move operator CR sync into core Client.Deploy() Replace the CLI-level postDeploy hook with a FunctionSyncer interface in pkg/functions, implemented by pkg/operator.Syncer. This makes operator management available to all library users, not just the CLI. - Add FunctionSyncer interface and WithSyncer option to Client - Add ManagementDisabled field to DeploySpec (managed by default) - Create pkg/operator/syncer.go encapsulating git/registry resolution - Wire operator.NewSyncer in cmd/client.go NewClient() - Replace --manage flag with --management-disabled - Remove postDeploy hook, syncFunctionCR from cmd/deploy.go - Update integration test to use WithSyncer --- cmd/client.go | 2 + cmd/deploy.go | 80 ++---------------- .../testing/integration_test_helper.go | 22 ++--- pkg/functions/client.go | 19 +++-- pkg/functions/function.go | 5 ++ pkg/operator/syncer.go | 82 +++++++++++++++++++ 6 files changed, 114 insertions(+), 96 deletions(-) create mode 100644 pkg/operator/syncer.go diff --git a/cmd/client.go b/cmd/client.go index b83b3fd4fa..a7221bf7b2 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -18,6 +18,7 @@ import ( "knative.dev/func/pkg/k8s" "knative.dev/func/pkg/knative" "knative.dev/func/pkg/oci" + "knative.dev/func/pkg/operator" "knative.dev/func/pkg/pipelines/tekton" ) @@ -82,6 +83,7 @@ func NewClient(cfg ClientConfig, options ...fn.Option) (*fn.Client, func()) { docker.WithCredentialsProvider(c), docker.WithTransport(t), docker.WithVerbose(cfg.Verbose))), + fn.WithSyncer(operator.NewSyncer(operator.WithCredentialsProvider(c))), } ) diff --git a/cmd/deploy.go b/cmd/deploy.go index e753542fdf..3e510cf527 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -1,11 +1,9 @@ package cmd import ( - "context" "errors" "fmt" "io" - "os" "strconv" "strings" @@ -18,14 +16,10 @@ import ( "knative.dev/func/cmd/common" "knative.dev/func/pkg/builders" "knative.dev/func/pkg/config" - "knative.dev/func/pkg/docker" fn "knative.dev/func/pkg/functions" - funcgit "knative.dev/func/pkg/git" "knative.dev/func/pkg/k8s" "knative.dev/func/pkg/keda" "knative.dev/func/pkg/knative" - "knative.dev/func/pkg/oci" - "knative.dev/func/pkg/operator" "knative.dev/func/pkg/utils" ) @@ -137,7 +131,7 @@ EXAMPLES SuggestFor: []string{"delpoy", "deplyo"}, PreRunE: bindEnv("build", "build-timestamp", "builder", "builder-image", "base-image", "confirm", "domain", "env", "git-branch", "git-dir", - "git-url", "image", "image-pull-secret", "manage", "namespace", "path", "platform", "push", "pvc-size", + "git-url", "image", "image-pull-secret", "management-disabled", "namespace", "path", "platform", "push", "pvc-size", "service-account", "deployer", "registry", "registry-insecure", "registry-authfile", "remote", "username", "password", "token", "verbose", "remote-storage-class"), @@ -222,8 +216,8 @@ EXAMPLES cmd.Flags().StringP("token", "", "", "Token to use when pushing to the registry. ($FUNC_TOKEN)") cmd.Flags().BoolP("build-timestamp", "", false, "Use the actual time as the created time for the docker image. This is only useful for buildpacks builder.") - cmd.Flags().Bool("manage", true, - "Create/update a Function CR for operator management if the func-operator is installed ($FUNC_MANAGE)") + cmd.Flags().Bool("management-disabled", f.Deploy.ManagementDisabled, + "Disable operator management of this function ($FUNC_MANAGEMENT_DISABLED)") cmd.Flags().StringP("namespace", "n", defaultNamespace(f, false), "Deploy into a specific namespace. Will use the function's current namespace by default if already deployed, and the currently active context if it can be determined. ($FUNC_NAMESPACE)") @@ -564,9 +558,8 @@ type deployConfig struct { // This is currently only supported by the Pack builder. Timestamp bool - // Manage indicates whether to create/update a Function CR for the - // func-operator after deployment. - Manage bool + // ManagementDisabled disables automatic Function CR sync after deploy. + ManagementDisabled bool } // newDeployConfig creates a buildConfig populated from command flags and @@ -588,7 +581,7 @@ func newDeployConfig(cmd *cobra.Command) deployConfig { ServiceAccountName: viper.GetString("service-account"), ImagePullSecret: viper.GetString("image-pull-secret"), Deployer: viper.GetString("deployer"), - Manage: viper.GetBool("manage"), + ManagementDisabled: viper.GetBool("management-disabled"), } // NOTE: .Env should be viper.GetStringSlice, but this returns unparsed // results and appears to be an open issue since 2017: @@ -625,6 +618,7 @@ func (c deployConfig) Configure(f fn.Function) (fn.Function, error) { f.Deploy.ServiceAccountName = c.ServiceAccountName f.Deploy.ImagePullSecret = c.ImagePullSecret f.Deploy.Deployer = c.Deployer + f.Deploy.ManagementDisabled = c.ManagementDisabled f.Local.Remote = c.Remote // PVCSize @@ -827,15 +821,6 @@ func (c deployConfig) clientOptions() ([]fn.Option, error) { return o, fmt.Errorf("unsupported deploy type: %s (supported: %s, %s, %s)", deployer, knative.KnativeDeployerName, k8s.KubernetesDeployerName, keda.KedaDeployerName) } - if c.Manage { - o = append(o, fn.WithPostDeploy(func(ctx context.Context, f fn.Function) error { - if err := syncFunctionCR(ctx, f, c, creds); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to sync Function CR: %v\n", err) - } - return nil - })) - } - return o, nil } @@ -919,54 +904,3 @@ func isDigested(v string) (validDigest bool, err error) { _, ok := ref.(name.Digest) return ok, nil } - -func syncFunctionCR(ctx context.Context, f fn.Function, cfg deployConfig, credentialsProvider oci.CredentialsProvider) error { - repoURL := cfg.GitURL - repoBranch := cfg.GitBranch - repoPath := cfg.GitDir - - if repoURL == "" { - var err error - repoURL, err = funcgit.ResolveRemoteURL(f.Root) - if err != nil { - return err - } - } - - if repoBranch == "" { - repoBranch = funcgit.ResolveBranch(f.Root) - } - - if repoPath == "" { - repoPath = "." - } - - namespace := f.Deploy.Namespace - if namespace == "" { - namespace = f.Namespace - } - - var registryCredentials *operator.RegistryCredentials - if credentialsProvider != nil && f.Deploy.Image != "" { - registry, err := docker.GetRegistry(f.Deploy.Image) - if err == nil { - creds, err := credentialsProvider(ctx, f.Deploy.Image) - if err == nil && creds.Username != "" { - registryCredentials = &operator.RegistryCredentials{ - Username: creds.Username, - Password: creds.Password, - Server: registry, - } - } - } - } - - return operator.SyncFunctionCR(ctx, operator.SyncConfig{ - FunctionName: f.Name, - Namespace: namespace, - RepoURL: repoURL, - RepoBranch: repoBranch, - RepoPath: repoPath, - RegistryCredentials: registryCredentials, - }) -} diff --git a/pkg/deployer/testing/integration_test_helper.go b/pkg/deployer/testing/integration_test_helper.go index 4a4a656dc5..19dec09d94 100644 --- a/pkg/deployer/testing/integration_test_helper.go +++ b/pkg/deployer/testing/integration_test_helper.go @@ -1201,7 +1201,7 @@ func (f *Function) Handle(w http.ResponseWriter, req *http.Request) { } ` -// TestInt_OperatorSync ensures that deploying with WithPostDeploy creates a +// TestInt_OperatorSync ensures that deploying with WithSyncer creates a // Function CR on first deploy, and only annotates it on subsequent deploys. func TestInt_OperatorSync(t *testing.T, deployer fn.Deployer, remover fn.Remover, describer fn.Describer, deployerName string) { t.Helper() @@ -1217,20 +1217,6 @@ func TestInt_OperatorSync(t *testing.T, deployer fn.Deployer, remover fn.Remover repoBranch := "main" repoPath := "." - postDeploy := func(ctx context.Context, f fn.Function) error { - namespace := f.Deploy.Namespace - if namespace == "" { - namespace = f.Namespace - } - return operator.SyncFunctionCR(ctx, operator.SyncConfig{ - FunctionName: f.Name, - Namespace: namespace, - RepoURL: repoURL, - RepoBranch: repoBranch, - RepoPath: repoPath, - }) - } - client := fn.New( fn.WithScaffolder(oci.NewScaffolder(true)), fn.WithBuilder(oci.NewBuilder("", false)), @@ -1238,7 +1224,7 @@ func TestInt_OperatorSync(t *testing.T, deployer fn.Deployer, remover fn.Remover fn.WithDeployer(deployer), fn.WithDescribers(describer), fn.WithRemovers(remover), - fn.WithPostDeploy(postDeploy), + fn.WithSyncer(operator.NewSyncer()), ) f, err := client.Init(fn.Function{ @@ -1252,6 +1238,10 @@ func TestInt_OperatorSync(t *testing.T, deployer fn.Deployer, remover fn.Remover t.Fatal(err) } + f.Build.Git.URL = repoURL + f.Build.Git.Revision = repoBranch + f.Build.Git.ContextDir = repoPath + err = client.Scaffold(ctx, f, "") if err != nil { t.Fatal(err) diff --git a/pkg/functions/client.go b/pkg/functions/client.go index fb00e16485..812d07cd78 100644 --- a/pkg/functions/client.go +++ b/pkg/functions/client.go @@ -82,7 +82,7 @@ type Client struct { pipelinesProvider PipelinesProvider // CI/CD pipelines management mcpServer MCPServer // MCP Server startTimeout time.Duration // default start timeout for all runs - postDeploy func(context.Context, Function) error + syncer FunctionSyncer // Syncs Function CR after deploy } // Scaffolder wraps a function with a service scaffolding (entrypoint) @@ -111,6 +111,11 @@ type Deployer interface { Deploy(context.Context, Function) (DeploymentResult, error) } +// FunctionSyncer syncs a Function's state to an external system after deploy. +type FunctionSyncer interface { + Sync(ctx context.Context, f Function) error +} + type DeploymentResult struct { Status Status URL string @@ -312,10 +317,10 @@ func WithDeployer(d Deployer) Option { } } -// WithPostDeploy sets a handler that is called after a successful deploy. -func WithPostDeploy(handler func(context.Context, Function) error) Option { +// WithSyncer sets the FunctionSyncer used to sync Function CRs after deploy. +func WithSyncer(s FunctionSyncer) Option { return func(c *Client) { - c.postDeploy = handler + c.syncer = s } } @@ -892,9 +897,9 @@ func (c *Client) Deploy(ctx context.Context, f Function, oo ...DeployOption) (Fu default: } - if c.postDeploy != nil { - if err := c.postDeploy(ctx, f); err != nil { - return f, fmt.Errorf("post-deploy: %w", err) + if c.syncer != nil && !f.Deploy.ManagementDisabled { + if err := c.syncer.Sync(ctx, f); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to sync Function CR: %v\n", err) } } diff --git a/pkg/functions/function.go b/pkg/functions/function.go index 09d2506273..ff07f41225 100644 --- a/pkg/functions/function.go +++ b/pkg/functions/function.go @@ -228,6 +228,11 @@ type DeploySpec struct { Deployer string `yaml:"deployer,omitempty" jsonschema:"enum=knative,enum=raw,enum=keda"` Subscriptions []KnativeSubscription `yaml:"subscriptions,omitempty"` + + // ManagementDisabled disables automatic creation/update of a Function CR + // for operator management after deploy. The zero value (false) means + // the function is managed by default when the func-operator is installed. + ManagementDisabled bool `yaml:"managementDisabled,omitempty"` } // HealthEndpoints specify the liveness and readiness endpoints for a Runtime diff --git a/pkg/operator/syncer.go b/pkg/operator/syncer.go new file mode 100644 index 0000000000..da94a3855a --- /dev/null +++ b/pkg/operator/syncer.go @@ -0,0 +1,82 @@ +package operator + +import ( + "context" + + fn "knative.dev/func/pkg/functions" + + "knative.dev/func/pkg/docker" + funcgit "knative.dev/func/pkg/git" + "knative.dev/func/pkg/oci" +) + +type SyncerOpt func(*Syncer) + +type Syncer struct { + credentialsProvider oci.CredentialsProvider +} + +func NewSyncer(opts ...SyncerOpt) *Syncer { + s := &Syncer{} + for _, o := range opts { + o(s) + } + return s +} + +func WithCredentialsProvider(cp oci.CredentialsProvider) SyncerOpt { + return func(s *Syncer) { + s.credentialsProvider = cp + } +} + +func (s *Syncer) Sync(ctx context.Context, f fn.Function) error { + repoURL := f.Build.Git.URL + repoBranch := f.Build.Git.Revision + repoPath := f.Build.Git.ContextDir + + if repoURL == "" { + var err error + repoURL, err = funcgit.ResolveRemoteURL(f.Root) + if err != nil { + return err + } + } + + if repoBranch == "" { + repoBranch = funcgit.ResolveBranch(f.Root) + } + + if repoPath == "" { + repoPath = "." + } + + namespace := f.Deploy.Namespace + if namespace == "" { + namespace = f.Namespace + } + + var registryCredentials *RegistryCredentials + if s.credentialsProvider != nil && f.Deploy.Image != "" { + registry, err := docker.GetRegistry(f.Deploy.Image) + if err == nil { + creds, err := s.credentialsProvider(ctx, f.Deploy.Image) + if err == nil && creds.Username != "" { + registryCredentials = &RegistryCredentials{ + Username: creds.Username, + Password: creds.Password, + Server: registry, + } + } + } + } + + return SyncFunctionCR(ctx, SyncConfig{ + FunctionName: f.Name, + Namespace: namespace, + RepoURL: repoURL, + RepoBranch: repoBranch, + RepoPath: repoPath, + RegistryCredentials: registryCredentials, + }) +} From daf9f2196d1b41be5d79babdb5710ddcdd3cd643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Mon, 11 May 2026 12:08:33 +0200 Subject: [PATCH 08/13] Skip Function CR sync silently when no git remote is configured A user deploying without a git remote is an expected base case, not a warning-worthy condition. The operator sync now requires both the CRD to be installed and a git URL to be available before attempting to create or update a Function CR. --- pkg/operator/sync.go | 1 - pkg/operator/syncer.go | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/operator/sync.go b/pkg/operator/sync.go index 8743d7f57a..cad8b7484c 100644 --- a/pkg/operator/sync.go +++ b/pkg/operator/sync.go @@ -76,7 +76,6 @@ func syncFunctionCR(ctx context.Context, cl ctrlclient.Client, disc discovery.Di } if cfg.RepoURL == "" { - fmt.Fprintln(os.Stderr, "Function CR not created: no git remote found. Set --git-url to enable operator management.") return nil } diff --git a/pkg/operator/syncer.go b/pkg/operator/syncer.go index da94a3855a..a54af32297 100644 --- a/pkg/operator/syncer.go +++ b/pkg/operator/syncer.go @@ -36,11 +36,11 @@ func (s *Syncer) Sync(ctx context.Context, f fn.Function) error { repoPath := f.Build.Git.ContextDir if repoURL == "" { - var err error - repoURL, err = funcgit.ResolveRemoteURL(f.Root) + resolved, err := funcgit.ResolveRemoteURL(f.Root) if err != nil { - return err + return nil } + repoURL = resolved } if repoBranch == "" { From c2e56eec1a7654f35cea86c0d916b7d999391a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Mon, 11 May 2026 12:14:19 +0200 Subject: [PATCH 09/13] Distinguish CRD-not-found from real errors in hasFunctionCRD Previously all errors from ServerResourcesForGroupVersion were swallowed and treated as "CRD not present". Now only NotFound errors are treated as CRD absence; other errors are propagated so legitimate failures are not silently ignored. --- pkg/operator/sync.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/operator/sync.go b/pkg/operator/sync.go index cad8b7484c..eb9d3a2c1e 100644 --- a/pkg/operator/sync.go +++ b/pkg/operator/sync.go @@ -8,6 +8,7 @@ import ( v1alpha1 "github.com/functions-dev/func-operator/api/v1alpha1" v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/discovery" @@ -140,7 +141,10 @@ func syncFunctionCR(ctx context.Context, cl ctrlclient.Client, disc discovery.Di func hasFunctionCRD(disc discovery.DiscoveryInterface) (bool, error) { resources, err := disc.ServerResourcesForGroupVersion("functions.dev/v1alpha1") if err != nil { - return false, nil + if apierrors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("querying API resources: %w", err) } for _, r := range resources.APIResources { if r.Kind == "Function" { From 278886e1c022037f637ffda723d282187291c90f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Mon, 11 May 2026 12:15:26 +0200 Subject: [PATCH 10/13] Combine two loops in findExistingCR into one Merge the separate status-name and metadata-name lookups into a single pass over the list. Status name match still takes priority and returns immediately; metadata name match is remembered as a fallback. --- pkg/operator/sync.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/pkg/operator/sync.go b/pkg/operator/sync.go index eb9d3a2c1e..b0f9c8e7d3 100644 --- a/pkg/operator/sync.go +++ b/pkg/operator/sync.go @@ -160,17 +160,16 @@ func findExistingCR(ctx context.Context, cl ctrlclient.Client, funcName, namespa return nil, err } + // Status.Name takes priority over metadata name, so we must scan the + // full list before falling back to a metadata name match. + var byName *v1alpha1.Function for i := range list.Items { if list.Items[i].Status.Name == funcName { return &list.Items[i], nil } - } - - for i := range list.Items { - if list.Items[i].Name == funcName { - return &list.Items[i], nil + if byName == nil && list.Items[i].Name == funcName { + byName = &list.Items[i] } } - - return nil, nil + return byName, nil } From 9f0bda800e4e7870e5a15aee8c56445f395e331c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Mon, 11 May 2026 12:19:13 +0200 Subject: [PATCH 11/13] Use func-operator GroupVersion constant instead of hardcoded string Replace the hardcoded "functions.dev/v1alpha1" string with v1alpha1.GroupVersion.String() in hasFunctionCRD, unit tests, and integration test helper. --- pkg/deployer/testing/integration_test_helper.go | 2 +- pkg/operator/sync.go | 2 +- pkg/operator/sync_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/deployer/testing/integration_test_helper.go b/pkg/deployer/testing/integration_test_helper.go index 19dec09d94..379cad6a73 100644 --- a/pkg/deployer/testing/integration_test_helper.go +++ b/pkg/deployer/testing/integration_test_helper.go @@ -1343,7 +1343,7 @@ func TestInt_OperatorSync(t *testing.T, deployer fn.Deployer, remover fn.Remover func hasFunctionCRD(t *testing.T, disc discovery.DiscoveryInterface) bool { t.Helper() - resources, err := disc.ServerResourcesForGroupVersion("functions.dev/v1alpha1") + resources, err := disc.ServerResourcesForGroupVersion(v1alpha1.GroupVersion.String()) if err != nil { return false } diff --git a/pkg/operator/sync.go b/pkg/operator/sync.go index b0f9c8e7d3..c067abe005 100644 --- a/pkg/operator/sync.go +++ b/pkg/operator/sync.go @@ -139,7 +139,7 @@ func syncFunctionCR(ctx context.Context, cl ctrlclient.Client, disc discovery.Di } func hasFunctionCRD(disc discovery.DiscoveryInterface) (bool, error) { - resources, err := disc.ServerResourcesForGroupVersion("functions.dev/v1alpha1") + resources, err := disc.ServerResourcesForGroupVersion(v1alpha1.GroupVersion.String()) if err != nil { if apierrors.IsNotFound(err) { return false, nil diff --git a/pkg/operator/sync_test.go b/pkg/operator/sync_test.go index 320e445180..e59fe8cb9c 100644 --- a/pkg/operator/sync_test.go +++ b/pkg/operator/sync_test.go @@ -26,7 +26,7 @@ func fakeDiscoveryWithCRD() *fakediscovery.FakeDiscovery { fd := cs.Discovery().(*fakediscovery.FakeDiscovery) fd.Resources = []*metav1.APIResourceList{ { - GroupVersion: "functions.dev/v1alpha1", + GroupVersion: v1alpha1.GroupVersion.String(), APIResources: []metav1.APIResource{ {Name: "functions", Kind: "Function"}, }, From af3f6be2ccb619f05d9eff82b082891e9a629a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Mon, 11 May 2026 12:34:10 +0200 Subject: [PATCH 12/13] Regenerate deploy command reference docs Update generated docs to reflect --manage being replaced by --management-disabled. --- docs/reference/func_deploy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/func_deploy.md b/docs/reference/func_deploy.md index 5fbb6a7d3b..81880ce1c4 100644 --- a/docs/reference/func_deploy.md +++ b/docs/reference/func_deploy.md @@ -128,7 +128,7 @@ func deploy -h, --help help for deploy -i, --image string Full image name in the form [registry]/[namespace]/[name]:[tag]@[digest]. This option takes precedence over --registry. Specifying digest is optional, but if it is given, 'build' and 'push' phases are disabled. ($FUNC_IMAGE) --image-pull-secret string Image pull secret to use when the function's image is in a private registry ($FUNC_IMAGE_PULL_SECRET) - --manage Create/update a Function CR for operator management if the func-operator is installed ($FUNC_MANAGE) (default true) + --management-disabled Disable operator management of this function ($FUNC_MANAGEMENT_DISABLED) -n, --namespace string Deploy into a specific namespace. Will use the function's current namespace by default if already deployed, and the currently active context if it can be determined. ($FUNC_NAMESPACE) (default "default") --password string Password to use when pushing to the registry. ($FUNC_PASSWORD) -p, --path string Path to the function. Default is current directory ($FUNC_PATH) From 470d6034d8f2e2140eb83139839ba3fe9d49745e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Mon, 11 May 2026 12:45:41 +0200 Subject: [PATCH 13/13] Regenerate func.yaml JSON schema Add ManagementDisabled field to the generated schema. --- schema/func_yaml-schema.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/schema/func_yaml-schema.json b/schema/func_yaml-schema.json index 762abdfe47..47e2e1b2b2 100644 --- a/schema/func_yaml-schema.json +++ b/schema/func_yaml-schema.json @@ -126,6 +126,10 @@ "$ref": "#/definitions/KnativeSubscription" }, "type": "array" + }, + "managementDisabled": { + "type": "boolean", + "description": "ManagementDisabled disables automatic creation/update of a Function CR\nfor operator management after deploy. The zero value (false) means\nthe function is managed by default when the func-operator is installed." } }, "additionalProperties": false,