Kafka Connect 0.8.1 Helm Readme

Helm chart that deploys a Strimzi KafkaConnect cluster for the Axual platform. The chart manages a KafkaConnect custom resource. The Strimzi operator owns pod lifecycle; this chart does not template a Deployment.

Plugins are pre-built OCI images stored in Harbor. They are built, gated and published by connect-plugins-lifecycle; this chart publishes no plugin image. It lists the ones it can mount in plugin-catalog.yaml, and the chart resolves a plugin name to the full image reference from there.

[[TOC]]

How plugins are delivered

Two modes are supported, controlled by pluginDelivery in values.yaml.

Per-plugin images (pluginDelivery: imageVolumes, default)

Each plugin is a tiny OCI image (~5-50 MB) containing only its JARs. Kubernetes mounts each image directly into the Connect pod at startup. The Connect base image stays vanilla Strimzi - nothing is baked in.

  • Update one plugin without touching anything else.

  • Requires Kubernetes >= 1.31 and Strimzi >= 0.47.

Prebuilt Connect image (pluginDelivery: prebuiltImage)

All plugins are baked into a full Strimzi Connect image (~600 MB). The pod runs that image - no mounting at startup.

  • Works on any Kubernetes and Strimzi version.

  • Slower to update: changing any plugin requires rebuilding the whole image.

  • The prebuilt image must already exist in Harbor before you deploy.

Which to use? Use per-plugin images if your cluster runs Kubernetes 1.31 or newer. Use the prebuilt image for older clusters or air-gapped environments.

Prerequisites

  • Strimzi cluster operator installed in the cluster.

  • A running Kafka CR that Connect will connect to.

  • Secrets for mTLS: beta-cluster-ca-cert and beta-beta-pool-0 in the target namespace (or override via values).

  • A pull secret for Harbor (regcred-harbor).

Install

Put the values for your cluster in a file. tenant, instance and clusterName are required: they become the pod labels the log viewer uses to find this cluster’s pods, so set them to the names registered in Self-Service, in lowercase.

# my-cluster.yaml
tenant: axual
instance: dta
clusterName: my-connect

bootstrapServers:
  tls: beta-kafka-bootstrap:9093
tls:
  trustedCertificates:
    - secretName: beta-cluster-ca-cert
      certificate: ca.crt
authentication:
  type: tls
  certificateAndKey:
    secretName: beta-beta-pool-0
    certificate: user.crt
    key: user.key
helm upgrade --install axual-dta-kafka-connect oci://registry.axual.io/axual-charts/kafka-connect \
  --version 0.8.1 \
  --namespace kafka \
  --values my-cluster.yaml \
  --set "imagePullSecrets[0].name=regcred-harbor"

Wait for Connect to be ready:

kubectl wait kafkaconnect/axual-dta-kafka-connect \
  --for=condition=Ready --namespace kafka --timeout=120s

For a prebuilt image deployment, add two settings to the same command:

helm upgrade --install axual-dta-kafka-connect oci://registry.axual.io/axual-charts/kafka-connect \
  --version 0.8.1 \
  --namespace kafka \
  --values my-cluster.yaml \
  --set pluginDelivery=prebuiltImage \
  --set prebuiltImageProfile=default \
  --set "imagePullSecrets[0].name=regcred-harbor"

Values reference

Key Default Description

pluginDelivery

imageVolumes

Delivery mode: imageVolumes or prebuiltImage

plugins

[]

Plugin names to load. Each name must have an entry in plugin-catalog.yaml; an unknown name fails the render and lists the catalogued plugins. An object entry may carry an image to mount a connector that is not catalogued. imageVolumes mode only.

connectBaseImageRegistry

registry.axual.io/axual/kafka-connect-base

Registry+repo for the Connect base image: the worker image in imageVolumes mode, and the ACL-bootstrap Job image in every mode. The full tag is built as <registry>:<chart-version>-strimzi-<strimziVersion>-kafka-<kafkaVersion>. Image is plain Strimzi plus vault-config-provider baked into /opt/kafka/libs/.

connectBaseImage

""

Full override for the base image. Sets the worker image in imageVolumes mode and the ACL-bootstrap Job image in every mode - so in prebuiltImage mode it still controls the Job’s image, just not the worker’s. Empty => computed from the chart version.

prebuiltImageRegistry

registry.axual.io/internal/axual/kafka-connect

Registry+repo the profile image is built from: <prebuiltImageRegistry>/<profile>:<chart-version>-strimzi-<strimziVersion>-kafka-<kafkaVersion>. Redirect it for a mirrored or air-gapped install. prebuiltImage mode only.

prebuiltImageProfile

default

Profile name for the prebuilt image. prebuiltImage mode only.

strimziVersion

0.51.0

Strimzi version. Used to construct the prebuilt image tag.

kafkaVersion

4.1.1

Kafka version for the Connect workers.

replicas

1

Number of Connect worker pods.

bootstrapServers.tls / bootstrapServers.sasl

"" / ""

Kafka broker address per listener. Each consumer selects by its auth type: the worker by authentication.type, the ACL-bootstrap Job by aclBootstrap.auth.type. Set the one(s) you use.

tls.trustedCertificates

beta-cluster-ca-cert / ca.crt

CA cert secret for broker TLS. Required for any TLS listener (mTLS or SASL_SSL).

authentication.type

tls

Worker auth method. One of tls, scram-sha-512. SCRAM-SHA-256 is not supported (Strimzi is SCRAM-SHA-512 only); OAuth is v2.

authentication.certificateAndKey

beta-beta-pool-0

Client cert reference when type=tls.

authentication.username

(unset)

SASL username. Required for SASL types.

authentication.passwordSecret

(unset)

K8s Secret reference with the SASL password. Required for SASL types.

imagePullSecrets

[]

Pull secrets for the plugin registry.

registryCredentials

[]

registry / username / password per registry. Rendered into one dockerconfigjson Secret added to the worker pods’ pull secrets. SOPS-encrypt the values file. The ACL bootstrap Job runs before the Secret exists, so its image registry belongs in imagePullSecrets.

managedPlugins.enabled

false

Not supported yet. Render a Kyverno Policy that mounts the plugins listed in the provisioner-owned ConfigMap <fullname>-managed-plugins. See “Plugins managed by a provisioner”.

managedPlugins.allowedRegistries

[]

Registry prefixes managed plugin images may come from, each ending in /. Required when enabled. Images must also be pinned by digest.

baseImageVerification.enabled

false

Needs Kyverno 1.19+. Render a Kyverno NamespacedImageValidatingPolicy that admits a pod running the base image only when it is signed by the release pipeline. See “Verifying the base image”.

baseImageVerification.subjectRegExp

^https://gitlab\.com/axual/product/kafka-connect-helm//\.gitlab-ci\.yml@refs/tags/[0-9]+\.[0-9]+\.[0-9]+$

Regular expression the signing identity must match. The default admits X.Y.Z release tag builds only.

baseImageVerification.registryCredentialsSecrets

[]

Secrets in the Kyverno namespace that Kyverno reads the signatures with.

egress.enabled

false

Add a NetworkPolicy limiting where the workers may connect to. See “Restricting worker egress”.

egress.dns

kube-dns in kube-system

to peers that serve DNS, allowed on port 53.

egress.rules

[]

Allowed destinations, as standard NetworkPolicy egress rules. Required when enabled.

jvmOptions

{}

Passed through to spec.jvmOptions. javaSystemProperties is how worker level plugin settings are set. Plain text: no secrets.

podLabels

{}

Extra labels for the worker pods. The identity labels win over the same key.

serviceAccountAnnotations

{}

Annotations for the worker ServiceAccount Strimzi creates.

extraVolumes / extraVolumeMounts

[] / []

Extra volumes for the worker pods and their mounts in the Connect container. Mounts are read-only unless readOnly: false is set. mountPath must be under /mnt: Strimzi refuses any other path, so the schema fails the render. Any /mnt path works for connector files, writable ones too (for example /mnt/exports with readOnly: false). Only /mnt/custom-plugins is loaded as plugin code, and it must stay read-only, so a writable mount there, inside it, or on /mnt itself fails the render. Every worker pod gets the same volumes.

runtimeClassName

""

Container runtime for the worker pods, e.g. kata-vm-isolation. Needs Kyverno in the cluster and needs affinity set too. See “Running the workers on a different container runtime”.

affinity

{}

Passed through to spec.template.pod.affinity. Strimzi has no nodeSelector field, so node pinning goes here as nodeAffinity.

tolerations

[]

Passed through to spec.template.pod.tolerations.

resources

requests 250m/1Gi, limits 2Gi

Connect worker pod resources.

podSecurityContext

runAsNonRoot: true, RuntimeDefault seccomp

Pod-level security context for the workers, rendered into spec.template.pod.securityContext. Strimzi’s default pod security provider is baseline, which sets none. Set to null to render none.

securityContext

drop ALL, no privilege escalation

Container-level security context for the workers, rendered into spec.template.connectContainer.securityContext. readOnlyRootFilesystem is not a default: Connect and its plugins write outside the /tmp emptyDir Strimzi mounts, and which paths depends on the plugins in use.

useConnectorResources

"false"

Set to "true" to manage connectors via KafkaConnector CRs.

tenant

(required)

Tenant short name, lowercase. Rendered as the axual.io/tenant pod label, which is how the Provisioner / log viewer finds this cluster’s pods.

instance

(required)

Instance short name, lowercase. Rendered as the axual.io/instance pod label.

clusterName

(required)

Connect cluster name as registered in Self-Service, lowercase. Rendered as the axual.io/connect-cluster pod label. Self-Service allows capitals and names up to 255 characters; the log viewer lowercases a name before it looks for pods, so lowercase it here. A name over 63 characters must be shortened in Self-Service first.

groupId

derived

Connect consumer group. Empty derives _<tenant>-<instance>-<clusterName>-connect. See “Internal topic names”.

configStorageTopic

derived

Empty derives the above plus -configs.

offsetStorageTopic

derived

Empty derives the above plus -offsets.

statusStorageTopic

derived

Empty derives the above plus -status.

podAnnotations

{}

Annotations for the worker pods. A log pipeline that reads container output has to be told the format, and these workers write JSON: Fluent Bit takes it as a pod annotation, so a cluster behind Fluent Bit wants fluentbit.io/parser: json. Not set for you, because which pipeline is in front of the cluster is not the chart’s to know.

logging.level

INFO

Root log level.

logging.loggers

org.reflections: ERROR

Levels for individual loggers, as name: level. Each writes to the same console appender as the root logger, so raising a level really does add output. Use OFF to silence one.

logging.monitorInterval

30

Seconds between checks of the log config, so a change needs no restart. Strimzi copies the config into its own ConfigMap first, so a change takes a few minutes to reach a running worker. log4j2 reconfigures only when the file has changed, not on every check, so a level set by hand via PUT /admin/loggers/<name> is not reverted on this timer: it lasts until the config really changes or the pod restarts. 0 turns the check off, which also means a config change then needs a pod restart.

vault.enabled

false

Turn on Vault integration at runtime. When true, the chart (a) renders the AppRole role_id + secret_id as env vars on the Connect container (spec.template.connectContainer.env), and (b) configures the worker config.providers for env + vault. The vault-config-provider JAR itself is always in the Connect base image regardless of this flag - this only controls the runtime wiring.

vault.address

""

Vault server URL (e.g. https://vault.example.com:8200). Required when vault.enabled: true.

vault.authMethod

APPROLE

Vault auth method. Currently only APPROLE is supported.

vault.approlePath

approle

AppRole auth mount path in Vault.

vault.namespace

""

Vault Enterprise namespace. Required for on-prem customers running Vault Enterprise with namespaces; leave empty for Axual Cloud (Vault OSS).

vault.testPath

""

Optional Vault path the provider reads once at startup as a liveness check. Surfaces a broken Vault setup at startup instead of at first connector deploy. Leave empty to skip.

vault.sslVerify

true

Whether the provider verifies the Vault server’s TLS certificate. Set false only for local dev against a self-signed cert that is not in the truststore.

vault.credentialsSecret

""

Mode B (default, recommended): name of a pre-existing Kubernetes Secret with VAULT_APPROLE_ROLE_ID + VAULT_APPROLE_SECRET_ID keys. Rendered via valueFrom.secretKeyRef, so the secret_id stays out of the rendered CR. Overrides approleRoleId / approleSecretId when set.

vault.approleRoleId

""

Mode A (opt-in shortcut): the worker AppRole role_id pasted inline. Rendered as a direct env var value, so the secret_id appears in the rendered CR. SOPS-encrypt the values file. Used only when credentialsSecret is empty.

vault.approleSecretId

""

Mode A (opt-in shortcut): the worker AppRole secret_id pasted inline.

vault.truststoreSecret

""

Optional Kubernetes Secret with truststore.p12 and truststore.password keys for HTTPS to Vault.

aclBootstrap.enabled

false

Run a superuser Helm hook Job that creates Connect’s internal topics and grants the worker principal(s) the required ACLs. See Bootstrapping internal topics and ACLs.

aclBootstrap.verifyHostname

true

Verify the broker TLS hostname against the certificate SAN. Set false only when the broker cert SAN does not include the bootstrap address (e.g. an internal Service name or localhost).

aclBootstrap.auth.type

tls

Superuser auth for the Job. One of tls, scram-sha-512. Must be a Kafka superuser: the worker cert cannot grant its own ACLs.

aclBootstrap.auth.tls.secretName

""

K8s Secret with the superuser cert + key (PEM). Required when auth.type=tls.

aclBootstrap.auth.tls.certificate / key

tls.crt / tls.key

Keys within that Secret holding the certificate and private key.

aclBootstrap.auth.scram.username

""

Superuser username. Required when auth.type=scram-sha-512.

aclBootstrap.auth.scram.passwordSecret.secretName

""

K8s Secret holding the superuser password. Required when auth.type=scram-sha-512.

aclBootstrap.auth.scram.passwordSecret.password

password

Key within that Secret holding the password.

aclBootstrap.principal

""

Principal the ACLs are granted to = the worker identity. User:<cert DN> for tls, User:<username> for scram. Required when enabled.

aclBootstrap.distributionPrincipal

""

Optional extra principal (e.g. a “read all” distributor) that also receives the ACLs.

aclBootstrap.replicationFactor / minInsyncReplicas

1 / 1

Durability of the internal topics. Match your broker count.

aclBootstrap.offsetTopicPartitions

25

Partition count for the offsets topic.

aclBootstrap.statusTopicPartitions

5

Partition count for the status topic. The config topic is always single-partition (a Connect requirement).

aclBootstrap.segmentMs

3600000

segment.ms for the compacted internal topics; controls how often log compaction runs.

aclBootstrap.additionalClientConfigs

[]

Extra lines appended verbatim to the admin client .properties (e.g. retries or extra security settings). Request timeouts are already set by the chart.

aclBootstrap.backoffLimit

3

Job retry budget.

aclBootstrap.activeDeadlineSeconds

240

Hard time limit for the Job in seconds. Kept below Helm’s default 300s hook timeout so the Job (not Helm) reports the failure first.

aclBootstrap.image

""

Image for the Job. Always the imageVolumes Connect base image (ships the Kafka CLI), regardless of pluginDelivery - not the prebuiltImage worker image. Override here if needed.

aclBootstrap.resources

requests 100m/256Mi, limits 512Mi

Pod resources for the Job. Modest defaults sized for the short-lived Kafka CLI.

aclBootstrap.podSecurityContext

runAsNonRoot: true, RuntimeDefault seccomp

Pod-level security context for the Job. Hardened, restricted-PSS-friendly baseline.

aclBootstrap.securityContext

drop ALL, no privilege escalation, read-only rootfs

Container-level security context for the Job.

restApi.route.enabled

false

Publish the REST API through a route. Requires restApi.networkPolicy.enabled, see “REST API access”.

restApi.route.kind

Ingress

Ingress or HTTPRoute (Gateway API).

restApi.route.host

""

Hostname for the REST API. Required, and the address to register in Self-Service as connectUrl.

restApi.route.authMethod

""

basic or none, matching Platform Manager’s BASIC_AUTH and NO_AUTH. Required: there is no default, so a cluster cannot end up unprotected by omission.

restApi.route.annotations

{}

Annotations that are not about authentication.

restApi.route.className

""

IngressClass name, e.g. nginx-private or f5-nginx-public. Required for kind: Ingress.

restApi.route.tlsSecret

""

TLS Secret for the host (kind: Ingress). Empty serves plain HTTP. For kind: HTTPRoute, TLS belongs to the Gateway listener.

restApi.route.allowInsecureBasicAuth

false

Allow basic auth over plain HTTP (kind: Ingress, no tlsSecret). Refused by default: the password would travel in the clear. Local runs only.

restApi.route.parentRefs

[]

Gateways the route attaches to. Required for kind: HTTPRoute.

restApi.route.filters

[]

Filters on the route rule (kind: HTTPRoute). Where Gateway API implementations attach authentication.

restApi.basicAuth.existingSecret

""

Secret that already holds an htpasswd line. Takes precedence over the options below; the chart then creates nothing.

restApi.basicAuth.htpasswd

""

htpasswd line (htpasswd -nbB <user> <pass>) the chart writes into its own Secret. SOPS-encrypt the values file.

restApi.basicAuth.secretType / .secretKey

Opaque / auth

Shape of the chart-created Secret. nginx.org/htpasswd + htpasswd for the F5 NGINX Ingress Controller.

restApi.basicAuth.annotations

{}

Annotations that switch authentication on, merged onto the route. Controller-specific, so the chart only carries them. Templated.

restApi.extraObjects

[]

Objects rendered verbatim, for implementations whose authentication is a resource: an F5 NIC Policy, an NGF AuthenticationFilter, an Envoy Gateway SecurityPolicy. Templated.

restApi.networkPolicy.enabled

false

Add a NetworkPolicy limiting ingress to the REST API (port 8083). Only enforced on a CNI that supports NetworkPolicy.

restApi.networkPolicy.router

ns "", labels {}

The pods that serve the route: an ingress controller, or a Gateway’s data plane. Required when the route is enabled.

restApi.networkPolicy.strimziOperator

ns "", strimzi.io/kind: cluster-operator

The Strimzi operator, which calls the REST API on every reconcile. Without it the cluster never reports Ready.

restApi.networkPolicy.allowedFrom

[]

Extra from peers allowed to reach 8083. Required when the policy is on without a route. Prefer a podSelector over a whole namespaceSelector. Log streaming does not use this port.

Worker log format

Workers write one JSON object per log entry, in the ECS layout, and the format is not configurable. A Java error keeps its whole stack trace inside one entry instead of arriving as ninety loose lines, and the severity is a field rather than a word in a sentence, which is what lets the log viewer filter by level. connector.context arrives as a field too, so the viewer can tell which connector a line belongs to without matching names in prose.

There is one layout on purpose. The Axual platform reads these logs, and a free-form pattern or a different JSON template would break the severity filter, the connector filter, or both, without any error to point at. The chart is the one place that could introduce that, so it does not offer it.

The cost is that kubectl logs and k9s show raw JSON. To read it by hand:

kubectl -n <namespace> logs <pod> \
  | jq -R -r '. as $l | try (fromjson | "\(.["@timestamp"]) \(.["log.level"]) \(.message)") catch $l'

The -R and the try …​ catch matter. Strimzi’s startup scripts write plain text before log4j2 takes over, so the output is mixed, and a plain jq stops at the first plain line. This version prints those lines unchanged. k9s users can get the same result with a plugin.

A log pipeline in front of the cluster has to be told the format too. For Fluent Bit that is a pod annotation:

podAnnotations:
  fluentbit.io/parser: json

Worker authentication

The Connect worker authenticates to the broker using one of these methods. The chart renders the value into spec.authentication of the KafkaConnect CR; Strimzi handles the rest.

Method authentication.type Required fields

mTLS (default)

tls

certificateAndKey.secretName, .certificate, .key

SASL/SCRAM-SHA-512

scram-sha-512

username, passwordSecret.secretName (+ passwordSecret.password if not the default password)

SASL/SCRAM-SHA-256 is not supported: Strimzi supports SCRAM-SHA-512 only (see the Strimzi security overview - its KafkaUser and listener authentication offer only scram-sha-512). SASL/PLAIN (type: plain) is not supported - Platform Manager does not support it. OAuth (type: oauth) is planned for v2 and is not yet supported by this chart.

mTLS (default)

The chart ships with mTLS as the default. Replace the example Secret names for non-local deployments:

tls:
  trustedCertificates:
    - secretName: my-cluster-ca-cert
      certificate: ca.crt
authentication:
  type: tls
  certificateAndKey:
    secretName: connect-worker-cert
    certificate: tls.crt
    key:         tls.key

SASL/SCRAM example

On Strimzi-managed Kafka, the SCRAM password is normally provisioned by the Strimzi User Operator from a KafkaUser CR. The User Operator creates a K8s Secret of the same name as the user, with the password under the password key. The chart references that Secret in passwordSecret.secretName.

Typical install order:

  1. Apply a KafkaUser CR for the worker:

    apiVersion: kafka.strimzi.io/v1beta2
    kind: KafkaUser
    metadata:
      name: connect-worker
      namespace: kafka
      labels:
        strimzi.io/cluster: my-cluster
    spec:
      authentication:
        type: scram-sha-512
  2. Wait for the Secret to appear:

    kubectl wait kafkauser/connect-worker -n kafka --for=condition=Ready --timeout=120s
  3. Install the chart with SASL values:

    helm upgrade --install axual-dta-kafka-connect oci://registry.axual.io/axual-charts/kafka-connect \
      --version 0.8.1 \
      --namespace kafka \
      --set tenant=axual --set instance=dta --set clusterName=my-connect \
      --set bootstrapServers.sasl=my-cluster-kafka-bootstrap:9094 \
      --set authentication.type=scram-sha-512 \
      --set authentication.username=connect-worker \
      --set authentication.passwordSecret.secretName=connect-worker \
      --set 'imagePullSecrets[0].name=regcred-harbor'

tls.trustedCertificates stays unchanged - the broker still uses TLS for the SASL_SSL listener, just with SASL on top.

Per-connector authentication is separate

The worker’s auth (authentication.type) and a connector’s auth (the *.override.* keys PM injects) are independent. A worker on scram-sha-512 can host connectors that themselves authenticate to a different listener with different credentials - the override mechanism picks up producer.override.bootstrap.servers, producer.override.security.protocol, producer.override.sasl.jaas.config, and the matching ${vault:…​} references PM puts in the connector config.

Internal topic names

Connect keeps its own state in three topics (config, offsets, status) and coordinates its workers through a consumer group. All four names default to being derived from the cluster identity:

groupId             _<tenant>-<instance>-<clusterName>-connect
configStorageTopic  _<tenant>-<instance>-<clusterName>-connect-configs
offsetStorageTopic  _<tenant>-<instance>-<clusterName>-connect-offsets
statusStorageTopic  _<tenant>-<instance>-<clusterName>-connect-status

This matters because these topics live on a Kafka cluster that is usually shared by many tenants. Two workers that share a group.id and a config topic are one Connect cluster as far as Kafka Connect is concerned: they elect a leader between them and distribute each other’s connectors. Deriving the names from the identity keeps two clusters on the same Kafka apart by construction.

The leading underscore marks them as platform-internal and keeps them out of the governed <tenant>-<instance>-<environment>- space where user topics live. tenant, instance and clusterName are already required by the chart and are validated to be lowercase DNS-style labels, so the derived names are always valid Kafka topic names and stay well inside Kafka’s 249-character limit.

Set groupId, configStorageTopic, offsetStorageTopic or statusStorageTopic to use a name verbatim, for example when adopting state that already lives under other names. Renaming these topics loses the worker’s connector configurations, offsets and status, so changing tenant, instance, clusterName or any of the four values on a running cluster is refused; see “Upgrade safety” below.

Upgrade safety

Before rendering the KafkaConnect resource, the chart looks up the cluster’s live resource (if any) and fails the upgrade if any of the four names from your values differs from what that cluster already runs with:

Error: UPGRADE FAILED: execution error at (kafka-connect/templates/kafkaconnect.yaml:1:4):
this cluster's configStorageTopic is "_axual-dta-mycluster-connect-configs" but these values
give "_axual-dta-othercluster-connect-configs". Set the four names in your values file to what
the cluster runs with, or the worker loses its connectors, offsets and status. See "Internal
topic names" in the README.

Nothing is applied when this fires - the check runs during template rendering, before Helm talks to the API server, so a blocked upgrade changes nothing. Fix it by restoring the identity values, or by pinning the four names to what the cluster already uses, then upgrade again.

This is a best-effort check, not a guarantee. It does nothing on a fresh install or under helm template / --dry-run, by design. It also does nothing if the identity running helm upgrade cannot read KafkaConnect resources - for example a scoped Argo CD service account (see “How this looks in Argo CD”) - since a failed lookup and a resource that does not exist look the same to the chart. Not changing the identity of a running cluster is still the only guarantee.

Bootstrapping internal topics and ACLs

A Connect worker needs three internal topics (configs, offsets, status) and ACLs on those topics plus its consumer group before it can start. On an authorized cluster the worker’s own certificate cannot create these ACLs: only a superuser can. Normally Platform Manager / Self-Service provisions them. Where that is not the case, enable aclBootstrap to have the chart do it, replacing the manual kafka-acls work.

Strimzi does not allow a custom init container on the KafkaConnect pod (discussion #6853), so instead of an init container the chart runs a pre-install / pre-upgrade Helm hook Job. The Job authenticates as a superuser and:

  • creates the three internal topics (compacted, with your replication factor / min.insync.replicas);

  • grants the worker principal(s) Read/Write/Describe/DescribeConfigs on those topics;

  • grants Read/Describe on the consumer group (groupId).

Being a pre-upgrade hook, it runs on every helm upgrade (a fresh -acl-bootstrap Job and one broker round-trip each time), even when nothing changed. It is idempotent (--create --if-not-exists, ACL --add), so re-running is safe: expect a new Job to appear on each upgrade. Its hook-delete-policy is before-hook-creation, so the completed Job and its pod are kept (removed only just before the next run, so the logs stay readable). As Helm hook resources the release does not track, the -acl-bootstrap Job and its ConfigMap are not removed by aclBootstrap.enabled: false or by helm uninstall — delete them by hand in either case.

It reuses tls.trustedCertificates, the same derived *StorageTopic names and groupId as the worker (see “Internal topic names” below), and picks its bootstrap from bootstrapServers by aclBootstrap.auth.type. It verifies the broker TLS hostname by default; set aclBootstrap.verifyHostname: false only if the broker cert SAN does not include the bootstrap address.

The Job’s bootstrap follows its auth type, independently of the worker. So when the worker uses SASL_SSL but you already have an mTLS superuser cert, set aclBootstrap.auth.type: tls and the Job automatically uses bootstrapServers.tls (the mTLS listener): no need to create a SASL superuser or edit the broker superUsers (which would force a restart).

mTLS superuser

aclBootstrap:
  enabled: true
  auth:
    type: tls
    tls:
      secretName: kafka-superuser        # Secret with the superuser cert + key (PEM)
      certificate: tls.crt
      key: tls.key
  principal: "User:CN=connect-worker,O=Axual B.V.,C=NL"   # the worker cert DN
  replicationFactor: 3
  minInsyncReplicas: 2

SASL/SCRAM superuser

aclBootstrap:
  enabled: true
  auth:
    type: scram-sha-512
    scram:
      username: admin
      passwordSecret:
        secretName: kafka-superuser
        password: password               # key within the Secret
  principal: "User:connect-worker"        # the worker SCRAM username

Logs

The Job logs timestamped, step-numbered progress (topics created, ACLs granted) and prints the resulting ACLs at the end: no secrets.

The topics and ACLs it created are broker-side state Helm never manages, so they remain even after you disable aclBootstrap or helm uninstall the chart. Read the logs any time:

kubectl -n <namespace> logs job/<fullname>-acl-bootstrap

<fullname> is the release name (or fullnameOverride). helm install/upgrade blocks on this hook but does not stream its logs: tail it from a second terminal (kubectl -n <namespace> logs -f job/<fullname>-acl-bootstrap) to watch it live.

REST API access

The Connect REST API is plain HTTP on port 8083 behind a ClusterIP Service, and Kafka Connect asks for no password. Its own Basic Auth needs rest.extension.classes, which Strimzi refuses to pass through (Configuration option "rest.extension.classes" is forbidden and will be ignored), so the check cannot live inside the worker. Two values put it back:

  • restApi.route publishes the REST API on one hostname, as an Ingress or an HTTPRoute. Whatever serves that route is what asks for the password.

  • restApi.networkPolicy shuts every other way to port 8083. A route only sees traffic that chooses to go through it: without the policy a pod inside the cluster calls the Service directly and never meets the password.

Neither half works alone, so the chart refuses to render a route while the policy is off.

Setting it up

  1. Pick what fronts the API. A private class or Gateway keeps it inside the cluster; a public one lets another cluster reach it. This is the only thing that decides public or private.

  2. Make the credential. htpasswd -nbB connect '<password>' prints the line. Put it in the SOPS encrypted values as restApi.basicAuth.htpasswd, or create the Secret yourself and name it in restApi.basicAuth.existingSecret. The chart never sees a plain password.

  3. Copy the block for your implementation from below, and set route.host plus route.authMethod.

  4. Find the router for the NetworkPolicy, because a guess here blocks the route:

    kubectl get pod -A -l app.kubernetes.io/name=ingress-nginx --show-labels   # ingress controller
    kubectl get pod -n <gateway namespace> --show-labels                       # Gateway data plane
  5. Install, then prove both halves. Through the route, and directly at the Service from any other pod:

    curl -o /dev/null -w '%{http_code}\n' https://<host>/connectors                    # 401
    curl -o /dev/null -w '%{http_code}\n' -u connect:<password> https://<host>/connectors  # 200
    
    kubectl run probe --rm -it --image=curlimages/curl --restart=Never -- \
      curl -m 5 http://<release>-connect-api.<namespace>.svc:8083/connectors          # times out

    A 200 on the first line means nothing is checking the password. A reply on the last one means the policy is not enforced, and the route is decoration.

  6. Register the cluster in Self-Service with connectUrl set to the route address and the auth method matching route.authMethod, then put the same password in the Governance Vault under connect-api-password.

The chart does not know your ingress implementation

Every implementation wires authentication differently, and not just by annotation name: ingress-nginx takes annotations, the F5 NGINX Ingress Controller takes a Policy object plus an annotation and insists on its own Secret type, and Gateway API has no authentication at all, so NGINX Gateway Fabric attaches an AuthenticationFilter to the route rule. The chart therefore renders three things only: the route, the credentials Secret, and any objects you hand it. The wiring is values.

Because of that, authMethod: basic is refused unless something actually carries the check: restApi.basicAuth.annotations, restApi.route.filters or restApi.extraObjects. A route that claims to be protected and is not is worse than an open one.

That check reaches as far as the chart can see, and no further: it catches an empty wiring, never a wrong one. Annotations for the controller you are not running, a filter naming a CRD that is not installed, a Secret name with a typo in it, all render and all leave the route open. Only the live route settles it, so helm install prints the unauthenticated curl to run against it. Expect 401, and treat anything else as an open REST API.

The three examples below all share:

restApi:
  route:
    enabled: true
    host: connect-<cluster>.<env>.axual.cloud
    authMethod: basic              # or none, matching Platform Manager's NO_AUTH
  basicAuth:
    existingSecret: connect-basic-auth
  networkPolicy:
    enabled: true

community ingress-nginx

Annotations on the Ingress, reading an Opaque Secret with the key auth (the chart defaults).

restApi:
  route:
    kind: Ingress
    className: nginx-private
    tlsSecret: axual-cloud-cluster-wildcard-tls
  basicAuth:
    annotations:
      nginx.ingress.kubernetes.io/auth-type: basic
      nginx.ingress.kubernetes.io/auth-secret: '{{ include "kafka-connect.restApi.authSecretName" . }}'
      nginx.ingress.kubernetes.io/auth-realm: "Kafka Connect REST API"
  networkPolicy:
    router:
      namespace: ingress
      podLabels:
        app.kubernetes.io/name: ingress-nginx
        app.kubernetes.io/component: controller

F5 NGINX Ingress Controller

A Policy object referenced by the nginx.org/policies annotation. It rejects any Secret that is not of type nginx.org/htpasswd with the content under the key htpasswd, which is why both are values. extraObjects entries and basicAuth.annotations are templated, so both can name the chart’s own Secret instead of repeating it by hand.

restApi:
  route:
    kind: Ingress
    className: f5-nginx-public
    tlsSecret: axual-cloud-cluster-wildcard-tls
  basicAuth:
    secretType: nginx.org/htpasswd
    secretKey: htpasswd
    annotations:
      nginx.org/policies: connect-api-basic-auth
  extraObjects:
    - apiVersion: k8s.nginx.org/v1
      kind: Policy
      metadata:
        name: connect-api-basic-auth
      spec:
        basicAuth:
          secret: '{{ include "kafka-connect.restApi.authSecretName" . }}'
          realm: Kafka Connect REST API
  networkPolicy:
    router:
      namespace: f5-nginx-ingress
      podLabels:
        app.kubernetes.io/name: nginx-ingress

Policies must live in the same namespace as the resource that references them, so this one is part of the release. Check your controller version supports policies on Ingress resources rather than only on VirtualServer; if it does not, put the same Policy behind a VirtualServer in extraObjects and leave route.enabled off.

NGINX Gateway Fabric (Gateway API)

An AuthenticationFilter attached to the rule through an ExtensionRef filter. The Secret is Opaque with the key auth, so the chart defaults apply. TLS belongs to the Gateway listener, not to this route, so tlsSecret is unused here.

restApi:
  route:
    kind: HTTPRoute
    parentRefs:
      - name: nginx-cluster-private
        namespace: infra-gateways
    filters:
      - type: ExtensionRef
        extensionRef:
          group: gateway.nginx.org
          kind: AuthenticationFilter
          name: connect-api-basic-auth
  extraObjects:
    - apiVersion: gateway.nginx.org/v1alpha1
      kind: AuthenticationFilter
      metadata:
        name: connect-api-basic-auth
      spec:
        type: Basic
        basic:
          realm: Kafka Connect REST API
          secretRef:
            name: '{{ include "kafka-connect.restApi.authSecretName" . }}'
  networkPolicy:
    router:
      namespace: infra-gateways
      podLabels:
        gateway.networking.k8s.io/gateway-name: nginx-cluster-private

A Gateway in another namespace has to allow this one, either through the listener’s allowedRoutes or a ReferenceGrant.

Attach straight to the Gateway when its listener already covers the hostname and accepts routes from this namespace. When the cluster needs its own hostname or certificate, and the Gateway sets allowedListeners, bring a ListenerSet instead and point parentRefs at that: it is one more entry in extraObjects, nothing new in the chart.

restApi:
  route:
    kind: HTTPRoute
    parentRefs:
      - group: gateway.networking.k8s.io
        kind: ListenerSet
        name: connect-api
  extraObjects:
    - apiVersion: gateway.networking.k8s.io/v1
      kind: ListenerSet
      metadata:
        name: connect-api
      spec:
        parentRef:
          name: nginx-cluster-private
          namespace: infra-gateways
        listeners:
          - name: https
            port: 443
            protocol: HTTPS
            hostname: connect-<cluster>.<env>.axual.cloud
            tls:
              mode: Terminate
              certificateRefs:
                - kind: Secret
                  name: axual-cloud-cluster-wildcard-tls

Envoy Gateway follows the same shape with a SecurityPolicy carrying basicAuth.users, and Traefik with a Middleware: same three values, different objects.

Public routes and other clusters

Public or private is not a chart setting, it is which class or Gateway you name: f5-nginx-public instead of nginx-private, or a Gateway on a public address. That is all a cluster somewhere else needs to reach this REST API, and the NetworkPolicy stays correct either way, because the traffic still arrives through the router pods.

Two things change once the address is public. Basic auth over plain HTTP now leaks the password outside the cluster, so the chart refuses kind: Ingress with authMethod: basic and no tlsSecret unless you set allowInsecureBasicAuth: true; for kind: HTTPRoute the TLS lives on the Gateway listener, where the chart cannot see it, so check it yourself. And a password is the only thing standing in front of the API, so narrow the source range at the router if you can: nginx.ingress.kubernetes.io/whitelist-source-range for ingress-nginx, an AccessControl Policy for F5 NIC, both of which go in the values you already pass.

Credentials

Two ways to give the route an htpasswd entry, existingSecret first if both are set:

Value Result

basicAuth.existingSecret

The chart only references it. Matching secretType and secretKey is then yours to get right.

basicAuth.htpasswd

The chart renders its own Secret from that line. Generate it with htpasswd -nbB <user> '<password>' and SOPS-encrypt the values file.

The chart takes a hash and never a plain password. bcrypt picks a new salt every time it runs, so hashing at render time would rewrite the Secret on every sync and leave Argo CD permanently out of sync with itself.

Registering the cluster

Platform Manager already sends Authorization: Basic on every call once a cluster is registered with BASIC_AUTH, so no code change is needed. Register connectUrl as the route address rather than the internal Service, and store the same password in the Governance Vault under connect-api-password. The password now lives in two places: change one without the other and Self-Service stops working.

What the policy allows

The rendered policy allows the same-cluster Connect pods (inter-worker REST forwarding), the Strimzi operator, and the router. Everything else, Platform Manager included, goes through the route. Add peers of your own with allowedFrom, preferring a podSelector over a whole namespace. Log streaming reads pod logs through the Kubernetes API and never touches 8083.

Without a route, allowedFrom is required. The operator is allowed by default but calls nothing on your behalf, so a policy carrying only that default locks out Platform Manager instead of defending anything, and the chart refuses it.

Find the router’s real namespace and labels rather than trusting an example: for an ingress controller kubectl get pod -A -l app.kubernetes.io/name=ingress-nginx --show-labels, for a Gateway kubectl get pod -n <gateway namespace> --show-labels. A wrong selector leaks nothing, it blocks the route instead.

The policy is only enforced on a CNI that supports NetworkPolicy (kind’s kindnet does not). Cilium and Azure CNI let the kubelet health probe through by default; on a CNI that does not, the workers never turn Ready until the node range is added to allowedFrom.

Running the workers on a different container runtime

Some clusters run sensitive workloads in a sandboxed runtime instead of the shared host kernel. On AKS that is Pod Sandboxing, where a pod asks for it with runtimeClassName: kata-vm-isolation. (The older name kata-mshv-vm-isolation still works but is on its way out.)

Two values turn this on: runtimeClassName picks the runtime, and affinity sends the pods to nodes that provide it. Both are needed.

runtimeClassName: kata-vm-isolation

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.azure.com/kata-vm-isolation
              operator: In
              values: ["true"]

Use the label your own kata node pool carries. kubectl get runtimeclass kata-vm-isolation -o yaml and kubectl get nodes --show-labels will show you which one that is. Add tolerations too if the node pool is tainted.

How the runtime class reaches the pod

Strimzi’s KafkaConnect pod template exposes a fixed list of pod fields, and runtimeClassName is not one of them, so the chart applies it at admission time instead. Setting runtimeClassName renders a namespaced Kyverno Policy that matches the pods Strimzi creates for this cluster, by strimzi.io/kind: KafkaConnect and strimzi.io/cluster: <fullname>, and sets the field on them.

This means Kyverno must be installed in the cluster. The policy keeps Kyverno’s default failurePolicy: Fail, so while Kyverno is unavailable new worker pods are refused rather than started on the shared kernel.

It also explains why affinity is part of the setup. A RuntimeClass can carry its own scheduling.nodeSelector, which Kubernetes copies onto any pod that asks for that runtime. The admission plugin that does the copying runs ahead of webhooks, so it sees the pod before Kyverno adds the field, and the pod keeps whatever placement it already had. Setting affinity here gives the pods that placement directly.

Rolling it out

The policy applies to pods created from that point on. Existing workers keep their current runtime until they are replaced, so roll them once the policy is in place:

kubectl annotate -n <namespace> strimzipodset <fullname>-connect strimzi.io/manual-rolling-update=true

Then confirm the field landed:

kubectl get pod -n <namespace> -l strimzi.io/cluster=<fullname> \
  -o jsonpath='{.items[*].spec.runtimeClassName}'

The ACL bootstrap Job keeps the node’s normal runtime. Helm creates that pod, not Strimzi, so it carries none of the strimzi.io/* labels the policy matches on.

How this looks in Argo CD

Argo CD compares the resources rendered from the Application source against their live state. For this release that is the KafkaConnect resource, its ConfigMaps, and the Policy itself, all of which come from the chart and therefore from git. The worker pods are created at runtime by a StrimziPodSet and are not part of that set, so the mutation happens outside what Argo tracks and sync status is unaffected. With runtimeClassName empty, the default, the chart renders no policy at all.

The runtimeClassName value on the pod is the one thing no manifest holds, because Strimzi has no field for it. That is a property of the feature rather than of how the chart ships it: any way of setting this field today happens at admission time.

If your platform team owns the Kyverno policies

Some setups keep every Kyverno policy in the cluster bootstrap repo rather than in application charts. The policy is then owned by a different Argo Application, still from git. To do it that way, leave runtimeClassName empty so the chart renders nothing, keep affinity set here, and hand this to whoever owns that repo:

apiVersion: kyverno.io/v1
kind: Policy
metadata:
  name: kafka-connect-runtime-class
  namespace: <the Connect namespace>
  annotations:
    pod-policies.kyverno.io/autogen-controllers: none
spec:
  failurePolicy: Fail
  rules:
    - name: set-runtime-class
      match:
        any:
          - resources:
              kinds:
                - Pod
              operations:
                - CREATE
              selector:
                matchLabels:
                  strimzi.io/kind: KafkaConnect
                  strimzi.io/cluster: <the KafkaConnect resource name>
      mutate:
        patchStrategicMerge:
          spec:
            runtimeClassName: kata-vm-isolation

Everything above about affinity, rolling the existing pods, and verifying still applies.

Verifying the base image

Every base image is signed with cosign (keyless, GitLab OIDC, public Rekor). Turn on a Kyverno NamespacedImageValidatingPolicy that refuses a pod whose base image lacks an Axual release signature:

baseImageVerification:
  enabled: true
  registryCredentialsSecrets:
    - harbor-robot   # in the Kyverno namespace, can pull registry.axual.io/axual/kafka-connect-base
  • Needs Kyverno 1.19 or later. On 1.17 and 1.18 the webhook for this policy kind is broken and refuses every pod it selects, so the Connect workers cannot start. Check first: kubectl -n kyverno get deploy kyverno-admission-controller -o jsonpath='{..image}'.

  • It checks only this release’s pods that use connectBaseImageRegistry: the workers and the ACL bootstrap Job, selected by their app.kubernetes.io/name and app.kubernetes.io/instance labels. Other pods in the namespace are never touched. Kyverno pins each admitted pod to the verified digest.

  • Only release images pass: development builds are refused.

  • A connectBaseImage override is not matched: we do not sign images we did not build.

  • Kyverno must reach registry.axual.io and rekor.sigstore.dev. Air-gapped clusters keep it off.

  • Base images released before 0.8.1 carry no release tag signature and are refused.

Restricting worker egress

Connector code runs inside the workers. Without an egress policy, a compromised plugin reaches anything the pod network does, including the cloud metadata endpoint and the Kubernetes API. egress.enabled adds a NetworkPolicy that allows DNS, the other workers of this cluster, and egress.rules, nothing else. Connectors share the pods, so the list covers all of them.

egress:
  enabled: true
  rules:
    - to:                       # brokers, Vault, schema registry
        - ipBlock:
            cidr: 10.0.0.0/8
    - to:                       # public sources and sinks, never the metadata endpoint
        - ipBlock:
            cidr: 0.0.0.0/0
            except: [10.0.0.0/8, 169.254.169.254/32]

Image pulls are done by the kubelet and are not affected. Check egress.dns matches your cluster’s DNS pods (kubectl get pod -n kube-system --show-labels). A connector that authenticates through the metadata endpoint (managed identity without workload identity) needs it allowed explicitly.

Vault integration

The chart can be configured to resolve ${vault:…​} placeholders in connector configs at task startup. This is used by Platform Manager to give each connector its own per-connector identity (mTLS PEM or SASL username/password) stored in Vault. Disabled by default; turn it on per deployment.

How it works at runtime

  1. PM writes the connector’s credentials to Vault. There is one shared engine, connector-auth/. Under each cluster’s path there is a tls/ sub-path for certificates and a sasl/ sub-path for passwords. The path starts with the tenant (paths use {cluster_name}, not {cluster_id}), so one AppRole policy per cluster (connector-auth/data/{tenant}/{instance}/{cluster_name}/*) covers both sub-paths:

    • For mTLS connectors: connector-auth/{tenant}/{instance}/{cluster_name}/tls/{env}/{app} holding private.key and certificate.chain (PEM).

    • For SASL connectors: connector-auth/{tenant}/{instance}/{cluster_name}/sasl/{env}/{app}/{credential_type} holding sasl.username and sasl.password.

  2. PM sends a connector config to Connect with ${vault:…​} placeholders in the *.override.* keys:

    • mTLS: in consumer.override.ssl.keystore.key / consumer.override.ssl.keystore.certificate.chain (and the same for producer.override.* and admin.override.*).

    • SASL: inside consumer.override.sasl.jaas.config as username="${vault:…​}" and password="${vault:…​}".

  3. When the connector task starts, the worker’s VaultConfigProvider authenticates to Vault using the AppRole credentials, reads the values, and substitutes them into the connector’s Kafka client config. No JKS file is ever created. The same provider resolves placeholders inside JAAS strings the same way it resolves them at the top of a value.

The chart’s role is to make the worker capable of step 3. It does NOT write certs to Vault and does NOT inject ${vault:…​} references into connector configs - PM does both of those at connector deploy time.

Operator setup

The Connect base image contains vault-config-provider in /opt/kafka/libs/. spec.image always points at this base image in imageVolumes mode.

vault-config-provider is on the worker classpath this way because Kafka loads ConfigProvider classes at worker startup, before plugin scanning runs - so they cannot be delivered via plugin.path / spec.plugins.

Whether the vault:// placeholder resolution actually happens at task startup is controlled separately by vault.enabled at runtime. When vault.enabled: true, the chart:

  • Renders the worker AppRole role_id + secret_id as env vars on the Connect container (spec.template.connectContainer.env). Mode B (default, recommended) renders them via valueFrom.secretKeyRef against vault.credentialsSecret; Mode A (opt-in shortcut) takes them from vault.approleRoleId + vault.approleSecretId chart values.

  • Sets config.providers = "env,vault" and uses ${env:…​} to feed the env vars into VaultConfigProvider’s params.

You do not need to build a custom image or manage a Dockerfile. pluginDelivery: imageVolumes (the default) continues to work for connectors / transforms / converters.

Mode B (default, recommended): use a Kubernetes Secret. The secret_id stays in the Secret and never appears in the rendered KafkaConnect CR.

kubectl create secret generic kc-dta-vault-creds \
  --namespace kafka \
  --from-literal=VAULT_APPROLE_ROLE_ID=<connect-role-id> \
  --from-literal=VAULT_APPROLE_SECRET_ID=<connect-secret-id>

helm upgrade --install axual-dta-kafka-connect oci://registry.axual.io/axual-charts/kafka-connect \

  --version 0.8.1 \
  --namespace kafka \
  --set vault.enabled=true \
  --set vault.address=https://vault.example.com:8200 \
  --set vault.credentialsSecret=kc-dta-vault-creds \
  --set 'imagePullSecrets[0].name=regcred-harbor'

Mode A (opt-in shortcut): paste the values inline. Simpler, but the secret_id ends up in plain text in the rendered CR, so SOPS-encrypt the values file in production.

helm upgrade --install axual-dta-kafka-connect oci://registry.axual.io/axual-charts/kafka-connect \
  --version 0.8.1 \
  --namespace kafka \
  --set vault.enabled=true \
  --set vault.address=https://vault.example.com:8200 \
  --set vault.approleRoleId=<connect-role-id> \
  --set vault.approleSecretId=<connect-secret-id> \
  --set 'imagePullSecrets[0].name=regcred-harbor'

If your Vault uses HTTPS with a private CA, also create a Secret containing truststore.p12 and truststore.password keys, then pass --set vault.truststoreSecret=vault-ca-truststore. This is a separate Secret from the AppRole credentials on purpose: the truststore.p12 file must be mounted into the pod as a file, so the chart keeps the file and its password together in their own Secret.

Adding a plugin

Plugin images are built and published by connect-plugins-lifecycle. The chart only decides which of them it offers, and that decision is one entry in plugin-catalog.yaml.

  1. Check the plugin is in the catalog of the chart version you deploy. Pull the chart and list the catalogued names:

    helm pull oci://registry.axual.io/axual-charts/kafka-connect --version 0.8.1 --untar
    yq '.plugins | keys' kafka-connect/plugin-catalog.yaml
  2. Add the name to plugins in your values:

    plugins:
      - kafka-topic-name-transforms
      - debezium-oracle
  3. Deploy: helm upgrade. The chart resolves each name to its catalogued image, pinned by tag and digest, and Strimzi mounts it.

A name that is not in the catalog fails the render and lists the names that are. A plugin that is missing from the catalog is added by Axual in a chart release.

A connector that is not in the catalog

Customer-supplied and licensed connectors live outside the catalogued Harbor project. Give the entry an explicit image instead of a catalogued name. name is still required, because Strimzi names the mounted volume after it:

plugins:
  - name: acme-connector
    image: registry.example.com/acme/acme-connector:1.0.0@sha256:...

Pin it by digest for the same reason the catalog does: a tag can be moved, a digest cannot.

Testing a snapshot connector

A snapshot has no catalog entry. Pick one of three ways.

Plugin volume (easiest). Needs pluginDelivery: imageVolumes and chart 0.8.1 or later. The base image links /opt/kafka/plugins/custom to /mnt/custom-plugins, so a volume mounted there loads next to the plugins in plugins. The link is needed because Strimzi accepts extraVolumeMounts only under /mnt, so a volume cannot be mounted in /opt/kafka/plugins directly. Other /mnt paths are not scanned for plugins: JARs there never load.

extraVolumes:
  - name: custom-plugins
    persistentVolumeClaim:
      claimName: custom-plugins
extraVolumeMounts:
  - name: custom-plugins
    mountPath: /mnt/custom-plugins

Every worker pod mounts the same volume, so the JARs are copied once. Copy them with a short lived pod that mounts the same PVC, then roll all workers:

kubectl cp my-connector-1.2.0-SNAPSHOT/ loader-pod:/plugins/my-connector/
kubectl annotate strimzipodset <fullname>-connect strimzi.io/manual-rolling-update=true

Connect reads plugins only at start, so roll every worker, not just one. A worker that did not restart lacks the connector, and its tasks fail when they land there.

  • No volume, or an empty one, is fine: Connect skips the link without an error or a warning, and loads only the plugins in plugins.

  • Everything on the volume is one plugin with one classloader. Test one connector at a time, or make sure the connectors on it do not ship conflicting dependencies.

  • With more than one worker the PVC must be ReadWriteMany, unless every worker runs on the same node. A ReadWriteOnce PVC keeps a worker on another node in ContainerCreating.

  • The worker can never write there: a mount at /mnt/custom-plugins, inside it or above it (such as /mnt) with readOnly: false fails the render. Otherwise a compromised connector could plant code that every worker loads on its next start.

  • Whoever can write to the volume itself runs code in every worker, and nothing pins what it holds. Use it for testing. For production use a plugin image pinned by digest.

Plugin image. Needs pluginDelivery: imageVolumes. Strimzi mounts the image root at /opt/kafka/plugins/<name>, so put the JARs at the root:

FROM scratch
COPY my-connector-1.2.0-SNAPSHOT/ /
docker build -t registry.example.com/test/my-connector:1.2.0-SNAPSHOT .
docker push registry.example.com/test/my-connector:1.2.0-SNAPSHOT
plugins:
  - name: my-connector
    image: registry.example.com/test/my-connector:1.2.0-SNAPSHOT@sha256:...

No access to registry.axual.io? Axual sends the image as a file (docker save) and you copy it into your own registry:

skopeo copy docker-archive:my-connector.tar docker://registry.example.com/test/my-connector:1.2.0-SNAPSHOT

Prebuilt image. For clusters without image volumes. Build on the Connect base image of your chart version and put the JARs in their own folder under /opt/kafka/plugins/:

FROM registry.axual.io/axual/kafka-connect-base:0.8.1-strimzi-0.51.0-kafka-4.1.1
COPY my-connector-1.2.0-SNAPSHOT/ /opt/kafka/plugins/my-connector/
pluginDelivery: prebuiltImage
prebuiltConnectImage: registry.example.com/test/kafka-connect-my-connector:1.2.0-SNAPSHOT

Every new snapshot means a new image of ~600 MB, and the catalogued plugins are not mounted in this mode. Copy any other JARs you need into the same image.

Add the registry’s pull secret to imagePullSecrets or registryCredentials in both cases.

Plugins managed by a provisioner

Not supported yet. No provisioner implements this side so far. The chart part ships now so that turning it on later needs no new chart release.

For plugins chosen at runtime (Self-Service) instead of in git. Off by default; needs Kyverno.

managedPlugins:
  enabled: true
  allowedRegistries:
    - registry.axual.io/axual/connect-plugins/

The chart renders a Kyverno Policy and nothing else. A provisioner owns the ConfigMap <fullname>-managed-plugins in the release namespace:

data:
  plugins: '[{"name":"debezium-postgres","image":"registry.axual.io/axual/connect-plugins/debezium-postgres:3.6.3.Final@sha256:..."}]'
  pullSecrets: '["harbor-robot"]'

After each change it rolls the workers with kubectl annotate strimzipodset <fullname>-connect strimzi.io/manual-rolling-update=true. Every new pod gets one image volume per plugin at /opt/kafka/plugins/managed-<name> and the listed pull secrets. Argo CD stays in sync: it tracks neither the ConfigMap nor the pods.

  • name must be a DNS label of at most 48 characters, or the API server refuses the pod.

  • Images outside allowedRegistries, or not pinned by @sha256:, are refused.

  • No ConfigMap means no managed plugins. While Kyverno is down, new worker pods are refused.

  • Write the ConfigMap and roll in one step: a pod replaced later reads whatever the ConfigMap says.

License

Apache 2.0 - see LICENSE.txt.

Reference Helm VALUES.YAML for Kafka Connect

# Default values for the kafka-connect chart.
# Templates a Strimzi KafkaConnect custom resource; the Strimzi operator runs the pods.

# -- Override the chart name used to build the release name. Rarely needed.
# nameOverride: ""

# -- Override the KafkaConnect CR's name entirely (and so every Strimzi-derived resource: pods,
# the -connect-api Service). Without it the name follows the Helm release name. Pin this when a
# GitOps tool's release name would otherwise differ from an already-running workload's name and
# rename/orphan it.
# fullnameOverride: ""

# -- Kafka version for the Connect workers. Must be supported by the installed Strimzi operator.
kafkaVersion: "4.1.1"

# -- Number of Connect worker pods.
replicas: 1

# -- Kafka broker bootstrap per listener/auth mechanism. The worker selects by authentication.type,
# the ACL-bootstrap Job by aclBootstrap.auth.type; set only the one(s) you use. See README.
bootstrapServers:
  # -- Bootstrap for the TLS/mTLS listener (used when auth type is "tls").
  tls: ""
  # -- Bootstrap for the SASL_SSL listener (used when auth type is "scram-sha-512").
  sasl: ""

# Broker TLS trust. Required for any TLS listener (mTLS or SASL_SSL).
tls:
  # -- CA(s) that verify the broker's server cert. Example entry:
  #   - secretName: my-cluster-ca-cert
  #     certificate: ca.crt
  trustedCertificates: []

# Worker authentication to the broker. Rendered into spec.authentication.
# Supported types: tls (mTLS), scram-sha-512. SASL/SCRAM-SHA-256 is not supported (Strimzi
# supports SCRAM-SHA-512 only); SASL/PLAIN and OAuth are not supported either.
authentication:
  type: tls
  # For type: tls - a K8s Secret holding the worker's client cert.
  certificateAndKey:
    secretName: ""
    certificate: ""
    key: ""
  # For type: scram-sha-512:
  # username: ""
  # passwordSecret:
  #   secretName: ""
  #   password: password

# -- Plugin delivery mode: "imageVolumes" (default; per-plugin OCI images, Strimzi >=0.47 + K8s >=1.31)
# or "prebuiltImage" (a full Connect image with plugins baked in).
pluginDelivery: imageVolumes

# -- Strimzi operator version. Used to build the Connect base image tag.
strimziVersion: "0.51.0"

# -- Full Connect image with plugins baked in. Used only when pluginDelivery=prebuiltImage
# and prebuiltImageProfile is unset.
prebuiltConnectImage: "registry.axual.io/internal/axual/kafka-connect-with-smt:0.0.8-strimzi-0.51.0-kafka-4.1.1"

# -- Registry+repo the profile image is built from:
# <prebuiltImageRegistry>/<profile>:<chartVersion>-strimzi-<strimziVersion>-kafka-<kafkaVersion>.
# A value like every other image path, so a mirrored or air-gapped install can redirect it.
prebuiltImageRegistry: "registry.axual.io/internal/axual/kafka-connect"

# -- Profile name for pluginDelivery=prebuiltImage. Takes precedence over prebuiltConnectImage.
prebuiltImageProfile: default

# -- Registry+repo for the Connect base image: the worker image in imageVolumes mode, and the
# ACL-bootstrap Job image in every mode. Plain Strimzi plus vault-config-provider baked into /opt/kafka/libs/.
connectBaseImageRegistry: "registry.axual.io/axual/kafka-connect-base"

# -- Full override for the Connect base image. Sets the worker image in imageVolumes mode and the
# ACL-bootstrap Job image in every mode (in prebuiltImage mode it controls the Job, not the worker).
# Empty => computed from chart/strimzi/kafka versions. Pin an image when the CI-built one is missing. See README.
connectBaseImage: ""

# -- Plugins to mount (imageVolumes mode). Empty by default; add the plugins this deployment
# needs, by name. Each name must appear in plugin-catalog.yaml, which carries the full image
# reference (tag + digest) published by connect-plugins-lifecycle. An unknown name fails the
# render and lists the catalogued plugins.
plugins: []

# Plugins managed outside git by a provisioner, on top of `plugins`. Off by default.
# NOT SUPPORTED YET: no provisioner implements this side so far. Shipped now so turning it on later
# needs no new chart release.
# When on, the chart renders a Kyverno Policy that reads the ConfigMap <fullname>-managed-plugins
# (owned by the provisioner, never by the chart) and mounts one image volume per plugin into each
# new worker pod. Kyverno must run in the cluster. See README "Plugins managed by a provisioner".
managedPlugins:
  # -- Render the Kyverno Policy.
  enabled: false
  # -- Registry prefixes managed plugin images may come from. Required when enabled. Every image
  # must also be pinned by digest. Example: [registry.axual.io/axual/connect-plugins/]
  allowedRegistries: []

# Kyverno Policy that admits a pod running the Connect base image only when it is signed by this
# chart's release pipeline (cosign keyless, GitLab OIDC). Off by default. Needs Kyverno 1.19+: on
# 1.17 and 1.18 it refuses every worker pod. Kyverno must reach registry.axual.io and
# rekor.sigstore.dev, so it does not work air-gapped.
# See README "Verifying the base image".
baseImageVerification:
  # -- Render the Kyverno Policy.
  enabled: false
  # -- Regular expression the signing identity must match. The default admits only images built by
  # an X.Y.Z release tag of this repository, so main and branch images are refused.
  subjectRegExp: '^https://gitlab\.com/axual/product/kafka-connect-helm//\.gitlab-ci\.yml@refs/tags/[0-9]+\.[0-9]+\.[0-9]+$'
  # -- Secrets in the Kyverno namespace Kyverno reads the signatures with, when the registry needs
  # credentials. Example: [harbor-robot]
  registryCredentialsSecrets: []

# -- Connect's group id and its three internal topics (config, offsets, status).
#
# Leave empty and they are derived as `_<tenant>-<instance>-<clusterName>-connect[-configs|-offsets|
# -status]`. That matters because these topics live on a Kafka cluster shared by many tenants: two
# workers sharing a group id and a config topic are one Connect cluster as far as Kafka Connect is
# concerned, so a fixed default would make two customers on one Kafka cluster either merge into a
# single Connect cluster or fail to start, depending on the ACLs.
#
# Set a value to use it verbatim. Do that only when adopting a cluster that already holds state under
# different names, because renaming these topics loses the worker's connector configurations, offsets
# and status.
groupId: ""
offsetStorageTopic: ""
configStorageTopic: ""
statusStorageTopic: ""

# -- Cluster identity. All three are required. They become the pod labels axual.io/tenant,
# axual.io/instance and axual.io/connect-cluster, which is how the log viewer finds this cluster's
# pods. Use the Self-Service names in lowercase: at most 63 characters, starting and ending with a
# letter or digit.
tenant: ""
instance: ""
clusterName: ""

# -- Annotations for the worker pods. Empty by default.
#
# A log pipeline that reads container output has to be told the format, and these workers write JSON.
# Fluent Bit takes it as a pod annotation, so a cluster behind Fluent Bit wants
# `fluentbit.io/parser: json`. The chart does not set it for you, because which pipeline is in front
# of the cluster is not the chart's to know.
podAnnotations: {}

# -- Worker log configuration, rendered into a log4j2 config for Strimzi.
#
# Entries are written as ECS JSON, one object per entry, and the format is not configurable. Strimzi's
# own default is plain text and its inline logging can only set levels, so the chart hands it a whole
# file. The platform reads these logs, and it reads one format: a Java error keeps its whole stack
# trace inside one entry, and the severity is a field rather than a word in a sentence, which is what
# lets the log viewer filter by level.
#
# `kubectl logs` and k9s therefore show raw JSON. To read it by hand, see "Worker log format" in the
# README for a jq recipe, or use a k9s plugin.
logging:
  # -- Root log level.
  level: "INFO"
  # -- Levels for individual loggers, as name: level. Each one writes to the same console appender
  # as the root logger, so raising a level here really does produce more output. Any logger name is
  # fine: the log4j2 property key is generated, not derived from the name.
  loggers:
    org.reflections: "ERROR"
    org.apache.kafka.connect.runtime.rest.RestServer: "WARN"
  # -- Seconds between checks of the log config, so a change needs no restart. Strimzi copies this
  # ConfigMap into its own first and the worker mounts that copy, so a change takes a few minutes to
  # reach a running worker.
  #
  # log4j2 reconfigures only when the file has changed, not on every check, so a level raised by hand
  # through Connect's REST API (PUT /admin/loggers/<name>) is not reverted on this timer. It lasts
  # until the config really changes, or until the pod restarts.
  #
  # Setting 0 turns the check off, which also means a config change then needs a pod restart.
  monitorInterval: 30

# -- Worker-level Connect configuration. Do not add plugin.path (Strimzi manages it).
config:
  offset.storage.replication.factor: 1
  config.storage.replication.factor: 1
  status.storage.replication.factor: 1
  key.converter: org.apache.kafka.connect.json.JsonConverter
  value.converter: org.apache.kafka.connect.json.JsonConverter
  key.converter.schemas.enable: false
  value.converter.schemas.enable: false
  connector.client.config.override.policy: All

# Worker Vault integration. vault-config-provider is always on the worker classpath; this only
# wires it up at runtime when enabled=true.
vault:
  # -- Turn on Vault integration.
  enabled: false
  # -- Vault server address.
  address: ""
  # -- Auth method. APPROLE is currently the only supported value.
  authMethod: APPROLE
  # -- AppRole auth mount path.
  approlePath: approle
  # -- Vault Enterprise namespace (leave empty for OSS).
  namespace: ""
  # -- Optional Vault path read once at startup as a liveness check.
  testPath: ""
  # -- Verify the Vault server's TLS certificate.
  sslVerify: true
  # -- Recommended: name of a pre-existing K8s Secret with keys VAULT_APPROLE_ROLE_ID and
  # VAULT_APPROLE_SECRET_ID. When set, the credential stays in the Secret, not in the CR.
  credentialsSecret: ""
  # -- Inline AppRole credentials (used only when credentialsSecret is empty). These end up in the
  # KafkaConnect resource in plain text, readable by anyone who can get kafkaconnects in the
  # namespace, which SOPS on the values file does not change. Prefer credentialsSecret.
  approleRoleId: ""
  approleSecretId: ""
  # -- Optional K8s Secret with the Vault CA truststore (keys: truststore.p12, truststore.password),
  # mounted at /mnt/vault-truststore/. Required when Vault uses HTTPS with a private CA.
  truststoreSecret: ""

# Optional superuser Helm-hook Job that creates Connect's internal topics and grants the worker
# the required ACLs (never Create). Off by default; enable only where Self-Service does not
# provision them. See README "Bootstrapping internal topics and ACLs".
aclBootstrap:
  # -- Turn the bootstrap Job on.
  enabled: false
  # -- Verify the broker TLS hostname against the certificate SAN. Set false only when the broker
  # cert SAN does not include the bootstrap address (e.g. an internal Service name or localhost).
  verifyHostname: true
  auth:
    # -- Superuser identity that creates the topics + ACLs. Must be a Kafka superuser: the worker's
    # own certificate cannot grant its own ACLs. One of: tls, scram-sha-512.
    type: tls
    # For type: tls - a K8s Secret with the superuser cert + key (PEM).
    tls:
      secretName: ""
      certificate: tls.crt
      key: tls.key
    # For type: scram-sha-512 - superuser username + a Secret holding its password.
    scram:
      username: ""
      passwordSecret:
        secretName: ""
        password: password
  # -- Principal that receives the ACLs = the Connect worker identity.
  # For tls: "User:<worker cert DN>". For scram: "User:<worker username>".
  principal: ""
  # -- Optional extra principal (e.g. a "read all" distributor) that also receives the ACLs.
  distributionPrincipal: ""
  # -- Replication factor for the internal topics. Match your broker count.
  replicationFactor: 1
  # -- min.insync.replicas for the internal topics. Must be <= replicationFactor.
  minInsyncReplicas: 1
  # -- Partition counts. The config topic is always single-partition (a Connect requirement).
  offsetTopicPartitions: 25
  statusTopicPartitions: 5
  # -- segment.ms for the (compacted) internal topics; controls how often log compaction runs.
  segmentMs: 3600000
  # -- Extra lines appended verbatim to the admin client .properties (e.g. retries or extra
  # security settings). Request timeouts are already set by the chart.
  additionalClientConfigs: []
  # -- Job retry budget.
  backoffLimit: 3
  # -- Hard time limit for the Job in seconds. A stuck run fails here; kept below Helm's default
  # 300s hook timeout so the Job (not Helm) reports the failure first.
  activeDeadlineSeconds: 240
  # -- Image for the Job. Always the imageVolumes Connect base image (ships the Kafka CLI),
  # regardless of pluginDelivery - NOT the prebuiltImage worker image. Override here if needed.
  image: ""
  # -- Pod resource requests/limits for the Job. Modest defaults sized for the short-lived Kafka
  # CLI (a small JVM); override for constrained namespaces.
  resources:
    requests:
      cpu: 100m
      memory: 256Mi
    limits:
      memory: 512Mi
  # -- Security contexts for the Job. Defaults enforce a hardened, restricted-PSS-friendly baseline
  # (the base image runs as non-root uid 1001; the script writes only to an emptyDir at /tmp).
  podSecurityContext:
    runAsNonRoot: true
    seccompProfile:
      type: RuntimeDefault
  securityContext:
    allowPrivilegeEscalation: false
    readOnlyRootFilesystem: true
    capabilities:
      drop:
        - ALL

# -- "false" keeps connectors created via the REST API (not reconciled from KafkaConnector CRs).
useConnectorResources: "false"

# -- Image pull secrets for private registries. Example: [{ name: regcred-harbor }]
imagePullSecrets: []

# -- Credentials for the registries the worker and plugin images come from, one entry per registry.
# The chart writes them into one kubernetes.io/dockerconfigjson Secret and adds it to the worker
# pods' imagePullSecrets. Plain text in values: SOPS-encrypt the file. The ACL bootstrap Job runs
# before this Secret exists, so the registry of its image must be covered by imagePullSecrets.
registryCredentials: []
#  - registry: registry.axual.io
#    username: robot$connect-pull
#    password: ""
#  - registry: acme.azurecr.io
#    username: 00000000-0000-0000-0000-000000000000
#    password: ""

# -- Pod resource requests/limits. None by default.
resources: {}
#  requests:
#    cpu: 250m
#    memory: 1Gi
#  limits:
#    memory: 2Gi

# -- Security contexts for the worker pods, rendered into spec.template.pod.securityContext and
# spec.template.connectContainer.securityContext. Strimzi's pod security provider is "baseline",
# which sets neither, so without these the workers run third-party connector code with the full
# default capability set and no seccomp profile.
#
# The defaults are the restricted Pod Security Standard, the same thing Strimzi's own "restricted"
# provider applies. readOnlyRootFilesystem is deliberately not among them: Connect and its plugins
# write outside the /tmp emptyDir Strimzi mounts, so it is left to be turned on per deployment.
podSecurityContext:
  runAsNonRoot: true
  seccompProfile:
    type: RuntimeDefault
securityContext:
  allowPrivilegeEscalation: false
  capabilities:
    drop:
      - ALL

# -- Container runtime for the worker pods, e.g. "kata-vm-isolation" for AKS Pod Sandboxing.
# Empty (default) means the node's normal runtime and nothing extra is installed.
#
# Strimzi's pod template has no runtimeClassName field, so the chart cannot put this on the
# KafkaConnect resource. Setting it renders a Kyverno Policy in the release namespace that adds
# the field to the worker pods at admission time, so Kyverno must be running in the cluster.
#
# Set `affinity` as well. Kubernetes normally copies a RuntimeClass's own scheduling.nodeSelector
# onto the pod, but that built-in step runs before Kyverno, so a runtime class added this way
# never gets it. Without affinity the pod can be scheduled onto a node that has no such runtime
# and will fail to start. See README "Running the workers on a different container runtime".
runtimeClassName: ""

# -- Where the worker pods may run, passed through to spec.template.pod.affinity. Strimzi has no
# nodeSelector field, so express node pinning as nodeAffinity. Example:
#  nodeAffinity:
#    requiredDuringSchedulingIgnoredDuringExecution:
#      nodeSelectorTerms:
#        - matchExpressions:
#            - key: kubernetes.azure.com/kata-vm-isolation
#              operator: In
#              values: ["true"]
affinity: {}

# -- Node taints the worker pods tolerate, passed through to spec.template.pod.tolerations.
tolerations: []

# -- Worker JVM options, passed through to spec.jvmOptions. javaSystemProperties is how worker level
# plugin settings are set. Values are plain text in the pod spec: never put secrets here.
jvmOptions: {}
#  javaSystemProperties:
#    - name: com.example.plugin.setting
#      value: some-value

# -- Extra labels for the worker pods. The chart's identity labels win over the same key.
podLabels: {}

# -- Annotations for the worker service account that Strimzi creates.
serviceAccountAnnotations: {}

# -- Extra volumes for the worker pods, passed through to spec.template.pod.volumes.
# Every connector on the worker can read them.
extraVolumes: []
#  - name: my-secret
#    secret:
#      secretName: my-secret

# -- Mounts of extraVolumes in the Connect container. readOnly defaults to true. mountPath must be
# under /mnt (a Strimzi rule). Writable mounts are fine for connector files. Only /mnt/custom-plugins
# loads plugins, and it must stay read-only.
extraVolumeMounts: []
#  - name: my-secret
#    mountPath: /mnt/my-secret

# NetworkPolicy limiting where the worker pods may connect to. Off by default. Connector code runs
# in these pods, so without it a compromised plugin reaches anything the pod network does, the
# cloud metadata endpoint (169.254.169.254) and the Kubernetes API included. Connectors share the
# pods, so the allow list is per Connect cluster. Only enforced on a CNI that supports it.
egress:
  # -- Turn the egress NetworkPolicy on.
  enabled: false
  # -- "to" peers that serve DNS, allowed on port 53. The default fits kube-dns / CoreDNS.
  dns:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: kube-system
      podSelector:
        matchLabels:
          k8s-app: kube-dns
  # -- Allowed destinations, as standard NetworkPolicy egress rules. Required when enabled: list
  # the brokers, Vault, the schema registry and every connector source/sink. Other workers of this
  # cluster are always allowed. Example:
  #   - to:
  #       - ipBlock:
  #           cidr: 10.0.0.0/8
  #     ports:
  #       - port: 9093
  #   # anything public, except the cloud metadata endpoint
  #   - to:
  #       - ipBlock:
  #           cidr: 0.0.0.0/0
  #           except: [10.0.0.0/8, 169.254.169.254/32]
  rules: []

# How the Connect REST API is reached, and who may reach it at all.
#
# Kafka Connect's own Basic Auth cannot be switched on: it needs rest.extension.classes, which
# Strimzi forbids and silently drops. The password check therefore lives in whatever fronts the API,
# and every implementation wires it differently, so the chart renders the route and the credentials
# Secret and takes the wiring from you as data. README "REST API access" has working blocks for
# ingress-nginx, F5 NGINX Ingress Controller and NGINX Gateway Fabric.
restApi:
  route:
    # -- Render the route. Requires restApi.networkPolicy.enabled: a route only filters traffic that
    # chooses to go through it, the policy is what closes the direct Service route.
    enabled: false
    # -- Ingress or HTTPRoute (Gateway API).
    kind: Ingress
    # -- Hostname for the REST API. Required. Register it in Self-Service as connectUrl.
    host: ""
    # -- basic or none. Required; matches Platform Manager's BASIC_AUTH and NO_AUTH. With basic,
    # something has to carry out the check: basicAuth.annotations, route.filters or extraObjects.
    authMethod: ""
    # -- Annotations that are not about authentication. Auth ones belong in basicAuth.annotations.
    annotations: {}
    # -- IngressClass name, e.g. nginx-private or f5-nginx-public. Required for kind: Ingress.
    className: ""
    # -- TLS Secret for the host (kind: Ingress). Empty serves plain HTTP. For kind: HTTPRoute, TLS
    # belongs to the Gateway listener and this is unused.
    tlsSecret: ""
    # -- Allow basic auth over plain HTTP (kind: Ingress, no tlsSecret). Local runs only: the
    # password would otherwise leave the cluster unencrypted. No effect on kind: HTTPRoute, whose
    # TLS lives on the Gateway listener.
    allowInsecureBasicAuth: false
    # -- Gateways this route attaches to. Required for kind: HTTPRoute. Example:
    #   - name: nginx-cluster-private
    #     namespace: infra-gateways
    parentRefs: []
    # -- Filters on the route rule (kind: HTTPRoute). This is where Gateway API implementations put
    # authentication, e.g. an NGINX Gateway Fabric AuthenticationFilter through ExtensionRef.
    filters: []
  # Credentials for authMethod: basic. Point at a Secret, or let the chart create one. The shape
  # differs per implementation: ingress-nginx and NGINX Gateway Fabric read an Opaque Secret with
  # the key "auth", F5 NGINX Ingress Controller a nginx.org/htpasswd Secret with the key "htpasswd".
  basicAuth:
    # -- Existing Secret holding an htpasswd line. Wins over htpasswd; the chart then creates
    # nothing, and matching secretType/secretKey is yours to get right.
    existingSecret: ""
    # -- htpasswd line (`htpasswd -nbB <user> <pass>`) the chart writes into its own Secret.
    # A hash, never a plain password: see README "Credentials". SOPS-encrypt the values file.
    htpasswd: ""
    # -- Type and data key of the chart-created Secret. Match what your controller expects.
    secretType: Opaque
    secretKey: auth
    # -- Annotations that switch authentication on. Controller-specific by nature, so the chart
    # only merges them onto the route. Templated, so an annotation can name the Secret above
    # whichever way it was given. Example for ingress-nginx:
    #   nginx.ingress.kubernetes.io/auth-type: basic
    #   nginx.ingress.kubernetes.io/auth-secret: '{{ include "kafka-connect.restApi.authSecretName" . }}'
    #   nginx.ingress.kubernetes.io/auth-realm: "Kafka Connect REST API"
    annotations: {}
  # -- Objects the chart renders verbatim, for implementations whose authentication is a resource
  # rather than an annotation: an F5 NIC Policy, an NGF AuthenticationFilter, an Envoy Gateway
  # SecurityPolicy. Entries are templated, so `{{ include "kafka-connect.fullname" . }}` works.
  extraObjects: []
  # NetworkPolicy restricting ingress to the REST API (HTTP :8083). Only enforced on a CNI that
  # supports NetworkPolicy. With `route` it leaves the route as the only way in; on its own it is
  # defence in depth.
  networkPolicy:
    # -- Turn the NetworkPolicy on. Same-cluster Connect pods are always allowed (inter-worker
    # REST forwarding).
    enabled: false
    # -- The pods that terminate the route and forward to Connect: the ingress controller, or a
    # Gateway's data plane. Required when route.enabled, otherwise the policy blocks the very thing
    # serving the route. Rendered only when namespace is set. Verify against what runs:
    # `kubectl get pod -n <namespace> --show-labels`.
    router:
      namespace: ""
      podLabels: {}
    # -- The Strimzi operator, which calls the REST API on every reconcile: without it the cluster
    # never reports Ready. Empty namespace means the release namespace.
    strimziOperator:
      namespace: ""
      podLabels:
        strimzi.io/kind: cluster-operator
    # -- Extra peers, as standard NetworkPolicy "from" entries. Prefer a podSelector over a whole
    # namespaceSelector. Required when the policy is on without a route, because the operator alone
    # reaches nothing. Platform Manager does not belong here once a route is in place: it should
    # go through the authenticated path. Log streaming reads pod logs through the Kubernetes API and
    # never touches this port. Example:
    #   - podSelector:
    #       matchLabels:
    #         app.kubernetes.io/name: provisioner
    allowedFrom: []