feat(refacto): move everything in internal package

This allows to install katenary with `go install` and to clean up the
project folder.
This commit is contained in:
2025-08-03 15:54:58 +02:00
parent d1768e5742
commit 14ca5bf0ea
91 changed files with 291 additions and 282 deletions

427
internal/generator/chart.go Normal file
View File

@@ -0,0 +1,427 @@
package generator
import (
"fmt"
"log"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/generator/labels/labelstructs"
"github.com/katenary/katenary/internal/utils"
"github.com/compose-spec/compose-go/types"
corev1 "k8s.io/api/core/v1"
)
// ChartTemplate is a template of a chart. It contains the content of the template and the name of the service.
// This is used internally to generate the templates.
type ChartTemplate struct {
Servicename string
Content []byte
}
// ConvertOptions are the options to convert a compose project to a helm chart.
type ConvertOptions struct {
AppVersion *string
OutputDir string
ChartVersion string
Icon string
Profiles []string
EnvFiles []string
Force bool
HelmUpdate bool
}
// HelmChart is a Helm Chart representation. It contains all the
// templates, values, versions, helpers...
type HelmChart struct {
Templates map[string]*ChartTemplate `yaml:"-"`
Values map[string]any `yaml:"-"`
VolumeMounts map[string]any `yaml:"-"`
composeHash *string `yaml:"-"`
Name string `yaml:"name"`
Icon string `yaml:"icon,omitempty"`
APIVersion string `yaml:"apiVersion"`
Version string `yaml:"version"`
AppVersion string `yaml:"appVersion"`
Description string `yaml:"description"`
Helper string `yaml:"-"`
Dependencies []labelstructs.Dependency `yaml:"dependencies,omitempty"`
}
// NewChart creates a new empty chart with the given name.
func NewChart(name string) *HelmChart {
return &HelmChart{
Name: name,
Templates: make(map[string]*ChartTemplate, 0),
Description: "A Helm chart for " + name,
APIVersion: "v2",
Version: "",
AppVersion: "", // set to 0.1.0 by default if no "main-app" label is found
Values: map[string]any{
"pullSecrets": []string{},
},
}
}
// SaveTemplates the templates of the chart to the given directory.
func (chart *HelmChart) SaveTemplates(templateDir string) {
for name, template := range chart.Templates {
t := template.Content
t = removeNewlinesInsideBrackets(t)
t = removeUnwantedLines(t)
// t = addModeline(t)
kind := utils.GetKind(name)
var icon utils.Icon
switch kind {
case "deployment":
icon = utils.IconPackage
case "service":
icon = utils.IconPlug
case "ingress":
icon = utils.IconWorld
case "volumeclaim":
icon = utils.IconCabinet
case "configmap":
icon = utils.IconConfig
case "secret":
icon = utils.IconSecret
default:
icon = utils.IconInfo
}
servicename := template.Servicename
if err := os.MkdirAll(filepath.Join(templateDir, servicename), utils.DirectoryPermission); err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
fmt.Println(icon, "Creating", kind, servicename)
// if the name is a path, create the directory
if strings.Contains(name, string(filepath.Separator)) {
name = filepath.Join(templateDir, name)
err := os.MkdirAll(filepath.Dir(name), utils.DirectoryPermission)
if err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
} else {
// remove the serivce name from the template name
name = strings.Replace(name, servicename+".", "", 1)
name = filepath.Join(templateDir, servicename, name)
}
f, err := os.Create(name)
if err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
defer f.Close()
if _, err := f.Write(t); err != nil {
log.Fatal("error writing template file:", err)
}
}
}
// generateConfigMapsAndSecrets creates the configmaps and secrets from the environment variables.
func (chart *HelmChart) generateConfigMapsAndSecrets(project *types.Project) error {
appName := chart.Name
for _, s := range project.Services {
if len(s.Environment) == 0 {
continue
}
originalEnv := types.MappingWithEquals{}
secretsVar := types.MappingWithEquals{}
// copy env to originalEnv
maps.Copy(originalEnv, s.Environment)
if v, ok := s.Labels[labels.LabelSecrets]; ok {
list, err := labelstructs.SecretsFrom(v)
if err != nil {
log.Fatal("error unmarshaling secrets label:", err)
}
for _, secret := range list {
if secret == "" {
continue
}
if _, ok := s.Environment[secret]; !ok {
fmt.Printf("%s secret %s not found in environment", utils.IconWarning, secret)
continue
}
secretsVar[secret] = s.Environment[secret]
}
}
if len(secretsVar) > 0 {
s.Environment = secretsVar
sec := NewSecret(s, appName)
y, _ := sec.Yaml()
name := sec.service.Name
chart.Templates[name+".secret.yaml"] = &ChartTemplate{
Content: y,
Servicename: s.Name,
}
}
// remove secrets from env
s.Environment = originalEnv // back to original
for k := range secretsVar {
delete(s.Environment, k)
}
if len(s.Environment) > 0 {
cm := NewConfigMap(s, appName, false)
y, _ := cm.Yaml()
name := cm.service.Name
chart.Templates[name+".configmap.yaml"] = &ChartTemplate{
Content: y,
Servicename: s.Name,
}
}
}
return nil
}
func (chart *HelmChart) generateDeployment(service types.ServiceConfig, deployments map[string]*Deployment, services map[string]*Service, podToMerge map[string]*types.ServiceConfig, appName string) error {
// check the "ports" label from container and add it to the service
if err := fixPorts(&service); err != nil {
return err
}
// isgnored service
if isIgnored(service) {
fmt.Printf("%s Ignoring service %s\n", utils.IconInfo, service.Name)
return nil
}
// helm dependency
if isHelmDependency, err := chart.setDependencies(service); err != nil {
return err
} else if isHelmDependency {
return nil
}
// create all deployments
d := NewDeployment(service, chart)
deployments[service.Name] = d
// generate the cronjob if needed
chart.setCronJob(service, appName)
if exchange, ok := service.Labels[labels.LabelExchangeVolume]; ok {
// we need to add a volume and a mount point
ex, err := labelstructs.NewExchangeVolumes(exchange)
if err != nil {
return err
}
for _, exchangeVolume := range ex {
d.AddLegacyVolume("exchange-"+exchangeVolume.Name, exchangeVolume.Type)
d.exchangesVolumes[service.Name] = exchangeVolume
}
}
// get the same-pod label if exists, add it to the list.
// We later will copy some parts to the target deployment and remove this one.
if samePod, ok := service.Labels[labels.LabelSamePod]; ok && samePod != "" {
podToMerge[samePod] = &service
}
// create the needed service for the container port
if len(service.Ports) > 0 {
s := NewService(service, appName)
services[service.Name] = s
}
// create all ingresses
if ingress := d.AddIngress(service, appName); ingress != nil {
y, _ := ingress.Yaml()
chart.Templates[ingress.Filename()] = &ChartTemplate{
Content: y,
Servicename: service.Name,
}
}
return nil
}
// setChartVersion sets the chart version from the service image tag.
func (chart *HelmChart) setChartVersion(service types.ServiceConfig) {
if chart.Version == "" {
image := service.Image
parts := strings.Split(image, ":")
if len(parts) > 1 {
chart.AppVersion = parts[1]
} else {
chart.AppVersion = "0.1.0"
}
}
}
// setCronJob creates a cronjob from the service labels.
func (chart *HelmChart) setCronJob(service types.ServiceConfig, appName string) *CronJob {
if _, ok := service.Labels[labels.LabelCronJob]; !ok {
return nil
}
cronjob, rbac := NewCronJob(service, chart, appName)
y, _ := cronjob.Yaml()
chart.Templates[cronjob.Filename()] = &ChartTemplate{
Content: y,
Servicename: service.Name,
}
if rbac != nil {
y, _ := rbac.RoleBinding.Yaml()
chart.Templates[rbac.RoleBinding.Filename()] = &ChartTemplate{
Content: y,
Servicename: service.Name,
}
y, _ = rbac.Role.Yaml()
chart.Templates[rbac.Role.Filename()] = &ChartTemplate{
Content: y,
Servicename: service.Name,
}
y, _ = rbac.ServiceAccount.Yaml()
chart.Templates[rbac.ServiceAccount.Filename()] = &ChartTemplate{
Content: y,
Servicename: service.Name,
}
}
return cronjob
}
// setDependencies sets the dependencies from the service labels.
func (chart *HelmChart) setDependencies(service types.ServiceConfig) (bool, error) {
// helm dependency
if v, ok := service.Labels[labels.LabelDependencies]; ok {
d, err := labelstructs.DependenciesFrom(v)
if err != nil {
return false, err
}
for _, dep := range d {
fmt.Printf("%s Adding dependency to %s\n", utils.IconDependency, dep.Name)
chart.Dependencies = append(chart.Dependencies, dep)
name := dep.Name
if dep.Alias != "" {
name = dep.Alias
}
// add the dependency env vars to the values.yaml
chart.Values[name] = dep.Values
}
return true, nil
}
return false, nil
}
// setSharedConf sets the shared configmap to the service.
func (chart *HelmChart) setSharedConf(service types.ServiceConfig, deployments map[string]*Deployment) {
// if the service has the "shared-conf" label, we need to add the configmap
// to the chart and add the env vars to the service
if _, ok := service.Labels[labels.LabelEnvFrom]; !ok {
return
}
fromservices, err := labelstructs.EnvFromFrom(service.Labels[labels.LabelEnvFrom])
if err != nil {
log.Fatal("error unmarshaling env-from label:", err)
}
// find the configmap in the chart templates
for _, fromservice := range fromservices {
if _, ok := chart.Templates[fromservice+".configmap.yaml"]; !ok {
log.Printf("configmap %s not found in chart templates", fromservice)
continue
}
// find the corresponding target deployment
target := findDeployment(service.Name, deployments)
if target == nil {
continue
}
// add the configmap to the service
addConfigMapToService(service.Name, fromservice, chart.Name, target)
}
}
// setEnvironmentValuesFrom sets the environment values from another service.
func (chart *HelmChart) setEnvironmentValuesFrom(service types.ServiceConfig, deployments map[string]*Deployment) {
if _, ok := service.Labels[labels.LabelValuesFrom]; !ok {
return
}
mapping, err := labelstructs.GetValueFrom(service.Labels[labels.LabelValuesFrom])
if err != nil {
log.Fatal("error unmarshaling values-from label:", err)
}
findDeployment := func(name string) *Deployment {
for _, dep := range deployments {
if dep.service.Name == name {
return dep
}
}
return nil
}
// each mapping key is the environment, and the value is serivename.variable name
for env, from := range *mapping {
// find the deployment that has the variable
depName := strings.Split(from, ".")
dep := findDeployment(depName[0])
target := findDeployment(service.Name)
if dep == nil || target == nil {
log.Fatalf("deployment %s or %s not found", depName[0], service.Name)
}
container, index := utils.GetContainerByName(target.service.ContainerName, target.Spec.Template.Spec.Containers)
if container == nil {
log.Fatalf("Container %s not found", target.GetName())
}
reourceName := fmt.Sprintf(`{{ include "%s.fullname" . }}-%s`, chart.Name, depName[0])
// add environment with from
// is it a secret?
isSecret := false
secrets, err := labelstructs.SecretsFrom(dep.service.Labels[labels.LabelSecrets])
if err == nil {
if slices.Contains(secrets, depName[1]) {
isSecret = true
}
}
if !isSecret {
container.Env = append(container.Env, corev1.EnvVar{
Name: env,
ValueFrom: &corev1.EnvVarSource{
ConfigMapKeyRef: &corev1.ConfigMapKeySelector{
LocalObjectReference: corev1.LocalObjectReference{
Name: reourceName,
},
Key: depName[1],
},
},
})
} else {
container.Env = append(container.Env, corev1.EnvVar{
Name: env,
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{
Name: reourceName,
},
Key: depName[1],
},
},
})
}
// the environment is bound, so we shouldn't add it to the values.yaml or in any other place
delete(service.Environment, env)
// also, remove the values
target.boundEnvVar = append(target.boundEnvVar, env)
// and save the container
target.Spec.Template.Spec.Containers[index] = *container
}
}

View File

@@ -0,0 +1,158 @@
package generator
import (
"fmt"
"os"
"strings"
"testing"
"github.com/katenary/katenary/internal/generator/labels"
appsv1 "k8s.io/api/apps/v1"
v1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)
func TestValuesFrom(t *testing.T) {
composeFile := `
services:
aa:
image: nginx:latest
environment:
AA_USER: foo
bb:
image: nginx:latest
labels:
%[1]s/values-from: |-
BB_USER: aa.USER
`
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/aa/configmap.yaml")
configMap := v1.ConfigMap{}
if err := yaml.Unmarshal([]byte(output), &configMap); err != nil {
t.Errorf(unmarshalError, err)
}
data := configMap.Data
if v, ok := data["AA_USER"]; !ok || v != "foo" {
t.Errorf("Expected AA_USER to be foo, got %s", v)
}
}
func TestValuesFromCopy(t *testing.T) {
composeFile := `
services:
aa:
image: nginx:latest
environment:
AA_USER: foo
bb:
image: nginx:latest
labels:
%[1]s/values-from: |-
BB_USER: aa.AA_USER
`
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/bb/deployment.yaml")
dep := appsv1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dep); err != nil {
t.Errorf(unmarshalError, err)
}
containers := dep.Spec.Template.Spec.Containers
environment := containers[0].Env[0]
envFrom := environment.ValueFrom.ConfigMapKeyRef
if envFrom.Key != "AA_USER" {
t.Errorf("Expected AA_USER, got %s", envFrom.Key)
}
if !strings.Contains(envFrom.Name, "aa") {
t.Errorf("Expected aa, got %s", envFrom.Name)
}
}
func TestValuesFromSecret(t *testing.T) {
composeFile := `
services:
aa:
image: nginx:latest
environment:
AA_USER: foo
labels:
%[1]s/secrets: |-
- AA_USER
bb:
image: nginx:latest
labels:
%[1]s/values-from: |-
BB_USER: aa.AA_USER
`
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/bb/deployment.yaml")
dep := appsv1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dep); err != nil {
t.Errorf(unmarshalError, err)
}
containers := dep.Spec.Template.Spec.Containers
environment := containers[0].Env[0]
envFrom := environment.ValueFrom.SecretKeyRef
if envFrom.Key != "AA_USER" {
t.Errorf("Expected AA_USER, got %s", envFrom.Key)
}
if !strings.Contains(envFrom.Name, "aa") {
t.Errorf("Expected aa, got %s", envFrom.Name)
}
}
func TestEnvFrom(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
environment:
Foo: bar
BAZ: qux
db:
image: postgres
labels:
%[1]s/env-from: |-
- web
`
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/db/deployment.yaml")
dep := appsv1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dep); err != nil {
t.Errorf(unmarshalError, err)
}
envFrom := dep.Spec.Template.Spec.Containers[0].EnvFrom
if len(envFrom) != 1 {
t.Fatalf("Expected 1 envFrom, got %d", len(envFrom))
}
}

View File

@@ -0,0 +1,254 @@
package generator
import (
"fmt"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/generator/labels/labelstructs"
"github.com/katenary/katenary/internal/utils"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"unicode/utf8"
"github.com/compose-spec/compose-go/types"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// FileMapUsage is the usage of the filemap.
type FileMapUsage uint8
// FileMapUsage constants.
const (
FileMapUsageConfigMap FileMapUsage = iota // pure configmap for key:values.
FileMapUsageFiles // files in a configmap.
)
// only used to check interface implementation
var (
_ DataMap = (*ConfigMap)(nil)
_ Yaml = (*ConfigMap)(nil)
)
// ConfigMap is a kubernetes ConfigMap.
// Implements the DataMap interface.
type ConfigMap struct {
*corev1.ConfigMap
service *types.ServiceConfig
path string
usage FileMapUsage
}
// NewConfigMap creates a new ConfigMap from a compose service. The appName is the name of the application taken from the project name.
// The ConfigMap is filled by environment variables and labels "map-env".
func NewConfigMap(service types.ServiceConfig, appName string, forFile bool) *ConfigMap {
done := map[string]bool{}
drop := map[string]bool{}
labelValues := []string{}
cm := &ConfigMap{
service: &service,
ConfigMap: &corev1.ConfigMap{
TypeMeta: metav1.TypeMeta{
Kind: "ConfigMap",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName),
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Data: make(map[string]string),
},
}
// get the secrets from the labels
secrets, err := labelstructs.SecretsFrom(service.Labels[labels.LabelSecrets])
if err != nil {
log.Fatal(err)
}
// drop the secrets from the environment
for _, secret := range secrets {
drop[secret] = true
}
// get the label values from the labels
varDescriptons := utils.GetValuesFromLabel(service, labels.LabelValues)
for value := range varDescriptons {
labelValues = append(labelValues, value)
}
// change the environment variables to the values defined in the values.yaml
for _, value := range labelValues {
if _, ok := service.Environment[value]; !ok {
done[value] = true
continue
}
}
if !forFile {
// do not bind env variables to the configmap
// remove the variables that are already defined in the environment
if l, ok := service.Labels[labels.LabelMapEnv]; ok {
envmap, err := labelstructs.MapEnvFrom(l)
if err != nil {
log.Fatal("Error parsing map-env", err)
}
for key, value := range envmap {
cm.AddData(key, strings.ReplaceAll(value, "__APP__", appName))
done[key] = true
}
}
for key, env := range service.Environment {
_, isDropped := drop[key]
_, isDone := done[key]
if isDropped || isDone {
continue
}
cm.AddData(key, *env)
}
}
return cm
}
// NewConfigMapFromDirectory creates a new ConfigMap from a compose service. This path is the path to the
// file or directory. If the path is a directory, all files in the directory are added to the ConfigMap.
// Each subdirectory are ignored. Note that the Generate() function will create the subdirectories ConfigMaps.
func NewConfigMapFromDirectory(service types.ServiceConfig, appName, path string) *ConfigMap {
normalized := path
normalized = strings.TrimLeft(normalized, ".")
normalized = strings.TrimLeft(normalized, "/")
normalized = regexp.MustCompile(`[^a-zA-Z0-9-]+`).ReplaceAllString(normalized, "-")
cm := &ConfigMap{
path: path,
service: &service,
usage: FileMapUsageFiles,
ConfigMap: &corev1.ConfigMap{
TypeMeta: metav1.TypeMeta{
Kind: "ConfigMap",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName) + "-" + normalized,
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Data: make(map[string]string),
},
}
// cumulate the path to the WorkingDir
path = filepath.Join(service.WorkingDir, path)
path = filepath.Clean(path)
if err := cm.AppendDir(path); err != nil {
log.Fatal("Error adding files to configmap:", err)
}
return cm
}
// AddData adds a key value pair to the configmap. Append or overwrite the value if the key already exists.
func (c *ConfigMap) AddData(key, value string) {
c.Data[key] = value
}
// AddBinaryData adds binary data to the configmap. Append or overwrite the value if the key already exists.
func (c *ConfigMap) AddBinaryData(key string, value []byte) {
if c.BinaryData == nil {
c.BinaryData = make(map[string][]byte)
}
c.BinaryData[key] = value
}
// AppendDir adds files from given path to the configmap. It is not recursive, to add all files in a directory,
// you need to call this function for each subdirectory.
func (c *ConfigMap) AppendDir(path string) error {
// read all files in the path and add them to the configmap
stat, err := os.Stat(path)
if err != nil {
return fmt.Errorf("path %s does not exist, %w", path, err)
}
// recursively read all files in the path and add them to the configmap
if stat.IsDir() {
files, err := os.ReadDir(path)
if err != nil {
return err
}
for _, file := range files {
if file.IsDir() {
utils.Warn("Subdirectories are ignored for the moment, skipping", filepath.Join(path, file.Name()))
continue
}
path := filepath.Join(path, file.Name())
content, err := os.ReadFile(path)
if err != nil {
return err
}
// remove the path from the file
filename := filepath.Base(path)
if utf8.Valid(content) {
c.AddData(filename, string(content))
} else {
c.AddBinaryData(filename, content)
}
}
} else {
// add the file to the configmap
content, err := os.ReadFile(path)
if err != nil {
return err
}
filename := filepath.Base(path)
if utf8.Valid(content) {
c.AddData(filename, string(content))
} else {
c.AddBinaryData(filename, content)
}
}
return nil
}
func (c *ConfigMap) AppendFile(path string) error {
// read all files in the path and add them to the configmap
stat, err := os.Stat(path)
if err != nil {
return fmt.Errorf("path %s doesn not exists, %w", path, err)
}
// recursively read all files in the path and add them to the configmap
if !stat.IsDir() {
// add the file to the configmap
content, err := os.ReadFile(path)
if err != nil {
return err
}
if utf8.Valid(content) {
c.AddData(filepath.Base(path), string(content))
} else {
c.AddBinaryData(filepath.Base(path), content)
}
}
return nil
}
// Filename returns the filename of the configmap. If the configmap is used for files, the filename contains the path.
func (c *ConfigMap) Filename() string {
switch c.usage {
case FileMapUsageFiles:
return filepath.Join(c.service.Name, "statics", c.path, "configmap.yaml")
default:
return c.service.Name + ".configmap.yaml"
}
}
// SetData sets the data of the configmap. It replaces the entire data.
func (c *ConfigMap) SetData(data map[string]string) {
c.Data = data
}
// Yaml returns the yaml representation of the configmap
func (c *ConfigMap) Yaml() ([]byte, error) {
return ToK8SYaml(c)
}

View File

@@ -0,0 +1,132 @@
package generator
import (
"fmt"
"io"
"github.com/katenary/katenary/internal/generator/labels"
"os"
"regexp"
"testing"
"github.com/compose-spec/compose-go/types"
v1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)
func TestEnvInConfigMap(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
environment:
- FOO=bar
- BAR=baz
`
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", "templates/web/configmap.yaml")
configMap := v1.ConfigMap{}
if err := yaml.Unmarshal([]byte(output), &configMap); err != nil {
t.Errorf(unmarshalError, err)
}
data := configMap.Data
if len(data) != 2 {
t.Errorf("Expected 2 data, got %d", len(data))
}
if data["FOO"] != "bar" {
t.Errorf("Expected FOO to be bar, got %s", data["FOO"])
}
if data["BAR"] != "baz" {
t.Errorf("Expected BAR to be baz, got %s", data["BAR"])
}
}
func TestMapEnv(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
environment:
FOO: bar
labels:
%[1]s/map-env: |-
FOO: 'baz'
`
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/configmap.yaml")
configMap := v1.ConfigMap{}
if err := yaml.Unmarshal([]byte(output), &configMap); err != nil {
t.Errorf(unmarshalError, err)
}
data := configMap.Data
if v, ok := data["FOO"]; !ok || v != "baz" {
t.Errorf("Expected FOO to be baz, got %s", v)
}
}
func TestAppendBadFile(t *testing.T) {
cm := NewConfigMap(types.ServiceConfig{}, "app", true)
err := cm.AppendFile("foo")
if err == nil {
t.Errorf("Expected error, got nil")
}
}
func TestAppendBadDir(t *testing.T) {
cm := NewConfigMap(types.ServiceConfig{}, "app", true)
err := cm.AppendDir("foo")
if err == nil {
t.Errorf("Expected error, got nil")
}
}
func TestRootConfigmapfile(t *testing.T) {
composeFile := `
services:
web:
image: nginx
volumes:
- ./foo.txt:/etc/foo.txt
labels:
%[1]s/configmap-files: |-
- ./foo.txt
`
composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix)
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
fooTxt := "foo content"
fooFp, _ := os.Create("foo.txt")
io.WriteString(fooFp, fooTxt)
fooFp.Close()
output := internalCompileTest(t, "-s", "templates/web/statics/configmap.yaml")
configMap := v1.ConfigMap{}
if err := yaml.Unmarshal([]byte(output), &configMap); err != nil {
t.Errorf(unmarshalError, err)
}
if configMap.Data == nil {
t.Error("Expected configmap data to not be nil")
}
// if the configmap.Name ends by anything that is not alphanumeric, there is a problem
valid := regexp.MustCompile(`.*[a-zA-Z0-9]+$`)
if !valid.MatchString(configMap.Name) {
t.Errorf("ConfigMap name %s is not valid", configMap.Name)
}
}

View File

@@ -0,0 +1,700 @@
package generator
import (
"bytes"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/katenary/katenary/internal/generator/extrafiles"
"github.com/katenary/katenary/internal/generator/katenaryfile"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/generator/labels/labelstructs"
"github.com/katenary/katenary/internal/parser"
"github.com/katenary/katenary/internal/utils"
"github.com/compose-spec/compose-go/types"
)
const ingressClassHelp = `# Default value for ingress.class annotation
# class: "-"
# 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"
`
const storageClassHelp = `# Storage class to use for PVCs
# storageClass: "-" means use default
# storageClass: "" means do not specify
# storageClass: "foo" means use that storageClass
`
const headerHelp = `# This file is autogenerated by katenary
#
# DO NOT EDIT IT BY HAND UNLESS YOU KNOW WHAT YOU ARE DOING
#
# If you want to change the content of this file, you should edit the
# compose file and run katenary again.
# If you need to override some values, you can do it in a override file
# and use the -f flag to specify it when running the helm command.
`
const imagePullSecretHelp = `
# imagePullSecrets allows you to specify a name of an image pull secret.
# You must provide a list of object with the name field set to the name of the
# e.g.
# pullSecrets:
# - name: regcred
# You are, for now, responsible for creating the secret.
`
const imagePullPolicyHelp = `# imagePullPolicy allows you to specify a policy to cache or always pull an image.
# You must provide a string value with one of the following values:
# - Always -> will always pull the image
# - Never -> will never pull the image, the image should be present on the node
# - IfNotPresent -> will pull the image only if it is not present on the node
`
const resourceHelp = `# Resources allows you to specify the resource requests and limits for a service.
# Resources are used to specify the amount of CPU and memory that
# a container needs.
#
# e.g.
# resources:
# requests:
# memory: "64Mi"
# cpu: "250m"
# limits:
# memory: "128Mi"
# cpu: "500m"
`
const mainTagAppDoc = `This is the version of the main application.
Leave it to blank to use the Chart "AppVersion" value.`
var unwantedLines = []string{
"creationTimestamp:",
"status:",
}
var ingressTLSHelp = `# Ingress TLS configuration
# If enabled, a secret containing the certificate and the key should be
# created by the ingress controller. If the name if emtpy, so the secret
# name is generated. You can specify the secret name to use your own secret.
`
// keyRegExp checks if the line starts by a #
var keyRegExp = regexp.MustCompile(`^\s*[^#]+:.*`)
// Convert a compose (docker, podman...) project to a helm chart.
// It calls Generate() to generate the chart and then write it to the disk.
func Convert(config ConvertOptions, dockerComposeFile ...string) error {
var (
templateDir = filepath.Join(config.OutputDir, "templates")
helpersPath = filepath.Join(config.OutputDir, "templates", "_helpers.tpl")
chartPath = filepath.Join(config.OutputDir, "Chart.yaml")
valuesPath = filepath.Join(config.OutputDir, "values.yaml")
readmePath = filepath.Join(config.OutputDir, "README.md")
notesPath = filepath.Join(templateDir, "NOTES.txt")
)
// the current working directory is the directory
currentDir, _ := os.Getwd()
// go to the root of the project
if err := os.Chdir(filepath.Dir(dockerComposeFile[0])); err != nil {
fmt.Println(utils.IconFailure, err)
return err
}
defer func() {
if err := os.Chdir(currentDir); err != nil { // after the generation, go back to the original directory
log.Fatal(err)
}
}()
// repove the directory part of the docker-compose files
for i, f := range dockerComposeFile {
dockerComposeFile[i] = filepath.Base(f)
}
// parse the compose files
project, err := parser.Parse(config.Profiles, config.EnvFiles, dockerComposeFile...)
if err != nil {
fmt.Println(err)
return err
}
// check older version of labels
if err := checkOldLabels(project); err != nil {
fmt.Println(utils.IconFailure, err)
return err
}
// TODO: use katenary.yaml file here to set the labels
katenaryfile.OverrideWithConfig(project)
if !config.Force {
// check if the chart directory exists
// if yes, prevent the user from overwriting it and ask for confirmation
if _, err := os.Stat(config.OutputDir); err == nil {
overwrite := utils.Confirm(
"The chart directory "+config.OutputDir+" already exists, do you want to overwrite it?",
utils.IconWarning,
)
if !overwrite {
fmt.Println("Aborting")
return nil
}
}
fmt.Println() // clean line
}
// Build the objects !
chart, err := Generate(project)
if err != nil {
fmt.Println(err)
return err
}
// if the app version is set from the command line, use it
if config.AppVersion != nil {
chart.AppVersion = *config.AppVersion
}
chart.Version = config.ChartVersion
// remove the chart directory if it exists
os.RemoveAll(config.OutputDir)
// create the chart directory
if err := os.MkdirAll(templateDir, utils.DirectoryPermission); err != nil {
return err
}
// add icon from the command line
if config.Icon != "" {
chart.Icon = config.Icon
}
// write the templates to the disk
chart.SaveTemplates(templateDir)
// write the Chart.yaml file
buildCharYamlFile(chart, project, chartPath)
// build and write the values.yaml file
buildValues(chart, project, valuesPath)
// write the _helpers.tpl to the disk
writeContent(helpersPath, []byte(chart.Helper))
// write the readme to the disk
readme := extrafiles.ReadMeFile(chart.Name, chart.Description, chart.Values)
writeContent(readmePath, []byte(readme))
// get the list of services to write in the notes
buildNotesFile(project, notesPath)
// call helm update if needed
callHelmUpdate(config)
return nil
}
func addChartDoc(values []byte, project *types.Project) []byte {
chartDoc := fmt.Sprintf(`# This is the main values.yaml file for the %s chart.
# More information can be found in the chart's README.md file.
#
`, project.Name)
lines := strings.Split(string(values), "\n")
for i, line := range lines {
if regexp.MustCompile(`(?m)^name:`).MatchString(line) {
doc := "\n# Name of the chart (required), basically the name of the project.\n"
lines[i] = doc + line
} else if regexp.MustCompile(`(?m)^version:`).MatchString(line) {
doc := "\n# Version of the chart (required)\n"
lines[i] = doc + line
} else if strings.Contains(line, "appVersion:") {
spaces := utils.CountStartingSpaces(line)
doc := fmt.Sprintf(
"\n%s# Version of the application (required).\n%s# This should be the main application version.\n",
strings.Repeat(" ", spaces),
strings.Repeat(" ", spaces),
)
lines[i] = doc + line
} else if strings.Contains(line, "dependencies:") {
spaces := utils.CountStartingSpaces(line)
doc := fmt.Sprintf("\n"+
"%s# Dependencies are external charts that this chart will depend on.\n"+
"%s# More information can be found in the chart's README.md file.\n",
strings.Repeat(" ", spaces),
strings.Repeat(" ", spaces),
)
lines[i] = doc + line
}
}
return []byte(chartDoc + strings.Join(lines, "\n"))
}
func addCommentsToValues(values []byte) []byte {
lines := strings.Split(string(values), "\n")
for i, line := range lines {
if strings.Contains(line, "ingress:") {
spaces := utils.CountStartingSpaces(line)
spacesString := strings.Repeat(" ", spaces)
// indent ingressClassHelper comment
ingressClassHelp := strings.ReplaceAll(ingressClassHelp, "\n", "\n"+spacesString)
ingressClassHelp = strings.TrimRight(ingressClassHelp, " ")
ingressClassHelp = spacesString + ingressClassHelp
lines[i] = ingressClassHelp + line
}
}
return []byte(strings.Join(lines, "\n"))
}
func addDependencyDescription(values []byte, dependencies []labelstructs.Dependency) []byte {
for _, d := range dependencies {
name := d.Name
if d.Alias != "" {
name = d.Alias
}
values = regexp.MustCompile(
`(?m)^`+name+`:$`,
).ReplaceAll(
values,
[]byte("\n# "+d.Name+" helm dependency configuration\n"+name+":"),
)
}
return values
}
// addDescriptions adds the description from the label to the values.yaml file on top
// of the service definition.
func addDescriptions(values []byte, project types.Project) []byte {
for _, service := range project.Services {
if description, ok := service.Labels[labels.LabelDescription]; ok {
// set it as comment
description = "\n# " + strings.ReplaceAll(description, "\n", "\n# ")
values = regexp.MustCompile(
`(?m)^`+service.Name+`:$`,
).ReplaceAll(values, []byte(description+"\n"+service.Name+":"))
} else {
// set it as comment
description = "\n# " + service.Name + " configuration"
values = regexp.MustCompile(
`(?m)^`+service.Name+`:$`,
).ReplaceAll(
values,
[]byte(description+"\n"+service.Name+":"),
)
}
}
return values
}
func addDocToVariable(service types.ServiceConfig, lines []string) []string {
currentService := ""
variables := utils.GetValuesFromLabel(service, labels.LabelValues)
for i, line := range lines {
// if the line is a service, it is a name followed by a colon
if regexp.MustCompile(`(?m)^` + service.Name + `:`).MatchString(line) {
currentService = service.Name
}
// for each variable in the service, add the description
for varname, variable := range variables {
if variable == nil {
continue
}
spaces := utils.CountStartingSpaces(line)
if regexp.MustCompile(`(?m)\s*`+varname+`:`).MatchString(line) && currentService == service.Name {
// add # to the beginning of the Description
doc := strings.ReplaceAll("\n"+variable.Description, "\n", "\n"+strings.Repeat(" ", spaces)+"# ")
doc = strings.TrimRight(doc, " ")
doc += "\n" + line
lines[i] = doc
}
}
}
return lines
}
func addImagePullPolicyHelp(values []byte) []byte {
// add imagePullPolicy help
lines := strings.Split(string(values), "\n")
for i, line := range lines {
if strings.Contains(line, "imagePullPolicy:") {
spaces := utils.CountStartingSpaces(line)
spacesString := strings.Repeat(" ", spaces)
// indent imagePullPolicyHelp comment
imagePullPolicyHelp := strings.ReplaceAll(imagePullPolicyHelp, "\n", "\n"+spacesString)
imagePullPolicyHelp = strings.TrimRight(imagePullPolicyHelp, " ")
imagePullPolicyHelp = spacesString + imagePullPolicyHelp
lines[i] = imagePullPolicyHelp + line
}
}
return []byte(strings.Join(lines, "\n"))
}
func addImagePullSecretsHelp(values []byte) []byte {
// add imagePullSecrets help
lines := strings.Split(string(values), "\n")
for i, line := range lines {
if strings.Contains(line, "pullSecrets:") {
spaces := utils.CountStartingSpaces(line)
spacesString := strings.Repeat(" ", spaces)
// indent imagePullSecretHelp comment
imagePullSecretHelp := strings.ReplaceAll(imagePullSecretHelp, "\n", "\n"+spacesString)
imagePullSecretHelp = strings.TrimRight(imagePullSecretHelp, " ")
imagePullSecretHelp = spacesString + imagePullSecretHelp
lines[i] = imagePullSecretHelp + line
}
}
return []byte(strings.Join(lines, "\n"))
}
func addMainAppDoc(lines []string, service types.ServiceConfig) []string {
inService := false
inRegistry := false
for i, line := range lines {
if regexp.MustCompile(`^` + service.Name + `:`).MatchString(line) {
inService = true
}
if inService && regexp.MustCompile(`^\s*repository:.*`).MatchString(line) {
inRegistry = true
}
if inService && inRegistry {
if regexp.MustCompile(`^\s*tag: .*`).MatchString(line) {
spaces := utils.CountStartingSpaces(line)
doc := strings.ReplaceAll(mainTagAppDoc, "\n", "\n"+strings.Repeat(" ", spaces)+"# ")
doc = strings.Repeat(" ", spaces) + "# " + doc
lines[i] = doc + "\n" + line + "\n"
break
}
}
}
return lines
}
func addMainTagAppDoc(values []byte, project *types.Project) []byte {
lines := strings.Split(string(values), "\n")
for _, service := range project.Services {
// read the label LabelMainApp
if v, ok := service.Labels[labels.LabelMainApp]; !ok {
continue
} else if v == "false" || v == "no" || v == "0" {
continue
} else {
fmt.Printf("%s Adding main tag app doc %s\n", utils.IconConfig, service.Name)
}
lines = addMainAppDoc(lines, service)
}
return []byte(strings.Join(lines, "\n"))
}
func addResourceHelp(values []byte) []byte {
lines := strings.Split(string(values), "\n")
for i, line := range lines {
if strings.Contains(line, "resources:") {
spaces := utils.CountStartingSpaces(line)
spacesString := strings.Repeat(" ", spaces)
// indent resourceHelp comment
resourceHelp := strings.ReplaceAll(resourceHelp, "\n", "\n"+spacesString)
resourceHelp = strings.TrimRight(resourceHelp, " ")
resourceHelp = spacesString + resourceHelp
lines[i] = resourceHelp + line
}
}
return []byte(strings.Join(lines, "\n"))
}
// addStorageClassHelp adds a comment to the values.yaml file to explain how to
// use the storageClass option.
func addStorageClassHelp(values []byte) []byte {
lines := strings.Split(string(values), "\n")
for i, line := range lines {
if strings.Contains(line, "storageClass:") {
spaces := utils.CountStartingSpaces(line)
spacesString := strings.Repeat(" ", spaces)
// indent ingressClassHelper comment
storageClassHelp := strings.ReplaceAll(storageClassHelp, "\n", "\n"+spacesString)
storageClassHelp = strings.TrimRight(storageClassHelp, " ")
storageClassHelp = spacesString + storageClassHelp
lines[i] = storageClassHelp + line
}
}
return []byte(strings.Join(lines, "\n"))
}
func addVariablesDoc(values []byte, project *types.Project) []byte {
lines := strings.Split(string(values), "\n")
for _, service := range project.Services {
lines = addDocToVariable(service, lines)
}
return []byte(strings.Join(lines, "\n"))
}
// addYAMLSelectorPath adds a selector path to the yaml file for each key
// as comment. E.g. foo.ingress.host
func addYAMLSelectorPath(values []byte) []byte {
lines := strings.Split(string(values), "\n")
currentKey := ""
currentLevel := 0
toReturn := []string{}
for _, line := range lines {
// if the line is a not a key, continue
if !keyRegExp.MatchString(line) {
toReturn = append(toReturn, line)
continue
}
// get the key
key := strings.TrimSpace(strings.Split(line, ":")[0])
// get the spaces
spaces := utils.CountStartingSpaces(line)
if spaces/2 > currentLevel {
currentLevel++
} else if spaces/2 < currentLevel {
currentLevel--
}
currentKey = strings.Join(strings.Split(currentKey, ".")[:spaces/2], ".")
if currentLevel == 0 {
currentKey = key
toReturn = append(toReturn, line)
continue
}
// if the key is not empty, add the selector path
if currentKey != "" {
currentKey += "."
}
currentKey += key
// add the selector path as comment
toReturn = append(
toReturn,
strings.Repeat(" ", spaces)+"# key: "+currentKey+"\n"+line,
)
}
return []byte(strings.Join(toReturn, "\n"))
}
// addTLSHelp adds a comment to the values.yaml file to explain how to
// use the tls option.
func addTLSHelp(values []byte) []byte {
lines := strings.Split(string(values), "\n")
for i, line := range lines {
if strings.Contains(line, "tls:") {
spaces := utils.CountStartingSpaces(line)
spacesString := strings.Repeat(" ", spaces)
// indent ingressClassHelper comment
ingressTLSHelp := strings.ReplaceAll(ingressTLSHelp, "\n", "\n"+spacesString)
ingressTLSHelp = strings.TrimRight(ingressTLSHelp, " ")
ingressTLSHelp = spacesString + ingressTLSHelp
lines[i] = ingressTLSHelp + line
}
}
return []byte(strings.Join(lines, "\n"))
}
func buildCharYamlFile(chart *HelmChart, project *types.Project, chartPath string) {
// calculate the sha1 hash of the services
yamlChart, err := utils.EncodeBasicYaml(chart)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
// concat chart adding a comment with hash of services on top
yamlChart = append(fmt.Appendf(nil, "# compose hash (sha1): %s\n", *chart.composeHash), yamlChart...)
// add the list of compose files
files := []string{}
for _, file := range project.ComposeFiles {
base := filepath.Base(file)
files = append(files, base)
}
yamlChart = append(fmt.Appendf(nil, "# compose files: %s\n", strings.Join(files, ", ")), yamlChart...)
// add generated date
yamlChart = append(fmt.Appendf(nil, "# generated at: %s\n", time.Now().Format(time.RFC3339)), yamlChart...)
// document Chart.yaml file
yamlChart = addChartDoc(yamlChart, project)
writeContent(chartPath, yamlChart)
}
func buildNotesFile(project *types.Project, notesPath string) {
// get the list of services to write in the notes
services := make([]string, 0)
for _, service := range project.Services {
services = append(services, service.Name)
}
// write the notes to the disk
notes := extrafiles.NotesFile(services)
writeContent(notesPath, []byte(notes))
}
func buildValues(chart *HelmChart, project *types.Project, valuesPath string) {
values, err := utils.EncodeBasicYaml(&chart.Values)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
values = addDescriptions(values, *project)
values = addDependencyDescription(values, chart.Dependencies)
values = addCommentsToValues(values)
values = addStorageClassHelp(values)
values = addImagePullSecretsHelp(values)
values = addImagePullPolicyHelp(values)
values = addVariablesDoc(values, project)
values = addMainTagAppDoc(values, project)
values = addResourceHelp(values)
values = addTLSHelp(values)
values = addYAMLSelectorPath(values)
values = append([]byte(headerHelp), values...)
// add vim modeline
values = append(values, []byte("\n# vim: ft=yaml\n")...)
// write the values to the disk
writeContent(valuesPath, values)
}
func callHelmUpdate(config ConvertOptions) {
executeAndHandleError := func(fn func(ConvertOptions) error, config ConvertOptions, message string) {
if err := fn(config); err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
fmt.Println(utils.IconSuccess, message)
}
if config.HelmUpdate {
executeAndHandleError(helmUpdate, config, "Helm dependencies updated")
executeAndHandleError(helmLint, config, "Helm chart linted")
fmt.Println(utils.IconSuccess, "Helm chart created successfully")
}
}
func removeNewlinesInsideBrackets(values []byte) []byte {
re, err := regexp.Compile(`(?s)\{\{(.*?)\}\}`)
if err != nil {
log.Fatal(err)
}
return re.ReplaceAllFunc(values, func(b []byte) []byte {
// get the first match
matches := re.FindSubmatch(b)
replacement := bytes.ReplaceAll(matches[1], []byte("\n"), []byte(" "))
// remove repeated spaces
replacement = regexp.MustCompile(`\s+`).ReplaceAll(replacement, []byte(" "))
// remove newlines inside brackets
return bytes.ReplaceAll(b, matches[1], replacement)
})
}
func removeUnwantedLines(values []byte) []byte {
lines := strings.Split(string(values), "\n")
output := []string{}
for _, line := range lines {
next := false
for _, unwanted := range unwantedLines {
if strings.Contains(line, unwanted) {
next = true
}
}
if !next {
output = append(output, line)
}
}
return []byte(strings.Join(output, "\n"))
}
func writeContent(path string, content []byte) {
f, err := os.Create(path)
if err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
defer f.Close()
defer func() {
if _, err := f.Write(content); err != nil {
log.Fatal(err)
}
}()
}
// helmLint runs "helm lint" on the output directory.
func helmLint(config ConvertOptions) error {
fmt.Println(utils.IconInfo, "Linting...")
helm, err := exec.LookPath("helm")
if err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
cmd := exec.Command(helm, "lint", config.OutputDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// helmUpdate runs "helm dependency update" on the output directory.
func helmUpdate(config ConvertOptions) error {
// lookup for "helm" binary
fmt.Println(utils.IconInfo, "Updating helm dependencies...")
helm, err := exec.LookPath("helm")
if err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
// run "helm dependency update"
cmd := exec.Command(helm, "dependency", "update", config.OutputDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// check if the project makes use of older labels (kanetary.[^v3])
func checkOldLabels(project *types.Project) error {
badServices := make([]string, 0)
for _, service := range project.Services {
for label := range service.Labels {
if strings.Contains(label, "katenary.") && !strings.Contains(label, labels.KatenaryLabelPrefix) {
badServices = append(badServices, fmt.Sprintf("- %s: %s", service.Name, label))
}
}
}
if len(badServices) > 0 {
message := fmt.Sprintf(` Old labels detected in project "%s".
The current version of katenary uses labels with the prefix "%s" which are not compatible with previous versions.
Your project is not compatible with this version.
Please upgrade your labels to follow the current version
Services to upgrade:
%s`,
project.Name,
labels.KatenaryLabelPrefix[0:len(labels.KatenaryLabelPrefix)-1],
strings.Join(badServices, "\n"),
)
return errors.New(utils.WordWrap(message, 80))
}
return nil
}

View File

@@ -0,0 +1,123 @@
package generator
import (
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/generator/labels/labelstructs"
"github.com/katenary/katenary/internal/utils"
"log"
"strings"
"github.com/compose-spec/compose-go/types"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// only used to check interface implementation
var (
_ Yaml = (*CronJob)(nil)
)
// CronJob is a kubernetes CronJob.
type CronJob struct {
*batchv1.CronJob
service *types.ServiceConfig
}
// NewCronJob creates a new CronJob from a compose service. The appName is the name of the application taken from the project name.
func NewCronJob(service types.ServiceConfig, chart *HelmChart, appName string) (*CronJob, *RBAC) {
labels, ok := service.Labels[labels.LabelCronJob]
if !ok {
return nil, nil
}
mapping, err := labelstructs.CronJobFrom(labels)
if err != nil {
log.Fatalf("Error parsing cronjob labels: %s", err)
return nil, nil
}
if _, ok := chart.Values[service.Name]; !ok {
chart.Values[service.Name] = NewValue(service, false)
}
if chart.Values[service.Name].(*Value).CronJob == nil {
chart.Values[service.Name].(*Value).CronJob = &CronJobValue{}
}
chart.Values[service.Name].(*Value).CronJob.Schedule = mapping.Schedule
chart.Values[service.Name].(*Value).CronJob.ImagePullPolicy = "IfNotPresent"
chart.Values[service.Name].(*Value).CronJob.Environment = map[string]any{}
image, tag := mapping.Image, ""
if image == "" { // if image is not set, use the image from the service
image = service.Image
}
if strings.Contains(image, ":") {
image = strings.Split(service.Image, ":")[0]
tag = strings.Split(service.Image, ":")[1]
}
chart.Values[service.Name].(*Value).CronJob.Repository = &RepositoryValue{
Image: image,
Tag: tag,
}
cronjob := &CronJob{
CronJob: &batchv1.CronJob{
TypeMeta: metav1.TypeMeta{
Kind: "CronJob",
APIVersion: "batch/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName),
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Spec: batchv1.CronJobSpec{
Schedule: "{{ .Values." + service.Name + ".cronjob.schedule }}",
JobTemplate: batchv1.JobTemplateSpec{
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "cronjob",
Image: "{{ .Values." + service.Name + ".cronjob.repository.image }}:{{ default .Values." + service.Name + ".cronjob.repository.tag \"latest\" }}",
Command: []string{
"sh",
"-c",
mapping.Command,
},
},
},
},
},
},
},
},
},
service: &service,
}
var rbac *RBAC
if mapping.Rbac {
rbac = NewRBAC(service, appName)
// add the service account to the cronjob
cronjob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName = utils.TplName(service.Name, appName)
}
return cronjob, rbac
}
// Filename returns the filename of the cronjob.
//
// Implements the Yaml interface.
func (c *CronJob) Filename() string {
return c.service.Name + ".cronjob.yaml"
}
// Yaml returns the yaml representation of the cronjob.
//
// Implements the Yaml interface.
func (c *CronJob) Yaml() ([]byte, error) {
return ToK8SYaml(c)
}

View File

@@ -0,0 +1,115 @@
package generator
import (
"os"
"strings"
"testing"
v1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)
func TestBasicCronJob(t *testing.T) {
composeFile := `
services:
cron:
image: fedora
labels:
katenary.v3/cronjob: |
image: alpine
command: echo hello
schedule: "*/1 * * * *"
rbac: false
`
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", "templates/cron/cronjob.yaml")
cronJob := batchv1.CronJob{}
if err := yaml.Unmarshal([]byte(output), &cronJob); err != nil {
t.Errorf(unmarshalError, err)
}
if cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image != "alpine:latest" {
t.Errorf("Expected image to be alpine, got %s", cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image)
}
combinedCommand := strings.Join(cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command, " ")
if combinedCommand != "sh -c echo hello" {
t.Errorf("Expected command to be sh -c echo hello, got %s", combinedCommand)
}
if cronJob.Spec.Schedule != "*/1 * * * *" {
t.Errorf("Expected schedule to be */1 * * * *, got %s", cronJob.Spec.Schedule)
}
// ensure that there are a deployment for the fedora Container
var err error
output, err = helmTemplate(ConvertOptions{
OutputDir: "./chart",
}, "-s", "templates/cron/deployment.yaml")
if err != nil {
t.Errorf("Error: %s", err)
}
deployment := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &deployment); err != nil {
t.Errorf(unmarshalError, err)
}
if deployment.Spec.Template.Spec.Containers[0].Image != "fedora:latest" {
t.Errorf("Expected image to be fedora, got %s", deployment.Spec.Template.Spec.Containers[0].Image)
}
}
func TestCronJobbWithRBAC(t *testing.T) {
composeFile := `
services:
cron:
image: fedora
labels:
katenary.v3/cronjob: |
image: alpine
command: echo hello
schedule: "*/1 * * * *"
rbac: true
`
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", "templates/cron/cronjob.yaml")
cronJob := batchv1.CronJob{}
if err := yaml.Unmarshal([]byte(output), &cronJob); err != nil {
t.Errorf(unmarshalError, err)
}
if cronJob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName == "" {
t.Errorf("Expected ServiceAccountName to be set")
}
// find the service account file
output, err := helmTemplate(ConvertOptions{
OutputDir: "./chart",
}, "-s", "templates/cron/serviceaccount.yaml")
if err != nil {
t.Errorf("Error: %s", err)
}
serviceAccount := corev1.ServiceAccount{}
if err := yaml.Unmarshal([]byte(output), &serviceAccount); err != nil {
t.Errorf(unmarshalError, err)
}
if serviceAccount.Name == "" {
t.Errorf("Expected ServiceAccountName to be set")
}
// ensure that the serviceAccount is equal to the cronJob
if serviceAccount.Name != cronJob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName {
t.Errorf("Expected ServiceAccountName to be %s, got %s", cronJob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName, serviceAccount.Name)
}
}

View File

@@ -0,0 +1,736 @@
package generator
import (
"fmt"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/generator/labels/labelstructs"
"github.com/katenary/katenary/internal/utils"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/compose-spec/compose-go/types"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var _ Yaml = (*Deployment)(nil)
type mountPathConfig struct {
mountPath string
subPath string
}
type ConfigMapMount struct {
configMap *ConfigMap
mountPath []mountPathConfig
}
// Deployment is a kubernetes Deployment.
type Deployment struct {
*appsv1.Deployment `yaml:",inline"`
chart *HelmChart `yaml:"-"`
configMaps map[string]*ConfigMapMount `yaml:"-"`
volumeMap map[string]string `yaml:"-"` // keep map of fixed named to original volume name
service *types.ServiceConfig `yaml:"-"`
defaultTag string `yaml:"-"`
isMainApp bool `yaml:"-"`
exchangesVolumes map[string]*labelstructs.ExchangeVolume `yaml:"-"`
boundEnvVar []string `yaml:"-"` // environement to remove
}
// NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name.
// It also creates the Values map that will be used to create the values.yaml file.
func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment {
isMainApp := false
if mainLabel, ok := service.Labels[labels.LabelMainApp]; ok {
main := strings.ToLower(mainLabel)
isMainApp = main == "true" || main == "yes" || main == "1"
}
defaultTag := `default "latest"`
if isMainApp {
defaultTag = `default .Chart.AppVersion`
}
chart.Values[service.Name] = NewValue(service, isMainApp)
appName := chart.Name
dep := &Deployment{
isMainApp: isMainApp,
defaultTag: defaultTag,
service: &service,
chart: chart,
Deployment: &appsv1.Deployment{
TypeMeta: metav1.TypeMeta{
Kind: "Deployment",
APIVersion: "apps/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName),
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Spec: appsv1.DeploymentSpec{
Replicas: utils.Int32Ptr(1),
Selector: &metav1.LabelSelector{
MatchLabels: GetMatchLabels(service.Name, appName),
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: GetMatchLabels(service.Name, appName),
},
Spec: corev1.PodSpec{
NodeSelector: map[string]string{
labels.LabelName("node-selector"): "replace",
},
},
},
},
},
configMaps: make(map[string]*ConfigMapMount),
volumeMap: make(map[string]string),
exchangesVolumes: map[string]*labelstructs.ExchangeVolume{},
boundEnvVar: []string{},
}
// add containers
dep.AddContainer(service)
// add volumes
dep.AddVolumes(service, appName)
if service.Environment != nil {
dep.SetEnvFrom(service, appName)
}
return dep
}
// AddContainer adds a container to the deployment.
func (d *Deployment) AddContainer(service types.ServiceConfig) {
ports := []corev1.ContainerPort{}
for _, port := range service.Ports {
name := utils.GetServiceNameByPort(int(port.Target))
if name == "" {
utils.Warn("Port name not found for port ", port.Target, " in service ", service.Name, ". Using port number instead")
name = fmt.Sprintf("port-%d", port.Target)
}
ports = append(ports, corev1.ContainerPort{
ContainerPort: int32(port.Target),
Name: name,
})
}
container := corev1.Container{
Image: utils.TplValue(service.Name, "repository.image") + ":" +
utils.TplValue(service.Name, "repository.tag", d.defaultTag),
Ports: ports,
Name: service.ContainerName,
ImagePullPolicy: corev1.PullIfNotPresent,
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{},
},
Command: service.Command,
}
if _, ok := d.chart.Values[service.Name]; !ok {
d.chart.Values[service.Name] = NewValue(service, d.isMainApp)
}
d.chart.Values[service.Name].(*Value).ImagePullPolicy = string(corev1.PullIfNotPresent)
// add an imagePullSecret, it actually does not work because the secret is not
// created but it add the reference in the YAML file. We'll change it in Yaml()
// method.
d.Spec.Template.Spec.ImagePullSecrets = []corev1.LocalObjectReference{{
Name: `{{ .Values.pullSecrets | toYaml | indent __indent__ }}`,
}}
// add ServiceAccount to the deployment
d.Spec.Template.Spec.ServiceAccountName = `{{ .Values.` + service.Name + `.serviceAccount | quote }}`
d.AddHealthCheck(service, &container)
d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, container)
}
func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) {
// get the label for healthcheck
if v, ok := service.Labels[labels.LabelHealthCheck]; ok {
probes, err := labelstructs.ProbeFrom(v)
if err != nil {
log.Fatal(err)
}
container.LivenessProbe = probes.LivenessProbe
container.ReadinessProbe = probes.ReadinessProbe
return
}
if service.HealthCheck != nil {
period := 30.0
if service.HealthCheck.Interval != nil {
period = time.Duration(*service.HealthCheck.Interval).Seconds()
}
container.LivenessProbe = &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{
Exec: &corev1.ExecAction{
Command: service.HealthCheck.Test[1:],
},
},
PeriodSeconds: int32(period),
}
}
}
// AddIngress adds an ingress to the deployment. It creates the ingress object.
func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *Ingress {
return NewIngress(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) {
tobind := map[string]bool{}
if v, ok := service.Labels[labels.LabelConfigMapFiles]; ok {
binds, err := labelstructs.ConfigMapFileFrom(v)
if err != nil {
log.Fatal(err)
}
for _, bind := range binds {
tobind[bind] = true
}
}
isSamePod := false
if v, ok := service.Labels[labels.LabelSamePod]; !ok {
isSamePod = false
} else {
isSamePod = v != ""
}
for _, volume := range service.Volumes {
d.bindVolumes(volume, isSamePod, tobind, service, appName)
}
}
func (d *Deployment) AddLegacyVolume(name, kind string) {
// ensure the volume is not present
for _, v := range d.Spec.Template.Spec.Volumes {
if v.Name == name {
return
}
}
// init
if d.Spec.Template.Spec.Volumes == nil {
d.Spec.Template.Spec.Volumes = []corev1.Volume{}
}
d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{
Name: name,
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
})
}
func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) {
// find the volume in the binded deployment
for _, bindedVolume := range binded.Spec.Template.Spec.Volumes {
skip := false
for _, targetVol := range d.Spec.Template.Spec.Volumes {
if targetVol.Name == bindedVolume.Name {
skip = true
break
}
}
if !skip {
// add the volume to the current deployment
d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, bindedVolume)
// get the container
}
// add volume mount to the container
targetContainer, ti := utils.GetContainerByName(service.ContainerName, d.Spec.Template.Spec.Containers)
sourceContainer, _ := utils.GetContainerByName(service.ContainerName, binded.Spec.Template.Spec.Containers)
for _, bindedMount := range sourceContainer.VolumeMounts {
if bindedMount.Name == bindedVolume.Name {
targetContainer.VolumeMounts = append(targetContainer.VolumeMounts, bindedMount)
}
}
d.Spec.Template.Spec.Containers[ti] = *targetContainer
}
}
// DependsOn adds a initContainer to the deployment that will wait for the service to be up.
func (d *Deployment) DependsOn(to *Deployment, servicename string) error {
// Add a initContainer with busybox:latest using netcat to check if the service is up
// it will wait until the service responds to all ports
for _, container := range to.Spec.Template.Spec.Containers {
commands := []string{}
if len(container.Ports) == 0 {
utils.Warn("No ports found for service ",
servicename,
". You should declare a port in the service or use "+
labels.LabelPorts+
" label.",
)
os.Exit(1)
}
for _, port := range container.Ports {
command := fmt.Sprintf("until nc -z %s %d; do\n sleep 1;\ndone", to.Name, port.ContainerPort)
commands = append(commands, command)
}
command := []string{"/bin/sh", "-c", strings.Join(commands, "\n")}
d.Spec.Template.Spec.InitContainers = append(d.Spec.Template.Spec.InitContainers, corev1.Container{
Name: "wait-for-" + to.service.Name,
Image: "busybox:latest",
Command: command,
})
}
return nil
}
// Filename returns the filename of the deployment.
func (d *Deployment) Filename() string {
return d.service.Name + ".deployment.yaml"
}
// SetEnvFrom sets the environment variables to a configmap. The configmap is created.
func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string, samePod ...bool) {
if len(service.Environment) == 0 {
return
}
inSamePod := len(samePod) > 0 && samePod[0]
drop := []string{}
secrets := []string{}
defer func() {
c, index := d.BindMapFilesToContainer(service, secrets, appName)
if c == nil || index == -1 {
log.Println("Container not found for service ", service.Name)
return
}
d.Spec.Template.Spec.Containers[index] = *c
}()
// secrets from label
labelSecrets, err := labelstructs.SecretsFrom(service.Labels[labels.LabelSecrets])
if err != nil {
log.Fatal(err)
}
// values from label
varDescriptons := utils.GetValuesFromLabel(service, labels.LabelValues)
labelValues := []string{}
for v := range varDescriptons {
labelValues = append(labelValues, v)
}
for _, secret := range labelSecrets {
// get the secret name
_, ok := service.Environment[secret]
if !ok {
drop = append(drop, secret)
utils.Warn("Secret " + secret + " not found in service " + service.Name + " - skpped")
continue
}
secrets = append(secrets, secret)
}
if inSamePod {
return
}
// for each values from label "values", add it to Values map and change the envFrom
// value to {{ .Values.<service>.<value> }}
for _, value := range labelValues {
// get the environment variable name
val, ok := service.Environment[value]
if !ok {
drop = append(drop, value)
utils.Warn("Environment variable " + value + " not found in service " + service.Name + " - skpped")
continue
}
if d.chart.Values[service.Name].(*Value).Environment == nil {
d.chart.Values[service.Name].(*Value).Environment = make(map[string]any)
}
d.chart.Values[service.Name].(*Value).Environment[value] = *val
// set the environment variable to bind to the values.yaml file
v := utils.TplValue(service.Name, "environment."+value)
service.Environment[value] = &v
}
for _, value := range drop {
delete(service.Environment, value)
}
}
func (d *Deployment) BindMapFilesToContainer(service types.ServiceConfig, secrets []string, appName string) (*corev1.Container, int) {
fromSources := []corev1.EnvFromSource{}
envSize := len(service.Environment)
for _, secret := range secrets {
for k := range service.Environment {
if k == secret {
envSize--
}
}
}
if envSize > 0 {
if service.Name == "db" {
log.Println("Service ", service.Name, " has environment variables")
log.Println(service.Environment)
}
fromSources = append(fromSources, corev1.EnvFromSource{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: utils.TplName(service.Name, appName),
},
},
})
}
if len(secrets) > 0 {
fromSources = append(fromSources, corev1.EnvFromSource{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: utils.TplName(service.Name, appName),
},
},
})
}
container, index := utils.GetContainerByName(service.ContainerName, d.Spec.Template.Spec.Containers)
if container == nil {
utils.Warn("Container not found for service " + service.Name)
return nil, -1
}
container.EnvFrom = append(container.EnvFrom, fromSources...)
if container.Env == nil {
container.Env = []corev1.EnvVar{}
}
return container, index
}
func (d *Deployment) MountExchangeVolumes() {
for name, ex := range d.exchangesVolumes {
for i, c := range d.Spec.Template.Spec.Containers {
c.VolumeMounts = append(c.VolumeMounts, corev1.VolumeMount{
Name: "exchange-" + ex.Name,
MountPath: ex.MountPath,
})
if len(ex.Init) > 0 && name == c.Name {
d.Spec.Template.Spec.InitContainers = append(d.Spec.Template.Spec.InitContainers, corev1.Container{
Command: []string{"/bin/sh", "-c", ex.Init},
Image: c.Image,
Name: "exhange-init-" + name,
VolumeMounts: []corev1.VolumeMount{{
Name: "exchange-" + ex.Name,
MountPath: ex.MountPath,
}},
})
}
d.Spec.Template.Spec.Containers[i] = c
}
}
}
// Yaml returns the yaml representation of the deployment.
func (d *Deployment) Yaml() ([]byte, error) {
var y []byte
var err error
serviceName := d.service.Name
if y, err = ToK8SYaml(d); err != nil {
return nil, err
}
// for each volume mount, add a condition "if values has persistence"
changing := false
content := strings.Split(string(y), "\n")
spaces := ""
volumeName := ""
nameDirective := "name: "
// this loop add condition for each volume mount
for line, volume := range content {
// find the volume name
for i := line; i < len(content); i++ {
if strings.Contains(content[i], nameDirective) {
volumeName = strings.TrimSpace(strings.Replace(content[i], "name: ", "", 1))
break
}
}
if volumeName == "" {
continue
}
if _, ok := d.configMaps[volumeName]; ok {
continue
}
if strings.Contains(volume, "mountPath: ") {
spaces = strings.Repeat(" ", utils.CountStartingSpaces(volume))
varName, ok := d.volumeMap[volumeName]
if !ok {
// this case happens when the volume is a "bind" volume comming from a "same-pod" service.
continue
}
varName = strings.ReplaceAll(varName, "-", "_")
content[line] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + varName + `.enabled }}` + "\n" + volume
changing = true
}
if strings.Contains(volume, nameDirective) && changing {
content[line] = volume + "\n" + spaces + "{{- end }}"
changing = false
}
}
changing = false
inVolumes := false
volumeName = ""
// this loop changes imagePullPolicy to {{ .Values.<service>.imagePullPolicy }}
// and the volume definition adding the condition "if values has persistence"
for i, line := range content {
if strings.Contains(line, "imagePullPolicy:") {
spaces = strings.Repeat(" ", utils.CountStartingSpaces(line))
content[i] = spaces + "imagePullPolicy: {{ .Values." + serviceName + ".imagePullPolicy }}"
}
// find the volume name
for i := i; i < len(content); i++ {
if strings.Contains(content[i], "- name: ") {
volumeName = strings.TrimSpace(strings.Replace(content[i], "- name: ", "", 1))
break
}
}
if strings.Contains(line, "volumes:") {
inVolumes = true
}
if volumeName == "" {
continue
}
if _, ok := d.configMaps[volumeName]; ok {
continue
}
if strings.Contains(line, "- name: ") && inVolumes {
spaces = strings.Repeat(" ", utils.CountStartingSpaces(line))
varName := d.volumeMap[volumeName]
varName = strings.ReplaceAll(varName, "-", "_")
content[i] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + varName + `.enabled }}` + "\n" + line
changing = true
}
if strings.Contains(line, "claimName: ") && changing {
content[i] = line + "\n" + spaces + "{{- end }}"
changing = false
}
}
// for impagePullSecrets, replace the name with the value from values.yaml
for i, line := range content {
if strings.Contains(line, "imagePullSecrets:") {
spaces = strings.Repeat(" ", utils.CountStartingSpaces(line))
line = spaces + "{{- if .Values.pullSecrets }}"
line += "\n" + spaces + "imagePullSecrets:\n"
line += spaces + "{{- .Values.pullSecrets | toYaml | nindent __indent__ }}"
line += "\n" + spaces + "{{- end }}"
content[i] = line
}
}
// Find the replicas line and replace it with the value from values.yaml
for i, line := range content {
// manage nodeSelector
if strings.Contains(line, "nodeSelector:") {
spaces = strings.Repeat(" ", utils.CountStartingSpaces(line))
pre := spaces + `{{- if .Values.` + serviceName + `.nodeSelector }}`
post := spaces + "{{- end }}"
ns := spaces + "nodeSelector:\n"
ns += spaces + ` {{- .Values.` + serviceName + `.nodeSelector | toYaml | nindent __indent__ }}`
line = pre + "\n" + ns + "\n" + post
}
// manage replicas
if strings.Contains(line, "replicas:") {
line = regexp.MustCompile("replicas: .*$").ReplaceAllString(line, "replicas: {{ .Values."+serviceName+".replicas }}")
}
// manage serviceAccount, add condition to use the serviceAccount from values.yaml
if strings.Contains(line, "serviceAccountName:") {
spaces = strings.Repeat(" ", utils.CountStartingSpaces(line))
pre := spaces + `{{- if ne .Values.` + serviceName + `.serviceAccount "" }}`
post := spaces + "{{- end }}"
line = strings.ReplaceAll(line, "'", "")
line = pre + "\n" + line + "\n" + post
}
if strings.Contains(line, "resources: {}") {
spaces = strings.Repeat(" ", utils.CountStartingSpaces(line))
pre := spaces + `{{- if .Values.` + serviceName + `.resources }}`
post := spaces + "{{- end }}"
line = strings.ReplaceAll(line, "resources: {}", "resources:")
line += "\n" + spaces + " {{ .Values." + serviceName + ".resources | toYaml | nindent __indent__ }}"
line = pre + "\n" + line + "\n" + post
}
content[i] = line
}
// find the katenary.v3/node-selector line, and remove it
for i, line := range content {
if strings.Contains(line, labels.LabelName("node-selector")) {
content = append(content[:i], content[i+1:]...)
continue
}
if strings.Contains(line, "- name: '{{ .Values.pullSecrets ") {
content = append(content[:i], content[i+1:]...)
continue
}
}
return []byte(strings.Join(content, "\n")), nil
}
func (d *Deployment) appendDirectoryToConfigMap(service types.ServiceConfig, appName string, volume types.ServiceVolumeConfig) {
pathnme := utils.PathToName(volume.Source)
if _, ok := d.configMaps[pathnme]; !ok {
d.configMaps[pathnme] = &ConfigMapMount{
mountPath: []mountPathConfig{},
}
}
// TODO: make it recursive to add all files in the directory and subdirectories
_, err := os.ReadDir(volume.Source)
if err != nil {
log.Fatal(err)
}
cm := NewConfigMapFromDirectory(service, appName, volume.Source)
d.configMaps[pathnme] = &ConfigMapMount{
configMap: cm,
mountPath: append(d.configMaps[pathnme].mountPath, mountPathConfig{
mountPath: volume.Target,
}),
}
}
func (d *Deployment) appendFileToConfigMap(service types.ServiceConfig, appName string, volume types.ServiceVolumeConfig) {
// In case of a file, add it to the configmap and use "subPath" to mount it
// Note that the volumes and volume mounts are not added to the deployment yet, they will be added later
// in generate.go
dirname := filepath.Dir(volume.Source)
pathname := utils.PathToName(dirname)
pathname = strings.TrimSpace(pathname)
if len(pathname) != 0 {
pathname += "-" + pathname
}
var cm *ConfigMap
if v, ok := d.configMaps[pathname]; !ok {
cm = NewConfigMap(*d.service, appName, true)
cm.usage = FileMapUsageFiles
cm.path = dirname
cm.Name = utils.TplName(service.Name, appName) + pathname
d.configMaps[pathname] = &ConfigMapMount{
configMap: cm,
mountPath: []mountPathConfig{{
mountPath: volume.Target,
subPath: filepath.Base(volume.Source),
}},
}
} else {
cm = v.configMap
mp := d.configMaps[pathname].mountPath
mp = append(mp, mountPathConfig{
mountPath: volume.Target,
subPath: filepath.Base(volume.Source),
})
d.configMaps[pathname].mountPath = mp
}
if err := cm.AppendFile(volume.Source); err != nil {
log.Fatal("Error adding file to configmap:", err)
}
}
func (d *Deployment) bindVolumes(volume types.ServiceVolumeConfig, isSamePod bool, tobind map[string]bool, service types.ServiceConfig, appName string) {
container, index := utils.GetContainerByName(service.ContainerName, d.Spec.Template.Spec.Containers)
defer func(d *Deployment, container *corev1.Container, index int) {
d.Spec.Template.Spec.Containers[index] = *container
}(d, container, index)
if _, found := tobind[volume.Source]; !isSamePod && volume.Type == "bind" && !found {
utils.Warn(
"Bind volumes are not supported yet, " +
"excepting for those declared as " +
labels.LabelConfigMapFiles +
", skipping volume " + volume.Source +
" from service " + service.Name,
)
return
}
if container == nil {
utils.Warn("Container not found for volume", volume.Source)
return
}
// ensure that the volume is not already present in the container
for _, vm := range container.VolumeMounts {
if vm.Name == volume.Source {
return
}
}
switch volume.Type {
case "volume":
// Add volume to container
fixedName := utils.FixedResourceName(volume.Source)
d.volumeMap[fixedName] = volume.Source
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{
Name: fixedName,
MountPath: volume.Target,
})
// Add volume to values.yaml only if it the service is not in the same pod that another service.
// If it is in the same pod, the volume will be added to the other service later
if _, ok := service.Labels[labels.LabelSamePod]; !ok {
d.chart.Values[service.Name].(*Value).AddPersistence(volume.Source)
}
// Add volume to deployment
d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{
Name: fixedName,
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: utils.TplName(service.Name, appName, volume.Source),
},
},
})
case "bind":
// Add volume to container
stat, err := os.Stat(volume.Source)
if err != nil {
log.Fatal(err)
}
if stat.IsDir() {
d.appendDirectoryToConfigMap(service, appName, volume)
} else {
d.appendFileToConfigMap(service, appName, volume)
}
}
}

View File

@@ -0,0 +1,533 @@
package generator
import (
"fmt"
"github.com/katenary/katenary/internal/generator/labels"
"os"
"strings"
"testing"
yaml3 "gopkg.in/yaml.v3"
v1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)
const webTemplateOutput = `templates/web/deployment.yaml`
func TestGenerate(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
`
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", webTemplateOutput)
// dt := DeploymentTest{}
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
if *dt.Spec.Replicas != 1 {
t.Errorf("Expected replicas to be 1, got %d", dt.Spec.Replicas)
t.Errorf("Output: %s", output)
}
if dt.Spec.Template.Spec.Containers[0].Image != "nginx:1.29" {
t.Errorf("Expected image to be nginx:1.29, got %s", dt.Spec.Template.Spec.Containers[0].Image)
}
}
func TestGenerateOneDeploymentWithSamePod(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
ports:
- 80:80
fpm:
image: php:fpm
ports:
- 9000:9000
labels:
katenary.v3/same-pod: web
`
outDir := "./chart"
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", webTemplateOutput)
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
if len(dt.Spec.Template.Spec.Containers) != 2 {
t.Errorf("Expected 2 containers, got %d", len(dt.Spec.Template.Spec.Containers))
}
// endsure that the fpm service is not created
var err error
_, err = helmTemplate(ConvertOptions{
OutputDir: outDir,
}, "-s", "templates/fpm/deployment.yaml")
if err == nil {
t.Errorf("Expected error, got nil")
}
// ensure that the web service is created and has got 2 ports
output, err = helmTemplate(ConvertOptions{
OutputDir: outDir,
}, "-s", "templates/web/service.yaml")
if err != nil {
t.Errorf("Error: %s", err)
}
service := corev1.Service{}
if err := yaml.Unmarshal([]byte(output), &service); err != nil {
t.Errorf(unmarshalError, err)
}
if len(service.Spec.Ports) != 2 {
t.Errorf("Expected 2 ports, got %d", len(service.Spec.Ports))
}
}
func TestDependsOn(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
ports:
- 80:80
depends_on:
- database
database:
image: mariadb:10.5
ports:
- 3306:3306
`
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", webTemplateOutput)
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
if len(dt.Spec.Template.Spec.Containers) != 1 {
t.Errorf("Expected 1 container, got %d", len(dt.Spec.Template.Spec.Containers))
}
// find an init container
if len(dt.Spec.Template.Spec.InitContainers) != 1 {
t.Errorf("Expected 1 init container, got %d", len(dt.Spec.Template.Spec.InitContainers))
}
}
func TestHelmDependencies(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
ports:
- 80:80
mariadb:
image: mariadb:10.5
ports:
- 3306:3306
labels:
%s/dependencies: |
- name: mariadb
repository: oci://registry-1.docker.io/bitnamicharts
version: 18.x.X
`
composeFile = fmt.Sprintf(composeFile, labels.Prefix())
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", webTemplateOutput)
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
// ensure that there is no mariasb deployment
_, err := helmTemplate(ConvertOptions{
OutputDir: "./chart",
}, "-s", "templates/mariadb/deployment.yaml")
if err == nil {
t.Errorf("Expected error, got nil")
}
// check that Chart.yaml has the dependency
chart := HelmChart{}
chartFile := "./chart/Chart.yaml"
if _, err := os.Stat(chartFile); os.IsNotExist(err) {
t.Errorf("Chart.yaml does not exist")
}
chartContent, err := os.ReadFile(chartFile)
if err != nil {
t.Errorf("Error reading Chart.yaml: %s", err)
}
if err := yaml.Unmarshal(chartContent, &chart); err != nil {
t.Errorf(unmarshalError, err)
}
if len(chart.Dependencies) != 1 {
t.Errorf("Expected 1 dependency, got %d", len(chart.Dependencies))
}
}
func TestLivenessProbesFromHealthCheck(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
ports:
- 80:80
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 5s
timeout: 3s
retries: 3
`
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", webTemplateOutput)
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
if dt.Spec.Template.Spec.Containers[0].LivenessProbe == nil {
t.Errorf("Expected liveness probe to be set")
}
}
func TestProbesFromLabels(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
ports:
- 80:80
labels:
%s/health-check: |
livenessProbe:
httpGet:
path: /healthz
port: 80
readinessProbe:
httpGet:
path: /ready
port: 80
`
composeFile = fmt.Sprintf(composeFile, labels.Prefix())
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", webTemplateOutput)
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
if dt.Spec.Template.Spec.Containers[0].LivenessProbe == nil {
t.Errorf("Expected liveness probe to be set")
}
if dt.Spec.Template.Spec.Containers[0].ReadinessProbe == nil {
t.Errorf("Expected readiness probe to be set")
}
t.Logf("LivenessProbe: %+v", dt.Spec.Template.Spec.Containers[0].LivenessProbe)
// ensure that the liveness probe is set to /healthz
if dt.Spec.Template.Spec.Containers[0].LivenessProbe.HTTPGet.Path != "/healthz" {
t.Errorf("Expected liveness probe path to be /healthz, got %s", dt.Spec.Template.Spec.Containers[0].LivenessProbe.HTTPGet.Path)
}
// ensure that the readiness probe is set to /ready
if dt.Spec.Template.Spec.Containers[0].ReadinessProbe.HTTPGet.Path != "/ready" {
t.Errorf("Expected readiness probe path to be /ready, got %s", dt.Spec.Template.Spec.Containers[0].ReadinessProbe.HTTPGet.Path)
}
}
func TestSetValues(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
environment:
FOO: bar
BAZ: qux
labels:
%s/values: |
- FOO
`
composeFile = fmt.Sprintf(composeFile, labels.Prefix())
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", webTemplateOutput)
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
// readh the values.yaml, we must have FOO in web environment but not BAZ
valuesFile := "./chart/values.yaml"
if _, err := os.Stat(valuesFile); os.IsNotExist(err) {
t.Errorf("values.yaml does not exist")
}
valuesContent, err := os.ReadFile(valuesFile)
if err != nil {
t.Errorf("Error reading values.yaml: %s", err)
}
mapping := struct {
Web struct {
Environment map[string]string `yaml:"environment"`
} `yaml:"web"`
}{}
if err := yaml3.Unmarshal(valuesContent, &mapping); err != nil {
t.Errorf(unmarshalError, err)
}
if v, ok := mapping.Web.Environment["FOO"]; !ok {
t.Errorf("Expected FOO in web environment")
if v != "bar" {
t.Errorf("Expected FOO to be bar, got %s", v)
}
}
if v, ok := mapping.Web.Environment["BAZ"]; ok {
t.Errorf("Expected BAZ not in web environment")
if v != "qux" {
t.Errorf("Expected BAZ to be qux, got %s", v)
}
}
}
func TestWithUnderscoreInContainerName(t *testing.T) {
composeFile := `
services:
web-app:
image: nginx:1.29
container_name: web_app_container
environment:
FOO: BAR
labels:
%s/values: |
- FOO
`
composeFile = fmt.Sprintf(composeFile, labels.Prefix())
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", "templates/web_app/deployment.yaml")
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
// find container.name
containerName := dt.Spec.Template.Spec.Containers[0].Name
if strings.Contains(containerName, "_") {
t.Errorf("Expected container name to not contain underscores, got %s", containerName)
}
}
func TestWithDashes(t *testing.T) {
composeFile := `
services:
web-app:
image: nginx:1.29
environment:
FOO: BAR
labels:
%s/values: |
- FOO
`
composeFile = fmt.Sprintf(composeFile, labels.Prefix())
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", "templates/web_app/deployment.yaml")
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
valuesFile := "./chart/values.yaml"
if _, err := os.Stat(valuesFile); os.IsNotExist(err) {
t.Errorf("values.yaml does not exist")
}
valuesContent, err := os.ReadFile(valuesFile)
if err != nil {
t.Errorf("Error reading values.yaml: %s", err)
}
mapping := struct {
Web struct {
Environment map[string]string `yaml:"environment"`
} `yaml:"web_app"`
}{}
if err := yaml3.Unmarshal(valuesContent, &mapping); err != nil {
t.Errorf(unmarshalError, err)
}
// we must have FOO in web_app environment (not web-app)
// this validates that the service name is converted to a valid k8s name
if v, ok := mapping.Web.Environment["FOO"]; !ok {
t.Errorf("Expected FOO in web_app environment")
if v != "BAR" {
t.Errorf("Expected FOO to be BAR, got %s", v)
}
}
}
func TestDashesWithValueFrom(t *testing.T) {
composeFile := `
services:
web-app:
image: nginx:1.29
environment:
FOO: BAR
labels:
%[1]s/values: |
- FOO
web2:
image: nginx:1.29
labels:
%[1]s/values-from: |
BAR: web-app.FOO
`
composeFile = fmt.Sprintf(composeFile, labels.Prefix())
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", "templates/web2/deployment.yaml")
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
valuesFile := "./chart/values.yaml"
if _, err := os.Stat(valuesFile); os.IsNotExist(err) {
t.Errorf("values.yaml does not exist")
}
valuesContent, err := os.ReadFile(valuesFile)
if err != nil {
t.Errorf("Error reading values.yaml: %s", err)
}
mapping := struct {
Web struct {
Environment map[string]string `yaml:"environment"`
} `yaml:"web_app"`
}{}
if err := yaml3.Unmarshal(valuesContent, &mapping); err != nil {
t.Errorf(unmarshalError, err)
}
// we must have FOO in web_app environment (not web-app)
// this validates that the service name is converted to a valid k8s name
if v, ok := mapping.Web.Environment["FOO"]; !ok {
t.Errorf("Expected FOO in web_app environment")
if v != "BAR" {
t.Errorf("Expected FOO to be BAR, got %s", v)
}
}
// ensure that the deployment has the value from the other service
barenv := dt.Spec.Template.Spec.Containers[0].Env[0]
if barenv.Value != "" {
t.Errorf("Expected value to be empty")
}
if barenv.ValueFrom == nil {
t.Errorf("Expected valueFrom to be set")
}
}
func TestCheckCommand(t *testing.T) {
composeFile := `
services:
web-app:
image: nginx:1.29
command:
- sh
- -c
- |-
echo "Hello, World!"
echo "Done"
`
// composeFile = fmt.Sprintf(composeFile, labels.Prefix())
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", "templates/web_app/deployment.yaml")
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
// find the command in the container
command := dt.Spec.Template.Spec.Containers[0].Command
if len(command) != 3 {
t.Errorf("Expected command to have 3 elements, got %d", len(command))
}
if command[0] != "sh" || command[1] != "-c" {
t.Errorf("Expected command to be 'sh -c', got %s", strings.Join(command, " "))
}
}

17
internal/generator/doc.go Normal file
View File

@@ -0,0 +1,17 @@
/*
Package generator generates kubernetes objects from a "compose" file and transforms them into a helm chart.
The generator package is the core of katenary. It is responsible for generating kubernetes objects from a compose file
and transforming them into a helm chart.
Conversion manipulates Yaml representation of kubernetes object to add conditions, labels, annotations, etc. to the
objects. It also create the values to be set to the values.yaml file.
The generate.Convert() create an HelmChart object and call "Generate()" method to convert from a compose file to a helm
chart. It saves the helm chart in the given directory.
If you want to change or override the write behavior, you can use the HelmChart.Generate() function and implement your
own write function. This function returns
the helm chart object containing all kubernetes objects and helm chart ingormation. It does not write the helm chart to
the disk.
*/
package generator

View File

@@ -0,0 +1,2 @@
/* Package extrafiles provides function to generate the Chart files that are not objects. Like README.md and notes.txt... */
package extrafiles

View File

@@ -0,0 +1,31 @@
package extrafiles
import (
_ "embed"
"fmt"
"strings"
)
//go:embed notes.tpl
var notesTemplate string
// NotesFile returns the content of the note.txt file.
func NotesFile(services []string) string {
// build a list of ingress URLs if there are any
ingresses := make([]string, len(services))
for i, service := range services {
condition := fmt.Sprintf(`{{- if and .Values.%[1]s.ingress .Values.%[1]s.ingress.enabled }}`, service)
line := fmt.Sprintf(`{{- $count = add1 $count -}}{{- $listOfURL = printf "%%s\n- http://%%s" $listOfURL (tpl .Values.%s.ingress.host .) -}}`, service)
ingresses[i] = fmt.Sprintf("%s\n%s\n{{- end }}", condition, line)
}
// inject the list of ingress URLs into the notes template
notes := strings.Split(notesTemplate, "\n")
for i, line := range notes {
if strings.Contains(line, "ingress_list") {
notes[i] = strings.Join(ingresses, "\n")
}
}
return strings.Join(notes, "\n")
}

View File

@@ -0,0 +1,39 @@
Thanks to have installed {{ .Chart.Name }} {{ .Chart.Version }} as {{ .Release.Name }} ({{.Chart.AppVersion }}).
# Get release information
To learn more about the release, try:
$ helm -n {{ .Release.Namespace }} status {{ .Release.Name }}
$ helm -n {{ .Release.Namespace }} get values {{ .Release.Name }}
$ helm -n {{ .Release.Namespace }} get all {{ .Release.Name }}
# To delete the release
Use helm uninstall command to delete the release.
$ helm -n {{ .Release.Namespace }} uninstall {{ .Release.Name }}
Note that some resources may still be in use after a release is deleted. For exemple, PersistentVolumeClaims are not deleted by default for some storage classes or if some annotations are set.
# More information
You can see this notes again by running:
$ helm -n {{ .Release.Namespace }} get notes {{ .Release.Name }}
{{- $count := 0 -}}
{{- $listOfURL := "" -}}
{{* DO NOT REMOVE, replaced by notes.go: ingress_list *}}
{{- if gt $count 0 }}
# List of activated ingresses URL:
{{ $listOfURL }}
You can get these urls with kubectl:
kubeclt get ingress -n {{ .Release.Namespace }}
{{- end }}
Thanks for using Helm!

View File

@@ -0,0 +1,40 @@
package extrafiles
import (
"strings"
"testing"
)
// override the embedded template for testing
var testTemplate = `
Some header
{{ ingress_list }}
Some footer
`
func init() {
notesTemplate = testTemplate
}
func TestNotesFile_NoServices(t *testing.T) {
result := NotesFile([]string{})
if !strings.Contains(result, "Some header") || !strings.Contains(result, "Some footer") {
t.Errorf("Expected template header/footer in output, got: %s", result)
}
}
func TestNotesFile_WithServices(t *testing.T) {
services := []string{"svc1", "svc2"}
result := NotesFile(services)
for _, svc := range services {
cond := "{{- if and .Values." + svc + ".ingress .Values." + svc + ".ingress.enabled }}"
line := "{{- $count = add1 $count -}}{{- $listOfURL = printf \"%s\\n- http://%s\" $listOfURL (tpl .Values." + svc + ".ingress.host .) -}}"
if !strings.Contains(result, cond) {
t.Errorf("Expected condition for service %s in output", svc)
}
if !strings.Contains(result, line) {
t.Errorf("Expected line for service %s in output", svc)
}
}
}

View File

@@ -0,0 +1,100 @@
package extrafiles
import (
"bytes"
_ "embed"
"fmt"
"log"
"sort"
"strings"
"text/template"
"gopkg.in/yaml.v3"
)
//go:embed readme.tpl
var readmeTemplate string
type chart struct {
Name string
Description string
Values []string
}
func parseValues(prefix string, values map[string]any, result map[string]string) {
for key, value := range values {
path := key
if prefix != "" {
path = prefix + "." + key
}
switch v := value.(type) {
case []any:
for i, u := range v {
parseValues(fmt.Sprintf("%s[%d]", path, i), map[string]any{"value": u}, result)
}
case map[string]any:
parseValues(path, v, result)
default:
strValue := fmt.Sprintf("`%v`", value)
result["`"+path+"`"] = strValue
}
}
}
// ReadMeFile returns the content of the README.md file.
func ReadMeFile(charname, description string, values map[string]any) string {
// values is a yaml structure with keys and structured values...
// we want to make list of dot separated keys and their values
vv := map[string]any{}
out, _ := yaml.Marshal(values)
if err := yaml.Unmarshal(out, &vv); err != nil {
log.Printf("Error parsing values: %s", err)
}
result := make(map[string]string)
parseValues("", vv, result)
funcMap := template.FuncMap{
"repeat": func(s string, count int) string {
return strings.Repeat(s, count)
},
}
tpl, err := template.New("readme").Funcs(funcMap).Parse(readmeTemplate)
if err != nil {
panic(err)
}
valuesLines := []string{}
maxParamLen := 0
maxDefaultLen := 0
for key, value := range result {
if len(key) > maxParamLen {
maxParamLen = len(key)
}
if len(value) > maxDefaultLen {
maxDefaultLen = len(value)
}
}
for key, value := range result {
valuesLines = append(valuesLines, fmt.Sprintf("| %-*s | %-*s |", maxParamLen, key, maxDefaultLen, value))
}
sort.Strings(valuesLines)
buf := &bytes.Buffer{}
err = tpl.Execute(buf, map[string]any{
"DescrptionPadding": maxParamLen,
"DefaultPadding": maxDefaultLen,
"Chart": chart{
Name: charname,
Description: description,
Values: valuesLines,
},
})
if err != nil {
panic(err)
}
return buf.String()
}

View File

@@ -0,0 +1,32 @@
# {{ .Chart.Name }}
{{ .Chart.Description }}
## Installing the Chart
To install the chart with the release name `my-release`:
```bash
# Standard Helm install
$ helm install my-release {{ .Chart.Name }}
# To use a custom namespace and force the creation of the namespace
$ helm install my-release --namespace my-namespace --create-namespace {{ .Chart.Name }}
# To use a custom values file
$ helm install my-release -f my-values.yaml {{ .Chart.Name }}
```
See the [Helm documentation](https://helm.sh/docs/intro/using_helm/) for more information on installing and managing the chart.
## Configuration
The following table lists the configurable parameters of the {{ .Chart.Name }} chart and their default values.
| {{ printf "%-*s" .DescrptionPadding "Parameter" }} | {{ printf "%-*s" .DefaultPadding "Default" }} |
| {{ repeat "-" .DescrptionPadding }} | {{ repeat "-" .DefaultPadding }} |
{{- range .Chart.Values }}
{{ . }}
{{- end }}

View File

@@ -0,0 +1,33 @@
package extrafiles
import (
"regexp"
"testing"
)
func TestReadMeFile_Basic(t *testing.T) {
values := map[string]any{
"replicas": 2,
"image": map[string]any{
"repository": "nginx",
"tag": "latest",
},
}
result := ReadMeFile("testchart", "A test chart", values)
t.Logf("Generated README content:\n%s", result)
paramerRegExp := regexp.MustCompile(`\|\s+` + "`" + `(.*?)` + "`" + `\s+\|\s+` + "`" + `(.*?)` + "`" + `\s+\|`)
matches := paramerRegExp.FindAllStringSubmatch(result, -1)
if len(matches) != 3 {
t.Errorf("Expected 5 lines in the table for headers and parameters, got %d", len(matches))
}
if matches[0][1] != "image.repository" || matches[0][2] != "nginx" {
t.Errorf("Expected third line to be image.repository, got %s", matches[1])
}
if matches[1][1] != "image.tag" || matches[1][2] != "latest" {
t.Errorf("Expected fourth line to be image.tag, got %s", matches[2])
}
if matches[2][1] != "replicas" || matches[2][2] != "2" {
t.Errorf("Expected second line to be replicas, got %s", matches[0])
}
}

View File

@@ -0,0 +1,433 @@
package generator
import (
"bytes"
"fmt"
"log"
"regexp"
"strings"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/generator/labels/labelstructs"
"github.com/katenary/katenary/internal/utils"
"github.com/compose-spec/compose-go/types"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)
// Generate a chart from a compose project.
// This does not write files to disk, it only creates the HelmChart object.
//
// The Generate function will create the HelmChart object this way:
//
// - Detect the service port name or leave the port number if not found.
// - Create a deployment for each service that are not ingnore.
// - Create a service and ingresses for each service that has ports and/or declared ingresses.
// - Create a PVC or Configmap volumes for each volume.
// - Create init containers for each service which has dependencies to other services.
// - Create a chart dependencies.
// - Create a configmap and secrets from the environment variables.
// - Merge the same-pod services.
func Generate(project *types.Project) (*HelmChart, error) {
var (
appName = project.Name
deployments = make(map[string]*Deployment, len(project.Services))
services = make(map[string]*Service)
podToMerge = make(map[string]*types.ServiceConfig)
)
chart := NewChart(appName)
// Add the compose files hash to the chart annotations
hash, err := utils.HashComposefiles(project.ComposeFiles)
if err != nil {
return nil, err
}
Annotations[labels.LabelName("compose-hash")] = hash
chart.composeHash = &hash
// drop all services with the "ignore" label
dropIngoredServices(project)
fixContainerNames(project)
// rename all services name to remove dashes
if err := fixResourceNames(project); err != nil {
return nil, err
}
// find the "main-app" label, and set chart.AppVersion to the tag if exists
mainCount := 0
for _, service := range project.Services {
if serviceIsMain(service) {
mainCount++
if mainCount > 1 {
return nil, fmt.Errorf("found more than one main app")
}
chart.setChartVersion(service)
}
}
if mainCount == 0 {
chart.AppVersion = "0.1.0"
}
// first pass, create all deployments whatewer they are.
for _, service := range project.Services {
err := chart.generateDeployment(service, deployments, services, podToMerge, appName)
if err != nil {
return nil, err
}
}
// now we have all deployments, we can create PVC if needed (it's separated from
// the above loop because we need all deployments to not duplicate PVC for "same-pod" services)
// bind static volumes
for _, service := range project.Services {
addStaticVolumes(deployments, service)
}
for _, service := range project.Services {
err := buildVolumes(service, chart, deployments)
if err != nil {
return nil, err
}
}
// if we have built exchange volumes, we need to moint them in each deployment
for _, d := range deployments {
d.MountExchangeVolumes()
}
// drop all "same-pod" deployments because the containers and volumes are already
// in the target deployment
for _, service := range podToMerge {
if samepod, ok := service.Labels[labels.LabelSamePod]; ok && samepod != "" {
// move this deployment volumes to the target deployment
if target, ok := deployments[samepod]; ok {
target.AddContainer(*service)
target.BindFrom(*service, deployments[service.Name])
target.SetEnvFrom(*service, appName, true)
// copy all init containers
initContainers := deployments[service.Name].Spec.Template.Spec.InitContainers
target.Spec.Template.Spec.InitContainers = append(target.Spec.Template.Spec.InitContainers, initContainers...)
delete(deployments, service.Name)
} else {
log.Printf("service %[1]s is declared as %[2]s, but %[2]s is not defined", service.Name, labels.LabelSamePod)
}
}
}
// create init containers for all DependsOn
for _, s := range project.Services {
for _, d := range s.GetDependencies() {
if dep, ok := deployments[d]; ok {
err := deployments[s.Name].DependsOn(dep, d)
if err != nil {
log.Printf("error creating init container for service %[1]s: %[2]s", s.Name, err)
}
} else {
log.Printf("service %[1]s depends on %[2]s, but %[2]s is not defined", s.Name, d)
}
}
}
// it's now time to get "value-from", before makeing the secrets and configmaps!
for _, s := range project.Services {
chart.setEnvironmentValuesFrom(s, deployments)
}
// generate configmaps with environment variables
if err := chart.generateConfigMapsAndSecrets(project); err != nil {
log.Fatalf("error generating configmaps and secrets: %s", err)
}
// if the env-from label is set, we need to add the env vars from the configmap
// to the environment of the service
for _, s := range project.Services {
chart.setSharedConf(s, deployments)
}
// remove all "boundEnv" from the values
for _, d := range deployments {
if len(d.boundEnvVar) == 0 {
continue
}
for _, e := range d.boundEnvVar {
delete(chart.Values[d.service.Name].(*Value).Environment, e)
}
}
// generate yaml files
for _, d := range deployments {
y, err := d.Yaml()
if err != nil {
return nil, err
}
chart.Templates[d.Filename()] = &ChartTemplate{
Content: y,
Servicename: d.service.Name,
}
}
// generate all services
for _, s := range services {
// add the service ports to the target service if it's a "same-pod" service
if samePod, ok := podToMerge[s.service.Name]; ok {
// get the target service
target := services[samePod.Name]
// merge the services
s.Spec.Ports = append(s.Spec.Ports, target.Spec.Ports...)
}
y, _ := s.Yaml()
chart.Templates[s.Filename()] = &ChartTemplate{
Content: y,
Servicename: s.service.Name,
}
}
// drop all "same-pod" services
for _, s := range podToMerge {
// get the target service
target := services[s.Name]
if target != nil {
delete(chart.Templates, target.Filename())
}
}
// compute all needed resplacements in YAML templates
for n, v := range chart.Templates {
v.Content = removeReplaceString(v.Content)
v.Content = computeNIndent(v.Content)
chart.Templates[n].Content = v.Content
}
// generate helper
chart.Helper = Helper(appName)
return chart, nil
}
// dropIngoredServices removes all services with the "ignore" label set to true (or yes).
func dropIngoredServices(project *types.Project) {
for i, service := range project.Services {
if isIgnored(service) {
project.Services = append(project.Services[:i], project.Services[i+1:]...)
}
}
}
// fixResourceNames renames all services and related resources to remove dashes.
func fixResourceNames(project *types.Project) error {
// rename all services name to remove dashes
for i, service := range project.Services {
if service.Name != utils.AsResourceName(service.Name) {
fixed := utils.AsResourceName(service.Name)
for j, s := range project.Services {
// for the same-pod services, we need to keep the original name
if samepod, ok := s.Labels[labels.LabelSamePod]; ok && samepod == service.Name {
s.Labels[labels.LabelSamePod] = fixed
project.Services[j] = s
}
// also, the value-from label should be updated
if valuefrom, ok := s.Labels[labels.LabelValuesFrom]; ok {
vf, err := labelstructs.GetValueFrom(valuefrom)
if err != nil {
return err
}
for varname, bind := range *vf {
log.Printf("service %s, varname %s, bind %s", service.Name, varname, bind)
bind := strings.ReplaceAll(bind, service.Name, fixed)
(*vf)[varname] = bind
}
output, err := yaml.Marshal(vf)
if err != nil {
return err
}
s.Labels[labels.LabelValuesFrom] = string(output)
}
}
service.Name = fixed
project.Services[i] = service
}
}
return nil
}
// serviceIsMain returns true if the service is the main app.
func serviceIsMain(service types.ServiceConfig) bool {
if main, ok := service.Labels[labels.LabelMainApp]; ok {
return main == "true" || main == "yes" || main == "1"
}
return false
}
func addStaticVolumes(deployments map[string]*Deployment, service types.ServiceConfig) {
// add the bound configMaps files to the deployment containers
var d *Deployment
var ok bool
if d, ok = deployments[service.Name]; !ok {
log.Printf("service %s not found in deployments", service.Name)
return
}
container, index := utils.GetContainerByName(service.ContainerName, d.Spec.Template.Spec.Containers)
if container == nil { // may append for the same-pod services
return
}
for volumeName, config := range d.configMaps {
var y []byte
var err error
if y, err = config.configMap.Yaml(); err != nil {
log.Fatal(err)
}
// add the configmap to the chart
d.chart.Templates[config.configMap.Filename()] = &ChartTemplate{
Content: y,
Servicename: d.service.Name,
}
// add the moint path to the container
for _, m := range config.mountPath {
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{
Name: utils.PathToName(volumeName),
MountPath: m.mountPath,
SubPath: m.subPath,
})
}
d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{
Name: utils.PathToName(volumeName),
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: config.configMap.Name,
},
},
},
})
}
d.Spec.Template.Spec.Containers[index] = *container
}
// computeNIndentm replace all __indent__ labels with the number of spaces before the label.
func computeNIndent(b []byte) []byte {
lines := bytes.Split(b, []byte("\n"))
for i, line := range lines {
if !bytes.Contains(line, []byte("__indent__")) {
continue
}
startSpaces := ""
spaces := regexp.MustCompile(`^\s+`).FindAllString(string(line), -1)
if len(spaces) > 0 {
startSpaces = spaces[0]
}
line = []byte(startSpaces + strings.TrimLeft(string(line), " "))
line = bytes.ReplaceAll(line, []byte("__indent__"), fmt.Appendf(nil, "%d", len(startSpaces)))
lines[i] = line
}
return bytes.Join(lines, []byte("\n"))
}
// removeReplaceString replace all __replace_ labels with the value of the
// capture group and remove all new lines and repeated spaces.
//
// we created:
//
// __replace_bar: '{{ include "foo.labels" .
// }}'
//
// note the new line and spaces...
//
// we now want to replace it with {{ include "foo.labels" . }}, without the label name.
func removeReplaceString(b []byte) []byte {
// replace all matches with the value of the capture group
// and remove all new lines and repeated spaces
b = replaceLabelRegexp.ReplaceAllFunc(b, func(b []byte) []byte {
inc := replaceLabelRegexp.FindSubmatch(b)[1]
inc = bytes.ReplaceAll(inc, []byte("\n"), []byte(""))
inc = bytes.ReplaceAll(inc, []byte("\r"), []byte(""))
inc = regexp.MustCompile(`\s+`).ReplaceAll(inc, []byte(" "))
return inc
})
return b
}
// buildVolumes creates the volumes for the service.
func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map[string]*Deployment) error {
appName := chart.Name
for _, v := range service.Volumes {
// Do not add volumes if the pod is injected in a deployments
// via "same-pod" and the volume in destination deployment exists
if samePodVolume(service, v, deployments) {
continue
}
switch v.Type {
case "volume":
v.Source = utils.AsResourceName(v.Source)
pvc := NewVolumeClaim(service, v.Source, appName)
// if the service is integrated in another deployment, we need to add the volume
// to the target deployment
if override, ok := service.Labels[labels.LabelSamePod]; ok {
pvc.nameOverride = override
pvc.Spec.StorageClassName = utils.StrPtr(`{{ .Values.` + override + `.persistence.` + v.Source + `.storageClass }}`)
chart.Values[override].(*Value).AddPersistence(v.Source)
}
y, _ := pvc.Yaml()
chart.Templates[pvc.Filename()] = &ChartTemplate{
Content: y,
Servicename: service.Name,
}
}
}
return nil
}
// samePodVolume returns true if the volume is already in the target deployment.
func samePodVolume(service types.ServiceConfig, v types.ServiceVolumeConfig, deployments map[string]*Deployment) bool {
// if the service has volumes, and it has "same-pod" label
// - get the target deployment
// - check if it has the same volume
// if not, return false
if v.Source == "" {
return false
}
if len(service.Volumes) == 0 {
return false
}
targetDeployment := ""
if targetName, ok := service.Labels[labels.LabelSamePod]; !ok {
return false
} else {
targetDeployment = targetName
}
// get the target deployment
target := findDeployment(targetDeployment, deployments)
if target == nil {
return false
}
// check if it has the same volume
for _, tv := range target.Spec.Template.Spec.Volumes {
if tv.Name == v.Source {
log.Printf("found same pod volume %s in deployment %s and %s", tv.Name, service.Name, targetDeployment)
return true
}
}
return false
}
func fixContainerNames(project *types.Project) {
// fix container names to be unique
for i, service := range project.Services {
if service.ContainerName == "" {
service.ContainerName = utils.FixedResourceName(service.Name)
} else {
service.ContainerName = utils.FixedResourceName(service.ContainerName)
}
project.Services[i] = service
}
}

View File

@@ -0,0 +1,20 @@
package generator
import (
"regexp"
"github.com/katenary/katenary/internal/generator/labels"
)
var (
// find all labels starting by __replace_ and ending with ":"
// and get the value between the quotes
// ?s => multiline
// (?P<inc>.+?) => named capture group to "inc" variable (so we could use $inc in the replace)
replaceLabelRegexp = regexp.MustCompile(`(?s)__replace_.+?: '(?P<inc>.+?)'`)
// Standard annotationss
Annotations = map[string]string{
labels.LabelName("version"): Version,
}
)

View File

@@ -0,0 +1,36 @@
{{- define "__APP__.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "__APP__.name" -}}
{{- if .Values.nameOverride -}}
{{- .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "__APP__.labels" -}}
{{ include "__APP__.selectorLabels" .}}
{{ if .Chart.Version -}}
{{ printf "__PREFIX__/chart-version: '%s'" .Chart.Version }}
{{- end }}
{{ if .Chart.AppVersion -}}
{{ printf "__PREFIX__/app-version: '%s'" .Chart.AppVersion }}
{{- end }}
{{- end -}}
{{- define "__APP__.selectorLabels" -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{ printf "__PREFIX__/name: %s" $name }}
{{ printf "__PREFIX__/instance: %s" .Release.Name }}
{{- end -}}

View File

@@ -0,0 +1,20 @@
package generator
import (
_ "embed"
"github.com/katenary/katenary/internal/generator/labels"
"strings"
)
// helmHelper is a template for the _helpers.tpl file in the chart templates directory.
//
//go:embed helmHelper.tpl
var helmHelper string
// Helper returns the _helpers.tpl file for a chart.
func Helper(name string) string {
helmHelper := strings.ReplaceAll(helmHelper, "__APP__", name)
helmHelper = strings.ReplaceAll(helmHelper, "__PREFIX__", labels.KatenaryLabelPrefix)
helmHelper = strings.ReplaceAll(helmHelper, "__VERSION__", "0.1.0")
return helmHelper
}

View File

@@ -0,0 +1,204 @@
package generator
import (
"log"
"strings"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/generator/labels/labelstructs"
"github.com/katenary/katenary/internal/utils"
"github.com/compose-spec/compose-go/types"
networkv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var _ Yaml = (*Ingress)(nil)
type Ingress struct {
*networkv1.Ingress
service *types.ServiceConfig `yaml:"-"`
appName string `yaml:"-"`
}
// NewIngress creates a new Ingress from a compose service.
func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress {
appName := Chart.Name
if service.Labels == nil {
service.Labels = make(map[string]string)
}
var label string
var ok bool
if label, ok = service.Labels[labels.LabelIngress]; !ok {
return nil
}
mapping, err := labelstructs.IngressFrom(label)
if err != nil {
log.Fatalf("Failed to parse ingress label: %s\n", err)
}
if mapping.Hostname == "" {
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 {
Chart.Values[service.Name] = &Value{}
}
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},
}
// ingressClassName := `{{ .Values.` + service.Name + `.ingress.class }}`
ingressClassName := utils.TplValue(service.Name, "ingress.class")
servicePortName := utils.GetServiceNameByPort(int(*mapping.Port))
ingressService := &networkv1.IngressServiceBackend{
Name: fullName,
Port: networkv1.ServiceBackendPort{},
}
if servicePortName != "" {
ingressService.Port.Name = servicePortName
} else {
ingressService.Port.Number = *mapping.Port
}
ing := &Ingress{
service: &service,
appName: appName,
Ingress: &networkv1.Ingress{
TypeMeta: metav1.TypeMeta{
Kind: "Ingress",
APIVersion: "networking.k8s.io/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: fullName,
Labels: GetLabels(serviceName, appName),
Annotations: Annotations,
},
Spec: networkv1.IngressSpec{
IngressClassName: &ingressClassName,
Rules: []networkv1.IngressRule{
{
Host: utils.TplValue(serviceName, "ingress.host"),
IngressRuleValue: networkv1.IngressRuleValue{
HTTP: &networkv1.HTTPIngressRuleValue{
Paths: []networkv1.HTTPIngressPath{
{
Path: utils.TplValue(serviceName, "ingress.path"),
PathType: &pathType,
Backend: networkv1.IngressBackend{
Service: ingressService,
},
},
},
},
},
},
},
TLS: []networkv1.IngressTLS{
{
Hosts: []string{
`{{ tpl .Values.` + serviceName + `.ingress.host . }}`,
},
SecretName: `{{ .Values.` + serviceName + `.ingress.tls.secretName | default $tlsname }}`,
},
},
},
},
}
return ing
}
func (ingress *Ingress) Filename() string {
return ingress.service.Name + ".ingress.yaml"
}
func (ingress *Ingress) Yaml() ([]byte, error) {
var ret []byte
var err error
if ret, err = ToK8SYaml(ingress); err != nil {
return nil, err
}
serviceName := ingress.service.Name
ret = UnWrapTPL(ret)
lines := strings.Split(string(ret), "\n")
// first pass, wrap the tls part with `{{- if .Values.serviceName.ingress.tlsEnabled -}}`
// and `{{- end -}}`
from, to, spaces := -1, -1, -1
for i, line := range lines {
if strings.Contains(line, "tls:") {
from = i
spaces = utils.CountStartingSpaces(line)
continue
}
if from > -1 {
if utils.CountStartingSpaces(line) >= spaces {
to = i
continue
}
}
}
if from > -1 && to > -1 {
lines[from] = strings.Repeat(" ", spaces) +
`{{- if .Values.` + serviceName + `.ingress.tls.enabled }}` +
"\n" +
lines[from]
lines[to] = strings.Repeat(" ", spaces) + `{{ end -}}`
}
out := []string{
`{{- if .Values.` + serviceName + `.ingress.enabled -}}`,
`{{- $fullname := include "` + ingress.appName + `.fullname" . -}}`,
`{{- $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
content := `` +
` {{- if .Values.` + serviceName + `.ingress.annotations -}}` + "\n" +
` {{- toYaml .Values.` + serviceName + `.ingress.annotations | nindent 4 }}` + "\n" +
` {{- end }}` + "\n" +
line
out = append(out, content)
} else if strings.Contains(line, "ingressClassName: ") {
content := utils.Wrap(
line,
`{{- if ne .Values.`+serviceName+`.ingress.class "-" }}`,
`{{- end }}`,
)
out = append(out, content)
} else {
out = append(out, line)
}
}
out = append(out, `{{- end -}}`)
ret = []byte(strings.Join(out, "\n"))
return ret, nil
}

View File

@@ -0,0 +1,128 @@
package generator
import (
"fmt"
"github.com/katenary/katenary/internal/generator/labels"
"os"
"testing"
v1 "k8s.io/api/networking/v1"
"sigs.k8s.io/yaml"
)
func TestSimpleIngress(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
ports:
- 80:80
- 443:443
labels:
%s/ingress: |-
hostname: my.test.tld
port: 80
`
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/ingress.yaml",
"--set", "web.ingress.enabled=true",
)
ingress := v1.Ingress{}
if err := yaml.Unmarshal([]byte(output), &ingress); err != nil {
t.Errorf(unmarshalError, err)
}
if len(ingress.Spec.Rules) != 1 {
t.Errorf("Expected 1 rule, got %d", len(ingress.Spec.Rules))
}
if ingress.Spec.Rules[0].Host != "my.test.tld" {
t.Errorf("Expected host to be my.test.tld, got %s", ingress.Spec.Rules[0].Host)
}
}
func TestTLS(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
ports:
- 80:80
- 443:443
labels:
%s/ingress: |-
hostname: my.test.tld
port: 80
`
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/ingress.yaml",
"--set", "web.ingress.enabled=true",
)
ingress := v1.Ingress{}
if err := yaml.Unmarshal([]byte(output), &ingress); err != nil {
t.Errorf(unmarshalError, err)
}
// find the tls section
tls := ingress.Spec.TLS
if len(tls) != 1 {
t.Errorf("Expected 1 tls section, got %d", len(tls))
}
}
func TestTLSName(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
ports:
- 80:80
- 443:443
labels:
%s/ingress: |-
hostname: my.test.tld
port: 80
`
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/ingress.yaml",
"--set", "web.ingress.enabled=true",
"--set", "web.ingress.tls.secretName=mysecret",
)
ingress := v1.Ingress{}
if err := yaml.Unmarshal([]byte(output), &ingress); err != nil {
t.Errorf(unmarshalError, err)
}
// find the tls section
tls := ingress.Spec.TLS
if len(tls) != 1 {
t.Errorf("Expected 1 tls section, got %d", len(tls))
}
if tls[0].SecretName != "mysecret" {
t.Errorf("Expected secretName to be mysecret, got %s", tls[0].SecretName)
}
}

View File

@@ -0,0 +1,10 @@
/*
Package katenaryfile is a package for reading and writing katenary files.
A katenary file, named "katenary.yml" or "katenary.yaml", is a file where you can define the
configuration of the conversion avoiding the use of labels in the compose file.
Formely, the file describe the same structure as in labels, and so that can be validated and
completed by LSP. It also ease the use of katenary.
*/
package katenaryfile

View File

@@ -0,0 +1,165 @@
package katenaryfile
import (
"bytes"
"encoding/json"
"fmt"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/generator/labels/labelstructs"
"github.com/katenary/katenary/internal/utils"
"log"
"os"
"reflect"
"strings"
"github.com/compose-spec/compose-go/types"
"github.com/invopop/jsonschema"
"gopkg.in/yaml.v3"
)
var allowedKatenaryYamlFileNames = []string{"katenary.yaml", "katenary.yml"}
// StringOrMap is a struct that can be either a string or a map of strings.
// It's a helper struct to unmarshal the katenary.yaml file and produce the schema
type StringOrMap any
// Service is a struct that contains the service configuration for katenary
type Service struct {
MainApp *bool `yaml:"main-app,omitempty" json:"main-app,omitempty" jsonschema:"title=Is this service the main application"`
Values []StringOrMap `yaml:"values,omitempty" json:"values,omitempty" jsonschema:"description=Environment variables to be set in values.yaml with or without a description"`
Secrets *labelstructs.Secrets `yaml:"secrets,omitempty" json:"secrets,omitempty" jsonschema:"title=Secrets,description=Environment variables to be set as secrets"`
Ports *labelstructs.Ports `yaml:"ports,omitempty" json:"ports,omitempty" jsonschema:"title=Ports,description=Ports to be exposed in services"`
Ingress *labelstructs.Ingress `yaml:"ingress,omitempty" json:"ingress,omitempty" jsonschema:"title=Ingress,description=Ingress configuration"`
HealthCheck *labelstructs.HealthCheck `yaml:"health-check,omitempty" json:"health-check,omitempty" jsonschema:"title=Health Check,description=Health check configuration that respects the kubernetes api"`
SamePod *string `yaml:"same-pod,omitempty" json:"same-pod,omitempty" jsonschema:"title=Same Pod,description=Service that should be in the same pod"`
Description *string `yaml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Description of the service that will be injected in the values.yaml file"`
Ignore *bool `yaml:"ignore,omitempty" json:"ignore,omitempty" jsonschema:"title=Ignore,description=Ignore the service in the conversion"`
Dependencies []labelstructs.Dependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty" jsonschema:"title=Dependencies,description=Services that should be injected in the Chart.yaml file"`
ConfigMapFiles *labelstructs.ConfigMapFiles `yaml:"configmap-files,omitempty" json:"configmap-files,omitempty" jsonschema:"title=ConfigMap Files,description=Files that should be injected as ConfigMap"`
MapEnv *labelstructs.MapEnv `yaml:"map-env,omitempty" json:"map-env,omitempty" jsonschema:"title=Map Env,description=Map environment variables to another value"`
CronJob *labelstructs.CronJob `yaml:"cron-job,omitempty" json:"cron-job,omitempty" jsonschema:"title=Cron Job,description=Cron Job configuration"`
EnvFrom *labelstructs.EnvFrom `yaml:"env-from,omitempty" json:"env-from,omitempty" jsonschema:"title=Env From,description=Inject environment variables from another service"`
ExchangeVolumes []*labelstructs.ExchangeVolume `yaml:"exchange-volumes,omitempty" json:"exchange-volumes,omitempty" jsonschema:"title=Exchange Volumes,description=Exchange volumes between services"`
ValuesFrom *labelstructs.ValueFrom `yaml:"values-from,omitempty" json:"values-from,omitempty" jsonschema:"title=Values From,description=Inject values from another service (secret or configmap environment variables)"`
}
// OverrideWithConfig overrides the project with the katenary.yaml file. It
// will set the labels of the services with the values from the katenary.yaml file.
// It work in memory, so it will not modify the original project.
func OverrideWithConfig(project *types.Project) {
var yamlFile string
var err error
for _, yamlFile = range allowedKatenaryYamlFileNames {
_, err = os.Stat(yamlFile)
if err == nil {
break
}
}
if err != nil {
// no katenary file found
return
}
fmt.Println(utils.IconInfo, "Using katenary file", yamlFile)
services := make(map[string]Service)
fp, err := os.Open(yamlFile)
if err != nil {
return
}
if err := yaml.NewDecoder(fp).Decode(&services); err != nil {
log.Fatal(err)
return
}
for i, p := range project.Services {
name := p.Name
if project.Services[i].Labels == nil {
project.Services[i].Labels = make(map[string]string)
}
mustGetLabelContent := func(o any, s *types.ServiceConfig, labelName string) {
err := getLabelContent(o, s, labelName)
if err != nil {
log.Fatal(err)
}
}
if s, ok := services[name]; ok {
mustGetLabelContent(s.MainApp, &project.Services[i], labels.LabelMainApp)
mustGetLabelContent(s.Values, &project.Services[i], labels.LabelValues)
mustGetLabelContent(s.Secrets, &project.Services[i], labels.LabelSecrets)
mustGetLabelContent(s.Ports, &project.Services[i], labels.LabelPorts)
mustGetLabelContent(s.Ingress, &project.Services[i], labels.LabelIngress)
mustGetLabelContent(s.HealthCheck, &project.Services[i], labels.LabelHealthCheck)
mustGetLabelContent(s.SamePod, &project.Services[i], labels.LabelSamePod)
mustGetLabelContent(s.Description, &project.Services[i], labels.LabelDescription)
mustGetLabelContent(s.Ignore, &project.Services[i], labels.LabelIgnore)
mustGetLabelContent(s.Dependencies, &project.Services[i], labels.LabelDependencies)
mustGetLabelContent(s.ConfigMapFiles, &project.Services[i], labels.LabelConfigMapFiles)
mustGetLabelContent(s.MapEnv, &project.Services[i], labels.LabelMapEnv)
mustGetLabelContent(s.CronJob, &project.Services[i], labels.LabelCronJob)
mustGetLabelContent(s.EnvFrom, &project.Services[i], labels.LabelEnvFrom)
mustGetLabelContent(s.ExchangeVolumes, &project.Services[i], labels.LabelExchangeVolume)
mustGetLabelContent(s.ValuesFrom, &project.Services[i], labels.LabelValuesFrom)
}
}
fmt.Println(utils.IconInfo, "Katenary file loaded successfully, the services are now configured.")
}
func getLabelContent(o any, service *types.ServiceConfig, labelName string) error {
if reflect.ValueOf(o).IsZero() {
return nil
}
c, err := yaml.Marshal(o)
if err != nil {
log.Println(err)
return err
}
val := strings.TrimSpace(string(c))
if labelName == labels.LabelIngress {
// special case, values must be set from some defaults
ing, err := labelstructs.IngressFrom(val)
if err != nil {
log.Fatal(err)
return err
}
c, err := yaml.Marshal(ing)
if err != nil {
return err
}
val = strings.TrimSpace(string(c))
}
service.Labels[labelName] = val
return nil
}
// GenerateSchema generates the schema for the katenary.yaml file.
func GenerateSchema() string {
s := jsonschema.Reflect(map[string]Service{})
// redefine the IntOrString type from k8s
s.Definitions["IntOrString"] = &jsonschema.Schema{
OneOf: []*jsonschema.Schema{
{Type: "integer"},
{Type: "string"},
},
}
// same for the StringOrMap type, that can be either a string or a map of string:string
s.Definitions["StringOrMap"] = &jsonschema.Schema{
OneOf: []*jsonschema.Schema{
{Type: "string"},
{Type: "object", AdditionalProperties: &jsonschema.Schema{Type: "string"}},
},
}
c, _ := s.MarshalJSON()
// indent the json
var out bytes.Buffer
err := json.Indent(&out, c, "", " ")
if err != nil {
return err.Error()
}
return out.String()
}

View File

@@ -0,0 +1,180 @@
package katenaryfile
import (
"github.com/katenary/katenary/internal/generator/labels"
"log"
"os"
"path/filepath"
"testing"
"github.com/compose-spec/compose-go/cli"
)
func TestBuildSchema(t *testing.T) {
sh := GenerateSchema()
if len(sh) == 0 {
t.Errorf("Expected schema to be defined")
}
}
func TestOverrideProjectWithKatenaryFile(t *testing.T) {
composeContent := `
services:
webapp:
image: nginx:latest
`
katenaryfileContent := `
webapp:
ports:
- 80
`
// create /tmp/katenary-test-override directory, save the compose.yaml file
tmpDir, err := os.MkdirTemp("", "katenary-test-override")
if err != nil {
t.Fatalf("Failed to create temp directory: %s", err.Error())
}
composeFile := filepath.Join(tmpDir, "compose.yaml")
katenaryFile := filepath.Join(tmpDir, "katenary.yaml")
os.MkdirAll(tmpDir, 0755)
if err := os.WriteFile(composeFile, []byte(composeContent), 0644); err != nil {
t.Log(err)
}
if err := os.WriteFile(katenaryFile, []byte(katenaryfileContent), 0644); err != nil {
t.Log(err)
}
defer os.RemoveAll(tmpDir)
c, _ := os.ReadFile(composeFile)
log.Println(string(c))
// chand dir to this directory
os.Chdir(tmpDir)
options, _ := cli.NewProjectOptions(nil,
cli.WithWorkingDirectory(tmpDir),
cli.WithDefaultConfigPath,
)
project, err := cli.ProjectFromOptions(options)
if err != nil {
t.Fatalf("Failed to create project from options: %s", err.Error())
}
OverrideWithConfig(project)
w := project.Services[0].Labels
if v, ok := w[labels.LabelPorts]; !ok {
t.Fatal("Expected ports to be defined", v)
}
}
func TestOverrideProjectWithIngress(t *testing.T) {
composeContent := `
services:
webapp:
image: nginx:latest
`
katenaryfileContent := `
webapp:
ports:
- 80
ingress:
port: 80
`
// create /tmp/katenary-test-override directory, save the compose.yaml file
tmpDir, err := os.MkdirTemp("", "katenary-test-override")
if err != nil {
t.Fatalf("Failed to create temp directory: %s", err.Error())
}
composeFile := filepath.Join(tmpDir, "compose.yaml")
katenaryFile := filepath.Join(tmpDir, "katenary.yaml")
os.MkdirAll(tmpDir, 0755)
if err := os.WriteFile(composeFile, []byte(composeContent), 0644); err != nil {
t.Log(err)
}
if err := os.WriteFile(katenaryFile, []byte(katenaryfileContent), 0644); err != nil {
t.Log(err)
}
defer os.RemoveAll(tmpDir)
c, _ := os.ReadFile(composeFile)
log.Println(string(c))
// chand dir to this directory
os.Chdir(tmpDir)
options, _ := cli.NewProjectOptions(nil,
cli.WithWorkingDirectory(tmpDir),
cli.WithDefaultConfigPath,
)
project, err := cli.ProjectFromOptions(options)
if err != nil {
t.Fatalf("Failed to create project from options: %s", err.Error())
}
OverrideWithConfig(project)
w := project.Services[0].Labels
if v, ok := w[labels.LabelPorts]; !ok {
t.Fatal("Expected ports to be defined", v)
}
if v, ok := w[labels.LabelIngress]; !ok {
t.Fatal("Expected ingress to be defined", v)
}
}
func TestOverrideConfigMapFiles(t *testing.T) {
composeContent := `
services:
webapp:
image: nginx:latest
`
katenaryfileContent := `
webapp:
configmap-files:
- foo/bar
ports:
- 80
ingress:
port: 80
`
// create /tmp/katenary-test-override directory, save the compose.yaml file
tmpDir, err := os.MkdirTemp("", "katenary-test-override")
if err != nil {
t.Fatalf("Failed to create temp directory: %s", err.Error())
}
composeFile := filepath.Join(tmpDir, "compose.yaml")
katenaryFile := filepath.Join(tmpDir, "katenary.yaml")
os.MkdirAll(tmpDir, 0755)
if err := os.WriteFile(composeFile, []byte(composeContent), 0644); err != nil {
t.Log(err)
}
if err := os.WriteFile(katenaryFile, []byte(katenaryfileContent), 0644); err != nil {
t.Log(err)
}
defer os.RemoveAll(tmpDir)
c, _ := os.ReadFile(composeFile)
log.Println(string(c))
// chand dir to this directory
os.Chdir(tmpDir)
options, _ := cli.NewProjectOptions(nil,
cli.WithWorkingDirectory(tmpDir),
cli.WithDefaultConfigPath,
)
project, err := cli.ProjectFromOptions(options)
if err != nil {
t.Fatalf("Failed to create project from options: %s", err.Error())
}
OverrideWithConfig(project)
w := project.Services[0].Labels
if v, ok := w[labels.LabelConfigMapFiles]; !ok {
t.Fatal("Expected configmap-files to be defined", v)
}
}

View File

@@ -0,0 +1,34 @@
package generator
import (
"fmt"
"github.com/katenary/katenary/internal/generator/labels"
)
var componentLabel = labels.LabelName("component")
// GetLabels returns the labels for a service. It uses the appName to replace the __replace__ in the labels.
// This is used to generate the labels in the templates.
func GetLabels(serviceName, appName string) map[string]string {
labels := map[string]string{
componentLabel: serviceName,
}
key := `{{- include "%s.labels" . | nindent __indent__ }}`
labels[`__replace_`+serviceName] = fmt.Sprintf(key, appName)
return labels
}
// GetMatchLabels returns the matchLabels for a service. It uses the appName to replace the __replace__ in the labels.
// This is used to generate the matchLabels in the templates.
func GetMatchLabels(serviceName, appName string) map[string]string {
labels := map[string]string{
componentLabel: serviceName,
}
key := `{{- include "%s.selectorLabels" . | nindent __indent__ }}`
labels[`__replace_`+serviceName] = fmt.Sprintf(key, appName)
return labels
}

View File

@@ -0,0 +1,2 @@
// Package labels provides functionality to parse and manipulate labels.
package labels

View File

@@ -0,0 +1,14 @@
## {{ .KatenaryPrefix }}/{{ .Name }}
{{ .Help.Short }}
**Type**: `{{ .Help.Type }}`
{{ .Help.Long }}
**Example:**
```yaml
{{ .Help.Example }}
```

View File

@@ -0,0 +1,9 @@
{{ .KatenaryPrefix }}/{{ .Name }}: {{ .Help.Short }}
Type: {{ .Help.Type }}
{{ .Help.Long }}
Example:
{{ .Help.Example }}

View File

@@ -0,0 +1,237 @@
package labels
import (
"bytes"
_ "embed"
"fmt"
"github.com/katenary/katenary/internal/utils"
"log"
"regexp"
"sort"
"strings"
"text/tabwriter"
"text/template"
"sigs.k8s.io/yaml"
)
const KatenaryLabelPrefix = "katenary.v3"
// Known labels.
const (
LabelMainApp Label = KatenaryLabelPrefix + "/main-app"
LabelValues Label = KatenaryLabelPrefix + "/values"
LabelSecrets Label = KatenaryLabelPrefix + "/secrets"
LabelPorts Label = KatenaryLabelPrefix + "/ports"
LabelIngress Label = KatenaryLabelPrefix + "/ingress"
LabelMapEnv Label = KatenaryLabelPrefix + "/map-env"
LabelHealthCheck Label = KatenaryLabelPrefix + "/health-check"
LabelSamePod Label = KatenaryLabelPrefix + "/same-pod"
LabelDescription Label = KatenaryLabelPrefix + "/description"
LabelIgnore Label = KatenaryLabelPrefix + "/ignore"
LabelDependencies Label = KatenaryLabelPrefix + "/dependencies"
LabelConfigMapFiles Label = KatenaryLabelPrefix + "/configmap-files"
LabelCronJob Label = KatenaryLabelPrefix + "/cronjob"
LabelEnvFrom Label = KatenaryLabelPrefix + "/env-from"
LabelExchangeVolume Label = KatenaryLabelPrefix + "/exchange-volumes"
LabelValuesFrom Label = KatenaryLabelPrefix + "/values-from"
)
var (
// Set the documentation of labels here
//
//go:embed katenaryLabelsDoc.yaml
labelFullHelpYAML []byte
// parsed yaml
labelFullHelp map[string]Help
//go:embed help-template.tpl
helpTemplatePlain string
//go:embed help-template.md.tpl
helpTemplateMarkdown string
)
// Label is a katenary label to find in compose files.
type Label = string
func LabelName(name string) Label {
return Label(KatenaryLabelPrefix + "/" + name)
}
// Help is the documentation of a label.
type Help struct {
Short string `yaml:"short"`
Long string `yaml:"long"`
Example string `yaml:"example"`
Type string `yaml:"type"`
}
// GetLabelNames returns a sorted list of all katenary label names.
func GetLabelNames() []string {
var names []string
for name := range labelFullHelp {
names = append(names, name)
}
sort.Strings(names)
return names
}
func init() {
if err := yaml.Unmarshal(labelFullHelpYAML, &labelFullHelp); err != nil {
panic(err)
}
}
// GetLabelHelp return the help for the labels.
func GetLabelHelp(asMarkdown bool) string {
names := GetLabelNames() // sorted
if !asMarkdown {
return generatePlainHelp(names)
}
return generateMarkdownHelp(names)
}
// GetLabelHelpFor returns the help for a specific label.
func GetLabelHelpFor(labelname string, asMarkdown bool) string {
help, ok := labelFullHelp[labelname]
if !ok {
return "No help available for " + labelname + "."
}
help.Long = strings.TrimPrefix(help.Long, "\n")
help.Example = strings.TrimPrefix(help.Example, "\n")
help.Short = strings.TrimPrefix(help.Short, "\n")
if asMarkdown {
// enclose templates in backticks
help.Long = regexp.MustCompile(`\{\{(.*?)\}\}`).ReplaceAllString(help.Long, "`{{$1}}`")
help.Long = strings.ReplaceAll(help.Long, "__APP__", "`__APP__`")
} else {
help.Long = strings.ReplaceAll(help.Long, " \n", "\n")
help.Long = strings.ReplaceAll(help.Long, "`", "")
help.Long = strings.ReplaceAll(help.Long, "<code>", "")
help.Long = strings.ReplaceAll(help.Long, "</code>", "")
help.Long = utils.WordWrap(help.Long, 80)
}
// get help template
var helpTemplate string
switch asMarkdown {
case true:
helpTemplate = helpTemplateMarkdown
case false:
helpTemplate = helpTemplatePlain
}
var buf bytes.Buffer
var err error
err = template.Must(template.New("shorthelp").Parse(help.Long)).Execute(&buf, struct {
KatenaryPrefix string
}{
KatenaryPrefix: KatenaryLabelPrefix,
})
if err != nil {
log.Fatalf("Error executing template: %v", err)
}
help.Long = buf.String()
buf.Reset()
err = template.Must(template.New("example").Parse(help.Example)).Execute(&buf, struct {
KatenaryPrefix string
}{
KatenaryPrefix: KatenaryLabelPrefix,
})
if err != nil {
log.Fatalf("Error executing template: %v", err)
}
help.Example = buf.String()
buf.Reset()
err = template.Must(template.New("complete").Parse(helpTemplate)).Execute(&buf, struct {
Name string
Help Help
KatenaryPrefix string
}{
Name: labelname,
Help: help,
KatenaryPrefix: KatenaryLabelPrefix,
})
if err != nil {
log.Fatalf("Error executing template: %v", err)
}
return buf.String()
}
func generateMarkdownHelp(names []string) string {
var builder strings.Builder
var maxNameLength, maxDescriptionLength, maxTypeLength int
max := func(a, b int) int {
if a > b {
return a
}
return b
}
for _, name := range names {
help := labelFullHelp[name]
maxNameLength = max(maxNameLength, len(name)+3+len(KatenaryLabelPrefix))
maxDescriptionLength = max(maxDescriptionLength, len(help.Short))
maxTypeLength = max(maxTypeLength, len(help.Type)+3)
}
fmt.Fprintf(&builder, "%s\n", generateTableHeader(maxNameLength, maxDescriptionLength, maxTypeLength))
fmt.Fprintf(&builder, "%s\n", generateTableHeaderSeparator(maxNameLength, maxDescriptionLength, maxTypeLength))
for _, name := range names {
help := labelFullHelp[name]
fmt.Fprintf(&builder, "| %-*s | %-*s | %-*s |\n",
maxNameLength, "`"+LabelName(name)+"`", // enclose in backticks
maxDescriptionLength, help.Short,
maxTypeLength, "`"+help.Type+"`",
)
}
return builder.String()
}
func generatePlainHelp(names []string) string {
var builder strings.Builder
for _, name := range names {
help := labelFullHelp[name]
fmt.Fprintf(&builder, "%s:\t%s\t%s\n", LabelName(name), help.Type, help.Short)
}
// use tabwriter to align the help text
buf := new(strings.Builder)
w := tabwriter.NewWriter(buf, 0, 8, 0, '\t', tabwriter.AlignRight)
fmt.Fprintln(w, builder.String())
w.Flush()
head := "To get more information about a label, use `katenary help-label <name_without_prefix>\ne.g. katenary help-label dependencies\n\n"
return head + buf.String()
}
func generateTableHeader(maxNameLength, maxDescriptionLength, maxTypeLength int) string {
return fmt.Sprintf(
"| %-*s | %-*s | %-*s |",
maxNameLength, "Label name",
maxDescriptionLength, "Description",
maxTypeLength, "Type",
)
}
func generateTableHeaderSeparator(maxNameLength, maxDescriptionLength, maxTypeLength int) string {
return fmt.Sprintf(
"| %s | %s | %s |",
strings.Repeat("-", maxNameLength),
strings.Repeat("-", maxDescriptionLength),
strings.Repeat("-", maxTypeLength),
)
}
func Prefix() string {
return KatenaryLabelPrefix
}

View File

@@ -0,0 +1,356 @@
# Labels documentation.
#
# To create a label documentation:
#
# "labelname":
# type: the label type (bool, string, array, object...)
# short: a short description
# long: |-
# A multiline description to explain the label behavior
# example: |-
# yamlsyntax: here
#
# This file is embed in the Katenary binary and parsed in kanetaryLabels.go init() function.
#
# Note:
# - The short and long texts are parsed with text/template, so you can use template syntax.
# That means that if you want to display double brackets, you need to enclose them to
# prevent template to try to expand the content, for example :
# This is an {{ "{{ example }}" }}.
#
# This will display "This is an {{ exemple }}" in the output.
# - Use {{ .KatenaryPrefix }} to let Katenary replace it with the label prefix (e.g. "katenary.v3")
"main-app":
short: "Mark the service as the main app."
long: |-
This makes the service to be the main application. Its image tag is
considered to be the Chart appVersion and to be the defaultvalue in Pod
container image attribute.
!!! Warning
This label cannot be repeated in others services. If this label is
set in more than one service as true, Katenary will return an error.
example: |-
ghost:
image: ghost:1.25.5
labels:
# The chart is now named ghost, and the appVersion is 1.25.5.
# In Deployment, the image attribute is set to ghost:1.25.5 if
# you don't change the "tag" attribute in values.yaml
{{ .KatenaryPrefix }}/main-app: true
type: "bool"
"values":
short: "Environment variables to be added to the values.yaml"
long: |-
By default, all environment variables in the "env" and environment
files are added to configmaps with the static values set. This label
allows adding environment variables to the values.yaml file.
Note that the value inside the configmap is {{ "{{ tpl vaname . }}" }}, so
you can set the value to a template that will be rendered with the
values.yaml file.
The value can be set with a documentation. This may help to understand
the purpose of the variable.
example: |-
env:
FOO: bar
DB_NAME: mydb
TO_CONFIGURE: something that can be changed in values.yaml
A_COMPLEX_VALUE: example
labels:
{{ .KatenaryPrefix }}/values: |-
# simple values, set as is in values.yaml
- TO_CONFIGURE
# complex values, set as a template in values.yaml with a documentation
- A_COMPLEX_VALUE: |-
This is the documentation for the variable to
configure in values.yaml.
It can be, of course, a multiline text.
type: "[]string or map[string]string"
"secrets":
short: "Env vars to be set as secrets."
long: |-
This label allows setting the environment variables as secrets. The variable
is removed from the environment and added to a secret object.
The variable can be set to the {{ printf "%s/%s" .KatenaryPrefix "values"}} too,
so the secret value can be configured in values.yaml
example: |-
env:
PASSWORD: a very secret password
NOT_A_SECRET: a public value
labels:
{{ .KatenaryPrefix }}/secrets: |-
- PASSWORD
type: "[]string"
"ports":
short: "Ports to be added to the service."
long: |-
Only useful for services without exposed port. It is mandatory if the
service is a dependency of another service.
example: |-
labels:
{{ .KatenaryPrefix }}/ports: |-
- 8080
- 8081
type: "[]uint32"
"ingress":
short: "Ingress rules to be added to the service."
long: |-
Declare an ingress rule for the service. The port should be exposed or
declared with {{ printf "%s/%s" .KatenaryPrefix "ports" }}.
example: |-
labels:
{{ .KatenaryPrefix }}/ingress: |-
port: 80
hostname: mywebsite.com (optional)
type: "object"
"map-env":
short: "Map env vars from the service to the deployment."
long: |-
Because you may need to change the variable for Kubernetes, this label
forces the value to another. It is also particullary helpful to use a template
value instead. For example, you could bind the value to a service name
with Helm attributes:
{{ "{{ tpl .Release.Name . }}" }}.
If you use __APP__ in the value, it will be replaced by the Chart name.
example: |-
env:
DB_HOST: database
RUNNING: docker
OTHER: value
labels:
{{ .KatenaryPrefix }}/map-env: |-
RUNNING: kubernetes
DB_HOST: '{{ "{{ include \"__APP__.fullname\" . }}" }}-database'
type: "map[string]string"
"health-check":
short: "Health check to be added to the deployment."
long: "Health check to be added to the deployment."
example: |-
labels:
{{ .KatenaryPrefix }}/health-check: |-
livenessProbe:
httpGet:
path: /health
port: 8080
type: "object"
"same-pod":
short: "Move the same-pod deployment to the target deployment."
long: |-
This will make the service to be included in another service pod. Some services
must work together in the same pod, like a sidecar or a proxy or nginx + php-fpm.
Note that volume and VolumeMount are copied from the source to the target
deployment.
example: |-
web:
image: nginx:1.19
php:
image: php:7.4-fpm
labels:
{{ .KatenaryPrefix }}/same-pod: web
type: "string"
"description":
short: "Description of the service"
long: |-
This replaces the default comment in values.yaml file to the given description.
It is useful to document the service and configuration.
The value can be set with a documentation in multiline format.
example: |-
labels:
{{ .KatenaryPrefix }}/description: |-
This is a description of the service.
It can be multiline.
type: "string"
"ignore":
short: "Ignore the service"
long: "Ingoring a service to not be exported in helm chart."
example: "labels:\n {{ .KatenaryPrefix }}/ignore: \"true\""
type: "bool"
"dependencies":
short: "Add Helm dependencies to the service."
long: |-
Set the service to be, actually, a Helm dependency. This means that the
service will not be exported as template. The dependencies are added to
the Chart.yaml file and the values are added to the values.yaml file.
It's a list of objects with the following attributes:
- name: the name of the dependency
- repository: the repository of the dependency
- alias: the name of the dependency in values.yaml (optional)
- values: the values to be set in values.yaml (optional)
!!! Info
Katenary doesn't update the helm depenedencies by default.
Use `--helm-update` (or `-u`) flag to update the dependencies.
example: <code>katenary convert -u</code>
By setting an alias, it is possible to change the name of the dependency
in values.yaml.
example: |-
labels:
{{ .KatenaryPrefix }}/dependencies: |-
- name: mariadb
repository: oci://registry-1.docker.io/bitnamicharts
## optional, it changes the name of the section in values.yaml
# alias: mydatabase
## optional, it adds the values to values.yaml
values:
auth:
database: mydatabasename
username: myuser
password: the secret password
type: "[]object"
"configmap-files":
short: "Inject files as Configmap."
long: |-
It makes a file or directory to be converted to one or more ConfigMaps
and mounted in the pod. The file or directory is relative to the
service directory.
If it is a directory, all files inside it are added to the ConfigMap.
If the directory as subdirectories, so one configmap per subpath are created.
!!! Warning
It is not intended to be used to store an entire project in configmaps.
It is intended to be used to store configuration files that are not managed
by the application, like nginx configuration files. Keep in mind that your
project sources should be stored in an application image or in a storage.
example: |-
volumes
- ./conf.d:/etc/nginx/conf.d
labels:
{{ .KatenaryPrefix }}/configmap-files: |-
- ./conf.d
type: "[]string"
"cronjob":
short: "Create a cronjob from the service."
long: |-
This adds a cronjob to the chart.
The label value is a YAML object with the following attributes:
- command: the command to be executed
- schedule: the cron schedule (cron format or @every where "every" is a
duration like 1h30m, daily, hourly...)
- rbac: false (optionnal), if true, it will create a role, a rolebinding and
a serviceaccount to make your cronjob able to connect the Kubernetes API
example: |-
labels:
{{ .KatenaryPrefix }}/cronjob: |-
command: echo "hello world"
schedule: "* */1 * * *" # or @hourly for example
type: "object"
"env-from":
short: "Add environment variables from another service."
type: "[]string"
long: |-
It adds environment variables from another service to the current service.
example: |-
service1:
image: nginx:1.19
environment:
FOO: bar
service2:
image: php:7.4-fpm
labels:
# get the congigMap from service1 where FOO is
# defined inside this service too
{{ .KatenaryPrefix }}/env-from: |-
- myservice1
"exchange-volumes":
short: Add exchange volumes (empty directory on the node) to share data
type: "[]object"
long: |-
This label allows sharing data between containres. The volume is created in
the node and mounted in the pod. It is useful to share data between containers
in a "same pod" logic. For example to let PHP-FPM and Nginx share the same direcotory.
This will create:
- an `emptyDir` volume in the deployment
- a `voumeMount` in the pod for **each container**
- a `initContainer` for each definition
Fields:
- name: the name of the volume (manadatory)
- mountPath: the path where the volume is mounted in the pod (optional, default is `/opt`)
- init: a command to run to initialize the volume with data (optional)
!!! Warning
This is highly experimental. This is mainly useful when using the "same-pod" label.
example: |-
nginx:
# ...
labels;
{{ .KatenaryPrefix }}/exchange-volumes: |-
- name: php-fpm
mountPath: /var/www/html
php:
# ...
labels:
{{ .KatenaryPrefix }}/exchange-volumes: |-
- name: php-fpm
mountPath: /opt
init: cp -ra /var/www/html/* /opt
"values-from":
short: "Add values from another service."
long: |-
This label allows adding values from another service to the current service.
It avoid duplicating values, environment or secrets that should be the same.
The key is the value to be added, and the value is the "key" to fetch in the
form `service_name.environment_name`.
type: "map[string]string"
example: |-
database:
image: mariadb:10.5
environment:
MARIADB_USER: myuser
MARIADB_PASSWORD: mypassword
labels:
# we can declare secrets
{{ .KatenaryPrefix }}/secrets: |-
- MARIADB_PASSWORD
php:
image: php:7.4-fpm
environment:
# it's duplicated in docker / podman
DB_USER: myuser
DB_PASSWORD: mypassword
labels:
# removes the duplicated, use the configMap and secrets from "database"
{{ .KatenaryPrefix }}/values-from: |-
DB_USER: database.MARIADB_USER
DB_PASSWORD: database.MARIADB_PASSWORD
# vim: ft=gotmpl.yaml

View File

@@ -0,0 +1,78 @@
package labels
import (
_ "embed"
"reflect"
"testing"
)
var testingKatenaryPrefix = Prefix()
const mainAppLabel = "main-app"
func TestPrefix(t *testing.T) {
tests := []struct {
name string
want string
}{
{
name: "TestPrefix",
want: "katenary.v3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Prefix(); got != tt.want {
t.Errorf("Prefix() = %v, want %v", got, tt.want)
}
})
}
}
func TestLabelName(t *testing.T) {
type args struct {
name string
}
tests := []struct {
name string
args args
want Label
}{
{
name: "Test_labelName",
args: args{
name: mainAppLabel,
},
want: testingKatenaryPrefix + "/" + mainAppLabel,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := LabelName(tt.args.name); !reflect.DeepEqual(got, tt.want) {
t.Errorf("labelName() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetLabelHelp(t *testing.T) {
help := GetLabelHelp(false)
if help == "" {
t.Errorf("GetLabelHelp() = %v, want %v", help, "Help")
}
help = GetLabelHelp(true)
if help == "" {
t.Errorf("GetLabelHelp() = %v, want %v", help, "Help")
}
}
func TestGetLabelHelpFor(t *testing.T) {
help := GetLabelHelpFor(mainAppLabel, false)
if help == "" {
t.Errorf("GetLabelHelpFor() = %v, want %v", help, "Help")
}
help = GetLabelHelpFor("main-app", true)
if help == "" {
t.Errorf("GetLabelHelpFor() = %v, want %v", help, "Help")
}
}

View File

@@ -0,0 +1,13 @@
package labelstructs
import "gopkg.in/yaml.v3"
type ConfigMapFiles []string
func ConfigMapFileFrom(data string) (ConfigMapFiles, error) {
var mapping ConfigMapFiles
if err := yaml.Unmarshal([]byte(data), &mapping); err != nil {
return nil, err
}
return mapping, nil
}

View File

@@ -0,0 +1,17 @@
package labelstructs_test
import (
"github.com/katenary/katenary/internal/generator/labels/labelstructs"
"testing"
)
func TestConfigMapFileFrom(t *testing.T) {
ts := "- foo/bar"
tc2, _ := labelstructs.ConfigMapFileFrom(ts)
if len(tc2) != 1 {
t.Errorf("Expected ConfigMapFile to have 1 item, got %d", len(tc2))
}
if tc2[0] != "foo/bar" {
t.Errorf("Expected ConfigMapFile to contain 'foo/bar', got %s", tc2[0])
}
}

View File

@@ -0,0 +1,18 @@
package labelstructs
import "gopkg.in/yaml.v3"
type CronJob struct {
Image string `yaml:"image,omitempty" json:"image,omitempty"`
Command string `yaml:"command" json:"command,omitempty"`
Schedule string `yaml:"schedule" json:"schedule,omitempty"`
Rbac bool `yaml:"rbac" json:"rbac,omitempty"`
}
func CronJobFrom(data string) (*CronJob, error) {
var mapping CronJob
if err := yaml.Unmarshal([]byte(data), &mapping); err != nil {
return nil, err
}
return &mapping, nil
}

View File

@@ -0,0 +1,25 @@
package labelstructs
import "testing"
func TestCronJobFrom(t *testing.T) {
ts := `
image: fooimage
command: thecommand
schedule: "0/3 0 * * *"
Rbac: false
`
tc, _ := CronJobFrom(ts)
if tc.Image != "fooimage" {
t.Errorf("Expected CronJob image to be 'fooimage', got %s", tc.Image)
}
if tc.Command != "thecommand" {
t.Errorf("Expected CronJob command to be 'thecommand', got %s", tc.Command)
}
if tc.Schedule != "0/3 0 * * *" {
t.Errorf("Expected CronJob schedule to be '0/3 0 * * *', got %s", tc.Schedule)
}
if tc.Rbac != false {
t.Errorf("Expected CronJob rbac to be false, got %t", tc.Rbac)
}
}

View File

@@ -0,0 +1,21 @@
package labelstructs
import "gopkg.in/yaml.v3"
// Dependency is a dependency of a chart to other charts.
type Dependency struct {
Values map[string]any `yaml:"-" json:"values,omitempty"`
Name string `yaml:"name" json:"name"`
Version string `yaml:"version" json:"version"`
Repository string `yaml:"repository" json:"repository"`
Alias string `yaml:"alias,omitempty" json:"alias,omitempty"`
}
// DependenciesFrom returns a slice of dependencies from the given string.
func DependenciesFrom(data string) ([]Dependency, error) {
var mapping []Dependency
if err := yaml.Unmarshal([]byte(data), &mapping); err != nil {
return nil, err
}
return mapping, nil
}

View File

@@ -0,0 +1,14 @@
package labelstructs
import "testing"
func TestDependenciesLabel(t *testing.T) {
ts := "- name: mongodb"
tc, _ := DependenciesFrom(ts)
if len(tc) != 1 {
t.Errorf("Expected DependenciesLabel to have 1 item, got %d", len(tc))
}
if tc[0].Name != "mongodb" {
t.Errorf("Expected DependenciesLabel to contain 'mongodb', got %s", tc[0].Name)
}
}

View File

@@ -0,0 +1,2 @@
// Package labelstructs is a package that contains the structs used to represent the labels in the yaml files.
package labelstructs

View File

@@ -0,0 +1,14 @@
package labelstructs
import "gopkg.in/yaml.v3"
type EnvFrom []string
// EnvFromFrom returns a EnvFrom from the given string.
func EnvFromFrom(data string) (EnvFrom, error) {
var mapping EnvFrom
if err := yaml.Unmarshal([]byte(data), &mapping); err != nil {
return nil, err
}
return mapping, nil
}

View File

@@ -0,0 +1,17 @@
package labelstructs
import "testing"
func TestEnvFromLabel(t *testing.T) {
ts := "- foo\n- bar"
tc, _ := EnvFromFrom(ts)
if len(tc) != 2 {
t.Errorf("Expected EnvFrom to have 2 items, got %d", len(tc))
}
if tc[0] != "foo" {
t.Errorf("Expected EnvFrom to contain 'foo', got %s", tc[0])
}
if tc[1] != "bar" {
t.Errorf("Expected EnvFrom to contain 'bar', got %s", tc[1])
}
}

View File

@@ -0,0 +1,20 @@
package labelstructs
import "gopkg.in/yaml.v3"
type ExchangeVolume struct {
Name string `yaml:"name" json:"name"`
MountPath string `yaml:"mountPath" json:"mountPath"`
Type string `yaml:"type,omitempty" json:"type,omitempty"`
Init string `yaml:"init,omitempty" json:"init,omitempty"`
}
func NewExchangeVolumes(data string) ([]*ExchangeVolume, error) {
mapping := []*ExchangeVolume{}
if err := yaml.Unmarshal([]byte(data), &mapping); err != nil {
return nil, err
}
return mapping, nil
}

View File

@@ -0,0 +1,17 @@
package labelstructs
import "testing"
func TestExchangeVolumeLabel(t *testing.T) {
ts := "- name: exchange-volume\n mountPath: /exchange\n readOnly: true"
tc, _ := NewExchangeVolumes(ts)
if len(tc) != 1 {
t.Errorf("Expected ExchangeVolumeLabel to have 1 item, got %d", len(tc))
}
if tc[0].Name != "exchange-volume" {
t.Errorf("Expected ExchangeVolumeLabel to contain 'exchange-volume', got %s", tc[0].Name)
}
if tc[0].MountPath != "/exchange" {
t.Errorf("Expected MountPath to be '/exchange', got %s", tc[0].MountPath)
}
}

View File

@@ -0,0 +1,41 @@
package labelstructs
import (
"fmt"
"github.com/katenary/katenary/internal/utils"
"gopkg.in/yaml.v3"
)
type TLS struct {
Enabled bool `yaml:"enabled" json:"enabled,omitempty"`
}
type Ingress struct {
Port *int32 `yaml:"port,omitempty" json:"port,omitempty"`
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:-"`
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
TLS *TLS `yaml:"tls,omitempty" json:"tls,omitempty"`
}
// IngressFrom creates a new Ingress from a compose service.
func IngressFrom(data string) (*Ingress, error) {
mapping := Ingress{
Hostname: "",
Path: utils.StrPtr("/"),
Enabled: false,
Class: utils.StrPtr("-"),
Port: nil,
TLS: &TLS{Enabled: true},
}
if err := yaml.Unmarshal([]byte(data), &mapping); err != nil {
return nil, err
}
if mapping.Port == nil {
return nil, fmt.Errorf("port is required in ingress definition")
}
return &mapping, nil
}

View File

@@ -0,0 +1,31 @@
package labelstructs
import "testing"
func TestIngressLabel(t *testing.T) {
ts := "\nhostname: example.com\npath: /\nenabled: true\nport: 8888"
tc, err := IngressFrom(ts)
if err != nil {
t.Errorf("Error parsing IngressLabel: %v", err)
}
if tc.Hostname != "example.com" {
t.Errorf("Expected IngressLabel to contain 'example.com', got %s", tc.Hostname)
}
if tc.Path == nil || *tc.Path != "/" {
t.Errorf("Expected IngressLabel to contain '/', got %v", tc.Path)
}
if tc.Enabled != true {
t.Errorf("Expected IngressLabel to be enabled, got %v", tc.Enabled)
}
if tc.Port == nil || *tc.Port != 8888 {
t.Errorf("Expected IngressLabel to have port 8888, got %d", tc.Port)
}
}
func TestIngressLabelNoPort(t *testing.T) {
ts := "\nhostname: example.com\npath: /\nenabled: true"
_, err := IngressFrom(ts)
if err == nil {
t.Errorf("Expected error when parsing IngressLabel without port, got nil")
}
}

View File

@@ -0,0 +1,14 @@
package labelstructs
import "gopkg.in/yaml.v3"
type MapEnv map[string]string
// MapEnvFrom returns a MapEnv from the given string.
func MapEnvFrom(data string) (MapEnv, error) {
var mapping MapEnv
if err := yaml.Unmarshal([]byte(data), &mapping); err != nil {
return nil, err
}
return mapping, nil
}

View File

@@ -0,0 +1,11 @@
package labelstructs
import "testing"
func TestConfigMapLabel(t *testing.T) {
ts := "foo: bar"
tc, _ := MapEnvFrom(ts)
if len(tc) != 1 {
t.Errorf("Expected ConfigMapFile to have 1 item, got %d", len(tc))
}
}

View File

@@ -0,0 +1,14 @@
package labelstructs
import "gopkg.in/yaml.v3"
type Ports []uint32
// PortsFrom returns a Ports from the given string.
func PortsFrom(data string) (Ports, error) {
var mapping Ports
if err := yaml.Unmarshal([]byte(data), &mapping); err != nil {
return nil, err
}
return mapping, nil
}

View File

@@ -0,0 +1,23 @@
package labelstructs
import "testing"
func TestPortsFromLabel(t *testing.T) {
data := "- 8080\n- 9090\n"
expected := Ports{8080, 9090}
ports, err := PortsFrom(data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(ports) != len(expected) {
t.Fatalf("expected length %d, got %d", len(expected), len(ports))
}
for i, port := range ports {
if port != expected[i] {
t.Errorf("expected port %d at index %d, got %d", expected[i], i, port)
}
}
}

View File

@@ -0,0 +1,56 @@
package labelstructs
import (
"encoding/json"
"log"
"gopkg.in/yaml.v3"
corev1 "k8s.io/api/core/v1"
)
type HealthCheck struct {
LivenessProbe *corev1.Probe `yaml:"livenessProbe,omitempty" json:"livenessProbe,omitempty"`
ReadinessProbe *corev1.Probe `yaml:"readinessProbe,omitempty" json:"readinessProbe,omitempty"`
}
func ProbeFrom(data string) (*HealthCheck, error) {
mapping := HealthCheck{}
tmp := map[string]any{}
err := yaml.Unmarshal([]byte(data), &tmp)
if err != nil {
return nil, err
}
if livenessProbe, ok := tmp["livenessProbe"]; ok {
livenessProbeBytes, err := json.Marshal(livenessProbe)
if err != nil {
log.Printf("Error marshalling livenessProbe: %v", err)
return nil, err
}
livenessProbe := &corev1.Probe{}
err = json.Unmarshal(livenessProbeBytes, livenessProbe)
if err != nil {
log.Printf("Error unmarshalling livenessProbe: %v", err)
return nil, err
}
mapping.LivenessProbe = livenessProbe
}
if readinessProbe, ok := tmp["readinessProbe"]; ok {
readinessProbeBytes, err := json.Marshal(readinessProbe)
if err != nil {
log.Printf("Error marshalling readinessProbe: %v", err)
return nil, err
}
readinessProbe := &corev1.Probe{}
err = json.Unmarshal(readinessProbeBytes, readinessProbe)
if err != nil {
log.Printf("Error unmarshalling readinessProbe: %v", err)
return nil, err
}
mapping.ReadinessProbe = readinessProbe
}
return &mapping, err
}

View File

@@ -0,0 +1,16 @@
package labelstructs
import "testing"
func TestProbesLabel(t *testing.T) {
readiness := "readinessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 5\n periodSeconds: 10"
tc, err := ProbeFrom(readiness)
if err != nil {
t.Errorf("Error parsing ProbesLabel: %v %v", err, tc)
}
liveness := "livenessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 5\n periodSeconds: 10"
tc2, err := ProbeFrom(liveness)
if err != nil {
t.Errorf("Error parsing ProbesLabel: %v %v", err, tc2)
}
}

View File

@@ -0,0 +1,13 @@
package labelstructs
import "gopkg.in/yaml.v3"
type Secrets []string
func SecretsFrom(data string) (Secrets, error) {
var mapping Secrets
if err := yaml.Unmarshal([]byte(data), &mapping); err != nil {
return nil, err
}
return mapping, nil
}

View File

@@ -0,0 +1,17 @@
package labelstructs
import "testing"
func TestSecretLabel(t *testing.T) {
data := "- foo\n- bar"
tc, err := SecretsFrom(data)
if err != nil {
t.Errorf("Error parsing SecretLabel: %v %v", err, tc)
}
items := []string{"foo", "bar"}
for i, item := range tc {
if item != items[i] {
t.Errorf("Expected SecretLabel to contain '%s', got '%s'", items[i], item)
}
}
}

View File

@@ -0,0 +1,13 @@
package labelstructs
import "gopkg.in/yaml.v3"
type ValueFrom map[string]string
func GetValueFrom(data string) (*ValueFrom, error) {
vf := ValueFrom{}
if err := yaml.Unmarshal([]byte(data), &vf); err != nil {
return nil, err
}
return &vf, nil
}

View File

@@ -0,0 +1,25 @@
package labelstructs
import (
"testing"
)
func TestValueFromLabel(t *testing.T) {
data := "data: foo\ndata2: bar"
tc, err := GetValueFrom(data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if tc == nil {
t.Fatalf("expected non-nil map, got nil")
}
if len(*tc) != 2 {
t.Errorf("expected 2 items, got %d", len(*tc))
}
if (*tc)["data"] != "foo" {
t.Errorf("expected 'data' to be 'foo', got %s", (*tc)["data"])
}
if (*tc)["data2"] != "bar" {
t.Errorf("expected 'data2' to be 'bar', got %s", (*tc)["data2"])
}
}

143
internal/generator/rbac.go Normal file
View File

@@ -0,0 +1,143 @@
package generator
import (
"github.com/katenary/katenary/internal/utils"
"github.com/compose-spec/compose-go/types"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
)
var (
_ Yaml = (*RoleBinding)(nil)
_ Yaml = (*Role)(nil)
_ Yaml = (*ServiceAccount)(nil)
)
// RBAC is a kubernetes RBAC containing a role, a rolebinding and an associated serviceaccount.
type RBAC struct {
RoleBinding *RoleBinding
Role *Role
ServiceAccount *ServiceAccount
}
// NewRBAC creates a new RBAC from a compose service. The appName is the name of the application taken from the project name.
func NewRBAC(service types.ServiceConfig, appName string) *RBAC {
role := &Role{
Role: &rbacv1.Role{
TypeMeta: metav1.TypeMeta{
Kind: "Role",
APIVersion: "rbac.authorization.k8s.io/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName),
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{"", "extensions", "apps"},
Resources: []string{"*"},
Verbs: []string{"*"},
},
},
},
service: &service,
}
rolebinding := &RoleBinding{
RoleBinding: &rbacv1.RoleBinding{
TypeMeta: metav1.TypeMeta{
Kind: "RoleBinding",
APIVersion: "rbac.authorization.k8s.io/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName),
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: utils.TplName(service.Name, appName),
Namespace: "{{ .Release.Namespace }}",
},
},
RoleRef: rbacv1.RoleRef{
Kind: "Role",
Name: utils.TplName(service.Name, appName),
APIGroup: "rbac.authorization.k8s.io",
},
},
service: &service,
}
serviceaccount := &ServiceAccount{
ServiceAccount: &corev1.ServiceAccount{
TypeMeta: metav1.TypeMeta{
Kind: "ServiceAccount",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName),
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
},
service: &service,
}
return &RBAC{
RoleBinding: rolebinding,
Role: role,
ServiceAccount: serviceaccount,
}
}
// RoleBinding is a kubernetes RoleBinding.
type RoleBinding struct {
*rbacv1.RoleBinding
service *types.ServiceConfig
}
func (r *RoleBinding) Filename() string {
return r.service.Name + ".rolebinding.yaml"
}
func (r *RoleBinding) Yaml() ([]byte, error) {
return yaml.Marshal(r)
}
// Role is a kubernetes Role.
type Role struct {
*rbacv1.Role
service *types.ServiceConfig
}
func (r *Role) Filename() string {
return r.service.Name + ".role.yaml"
}
func (r *Role) Yaml() ([]byte, error) {
if o, err := yaml.Marshal(r); err != nil {
return nil, err
} else {
return UnWrapTPL(o), nil
}
}
// ServiceAccount is a kubernetes ServiceAccount.
type ServiceAccount struct {
*corev1.ServiceAccount
service *types.ServiceConfig
}
func (r *ServiceAccount) Filename() string {
return r.service.Name + ".serviceaccount.yaml"
}
func (r *ServiceAccount) Yaml() ([]byte, error) {
return ToK8SYaml(r)
}

View File

@@ -0,0 +1,112 @@
package generator
import (
"encoding/base64"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/utils"
"strings"
"github.com/compose-spec/compose-go/types"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var (
_ DataMap = (*Secret)(nil)
_ Yaml = (*Secret)(nil)
)
// Secret is a kubernetes Secret.
//
// Implements the DataMap interface.
type Secret struct {
*corev1.Secret
service types.ServiceConfig `yaml:"-"`
}
// NewSecret creates a new Secret from a compose service
func NewSecret(service types.ServiceConfig, appName string) *Secret {
secret := &Secret{
service: service,
Secret: &corev1.Secret{
TypeMeta: metav1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName),
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Data: make(map[string][]byte),
},
}
// check if the value should be in values.yaml
valueList := []string{}
varDescriptons := utils.GetValuesFromLabel(service, labels.LabelValues)
for value := range varDescriptons {
valueList = append(valueList, value)
}
for _, value := range valueList {
if val, ok := service.Environment[value]; ok {
value = strings.TrimPrefix(value, `"`)
*val = `.Values.` + service.Name + `.environment.` + value
}
}
for key, value := range service.Environment {
if value == nil {
continue
}
secret.AddData(key, *value)
}
return secret
}
// AddData adds a key value pair to the secret.
func (s *Secret) AddData(key, value string) {
if value == "" {
return
}
valuesLabels := utils.GetValuesFromLabel(s.service, labels.LabelValues)
if _, ok := valuesLabels[key]; ok {
// the value should be in values.yaml
s.Data[key] = []byte(`{{ tpl .Values.` + s.service.Name + `.environment.` + key + ` $ | b64enc }}`)
} else {
encoded := base64.StdEncoding.EncodeToString([]byte(value))
s.Data[key] = []byte(encoded)
}
// s.Data[key] = []byte(`{{ tpl ` + value + ` $ | b64enc }}`)
}
// Filename returns the filename of the secret.
func (s *Secret) Filename() string {
return s.service.Name + ".secret.yaml"
}
// SetData sets the data of the secret.
func (s *Secret) SetData(data map[string]string) {
for key, value := range data {
s.AddData(key, value)
}
}
// Yaml returns the yaml representation of the secret.
func (s *Secret) Yaml() ([]byte, error) {
var y []byte
var err error
if y, err = ToK8SYaml(s); err != nil {
return nil, err
}
// replace the b64 value by the real value
for _, value := range s.Data {
encoded := base64.StdEncoding.EncodeToString([]byte(value))
y = []byte(strings.ReplaceAll(string(y), encoded, string(value)))
}
return y, nil
}

View File

@@ -0,0 +1,95 @@
package generator
import (
"bytes"
"fmt"
"github.com/katenary/katenary/internal/generator/labels"
"os"
"testing"
v1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)
func TestCreateSecretFromEnvironment(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
environment:
- FOO=bar
- BAR=baz
labels:
%s/secrets: |-
- BAR
`
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/secret.yaml")
secret := v1.Secret{}
if err := yaml.Unmarshal([]byte(output), &secret); err != nil {
t.Errorf(unmarshalError, err)
}
data := secret.Data
if len(data) != 1 {
t.Errorf("Expected 1 data, got %d", len(data))
}
// v1.Secret.Data is decoded, no problem
if string(data["BAR"]) != "baz" {
t.Errorf("Expected BAR to be baz, got %s", data["BAR"])
}
}
func TestCreateSecretFromEnvironmentWithValue(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
environment:
- FOO=bar
- BAR=baz
labels:
%[1]s/secrets: |-
- BAR
%[1]s/values: |-
- BAR
`
composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix)
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
force := false
outputDir := "./chart"
profiles := make([]string, 0)
helmdepUpdate := true
var appVersion *string
chartVersion := "0.1.0"
convertOptions := ConvertOptions{
Force: force,
OutputDir: outputDir,
Profiles: profiles,
HelmUpdate: helmdepUpdate,
AppVersion: appVersion,
ChartVersion: chartVersion,
}
if err := Convert(convertOptions, "compose.yml"); err != nil {
t.Fatalf("Failed to convert compose file: %s", err)
}
c, err := os.ReadFile("chart/values.yaml")
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(c, []byte("BAR: baz")) {
t.Errorf("Expected BAR to be baz, got %s", c)
}
}

View File

@@ -0,0 +1,96 @@
package generator
import (
"fmt"
"github.com/katenary/katenary/internal/utils"
"regexp"
"strings"
"github.com/compose-spec/compose-go/types"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
)
var _ Yaml = (*Service)(nil)
// Service is a kubernetes Service.
type Service struct {
*v1.Service `yaml:",inline"`
service *types.ServiceConfig `yaml:"-"`
}
// NewService creates a new Service from a compose service.
func NewService(service types.ServiceConfig, appName string) *Service {
ports := []v1.ServicePort{}
s := &Service{
service: &service,
Service: &v1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName),
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Spec: v1.ServiceSpec{
Selector: GetMatchLabels(service.Name, appName),
Ports: ports,
},
},
}
for _, port := range service.Ports {
s.AddPort(port)
}
return s
}
// AddPort adds a port to the service.
func (s *Service) AddPort(port types.ServicePortConfig, serviceName ...string) {
var name string
var finalport intstr.IntOrString
if targetPort := utils.GetServiceNameByPort(int(port.Target)); targetPort == "" {
finalport = intstr.FromInt(int(port.Target))
name = fmt.Sprintf("port-%d", port.Target)
} else {
finalport = intstr.FromString(targetPort)
name = targetPort
}
s.Spec.Ports = append(s.Spec.Ports, v1.ServicePort{
Protocol: v1.ProtocolTCP,
Port: int32(port.Target),
TargetPort: finalport,
Name: name,
})
}
// Filename returns the filename of the service.
func (s *Service) Filename() string {
return s.service.Name + ".service.yaml"
}
// Yaml returns the yaml representation of the service.
func (s *Service) Yaml() ([]byte, error) {
var y []byte
var err error
if y, err = ToK8SYaml(s); err != nil {
return nil, err
}
lines := []string{}
for line := range strings.SplitSeq(string(y), "\n") {
if regexp.MustCompile(`^\s*loadBalancer:\s*`).MatchString(line) {
continue
}
lines = append(lines, line)
}
y = []byte(strings.Join(lines, "\n"))
return y, err
}

View File

@@ -0,0 +1,83 @@
package generator
import (
"os"
"testing"
v1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)
func TestBasicService(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
ports:
- 80:80
- 443:443
`
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", "templates/web/service.yaml")
service := v1.Service{}
if err := yaml.Unmarshal([]byte(output), &service); err != nil {
t.Errorf(unmarshalError, err)
}
if len(service.Spec.Ports) != 2 {
t.Errorf("Expected 2 ports, got %d", len(service.Spec.Ports))
}
foundPort := 0
for _, port := range service.Spec.Ports {
if port.Port == 80 && port.TargetPort.StrVal == "http" {
foundPort++
}
if port.Port == 443 && port.TargetPort.StrVal == "https" {
foundPort++
}
}
if foundPort != 2 {
t.Errorf("Expected 2 ports, got %d", foundPort)
}
}
func TestWithSeveralUnknownPorts(t *testing.T) {
composeFile := `
services:
multi:
image: nginx
ports:
- 12443
- 12480
labels:
katenary.v3/ingress: |-
port: 12443
`
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", "templates/multi/service.yaml")
service := v1.Service{}
if err := yaml.Unmarshal([]byte(output), &service); err != nil {
t.Errorf(unmarshalError, err)
}
if len(service.Spec.Ports) != 2 {
t.Errorf("Expected 2 ports, got %d", len(service.Spec.Ports))
}
// ensure that both port names are different
if service.Spec.Ports[0].Name == service.Spec.Ports[1].Name {
t.Errorf("Expected different port names, got %s and %s", service.Spec.Ports[0].Name, service.Spec.Ports[1].Name)
}
}

View File

@@ -0,0 +1,80 @@
package generator
import (
"log"
"os"
"os/exec"
"testing"
"github.com/katenary/katenary/internal/parser"
)
const unmarshalError = "Failed to unmarshal the output: %s"
func setup(content string) string {
// write the _compose_file in temporary directory
tmpDir, err := os.MkdirTemp("", "katenary")
if err != nil {
panic(err)
}
os.WriteFile(tmpDir+"/compose.yml", []byte(content), 0o644)
return tmpDir
}
func teardown(tmpDir string) {
// remove the temporary directory
log.Println("Removing temporary directory: ", tmpDir)
if err := os.RemoveAll(tmpDir); err != nil {
panic(err)
}
}
func internalCompileTest(t *testing.T, options ...string) string {
_, err := parser.Parse(nil, nil, "compose.yml")
if err != nil {
t.Fatalf("Failed to parse the project: %s", err)
}
force := false
outputDir := "./chart"
profiles := make([]string, 0)
helmdepUpdate := true
var appVersion *string
chartVersion := "0.1.0"
convertOptions := ConvertOptions{
Force: force,
OutputDir: outputDir,
Profiles: profiles,
HelmUpdate: helmdepUpdate,
AppVersion: appVersion,
ChartVersion: chartVersion,
}
if err := Convert(convertOptions, "compose.yml"); err != nil {
log.Printf("Failed to convert: %s", err)
return err.Error()
}
// launch helm lint to check the generated chart
if helmLint(convertOptions) != nil {
t.Errorf("Failed to lint the generated chart")
}
// try with helm template
var output string
if output, err = helmTemplate(convertOptions, options...); err != nil {
t.Errorf("Failed to template the generated chart")
t.Fatalf("Output %s", output)
}
return output
}
func helmTemplate(options ConvertOptions, arguments ...string) (string, error) {
args := []string{"template", options.OutputDir}
args = append(args, arguments...)
cmd := exec.Command("helm", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), err
}
return string(output), nil
}

View File

@@ -0,0 +1,13 @@
package generator
// DataMap is a kubernetes ConfigMap or Secret. It can be used to add data to the ConfigMap or Secret.
type DataMap interface {
SetData(map[string]string)
AddData(string, string)
}
// Yaml is a kubernetes object that can be converted to yaml.
type Yaml interface {
Yaml() ([]byte, error)
Filename() string
}

View File

@@ -0,0 +1,97 @@
package generator
import (
"regexp"
"strconv"
"strings"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/generator/labels/labelstructs"
"github.com/katenary/katenary/internal/utils"
"github.com/compose-spec/compose-go/types"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)
var regexpLineWrap = regexp.MustCompile(`\n\s+}}`)
// findDeployment finds the corresponding target deployment for a service.
func findDeployment(serviceName string, deployments map[string]*Deployment) *Deployment {
for _, d := range deployments {
if d.service.Name == serviceName {
return d
}
}
return nil
}
// addConfigMapToService adds the configmap to the service.
func addConfigMapToService(serviceName, fromservice, chartName string, target *Deployment) {
for i, c := range target.Spec.Template.Spec.Containers {
if c.Name != serviceName {
continue
}
c.EnvFrom = append(c.EnvFrom, corev1.EnvFromSource{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: utils.TplName(fromservice, chartName),
},
},
})
target.Spec.Template.Spec.Containers[i] = c
}
}
// fixPorts checks the "ports" label from container and add it to the service.
func fixPorts(service *types.ServiceConfig) error {
// check the "ports" label from container and add it to the service
portsLabel := ""
ok := false
if portsLabel, ok = service.Labels[labels.LabelPorts]; !ok {
return nil
}
ports, err := labelstructs.PortsFrom(portsLabel)
if err != nil {
// maybe it's a string, comma separated
parts := strings.SplitSeq(portsLabel, ",")
for part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
port, err := strconv.ParseUint(part, 10, 32)
if err != nil {
return err
}
ports = append(ports, uint32(port))
}
}
for _, port := range ports {
service.Ports = append(service.Ports, types.ServicePortConfig{
Target: port,
})
}
return nil
}
// isIgnored returns true if the service is ignored.
func isIgnored(service types.ServiceConfig) bool {
if v, ok := service.Labels[labels.LabelIgnore]; ok {
return v == "true" || v == "yes" || v == "1"
}
return false
}
// UnWrapTPL removes the line wrapping from a template.
func UnWrapTPL(in []byte) []byte {
return regexpLineWrap.ReplaceAll(in, []byte(" }}"))
}
func ToK8SYaml(obj any) ([]byte, error) {
if o, err := yaml.Marshal(obj); err != nil {
return nil, nil
} else {
return UnWrapTPL(o), nil
}
}

View File

@@ -0,0 +1,106 @@
package generator
import (
"fmt"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/utils"
"os"
"path/filepath"
"testing"
"github.com/compose-spec/compose-go/cli"
)
func TestSplitPorts(t *testing.T) {
composeFileContent := `
services:
foo:
image: nginx:latest
labels:
%[1]s/ports: 80,443
`
composeFileContent = fmt.Sprintf(composeFileContent, labels.KatenaryLabelPrefix)
tmpDir, err := os.MkdirTemp("", "katenary-test-override")
if err != nil {
t.Fatal(err.Error())
}
composeFile := filepath.Join(tmpDir, "compose.yaml")
os.MkdirAll(tmpDir, utils.DirectoryPermission)
if err := os.WriteFile(composeFile, []byte(composeFileContent), 0644); err != nil {
t.Log(err)
}
defer os.RemoveAll(tmpDir)
// chand dir to this directory
os.Chdir(tmpDir)
options, _ := cli.NewProjectOptions(nil,
cli.WithWorkingDirectory(tmpDir),
cli.WithDefaultConfigPath,
)
project, err := cli.ProjectFromOptions(options)
if err != nil {
t.Fatal(err)
}
if err := fixPorts(&project.Services[0]); err != nil {
t.Errorf("Expected no error, got %s", err)
}
found := 0
for _, p := range project.Services[0].Ports {
switch p.Target {
case 80, 443:
found++
}
}
if found != 2 {
t.Errorf("Expected 2 ports, got %d", found)
}
}
func TestSplitPortsWithDefault(t *testing.T) {
composeFileContent := `
services:
foo:
image: nginx:latest
ports:
- 8080
labels:
%[1]s/ports: 80,443
`
composeFileContent = fmt.Sprintf(composeFileContent, labels.KatenaryLabelPrefix)
tmpDir, err := os.MkdirTemp("", "katenary-test-override")
if err != nil {
t.Fatal(err)
}
composeFile := filepath.Join(tmpDir, "compose.yaml")
os.MkdirAll(tmpDir, utils.DirectoryPermission)
if err := os.WriteFile(composeFile, []byte(composeFileContent), 0644); err != nil {
t.Log(err)
}
defer os.RemoveAll(tmpDir)
// chand dir to this directory
os.Chdir(tmpDir)
options, _ := cli.NewProjectOptions(nil,
cli.WithWorkingDirectory(tmpDir),
cli.WithDefaultConfigPath,
)
project, err := cli.ProjectFromOptions(options)
if err != nil {
t.Fatal(err)
}
if err := fixPorts(&project.Services[0]); err != nil {
t.Errorf("Expected no error, got %s", err)
}
found := 0
for _, p := range project.Services[0].Ports {
switch p.Target {
case 80, 443, 8080:
found++
}
}
if found != 3 {
t.Errorf("Expected 3 ports, got %d", found)
}
}

View File

@@ -0,0 +1,123 @@
package generator
import (
"strings"
"github.com/compose-spec/compose-go/types"
)
// RepositoryValue is a docker repository image and tag that will be saved in values.yaml.
type RepositoryValue struct {
Image string `yaml:"image"`
Tag string `yaml:"tag"`
}
// PersistenceValue is a persistence configuration that will be saved in values.yaml.
type PersistenceValue struct {
StorageClass string `yaml:"storageClass"`
Size string `yaml:"size"`
AccessMode []string `yaml:"accessMode"`
Enabled bool `yaml:"enabled"`
}
type TLS struct {
Enabled bool `yaml:"enabled"`
SecretName string `yaml:"secretName"`
}
// 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"`
}
// Value will be saved in values.yaml. It contains configuration for all deployment and services.
type Value struct {
Repository *RepositoryValue `yaml:"repository,omitempty"`
Persistence map[string]*PersistenceValue `yaml:"persistence,omitempty"`
Ingress *IngressValue `yaml:"ingress,omitempty"`
Environment map[string]any `yaml:"environment,omitempty"`
Replicas *uint32 `yaml:"replicas,omitempty"`
CronJob *CronJobValue `yaml:"cronjob,omitempty"`
NodeSelector map[string]string `yaml:"nodeSelector"`
Resources map[string]any `yaml:"resources"`
ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"`
ServiceAccount string `yaml:"serviceAccount"`
}
// NewValue creates a new Value from a compose service.
// The value contains the necessary information to deploy the service (image, tag, replicas, etc.).
//
// If `main` is true, the tag will be empty because
// it will be set in the helm chart appVersion.
func NewValue(service types.ServiceConfig, main ...bool) *Value {
replicas := uint32(1)
v := &Value{
Replicas: &replicas,
}
// find the image tag
tag := ""
split := strings.Split(service.Image, ":")
if len(split) == 1 {
v.Repository = &RepositoryValue{
Image: service.Image,
}
} else {
v.Repository = &RepositoryValue{
Image: strings.Join(split[:len(split)-1], ":"),
}
}
// for main service, the tag should the appVersion. So here we set it to empty.
if len(main) > 0 && !main[0] {
if len(split) > 1 {
tag = split[len(split)-1]
}
v.Repository.Tag = tag
} else {
v.Repository.Tag = ""
}
return v
}
func (v *Value) AddIngress(host, path string) {
v.Ingress = &IngressValue{
Enabled: true,
Host: host,
Path: path,
Class: "-",
TLS: TLS{
Enabled: true,
SecretName: "",
},
}
}
// AddPersistence adds persistence configuration to the Value.
func (v *Value) AddPersistence(volumeName string) {
volumeName = strings.ReplaceAll(volumeName, "-", "_")
if v.Persistence == nil {
v.Persistence = make(map[string]*PersistenceValue, 0)
}
v.Persistence[volumeName] = &PersistenceValue{
Enabled: true,
StorageClass: "-",
Size: "1Gi",
AccessMode: []string{"ReadWriteOnce"},
}
}
// CronJobValue is a cronjob configuration that will be saved in values.yaml.
type CronJobValue struct {
Repository *RepositoryValue `yaml:"repository,omitempty"`
Environment map[string]any `yaml:"environment,omitempty"`
ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"`
Schedule string `yaml:"schedule"`
}

View File

@@ -0,0 +1,28 @@
package generator
import (
"regexp"
"runtime/debug"
)
// Version is the version of katenary. It is set at compile time.
var Version = "master" // changed at compile time
// GetVersion return the version of katneary. It's important to understand that
// the version is set at compile time for the github release. But, it the user get
// katneary using `go install`, the version should be different.
func GetVersion() string {
// try to get the semantic version from the Version variable (theorically set at compile time)
if reg := regexp.MustCompile(`^v?\d+.\d+.\d+.*|^release-.*`); reg.MatchString(Version) {
return Version
}
// OK... let's try to get the version from the build info
// get the version from the build info (when installed with go install)
if v, ok := debug.ReadBuildInfo(); ok {
return v.Main.Version + "-" + v.GoVersion
}
// OK... none worked, so we return the default version
return Version
}

View File

@@ -0,0 +1,34 @@
package generator
import (
"strings"
"testing"
)
func TestVersion(t *testing.T) {
// we build on "devel" branch
v := GetVersion()
// by default, the version comes from build info and it's a development version
if !strings.Contains(v, "(devel)") {
t.Errorf("Expected version to be set, got %s", v)
}
// now, imagine we are on a release branch
Version = "1.0.0"
v = GetVersion()
if !strings.Contains(v, "1.0.0") {
t.Errorf("Expected version to be set, got %s", v)
}
// now, imagine we are on v1.0.0
Version = "v1.0.0"
v = GetVersion()
if !strings.Contains(v, "v1.0.0") {
t.Errorf("Expected version to be set, got %s", v)
}
// we can also compile a release branch
Version = "release-1.0.0"
v = GetVersion()
if !strings.Contains(v, "release-1.0.0") {
t.Errorf("Expected version to be set, got %s", v)
}
}

View File

@@ -0,0 +1,130 @@
package generator
import (
"github.com/katenary/katenary/internal/utils"
"strings"
"github.com/compose-spec/compose-go/types"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const persistenceKey = "persistence"
var _ Yaml = (*VolumeClaim)(nil)
// VolumeClaim is a kubernetes VolumeClaim. This is a PersistentVolumeClaim.
type VolumeClaim struct {
*v1.PersistentVolumeClaim
service *types.ServiceConfig `yaml:"-"`
volumeName string
nameOverride string
}
// NewVolumeClaim creates a new VolumeClaim from a compose service.
func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *VolumeClaim {
fixedName := utils.FixedResourceName(volumeName)
return &VolumeClaim{
volumeName: volumeName,
service: &service,
PersistentVolumeClaim: &v1.PersistentVolumeClaim{
TypeMeta: metav1.TypeMeta{
Kind: "PersistentVolumeClaim",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName) + "-" + fixedName,
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Spec: v1.PersistentVolumeClaimSpec{
AccessModes: []v1.PersistentVolumeAccessMode{
v1.ReadWriteOnce,
},
StorageClassName: utils.StrPtr(
`{{ .Values.` +
service.Name +
"." + persistenceKey +
"." + volumeName + `.storageClass }}`,
),
Resources: v1.VolumeResourceRequirements{
Requests: v1.ResourceList{
v1.ResourceStorage: resource.MustParse("1Gi"),
},
},
},
},
}
}
// Filename returns the suggested filename for a VolumeClaim.
func (v *VolumeClaim) Filename() string {
return v.service.Name + "." + v.volumeName + ".volumeclaim.yaml"
}
// Yaml marshals a VolumeClaim into yaml.
func (v *VolumeClaim) Yaml() ([]byte, error) {
var out []byte
var err error
if out, err = ToK8SYaml(v); err != nil {
return nil, err
}
serviceName := v.service.Name
if v.nameOverride != "" {
serviceName = v.nameOverride
}
volumeName := v.volumeName
// replace 1Gi to {{ .Values.serviceName.volume.size }}
out = []byte(
strings.Replace(
string(out),
"1Gi",
utils.TplValue(serviceName, persistenceKey+"."+volumeName+".size"),
1,
),
)
out = []byte(
strings.Replace(
string(out),
"- ReadWriteOnce",
"{{- .Values."+
serviceName+
"."+persistenceKey+
"."+volumeName+
".accessMode | toYaml | nindent __indent__ }}",
1,
),
)
lines := strings.Split(string(out), "\n")
for i, line := range lines {
if strings.Contains(line, "storageClass") {
lines[i] = utils.Wrap(
line,
"{{- if ne .Values."+
serviceName+
"."+persistenceKey+
"."+volumeName+".storageClass \"-\" }}",
"{{- end }}",
)
}
}
out = []byte(strings.Join(lines, "\n"))
// add condition
out = []byte(
"{{- if .Values." +
serviceName +
"." + persistenceKey +
"." + volumeName +
".enabled }}\n" +
string(out) +
"\n{{- end }}",
)
return out, nil
}

View File

@@ -0,0 +1,400 @@
package generator
import (
"fmt"
"image"
"image/color"
"image/png"
"github.com/katenary/katenary/internal/generator/labels"
"github.com/katenary/katenary/internal/utils"
"log"
"os"
"path/filepath"
"testing"
v1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)
const (
htmlContent = "<html><body><h1>Hello, World!</h1></body></html>"
developementFile = "templates/web/deployment.yaml"
indexHMLFile = "index.html"
)
func TestGenerateWithBoundVolume(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
volumes:
- data:/var/www
volumes:
data:
`
tmpDir := setup(composeFile)
defer teardown(tmpDir)
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", developementFile)
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
if dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name != "data" {
t.Errorf("Expected container volume name to be data: %v", dt)
}
}
func TestWithStaticFiles(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
volumes:
- ./static:/var/www
labels:
%s/configmap-files: |-
- ./static
`
composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix)
tmpDir := setup(composeFile)
defer teardown(tmpDir)
// create a static directory with an index.html file
staticDir := tmpDir + "/static"
os.Mkdir(staticDir, utils.DirectoryPermission)
indexFile, err := os.Create(staticDir + "/index.html")
if err != nil {
t.Errorf("Failed to create index.html: %s", err)
}
indexFile.WriteString(htmlContent)
indexFile.Close()
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", developementFile)
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
// get the volume mount path
volumeMountPath := dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].MountPath
if volumeMountPath != "/var/www" {
t.Errorf("Expected volume mount path to be /var/www, got %s", volumeMountPath)
}
// read the configMap
output, err = helmTemplate(ConvertOptions{
OutputDir: tmpDir + "/chart",
}, "-s", "templates/web/statics/static/configmap.yaml")
if err != nil {
t.Errorf("Failed to run helm template: %s", err)
}
configMap := corev1.ConfigMap{}
if err := yaml.Unmarshal([]byte(output), &configMap); err != nil {
t.Errorf(unmarshalError, err)
}
data := configMap.Data
if len(data) != 1 {
t.Errorf("Expected 1 data, got %d", len(data))
}
if data[indexHMLFile] != htmlContent {
t.Errorf("Expected index.html to be "+htmlContent+", got %s", data[indexHMLFile])
}
}
func TestWithFileMapping(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
volumes:
- ./static/index.html:/var/www/index.html
labels:
%s/configmap-files: |-
- ./static/index.html
`
composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix)
tmpDir := setup(composeFile)
defer teardown(tmpDir)
// create a static directory with an index.html file
staticDir := tmpDir + "/static"
os.Mkdir(staticDir, utils.DirectoryPermission)
indexFile, err := os.Create(staticDir + "/index.html")
if err != nil {
t.Errorf("Failed to create index.html: %s", err)
}
indexFile.WriteString(htmlContent)
indexFile.Close()
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
output := internalCompileTest(t, "-s", developementFile)
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
// get the volume mount path
volumeMountPath := dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].MountPath
if volumeMountPath != "/var/www/index.html" {
t.Errorf("Expected volume mount path to be /var/www/index.html, got %s", volumeMountPath)
}
// but this time, we need a subpath
subPath := dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].SubPath
if subPath != indexHMLFile {
t.Errorf("Expected subpath to be index.html, got %s", subPath)
}
}
func TestBinaryMount(t *testing.T) {
composeFile := `
services:
web:
image: nginx
volumes:
- ./images/foo.png:/var/www/foo
labels:
%[1]s/configmap-files: |-
- ./images/foo.png
`
composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix)
tmpDir := setup(composeFile)
log.Println(tmpDir)
defer teardown(tmpDir)
os.Mkdir(filepath.Join(tmpDir, "images"), utils.DirectoryPermission)
// create a png image
pngFile := tmpDir + "/images/foo.png"
w, h := 100, 100
img := image.NewRGBA(image.Rect(0, 0, w, h))
red := color.RGBA{255, 0, 0, 255}
for y := range h {
for x := range w {
img.Set(x, y, red)
}
}
blue := color.RGBA{0, 0, 255, 255}
for y := 30; y < 70; y++ {
for x := 30; x < 70; x++ {
img.Set(x, y, blue)
}
}
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
f, err := os.Create(pngFile)
if err != nil {
t.Fatal(err)
}
png.Encode(f, img)
f.Close()
output := internalCompileTest(t, "-s", developementFile)
d := v1.Deployment{}
yaml.Unmarshal([]byte(output), &d)
volumes := d.Spec.Template.Spec.Volumes
if len(volumes) != 1 {
t.Errorf("Expected 1 volume, got %d", len(volumes))
}
cm := corev1.ConfigMap{}
cmContent, err := helmTemplate(ConvertOptions{
OutputDir: "chart",
}, "-s", "templates/web/statics/images/configmap.yaml")
if err != nil {
t.Fatal(err)
}
yaml.Unmarshal([]byte(cmContent), &cm)
if im, ok := cm.BinaryData["foo.png"]; !ok {
t.Errorf("Expected foo.png to be in the configmap")
} else {
if len(im) == 0 {
t.Errorf("Expected image to be non-empty")
}
}
}
func TestGloballyBinaryMount(t *testing.T) {
composeFile := `
services:
web:
image: nginx
volumes:
- ./images:/var/www/foo
labels:
%[1]s/configmap-files: |-
- ./images
`
composeFile = fmt.Sprintf(composeFile, labels.KatenaryLabelPrefix)
tmpDir := setup(composeFile)
log.Println(tmpDir)
defer teardown(tmpDir)
os.Mkdir(filepath.Join(tmpDir, "images"), utils.DirectoryPermission)
// create a png image
pngFile := tmpDir + "/images/foo.png"
w, h := 100, 100
img := image.NewRGBA(image.Rect(0, 0, w, h))
red := color.RGBA{255, 0, 0, 255}
for y := range h {
for x := range w {
img.Set(x, y, red)
}
}
blue := color.RGBA{0, 0, 255, 255}
for y := 30; y < 70; y++ {
for x := 30; x < 70; x++ {
img.Set(x, y, blue)
}
}
currentDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(currentDir)
f, err := os.Create(pngFile)
if err != nil {
t.Fatal(err)
}
png.Encode(f, img)
f.Close()
output := internalCompileTest(t, "-s", developementFile)
d := v1.Deployment{}
yaml.Unmarshal([]byte(output), &d)
volumes := d.Spec.Template.Spec.Volumes
if len(volumes) != 1 {
t.Errorf("Expected 1 volume, got %d", len(volumes))
}
cm := corev1.ConfigMap{}
cmContent, err := helmTemplate(ConvertOptions{
OutputDir: "chart",
}, "-s", "templates/web/statics/images/configmap.yaml")
if err != nil {
t.Fatal(err)
}
yaml.Unmarshal([]byte(cmContent), &cm)
if im, ok := cm.BinaryData["foo.png"]; !ok {
t.Errorf("Expected foo.png to be in the configmap")
} else {
if len(im) == 0 {
t.Errorf("Expected image to be non-empty")
}
}
}
func TestBindFrom(t *testing.T) {
composeFile := `
services:
web:
image: nginx:1.29
volumes:
- data:/var/www
fpm:
image: php:fpm
volumes:
- data:/var/www
labels:
%[1]s/ports: |
- 9000
%[1]s/same-pod: web
volumes:
data:
`
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", developementFile)
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
// both containers should have the same volume mount
if dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name != "data" {
t.Errorf("Expected container 0 volume name to be data: %v", dt)
}
if dt.Spec.Template.Spec.Containers[1].VolumeMounts[0].Name != "data" {
t.Errorf("Expected container 1 volume name to be data: %v", dt)
}
}
func TestExchangeVolume(t *testing.T) {
composeFile := `
services:
app1:
image: nginx:1.29
labels:
%[1]s/exchange-volumes: |-
- name: data
mountPath: /var/www
app2:
image: foo:bar
labels:
%[1]s/same-pod: app1
%[1]s/exchange-volumes: |-
- name: data
mountPath: /opt
init: cp -r /var/www /opt
`
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/app1/deployment.yaml")
dt := v1.Deployment{}
if err := yaml.Unmarshal([]byte(output), &dt); err != nil {
t.Errorf(unmarshalError, err)
}
// the deployment should have a volume named "data"
volumes := dt.Spec.Template.Spec.Volumes
found := false
for v := range volumes {
if volumes[v].Name == "exchange-data" {
found = true
break
}
}
if !found {
t.Errorf("Expected volume name to be data: %v", volumes)
}
mounted := 0
// we should have a volume mount for both containers
containers := dt.Spec.Template.Spec.Containers
for c := range containers {
for _, vm := range containers[c].VolumeMounts {
if vm.Name == "exchange-data" {
mounted++
}
}
}
if mounted != 2 {
t.Errorf("Expected 2 mounted volumes, got %d", mounted)
}
}

57
internal/parser/main.go Normal file
View File

@@ -0,0 +1,57 @@
// Package parser is a wrapper around compose-go to parse compose files.
package parser
import (
"log"
"path/filepath"
"github.com/compose-spec/compose-go/cli"
"github.com/compose-spec/compose-go/types"
)
func init() {
// prepend compose.katenary.yaml to the list of default override file names
cli.DefaultOverrideFileNames = append([]string{
"compose.katenary.yml",
"compose.katenary.yaml",
}, cli.DefaultOverrideFileNames...)
// add podman-compose files
cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames,
[]string{
"podman-compose.katenary.yml",
"podman-compose.katenary.yaml",
"podman-compose.yml",
"podman-compose.yaml",
}...)
}
// Parse compose files and return a project. The project is parsed with dotenv, osenv and profiles.
func Parse(profiles []string, envFiles []string, dockerComposeFile ...string) (*types.Project, error) {
if len(dockerComposeFile) == 0 {
cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames, dockerComposeFile...)
}
// resolve absolute paths of envFiles
for i := range envFiles {
var err error
envFiles[i], err = filepath.Abs(envFiles[i])
if err != nil {
log.Fatal(err)
}
}
options, err := cli.NewProjectOptions(nil,
cli.WithProfiles(profiles),
cli.WithInterpolation(true),
cli.WithDefaultConfigPath,
cli.WithEnvFiles(envFiles...),
cli.WithOsEnv,
cli.WithDotEnv,
cli.WithNormalization(true),
cli.WithResolvedPaths(false),
)
if err != nil {
return nil, err
}
return cli.ProjectFromOptions(options)
}

View File

@@ -0,0 +1,60 @@
package parser
import (
"log"
"os"
"path/filepath"
"testing"
)
const composeFile = `
services:
app:
image: nginx:latest
`
func setupTest() (string, error) {
// write the composeFile to a temporary file
tmpDir, err := os.MkdirTemp("", "katenary-test-parse")
if err != nil {
return "", err
}
writeFile := filepath.Join(tmpDir, "compose.yaml")
writeErr := os.WriteFile(writeFile, []byte(composeFile), 0644)
return writeFile, writeErr
}
func tearDownTest(tmpDir string) {
if tmpDir != "" {
if err := os.RemoveAll(tmpDir); err != nil {
log.Fatalf("Failed to remove temporary directory %s: %s", tmpDir, err.Error())
}
}
}
func TestParse(t *testing.T) {
file, err := setupTest()
dirname := filepath.Dir(file)
currentDir, _ := os.Getwd()
if err := os.Chdir(dirname); err != nil {
t.Fatalf("Failed to change directory to %s: %s", dirname, err.Error())
}
defer func() {
tearDownTest(dirname)
if err := os.Chdir(currentDir); err != nil {
t.Fatalf("Failed to change back to original directory %s: %s", currentDir, err.Error())
}
}()
if err != nil {
t.Fatalf("Failed to setup test: %s", err.Error())
}
Project, err := Parse(nil, nil)
if err != nil {
t.Fatalf("Failed to parse compose file: %s", err.Error())
}
if Project == nil {
t.Fatal("Expected project to be not nil")
}
}

3
internal/utils/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package utils provides some utility functions used in katenary.
// It defines some constants and functions used in the whole project.
package utils

26
internal/utils/hash.go Normal file
View File

@@ -0,0 +1,26 @@
package utils
import (
"crypto/sha1"
"encoding/hex"
"io"
"os"
"sort"
)
// HashComposefiles returns a hash of the compose files.
func HashComposefiles(files []string) (string, error) {
sort.Strings(files) // ensure the order is always the same
sha := sha1.New()
for _, file := range files {
f, err := os.Open(file)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(sha, f); err != nil {
return "", err
}
}
return hex.EncodeToString(sha.Sum(nil)), nil
}

View File

@@ -0,0 +1,13 @@
package utils
import "testing"
func TestHash(t *testing.T) {
h, err := HashComposefiles([]string{"./hash.go"})
if err != nil {
t.Fatalf("failed to hash compose files: %v", err)
}
if len(h) == 0 {
t.Fatal("hash should not be empty")
}
}

31
internal/utils/icons.go Normal file
View File

@@ -0,0 +1,31 @@
package utils
import "fmt"
// Icon is a unicode icon
type Icon string
// Icons used in katenary.
const (
IconSuccess Icon = "✅"
IconFailure Icon = "❌"
IconWarning Icon = "❕"
IconNote Icon = "📝"
IconWorld Icon = "🌐"
IconPlug Icon = "🔌"
IconPackage Icon = "📦"
IconCabinet Icon = "🗄️"
IconInfo Icon = "🔵"
IconSecret Icon = "🔒"
IconConfig Icon = "🔧"
IconDependency Icon = "🔗"
)
// Warn prints a warning message
func Warn(msg ...any) {
orange := "\033[38;5;214m"
reset := "\033[0m"
fmt.Print(IconWarning, orange, " ")
fmt.Print(msg...)
fmt.Println(reset)
}

198
internal/utils/utils.go Normal file
View File

@@ -0,0 +1,198 @@
package utils
import (
"bytes"
"fmt"
"log"
"path/filepath"
"strings"
"github.com/compose-spec/compose-go/types"
"github.com/mitchellh/go-wordwrap"
"github.com/thediveo/netdb"
"gopkg.in/yaml.v3"
corev1 "k8s.io/api/core/v1"
)
// DirectoryPermission is the default values for permissions apply to created directories.
const DirectoryPermission = 0o755
// TplName returns the name of the kubernetes resource as a template string.
// It is used in the templates and defined in _helper.tpl file.
func TplName(serviceName, appname string, suffix ...string) string {
if len(suffix) > 0 {
suffix[0] = "-" + suffix[0]
}
for i, s := range suffix {
// replae all "_" with "-"
suffix[i] = strings.ReplaceAll(s, "_", "-")
}
serviceName = strings.ReplaceAll(serviceName, "_", "-")
return `{{ include "` + appname + `.fullname" . }}-` + serviceName + strings.Join(suffix, "-")
}
// Int32Ptr returns a pointer to an int32.
func Int32Ptr(i int32) *int32 { return &i }
// StrPtr returns a pointer to a string.
func StrPtr(s string) *string { return &s }
// CountStartingSpaces counts the number of spaces at the beginning of a string.
func CountStartingSpaces(line string) int {
count := 0
for _, char := range line {
if char == ' ' {
count++
} else {
break
}
}
return count
}
// GetKind returns the kind of the resource from the file path.
func GetKind(path string) (kind string) {
defer func() {
if r := recover(); r != nil {
kind = ""
}
}()
filename := filepath.Base(path)
parts := strings.Split(filename, ".")
if len(parts) == 2 {
kind = parts[0]
} else {
kind = strings.Split(path, ".")[1]
}
return
}
// Wrap wraps a string with a string above and below. It will respect the indentation of the src string.
func Wrap(src, above, below string) string {
spaces := strings.Repeat(" ", CountStartingSpaces(src))
return spaces + above + "\n" + src + "\n" + spaces + below
}
// GetServiceNameByPort returns the service name for a port. It the service name is not found, it returns an empty string.
func GetServiceNameByPort(port int) string {
name := ""
info := netdb.ServiceByPort(port, "tcp")
if info != nil {
name = info.Name
}
return name
}
// GetContainerByName returns a container by name and its index in the array. It returns nil, -1 if not found.
func GetContainerByName(name string, containers []corev1.Container) (*corev1.Container, int) {
for index, c := range containers {
if c.Name == name {
return &c, index
}
}
return nil, -1
}
// TplValue returns a string that can be used in a template to access a value from the values file.
func TplValue(serviceName, variable string, pipes ...string) string {
if len(pipes) == 0 {
return `{{ tpl .Values.` + serviceName + `.` + variable + ` $ }}`
} else {
return `{{ tpl .Values.` + serviceName + `.` + variable + ` $ | ` + strings.Join(pipes, " | ") + ` }}`
}
}
// PathToName converts a path to a kubernetes complient name.
func PathToName(path string) string {
if len(path) == 0 {
return ""
}
path = filepath.Clean(path)
if path[0] == '/' || path[0] == '.' {
path = path[1:]
}
path = strings.ReplaceAll(path, "_", "-")
path = strings.ReplaceAll(path, "/", "-")
path = strings.ReplaceAll(path, ".", "-")
path = strings.ToLower(path)
return path
}
// EnvConfig is a struct to hold the description of an environment variable.
type EnvConfig struct {
Service types.ServiceConfig
Description string
}
// GetValuesFromLabel returns a map of values from a label.
func GetValuesFromLabel(service types.ServiceConfig, LabelValues string) map[string]*EnvConfig {
descriptions := make(map[string]*EnvConfig)
if v, ok := service.Labels[LabelValues]; ok {
labelContent := []any{}
err := yaml.Unmarshal([]byte(v), &labelContent)
if err != nil {
log.Printf("Error parsing label %s: %s", v, err)
log.Fatal(err)
}
for _, value := range labelContent {
switch val := value.(type) {
case string:
descriptions[val] = nil
case map[string]any:
for k, v := range value.(map[string]any) {
descriptions[k] = &EnvConfig{Service: service, Description: v.(string)}
}
case map[any]any:
for k, v := range value.(map[any]any) {
descriptions[k.(string)] = &EnvConfig{Service: service, Description: v.(string)}
}
default:
log.Fatalf("Unknown type in label: %s %T", LabelValues, value)
}
}
}
return descriptions
}
// WordWrap wraps a string to a given line width. Warning: it may break the string. You need to check the result.
func WordWrap(text string, lineWidth int) string {
return wordwrap.WrapString(text, uint(lineWidth))
}
// Confirm asks a question and returns true if the answer is y.
func Confirm(question string, icon ...Icon) bool {
if len(icon) > 0 {
fmt.Printf("%s %s [y/N] ", icon[0], question)
} else {
fmt.Print(question + " [y/N] ")
}
var response string
if _, err := fmt.Scanln(&response); err != nil {
log.Fatalf("Error parsing response: %s", err.Error())
}
return strings.ToLower(response) == "y"
}
// EncodeBasicYaml encodes a basic yaml from an interface.
func EncodeBasicYaml(data any) ([]byte, error) {
buf := bytes.NewBuffer(nil)
enc := yaml.NewEncoder(buf)
enc.SetIndent(2)
err := enc.Encode(data)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// FixedResourceName returns a resource name without underscores to respect the kubernetes naming convention.
func FixedResourceName(name string) string {
return strings.ReplaceAll(name, "_", "-")
}
// AsResourceName returns a resource name with underscores to respect the kubernetes naming convention.
// It's the opposite of FixedResourceName.
func AsResourceName(name string) string {
return strings.ReplaceAll(name, "-", "_")
}

View File

@@ -0,0 +1,245 @@
package utils
import (
"testing"
corev1 "k8s.io/api/core/v1"
)
func TestTplName(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
serviceName string
appname string
suffix []string
want string
}{
{"simple test without suffix", "foosvc", "myapp", nil, `{{ include "myapp.fullname" . }}-foosvc`},
{"simple test with suffix", "foosvc", "myapp", []string{"bar"}, `{{ include "myapp.fullname" . }}-foosvc-bar`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := TplName(tt.serviceName, tt.appname, tt.suffix...)
if got != tt.want {
t.Errorf("TplName() = %v, want %v", got, tt.want)
}
})
}
}
func TestCountStartingSpaces(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
line string
want int
}{
{
"test no spaces",
"the line is here",
0,
},
{
"test with 4 spaces",
" line with 4 spaces",
4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CountStartingSpaces(tt.line)
if got != tt.want {
t.Errorf("CountStartingSpaces() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetKind(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
path string
want string
}{
{
"test get kind from file path",
"my.deployment.yaml",
"deployment",
},
{
"test with 2 parts",
"service.yaml",
"service",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetKind(tt.path)
if got != tt.want {
t.Errorf("GetKind() = %v, want %v", got, tt.want)
}
})
}
}
func TestWrap(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
src string
above string
below string
want string
}{
{
"test a simple wrap",
" - foo: bar",
"line above",
"line below",
" line above\n - foo: bar\n line below",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Wrap(tt.src, tt.above, tt.below)
if got != tt.want {
t.Errorf("Wrap() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetServiceNameByPort(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
port int
want string
}{
{
"test http port by service number 80",
80,
"http",
},
{
"test with a port that has no service name",
8745,
"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetServiceNameByPort(tt.port)
if got != tt.want {
t.Errorf("GetServiceNameByPort() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetContainerByName(t *testing.T) {
httpContainer := &corev1.Container{
Name: "http",
}
mariadbContainer := &corev1.Container{
Name: "mariadb",
}
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
containerName string
containers []corev1.Container
want *corev1.Container
want2 int
}{
{
"get container from by name",
"http",
[]corev1.Container{
*httpContainer,
*mariadbContainer,
},
httpContainer, 0,
},
{
"get container from by name",
"mariadb",
[]corev1.Container{
*httpContainer,
*mariadbContainer,
},
mariadbContainer, 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got2 := GetContainerByName(tt.containerName, tt.containers)
if got.Name != tt.want.Name {
t.Errorf("GetContainerByName() = %v, want %v", got.Name, tt.want.Name)
}
if got2 != tt.want2 {
t.Errorf("GetContainerByName() = %v, want %v", got2, tt.want2)
}
})
}
}
func TestTplValue(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
serviceName string
variable string
pipes []string
want string
}{
{
"check simple template value",
"foosvc",
"variableFoo",
nil,
"{{ tpl .Values.foosvc.variableFoo $ }}",
},
{
"check with pipes",
"foosvc",
"bar",
[]string{"toYaml", "nindent 2"},
"{{ tpl .Values.foosvc.bar $ | toYaml | nindent 2 }}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := TplValue(tt.serviceName, tt.variable, tt.pipes...)
if got != tt.want {
t.Errorf("TplValue() = %v, want %v", got, tt.want)
}
})
}
}
func TestPathToName(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
path string
want string
}{
{
"check complete path with various characters",
"./foo/bar.test/and_bad_name",
"foo-bar-test-and-bad-name",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := PathToName(tt.path)
if got != tt.want {
t.Errorf("PathToName() = %v, want %v", got, tt.want)
}
})
}
}