Skip to content

Add KEDA Kafka consumer-lag scaling support - #4051

Open
aliok wants to merge 4 commits into
knative:mainfrom
aliok:2026-09-08-kafka-scaling-2
Open

aliok wants to merge 4 commits into
knative:mainfrom
aliok:2026-09-08-kafka-scaling-2

Conversation

@aliok

@aliok aliok commented Sep 8, 2026

Copy link
Copy Markdown
Member

Changes

This PR adds Kafka consumer-lag autoscaling support via KEDA. When deployer: keda is set and a Kafka trigger is configured, the deployer creates a KEDA ScaledObject with a Kafka trigger that scales function pods based on consumer group lag, along with a TriggerAuthentication resource for SASL/TLS credentials.

The func.yaml scale configuration has been restructured:

  • scale is now a top-level field (previously nested under deploy.options.scale), with min/max plus one of two mutually-exclusive sub-keys:
    • scale.kpa for the knative deployer: metric, target, utilization.
    • scale.keda for the keda deployer: pollingInterval, cooldownPeriod, and a triggers list. Each trigger (http, kafka, or cron) carries its own knobs, e.g. lagThreshold and activationLagThreshold for Kafka triggers.
  • Kafka runtime configuration lives under run.kafka (brokers, topic, consumerGroup, security protocol, SASL/TLS settings).

A migration handles existing func.yaml files automatically: it moves deploy.options.scale to the top-level scale, lifts the old flat metric/target/utilization fields into scale.kpa, and renames deploy.deployer to deploy.activeDeployer.

The PR also includes testing deployment scenarios (A-G) with collected Kubernetes resource snapshots for each deployer/Kafka combination.

/kind enhancement

Supersedes #4045

Closes #2419

Release Note

Functions can now consume from Apache Kafka using KEDA-based consumer-lag autoscaling. Configure Kafka connection details (brokers, topic, consumer group, SASL/TLS) under `run.kafka` in func.yaml and set `deployer: keda` to deploy with a ScaledObject that scales based on Kafka consumer lag. The `scale` config is now a top-level field with deployer-specific sub-keys: `scale.kpa` (knative) and `scale.keda` (keda), the latter supporting tuning knobs like pollingInterval, cooldownPeriod, and per-trigger lagThreshold/activationLagThreshold.

Docs


@knative-prow knative-prow Bot added the kind/enhancement Feature additions or improvements to existing label Sep 8, 2026
@knative-prow

knative-prow Bot commented Sep 8, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: aliok
Once this PR has been reviewed and has the lgtm label, please assign gauron99 for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@knative-prow knative-prow Bot added the size/XXL 🤖 PR changes 1000+ lines, ignoring generated files. label Sep 8, 2026
@aliok
aliok requested a lite review from Copilot September 8, 2026 20:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are correctness and UX gaps around KEDA trigger configurations (unsupported trigger types/combos) and TriggerAuthentication construction that can lead to broken Kafka scaling at runtime.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds KEDA-based Kafka consumer-lag autoscaling support and restructures func.yaml autoscaling configuration so KEDA/KPA settings live under a new top-level scale field, with a migration to preserve backwards compatibility.

Changes:

  • Introduces top-level scale with keda/kpa sub-keys, updates validation, schema, docs, and a migration from deploy.options.scale.
  • Adds KEDA Kafka scaling implementation that creates a ScaledObject + optional TriggerAuthentication, and updates the KEDA remover to clean these up.
  • Updates deployers, CLI, and tests to use Deploy.ActiveDeployer/Deploy.ActiveExpose and the new Function.Scale location.
File summaries
File Description
schema/func_yaml-schema.json Updates schema for scale top-level move and new KEDA/KPA structures.
pkg/pipelines/tekton/pipelines_provider.go Renames deployed-state fields to ActiveDeployer/ActiveExpose.
pkg/mock/deployer.go Updates mock deployer to use Deploy.ActiveDeployer.
pkg/knative/deployer.go Reads autoscaling annotations from Function.Scale (KPA sub-key).
pkg/keda/remover.go Adds explicit deletion of Kafka KEDA scaling resources during remove.
pkg/keda/kafka_scaling.go Implements unstructured KEDA ScaledObject/TriggerAuthentication helpers.
pkg/keda/kafka_scaling_test.go Unit tests for Kafka scaling object construction helpers.
pkg/keda/kafka_scaling_int_test.go Integration test verifying KEDA resources created/removed in-cluster.
pkg/keda/deployer.go Wires trigger selection and creates Kafka scaling resources during deploy.
pkg/k8s/wait.go Treats Deployments with desired replicas 0 as “available” for waits.
pkg/k8s/deployer.go Seeds Deployment replicas from Function.Scale.Min.
pkg/functions/function.go Adds Function.Scale, renames deployed-state fields, hooks new validation.
pkg/functions/function_scale.go New deployer-aware scale validation entry point.
pkg/functions/function_options.go Reworks scale types into KEDA/KPA; keeps legacy options-scale for migration only.
pkg/functions/function_options_unit_test.go Moves scale tests to ValidateScale; retains resource validation tests.
pkg/functions/function_migrations.go Adds 0.37.0 migration to move scale to top-level + KPA flattening.
pkg/functions/function_migrations_unit_test.go Adds unit tests covering the new scale migration scenarios.
pkg/functions/client.go Uses Deploy.ActiveDeployer/ActiveExpose for deploy/remove state tracking.
pkg/functions/client_test.go Updates tests for renamed deployed-state fields.
pkg/deployer/testing/integration_test_helper.go Updates integration helper to set Function.Scale and ActiveExpose.
pkg/config/config.go Uses Deploy.ActiveDeployer when seeding global config.
e2e/e2e_recorder_test.go Updates E2E recorder test to set Function.Scale.
e2e/e2e_expose_test.go Updates E2E exposure tests for trigger requirement and ActiveExpose/Deployer.
docs/reference/func_yaml.md Documents new scale and run.kafka structures and examples.
docs/reference/func_deploy.md Updates deployer flag help to reflect broader KEDA trigger support.
cmd/func-util/main.go Uses Deploy.ActiveDeployer as fallback for effective deployer.
cmd/deploy.go Updates help text and deployed-state field usage; adjusts exposure-record warning logic.
cmd/deploy_test.go Updates deploy tests for ActiveDeployer/Expose and KEDA trigger requirement.
cmd/delete_test.go Updates delete tests for ActiveDeployer and KEDA trigger requirement.
Review details

Suppressed comments (1)

docs/reference/func_yaml.md:218

  • The KEDA example configures both an HTTP trigger and a Kafka trigger, but the current deployer implementation uses an HTTPScaledObject for HTTP and a separate ScaledObject for Kafka; those generally can’t both target the same Deployment. Update the example to a supported single-trigger configuration (or add an explicit limitation note).
      - type: http
        targetValue: 200
      - type: kafka
        lagThreshold: 5
        activationLagThreshold: 0
  • Files reviewed: 29/29 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/keda/kafka_scaling.go
Comment thread pkg/functions/function_scale.go
Comment thread pkg/keda/kafka_scaling.go Outdated
Comment thread pkg/keda/remover.go Outdated
Comment thread docs/reference/func_yaml.md Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

There are a few correctness gaps (stale Kafka scaler resources not cleaned up on trigger removal, incomplete mutual-TLS wiring for KEDA auth, and an integration test helper still setting legacy scale config) that should be addressed before approval.

Review details

Suppressed comments (6)

Previously missed (5) — in code that hasn't changed since the last review.

pkg/deployer/testing/integration_test_helper.go:746

  • This integration test helper still sets scaling under Deploy.Options.Scale, but the deployers now read scaling from the top-level Function.Scale. As written, minScale/maxScale here will no longer affect the deployed resources, so the test isn’t exercising scale bounds as intended.
    pkg/keda/deployer.go:211
  • Kafka scaler resources are only created when a Kafka trigger is configured, but there’s no corresponding cleanup when the Kafka trigger is removed (or when switching back to HTTP-only). That can leave a stale ScaledObject/TriggerAuthentication behind and prevent switching triggers cleanly (or keep scaling based on lag unexpectedly).
    pkg/keda/kafka_scaling.go:92
  • needsTriggerAuth doesn’t consider mutual-TLS inputs (run.kafka.tls.clientCert/clientKey). If a user configures mTLS without a CA cert and without SASL, the ScaledObject won’t reference a TriggerAuthentication at all, so the scaler can’t be given the cert/key material.

This issue also appears on line 178 of the same file.
docs/reference/func_yaml.md:150

  • The documented defaults for scale.min/scale.max don’t match the current deployer implementations. For example, the raw deployer defaults to 1 replica, and the keda deployer defaults HTTP scaler bounds to min=1/max=10 when unset.
    schema/func_yaml-schema.json:263
  • schema/func_yaml-schema.json is listed as a generated file in AGENTS.md and shouldn’t be edited directly. Please regenerate it via the repo’s codegen workflow (and commit the generated output) so it stays consistent with the Go struct tags and other generated artifacts.

pkg/keda/kafka_scaling.go:187

  • buildTriggerAuth only wires run.kafka.tls.caCert into the TriggerAuthentication. If run.kafka.tls.clientCert/clientKey are set (mutual TLS), they’re currently ignored, so KEDA can’t be configured with the client cert/key even though the func.yaml schema/docs allow them.
	if kafka.TLS != nil && kafka.TLS.CACert != "" {
		caSecretName, caKey := findSecretForPath(kafka.TLS.CACert, f.Run.Volumes)
		if caSecretName != "" {
			secretRefs = append(secretRefs, map[string]interface{}{
				"parameter": "ca",
				"name":      caSecretName,
				"key":       caKey,
			})
		}
	}
  • Files reviewed: 29/29 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

It introduces a few correctness issues in the KEDA Kafka auth/scaling path and also directly edits a generated schema file that should be regenerated via the project’s codegen workflow.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

pkg/functions/function_scale.go:24

  • For deployer: keda, allowing scale.max=0 will result in maxReplicaCount=0 in the created HTTPScaledObject/ScaledObject, which is not a valid HPA maxReplicas value. Treat 0 as invalid for keda (or interpret it as "unset" and apply the keda default) to avoid generating invalid autoscaler specs.
    schema/func_yaml-schema.json:263
  • schema/func_yaml-schema.json is listed as a generated file in AGENTS.md ("Never Do: Edit generated files directly"). Direct edits here are likely to get overwritten and can drift from the generator output; regenerate the schema instead and commit the regenerated result.
  • Files reviewed: 29/29 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread pkg/keda/deployer.go Outdated
Comment thread pkg/keda/kafka_scaling.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The keda deploy path can silently proceed with a configured Kafka trigger but missing run.kafka, resulting in a deploy that does not create Kafka scaling resources and does not error.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

pkg/keda/kafka_scaling.go:159

  • This comment says {{ configMap:... }} refs become a “literal/resolved” KAFKA_SASL_PASSWORD env var, but pkg/k8s/deployer.go actually wires template refs via EnvVar.ValueFrom (secretKeyRef/configMapKeyRef). The wording is misleading for anyone maintaining the TriggerAuthentication wiring.
  • Files reviewed: 29/29 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread pkg/keda/deployer.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The KEDA deployer currently produces incorrect/no-HTTP-trigger service URLs and has gaps in deploy-time validation/ScaledObject TLS metadata that can break valid Kafka/KEDA configurations.

Review details

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

pkg/keda/deployer.go:150

  • keda.Deployer can be called without going through Function.Validate; in that case, scale.max=0 (or any <1) will be threaded into the HTTPScaledObject/ScaledObject and fail server-side (HPA maxReplicas must be >= 1). Since this deployer already does extra validation for bypass callers (e.g. kafka trigger without run.kafka), it should also fail fast on an invalid scale.max here.
    pkg/keda/deployer.go:192
  • The in-cluster URL returned for the no-HTTP-trigger case hardcodes port 8080, but the function’s Service listens on port 80 (targetPort 8080). This produces a URL that won’t resolve unless clients also use 8080; other parts of the codebase (e.g. pkg/k8s/describer.go) use the :80 default for Service URLs.
    pkg/keda/kafka_scaling.go:302
  • Kafka TLS enablement for the KEDA trigger is currently inferred from run.kafka.tls being non-nil. That breaks valid configs that set securityProtocol=SSL/SASL_SSL but rely on system CAs (no explicit run.kafka.tls block): the ScaledObject will omit tls=enable and KEDA will attempt plaintext connections. TLS should be enabled based on run.kafka.securityProtocol instead of presence of the tls struct.
  • Files reviewed: 29/29 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

There are at least two correctness/documentation issues to address (empty KEDA trigger list can silently deploy without any scaler; run.kafka.tls docs contradict the validated/implemented contract).

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

pkg/keda/deployer.go:105

  • If scale.keda is present but scale.keda.triggers is an explicitly empty list, triggers(f) returns an empty slice, so Deploy skips both the HTTPScaledObject and the Kafka ScaledObject paths and silently falls back to returning a cluster-local Service URL (no KEDA scaling at all). Since Deploy can be reached without Function.Validate, it should fail fast on an empty trigger list instead of deploying a misconfigured function.
    docs/reference/func_yaml.md:263
  • The docs claim run.kafka.tls is required for securityProtocol SSL/SASL_SSL, but validation only enforces that if tls is set then the protocol must be SSL/SASL_SSL; it allows SSL/SASL_SSL with no tls block (relying on the system trust store), which is also explicitly supported by the KEDA ScaledObject builder. Please adjust the wording so it’s not stricter than the actual config contract.
  • Files reviewed: 30/30 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

A few edge-case correctness and clarity issues remain (notably around KEDA Kafka SASL mechanism handling and validation messaging) that should be addressed before approval.

Review details

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

pkg/keda/deployer.go:226

  • If run.kafka.sasl.mechanism is set to an unexpected value (possible for library callers that bypass Function.Validate), buildScaledObject will set trigger metadata "sasl" to an empty string, which can produce an invalid KEDA trigger configuration. Consider failing fast here with a clear error before creating scaling resources.
    pkg/functions/function_scale.go:94
  • The default-case error says the allowed trigger types are "http, kafka, cron", but "cron" is explicitly rejected as not yet supported. This is misleading when users mistype the trigger type and then try "cron" based on the message.
    pkg/keda/kafka_scaling.go:106
  • parseSecretRef uses ad-hoc trimming/splitting and will accept strings that start with "{{" but don’t actually match the project’s template-ref format (and it can diverge from other parsing/validation that uses fn.TemplateRefPattern). Reusing the shared TemplateRefPattern keeps parsing consistent and avoids surprising secret lookups for malformed refs.
  • Files reviewed: 30/30 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The schema currently contradicts code validation for scale.kpa.target, and the new migration can drop existing scaling config when Function.Root is empty (library callers).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

schema/func_yaml-schema.json:373

  • The JSON schema allows scale.kpa.target values down to 0, but runtime validation (ValidateScale/validateKPAScale) requires target >= 0.01. This mismatch will let editors/clients accept invalid func.yaml values that later fail validation; align the schema minimum with the code.
  • Files reviewed: 30/30 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread pkg/functions/function_migrations.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Moderate issues remain in Kafka migration, schema validation, KEDA preflight validation, and scaler cleanup ordering.

Review details

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

pkg/keda/deployer.go:184

  • This direct-deployer preflight still omits constraints enforced by ValidateScale: invalid pollingInterval/cooldownPeriod values, per-trigger threshold bounds, and the mutual exclusion of scale.keda and scale.kpa. Callers that invoke the deployer without Function.Validate can therefore create/update the raw Deployment before KEDA rejects or silently ignores an invalid scaler configuration. Reuse the shared validation for explicit scale.keda/kpa configs while preserving the intentional default-HTTP fallback when neither is set.

pkg/functions/function_migrations.go:390

  • oldDeploy only captures options, deployer, and expose; it never reads the legacy deploy.kafka field. A pre-migration func.yaml with Kafka configuration therefore loses it from Run.Kafka during load/migration, so a Kafka trigger will fail validation or deploy without the runtime Kafka settings. Add the legacy field to the migration input and copy it to f.Run.Kafka, with a regression test.
	type oldDeploy struct {
		Options  oldOptions `yaml:"options,omitempty"`
		Deployer string     `yaml:"deployer,omitempty"`
		Expose   string     `yaml:"expose,omitempty"`
	}

pkg/functions/function_options.go:31

  • ValidateScale rejects an empty trigger list, but the published schema has no minItems constraint here, so scale.keda.triggers: [] is accepted by schema-based tooling and only fails later during deployment. Add the schema constraint so the declared KEDA contract catches this invalid configuration early.
	Triggers        []KEDATrigger `yaml:"triggers,omitempty"`

pkg/keda/deployer.go:309

  • Scaler reconciliation happens only after d.Deployer.Deploy has updated the Deployment and waited for availability, and these deletes run only when a trigger type is removed. An existing HTTPScaledObject/ScaledObject with a zero minimum can keep resetting the Deployment to zero while the raw deploy waits for one ready replica, so scale-to-zero functions can time out on redeploys or HTTP↔Kafka transitions before this cleanup runs. Pause/remove the active scaler before the raw update, then recreate or reconcile it after readiness.
	if !wantHTTP {
		// No HTTP trigger: a prior deploy's HTTPScaledObject and
		// interceptor bridge Service, if any, are now orphaned.
		if err := deleteHTTPScaledObject(ctx, namespace, f.Name); err != nil {
  • Files reviewed: 35/35 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved Kafka-trigger validation, legacy migration and scale compatibility, KEDA readiness, secret-reference handling, and regression coverage remain.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (7)

pkg/functions/function_migrations.go:394

  • This migration only re-reads deploy.options.scale and the legacy observed deployer/exposure fields; it never captures or moves deploy.kafka. Because the current unmarshal has no destination for that key, an existing function using the legacy deploy.kafka layout loses its Kafka runtime configuration during migration and will no longer consume Kafka after writing the migrated file. Add the legacy field to the migration input and copy it to f.Run.Kafka when run.kafka is not already set.
	var disk struct {
		Deploy oldDeploy     `yaml:"deploy,omitempty"`
		Scale  *ScaleOptions `yaml:"scale,omitempty"`
	}

pkg/functions/function_scale.go:103

  • The Kafka-runtime check is only one-way: it rejects a kafka trigger without run.kafka, but it allows run.kafka with a KEDA http trigger. That combination passes validation, causes the deployer to create only an HTTPScaledObject, and never creates the Kafka ScaledObject, so the Kafka consumer is not scaled by lag (while run.kafka is documented as replacing HTTP serving). Reject this combination or require a Kafka trigger whenever run.kafka is used with deployer: keda.
		case "kafka":
			sawKafka = true
			if kafka == nil {
				errors = append(errors, fmt.Sprintf("scale.keda.triggers[%d] has type kafka but run.kafka is not configured", i))

pkg/k8s/deployer.go:677

  • Options.Scale remains a public field and is explicitly retained for legacy YAML deserialization, but direct Function/Client.Deploy callers that still populate f.Deploy.Options.Scale are silently ignored here because deployment generation now reads only f.Scale. Since Client.Deploy does not run the YAML migration, existing library callers can deploy with default replicas instead of their requested scale. Either use the legacy field as a fallback across deployers or migrate/reject it in the public deployment path.
	replicas := int32(1)
	if f.Scale != nil && f.Scale.Min != nil && *f.Scale.Min > 0 {
		replicas = int32(*f.Scale.Min)

pkg/keda/deployer.go:640

  • The new cooldownPeriod and HTTP targetValue mapping changes the generated HTTPScaledObject, but this package has no focused test for httpScaledObject with non-default values; the existing added tests exercise validation and Kafka resources instead. Add a unit test that asserts both configured values are copied into the HTTP scaler so a regression cannot silently restore the hardcoded defaults.
	cooldown := int32(300)
	targetValue := int64(100)
	if scale != nil && scale.KEDA != nil {
		if scale.KEDA.CooldownPeriod != nil {
			cooldown = *scale.KEDA.CooldownPeriod
		}
		for _, trig := range scale.KEDA.Triggers {
			if trig.Type == "http" && trig.TargetValue != nil {
				targetValue = *trig.TargetValue
				break
			}

pkg/keda/deployer.go:447

  • ensureScaledObject returns as soon as the API create/update succeeds, unlike the HTTP path which waits for the scaler's Ready condition. KEDA can accept this object and then mark it not ready because of invalid trigger metadata, authentication, or operator/RBAC problems; Deploy will still report success even though consumer-lag scaling is not active. Poll the ScaledObject status and surface a non-ready condition before declaring the Kafka deployment successful.
		kt := kafkaTrigger(triggers)
		so := buildScaledObject(f, kt, deployment, namespace, minScale, maxScale)
		if so != nil {
			if err := ensureScaledObject(ctx, dynClient, so); err != nil {
				return fn.DeploymentResult{}, fmt.Errorf("failed to ensure ScaledObject: %w", err)

pkg/keda/kafka_scaling.go:145

  • This returns the first matching volume, so an earlier parent mount such as /etc/kafka can shadow a later, more-specific TLS mount at /etc/kafka/ca. The derived key then becomes ca/ca.crt, causing the TriggerAuthentication to reference a nonexistent Secret key even though the correct volume is configured. Select the longest matching mount path (or reject overlapping mounts) before returning the Secret reference.
func findSecretForPath(certPath string, volumes []fn.Volume) (secretName, key string) {
	for _, v := range volumes {

pkg/keda/lister.go:115

  • The new Kafka-only listing path is not covered by the existing KEDA lister integration test: the shared helper deploys the default HTTP-triggered function, while this branch requires an absent HTTPScaledObject and a present Kafka ScaledObject. Add a focused test for that state, including the fallback readiness and cluster-local URL, so regressions in the dynamic-resource lookup are detected.
	if !hasHTTPTrigger {
		// Absence of an HTTPScaledObject isn't proof this is a Kafka-only
		// function -- an http-triggered function's scaler could have been
		// deleted externally, failed to create, or be mid-transition.
		// Corroborate with the Kafka ScaledObject before assuming
		// Kafka-only; if neither scaler exists, something's broken and
		// should be reported instead of silently listed as healthy.
		if _, err := dynClient.Resource(scaledObjectGVR).Namespace(namespace).Get(ctx, scaledObjectName(name), metav1.GetOptions{}); err != nil {
			if errors.IsNotFound(err) {
				return fn.ListItem{}, fmt.Errorf(
					"function %q uses the keda deployer but has neither an HTTPScaledObject nor a Kafka ScaledObject: the scaler may have failed to create or been deleted externally", name)
			}
			return fn.ListItem{}, fmt.Errorf("unable to get ScaledObject: %v", err)
		}
	}
  • Files reviewed: 35/35 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread pkg/keda/deployer.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved migration and raw-deployer scale validation issues remain, and the documentation lists an unsupported cron trigger.

Review details

Suppressed comments (4)

docs/reference/func_yaml.md:165

  • The documentation presents cron as a usable KEDA trigger, but validateKEDAScale explicitly rejects it as not yet supported and the deployer only materializes HTTP and Kafka triggers. A user following this section gets a validation error; either implement cron or clearly mark/remove these options until it is supported.
  - `triggers`: a list of KEDA triggers. At least one is required. Each trigger has a `type` of `http`, `kafka`, or `cron`:
    - `http`: scales based on incoming HTTP request rate.
      - `targetValue`: requests per second per replica before scaling up. Default is 100.
    - `kafka`: scales based on consumer group lag. Requires [`run.kafka`](#runkafka) to be configured.
      - `lagThreshold`: average consumer lag per partition that triggers scaling up. Default is 10.

pkg/functions/function_migrations.go:500

  • At migration time f.Deployer is still the value read from func.yaml; command/global overrides are applied later (cmd/deploy.go:313). For an old file with flat deploy.options.scale and no persisted deployer, selecting --deployer raw or keda leaves validKPADeployer true, creates scale.kpa, and skips the KEDA HTTP default; validation then rejects the requested deployer. Defer this conversion/defaulting until the effective deployer is known, or preserve the legacy settings without binding them to KPA.
		validKPADeployer := f.Deployer == "" || f.Deployer == "knative"
		if hasFlat && newScale.KPA == nil && validKPADeployer {

pkg/functions/function_migrations.go:444

  • This migration never decodes the legacy deploy.kafka field or copies it into f.Run.Kafka. Because the normal YAML unmarshal ignores that old key, upgrading an existing Kafka function drops its consumer configuration: Kafka-triggered functions then fail validation, while fixed/HTTP-scaled consumers are deployed without Kafka. Decode the legacy field and move it to run.kafka (while preserving an already-present top-level value), and add a migration regression test.
	var disk struct {
		Deploy oldDeploy     `yaml:"deploy,omitempty"`
		Scale  *ScaleOptions `yaml:"scale,omitempty"`
	}

pkg/k8s/deployer.go:677

  • This path now casts the top-level scale.min directly to int32, but Client.Deploy does not call Function.Validate, so direct raw-deployer callers can pass values outside the Kubernetes range and get wraparound (for example 2147483648 becomes zero) instead of an error. Add the same bounds check used by the KEDA direct-deploy preflight before narrowing this value.
	replicas := int32(1)
	if f.Scale != nil && f.Scale.Min != nil && *f.Scale.Min > 0 {
		replicas = int32(*f.Scale.Min)
  • Files reviewed: 35/35 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Six moderate findings remain unresolved across migration, schema, validation, lifecycle ordering, and pipeline deployer handling.

Review details

Suppressed comments (6)

pkg/functions/function_migrations.go:417

  • This 0.38.0 migrator never reads a legacy deploy.kafka field; it only captures the old scale and deployer/expose fields. Since the current Function no longer has a DeploySpec.Kafka field, YAML unmarshalling and this disk-side struct both discard that data, so existing functions using the promised old Kafka location lose their runtime configuration instead of being moved to run.kafka. Add the legacy field to the migration input and copy it into f.Run.Kafka, with a migration test.
// migrateScaleToTopLevel moves scale config from deploy.options.scale to the
// top-level scale field, moves the flat metric/target/utilization fields
// (from pre-0.38.0 func.yaml files) into the kpa sub-key, and renames
// DeploySpec's observed-state deploy.deployer/deploy.expose YAML keys to
// deploy.activeDeployer/deploy.activeExpose (they collided in name with the
// top-level deployer/expose fields -- user intent -- despite meaning the
// opposite thing: observed, currently-deployed state).
func migrateScaleToTopLevel(f Function, m migration) (Function, error) {

pkg/functions/function_scale.go:33

  • The new int32 upper-bound validation is not represented in the generated schema: scale.min and scale.max still accept any non-negative integer, so values such as 2147483648 pass schema validation but are rejected here (and would not fit Kubernetes replica counts). Add the same maximum to the schema source/generator and regenerate schema/func_yaml-schema.json.
	if scale.Min != nil && *scale.Min > math.MaxInt32 {
		errors = append(errors, fmt.Sprintf("scale.min has invalid value: %d, must be <= %d", *scale.Min, math.MaxInt32))
	}
	if scale.Max != nil && *scale.Max > math.MaxInt32 {
		errors = append(errors, fmt.Sprintf("scale.max has invalid value: %d, must be <= %d", *scale.Max, math.MaxInt32))

pkg/k8s/deployer.go:677

  • Because the raw deployer is callable without Function.Validate, a negative scale.min reaches this condition, skips the block, and is silently normalized to one replica. That violates the non-negative scale contract and is inconsistent with the overflow guard added here; reject values < 0 as well, while keeping the initial replica fallback for exactly zero.
	if f.Scale != nil && f.Scale.Min != nil && *f.Scale.Min > 0 {

pkg/keda/deployer.go:439

  • When a valid Kafka configuration is changed from SASL/TLS credentials to no authentication, this deletes the existing TriggerAuthentication before ensureScaledObject updates the existing ScaledObject to remove its authenticationRef. If that update is rejected or fails transiently, the old scaler continues referencing a deleted authentication resource and Kafka scaling is broken. Reconcile the ScaledObject first, then delete the now-unreferenced TriggerAuthentication only after that update succeeds.
			if err := deleteTriggerAuth(ctx, dynClient, namespace, triggerAuthName(f.Name)); err != nil {
				fmt.Fprintf(os.Stderr, "warning: %v\n", err)

pkg/pipelines/tekton/pipelines_provider.go:161

  • The effective deployer is only copied into Deploy.ActiveDeployer, leaving f.Deployer empty when a flag-less pipeline call relies on the persisted active deployer. For a previously deployed KEDA function this makes the later f.Validate() reject scale.keda with requires deployer: keda, even though func-util will select KEDA from ActiveDeployer; for an active raw function, KPA settings can instead pass validation and then be silently ignored. Copy the resolved deployer into the intent field (or validate against the resolved value) before validation and serializing the pipeline input.
    schema/func_yaml-schema.json:285
  • scale.keda.triggers is documented and runtime-validated as required, but this generated schema only sets minItems when the property is present and has no required entry. Consequently scale: {keda: {}} passes schema validation while ValidateScale and the KEDA deployer reject it. Make the source/generator emit required: ["triggers"] and regenerate the schema.
  • Files reviewed: 35/35 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

aliok added a commit to aliok/func that referenced this pull request Sep 14, 2026
@aliok
aliok requested a lite review from Copilot September 14, 2026 10:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved migration, scaler-transition cleanup, exposure, and schema-validation issues block approval.

Review details

Suppressed comments (6)

pkg/functions/function_migrations.go:444

  • The PR description says existing configurations with Kafka under deploy.kafka are migrated to run.kafka, but this migration only reads deploy.options.scale, deploy.deployer, and deploy.expose. Since the current Function shape has no deploy.kafka field, such a legacy config is silently dropped during unmarshal and the resulting function is no longer a Kafka consumer. Add an explicit legacy Kafka field to the migration input and copy it to f.Run.Kafka before writing the migrated function.
	type oldDeploy struct {
		Options  oldOptions `yaml:"options,omitempty"`
		Deployer string     `yaml:"deployer,omitempty"`
		Expose   string     `yaml:"expose,omitempty"`
	}
	var disk struct {
		Deploy oldDeploy     `yaml:"deploy,omitempty"`
		Scale  *ScaleOptions `yaml:"scale,omitempty"`
	}

pkg/functions/function_migrations.go:500

  • Migration runs before cmd/deploy applies the CLI or global --deployer override. For a legacy file with flat deploy.options.scale and no persisted top-level deployer, this branch sees an empty f.Deployer and moves the fields into scale.kpa; a later --deployer=keda then makes ValidateScale reject the migrated function. Please defer this deployer-dependent classification until after the effective deployer is resolved (and add a CLI/global-override regression test).
		validKPADeployer := f.Deployer == "" || f.Deployer == "knative"
		if hasFlat && newScale.KPA == nil && validKPADeployer {

pkg/keda/deployer.go:341

  • A failed deletion is only logged before the HTTP scaler path proceeds. Because the Deployment remains in place when switching from Kafka to HTTP, ownerReferences do not clean up this ScaledObject; the subsequent HTTPScaledObject can therefore conflict with the stale Kafka scaler or leave both controllers scaling the workload. Treat non-NotFound cleanup failures as fatal, or wait until the old scaler is actually gone before creating the HTTP scaler.
		if err := deleteScaledObject(ctx, dynClient, namespace, scaledObjectName(f.Name)); err != nil {
			fmt.Fprintf(os.Stderr, "warning: %v\n", err)
		} else if d.verbose {

pkg/keda/deployer.go:327

  • If an existing HTTPScaledObject cannot be deleted (for example because the deployer lacks permission), this warning is ignored and the Kafka scaler is created anyway. The Deployment is retained during a trigger transition, so its owner reference cannot garbage-collect that stale HTTP scaler; KEDA can then reject the new scaler or have two scalers competing for the same Deployment. Treat non-NotFound cleanup failures as a deploy error, or wait until the old scaler is actually gone before provisioning the replacement.
		if err := deleteHTTPScaledObject(ctx, namespace, f.Name); err != nil {
			fmt.Fprintf(os.Stderr, "warning: %v\n", err)
		} else if d.verbose {

pkg/keda/deployer.go:406

  • When a Kafka-only function requests expose: route, this branch skips the configured KEDA exposer and never records an applied exposure. Function.Expose explicitly applies to the KEDA deployer, so an OpenShift deploy can report success while silently ignoring the user's route request; reject this combination before creating resources or implement a Kafka-only exposure path.
	} else {
		// No HTTP trigger — URL is the app service. The Service listens on
		// port 80 (routing to the container's DefaultHTTPPort via
		// targetPort), so the URL, like elsewhere in the codebase (e.g.
		// pkg/k8s/describer.go), has no explicit port.
		url = fmt.Sprintf("http://%s.%s.svc", f.Name, namespace)

		// A prior deploy may have exposed this function over HTTP. Nothing
		// reconciles that exposure once the HTTP trigger is gone, so clear
		// it the same way deployClusterLocal does -- otherwise the old
		// Route and the Service's exposure annotations stay active,
		// pointing at a function that no longer has anything serving HTTP.
		target := deployTarget{
			clientset: k8sClientset,
			dynClient: dynClient,
			ref:       deployer.NewExposureRef(f.Name, namespace, ""),
		}
		if err := d.clearExposure(ctx, target, appService.Annotations[k8s.RouteNamespaceAnnotation]); err != nil {
			return fn.DeploymentResult{}, err
		}

schema/func_yaml-schema.json:364

  • The generated schema now represents scale.kpa.target as exclusiveMinimum: true with minimum: 0, so values such as 0.005 pass schema validation even though ValidateScale rejects every value below 0.01. This makes schema validation and runtime validation disagree; update the generator metadata/generation path so the documented decimal lower bound is enforced consistently.
  • Files reviewed: 36/36 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@aliok

aliok commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

/retest-required

@gauron99 gauron99 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass on the PR. Thanks for doing the migration and the scale yaml movement too!
Im not sure if my page is messed up but im seeing a lot of ActiveDeployer left over after that 4th commit -- Ive stopped naming them in the reviews after a couple.

Ive also though we could simplify the PR by removing the deployer migration (which seems unrelated and its there as a transient "fix") -> we fix the empty scale validator -> we can remove the deployer migration

Comment thread cmd/func-util/main.go Outdated
Comment thread cmd/delete_test.go Outdated
Comment thread cmd/delete_test.go Outdated
Comment thread cmd/delete_test.go Outdated
Comment thread cmd/delete_test.go Outdated
Comment thread pkg/config/config.go Outdated
Comment thread pkg/functions/function_migrations.go Outdated
// decision and the keda-defaults-to-http-trigger block see it. Read from
// disk rather than f.Deploy.ActiveDeployer so the migration is robust to
// callers that pass a Function not populated via the primary unmarshal.
if f.Deployer == "" && disk.Deploy.Deployer != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we fix the validation (to allow empty scale because default should be the http scaler as is today on main) we can completely remove this deploy.deployer -> deployer intent transition.

This is technically fixing one scenario if im not missing any and that is if a user has a function deployed and they run func delete with a new binary the deploy.deployer (which was the only key for them but now is status only) would get removed and the intent would be lost so re-running a func deploy would run with defaultDeployer instead of their declared one. I think that blast radius is extremely small and it does not need to be coupled with this PR

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — done. Removed the deploy.deployerdeployer intent-recovery block entirely. The remaining edge you noted (a func delete with a newer binary dropping the intent so a re-deploy falls back to the default deployer) is pre-existing on main and decoupled from this PR as you suggested — tracked in #4054.

Comment thread pkg/functions/function_scale.go Outdated
// deployer and Kafka config. It replaces the previous validateScaleDeployer,
// validateKEDAScale, and validateKPAScale functions with a single entry point.
func ValidateScale(scale *ScaleOptions, deployer string, kafka *KafkaConfig) (errors []string) {
if deployer == "keda" && (scale == nil || scale.KEDA == nil || len(scale.KEDA.Triggers) == 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we go with the rest of fixes we can drop this condition block and say that empty scale is valid for every deployer -> for keda deployer it simply means "use http scaler"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — dropped the block. scale == nil now returns valid for every deployer; for deployer: keda it means "use the http scaler", supplied by the deployer's triggers() helper. Only an explicitly-written scale.keda with an empty triggers: [] is still rejected.

Comment thread pkg/keda/deployer.go
// callers, tests): without this check, Deploy would silently skip
// both the HTTPScaledObject and Kafka ScaledObject paths and deploy
// with no scaler at all.
return fn.DeploymentResult{}, fmt.Errorf("function %q: deployer keda requires at least one trigger in scale.keda.triggers", f.Name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as mentioned above in some test i reviewed first I think we should build-in a default to always fallback to http scaler (as is currently on main now) when the scale.keda is nil (not explicitly [] empty) & deployer is keda.
This will give us backwards compatibility and super ease of use for users because you dont need to worry about anything after func deploy --deployer keda.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — deployer: keda with a nil scale.keda now defaults to a single http trigger (backwards-compatible with main). func deploy --deployer keda works with no scale config at all; only an explicitly-written empty triggers: [] is rejected. Docs (func_yaml.md) updated to state this.

Comment thread pkg/keda/deployer.go
@@ -99,8 +102,165 @@ func (k *kedaDeployerDecorator) UpdateLabels(function fn.Function, labels map[st
}

func (d *Deployer) Deploy(ctx context.Context, f fn.Function) (fn.DeploymentResult, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the deployer seems to be doing a lot of validation that is already done beforehand duplicating. Also the deployer should be minimal "executor". I think we could trim this down to nil guards and name validators (for k8s resource char limits) and secret path?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed the deployer is doing too much and should be a minimal executor — but Id like to keep it for this PR and do the trim separately, tracked in #4055.

The reason it can't just be deleted now: these checks aren't re-validation within one process — each guards a different deploy process, and Function.Validate() (which runs ValidateScale) is skipped in most of the flows that reach the deployer. func-util deploy (the on-cluster Tekton deploy step) calls client.Deploy(ctx, f) with no Validate(), and Client.Deploy itself only validates expose/switch, never scale. So for every on-cluster deploy (and any library caller) the deployer's preflight is currently the only validation that runs.

The genuinely deployer-only bits you called out — k8s resource-name length and secret/TLS paths — stay regardless. The rest duplicates ValidateScale/ValidateKafkaSecurity, and the clean fix is to validate once at the shared choke point (Client.Deploy, which both the CLI and func-util go through) and then shrink the deployer. That's a cross-deployer change with a scoping decision (deploy-relevant validation vs. the full authoring Validate()), so it's better as its own PR — #4055.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved moderate migration/schema issues and critical KEDA cleanup failures block approval.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

docs/reference/func_yaml.md:156

  • This new scale documentation points to options.resources.limits.concurrency, but resources is serialized under deploy.options (as shown by the example immediately below). A user following this path would write a top-level options block that the schema rejects; document the actual deploy.options.resources.limits.concurrency path.
  - `target`: target value for the metric. Defaults to `options.resources.limits.concurrency` when given. Float >= 0.01, default is 100. See related [Knative docs](https://knative.dev/docs/serving/autoscaling/concurrency/#soft-limit).

pkg/functions/function_migrations.go:519

  • The migration decides whether to create scale.kpa and whether to inject the default HTTP trigger from f.Deployer, but older files can carry only the observed deployer in deploy.deployer. The code copies that value into f.Deploy.Deployer later (lines 536-540), so a legacy KEDA file reaches validation with either a misplaced scale.kpa or an empty scale.keda.triggers and fails/loses the promised HTTP default. Use the effective deployer from the legacy observed field as a migration-only fallback without changing the intent field.
	if f.Deployer == "keda" {
		if f.Scale == nil {
			f.Scale = &ScaleOptions{}
		}
		if f.Scale.KEDA == nil {
  • Files reviewed: 31/32 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment thread pkg/keda/deployer.go
Comment thread pkg/keda/deployer.go
Comment thread pkg/functions/function.go
Comment thread pkg/functions/function_migrations.go
Comment thread pkg/functions/function_options.go
…r transition

When a function switches trigger type (http<->kafka), the keda deployer
deletes the other type's stale scaler before creating the new one. Those
deletions were best-effort (logged as warnings), so a non-NotFound delete
failure left two scalers targeting the same Deployment while Deploy still
reported success. Unlike Remover.Remove, a transition keeps the Deployment,
so owner-reference GC does not clean up the leftover scaler.

Return the error instead. The delete helpers already suppress NotFound, so
steady-state single-type deploys stay a no-op; only a real API failure mid
-transition now aborts. Add unit coverage that the delete helpers propagate
non-NotFound errors.
@aliok

aliok commented Sep 17, 2026

Copy link
Copy Markdown
Member Author

/retest-required

@gauron99

Copy link
Copy Markdown
Contributor

this seems to be an issue

 deleted ScaledObject default/testremote-default-kafka (if it existed); KEDA's finalizer means removal may still be in progress
        ERROR: cannot deploy the function: deploy error. failed to remove stale Kafka TriggerAuthentication before switching triggers: failed to delete TriggerAuthentication default/testremote-default-kafka-auth: triggerauthentications.keda.sh "testremote-default-kafka-auth" is forbidden: User "system:serviceaccount:default:default" cannot delete resource "triggerauthentications" in API group "keda.sh" in the namespace "default"

…ources

The trigger-transition cleanup made all four stale-resource deletions fatal,
but two of them (interceptor bridge Service, TriggerAuthentication) are not
scalers and run speculatively on every deploy. On-cluster the deploy
ServiceAccount may lack the delete verb for a resource that never existed;
Kubernetes authorizes before checking existence, so that delete returns
Forbidden (not NotFound) and aborted every on-cluster http keda deploy
(TestInt_Remote_Default/keda).

Narrow fatality to the actual scalers (HTTPScaledObject, ScaledObject), which
are the only resources that trip KEDA's one-scaler-per-workload rule. Their
companions are inert when orphaned and now log a warning instead of failing
the deploy.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/enhancement Feature additions or improvements to existing size/XXL 🤖 PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants