Jelajahi Sumber

feat: add lib for template merging

Thomas Zhang 3 bulan lalu
induk
melakukan
9b110b24fe
2 mengubah file dengan 427 tambahan dan 0 penghapusan
  1. 237 0
      pkg/templates/templates.go
  2. 190 0
      pkg/templates/templates_test.go

+ 237 - 0
pkg/templates/templates.go

@@ -0,0 +1,237 @@
+/*
+Copyright 2026 LocoStack.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package templates
+
+import (
+	"fmt"
+	"maps"
+	"slices"
+
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
+)
+
+type TemplateManager struct {
+	templates map[string]v1alpha1.Template
+	runtimes  map[string]v1alpha1.RuntimeSpec
+}
+
+func NewTemplateManager() *TemplateManager {
+	return &TemplateManager{
+		templates: make(map[string]v1alpha1.Template),
+		runtimes:  make(map[string]v1alpha1.RuntimeSpec),
+	}
+}
+
+func (m *TemplateManager) GetTemplate(name string) (v1alpha1.Template, bool) {
+	tmpl, ok := m.templates[name]
+	return tmpl, ok
+}
+
+func (m *TemplateManager) GetRuntime(name string) (v1alpha1.RuntimeSpec, bool) {
+	rt, ok := m.runtimes[name]
+	return rt, ok
+}
+
+func (m *TemplateManager) RegisterTemplate(tmpl v1alpha1.Template) {
+	m.templates[tmpl.Name] = tmpl
+}
+
+func (m *TemplateManager) RegisterRuntime(rt v1alpha1.RuntimeSpec) {
+	m.runtimes[rt.Name] = rt
+}
+
+func (m *TemplateManager) ResolveTemplate(tmpl *v1alpha1.Template, defaultKey string) (v1alpha1.Template, error) {
+	var templateName string
+	if tmpl == nil {
+		templateName = defaultKey
+	} else {
+		templateName = tmpl.Name
+		if templateName == "" {
+			templateName = defaultKey
+		}
+	}
+	base, ok := m.GetTemplate(templateName)
+	if !ok {
+		return v1alpha1.Template{}, fmt.Errorf("template %q not found", templateName)
+	}
+	// resolve runtime if specified in the template or inherited from the base
+	if tmpl != nil && tmpl.Spec.Runtime.Name != "" && tmpl.Spec.Runtime.Name != base.Spec.Runtime.Name {
+		rtBase, ok := m.GetRuntime(tmpl.Spec.Runtime.Name)
+		if !ok {
+			return v1alpha1.Template{}, fmt.Errorf("runtime %q not found", tmpl.Spec.Runtime.Name)
+		}
+		mergeRuntimeSpec(&rtBase, &tmpl.Spec.Runtime)
+		tmpl.Spec.Runtime = rtBase
+	}
+	return mergeTemplates(base, tmpl), nil
+}
+
+func mergeTemplates(base v1alpha1.Template, override *v1alpha1.Template) v1alpha1.Template {
+	merged := base.DeepCopy()
+	if override == nil {
+		return *merged
+	}
+	if override.Name != "" && base.Name != override.Name {
+		return *override.DeepCopy()
+	}
+	mergeStringMap(&merged.Metadata.Labels, override.Metadata.Labels)
+	mergeStringMap(&merged.Metadata.Annotations, override.Metadata.Annotations)
+	mergeTemplateSpec(&merged.Spec, &override.Spec)
+	return *merged
+}
+
+func mergeTemplateSpec(dst, src *v1alpha1.TemplateSpec) {
+	mergeRuntimeSpec(&dst.Runtime, &src.Runtime)
+
+	if src.Volumes != nil {
+		dst.Volumes = deepCopyVolumes(src.Volumes)
+	}
+
+	mergeResourceRequirements(&dst.Resources, &src.Resources)
+	mergeStringMap(&dst.NodeSelector, src.NodeSelector)
+
+	if src.Affinity != nil {
+		dst.Affinity = src.Affinity.DeepCopy()
+	}
+
+	if src.Tolerations != nil {
+		dst.Tolerations = deepCopyTolerations(src.Tolerations)
+	}
+
+	if src.TopologySpreadConstraints != nil {
+		dst.TopologySpreadConstraints = deepCopyTopologySpreadConstraints(src.TopologySpreadConstraints)
+	}
+
+	if src.SecurityContext != nil {
+		dst.SecurityContext = src.SecurityContext.DeepCopy()
+	}
+}
+
+func mergeRuntimeSpec(dst, src *v1alpha1.RuntimeSpec) {
+	if src.Name != "" && dst.Name != "" && src.Name != dst.Name {
+		*dst = *src.DeepCopy()
+		return
+	}
+
+	if src.Name != "" {
+		dst.Name = src.Name
+	}
+	if src.Image != "" {
+		dst.Image = src.Image
+	}
+	if src.Command != nil {
+		dst.Command = slices.Clone(src.Command)
+	}
+	if src.Args != nil {
+		dst.Args = slices.Clone(src.Args)
+	}
+	if src.ExtraArgs != nil {
+		dst.ExtraArgs = slices.Clone(src.ExtraArgs)
+	}
+	if src.Env != nil {
+		mergeEnvVars(&dst.Env, src.Env)
+	}
+	if src.VolumeMounts != nil {
+		dst.VolumeMounts = deepCopyVolumeMounts(src.VolumeMounts)
+	}
+	if src.Port != 0 {
+		dst.Port = src.Port
+	}
+}
+
+func mergeResourceRequirements(dst, src *corev1.ResourceRequirements) {
+	if src.Limits != nil {
+		if dst.Limits == nil {
+			dst.Limits = make(corev1.ResourceList)
+		}
+		maps.Copy(dst.Limits, src.Limits)
+	}
+	if src.Requests != nil {
+		if dst.Requests == nil {
+			dst.Requests = make(corev1.ResourceList)
+		}
+		maps.Copy(dst.Requests, src.Requests)
+	}
+	if src.Claims != nil {
+		dst.Claims = slices.Clone(src.Claims)
+	}
+}
+
+func mergeStringMap(dst *map[string]string, src map[string]string) {
+	if src == nil {
+		return
+	}
+	if *dst == nil {
+		*dst = make(map[string]string)
+	}
+	maps.Copy(*dst, src)
+}
+
+func mergeEnvVars(dst *[]corev1.EnvVar, src []corev1.EnvVar) {
+	if src == nil {
+		return
+	}
+	if *dst == nil {
+		*dst = make([]corev1.EnvVar, 0, len(src))
+	}
+	existing := make(map[string]int)
+	for i, env := range *dst {
+		existing[env.Name] = i
+	}
+	for _, env := range src {
+		if idx, ok := existing[env.Name]; ok {
+			env.DeepCopyInto(&(*dst)[idx])
+		} else {
+			*dst = append(*dst, *env.DeepCopy())
+		}
+	}
+}
+
+func deepCopyVolumeMounts(in []corev1.VolumeMount) []corev1.VolumeMount {
+	out := make([]corev1.VolumeMount, len(in))
+	for i := range in {
+		in[i].DeepCopyInto(&out[i])
+	}
+	return out
+}
+
+func deepCopyVolumes(in []corev1.Volume) []corev1.Volume {
+	out := make([]corev1.Volume, len(in))
+	for i := range in {
+		in[i].DeepCopyInto(&out[i])
+	}
+	return out
+}
+
+func deepCopyTolerations(in []corev1.Toleration) []corev1.Toleration {
+	out := make([]corev1.Toleration, len(in))
+	for i := range in {
+		in[i].DeepCopyInto(&out[i])
+	}
+	return out
+}
+
+func deepCopyTopologySpreadConstraints(in []corev1.TopologySpreadConstraint) []corev1.TopologySpreadConstraint {
+	out := make([]corev1.TopologySpreadConstraint, len(in))
+	for i := range in {
+		in[i].DeepCopyInto(&out[i])
+	}
+	return out
+}
+
+var Manager = NewTemplateManager()

+ 190 - 0
pkg/templates/templates_test.go

@@ -0,0 +1,190 @@
+/*
+Copyright 2026 LocoStack.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package templates
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
+)
+
+func TestResolveTemplateMergesBaseAndOverride(t *testing.T) {
+	m := NewTemplateManager()
+	m.RegisterTemplate(v1alpha1.Template{
+		Name: "stack-default",
+		Spec: v1alpha1.TemplateSpec{
+			NodeSelector: map[string]string{"zone": "a", "disk": "hdd"},
+			Runtime: v1alpha1.RuntimeSpec{
+				Name:  "go-runtime",
+				Image: "golang:1.24",
+				Port:  8080,
+			},
+		},
+	})
+
+	override := &v1alpha1.Template{
+		Name: "stack-default",
+		Spec: v1alpha1.TemplateSpec{
+			NodeSelector: map[string]string{"disk": "ssd"},
+			Runtime: v1alpha1.RuntimeSpec{
+				Image: "my-custom-image:1",
+			},
+		},
+	}
+
+	resolved, err := m.ResolveTemplate(override, "stack-default")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+
+	if got := resolved.Spec.NodeSelector["zone"]; got != "a" {
+		t.Fatalf("expected inherited nodeSelector zone, got %q", got)
+	}
+	if got := resolved.Spec.NodeSelector["disk"]; got != "ssd" {
+		t.Fatalf("expected override nodeSelector disk, got %q", got)
+	}
+	if resolved.Spec.Runtime.Name != "go-runtime" {
+		t.Fatalf("expected runtime name from base, got %q", resolved.Spec.Runtime.Name)
+	}
+	if resolved.Spec.Runtime.Image != "my-custom-image:1" {
+		t.Fatalf("expected runtime image override, got %q", resolved.Spec.Runtime.Image)
+	}
+	if resolved.Spec.Runtime.Port != 8080 {
+		t.Fatalf("expected inherited runtime port, got %d", resolved.Spec.Runtime.Port)
+	}
+}
+
+func TestResolveTemplateErrorsWhenTemplateMissing(t *testing.T) {
+	m := NewTemplateManager()
+
+	_, err := m.ResolveTemplate(&v1alpha1.Template{Name: "missing-template"}, "stack-default")
+	if err == nil {
+		t.Fatalf("expected error for missing template")
+	}
+	if !strings.Contains(err.Error(), "template \"missing-template\" not found") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
+func TestResolveTemplateErrorsWhenRuntimeMissing(t *testing.T) {
+	m := NewTemplateManager()
+	m.RegisterTemplate(v1alpha1.Template{
+		Name: "stack-default",
+		Spec: v1alpha1.TemplateSpec{
+			Runtime: v1alpha1.RuntimeSpec{Name: "go-runtime"},
+		},
+	})
+
+	_, err := m.ResolveTemplate(&v1alpha1.Template{
+		Name: "stack-default",
+		Spec: v1alpha1.TemplateSpec{
+			Runtime: v1alpha1.RuntimeSpec{Name: "python-runtime"},
+		},
+	}, "stack-default")
+	if err == nil {
+		t.Fatalf("expected error for missing runtime")
+	}
+	if !strings.Contains(err.Error(), "runtime \"python-runtime\" not found") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
+func TestResolveTemplateResolvesRuntimePresetAndMergesOverride(t *testing.T) {
+	m := NewTemplateManager()
+	m.RegisterTemplate(v1alpha1.Template{
+		Name: "stack-default",
+		Spec: v1alpha1.TemplateSpec{
+			Runtime: v1alpha1.RuntimeSpec{Name: "go-runtime", Image: "golang:1.24"},
+		},
+	})
+	m.RegisterRuntime(v1alpha1.RuntimeSpec{
+		Name:      "python-runtime",
+		Image:     "python:3.12",
+		Command:   []string{"python"},
+		ExtraArgs: []string{"-m", "http.server"},
+		Env:       []corev1.EnvVar{{Name: "BASE", Value: "1"}},
+		Port:      8000,
+	})
+
+	override := &v1alpha1.Template{
+		Name: "stack-default",
+		Spec: v1alpha1.TemplateSpec{
+			Runtime: v1alpha1.RuntimeSpec{
+				Name:  "python-runtime",
+				Image: "python:3.12-slim",
+				Args:  []string{"-u"},
+				Env:   []corev1.EnvVar{{Name: "OVERRIDE", Value: "2"}},
+			},
+		},
+	}
+
+	resolved, err := m.ResolveTemplate(override, "stack-default")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+
+	if resolved.Spec.Runtime.Name != "python-runtime" {
+		t.Fatalf("expected resolved runtime name, got %q", resolved.Spec.Runtime.Name)
+	}
+	if resolved.Spec.Runtime.Image != "python:3.12-slim" {
+		t.Fatalf("expected override image on resolved runtime, got %q", resolved.Spec.Runtime.Image)
+	}
+	if len(resolved.Spec.Runtime.Command) != 1 || resolved.Spec.Runtime.Command[0] != "python" {
+		t.Fatalf("expected inherited command from runtime preset")
+	}
+	if len(resolved.Spec.Runtime.ExtraArgs) != 2 || resolved.Spec.Runtime.ExtraArgs[0] != "-m" {
+		t.Fatalf("expected inherited extraArgs from runtime preset")
+	}
+	if len(resolved.Spec.Runtime.Args) != 1 || resolved.Spec.Runtime.Args[0] != "-u" {
+		t.Fatalf("expected override args on resolved runtime")
+	}
+	if len(resolved.Spec.Runtime.Env) != 1 || resolved.Spec.Runtime.Env[0].Name != "OVERRIDE" {
+		t.Fatalf("expected override env on resolved runtime")
+	}
+	if resolved.Spec.Runtime.Port != 8000 {
+		t.Fatalf("expected inherited port from runtime preset, got %d", resolved.Spec.Runtime.Port)
+	}
+}
+
+func TestResolveTemplateWithoutNameUsesDefaultTemplate(t *testing.T) {
+	m := NewTemplateManager()
+	m.RegisterTemplate(v1alpha1.Template{
+		Name: "stack-default",
+		Spec: v1alpha1.TemplateSpec{
+			NodeSelector: map[string]string{"zone": "a", "disk": "hdd"},
+		},
+	})
+
+	in := &v1alpha1.Template{
+		Spec: v1alpha1.TemplateSpec{
+			NodeSelector: map[string]string{"disk": "ssd"},
+		},
+	}
+
+	resolved, err := m.ResolveTemplate(in, "stack-default")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if got := resolved.Spec.NodeSelector["zone"]; got != "a" {
+		t.Fatalf("expected nodeSelector zone from default template, got %q", got)
+	}
+	if got := resolved.Spec.NodeSelector["disk"]; got != "ssd" {
+		t.Fatalf("expected nodeSelector disk override from input template, got %q", got)
+	}
+}