Skip to content

Conversation

@cilasbeltrame
Copy link

@cilasbeltrame cilasbeltrame commented Aug 4, 2025

What does this PR do?

This PR adds ConfigMap support for local plugins in the experimental.localPlugins configuration, providing an alternative to the existing hostPath approach. The enhancement allows users to embed plugin source code directly in their Helm values, which the chart automatically converts into Kubernetes ConfigMaps and mounts into Traefik containers.

Key Changes:

  • New inlinePlugin field for embedding plugin source code in values.yaml with automatic ConfigMap generation from inline plugin content (up to 1MiB - etcd limit)
  • Backward compatibility - existing hostPath configurations continue to work unchanged
  • Support for both Deployment and DaemonSet deployment modes

Example configuration:

experimental:
  localPlugins:
    prodplugin:
      moduleName: github.com/example/prodplugin
      mountPath: /plugins-local/src/github.com/example/prodplugin
      inlinePlugin:
        go.mod: |
          module github.com/example/prodplugin
          go 1.23
        .traefik.yml: |
          displayName: Your Traefik Plugin
          type: middleware
          import: github.com/example/prodplugin
          // Your plugin settings
        main.go: |
          package main
          // Your plugin implementation

Motivation

The current hostPath approach for local plugins has several limitations in containerized environments:
Problems with hostPath:

  • Node dependency - Plugin source must be available on every node where Traefik pods might be scheduled, also difficult to maintain consistency across nodes.
  • Poor GitOps integration - Plugin source code stored outside of Helm values
  • In case, plugins are huge, they can be still used with hostPath backward compatibility.

More

  • Yes, I updated the tests accordingly
  • Yes, I updated the schema accordingly
  • Yes, I ran make test and all the tests passed

Copy link
Member

@mloiseleur mloiseleur left a comment

Choose a reason for hiding this comment

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

PR looks technically correct.
I'll need review of other chart maintainers, just to be sure I'm not missing anything here.

Copy link
Collaborator

@jnoordsij jnoordsij left a comment

Choose a reason for hiding this comment

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

Thanks for this, I think this is a neat thing to add, especially for people employing small plugins!

From what I can see this is all technically sound, just some coding style questions.

{{- toYaml .Values.deployment.additionalVolumes | nindent 8 }}
{{- end }}
{{- $root := . }}
{{- range $localPluginName, $localPlugin := .Values.experimental.localPlugins }}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I find the ordering of volumes a bit weird looking at it now; probably the plugin-related volumes, i.e. this range and the /plugins-storage mount, should be grouped. However that is probably more of a follow-up thing to clean.

Copy link
Author

Choose a reason for hiding this comment

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

Later on, I'm happy to work on this in a separate PR.

{{- if $localPlugin.hostPath }}
hostPath:
path: {{ $localPlugin.hostPath | quote }}
{{- else if $localPlugin.inlinePlugin }}
Copy link
Collaborator

Choose a reason for hiding this comment

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

In terms of values, I find it to be slightly weird that now basically it would be allowed to set both hostPath and inlinePlugin, which will (silently) result in just using the former.

However, I think it's probably still fine, as the alternatives I can come up with are not much better:

  • add a required type value to localPlugin = bad because it's a breaking change and introduces an overhead field
  • add an optional type value which defaults to host = bad because overhead field
  • create an entire new inlinePlugins fields = bad because of a lot of duplicated code or merging is required
  • add an explicit check in the first case that errs if both values are set = bad because a bit ugly

@mloiseleur WDYT?

Copy link
Author

Choose a reason for hiding this comment

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

Thanks, @jnoordsij — agreed. My goal here was to avoid any breaking changes. Since localPlugin only landed in v37.0.0, it may not be widely used yet, but changing its shape now would still be a breaking change. One alternative would be keeping the current key path and introduce the “more organized” structure alongside it, then deprecate the old one gradually. Not sure how this kind of situation is handled for this helm chart.

Copy link
Member

Choose a reason for hiding this comment

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

@cilasbeltrame What kind of "more organized" structure you have in mind ?
On helm chart, it's quite rare to maintain both solution and deprecate.

Usually, we:

  1. Advertise the breaking changes: major version + upgrade instructions
  2. Add a check in requirements.yaml that will fail gracefully in case user did not notice the change despite 1.

See here for an example PR of breaking change about plugins.

Copy link
Author

Choose a reason for hiding this comment

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

I was thinking to implement what @jnoordsij said:

  • having a typePlugin with the options of: inline, host and possibly a localPath, cause having hostPath is not that well recommended, especially for security reasons and it requires additional work to pull the plugins from a repo or blob storage for example and save it into k8s nodes filesystem. localPath could be another future feature using Blob storage with PV/PVC, taking advantage of safer solutions such as s3-csi-driver or FUSE CSI driver for Google.
  • keep backwards compatibility with both and alert it to be depricated soon
  • next major version, we remove the backward compatibility.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sounds perfectly fine to me!

Copy link
Author

Choose a reason for hiding this comment

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

@jnoordsij could you pls check the commit b33f4f5

where I have enabled backwards compatibility with previous localPlugin declaration + added two new options: inlinePlugin (config map based) and localPathPlugin where we can mount plugins using pod mountpoints, it allows us to extend storage support such as PVC, CSI drivers (s3-csi-driver, FUSE), and other volume types.

Cilas Beltrame added 2 commits August 11, 2025 14:48
@mloiseleur
Copy link
Member

@jnoordsij Anything left on your side ?

Copy link
Contributor

@darkweaver87 darkweaver87 left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks, it's a great addition. Just see my comment please :-)

@cilasbeltrame cilasbeltrame force-pushed the feat/support-cm-local-plugins branch 2 times, most recently from d794c78 to b33f4f5 Compare September 2, 2025 16:11
@bpsoraggi bpsoraggi requested a review from darkweaver87 October 1, 2025 05:57
@darkweaver87
Copy link
Contributor

@mloiseleur , @jnoordsij, to not make @cilasbeltrame loose his time reverting and implementing again, we might agree on specifications on that topic.

On my side, I thought about a different approach in order to keep the code clean and consistent.
I wouldn't put source code directly in the plugin definition.
Instead I would introduce a - K8S way - from parameter and directly reference a configMap:

configMap:

experimental:
  localPlugins:
    inline-demo:
      moduleName: github.com/traefik/inlinedemo
      mountPath: /plugins-local/src/github.com/traefik/inlinedemo
      from:
        configMap:
          name: mysrc
          namespace: default

hostPath:

experimental:
  localPlugins:
    inline-demo:
      moduleName: github.com/traefik/inlinedemo
      mountPath: /plugins-local/src/github.com/traefik/inlinedemo
      from:
        hostPath: /plugins-local/src/github.com/traefik/bar

From here, user will have to:

  • for hostPath, put the code in the given directory as he used to do it
  • for configMap, use tools like kustomize ConfigMap generator or this chart extraObjects to create the ConfigMap we are refering to

I also don't think we necessarily need backward compatibility and we can break here. People we need to update their values while updating to new major release. We can just raise an error if they are using the old config.

WDTY ?

@mloiseleur
Copy link
Member

@darkweaver87 I agree with you that we can introduce breaking change, if needed.

One of the need/motivation outlined by @cilasbeltrame in the head of this PR is:

Poor GitOps integration - Plugin source code stored outside of Helm values

So I'm not sure that this proposal with ConfigMap handled outside of values answers his need.

On my side, I'm ok with your previous proposal.

@jnoordsij Any comment ?

@jnoordsij
Copy link
Collaborator

I think both proposals @darkweaver87 are perfectly fine; you could extend the second even with allowing pluginName.from.source.* as to fulfill the initial goals of the PR.

In terms of preference, I still like the idea of the explicit type key, so that there always is a "canonical truth" about how to interpret the values. If one adds values not matching with the provided type, they will just be ignored and it is always clear why. Moreover it is the easiest to implement in a backwards compatible manner (although I do agree that should not necessarily be a goal).

For comparison: in the second proposal, if one would add both configMap and hostPath to from, what should happen? Pick just the first? Use both? Add some validation to throw an error? Of course we can just pick any option and it should be fine; it just seems a bit more ambiguous to me.

As for whether or not allowing source code: as @darkweaver87 correctly pointed out, this can be achieved in a similar manner with the extraObjects key we have and using configMap, but it would be somewhat complicated. I don't have any strong opinion on whether or not to allow it, but it doesn't do any harm as far as I can see.

All in all: I do agree implementing just the suggestion from #1492 (comment) should be preferred the way to go here.

@darkweaver87
Copy link
Contributor

darkweaver87 commented Oct 8, 2025

@cilasbeltrame,thanks for your patience :-)
We have a consensus on that proposition: #1492 (comment) so if it makes sense to you, you can update your PR based on it.

@cilasbeltrame
Copy link
Author

@cilasbeltrame,thanks for your patience :-) We have a consensus on that proposition: #1492 (comment) so if it makes sense to you, you can update your PR based on it.

No problem - the PR grew a bit more than I expected!
Updated according to commits 38924a7 and 53c6eb9.
@darkweaver87 @jnoordsij feel free to do a final check. Thanks!

Copy link
Collaborator

@jnoordsij jnoordsij left a comment

Choose a reason for hiding this comment

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

I think this looks great and almost done. Having the deprecation notice is really nice! Just a few last technical changes to tackle now.

Apart from that, we may want to update the values to ensure the schema properly reflects the new structure, but given there's no schema defined yet anyways, I'm also fine with doing that in a follow-up to no longer delay merging this.

@@ -0,0 +1,39 @@
# traefik-crds
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not exactly sure what happened here, but this README (and also the one in the traefik chart directory) are autogenerated and thus should not be committed; definitely not as part of this PR.

@mloiseleur should we gitignore these maybe?

Copy link
Contributor

Choose a reason for hiding this comment

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

@jnoordsij this README should be removed from the PR.
There is only one README.md at the root of this project and it's not auto-generated.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, that makes sense 👍

Copy link
Collaborator

Choose a reason for hiding this comment

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

I just figured out these are actually the output from make docs, however outputted as README.md rather than VALUES.md.

@cilasbeltrame can you remove this file (and the one in traefik chart) from this PR, then run make docs and ensure it updates VALUES.md rather than adding readmes?

Copy link
Author

Choose a reason for hiding this comment

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

@jnoordsij That's correct, let me review this.

Copy link
Author

Choose a reason for hiding this comment

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

@jnoordsij just did it, I've removed the files. When running make docs it didn't update the files values.md, my gues is because I haven't changed the existent schema:
experimental.localPlugins | object | {}

It should be good. Pls confirm.

cilasbeltrame in traefik-helm-chart on  feat/support-cm-local-plugins [⇣$✘?] 
❯ make docs
docker run --rm -v "/Users/cilasbeltrame/Documents/projects/opensource/traefik-helm-chart:/helm-docs" jnorwood/helm-docs:v1.14.2 -o VALUES.md
time="2025-11-06T09:41:54Z" level=info msg="Found Chart directories [traefik, traefik-crds]"
time="2025-11-06T09:41:54Z" level=info msg="Generating README Documentation for chart traefik-crds"
time="2025-11-06T09:41:54Z" level=info msg="Generating README Documentation for chart traefik"

Cilas Beltrame added 2 commits October 30, 2025 17:20
cilasbeltrame and others added 7 commits November 5, 2025 09:49
Co-authored-by: Michel Loiseleur <[email protected]>
Co-authored-by: Michel Loiseleur <[email protected]>
Co-authored-by: Michel Loiseleur <[email protected]>
Co-authored-by: Michel Loiseleur <[email protected]>
…ame:cilasbeltrame/traefik-helm-chart into feat/support-cm-local-plugins
Copy link
Contributor

@darkweaver87 darkweaver87 left a comment

Choose a reason for hiding this comment

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

I tested it with the following values:

experimental:
  localPlugins:
    demo-inline:
      moduleName: github.com/traefik/plugindemo
      mountPath: /plugins-local/src/github.com/traefik/plugindemo
      type: inlinePlugin
      source:
        go.mod: |
          module github.com/traefik/plugindemo
          
          go 1.19

        .traefik.yml: |
          displayName: Demo Plugin
          type: middleware
          iconPath: .assets/icon.png
          
          import: github.com/traefik/plugindemo
          
          summary: '[Demo] Add Request Header'
          
          testData:
            Headers:
              X-Demo: test
              X-URL: '{{URL}}'

        demo.go: |
          // Package plugindemo a demo plugin.
          package plugindemo
          
          import (
          "bytes"
          "context"
          "fmt"
          "net/http"
          "text/template"
          )
          
          // Config the plugin configuration.
          type Config struct {
          Headers map[string]string `json:"headers,omitempty"`
          }

          // CreateConfig creates the default plugin configuration.
          func CreateConfig() *Config {
            return &Config{
          Headers: make(map[string]string),
          }
          }

          // Demo a Demo plugin.
          type Demo struct {
          next     http.Handler
          headers  map[string]string
          name     string
          template *template.Template
          }

          // New created a new Demo plugin.
          func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
          if len(config.Headers) == 0 {
          return nil, fmt.Errorf("headers cannot be empty")
          }

          return &Demo{
          headers:  config.Headers,
          next:     next,
          name:     name,
          template: template.New("demo").Delims("[[", "]]"),
          }, nil
          }
  
            func (a *Demo) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
            for key, value := range a.headers {
            tmpl, err := a.template.Parse(value)
            if err != nil {
            http.Error(rw, err.Error(), http.StatusInternalServerError)
            return
          }
  
            writer := &bytes.Buffer{}
            
            err = tmpl.Execute(writer, req)
            if err != nil {
            http.Error(rw, err.Error(), http.StatusInternalServerError)
            return
          }
  
            req.Header.Set(key, writer.String())
          }
  
            a.next.ServeHTTP(rw, req)
          }

    # Test the warning; BTW traefik should ignore the error with abortOnPluginFailure but that's not the case
    #demo-legacy-hostpath:
    #  moduleName: github.com/traefik/plugindemolegacyhostpath
    #  mountPath: /plugins-local/src/github.com/traefik/plugindemolegacyhostpath
    #  hostPath: /tmp/plugin-source  # This will trigger deprecation warning; just to test, path doesn't exist

    demo-localpath:
      moduleName: github.com/traefik/plugindemolocalpath
      mountPath: /plugins-local/src/github.com/traefik/plugindemolocalpath
      type: localPath
      volumeName: plugindemolocalpath

extraObjects:
  - apiVersion: v1
    kind: ConfigMap
    metadata:
      name: "plugindemo-localpath"
    data:
      go.mod: |
        module github.com/traefik/plugindemolocalpath
        
        go 1.19

      .traefik.yml: |
        displayName: Demo Plugin
        type: middleware
        iconPath: .assets/icon.png
        
        import: github.com/traefik/plugindemolocalpath
        
        summary: '[Demo] Add Request Header'
        
        testData:
          Headers:
            X-Demo: test
            X-URL: '{{"{{"}}URL{{"}}"}}'

      demo.go: |
        // Package plugindemolocalpath a demo plugin.
        package plugindemolocalpath
        
        import (
        "bytes"
        "context"
        "fmt"
        "net/http"
        "text/template"
        )
        
        // Config the plugin configuration.
        type Config struct {
        Headers map[string]string `json:"headers,omitempty"`
        }
        
        // CreateConfig creates the default plugin configuration.
        func CreateConfig() *Config {
          return &Config{
        Headers: make(map[string]string),
        }
        }
        
        // Demo a Demo plugin.
        type Demo struct {
        next     http.Handler
        headers  map[string]string
        name     string
        template *template.Template
        }
        
        // New created a new Demo plugin.
        func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
        if len(config.Headers) == 0 {
        return nil, fmt.Errorf("headers cannot be empty")
        }
        
        return &Demo{
        headers:  config.Headers,
        next:     next,
        name:     name,
        template: template.New("demo").Delims("[[", "]]"),
        }, nil
        }
        
          func (a *Demo) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
          for key, value := range a.headers {
          tmpl, err := a.template.Parse(value)
          if err != nil {
          http.Error(rw, err.Error(), http.StatusInternalServerError)
          return
        }
        
          writer := &bytes.Buffer{}
        
          err = tmpl.Execute(writer, req)
          if err != nil {
          http.Error(rw, err.Error(), http.StatusInternalServerError)
          return
        }
        
          req.Header.Set(key, writer.String())
        }
        
          a.next.ServeHTTP(rw, req)
        }
  - apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: whoami
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: whoami
      template:
        metadata:
          labels:
            app: whoami
        spec:
          containers:
            - name: whoami
              image: traefik/whoami:latest
              ports:
                - containerPort: 80

  - apiVersion: v1
    kind: Service
    metadata:
      name: whoami
    spec:
      ports:
        - port: 80
          targetPort: 80
      selector:
        app: whoami

  - apiVersion: traefik.io/v1alpha1
    kind: Middleware
    metadata:
      name: demo-inline
    spec:
      plugin:
        demo-inline:
          headers:
            X-Demo: "demo"
  - apiVersion: traefik.io/v1alpha1
    kind: Middleware
    metadata:
      name: demo-localpath
    spec:
      plugin:
        demo-localpath:
          headers:
            X-Demo: "demo-localpath"
  - apiVersion: traefik.io/v1alpha1
    kind: IngressRoute
    metadata:
      name: whoami-test
    spec:
      entryPoints:
        - web
      routes:
        - match: Host(`whoami.localhost`) || PathPrefix(`/whoami`)
          kind: Rule
          services:
            - name: whoami
              port: 80
          middlewares:
            - name: demo-inline
        - match: Host(`whoami.localhost`) || PathPrefix(`/whoami2`)
          kind: Rule
          services:
            - name: whoami
              port: 80
          middlewares:
            - name: demo-localpath
ingressRoute:
  dashboard:
    enabled: true

logs:
  general:
    level: INFO
  access:
    enabled: true

deployment:
  additionalVolumes:
  - name: plugindemolocalpath
    configMap:
      name: plugindemo-localpath

It misses a control on mountPath which should be required and produces:

$ helm upgrade --install traefik ./traefik -f test-pr1492-values.yaml -n traefik --create-namespace
Error: UPGRADE FAILED: cannot patch "traefik" with kind Deployment: Deployment.apps "traefik" is invalid: spec.template.spec.containers[0].volumeMounts[3].mountPath: Required value

otherwize LGTM

@@ -0,0 +1,39 @@
# traefik-crds
Copy link
Contributor

Choose a reason for hiding this comment

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

@jnoordsij this README should be removed from the PR.
There is only one README.md at the root of this project and it's not auto-generated.

{{- end -}}

{{- define "traefik.localPluginCmName" -}}
{{ include "traefik.fullname" .context }}-local-plugin-{{ .pluginName | replace "." "-" }}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
{{ include "traefik.fullname" .context }}-local-plugin-{{ .pluginName | replace "." "-" }}
{{ include "traefik.fullname" .context }}-local-plugin-{{ .pluginName | replace "." "-" }}

Comment on lines +179 to +193
{{- $pluginType := include "traefik.getLocalPluginType" (dict "plugin" $localPlugin "pluginName" $localPluginName) }}
{{- if eq $pluginType "localPath" }}
{{- $localPathConfig := include "traefik.getLocalPluginLocalPath" (dict "plugin" $localPlugin) | fromYaml }}
- name: {{ $localPathConfig.volumeName }}
mountPath: {{ $localPlugin.mountPath | quote }}
{{- if $localPathConfig.subPath }}
subPath: {{ $localPathConfig.subPath }}
{{- end }}
{{- else }}
- name: {{ $localPluginName | replace "." "-" }}
mountPath: {{ $localPlugin.mountPath | quote }}
{{- if eq $pluginType "inlinePlugin" }}
readOnly: true
{{- end }}
{{- end }}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
{{- $pluginType := include "traefik.getLocalPluginType" (dict "plugin" $localPlugin "pluginName" $localPluginName) }}
{{- if eq $pluginType "localPath" }}
{{- $localPathConfig := include "traefik.getLocalPluginLocalPath" (dict "plugin" $localPlugin) | fromYaml }}
- name: {{ $localPathConfig.volumeName }}
mountPath: {{ $localPlugin.mountPath | quote }}
{{- if $localPathConfig.subPath }}
subPath: {{ $localPathConfig.subPath }}
{{- end }}
{{- else }}
- name: {{ $localPluginName | replace "." "-" }}
mountPath: {{ $localPlugin.mountPath | quote }}
{{- if eq $pluginType "inlinePlugin" }}
readOnly: true
{{- end }}
{{- end }}
{{- $pluginType := include "traefik.getLocalPluginType" (dict "plugin" $localPlugin "pluginName" $localPluginName) }}
{{- if eq $pluginType "localPath" }}
{{- $localPathConfig := include "traefik.getLocalPluginLocalPath" (dict "plugin" $localPlugin) | fromYaml }}
- name: {{ $localPathConfig.volumeName }}
mountPath: {{ $localPlugin.mountPath | quote }}
{{- if $localPathConfig.subPath }}
subPath: {{ $localPathConfig.subPath }}
{{- end }}
{{- else }}
- name: {{ $localPluginName | replace "." "-" }}
mountPath: {{ $localPlugin.mountPath | quote }}
{{- if eq $pluginType "inlinePlugin" }}
readOnly: true
{{- end }}
{{- end }}

Comment on lines +1 to +21
{{- if .Values.experimental.localPlugins }}
{{- range $localPluginName, $localPlugin := .Values.experimental.localPlugins }}
{{- $pluginType := include "traefik.getLocalPluginType" (dict "plugin" $localPlugin "pluginName" $localPluginName) }}
{{- if eq $pluginType "inlinePlugin" }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "traefik.localPluginCmName" (dict "context" $ "pluginName" $localPluginName) }}
namespace: {{ template "traefik.namespace" $ }}
labels:
{{- include "traefik.labels" $ | nindent 4 }}
data:
{{- $inlineFiles := include "traefik.getLocalPluginInlineFiles" (dict "plugin" $localPlugin "pluginName" $localPluginName) | fromYaml }}
{{- range $fileName, $fileContent := $inlineFiles }}
{{ $fileName }}: |
{{- $fileContent | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
{{- if .Values.experimental.localPlugins }}
{{- range $localPluginName, $localPlugin := .Values.experimental.localPlugins }}
{{- $pluginType := include "traefik.getLocalPluginType" (dict "plugin" $localPlugin "pluginName" $localPluginName) }}
{{- if eq $pluginType "inlinePlugin" }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "traefik.localPluginCmName" (dict "context" $ "pluginName" $localPluginName) }}
namespace: {{ template "traefik.namespace" $ }}
labels:
{{- include "traefik.labels" $ | nindent 4 }}
data:
{{- $inlineFiles := include "traefik.getLocalPluginInlineFiles" (dict "plugin" $localPlugin "pluginName" $localPluginName) | fromYaml }}
{{- range $fileName, $fileContent := $inlineFiles }}
{{ $fileName }}: |
{{- $fileContent | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.experimental.localPlugins }}
{{- range $localPluginName, $localPlugin := .Values.experimental.localPlugins }}
{{- $pluginType := include "traefik.getLocalPluginType" (dict "plugin" $localPlugin "pluginName" $localPluginName) }}
{{- if eq $pluginType "inlinePlugin" }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "traefik.localPluginCmName" (dict "context" $ "pluginName" $localPluginName) }}
namespace: {{ template "traefik.namespace" $ }}
labels:
{{- include "traefik.labels" $ | nindent 4 }}
data:
{{- $inlineFiles := include "traefik.getLocalPluginInlineFiles" (dict "plugin" $localPlugin "pluginName" $localPluginName) | fromYaml }}
{{- range $fileName, $fileContent := $inlineFiles }}
{{ $fileName }}: |
{{- $fileContent | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

Comment on lines +85 to +107
{{/* Warn about deprecated localPlugins */}}
{{- if include "traefik.hasDeprecatedLocalPlugins" . }}
{{- printf "\n" -}}
⚠️ DEPRECATION WARNING: You are using the deprecated legacy 'hostPath' configuration.
Please migrate to the new structured 'type.hostPathPlugin' configuration within localPlugins.
The legacy root-level hostPath configuration will be removed in the next major version.

Migration example:
experimental:
localPlugins:
your-plugin:
moduleName: github.com/example/yourplugin
mountPath: /plugins-local/src/github.com/example/yourplugin
# Choose one of the following types:
type: inlinePlugin # Recommended for small/medium plugins: secure ConfigMap-based
source: # Required for inlinePlugin
# your plugin files here
# type: hostPath # Use with caution for security reasons
# hostPath: /path/to/plugin
# type: localPath # Advanced: Uses additionalVolumes, can be used with PVC, CSI drivers (s3-csi-driver, FUSE), etc.
# volumeName: plugin-storage
{{- printf "\n" -}}
{{- end -}}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
{{/* Warn about deprecated localPlugins */}}
{{- if include "traefik.hasDeprecatedLocalPlugins" . }}
{{- printf "\n" -}}
⚠️ DEPRECATION WARNING: You are using the deprecated legacy 'hostPath' configuration.
Please migrate to the new structured 'type.hostPathPlugin' configuration within localPlugins.
The legacy root-level hostPath configuration will be removed in the next major version.
Migration example:
experimental:
localPlugins:
your-plugin:
moduleName: github.com/example/yourplugin
mountPath: /plugins-local/src/github.com/example/yourplugin
# Choose one of the following types:
type: inlinePlugin # Recommended for small/medium plugins: secure ConfigMap-based
source: # Required for inlinePlugin
# your plugin files here
# type: hostPath # Use with caution for security reasons
# hostPath: /path/to/plugin
# type: localPath # Advanced: Uses additionalVolumes, can be used with PVC, CSI drivers (s3-csi-driver, FUSE), etc.
# volumeName: plugin-storage
{{- printf "\n" -}}
{{- end -}}
{{/* Warn about deprecated localPlugins */}}
{{- if include "traefik.hasDeprecatedLocalPlugins" . }}
{{- printf "\n" -}}
⚠️ DEPRECATION WARNING: You are using the deprecated legacy 'hostPath' configuration.
Please migrate to the new structured 'type.hostPathPlugin' configuration within localPlugins.
The legacy root-level hostPath configuration will be removed in the next major version.
Migration example:
experimental:
localPlugins:
your-plugin:
moduleName: github.com/example/yourplugin
mountPath: /plugins-local/src/github.com/example/yourplugin
# Choose one of the following types:
type: inlinePlugin # Recommended for small/medium plugins: secure ConfigMap-based
source: # Required for inlinePlugin
# your plugin files here
# type: hostPath # Use with caution for security reasons
# hostPath: /path/to/plugin
# type: localPath # Advanced: Uses additionalVolumes, can be used with PVC, CSI drivers (s3-csi-driver, FUSE), etc.
# volumeName: plugin-storage
{{- printf "\n" -}}
{{- end -}}

Comment on lines 930 to +948
{{- range $localPluginName, $localPlugin := .Values.experimental.localPlugins }}
{{- $pluginType := include "traefik.getLocalPluginType" (dict "plugin" $localPlugin "pluginName" $localPluginName) }}
{{- if ne $pluginType "localPath" }}
- name: {{ $localPluginName | replace "." "-" }}
{{- if eq $pluginType "hostPath" }}
{{- $hostPath := include "traefik.getLocalPluginHostPath" (dict "plugin" $localPlugin) }}
hostPath:
path: {{ $localPlugin.hostPath | quote }}
path: {{ $hostPath | quote }}
{{- else if eq $pluginType "inlinePlugin" }}
configMap:
name: {{ include "traefik.localPluginCmName" (dict "context" $ "pluginName" $localPluginName) }}
{{- end }}
{{- else }}
{{- $localPathConfig := include "traefik.getLocalPluginLocalPath" (dict "plugin" $localPlugin) | fromYaml }}
{{- $volumeExists := include "traefik.volumeExistsInAdditionalVolumes" (dict "volumeName" $localPathConfig.volumeName "additionalVolumes" $.Values.deployment.additionalVolumes) }}
{{- if ne $volumeExists "true" }}
{{- fail (printf "ERROR: localPlugin %s references volume '%s' which is not found in deployment.additionalVolumes!" $localPluginName $localPathConfig.volumeName) }}
{{- end }}
{{- end }}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
{{- range $localPluginName, $localPlugin := .Values.experimental.localPlugins }}
{{- $pluginType := include "traefik.getLocalPluginType" (dict "plugin" $localPlugin "pluginName" $localPluginName) }}
{{- if ne $pluginType "localPath" }}
- name: {{ $localPluginName | replace "." "-" }}
{{- if eq $pluginType "hostPath" }}
{{- $hostPath := include "traefik.getLocalPluginHostPath" (dict "plugin" $localPlugin) }}
hostPath:
path: {{ $localPlugin.hostPath | quote }}
path: {{ $hostPath | quote }}
{{- else if eq $pluginType "inlinePlugin" }}
configMap:
name: {{ include "traefik.localPluginCmName" (dict "context" $ "pluginName" $localPluginName) }}
{{- end }}
{{- else }}
{{- $localPathConfig := include "traefik.getLocalPluginLocalPath" (dict "plugin" $localPlugin) | fromYaml }}
{{- $volumeExists := include "traefik.volumeExistsInAdditionalVolumes" (dict "volumeName" $localPathConfig.volumeName "additionalVolumes" $.Values.deployment.additionalVolumes) }}
{{- if ne $volumeExists "true" }}
{{- fail (printf "ERROR: localPlugin %s references volume '%s' which is not found in deployment.additionalVolumes!" $localPluginName $localPathConfig.volumeName) }}
{{- end }}
{{- end }}
{{- range $localPluginName, $localPlugin := .Values.experimental.localPlugins }}
{{- $pluginType := include "traefik.getLocalPluginType" (dict "plugin" $localPlugin "pluginName" $localPluginName) }}
{{- if ne $pluginType "localPath" }}
- name: {{ $localPluginName | replace "." "-" }}
{{- if eq $pluginType "hostPath" }}
{{- $hostPath := include "traefik.getLocalPluginHostPath" (dict "plugin" $localPlugin) }}
hostPath:
path: {{ $hostPath | quote }}
{{- else if eq $pluginType "inlinePlugin" }}
configMap:
name: {{ include "traefik.localPluginCmName" (dict "context" $ "pluginName" $localPluginName) }}
{{- end }}
{{- else }}
{{- $localPathConfig := include "traefik.getLocalPluginLocalPath" (dict "plugin" $localPlugin) | fromYaml }}
{{- $volumeExists := include "traefik.volumeExistsInAdditionalVolumes" (dict "volumeName" $localPathConfig.volumeName "additionalVolumes" $.Values.deployment.additionalVolumes) }}
{{- if ne $volumeExists "true" }}
{{- fail (printf "ERROR: localPlugin %s references volume '%s' which is not found in deployment.additionalVolumes!" $localPluginName $localPathConfig.volumeName) }}
{{- end }}
{{- end }}
{{- end }}

@cilasbeltrame
Copy link
Author

I tested it with the following values:

experimental:
  localPlugins:
    demo-inline:
      moduleName: github.com/traefik/plugindemo
      mountPath: /plugins-local/src/github.com/traefik/plugindemo
      type: inlinePlugin
      source:
        go.mod: |
          module github.com/traefik/plugindemo
          
          go 1.19

        .traefik.yml: |
          displayName: Demo Plugin
          type: middleware
          iconPath: .assets/icon.png
          
          import: github.com/traefik/plugindemo
          
          summary: '[Demo] Add Request Header'
          
          testData:
            Headers:
              X-Demo: test
              X-URL: '{{URL}}'

        demo.go: |
          // Package plugindemo a demo plugin.
          package plugindemo
          
          import (
          "bytes"
          "context"
          "fmt"
          "net/http"
          "text/template"
          )
          
          // Config the plugin configuration.
          type Config struct {
          Headers map[string]string `json:"headers,omitempty"`
          }

          // CreateConfig creates the default plugin configuration.
          func CreateConfig() *Config {
            return &Config{
          Headers: make(map[string]string),
          }
          }

          // Demo a Demo plugin.
          type Demo struct {
          next     http.Handler
          headers  map[string]string
          name     string
          template *template.Template
          }

          // New created a new Demo plugin.
          func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
          if len(config.Headers) == 0 {
          return nil, fmt.Errorf("headers cannot be empty")
          }

          return &Demo{
          headers:  config.Headers,
          next:     next,
          name:     name,
          template: template.New("demo").Delims("[[", "]]"),
          }, nil
          }
  
            func (a *Demo) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
            for key, value := range a.headers {
            tmpl, err := a.template.Parse(value)
            if err != nil {
            http.Error(rw, err.Error(), http.StatusInternalServerError)
            return
          }
  
            writer := &bytes.Buffer{}
            
            err = tmpl.Execute(writer, req)
            if err != nil {
            http.Error(rw, err.Error(), http.StatusInternalServerError)
            return
          }
  
            req.Header.Set(key, writer.String())
          }
  
            a.next.ServeHTTP(rw, req)
          }

    # Test the warning; BTW traefik should ignore the error with abortOnPluginFailure but that's not the case
    #demo-legacy-hostpath:
    #  moduleName: github.com/traefik/plugindemolegacyhostpath
    #  mountPath: /plugins-local/src/github.com/traefik/plugindemolegacyhostpath
    #  hostPath: /tmp/plugin-source  # This will trigger deprecation warning; just to test, path doesn't exist

    demo-localpath:
      moduleName: github.com/traefik/plugindemolocalpath
      mountPath: /plugins-local/src/github.com/traefik/plugindemolocalpath
      type: localPath
      volumeName: plugindemolocalpath

extraObjects:
  - apiVersion: v1
    kind: ConfigMap
    metadata:
      name: "plugindemo-localpath"
    data:
      go.mod: |
        module github.com/traefik/plugindemolocalpath
        
        go 1.19

      .traefik.yml: |
        displayName: Demo Plugin
        type: middleware
        iconPath: .assets/icon.png
        
        import: github.com/traefik/plugindemolocalpath
        
        summary: '[Demo] Add Request Header'
        
        testData:
          Headers:
            X-Demo: test
            X-URL: '{{"{{"}}URL{{"}}"}}'

      demo.go: |
        // Package plugindemolocalpath a demo plugin.
        package plugindemolocalpath
        
        import (
        "bytes"
        "context"
        "fmt"
        "net/http"
        "text/template"
        )
        
        // Config the plugin configuration.
        type Config struct {
        Headers map[string]string `json:"headers,omitempty"`
        }
        
        // CreateConfig creates the default plugin configuration.
        func CreateConfig() *Config {
          return &Config{
        Headers: make(map[string]string),
        }
        }
        
        // Demo a Demo plugin.
        type Demo struct {
        next     http.Handler
        headers  map[string]string
        name     string
        template *template.Template
        }
        
        // New created a new Demo plugin.
        func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
        if len(config.Headers) == 0 {
        return nil, fmt.Errorf("headers cannot be empty")
        }
        
        return &Demo{
        headers:  config.Headers,
        next:     next,
        name:     name,
        template: template.New("demo").Delims("[[", "]]"),
        }, nil
        }
        
          func (a *Demo) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
          for key, value := range a.headers {
          tmpl, err := a.template.Parse(value)
          if err != nil {
          http.Error(rw, err.Error(), http.StatusInternalServerError)
          return
        }
        
          writer := &bytes.Buffer{}
        
          err = tmpl.Execute(writer, req)
          if err != nil {
          http.Error(rw, err.Error(), http.StatusInternalServerError)
          return
        }
        
          req.Header.Set(key, writer.String())
        }
        
          a.next.ServeHTTP(rw, req)
        }
  - apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: whoami
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: whoami
      template:
        metadata:
          labels:
            app: whoami
        spec:
          containers:
            - name: whoami
              image: traefik/whoami:latest
              ports:
                - containerPort: 80

  - apiVersion: v1
    kind: Service
    metadata:
      name: whoami
    spec:
      ports:
        - port: 80
          targetPort: 80
      selector:
        app: whoami

  - apiVersion: traefik.io/v1alpha1
    kind: Middleware
    metadata:
      name: demo-inline
    spec:
      plugin:
        demo-inline:
          headers:
            X-Demo: "demo"
  - apiVersion: traefik.io/v1alpha1
    kind: Middleware
    metadata:
      name: demo-localpath
    spec:
      plugin:
        demo-localpath:
          headers:
            X-Demo: "demo-localpath"
  - apiVersion: traefik.io/v1alpha1
    kind: IngressRoute
    metadata:
      name: whoami-test
    spec:
      entryPoints:
        - web
      routes:
        - match: Host(`whoami.localhost`) || PathPrefix(`/whoami`)
          kind: Rule
          services:
            - name: whoami
              port: 80
          middlewares:
            - name: demo-inline
        - match: Host(`whoami.localhost`) || PathPrefix(`/whoami2`)
          kind: Rule
          services:
            - name: whoami
              port: 80
          middlewares:
            - name: demo-localpath
ingressRoute:
  dashboard:
    enabled: true

logs:
  general:
    level: INFO
  access:
    enabled: true

deployment:
  additionalVolumes:
  - name: plugindemolocalpath
    configMap:
      name: plugindemo-localpath

It misses a control on mountPath which should be required and produces:

$ helm upgrade --install traefik ./traefik -f test-pr1492-values.yaml -n traefik --create-namespace
Error: UPGRADE FAILED: cannot patch "traefik" with kind Deployment: Deployment.apps "traefik" is invalid: spec.template.spec.containers[0].volumeMounts[3].mountPath: Required value

otherwize LGTM

@darkweaver87 I think you are having old ref on your local, using your values file I'm able to test it sucessfully from my end. Pls review and confirm.

❯ helm install test . -f ../dark-file.yaml  -n traefik2 --create-namespace  
NAME: test
LAST DEPLOYED: Thu Nov  6 07:55:24 2025
NAMESPACE: traefik2
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
test with docker.io/traefik:v3.5.4 has been deployed successfully on traefik2 namespace !
cilasbeltrame in traefik-helm-chart/traefik took 21.1s 
cilasbeltrame in traefik-helm-chart/traefik on  feat/support-cm-local-plugins [$?] took 21.1s 
❯ k get pods -n traefik2                                                                     
NAME                            READY   STATUS    RESTARTS   AGE
test-traefik-85c7596c99-hz5z7   1/1     Running   0          3m2s
whoami-55dd44b4d9-9qzhh         1/1     Running   0          3m2s
cilasbeltrame in traefik-helm-chart/traefik took 2.2s
❯ k logs test-traefik-85c7596c99-hz5z7 -n traefik2 | grep -i plugin
2025-11-06T10:55:36Z INF Loading plugins... plugins=["demo-inline","demo-localpath"]
2025-11-06T10:55:36Z INF Plugins loaded. plugins=["demo-inline","demo-localpath"] 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants