diff --git a/internal/generator/chart.go b/internal/generator/chart.go index 3941841..93adf9a 100644 --- a/internal/generator/chart.go +++ b/internal/generator/chart.go @@ -247,6 +247,15 @@ func (chart *HelmChart) generateDeployment(service types.ServiceConfig, deployme } } + // create IngressRoute (Traefik CRD) if specified + if ingressRoute := d.AddIngressRoute(service, appName); ingressRoute != nil { + y, _ := ingressRoute.Yaml() + chart.Templates[ingressRoute.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + } + return nil } diff --git a/internal/generator/converter.go b/internal/generator/converter.go index fc9c863..ab3c148 100644 --- a/internal/generator/converter.go +++ b/internal/generator/converter.go @@ -22,12 +22,15 @@ import ( "github.com/compose-spec/compose-go/v2/types" ) -const ingressClassHelp = `# Default value for ingress.class annotation -# class: "-" +const ingressClassHelp = `# Default value for ingress.class +# class: "traefik" # If the value is "-", controller will not set ingressClassName # If the value is "", Ingress will be set to an empty string, so # controller will use the default value for ingressClass -# If the value is specified, controller will set the named class e.g. "nginx" +# If the value is specified, controller will set the named class e.g. "traefik" +# +# Ingress type: "ingress" (default) or "ingressroute" (Traefik CRD) +# type: "ingress" ` const storageClassHelp = `# Storage class to use for PVCs diff --git a/internal/generator/deployment.go b/internal/generator/deployment.go index ac01c71..b42a617 100644 --- a/internal/generator/deployment.go +++ b/internal/generator/deployment.go @@ -196,6 +196,11 @@ func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *In return NewIngress(service, d.chart) } +// AddIngressRoute adds an IngressRoute to the deployment if type is "ingressroute". +func (d *Deployment) AddIngressRoute(service types.ServiceConfig, appName string) Yaml { + return NewIngressRouteFromService(service, d.chart) +} + // AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. // If the volume is a bind volume it will warn the user that it is not supported yet. func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { diff --git a/internal/generator/ingress.go b/internal/generator/ingress.go index f17b3ce..6811132 100644 --- a/internal/generator/ingress.go +++ b/internal/generator/ingress.go @@ -21,7 +21,7 @@ type Ingress struct { appName string `yaml:"-"` } -// NewIngress creates a new Ingress from a compose service. +// NewIngress creates a new standard Kubernetes Ingress from a compose service. func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress { appName := Chart.Name @@ -42,13 +42,7 @@ func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress { mapping.Hostname = service.Name + ".tld" } - // create the ingress - pathType := networkv1.PathTypeImplementationSpecific - - // fix the service name, and create the full name from variable name - // which is injected in the YAML() method serviceName := strings.ReplaceAll(service.Name, "_", "-") - fullName := `{{ $fullname }}-` + serviceName // Add the ingress host to the values.yaml if Chart.Values[service.Name] == nil { @@ -56,15 +50,48 @@ func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress { } Chart.Values[service.Name].(*Value).Ingress = &IngressValue{ - Enabled: mapping.Enabled, - Path: *mapping.Path, - Host: mapping.Hostname, - Class: *mapping.Class, - Annotations: mapping.Annotations, - TLS: TLS{Enabled: mapping.TLS.Enabled}, + Enabled: mapping.Enabled, + Path: *mapping.Path, + Host: mapping.Hostname, + Class: *mapping.Class, + Type: mapping.Type, + IngressRouteEnabled: mapping.Type == "ingressroute" && mapping.Enabled, + Annotations: mapping.Annotations, + TLS: TLS{Enabled: mapping.TLS.Enabled}, } - // ingressClassName := `{{ .Values.` + service.Name + `.ingress.class }}` + return newStandardIngress(service, mapping, serviceName, appName) +} + +// NewIngressRouteFromService creates a Traefik IngressRoute from the same service config. +// This is called separately to generate the IngressRoute file in addition to Ingress. +func NewIngressRouteFromService(service types.ServiceConfig, Chart *HelmChart) Yaml { + appName := Chart.Name + + if service.Labels == nil { + return nil + } + var label string + var ok bool + if label, ok = service.Labels[labels.LabelIngress]; !ok { + return nil + } + + mapping, err := labelstructs.IngressFrom(label) + if err != nil { + return nil + } + + serviceName := strings.ReplaceAll(service.Name, "_", "-") + return NewIngressRoute(service, Chart, mapping, serviceName, appName) +} + +// newStandardIngress creates a standard Kubernetes Ingress +func newStandardIngress(service types.ServiceConfig, mapping *labelstructs.Ingress, serviceName, appName string) *Ingress { + pathType := networkv1.PathTypeImplementationSpecific + + fullName := `{{ $fullname }}-` + serviceName + ingressClassName := utils.TplValue(service.Name, "ingress.class") servicePortName := utils.GetServiceNameByPort(int(*mapping.Port)) @@ -174,16 +201,13 @@ func (ingress *Ingress) Yaml() ([]byte, error) { `{{- $tlsname := printf "%s-%s-tls" $fullname "` + ingress.service.Name + `" -}}`, } for _, line := range lines { - if strings.Contains(line, "loadBalancer: ") { - continue - } - if strings.Contains(line, "labels:") { // add annotations above labels from values.yaml + indent := strings.Repeat(" ", utils.CountStartingSpaces(line)) content := `` + - ` {{- if .Values.` + serviceName + `.ingress.annotations -}}` + "\n" + - ` {{- toYaml .Values.` + serviceName + `.ingress.annotations | nindent 4 }}` + "\n" + - ` {{- end }}` + "\n" + + indent + `{{- if .Values.` + serviceName + `.ingress.annotations -}}` + "\n" + + indent + ` {{- toYaml .Values.` + serviceName + `.ingress.annotations | nindent __indent__ }}` + "\n" + + indent + ` {{- end }}` + "\n" + line out = append(out, content) diff --git a/internal/generator/ingress_test.go b/internal/generator/ingress_test.go index 122a71c..225358c 100644 --- a/internal/generator/ingress_test.go +++ b/internal/generator/ingress_test.go @@ -98,7 +98,7 @@ services: %s/ingress: |- hostname: my.test.tld port: 80 -` + ` composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix) tmpDir := setup(composeFile) defer teardown(tmpDir) diff --git a/internal/generator/ingressroute.go b/internal/generator/ingressroute.go new file mode 100644 index 0000000..34575c8 --- /dev/null +++ b/internal/generator/ingressroute.go @@ -0,0 +1,186 @@ +package generator + +import ( + "strings" + + "sigs.k8s.io/yaml" + + "katenary.io/internal/generator/labels/labelstructs" + "katenary.io/internal/utils" + + "github.com/compose-spec/compose-go/v2/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ Yaml = (*IngressRoute)(nil) + +// IngressRoute represents a Traefik IngressRoute CRD +type IngressRoute struct { + metav1.TypeMeta `yaml:",inline"` + metav1.ObjectMeta `yaml:"metadata"` + Spec IngressRouteSpec `yaml:"spec"` + service *types.ServiceConfig `yaml:"-"` + appName string `yaml:"-"` + serviceName string `yaml:"-"` +} + +// IngressRouteSpec defines the spec for Traefik IngressRoute +type IngressRouteSpec struct { + EntryPoints []string `json:"entryPoints,omitempty" yaml:"entryPoints,omitempty"` + Routes []IngressRouteRoute `json:"routes" yaml:"routes"` + TLS *IngressRouteTLS `json:"tls,omitempty" yaml:"tls,omitempty"` +} + +// IngressRouteRoute defines a route in the IngressRoute +type IngressRouteRoute struct { + Match string `json:"match" yaml:"match"` + Kind string `json:"kind" yaml:"kind"` + Services []IngressRouteService `json:"services" yaml:"services"` +} + +// IngressRouteService defines a service backend in IngressRoute +type IngressRouteService struct { + Name string `json:"name" yaml:"name"` + Port int `json:"port" yaml:"port"` +} + +// IngressRouteTLS defines TLS configuration for IngressRoute +type IngressRouteTLS struct { + SecretName string `json:"secretName,omitempty" yaml:"secretName,omitempty"` + Domains []IngressRouteTLSDomain `json:"domains,omitempty" yaml:"domains,omitempty"` +} + +// IngressRouteTLSDomain defines a domain for TLS +type IngressRouteTLSDomain struct { + Main string `json:"main" yaml:"main"` +} + +// NewIngressRoute creates a new Traefik IngressRoute from a compose service. +func NewIngressRoute(service types.ServiceConfig, Chart *HelmChart, mapping *labelstructs.Ingress, serviceName, appName string) *IngressRoute { + fullName := `{{ $fullname }}-` + serviceName + + // Build the route match rule + match := `Host("{{ tpl .Values.` + serviceName + `.ingress.host . }}")` + path := utils.TplValue(serviceName, "ingress.path") + if path != "/" && path != "" { + match += ` && PathPrefix("` + path + `")` + } + + ir := &IngressRoute{ + TypeMeta: metav1.TypeMeta{ + Kind: "IngressRoute", + APIVersion: "traefik.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: fullName, + Labels: GetLabels(serviceName, appName), + Annotations: Annotations, + }, + Spec: IngressRouteSpec{ + EntryPoints: []string{"web", "websecure"}, + Routes: []IngressRouteRoute{ + { + Match: match, + Kind: "Rule", + Services: []IngressRouteService{ + { + Name: fullName, + Port: int(*mapping.Port), + }, + }, + }, + }, + }, + service: &service, + appName: appName, + serviceName: serviceName, + } + + // Add TLS configuration if enabled + if mapping.TLS != nil && mapping.TLS.Enabled { + tlsSecretName := `{{ .Values.` + serviceName + `.ingress.tls.secretName | default $tlsname }}` + ir.Spec.TLS = &IngressRouteTLS{ + SecretName: tlsSecretName, + Domains: []IngressRouteTLSDomain{ + { + Main: `{{ tpl .Values.` + serviceName + `.ingress.host . }}`, + }, + }, + } + } + + return ir +} + +func (ir *IngressRoute) Filename() string { + return ir.serviceName + ".ingressroute.yaml" +} + +func (ir *IngressRoute) Yaml() ([]byte, error) { + var ret []byte + var err error + + // Manually construct YAML - sigs.k8s.io/yaml doesn't handle metav1.ObjectMeta + // with yaml:"metadata" correctly unless embedded in a standard K8s type with JSON tags. + // We build the YAML as a string to ensure proper nesting. + + // Build metadata block + metadata, err := yaml.Marshal(map[string]interface{}{ + "name": ir.Name, + "labels": ir.Labels, + "annotations": ir.Annotations, + }) + if err != nil { + return nil, err + } + + // Build spec block + spec, err := yaml.Marshal(ir.Spec) + if err != nil { + return nil, err + } + + // Build final YAML with proper structure + ret = []byte("apiVersion: " + ir.APIVersion + "\n") + ret = append(ret, "kind: "+ir.Kind+"\n"...) + ret = append(ret, "metadata:\n"...) + // Indent metadata content by 2 spaces + for _, line := range strings.Split(strings.TrimRight(string(metadata), "\n"), "\n") { + ret = append(ret, " "+line+"\n"...) + } + ret = append(ret, "spec:\n"...) + // Indent spec content by 2 spaces + for _, line := range strings.Split(strings.TrimRight(string(spec), "\n"), "\n") { + ret = append(ret, " "+line+"\n"...) + } + + ret = UnWrapTPL(ret) + + lines := strings.Split(string(ret), "\n") + + out := []string{ + `{{- if .Values.` + ir.serviceName + `.ingress.ingressRouteEnabled -}}`, + `{{- $fullname := include "` + ir.appName + `.fullname" . -}}`, + `{{- $tlsname := printf "%s-%s-tls" $fullname "` + ir.serviceName + `" -}}`, + } + + for _, line := range lines { + if strings.Contains(line, "labels:") { + // add annotations above labels from values.yaml (inside metadata block) + indent := strings.Repeat(" ", utils.CountStartingSpaces(line)) + content := `` + + indent + `{{- if .Values.` + ir.serviceName + `.ingress.annotations -}}` + "\n" + + indent + ` {{- toYaml .Values.` + ir.serviceName + `.ingress.annotations | nindent __indent__ }}` + "\n" + + indent + ` {{- end }}` + "\n" + + line + + out = append(out, content) + } else { + out = append(out, line) + } + } + out = append(out, `{{- end -}}`) + ret = []byte(strings.Join(out, "\n")) + return ret, nil +} + diff --git a/internal/generator/ingressroute_test.go b/internal/generator/ingressroute_test.go new file mode 100644 index 0000000..e802fb1 --- /dev/null +++ b/internal/generator/ingressroute_test.go @@ -0,0 +1,240 @@ +package generator + +import ( + "fmt" + "os" + "strings" + "testing" + + "katenary.io/internal/generator/labels" +) + +func TestIngressRoute(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + labels: + %s/ingress: |- + hostname: my.test.tld + port: 80 + type: ingressroute + enabled: true + ` + composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix) + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + // Test that the ingressroute file is generated + output := internalCompileTest( + t, + "-s", "templates/web/ingressroute.yaml", + ) + + // Check that it's a Traefik IngressRoute + if !strings.Contains(output, "kind: IngressRoute") { + t.Errorf("Expected IngressRoute kind in output") + } + if !strings.Contains(output, "apiVersion: traefik.io/v1alpha1") { + t.Errorf("Expected traefik.io/v1alpha1 apiVersion in output") + } + if !strings.Contains(output, "my.test.tld") { + t.Errorf("Expected host my.test.tld in output") + } +} + +func TestIngressRouteWithTLS(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + ports: + - 443:443 + labels: + %s/ingress: |- + hostname: secure.example.com + port: 443 + type: ingressroute + enabled: true + tls: + enabled: true + ` + composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix) + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := internalCompileTest( + t, + "-s", "templates/web/ingressroute.yaml", + ) + + // Check TLS configuration + if !strings.Contains(output, "tls:") { + t.Errorf("Expected TLS configuration in IngressRoute, got: %s", output) + } + if !strings.Contains(output, "secretName:") { + t.Errorf("Expected SecretName in TLS configuration, got: %s", output) + } +} + +func TestIngressRouteWithPath(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + labels: + %s/ingress: |- + hostname: app.example.com + port: 80 + path: /api + type: ingressroute + enabled: true + ` + composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix) + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := internalCompileTest( + t, + "-s", "templates/web/ingressroute.yaml", + ) + + // Check path prefix in match rule + if !strings.Contains(output, "PathPrefix") { + t.Errorf("Expected PathPrefix in match rule") + } + if !strings.Contains(output, "/api") { + t.Errorf("Expected path /api in match rule") + } +} + +func TestIngressRouteEntryPoints(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + labels: + %s/ingress: |- + hostname: app.example.com + port: 80 + type: ingressroute + enabled: true + ` + composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix) + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := internalCompileTest( + t, + "-s", "templates/web/ingressroute.yaml", + ) + + // Check default entryPoints + if !strings.Contains(output, "web") { + t.Errorf("Expected 'web' entryPoint in IngressRoute") + } + if !strings.Contains(output, "websecure") { + t.Errorf("Expected 'websecure' entryPoint in IngressRoute") + } +} + +func TestIngressRouteDisabled(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + labels: + %s/ingress: |- + hostname: app.example.com + port: 80 + type: ingressroute + enabled: false + ` + composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix) + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + // When ingress is disabled, the file is generated but the helm template + // with -s flag will fail because output is empty due to conditional + // Instead, just verify the chart is valid by running helm template without -s + // The chart should lint successfully + output := internalCompileTest( + t, + ) + + // The output should not contain IngressRoute kind since it's disabled + if strings.Contains(output, "kind: IngressRoute") { + t.Errorf("IngressRoute should not be in output when disabled") + } +} + +func TestIngressRouteMetadata(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + labels: + %s/ingress: |- + hostname: meta.example.com + port: 80 + type: ingressroute + enabled: true + ` + composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix) + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := internalCompileTest( + t, + "-s", "templates/web/ingressroute.yaml", + ) + + // Check that labels are present (like other objects - no metadata: block) + if !strings.Contains(output, "labels:") { + t.Errorf("Expected labels: in IngressRoute output") + } + + // Check that katenary labels are present (set by GetLabels) + if !strings.Contains(output, "katenary.v3/component") { + t.Errorf("Expected katenary.v3/component label in IngressRoute") + } + + // Check that the standard labels are present + if !strings.Contains(output, "app.kubernetes.io/name") { + t.Errorf("Expected app.kubernetes.io/name label in IngressRoute") + } +} diff --git a/internal/generator/labels/katenaryLabelsDoc.yaml b/internal/generator/labels/katenaryLabelsDoc.yaml index f1a3742..32f1124 100644 --- a/internal/generator/labels/katenaryLabelsDoc.yaml +++ b/internal/generator/labels/katenaryLabelsDoc.yaml @@ -110,11 +110,25 @@ long: |- Declare an ingress rule for the service. The port should be exposed or declared with {{ printf "%s/%s" .KatenaryPrefix "ports" }}. + + The default ingress class is "traefik". + + **Files generated:** Both `ingress.yaml` (standard Kubernetes Ingress) and + `ingressroute.yaml` (Traefik IngressRoute CRD) are generated. You can + control which one is installed via values.yaml: + + - `ingress.enabled` - controls standard Ingress installation + - `ingress.ingressRouteEnabled` - controls IngressRoute installation + + Setting `type: ingressroute` automatically sets `ingressRouteEnabled: true`. example: |- labels: {{ .KatenaryPrefix }}/ingress: |- port: 80 hostname: mywebsite.com (optional) + # Use Traefik IngressRoute instead of standard Ingress + type: ingressroute + enabled: true type: "object" "map-env": diff --git a/internal/generator/labels/labelstructs/ingress.go b/internal/generator/labels/labelstructs/ingress.go index 93ddc61..217794b 100644 --- a/internal/generator/labels/labelstructs/ingress.go +++ b/internal/generator/labels/labelstructs/ingress.go @@ -17,7 +17,8 @@ type Ingress struct { Annotations map[string]string `yaml:"annotations,omitempty" jsonschema:"nullable" json:"annotations,omitempty"` Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"` Path *string `yaml:"path,omitempty" json:"path,omitempty"` - Class *string `yaml:"class,omitempty" json:"class,omitempty" jsonschema:"default:-"` + Class *string `yaml:"class,omitempty" json:"class,omitempty" jsonschema:"default:traefik"` + Type string `yaml:"type,omitempty" json:"type,omitempty"` Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` TLS *TLS `yaml:"tls,omitempty" json:"tls,omitempty"` } @@ -28,7 +29,8 @@ func IngressFrom(data string) (*Ingress, error) { Hostname: "", Path: utils.StrPtr("/"), Enabled: false, - Class: utils.StrPtr("-"), + Class: utils.StrPtr("traefik"), + Type: "ingress", Port: nil, TLS: &TLS{Enabled: true}, } diff --git a/internal/generator/values.go b/internal/generator/values.go index f129e9a..16b184a 100644 --- a/internal/generator/values.go +++ b/internal/generator/values.go @@ -27,12 +27,14 @@ type TLS struct { // IngressValue is a ingress configuration that will be saved in values.yaml. type IngressValue struct { - Annotations map[string]string `yaml:"annotations"` - Host string `yaml:"host"` - Path string `yaml:"path"` - Class string `yaml:"class"` - Enabled bool `yaml:"enabled"` - TLS TLS `yaml:"tls"` + Annotations map[string]string `yaml:"annotations"` + Host string `yaml:"host"` + Path string `yaml:"path"` + Class string `yaml:"class"` + Type string `yaml:"type"` + Enabled bool `yaml:"enabled"` + IngressRouteEnabled bool `yaml:"ingressRouteEnabled"` + TLS TLS `yaml:"tls"` } // Value will be saved in values.yaml. It contains configuration for all deployment and services. @@ -92,7 +94,8 @@ func (v *Value) AddIngress(host, path string) { Enabled: true, Host: host, Path: path, - Class: "-", + Class: "traefik", + Type: "ingress", TLS: TLS{ Enabled: true, SecretName: "",