diff --git a/src/TextTemplate/TemplateEngine.cs b/src/TextTemplate/TemplateEngine.cs index a8a57bb..569a813 100644 --- a/src/TextTemplate/TemplateEngine.cs +++ b/src/TextTemplate/TemplateEngine.cs @@ -162,6 +162,13 @@ private class ReplacementVisitor : GoTextTemplateParserBaseVisitor ["html"] = args => System.Net.WebUtility.HtmlEncode(string.Concat(args.Select(a => a?.ToString()))), ["js"] = args => System.Text.Encodings.Web.JavaScriptEncoder.Default.Encode(string.Concat(args.Select(a => a?.ToString()))), ["urlquery"] = args => Uri.EscapeDataString(string.Concat(args.Select(a => a?.ToString()))), + ["quote"] = args => + { + if (args.Length == 0 || args[0] == null) return string.Empty; + var s = args[0]?.ToString() ?? string.Empty; + s = s.Replace("\"", "\\\""); + return $"\"{s}\""; + }, ["len"] = args => { if (args.Length == 0 || args[0] == null) return 0; diff --git a/tests/TextTemplate.Tests/TestData/k8s-template-data.yml b/tests/TextTemplate.Tests/TestData/k8s-template-data.yml new file mode 100644 index 0000000..248658b --- /dev/null +++ b/tests/TextTemplate.Tests/TestData/k8s-template-data.yml @@ -0,0 +1,5 @@ +{{template "deployment" .}} +--- +{{template "service" .}} +--- +{{template "ingress" .}} diff --git a/tests/TextTemplate.Tests/TestData/k8s-template-deployment.yml b/tests/TextTemplate.Tests/TestData/k8s-template-deployment.yml new file mode 100644 index 0000000..9d9754a --- /dev/null +++ b/tests/TextTemplate.Tests/TestData/k8s-template-deployment.yml @@ -0,0 +1,97 @@ +{{define "deployment"}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.appName }}-deployment + namespace: {{ .Values.namespace | default "default" }} + labels: + app: {{ .Values.appName }} + version: {{ .Values.version }} + {{- if .Values.environment }} + environment: {{ .Values.environment }} + {{- end }} + {{- range $key, $value := .Values.customLabels }} + {{ $key }}: {{ $value }} + {{- end }} +spec: + replicas: {{ .Values.replicaCount | default 3 }} + selector: + matchLabels: + app: {{ .Values.appName }} + template: + metadata: + labels: + app: {{ .Values.appName }} + version: {{ .Values.version }} + spec: + containers: + - name: {{ .Values.appName }} + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + ports: + - containerPort: {{ .Values.service.port }} + {{- if .Values.env }} + env: + {{- range .Values.env }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + {{- end }} + {{- if .Values.resources }} + resources: + {{- if .Values.resources.limits }} + limits: + {{- range $key, $value := .Values.resources.limits }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} + {{- if .Values.resources.requests }} + requests: + {{- range $key, $value := .Values.resources.requests }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} + {{- end }} + {{- if .Values.volumeMounts }} + volumeMounts: + {{- range .Values.volumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + {{- if .readOnly }} + readOnly: {{ .readOnly }} + {{- end }} + {{- end }} + {{- end }} + {{- if .Values.volumes }} + volumes: + {{- range .Values.volumes }} + - name: {{ .name }} + {{- if .configMap }} + configMap: + name: {{ .configMap.name }} + {{- else if .secret }} + secret: + secretName: {{ .secret.secretName }} + {{- else if .persistentVolumeClaim }} + persistentVolumeClaim: + claimName: {{ .persistentVolumeClaim.claimName }} + {{- end }} + {{- end }} + {{- end }} + {{- if .Values.nodeSelector }} + nodeSelector: + {{- range $key, $value := .Values.nodeSelector }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} + {{- if .Values.tolerations }} + tolerations: + {{- range .Values.tolerations }} + - key: {{ .key }} + operator: {{ .operator | default "Equal" }} + {{- if .value }} + value: {{ .value }} + {{- end }} + effect: {{ .effect }} + {{- end }} + {{- end }} +{{end}} diff --git a/tests/TextTemplate.Tests/TestData/expected.yml b/tests/TextTemplate.Tests/TestData/k8s-template-expected.yml similarity index 100% rename from tests/TextTemplate.Tests/TestData/expected.yml rename to tests/TextTemplate.Tests/TestData/k8s-template-expected.yml diff --git a/tests/TextTemplate.Tests/TestData/k8s-template-ingress.yml b/tests/TextTemplate.Tests/TestData/k8s-template-ingress.yml new file mode 100644 index 0000000..0b7ab35 --- /dev/null +++ b/tests/TextTemplate.Tests/TestData/k8s-template-ingress.yml @@ -0,0 +1,41 @@ +{{define "ingress"}} +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Values.appName }}-ingress + namespace: {{ .Values.namespace | default "default" }} + {{- if .Values.ingress.annotations }} + annotations: + {{- range $key, $value := .Values.ingress.annotations }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} +spec: + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ $.Values.appName }}-service + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} +{{end}} diff --git a/tests/TextTemplate.Tests/TestData/k8s-template-service.yml b/tests/TextTemplate.Tests/TestData/k8s-template-service.yml new file mode 100644 index 0000000..e05a9ef --- /dev/null +++ b/tests/TextTemplate.Tests/TestData/k8s-template-service.yml @@ -0,0 +1,22 @@ +{{define "service"}} +{{- if .Values.service.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.appName }}-service + namespace: {{ .Values.namespace | default "default" }} + labels: + app: {{ .Values.appName }} +spec: + type: {{ .Values.service.type | default "ClusterIP" }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort | default .Values.service.port }} + protocol: TCP + {{- if .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} + selector: + app: {{ .Values.appName }} +{{- end }} +{{end}} diff --git a/tests/TextTemplate.Tests/TestData/template.yml b/tests/TextTemplate.Tests/TestData/single-k8s-data.yml similarity index 98% rename from tests/TextTemplate.Tests/TestData/template.yml rename to tests/TextTemplate.Tests/TestData/single-k8s-data.yml index fdb9677..d950aa6 100644 --- a/tests/TextTemplate.Tests/TestData/template.yml +++ b/tests/TextTemplate.Tests/TestData/single-k8s-data.yml @@ -108,7 +108,7 @@ spec: - port: {{ .Values.service.port }} targetPort: {{ .Values.service.targetPort | default .Values.service.port }} protocol: TCP - {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }} + {{- if .Values.service.nodePort }} nodePort: {{ .Values.service.nodePort }} {{- end }} selector: diff --git a/tests/TextTemplate.Tests/TestData/single-k8s-expected.yml b/tests/TextTemplate.Tests/TestData/single-k8s-expected.yml new file mode 100644 index 0000000..9724686 --- /dev/null +++ b/tests/TextTemplate.Tests/TestData/single-k8s-expected.yml @@ -0,0 +1,136 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-web-app-deployment + namespace: production + labels: + app: my-web-app + version: "1.2.3" + environment: prod + team: backend + cost-center: engineering + project: web-platform +spec: + replicas: 5 + selector: + matchLabels: + app: my-web-app + template: + metadata: + labels: + app: my-web-app + version: "1.2.3" + spec: + containers: + - name: my-web-app + image: myregistry.com/my-web-app:v1.2.3 + ports: + - containerPort: 80 + env: + - name: DATABASE_URL + value: "postgresql://db.example.com:5432/myapp" + - name: REDIS_HOST + value: "redis.example.com" + - name: LOG_LEVEL + value: "info" + - name: API_KEY + value: "secret-api-key-123" + resources: + limits: + cpu: "1000m" + memory: "1Gi" + requests: + cpu: "500m" + memory: "512Mi" + volumeMounts: + - name: config-volume + mountPath: /etc/config + readOnly: true + - name: secret-volume + mountPath: /etc/secrets + readOnly: true + - name: data-volume + mountPath: /var/data + volumes: + - name: config-volume + configMap: + name: my-web-app-config + - name: secret-volume + secret: + secretName: my-web-app-secrets + - name: data-volume + persistentVolumeClaim: + claimName: my-web-app-data + nodeSelector: + kubernetes.io/os: linux + node-type: web-tier + tolerations: + - key: "node-type" + operator: "Equal" + value: "web-tier" + effect: "NoSchedule" + - key: "dedicated" + operator: "Equal" + value: "web-app" + effect: "NoExecute" +--- +apiVersion: v1 +kind: Service +metadata: + name: my-web-app-service + namespace: production + labels: + app: my-web-app +spec: + type: LoadBalancer + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + nodePort: 30080 + selector: + app: my-web-app +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: my-web-app-ingress + namespace: production + annotations: + kubernetes.io/ingress.class: "nginx" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/rewrite-target: "/" +spec: + tls: + - hosts: + - myapp.example.com + - api.myapp.example.com + secretName: myapp-tls-cert + rules: + - host: myapp.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: my-web-app-service + port: + number: 80 + - path: /api + pathType: Prefix + backend: + service: + name: my-web-app-service + port: + number: 80 + - host: api.myapp.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: my-web-app-service + port: + number: 80 diff --git a/tests/TextTemplate.Tests/TextTemplate.Tests.csproj b/tests/TextTemplate.Tests/TextTemplate.Tests.csproj index 692ecac..e976869 100644 --- a/tests/TextTemplate.Tests/TextTemplate.Tests.csproj +++ b/tests/TextTemplate.Tests/TextTemplate.Tests.csproj @@ -37,10 +37,25 @@ PreserveNewest - + PreserveNewest - + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + PreserveNewest diff --git a/tests/TextTemplate.Tests/YmlTemplateFileTest.cs b/tests/TextTemplate.Tests/YmlTemplateFileTest.cs index 1d1f87e..c1705b6 100644 --- a/tests/TextTemplate.Tests/YmlTemplateFileTest.cs +++ b/tests/TextTemplate.Tests/YmlTemplateFileTest.cs @@ -25,12 +25,12 @@ private static string NormalizeYaml(string text) return sb.ToString(); } - [Fact(Skip = "Template features not supported")] + [Fact] public void Template_YamlFile_RendersExpectedOutput() { string baseDir = AppContext.BaseDirectory; - string templatePath = Path.Combine(baseDir, "TestData", "template.yml"); - string expectedPath = Path.Combine(baseDir, "TestData", "expected.yml"); + string templatePath = Path.Combine(baseDir, "TestData", "single-k8s-data.yml"); + string expectedPath = Path.Combine(baseDir, "TestData", "single-k8s-expected.yml"); string template = File.ReadAllText(templatePath); string expected = File.ReadAllText(expectedPath); @@ -65,10 +65,10 @@ public void Template_YamlFile_RendersExpectedOutput() }, ["env"] = new object[] { - new Dictionary{{"name","DATABASE_URL"},{"value","\"postgresql://db.example.com:5432/myapp\""}}, - new Dictionary{{"name","REDIS_HOST"},{"value","\"redis.example.com\""}}, - new Dictionary{{"name","LOG_LEVEL"},{"value","\"info\""}}, - new Dictionary{{"name","API_KEY"},{"value","\"secret-api-key-123\""}} + new Dictionary{{"name","DATABASE_URL"},{"value","postgresql://db.example.com:5432/myapp"}}, + new Dictionary{{"name","REDIS_HOST"},{"value","redis.example.com"}}, + new Dictionary{{"name","LOG_LEVEL"},{"value","info"}}, + new Dictionary{{"name","API_KEY"},{"value","secret-api-key-123"}} }, ["resources"] = new Dictionary { @@ -85,8 +85,8 @@ public void Template_YamlFile_RendersExpectedOutput() }, ["volumeMounts"] = new object[] { - new Dictionary{{"name","config-volume"},{"mountPath","/etc/config"},{"readOnly",true}}, - new Dictionary{{"name","secret-volume"},{"mountPath","/etc/secrets"},{"readOnly",true}}, + new Dictionary{{"name","config-volume"},{"mountPath","/etc/config"},{"readOnly","true"}}, + new Dictionary{{"name","secret-volume"},{"mountPath","/etc/secrets"},{"readOnly","true"}}, new Dictionary{{"name","data-volume"},{"mountPath","/var/data"}} }, ["volumes"] = new object[] @@ -110,9 +110,9 @@ public void Template_YamlFile_RendersExpectedOutput() ["enabled"] = true, ["annotations"] = new Dictionary { - ["kubernetes.io/ingress.class"] = "\"nginx\"", - ["cert-manager.io/cluster-issuer"] = "\"letsencrypt-prod\"", - ["nginx.ingress.kubernetes.io/rewrite-target"] = "\"/\"" + ["kubernetes.io/ingress.class"] = "nginx", + ["cert-manager.io/cluster-issuer"] = "letsencrypt-prod", + ["nginx.ingress.kubernetes.io/rewrite-target"] = "/" }, ["tls"] = new object[] { @@ -153,4 +153,135 @@ public void Template_YamlFile_RendersExpectedOutput() normalizedResult.ShouldBe(normalizedExpected); } + + [Fact] + public void Template_YamlFilesWithSubTemplates_RendersExpectedOutput() + { + string baseDir = AppContext.BaseDirectory; + string depPath = Path.Combine(baseDir, "TestData", "k8s-template-deployment.yml"); + string svcPath = Path.Combine(baseDir, "TestData", "k8s-template-service.yml"); + string ingPath = Path.Combine(baseDir, "TestData", "k8s-template-ingress.yml"); + string rootPath = Path.Combine(baseDir, "TestData", "k8s-template-data.yml"); + string expectedPath = Path.Combine(baseDir, "TestData", "k8s-template-expected.yml"); + + var tmpl = Template.New("k8s").ParseFiles(depPath, svcPath, ingPath, rootPath); + string expected = File.ReadAllText(expectedPath); + + var values = new Dictionary + { + ["Values"] = new Dictionary + { + ["appName"] = "my-web-app", + ["namespace"] = "production", + ["version"] = "\"1.2.3\"", + ["environment"] = "prod", + ["customLabels"] = new Dictionary + { + ["team"] = "backend", + ["cost-center"] = "engineering", + ["project"] = "web-platform" + }, + ["replicaCount"] = 5, + ["image"] = new Dictionary + { + ["repository"] = "myregistry.com/my-web-app", + ["tag"] = "v1.2.3" + }, + ["service"] = new Dictionary + { + ["enabled"] = true, + ["type"] = "LoadBalancer", + ["port"] = 80, + ["targetPort"] = 8080, + ["nodePort"] = 30080 + }, + ["env"] = new object[] + { + new Dictionary{{"name","DATABASE_URL"},{"value","postgresql://db.example.com:5432/myapp"}}, + new Dictionary{{"name","REDIS_HOST"},{"value","redis.example.com"}}, + new Dictionary{{"name","LOG_LEVEL"},{"value","info"}}, + new Dictionary{{"name","API_KEY"},{"value","secret-api-key-123"}} + }, + ["resources"] = new Dictionary + { + ["limits"] = new Dictionary + { + ["cpu"] = "\"1000m\"", + ["memory"] = "\"1Gi\"" + }, + ["requests"] = new Dictionary + { + ["cpu"] = "\"500m\"", + ["memory"] = "\"512Mi\"" + } + }, + ["volumeMounts"] = new object[] + { + new Dictionary{{"name","config-volume"},{"mountPath","/etc/config"},{"readOnly","true"}}, + new Dictionary{{"name","secret-volume"},{"mountPath","/etc/secrets"},{"readOnly","true"}}, + new Dictionary{{"name","data-volume"},{"mountPath","/var/data"}} + }, + ["volumes"] = new object[] + { + new Dictionary{{"name","config-volume"},{"configMap",new Dictionary{{"name","my-web-app-config"}}}}, + new Dictionary{{"name","secret-volume"},{"secret",new Dictionary{{"secretName","my-web-app-secrets"}}}}, + new Dictionary{{"name","data-volume"},{"persistentVolumeClaim",new Dictionary{{"claimName","my-web-app-data"}}}} + }, + ["nodeSelector"] = new Dictionary + { + ["kubernetes.io/os"] = "linux", + ["node-type"] = "web-tier" + }, + ["tolerations"] = new object[] + { + new Dictionary{{"key","\"node-type\""},{"operator","\"Equal\""},{"value","\"web-tier\""},{"effect","\"NoSchedule\""}}, + new Dictionary{{"key","\"dedicated\""},{"operator","\"Equal\""},{"value","\"web-app\""},{"effect","\"NoExecute\""}} + }, + ["ingress"] = new Dictionary + { + ["enabled"] = true, + ["annotations"] = new Dictionary + { + ["kubernetes.io/ingress.class"] = "nginx", + ["cert-manager.io/cluster-issuer"] = "letsencrypt-prod", + ["nginx.ingress.kubernetes.io/rewrite-target"] = "/" + }, + ["tls"] = new object[] + { + new Dictionary + { + ["hosts"] = new object[]{"myapp.example.com","api.myapp.example.com"}, + ["secretName"] = "myapp-tls-cert" + } + }, + ["hosts"] = new object[] + { + new Dictionary + { + ["host"] = "myapp.example.com", + ["paths"] = new object[] + { + new Dictionary{{"path","/"},{"pathType","Prefix"}}, + new Dictionary{{"path","/api"},{"pathType","Prefix"}} + } + }, + new Dictionary + { + ["host"] = "api.myapp.example.com", + ["paths"] = new object[] + { + new Dictionary{{"path","/"},{"pathType","Prefix"}} + } + } + } + } + } + }; + + string result = tmpl.Execute(values); + string normalizedResult = NormalizeYaml(result); + string normalizedExpected = NormalizeYaml(expected); + + normalizedResult.ShouldBe(normalizedExpected); + } }