Go to Katenary V3

This is the next-gen of Katenary
This commit is contained in:
2023-12-06 15:24:02 +01:00
parent c37bde487b
commit 475a025d9e
132 changed files with 6410 additions and 4621 deletions

60
generator/chart.go Normal file
View File

@@ -0,0 +1,60 @@
package generator
// Dependency is a dependency of a chart to other charts.
type Dependency struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Repository string `yaml:"repository"`
Alias string `yaml:"alias,omitempty"`
Values map[string]any `yaml:"-"` // do not export to Chart.yaml
}
// 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.
//
// TODO: maybe we can set it private.
type ChartTemplate struct {
Content []byte
Servicename string
}
// HelmChart is a Helm Chart representation. It contains all the
// tempaltes, values, versions, helpers...
type HelmChart struct {
Name string `yaml:"name"`
ApiVersion string `yaml:"apiVersion"`
Version string `yaml:"version"`
AppVersion string `yaml:"appVersion"`
Description string `yaml:"description"`
Dependencies []Dependency `yaml:"dependencies,omitempty"`
Templates map[string]*ChartTemplate `yaml:"-"` // do not export to yaml
Helper string `yaml:"-"` // do not export to yaml
Values map[string]any `yaml:"-"` // do not export to yaml
VolumeMounts map[string]any `yaml:"-"` // do not export to yaml
composeHash *string `yaml:"-"` // do not export to yaml
}
// 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{},
},
}
}
// ConvertOptions are the options to convert a compose project to a helm chart.
type ConvertOptions struct {
Force bool // Force the chart directory deletion if it already exists.
OutputDir string // The output directory of the chart.
Profiles []string // Profile to use for the conversion.
HelmUpdate bool // If true, the "helm dep update" command will be run after the chart generation.
AppVersion *string // Set the chart "appVersion" field. If nil, the version will be set to 0.1.0.
ChartVersion string // Set the chart "version" field.
}

224
generator/configMap.go Normal file
View File

@@ -0,0 +1,224 @@
package generator
import (
"katenary/utils"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/compose-spec/compose-go/types"
goyaml "gopkg.in/yaml.v3"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
)
// only used to check interface implementation
var (
_ DataMap = (*ConfigMap)(nil)
_ Yaml = (*ConfigMap)(nil)
)
// NewFileMap creates a new DataMap from a compose service. The appName is the name of the application taken from the project name.
func NewFileMap(service types.ServiceConfig, appName string, kind string) DataMap {
switch kind {
case "configmap":
return NewConfigMap(service, appName)
default:
log.Fatalf("Unknown filemap kind: %s", kind)
}
return nil
}
// 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.
)
// ConfigMap is a kubernetes ConfigMap.
// Implements the DataMap interface.
type ConfigMap struct {
*corev1.ConfigMap
service *types.ServiceConfig
usage FileMapUsage
path string
}
// 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) *ConfigMap {
done := map[string]bool{}
drop := map[string]bool{}
secrets := []string{}
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
if v, ok := service.Labels[LABEL_SECRETS]; ok {
err := yaml.Unmarshal([]byte(v), &secrets)
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, LABEL_VALUES)
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
}
//val := `{{ tpl .Values.` + service.Name + `.environment.` + value + ` $ }}`
val := utils.TplValue(service.Name, "environment."+value)
service.Environment[value] = &val
}
// remove the variables that are already defined in the environment
if l, ok := service.Labels[LABEL_MAP_ENV]; ok {
envmap := make(map[string]string)
if err := goyaml.Unmarshal([]byte(l), &envmap); 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 {
if _, ok := done[key]; ok {
continue
}
if _, ok := drop[key]; ok {
continue
}
cm.AddData(key, *env)
}
return cm
}
// NewConfigMapFromFiles 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 NewConfigMapFromFiles(service types.ServiceConfig, appName string, 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)
cm.AppendDir(path)
return cm
}
// SetData sets the data of the configmap. It replaces the entire data.
func (c *ConfigMap) SetData(data map[string]string) {
c.Data = data
}
// AddData adds a key value pair to the configmap. Append or overwrite the value if the key already exists.
func (c *ConfigMap) AddData(key string, value string) {
c.Data[key] = value
}
// AddFile 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) {
// read all files in the path and add them to the configmap
stat, err := os.Stat(path)
if err != nil {
log.Fatalf("Path %s does not exist\n", path)
}
// recursively read all files in the path and add them to the configmap
if stat.IsDir() {
files, err := os.ReadDir(path)
if err != nil {
log.Fatal(err)
}
for _, file := range files {
if file.IsDir() {
continue
}
path := filepath.Join(path, file.Name())
content, err := os.ReadFile(path)
if err != nil {
log.Fatal(err)
}
// remove the path from the file
filename := filepath.Base(path)
c.AddData(filename, string(content))
}
} else {
// add the file to the configmap
content, err := os.ReadFile(path)
if err != nil {
log.Fatal(err)
}
c.AddData(filepath.Base(path), string(content))
}
}
// 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"
}
}
// Yaml returns the yaml representation of the configmap
func (c *ConfigMap) Yaml() ([]byte, error) {
return yaml.Marshal(c)
}

View File

@@ -1,200 +0,0 @@
package generator
import (
"fmt"
"katenary/helm"
"katenary/logger"
"log"
"os"
"strconv"
"strings"
"github.com/compose-spec/compose-go/types"
)
// Generate a container in deployment with all needed objects (volumes, secrets, env, ...).
// The deployName shoud be the name of the deployment, we cannot get it from Metadata as this is a variable name.
func newContainerForDeployment(
deployName, containerName string,
deployment *helm.Deployment,
s *types.ServiceConfig,
fileGeneratorChan HelmFileGenerator) *helm.Container {
buildCrontab(deployName, deployment, s, fileGeneratorChan)
container := helm.NewContainer(containerName, s.Image, s.Environment, s.Labels)
applyEnvMapLabel(s, container)
if secretFile := setSecretVar(containerName, s, container); secretFile != nil {
fileGeneratorChan <- secretFile
container.EnvFrom = append(container.EnvFrom, map[string]map[string]string{
"secretRef": {
"name": secretFile.Metadata().Name,
},
})
}
setEnvToValues(containerName, s, container)
prepareContainer(container, s, containerName)
prepareEnvFromFiles(deployName, s, container, fileGeneratorChan)
// add the container in deployment
if deployment.Spec.Template.Spec.Containers == nil {
deployment.Spec.Template.Spec.Containers = make([]*helm.Container, 0)
}
deployment.Spec.Template.Spec.Containers = append(
deployment.Spec.Template.Spec.Containers,
container,
)
// add the volumes
if deployment.Spec.Template.Spec.Volumes == nil {
deployment.Spec.Template.Spec.Volumes = make([]map[string]interface{}, 0)
}
// manage LABEL_VOLUMEFROM
addVolumeFrom(deployment, container, s)
// and then we can add other volumes
deployment.Spec.Template.Spec.Volumes = append(
deployment.Spec.Template.Spec.Volumes,
prepareVolumes(deployName, containerName, s, container, fileGeneratorChan)...,
)
// add init containers
if deployment.Spec.Template.Spec.InitContainers == nil {
deployment.Spec.Template.Spec.InitContainers = make([]*helm.Container, 0)
}
deployment.Spec.Template.Spec.InitContainers = append(
deployment.Spec.Template.Spec.InitContainers,
prepareInitContainers(containerName, s, container)...,
)
// check if there is containerPort assigned in label, add it, and do
// not create service for this.
if ports, ok := s.Labels[helm.LABEL_CONTAINER_PORT]; ok {
for _, port := range strings.Split(ports, ",") {
func(port string, container *helm.Container, s *types.ServiceConfig) {
port = strings.TrimSpace(port)
if port == "" {
return
}
portNumber, err := strconv.Atoi(port)
if err != nil {
return
}
// avoid already declared ports
for _, p := range s.Ports {
if int(p.Target) == portNumber {
return
}
}
container.Ports = append(container.Ports, &helm.ContainerPort{
Name: deployName + "-" + port,
ContainerPort: portNumber,
})
}(port, container, s)
}
}
return container
}
// prepareContainer assigns image, command, env, and labels to a container.
func prepareContainer(container *helm.Container, service *types.ServiceConfig, servicename string) {
// if there is no image name, this should fail!
if service.Image == "" {
log.Fatal(ICON_PACKAGE+" No image name for service ", servicename)
}
// Get the image tag
imageParts := strings.Split(service.Image, ":")
tag := ""
if len(imageParts) == 2 {
container.Image = imageParts[0]
tag = imageParts[1]
}
vtag := ".Values." + servicename + ".repository.tag"
container.Image = `{{ .Values.` + servicename + `.repository.image }}` +
`{{ if ne ` + vtag + ` "" }}:{{ ` + vtag + ` }}{{ end }}`
container.Command = service.Command
AddValues(servicename, map[string]EnvVal{
"repository": map[string]EnvVal{
"image": imageParts[0],
"tag": tag,
},
})
prepareProbes(servicename, service, container)
generateContainerPorts(service, servicename, container)
}
// generateContainerPorts add the container ports of a service.
func generateContainerPorts(s *types.ServiceConfig, name string, container *helm.Container) {
exists := make(map[int]string)
for _, port := range s.Ports {
portName := name
for _, n := range exists {
if name == n {
portName = fmt.Sprintf("%s-%d", name, port.Target)
}
}
container.Ports = append(container.Ports, &helm.ContainerPort{
Name: portName,
ContainerPort: int(port.Target),
})
exists[int(port.Target)] = name
}
// manage the "expose" section to be a NodePort in Kubernetes
for _, expose := range s.Expose {
port, _ := strconv.Atoi(expose)
if _, exist := exists[port]; exist {
continue
}
container.Ports = append(container.Ports, &helm.ContainerPort{
Name: name,
ContainerPort: port,
})
}
}
// prepareInitContainers add the init containers of a service.
func prepareInitContainers(name string, s *types.ServiceConfig, container *helm.Container) []*helm.Container {
// We need to detect others services, but we probably not have parsed them yet, so
// we will wait for them for a while.
initContainers := make([]*helm.Container, 0)
for dp := range s.DependsOn {
c := helm.NewContainer("check-"+dp, "busybox", nil, s.Labels)
command := strings.ReplaceAll(strings.TrimSpace(dependScript), "__service__", dp)
foundPort := -1
locker.Lock()
if defaultPort, ok := servicesMap[dp]; !ok {
logger.Redf("Error while getting port for service %s\n", dp)
os.Exit(1)
} else {
foundPort = defaultPort
}
locker.Unlock()
if foundPort == -1 {
log.Fatalf(
"ERROR, the %s service is waiting for %s port number, "+
"but it is never discovered. You must declare at least one port in "+
"the \"ports\" section of the service in the docker-compose file",
name,
dp,
)
}
command = strings.ReplaceAll(command, "__port__", strconv.Itoa(foundPort))
c.Command = []string{
"sh",
"-c",
command,
}
initContainers = append(initContainers, c)
}
return initContainers
}

638
generator/converter.go Normal file
View File

@@ -0,0 +1,638 @@
package generator
import (
"bytes"
"errors"
"fmt"
"katenary/generator/extrafiles"
"katenary/parser"
"katenary/utils"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/compose-spec/compose-go/types"
goyaml "gopkg.in/yaml.v3"
)
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.
`
// 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) {
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)
os.Exit(1)
}
defer os.Chdir(currentDir) // after the generation, go back to the original directory
// 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, dockerComposeFile...)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
// check older version of labels
if err := checkOldLabels(project); err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
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 {
fmt.Print(utils.IconWarning, " The chart directory "+config.OutputDir+" already exists, do you want to overwrite it? [y/N] ")
var answer string
fmt.Scanln(&answer)
if strings.ToLower(answer) != "y" {
fmt.Println("Aborting")
os.Exit(126) // 126 is the exit code for "Command invoked cannot execute"
}
}
fmt.Println() // clean line
}
// Build the objects !
chart, err := Generate(project)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
// 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, 0755); err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
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), 0755); 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), 0755)
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)
}
f.Write(t)
f.Close()
}
// calculate the sha1 hash of the services
buf := bytes.NewBuffer(nil)
encoder := goyaml.NewEncoder(buf)
encoder.SetIndent(2)
if err := encoder.Encode(chart); err != nil {
fmt.Println(err)
os.Exit(1)
}
yamlChart := buf.Bytes()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
// concat chart adding a comment with hash of services on top
yamlChart = append([]byte(fmt.Sprintf("# 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([]byte(fmt.Sprintf("# compose files: %s\n", strings.Join(files, ", "))), yamlChart...)
// add generated date
yamlChart = append([]byte(fmt.Sprintf("# generated at: %s\n", time.Now().Format(time.RFC3339))), yamlChart...)
// document Chart.yaml file
yamlChart = addChartDoc(yamlChart, project)
f, err := os.Create(chartPath)
if err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
f.Write(yamlChart)
f.Close()
buf.Reset()
encoder = goyaml.NewEncoder(buf)
encoder.SetIndent(2)
if err = encoder.Encode(&chart.Values); err != nil {
fmt.Println(err)
os.Exit(1)
}
values := buf.Bytes()
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 = append([]byte(headerHelp), values...)
f, err = os.Create(valuesPath)
if err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
f.Write(values)
f.Close()
f, err = os.Create(helpersPath)
if err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
f.Write([]byte(chart.Helper))
f.Close()
readme := extrafiles.ReadMeFile(chart.Name, chart.Description, chart.Values)
f, err = os.Create(readmePath)
if err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
f.Write([]byte(readme))
f.Close()
notes := extrafiles.NotesFile()
f, err = os.Create(notesPath)
if err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
}
f.Write([]byte(notes))
f.Close()
if config.HelmUpdate {
if err := helmUpdate(config); err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
} else if err := helmLint(config); err != nil {
fmt.Println(utils.IconFailure, err)
os.Exit(1)
} else {
fmt.Println(utils.IconSuccess, "Helm chart created successfully")
}
}
}
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"
# More info: https://kubernetes.io/docs/concepts/services-networking/ingress/#the-ingress-resource
`
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"))
}
const storageClassHelp = `# Storage class to use for PVCs
# storageClass: "-" means use default
# storageClass: "" means do not specify
# storageClass: "foo" means use that storageClass
# More info: https://kubernetes.io/docs/concepts/storage/storage-classes/
`
// 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"))
}
// addModeline adds a modeline to the values.yaml file to make sure that vim
// will use the correct syntax highlighting.
func addModeline(values []byte) []byte {
modeline := "# vi" + "m: ft=gotmpl.yaml"
// if the values ends by `{{- end }}` we need to add the modeline before
lines := strings.Split(string(values), "\n")
if lines[len(lines)-1] == "{{- end }}" || lines[len(lines)-1] == "{{- end -}}" {
lines = lines[:len(lines)-1]
lines = append(lines, modeline, "{{- end }}")
return []byte(strings.Join(lines, "\n"))
}
return append(values, []byte(modeline)...)
}
// 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[LABEL_DESCRIPTION]; 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 addDependencyDescription(values []byte, dependencies []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
}
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, repsonsible for creating the secret.
# More info: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
`
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 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 := fmt.Sprintf("\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 := fmt.Sprintf("\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"))
}
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
# More info: https://kubernetes.io/docs/concepts/containers/images/#updating-images
`
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 addVariablesDoc(values []byte, project *types.Project) []byte {
lines := strings.Split(string(values), "\n")
currentService := ""
for _, service := range project.Services {
variables := utils.GetValuesFromLabel(service, LABEL_VALUES)
for i, line := range lines {
if regexp.MustCompile(`(?m)^` + service.Name + `:`).MatchString(line) {
currentService = service.Name
}
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 []byte(strings.Join(lines, "\n"))
}
const mainTagAppDoc = `This is the version of the main application.
Leave it to blank to use the Chart "AppVersion" value.`
func addMainTagAppDoc(values []byte, project *types.Project) []byte {
lines := strings.Split(string(values), "\n")
for _, service := range project.Services {
inService := false
inRegistry := false
// read the label LabelMainApp
if v, ok := service.Labels[LABEL_MAIN_APP]; !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)
}
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 []byte(strings.Join(lines, "\n"))
}
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)
})
}
var unwantedLines = []string{
"creationTimestamp:",
"status:",
}
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"))
}
// 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, KATENARY_PREFIX) {
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,
KATENARY_PREFIX[0:len(KATENARY_PREFIX)-1],
strings.Join(badServices, "\n"),
)
return errors.New(utils.WordWrap(message, 80))
}
return nil
}
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()
}
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()
}

133
generator/cronJob.go Normal file
View File

@@ -0,0 +1,133 @@
package generator
import (
"katenary/utils"
"log"
"strings"
"github.com/compose-spec/compose-go/types"
goyaml "gopkg.in/yaml.v3"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
)
// 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) {
var labels, ok = service.Labels[LABEL_CRONJOB]
if !ok {
return nil, nil
}
mapping := struct {
Image string `yaml:"image,omitempty"`
Command string `yaml:"command"`
Schedule string `yaml:"schedule"`
Rbac bool `yaml:"rbac"`
}{
Image: "",
Command: "",
Schedule: "",
Rbac: false,
}
if err := goyaml.Unmarshal([]byte(labels), &mapping); 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 yaml.Marshal(c)
}

View File

@@ -1,110 +0,0 @@
package generator
import (
"fmt"
"katenary/helm"
"katenary/logger"
"log"
"github.com/alessio/shellescape"
"github.com/compose-spec/compose-go/types"
"gopkg.in/yaml.v3"
)
const (
cronMulti = `pods=$(kubectl get pods --selector=%s/component=%s,%s/resource=deployment -o jsonpath='{.items[*].metadata.name}')`
cronMultiCmd = `
for pod in $pods; do
kubectl exec -i $pod -c %s -- sh -c %s
done`
cronSingle = `pod=$(kubectl get pods --selector=%s/component=%s,%s/resource=deployment -o jsonpath='{.items[0].metadata.name}')`
cronCmd = `
kubectl exec -i $pod -c %s -- sh -c %s`
)
type CronDef struct {
Command string `yaml:"command"`
Schedule string `yaml:"schedule"`
Image string `yaml:"image"`
Multi bool `yaml:"allPods,omitempty"`
}
func buildCrontab(deployName string, deployment *helm.Deployment, s *types.ServiceConfig, fileGeneratorChan HelmFileGenerator) {
// get the cron label from the service
var crondef string
var ok bool
if crondef, ok = s.Labels[helm.LABEL_CRON]; !ok {
return
}
// parse yaml
crons := []CronDef{}
err := yaml.Unmarshal([]byte(crondef), &crons)
if err != nil {
log.Fatalf("error: %v", err)
}
if len(crons) == 0 {
return
}
// create a serviceAccount
sa := helm.NewServiceAccount(deployName)
// create a role
role := helm.NewCronRole(deployName)
// create a roleBinding
roleBinding := helm.NewRoleBinding(deployName, sa, role)
// make generation
logger.Magenta(ICON_RBAC, "Generating ServiceAccount, Role and RoleBinding for cron jobs", deployName)
fileGeneratorChan <- sa
fileGeneratorChan <- role
fileGeneratorChan <- roleBinding
numcron := len(crons) - 1
index := 1
// create crontabs
for _, cron := range crons {
escaped := shellescape.Quote(cron.Command)
var cmd, podget string
if cron.Multi {
podget = cronMulti
cmd = cronMultiCmd
} else {
podget = cronSingle
cmd = cronCmd
}
podget = fmt.Sprintf(podget, helm.K, deployName, helm.K)
cmd = fmt.Sprintf(cmd, s.Name, escaped)
cmd = podget + cmd
if cron.Image == "" {
cron.Image = `bitnami/kubectl:{{ printf "%s.%s" .Capabilities.KubeVersion.Major .Capabilities.KubeVersion.Minor }}`
}
name := deployName
if numcron > 0 {
name = fmt.Sprintf("%s-%d", deployName, index)
}
// add crontab
suffix := ""
if numcron > 0 {
suffix = fmt.Sprintf("%d", index)
}
cronTab := helm.NewCrontab(
name,
cron.Image,
cmd,
cron.Schedule,
sa,
)
logger.Magenta(ICON_CRON, "Generating crontab", deployName, suffix)
fileGeneratorChan <- cronTab
index++
}
return
}

View File

@@ -1,70 +1,569 @@
package generator
import (
"katenary/helm"
"katenary/logger"
"fmt"
"katenary/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"
"sigs.k8s.io/yaml"
)
// This function will try to yied deployment and services based on a service from the compose file structure.
func buildDeployment(name string, s *types.ServiceConfig, linked map[string]types.ServiceConfig, fileGeneratorChan HelmFileGenerator) {
var _ Yaml = (*Deployment)(nil)
logger.Magenta(ICON_PACKAGE+" Generating deployment for ", name)
deployment := helm.NewDeployment(name)
// Deployment is a kubernetes Deployment.
type Deployment struct {
*appsv1.Deployment `yaml:",inline"`
chart *HelmChart `yaml:"-"`
configMaps map[string]bool `yaml:"-"`
service *types.ServiceConfig `yaml:"-"`
defaultTag string `yaml:"-"`
isMainApp bool `yaml:"-"`
}
newContainerForDeployment(name, name, deployment, s, fileGeneratorChan)
// 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 {
// Add selectors
selectors := buildSelector(name, s)
selectors[helm.K+"/resource"] = "deployment"
deployment.Spec.Selector = map[string]interface{}{
"matchLabels": selectors,
ports := []corev1.ContainerPort{}
for _, port := range service.Ports {
ports = append(ports, corev1.ContainerPort{
ContainerPort: int32(port.Target),
})
}
deployment.Spec.Template.Metadata.Labels = selectors
// Now, the linked services (same pod)
for lname, link := range linked {
newContainerForDeployment(name, lname, deployment, &link, fileGeneratorChan)
// append ports and expose ports to the deployment,
// to be able to generate them in the Service file
if len(link.Ports) > 0 || len(link.Expose) > 0 {
s.Ports = append(s.Ports, link.Ports...)
s.Expose = append(s.Expose, link.Expose...)
isMainApp := false
if mainLabel, ok := service.Labels[LABEL_MAIN_APP]; ok {
main := strings.ToLower(mainLabel)
isMainApp = main == "true" || main == "yes" || main == "1"
}
defaultTag := `default "latest"`
if isMainApp {
defaultTag = `default .Chart.AppVersion "latest"`
}
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),
},
},
},
},
configMaps: map[string]bool{},
}
// add containers
dep.AddContainer(service)
// add volumes
dep.AddVolumes(service, appName)
if service.Environment != nil {
dep.SetEnvFrom(service, appName)
}
return dep
}
// DependsOn adds a initContainer to the deployment that will wait for the service to be up.
func (d *Deployment) DependsOn(to *Deployment) 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{}
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
}
// 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")
}
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.Name,
ImagePullPolicy: corev1.PullIfNotPresent,
}
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__ }}`,
}}
d.AddHealthCheck(service, &container)
d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, container)
}
// 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[LABEL_CM_FILES]; ok {
binds := []string{}
if err := yaml.Unmarshal([]byte(v), &binds); err != nil {
log.Fatal(err)
}
for _, bind := range binds {
tobind[bind] = true
}
}
// Remove duplicates in volumes
volumes := make([]map[string]interface{}, 0)
done := make(map[string]bool)
for _, vol := range deployment.Spec.Template.Spec.Volumes {
name := vol["name"].(string)
if _, ok := done[name]; ok {
isSamePod := false
if v, ok := service.Labels[LABEL_SAME_POD]; !ok {
isSamePod = false
} else {
isSamePod = v != ""
}
for _, volume := range service.Volumes {
// not declared as a bind volume, skip
if _, ok := tobind[volume.Source]; !isSamePod && volume.Type == "bind" && !ok {
utils.Warn(
"Bind volumes are not supported yet, " +
"excepting for those declared as " +
LABEL_CM_FILES +
", skipping volume " + volume.Source +
" from service " + service.Name,
)
continue
} else {
done[name] = true
volumes = append(volumes, vol)
}
}
deployment.Spec.Template.Spec.Volumes = volumes
// Then, create Services and possible Ingresses for ingress labels, "ports" and "expose" section
if len(s.Ports) > 0 || len(s.Expose) > 0 {
for _, s := range generateServicesAndIngresses(name, s) {
if s != nil {
fileGeneratorChan <- s
container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers)
if container == nil {
utils.Warn("Container not found for volume", volume.Source)
continue
}
// ensure that the volume is not already present in the container
for _, vm := range container.VolumeMounts {
if vm.Name == volume.Source {
continue
}
}
switch volume.Type {
case "volume":
// Add volume to container
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{
Name: volume.Source,
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[LABEL_SAME_POD]; !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: volume.Source,
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: utils.TplName(service.Name, appName, volume.Source),
},
},
})
case "bind":
// Add volume to container
cm := NewConfigMapFromFiles(service, appName, volume.Source)
d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{
Name: utils.PathToName(volume.Source),
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: cm.ObjectMeta.Name,
},
},
},
})
// add the mount path to the container
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{
Name: utils.PathToName(volume.Source),
MountPath: volume.Target,
})
d.configMaps[utils.PathToName(volume.Source)] = true
// add all subdirectories to the list of directories
stat, err := os.Stat(volume.Source)
if err != nil {
log.Fatal(err)
}
if stat.IsDir() {
files, err := os.ReadDir(volume.Source)
if err != nil {
log.Fatal(err)
}
for _, file := range files {
if file.IsDir() {
cm := NewConfigMapFromFiles(service, appName, filepath.Join(volume.Source, file.Name()))
name := utils.PathToName(volume.Source) + "-" + file.Name()
d.configMaps[name] = true
d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{
Name: utils.PathToName(volume.Source) + "-" + file.Name(),
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: cm.ObjectMeta.Name,
},
},
},
})
// add the mount path to the container
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{
Name: name,
MountPath: filepath.Join(volume.Target, file.Name()),
})
}
}
}
}
d.Spec.Template.Spec.Containers[index] = *container
}
// add the volumes in Values
if len(VolumeValues[name]) > 0 {
AddValues(name, map[string]EnvVal{"persistence": VolumeValues[name]})
}
// the deployment is ready, give it
fileGeneratorChan <- deployment
// and then, we can say that it's the end
fileGeneratorChan <- nil
}
func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) {
log.Printf("In %s deployment, add volumes for service %s from binded deployment %s", d.Name, service.Name, binded.Name)
// find the volume in the binded deployment
for _, bindedVolume := range binded.Spec.Template.Spec.Volumes {
log.Println("bindedVolume.Name found", bindedVolume.Name)
skip := false
for _, targetVol := range d.Spec.Template.Spec.Volumes {
if targetVol.Name == bindedVolume.Name {
log.Println("Volume", bindedVolume.Name, "already exists in deployment", d.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)
log.Println("d.Spec.Template.Spec.Volumes", d.Spec.Template.Spec.Volumes)
// get the container
}
// add volume mount to the container
targetContainer, ti := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers)
sourceContainer, _ := utils.GetContainerByName(service.Name, binded.Spec.Template.Spec.Containers)
for _, bindedMount := range sourceContainer.VolumeMounts {
if bindedMount.Name == bindedVolume.Name {
log.Println("bindedMount.Name found", bindedMount.Name)
targetContainer.VolumeMounts = append(targetContainer.VolumeMounts, bindedMount)
}
}
d.Spec.Template.Spec.Containers[ti] = *targetContainer
}
}
// SetEnvFrom sets the environment variables to a configmap. The configmap is created.
func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) {
if len(service.Environment) == 0 {
return
}
drop := []string{}
secrets := []string{}
// secrets from label
labelSecrets := []string{}
if v, ok := service.Labels[LABEL_SECRETS]; ok {
err := yaml.Unmarshal([]byte(v), &labelSecrets)
if err != nil {
log.Fatal(err)
}
}
// values from label
varDescriptons := utils.GetValuesFromLabel(service, LABEL_VALUES)
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)
}
// 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)
}
fromSources := []corev1.EnvFromSource{}
if len(service.Environment) > 0 {
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.Name, d.Spec.Template.Spec.Containers)
if container == nil {
utils.Warn("Container not found for service " + service.Name)
return
}
container.EnvFrom = append(container.EnvFrom, fromSources...)
if container.Env == nil {
container.Env = []corev1.EnvVar{}
}
d.Spec.Template.Spec.Containers[index] = *container
}
func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) {
// get the label for healthcheck
if v, ok := service.Labels[LABEL_HEALTHCHECK]; ok {
probes := struct {
LivenessProbe *corev1.Probe `yaml:"livenessProbe"`
ReadinessProbe *corev1.Probe `yaml:"readinessProbe"`
}{}
err := yaml.Unmarshal([]byte(v), &probes)
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),
}
}
}
// Yaml returns the yaml representation of the deployment.
func (d *Deployment) Yaml() ([]byte, error) {
serviceName := d.service.Name
y, err := yaml.Marshal(d)
if 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 := ""
// 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], "name: ") {
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))
content[line] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + volumeName + `.enabled }}` + "\n" + volume
changing = true
}
if strings.Contains(volume, "name: ") && 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))
content[i] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + volumeName + `.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
inpullsecrets := false
for i, line := range content {
if strings.Contains(line, "imagePullSecrets:") {
inpullsecrets = true
}
if inpullsecrets && strings.Contains(line, "- name: ") && inpullsecrets {
line = strings.Replace(line, "- name: ", "", 1)
line = strings.ReplaceAll(line, "'", "")
content[i] = line
inpullsecrets = false
}
}
// Find the replicas line and replace it with the value from values.yaml
for i, line := range content {
if strings.Contains(line, "replicas:") {
line = regexp.MustCompile("replicas: .*$").ReplaceAllString(line, "replicas: {{ .Values."+serviceName+".replicas }}")
content[i] = line
}
}
return []byte(strings.Join(content, "\n")), nil
}
func (d *Deployment) Filename() string {
return d.service.Name + ".deployment.yaml"
}

18
generator/doc.go Normal file
View File

@@ -0,0 +1,18 @@
/*
The generator package 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.
Convertion 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.
TODO: Manage cronjob + rbac
TODO: create note.txt
TODO: manage emptyDirs
*/
package generator

View File

@@ -1,154 +0,0 @@
package generator
import (
"fmt"
"io/ioutil"
"katenary/compose"
"katenary/helm"
"katenary/logger"
"katenary/tools"
"os"
"path/filepath"
"strings"
"github.com/compose-spec/compose-go/types"
"gopkg.in/yaml.v3"
)
// applyEnvMapLabel will get all LABEL_MAP_ENV to rebuild the env map with tpl.
func applyEnvMapLabel(s *types.ServiceConfig, c *helm.Container) {
locker.Lock()
defer locker.Unlock()
mapenv, ok := s.Labels[helm.LABEL_MAP_ENV]
if !ok {
return
}
// the mapenv is a YAML string
var envmap map[string]EnvVal
err := yaml.Unmarshal([]byte(mapenv), &envmap)
if err != nil {
logger.ActivateColors = true
logger.Red(err.Error())
logger.ActivateColors = false
return
}
// add in envmap
for k, v := range envmap {
vstring := fmt.Sprintf("%v", v)
s.Environment[k] = &vstring
touched := false
if c.Env != nil {
c.Env = make([]*helm.Value, 0)
}
for _, env := range c.Env {
if env.Name == k {
env.Value = v
touched = true
}
}
if !touched {
c.Env = append(c.Env, &helm.Value{Name: k, Value: v})
}
}
}
// readEnvFile read environment file and add to the values.yaml map.
func readEnvFile(envfilename string) map[string]EnvVal {
env := make(map[string]EnvVal)
content, err := ioutil.ReadFile(envfilename)
if err != nil {
logger.ActivateColors = true
logger.Red(err.Error())
logger.ActivateColors = false
os.Exit(2)
}
// each value is on a separate line with KEY=value
lines := strings.Split(string(content), "\n")
for _, line := range lines {
if strings.Contains(line, "=") {
kv := strings.SplitN(line, "=", 2)
env[kv[0]] = kv[1]
}
}
return env
}
// prepareEnvFromFiles generate configMap or secrets from environment files.
func prepareEnvFromFiles(name string, s *types.ServiceConfig, container *helm.Container, fileGeneratorChan HelmFileGenerator) {
// prepare secrets
secretsFiles := make([]string, 0)
if v, ok := s.Labels[helm.LABEL_ENV_SECRET]; ok {
secretsFiles = strings.Split(v, ",")
}
var secretVars []string
if v, ok := s.Labels[helm.LABEL_SECRETVARS]; ok {
secretVars = strings.Split(v, ",")
}
for i, s := range secretVars {
secretVars[i] = strings.TrimSpace(s)
}
// manage environment files (env_file in compose)
for _, envfile := range s.EnvFile {
f := tools.PathToName(envfile)
f = strings.ReplaceAll(f, ".env", "")
isSecret := false
for _, s := range secretsFiles {
s = strings.TrimSpace(s)
if s == envfile {
isSecret = true
}
}
var store helm.InlineConfig
if !isSecret {
logger.Bluef(ICON_CONF+" Generating configMap from %s\n", envfile)
store = helm.NewConfigMap(name, envfile)
} else {
logger.Bluef(ICON_SECRET+" Generating secret from %s\n", envfile)
store = helm.NewSecret(name, envfile)
}
envfile = filepath.Join(compose.GetCurrentDir(), envfile)
if err := store.AddEnvFile(envfile, secretVars); err != nil {
logger.ActivateColors = true
logger.Red(err.Error())
logger.ActivateColors = false
os.Exit(2)
}
section := "configMapRef"
if isSecret {
section = "secretRef"
}
container.EnvFrom = append(container.EnvFrom, map[string]map[string]string{
section: {
"name": store.Metadata().Name,
},
})
// read the envfile and remove them from the container environment or secret
envs := readEnvFile(envfile)
for varname := range envs {
if !isSecret {
// remove varname from container
for i, s := range container.Env {
if s.Name == varname {
container.Env = append(container.Env[:i], container.Env[i+1:]...)
i--
}
}
}
}
if store != nil {
fileGeneratorChan <- store.(HelmFile)
}
}
}

View File

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

View File

@@ -0,0 +1,11 @@
package extrafiles
import _ "embed"
//go:embed notes.tpl
var notesTemplate string
// NoteTXTFile returns the content of the note.txt file.
func NotesFile() string {
return notesTemplate
}

View File

@@ -0,0 +1,27 @@
Your release is named {{ .Release.Name }}.
To learn more about the release, try:
$ helm -n {{ .Release.Namespace }} status {{ .Release.Name }}
$ helm -n {{ .Release.Namespace }} get all {{ .Release.Name }}
To delete the release, run:
$ helm -n {{ .Release.Namespace }} delete {{ .Release.Name }}
You can see this notes again by running:
$ helm -n {{ .Release.Namespace }} get notes {{ .Release.Name }}
{{- $count := 0 -}}
{{- range $s, $v := .Values -}}
{{- if and $v $v.ingress -}}
{{- $count = add $count 1 -}}
{{- if eq $count 1 }}
The ingress list is:
{{ end }}
- {{ $s }}: http://{{ $v.ingress.host }}{{ $v.ingress.path }}
{{- end -}}
{{ end -}}

View File

@@ -0,0 +1,99 @@
package extrafiles
import (
"bytes"
"fmt"
"sort"
"strings"
"text/template"
_ "embed"
"gopkg.in/yaml.v3"
)
type chart struct {
Name string
Description string
Values []string
}
//go:embed readme.tpl
var readmeTemplate string
// 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)
yaml.Unmarshal(out, &vv)
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()
}
func parseValues(prefix string, values map[string]interface{}, result map[string]string) {
for key, value := range values {
path := key
if prefix != "" {
path = prefix + "." + key
}
switch v := value.(type) {
case []interface{}:
for i, u := range v {
parseValues(fmt.Sprintf("%s[%d]", path, i), map[string]interface{}{"value": u}, result)
}
case map[string]interface{}:
parseValues(path, v, result)
default:
strValue := fmt.Sprintf("`%v`", value)
result["`"+path+"`"] = strValue
}
}
}

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 }}

658
generator/generator.go Normal file
View File

@@ -0,0 +1,658 @@
package generator
// TODO: configmap from files 20%
import (
"bytes"
"fmt"
"katenary/utils"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/compose-spec/compose-go/types"
goyaml "gopkg.in/yaml.v3"
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:
//
// 1. Detect the service port name or leave the port number if not found.
//
// 2. Create a deployment for each service that are not ingnore.
//
// 3. Create a service and ingresses for each service that has ports and/or declared ingresses.
//
// 4. Create a PVC or Configmap volumes for each volume.
//
// 5. Create init containers for each service which has dependencies to other services.
//
// 6. Create a chart dependencies.
//
// 7. Create a configmap and secrets from the environment variables.
//
// 8. 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]*Deployment)
)
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[KATENARY_PREFIX+"compose-hash"] = hash
chart.composeHash = &hash
// find the "main-app" label, and set chart.AppVersion to the tag if exists
mainCount := 0
for _, service := range project.Services {
if serviceIsMain(service) {
log.Printf("Found main app %s", service.Name)
mainCount++
if mainCount > 1 {
return nil, fmt.Errorf("found more than one main app")
}
setChartVersion(chart, service)
}
}
if mainCount == 0 {
chart.AppVersion = "0.1.0"
}
// first pass, create all deployments whatewer they are.
for _, service := range project.Services {
// check the "ports" label from container and add it to the service
if err := fixPorts(&service); err != nil {
return nil, err
}
// isgnored service
if isIgnored(service) {
fmt.Printf("%s Ignoring service %s\n", utils.IconInfo, service.Name)
continue
}
// helm dependency
if isHelmDependency, err := setDependencies(chart, service); err != nil {
return nil, err
} else if isHelmDependency {
continue
}
// create all deployments
d := NewDeployment(service, chart)
deployments[service.Name] = d
// generate the cronjob if needed
setCronJob(service, chart, appName)
// 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[LABEL_SAME_POD]; ok && samePod != "" {
podToMerge[samePod] = d
}
// 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,
}
}
}
// 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)
for _, service := range project.Services {
if err := buildVolumes(service, chart, deployments); err != nil {
return nil, err
}
}
// drop all "same-pod" deployments because the containers and volumes are already
// in the target deployment
for _, service := range project.Services {
if samepod, ok := service.Labels[LABEL_SAME_POD]; 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])
delete(deployments, service.Name)
} else {
log.Printf("service %[1]s is declared as %[2]s, but %[2]s is not defined", service.Name, LABEL_SAME_POD)
}
}
}
// create init containers for all DependsOn
for _, s := range project.Services {
for _, d := range s.GetDependencies() {
if dep, ok := deployments[d]; ok {
deployments[s.Name].DependsOn(dep)
} else {
log.Printf("service %[1]s depends on %[2]s, but %[2]s is not defined", s.Name, d)
}
}
}
// generate configmaps with environment variables
generateConfigMapsAndSecrets(project, chart)
// 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 {
setSharedConf(s, chart, deployments)
}
// generate yaml files
for _, d := range deployments {
y, _ := d.Yaml()
chart.Templates[d.Filename()] = &ChartTemplate{
Content: y,
Servicename: d.service.Name,
}
}
// generate all services
for _, s := range services {
y, _ := s.Yaml()
chart.Templates[s.Filename()] = &ChartTemplate{
Content: y,
Servicename: s.service.Name,
}
}
// 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
}
// 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__"), []byte(fmt.Sprintf("%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
}
func serviceIsMain(service types.ServiceConfig) bool {
if main, ok := service.Labels[LABEL_MAIN_APP]; ok {
return main == "true" || main == "yes" || main == "1"
}
return false
}
func setChartVersion(chart *HelmChart, 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"
}
}
}
func fixPorts(service *types.ServiceConfig) error {
// check the "ports" label from container and add it to the service
if portsLabel, ok := service.Labels[LABEL_PORTS]; ok {
ports := []uint32{}
if err := goyaml.Unmarshal([]byte(portsLabel), &ports); err != nil {
// maybe it's a string, comma separated
parts := strings.Split(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
}
func setCronJob(service types.ServiceConfig, chart *HelmChart, appName string) *CronJob {
if _, ok := service.Labels[LABEL_CRONJOB]; !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
}
func setDependencies(chart *HelmChart, service types.ServiceConfig) (bool, error) {
// helm dependency
if v, ok := service.Labels[LABEL_DEPENDENCIES]; ok {
d := Dependency{}
if err := yaml.Unmarshal([]byte(v), &d); err != nil {
return false, err
}
fmt.Printf("%s Adding dependency to %s\n", utils.IconDependency, d.Name)
chart.Dependencies = append(chart.Dependencies, d)
name := d.Name
if d.Alias != "" {
name = d.Alias
}
// add the dependency env vars to the values.yaml
chart.Values[name] = d.Values
return true, nil
}
return false, nil
}
func isIgnored(service types.ServiceConfig) bool {
if v, ok := service.Labels[LABEL_IGNORE]; ok {
return v == "true" || v == "yes" || v == "1"
}
return false
}
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":
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[LABEL_SAME_POD]; ok {
pvc.nameOverride = override
pvc.PersistentVolumeClaim.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, //TODO, use name
}
case "bind":
// ensure the path is in labels
bindPath := map[string]string{}
if _, ok := service.Labels[LABEL_CM_FILES]; ok {
files := []string{}
if err := yaml.Unmarshal([]byte(service.Labels[LABEL_CM_FILES]), &files); err != nil {
return err
}
for _, f := range files {
bindPath[f] = f
}
}
if _, ok := bindPath[v.Source]; !ok {
continue
}
cm := NewConfigMapFromFiles(service, appName, v.Source)
var err error
var y []byte
if y, err = cm.Yaml(); err != nil {
log.Fatal(err)
}
chart.Templates[cm.Filename()] = &ChartTemplate{
Content: y,
Servicename: service.Name,
}
// continue with subdirectories
stat, err := os.Stat(v.Source)
if err != nil {
return err
}
if stat.IsDir() {
files, err := filepath.Glob(filepath.Join(v.Source, "*"))
if err != nil {
return err
}
for _, f := range files {
if f == v.Source {
continue
}
if stat, err := os.Stat(f); err != nil || !stat.IsDir() {
continue
}
cm := NewConfigMapFromFiles(service, appName, f)
var err error
var y []byte
if y, err = cm.Yaml(); err != nil {
log.Fatal(err)
}
log.Printf("Adding configmap %s %s", cm.Filename(), f)
chart.Templates[cm.Filename()] = &ChartTemplate{
Content: y,
Servicename: service.Name,
}
}
}
}
}
return nil
}
func generateConfigMapsAndSecrets(project *types.Project, chart *HelmChart) error {
appName := chart.Name
for _, s := range project.Services {
if s.Environment == nil || len(s.Environment) == 0 {
continue
}
originalEnv := types.MappingWithEquals{}
secretsVar := types.MappingWithEquals{}
// copy env to originalEnv
for k, v := range s.Environment {
originalEnv[k] = v
}
if v, ok := s.Labels[LABEL_SECRETS]; ok {
list := []string{}
if err := yaml.Unmarshal([]byte(v), &list); 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)
y, _ := cm.Yaml()
name := cm.service.Name
chart.Templates[name+".configmap.yaml"] = &ChartTemplate{
Content: y,
Servicename: s.Name,
}
}
}
return nil
}
func mergePods(target, from *Deployment, services map[string]*Service, chart *HelmChart) {
targetName := target.service.Name
fromName := from.service.Name
// copy the volumes from the source deployment
for _, v := range from.Spec.Template.Spec.Volumes {
// ensure that the volume is not already present
found := false
for _, tv := range target.Spec.Template.Spec.Volumes {
if tv.Name == v.Name {
found = true
break
}
}
if found {
continue
}
target.Spec.Template.Spec.Volumes = append(target.Spec.Template.Spec.Volumes, v)
}
// copy the containers from the source deployment
for _, c := range from.Spec.Template.Spec.Containers {
target.Spec.Template.Spec.Containers = append(target.Spec.Template.Spec.Containers, c)
}
// copy the init containers from the source deployment
for _, c := range from.Spec.Template.Spec.InitContainers {
target.Spec.Template.Spec.InitContainers = append(target.Spec.Template.Spec.InitContainers, c)
}
// drop the deployment from the chart
delete(chart.Templates, fromName+".deployment.yaml")
// rewite the target deployment
y, err := target.Yaml()
if err != nil {
log.Fatal("error rewriting deployment:", err)
}
chart.Templates[target.Filename()] = &ChartTemplate{
Content: y,
Servicename: targetName,
}
// now, if the source deployment has a service, we need to merge it with the target service
if _, ok := chart.Templates[targetName+".service.yaml"]; ok {
container, _ := utils.GetContainerByName(fromName, target.Spec.Template.Spec.Containers)
if container.Ports == nil || len(container.Ports) == 0 {
return
}
targetService := services[targetName]
for _, port := range container.Ports {
targetService.AddPort(types.ServicePortConfig{
Target: uint32(port.ContainerPort),
Protocol: "TCP",
}, port.Name)
}
// rewrite the tartget service
y, _ := targetService.Yaml()
chart.Templates[targetName+".service.yaml"] = &ChartTemplate{
Content: y,
Servicename: target.service.Name,
}
// and remove the source service from the chart
delete(chart.Templates, fromName+".service.yaml")
// In Valuses, remove the "replicas" key from the source service
if v, ok := chart.Values[fromName]; ok {
// if v is a Value
if v, ok := v.(*Value); ok {
v.Replicas = nil
}
}
}
}
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 service.Volumes == nil || len(service.Volumes) == 0 {
return false
}
targetDeployment := ""
if targetName, ok := service.Labels[LABEL_SAME_POD]; !ok {
return false
} else {
targetDeployment = targetName
}
// get the target deployment
var target *Deployment
for _, d := range deployments {
if d.service.Name == targetDeployment {
target = d
break
}
}
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 setSharedConf(service types.ServiceConfig, chart *HelmChart, 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[LABEL_ENV_FROM]; !ok {
return
}
fromservices := []string{}
if err := yaml.Unmarshal([]byte(service.Labels[LABEL_ENV_FROM]), &fromservices); 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
var target *Deployment
for _, d := range deployments {
if d.service.Name == service.Name {
target = d
break
}
}
if target == nil {
continue
}
// add the configmap to the service
for i, c := range target.Spec.Template.Spec.Containers {
if c.Name != service.Name {
continue
}
c.EnvFrom = append(c.EnvFrom, corev1.EnvFromSource{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: utils.TplName(fromservice, chart.Name),
},
},
})
target.Spec.Template.Spec.Containers[i] = c
}
}
}

19
generator/globals.go Normal file
View File

@@ -0,0 +1,19 @@
package generator
import "regexp"
var (
// regexp to all tpl strings
tplValueRegexp = regexp.MustCompile(`\{\{.*\}\}-`)
// 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{
KATENARY_PREFIX + "version": Version,
}
)

36
generator/helmHelper.tpl Normal file
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 -}}

19
generator/helper.go Normal file
View File

@@ -0,0 +1,19 @@
package generator
import (
_ "embed"
"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__", KATENARY_PREFIX)
helmHelper = strings.ReplaceAll(helmHelper, "__VERSION__", "0.1.0")
return helmHelper
}

175
generator/ingress.go Normal file
View File

@@ -0,0 +1,175 @@
package generator
import (
"katenary/utils"
"log"
"strings"
"github.com/compose-spec/compose-go/types"
goyaml "gopkg.in/yaml.v3"
networkv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
)
var _ Yaml = (*Ingress)(nil)
type Ingress struct {
*networkv1.Ingress
service *types.ServiceConfig `yaml:"-"`
}
// NewIngress creates a new Ingress from a compose service.
func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress {
appName := Chart.Name
// parse the KATENARY_PREFIX/ingress label from the service
if service.Labels == nil {
service.Labels = make(map[string]string)
}
var label string
var ok bool
if label, ok = service.Labels[LABEL_INGRESS]; !ok {
return nil
}
mapping := map[string]interface{}{
"enabled": false,
"host": service.Name + ".tld",
"path": "/",
"class": "-",
}
if err := goyaml.Unmarshal([]byte(label), &mapping); err != nil {
log.Fatalf("Failed to parse ingress label: %s\n", err)
}
// create the ingress
pathType := networkv1.PathTypeImplementationSpecific
serviceName := `{{ include "` + appName + `.fullname" . }}-` + service.Name
if v, ok := mapping["port"]; ok {
if port, ok := v.(int); ok {
mapping["port"] = int32(port)
}
} else {
log.Fatalf("No port provided for ingress target in service %s\n", service.Name)
}
// 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"].(bool),
Path: mapping["path"].(string),
Host: mapping["host"].(string),
Class: mapping["class"].(string),
Annotations: map[string]string{},
}
//ingressClassName := `{{ .Values.` + service.Name + `.ingress.class }}`
ingressClassName := utils.TplValue(service.Name, "ingress.class")
servicePortName := utils.GetServiceNameByPort(int(mapping["port"].(int32)))
ingressService := &networkv1.IngressServiceBackend{
Name: serviceName,
Port: networkv1.ServiceBackendPort{},
}
if servicePortName != "" {
ingressService.Port.Name = servicePortName
} else {
ingressService.Port.Number = mapping["port"].(int32)
}
ing := &Ingress{
service: &service,
Ingress: &networkv1.Ingress{
TypeMeta: metav1.TypeMeta{
Kind: "Ingress",
APIVersion: "networking.k8s.io/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: utils.TplName(service.Name, appName),
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Spec: networkv1.IngressSpec{
IngressClassName: &ingressClassName,
Rules: []networkv1.IngressRule{
{
Host: utils.TplValue(service.Name, "ingress.host"),
IngressRuleValue: networkv1.IngressRuleValue{
HTTP: &networkv1.HTTPIngressRuleValue{
Paths: []networkv1.HTTPIngressPath{
{
Path: utils.TplValue(service.Name, "ingress.path"),
PathType: &pathType,
Backend: networkv1.IngressBackend{
Service: ingressService,
},
},
},
},
},
},
},
TLS: []networkv1.IngressTLS{
{
Hosts: []string{
`{{ tpl .Values.` + service.Name + `.ingress.host . }}`,
},
SecretName: `{{ include "` + appName + `.fullname" . }}-` + service.Name + `-tls`,
},
},
},
},
}
return ing
}
func (ingress *Ingress) Yaml() ([]byte, error) {
serviceName := ingress.service.Name
ret, err := yaml.Marshal(ingress)
if err != nil {
return nil, err
}
lines := strings.Split(string(ret), "\n")
out := []string{
`{{- if .Values.` + serviceName + `.ingress.enabled -}}`,
}
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
}
func (ingress *Ingress) Filename() string {
return ingress.service.Name + ".ingress.yaml"
}

229
generator/katenaryLabels.go Normal file
View File

@@ -0,0 +1,229 @@
package generator
import (
"bytes"
_ "embed"
"fmt"
"katenary/utils"
"regexp"
"sort"
"strings"
"text/tabwriter"
"text/template"
"sigs.k8s.io/yaml"
)
var (
// Set the documentation of labels here
//
//go:embed katenaryLabelsDoc.yaml
labelFullHelpYAML []byte
// parsed yaml
labelFullHelp map[string]Help
)
// Label is a katenary label to find in compose files.
type Label = string
// 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"`
}
const KATENARY_PREFIX = "katenary.v3/"
// Known labels.
const (
LABEL_MAIN_APP Label = KATENARY_PREFIX + "main-app"
LABEL_VALUES Label = KATENARY_PREFIX + "values"
LABEL_SECRETS Label = KATENARY_PREFIX + "secrets"
LABEL_PORTS Label = KATENARY_PREFIX + "ports"
LABEL_INGRESS Label = KATENARY_PREFIX + "ingress"
LABEL_MAP_ENV Label = KATENARY_PREFIX + "map-env"
LABEL_HEALTHCHECK Label = KATENARY_PREFIX + "health-check"
LABEL_SAME_POD Label = KATENARY_PREFIX + "same-pod"
LABEL_DESCRIPTION Label = KATENARY_PREFIX + "description"
LABEL_IGNORE Label = KATENARY_PREFIX + "ignore"
LABEL_DEPENDENCIES Label = KATENARY_PREFIX + "dependencies"
LABEL_CM_FILES Label = KATENARY_PREFIX + "configmap-files"
LABEL_CRONJOB Label = KATENARY_PREFIX + "cronjob"
LABEL_ENV_FROM Label = KATENARY_PREFIX + "env-from"
)
func init() {
if err := yaml.Unmarshal(labelFullHelpYAML, &labelFullHelp); err != nil {
panic(err)
}
}
// Generate the help for the labels.
func GetLabelHelp(asMarkdown bool) string {
names := GetLabelNames() // sorted
if !asMarkdown {
return generatePlainHelp(names)
}
return generateMarkdownHelp(names)
}
func generatePlainHelp(names []string) string {
var builder strings.Builder
for _, name := range names {
help := labelFullHelp[name]
fmt.Fprintf(&builder, "%s%s:\t%s\t%s\n", KATENARY_PREFIX, 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 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)+2+len(KATENARY_PREFIX))
maxDescriptionLength = max(maxDescriptionLength, len(help.Short))
maxTypeLength = max(maxTypeLength, len(help.Type))
}
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, "`"+KATENARY_PREFIX+name+"`", // enclose in backticks
maxDescriptionLength, help.Short,
maxTypeLength, help.Type,
)
}
return builder.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),
)
}
// 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")
// get help template
helpTemplate := getHelpTemplate(asMarkdown)
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)
}
var buf bytes.Buffer
template.Must(template.New("shorthelp").Parse(help.Long)).Execute(&buf, struct {
KATENARY_PREFIX string
}{
KATENARY_PREFIX: KATENARY_PREFIX,
})
help.Long = buf.String()
buf.Reset()
template.Must(template.New("example").Parse(help.Example)).Execute(&buf, struct {
KATENARY_PREFIX string
}{
KATENARY_PREFIX: KATENARY_PREFIX,
})
help.Example = buf.String()
buf.Reset()
template.Must(template.New("complete").Parse(helpTemplate)).Execute(&buf, struct {
Name string
Help Help
KATENARY_PREFIX string
}{
Name: labelname,
Help: help,
KATENARY_PREFIX: KATENARY_PREFIX,
})
return buf.String()
}
// 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 getHelpTemplate(asMarkdown bool) string {
if asMarkdown {
return `## {{ .KATENARY_PREFIX }}{{ .Name }}
{{ .Help.Short }}
**Type**: ` + "`" + `{{ .Help.Type }}` + "`" + `
{{ .Help.Long }}
**Example:**` + "\n\n```yaml\n" + `{{ .Help.Example }}` + "\n```\n"
}
return `{{ .KATENARY_PREFIX }}{{ .Name }}: {{ .Help.Short }}
Type: {{ .Help.Type }}
{{ .Help.Long }}
Example:
{{ .Help.Example }}
`
}

36
generator/labels.go Normal file
View File

@@ -0,0 +1,36 @@
package generator
import (
"fmt"
)
// LabelType identifies the type of label to generate in objects.
// TODO: is this still needed?
type LabelType uint8
const (
DeploymentLabel LabelType = iota
ServiceLabel
)
func GetLabels(serviceName, appName string) map[string]string {
labels := map[string]string{
KATENARY_PREFIX + "component": serviceName,
}
key := `{{- include "%s.labels" . | nindent __indent__ }}`
labels[`__replace_`+serviceName] = fmt.Sprintf(key, appName)
return labels
}
func GetMatchLabels(serviceName, appName string) map[string]string {
labels := map[string]string{
KATENARY_PREFIX + "component": serviceName,
}
key := `{{- include "%s.selectorLabels" . | nindent __indent__ }}`
labels[`__replace_`+serviceName] = fmt.Sprintf(key, appName)
return labels
}

View File

@@ -1,304 +0,0 @@
package generator
import (
"fmt"
"io/ioutil"
"katenary/helm"
"katenary/logger"
"katenary/tools"
"log"
"net/url"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"github.com/compose-spec/compose-go/types"
)
type EnvVal = helm.EnvValue
const (
ICON_PACKAGE = "📦"
ICON_SERVICE = "🔌"
ICON_SECRET = "🔏"
ICON_CONF = "📝"
ICON_STORE = "⚡"
ICON_INGRESS = "🌐"
ICON_RBAC = "🔑"
ICON_CRON = "🕒"
)
var (
EmptyDirs = []string{}
servicesMap = make(map[string]int)
locker = &sync.Mutex{}
dependScript = `
OK=0
echo "Checking __service__ port"
while [ $OK != 1 ]; do
echo -n "."
nc -z ` + helm.ReleaseNameTpl + `-__service__ __port__ 2>&1 >/dev/null && OK=1 || sleep 1
done
echo
echo "Done"
`
madeDeployments = make(map[string]helm.Deployment, 0)
)
// Create a Deployment for a given compose.Service. It returns a list chan
// of HelmFileGenerator which will be used to generate the files (deployment, secrets, configMap...).
func CreateReplicaObject(name string, s types.ServiceConfig, linked map[string]types.ServiceConfig) HelmFileGenerator {
ret := make(chan HelmFile, runtime.NumCPU())
// there is a bug woth typs.ServiceConfig if we use the pointer. So we need to dereference it.
go buildDeployment(name, &s, linked, ret)
return ret
}
// Create a service (k8s).
func generateServicesAndIngresses(name string, s *types.ServiceConfig) []HelmFile {
ret := make([]HelmFile, 0) // can handle helm.Service or helm.Ingress
logger.Magenta(ICON_SERVICE+" Generating service for ", name)
ks := helm.NewService(name)
for _, p := range s.Ports {
target := int(p.Target)
ks.Spec.Ports = append(ks.Spec.Ports, helm.NewServicePort(target, target))
}
ks.Spec.Selector = buildSelector(name, s)
ret = append(ret, ks)
if v, ok := s.Labels[helm.LABEL_INGRESS]; ok {
port, err := strconv.Atoi(v)
if err != nil {
log.Fatalf("The given port \"%v\" as ingress port in \"%s\" service is not an integer\n", v, name)
}
logger.Cyanf(ICON_INGRESS+" Create an ingress for port %d on %s service\n", port, name)
ing := createIngress(name, port, s)
ret = append(ret, ing)
}
if len(s.Expose) > 0 {
logger.Magenta(ICON_SERVICE+" Generating service for ", name+"-external")
ks := helm.NewService(name + "-external")
ks.Spec.Type = "NodePort"
for _, expose := range s.Expose {
p, _ := strconv.Atoi(expose)
ks.Spec.Ports = append(ks.Spec.Ports, helm.NewServicePort(p, p))
}
ks.Spec.Selector = buildSelector(name, s)
ret = append(ret, ks)
}
return ret
}
// Create an ingress.
func createIngress(name string, port int, s *types.ServiceConfig) *helm.Ingress {
ingress := helm.NewIngress(name)
annotations := map[string]string{}
ingressVal := map[string]interface{}{
"class": "nginx",
"host": name + "." + helm.Appname + ".tld",
"enabled": false,
"annotations": annotations,
}
// add Annotations in values
AddValues(name, map[string]EnvVal{"ingress": ingressVal})
ingress.Spec.Rules = []helm.IngressRule{
{
Host: fmt.Sprintf("{{ .Values.%s.ingress.host }}", name),
Http: helm.IngressHttp{
Paths: []helm.IngressPath{{
Path: "/",
PathType: "Prefix",
Backend: &helm.IngressBackend{
Service: helm.IngressService{
Name: helm.ReleaseNameTpl + "-" + name,
Port: map[string]interface{}{
"number": port,
},
},
},
}},
},
},
}
ingress.SetIngressClass(name)
return ingress
}
// Build the selector for the service.
func buildSelector(name string, s *types.ServiceConfig) map[string]string {
return map[string]string{
"katenary.io/component": name,
"katenary.io/release": helm.ReleaseNameTpl,
}
}
// buildConfigMapFromPath generates a ConfigMap from a path.
func buildConfigMapFromPath(name, path string) *helm.ConfigMap {
stat, err := os.Stat(path)
if err != nil {
return nil
}
files := make(map[string]string, 0)
if stat.IsDir() {
found, _ := filepath.Glob(path + "/*")
for _, f := range found {
if s, err := os.Stat(f); err != nil || s.IsDir() {
if err != nil {
fmt.Fprintf(os.Stderr, "An error occured reading volume path %s\n", err.Error())
} else {
logger.ActivateColors = true
logger.Yellowf("Warning, %s is a directory, at this time we only "+
"can create configmap for first level file list\n", f)
logger.ActivateColors = false
}
continue
}
_, filename := filepath.Split(f)
c, _ := ioutil.ReadFile(f)
files[filename] = string(c)
}
} else {
c, _ := ioutil.ReadFile(path)
_, filename := filepath.Split(path)
files[filename] = string(c)
}
cm := helm.NewConfigMap(name, tools.GetRelPath(path))
cm.Data = files
return cm
}
// prepareProbes generate http/tcp/command probes for a service.
func prepareProbes(name string, s *types.ServiceConfig, container *helm.Container) {
// first, check if there a label for the probe
if check, ok := s.Labels[helm.LABEL_HEALTHCHECK]; ok {
check = strings.TrimSpace(check)
p := helm.NewProbeFromService(s)
// get the port of the "url" check
if checkurl, err := url.Parse(check); err == nil {
if err == nil {
container.LivenessProbe = buildProtoProbe(p, checkurl)
}
} else {
// it's a command
container.LivenessProbe = p
container.LivenessProbe.Exec = &helm.Exec{
Command: []string{
"sh",
"-c",
check,
},
}
}
return // label overrides everything
}
// if not, we will use the default one
if s.HealthCheck != nil {
container.LivenessProbe = buildCommandProbe(s)
}
}
// buildProtoProbe builds a probe from a url that can be http or tcp.
func buildProtoProbe(probe *helm.Probe, u *url.URL) *helm.Probe {
port, err := strconv.Atoi(u.Port())
if err != nil {
port = 80
}
path := "/"
if u.Path != "" {
path = u.Path
}
switch u.Scheme {
case "http", "https":
probe.HttpGet = &helm.HttpGet{
Path: path,
Port: port,
}
case "tcp":
probe.TCP = &helm.TCP{
Port: port,
}
default:
logger.Redf("Error while parsing healthcheck url %s\n", u.String())
os.Exit(1)
}
return probe
}
func buildCommandProbe(s *types.ServiceConfig) *helm.Probe {
// Get the first element of the command from ServiceConfig
first := s.HealthCheck.Test[0]
p := helm.NewProbeFromService(s)
switch first {
case "CMD", "CMD-SHELL":
// CMD or CMD-SHELL
p.Exec = &helm.Exec{
Command: s.HealthCheck.Test[1:],
}
return p
default:
// badly made but it should work...
p.Exec = &helm.Exec{
Command: []string(s.HealthCheck.Test),
}
return p
}
}
func setSecretVar(name string, s *types.ServiceConfig, c *helm.Container) *helm.Secret {
// get the list of secret vars
secretvars, ok := s.Labels[helm.LABEL_SECRETVARS]
if !ok {
return nil
}
store := helm.NewSecret(name, "")
for _, secretvar := range strings.Split(secretvars, ",") {
secretvar = strings.TrimSpace(secretvar)
// get the value from env
_, ok := s.Environment[secretvar]
if !ok {
continue
}
// add the secret
store.AddEnv(secretvar, ".Values."+name+".environment."+secretvar)
AddEnvironment(name, secretvar, *s.Environment[secretvar])
// Finally remove the secret var from the environment on the service
// and the helm container definition.
defer func(secretvar string) { // defered because AddEnvironment locks the memory
locker.Lock()
defer locker.Unlock()
for i, env := range c.Env {
if env.Name == secretvar {
c.Env = append(c.Env[:i], c.Env[i+1:]...)
i--
}
}
delete(s.Environment, secretvar)
}(secretvar)
}
return store
}

View File

@@ -1,397 +0,0 @@
package generator
import (
"io/ioutil"
"katenary/compose"
"katenary/helm"
"katenary/logger"
"os"
"path/filepath"
"strings"
"testing"
"github.com/compose-spec/compose-go/cli"
)
const DOCKER_COMPOSE_YML = `version: '3'
services:
# first service, very simple
http:
image: nginx
ports:
- "80:80"
# second service, with environment variables
http2:
image: nginx
environment:
SOME_ENV_VAR: some_value
ANOTHER_ENV_VAR: another_value
# third service with ingress label
web:
image: nginx
ports:
- "80:80"
labels:
katenary.io/ingress: 80
web2:
image: nginx
command: ["/bin/sh", "-c", "while true; do echo hello; sleep 1; done"]
# fourth service is a php service depending on database
php:
image: php:7.2-apache
depends_on:
- database
environment:
SOME_ENV_VAR: some_value
ANOTHER_ENV_VAR: another_value
DB_HOST: database
labels:
katenary.io/mapenv: |
DB_HOST: {{ .Release.Name }}-database
database:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: database
MYSQL_USER: user
MYSQL_PASSWORD: password
volumes:
- data:/var/lib/mysql
labels:
katenary.io/ports: 3306
# try to deploy 2 services but one is in the same pod than the other
http3:
image: nginx
http4:
image: nginx
labels:
katenary.io/same-pod: http3
# unmapped volumes
novol:
image: nginx
volumes:
- /tmp/data
labels:
katenary.io/ports: 80
# use = sign for environment variables
eqenv:
image: nginx
environment:
- SOME_ENV_VAR=some_value
- ANOTHER_ENV_VAR=another_value
# use environment file
useenvfile:
image: nginx
env_file:
- config/env
volumes:
data:
`
var defaultCliFiles = cli.DefaultFileNames
var TMP_DIR = ""
var TMPWORK_DIR = ""
func init() {
logger.NOLOG = len(os.Getenv("NOLOG")) < 1
}
func setUp(t *testing.T) (string, *compose.Parser) {
// cleanup "made" files
helm.ResetMadePVC()
cli.DefaultFileNames = defaultCliFiles
// create a temporary directory
tmp, err := os.MkdirTemp(os.TempDir(), "katenary-test-")
if err != nil {
t.Fatal(err)
}
tmpwork, err := os.MkdirTemp(os.TempDir(), "katenary-test-work-")
if err != nil {
t.Fatal(err)
}
composefile := filepath.Join(tmpwork, "docker-compose.yaml")
p := compose.NewParser([]string{composefile}, DOCKER_COMPOSE_YML)
// create envfile for "useenvfile" service
err = os.Mkdir(filepath.Join(tmpwork, "config"), 0777)
if err != nil {
t.Fatal(err)
}
envfile := filepath.Join(tmpwork, "config", "env")
fp, err := os.Create(envfile)
if err != nil {
t.Fatal("MKFILE", err)
}
fp.WriteString("FILEENV1=some_value\n")
fp.WriteString("FILEENV2=another_value\n")
fp.Close()
TMP_DIR = tmp
TMPWORK_DIR = tmpwork
p.Parse("testapp")
Generate(p, "test-0", "testapp", "1.2.3", "4.5.6", DOCKER_COMPOSE_YML, tmp)
return tmp, p
}
func tearDown() {
if len(TMP_DIR) > 0 {
os.RemoveAll(TMP_DIR)
}
if len(TMPWORK_DIR) > 0 {
os.RemoveAll(TMPWORK_DIR)
}
}
// Check if the web2 service has got a command.
func TestCommand(t *testing.T) {
tmp, p := setUp(t)
defer tearDown()
for _, service := range p.Data.Services {
name := service.Name
if name == "web2" {
// Ensure that the command is correctly set
// The command should be a string array
path := filepath.Join(tmp, "templates", name+".deployment.yaml")
path = filepath.Join(tmp, "templates", name+".deployment.yaml")
fp, _ := os.Open(path)
defer fp.Close()
lines, _ := ioutil.ReadAll(fp)
next := false
commands := make([]string, 0)
for _, line := range strings.Split(string(lines), "\n") {
if strings.Contains(line, "command") {
next = true
continue
}
if next {
commands = append(commands, line)
}
}
ok := 0
for _, command := range commands {
if strings.Contains(command, "- /bin/sh") {
ok++
}
if strings.Contains(command, "- -c") {
ok++
}
if strings.Contains(command, "while true; do") {
ok++
}
}
if ok != 3 {
t.Error("Command is not correctly set")
}
}
}
}
// Check if environment is correctly set.
func TestEnvs(t *testing.T) {
tmp, p := setUp(t)
defer tearDown()
for _, service := range p.Data.Services {
name := service.Name
if name == "php" {
// the "DB_HOST" environment variable inside the template must be set to '{{ .Release.Name }}-database'
path := filepath.Join(tmp, "templates", name+".deployment.yaml")
// read the file and find the DB_HOST variable
matched := false
fp, _ := os.Open(path)
defer fp.Close()
lines, _ := ioutil.ReadAll(fp)
next := false
for _, line := range strings.Split(string(lines), "\n") {
if !next && strings.Contains(line, "name: DB_HOST") {
next = true
continue
} else if next && strings.Contains(line, "value:") {
matched = true
if !strings.Contains(line, "{{ tpl .Values.php.environment.DB_HOST . }}") {
t.Error("DB_HOST variable should be set to {{ tpl .Values.php.environment.DB_HOST . }}", line, string(lines))
}
break
}
}
if !matched {
t.Error("DB_HOST variable not found in ", path)
t.Log(string(lines))
}
}
}
}
// Check if the same pod is not deployed twice.
func TestSamePod(t *testing.T) {
tmp, p := setUp(t)
defer tearDown()
for _, service := range p.Data.Services {
name := service.Name
path := filepath.Join(tmp, "templates", name+".deployment.yaml")
if _, found := service.Labels[helm.LABEL_SAMEPOD]; found {
// fail if the service has a deployment
if _, err := os.Stat(path); err == nil {
t.Error("Service ", name, " should not have a deployment")
}
continue
}
// others should have a deployment file
t.Log("Checking ", name, " deployment file")
_, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
}
}
// Check if the ports are correctly set.
func TestPorts(t *testing.T) {
tmp, p := setUp(t)
defer tearDown()
for _, service := range p.Data.Services {
name := service.Name
path := ""
// if the service has a port found in helm.LABEL_PORT or ports, so the service file should exist
hasPort := false
if _, found := service.Labels[helm.LABEL_PORT]; found {
hasPort = true
}
if service.Ports != nil {
hasPort = true
}
if hasPort {
path = filepath.Join(tmp, "templates", name+".service.yaml")
t.Log("Checking ", name, " service file")
_, err := os.Stat(path)
if err != nil {
t.Error(err)
}
}
}
}
// Check if the volumes are correctly set.
func TestPVC(t *testing.T) {
tmp, p := setUp(t)
defer tearDown()
for _, service := range p.Data.Services {
name := service.Name
path := filepath.Join(tmp, "templates", name+"-data.pvc.yaml")
// the "database" service should have a pvc file in templates (name-data.pvc.yaml)
if name == "database" {
path = filepath.Join(tmp, "templates", name+"-data.pvc.yaml")
t.Log("Checking ", name, " pvc file")
_, err := os.Stat(path)
if err != nil {
list, _ := filepath.Glob(tmp + "/templates/*")
t.Log(list)
t.Fatal(err)
}
}
}
}
//Check if web service has got a ingress.
func TestIngress(t *testing.T) {
tmp, p := setUp(t)
defer tearDown()
for _, service := range p.Data.Services {
name := service.Name
path := filepath.Join(tmp, "templates", name+".ingress.yaml")
// the "web" service should have a ingress file in templates (name.ingress.yaml)
if name == "web" {
path = filepath.Join(tmp, "templates", name+".ingress.yaml")
t.Log("Checking ", name, " ingress file")
_, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
}
}
}
// Check unmapped volumes
func TestUnmappedVolumes(t *testing.T) {
tmp, p := setUp(t)
defer tearDown()
for _, service := range p.Data.Services {
name := service.Name
if name == "novol" {
path := filepath.Join(tmp, "templates", name+".deployment.yaml")
fp, _ := os.Open(path)
defer fp.Close()
lines, _ := ioutil.ReadAll(fp)
for _, line := range strings.Split(string(lines), "\n") {
if strings.Contains(line, "novol-data") {
t.Error("novol service should not have a volume")
}
}
}
}
}
// Check if service using equal sign for environment works
func TestEqualSignOnEnv(t *testing.T) {
tmp, p := setUp(t)
defer tearDown()
// if the name is eqenv, the service should habe environment
for _, service := range p.Data.Services {
name := service.Name
if name == "eqenv" {
path := filepath.Join(tmp, "templates", name+".deployment.yaml")
fp, _ := os.Open(path)
defer fp.Close()
lines, _ := ioutil.ReadAll(fp)
match := 0
for _, line := range strings.Split(string(lines), "\n") {
// we must find the line with the environment variable name
if strings.Contains(line, "SOME_ENV_VAR") {
// we must find the line with the environment variable value
match++
}
if strings.Contains(line, "ANOTHER_ENV_VAR") {
// we must find the line with the environment variable value
match++
}
}
if match != 4 { // because the value points on .Values...
t.Error("eqenv service should have 2 environment variables")
t.Log(string(lines))
}
}
}
}

139
generator/rbac.go Normal file
View File

@@ -0,0 +1,139 @@
package generator
import (
"katenary/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) Yaml() ([]byte, error) {
return yaml.Marshal(r)
}
func (r *RoleBinding) Filename() string {
return r.service.Name + ".rolebinding.yaml"
}
// Role is a kubernetes Role.
type Role struct {
*rbacv1.Role
service *types.ServiceConfig
}
func (r *Role) Yaml() ([]byte, error) {
return yaml.Marshal(r)
}
func (r *Role) Filename() string {
return r.service.Name + ".role.yaml"
}
// ServiceAccount is a kubernetes ServiceAccount.
type ServiceAccount struct {
*corev1.ServiceAccount
service *types.ServiceConfig
}
func (r *ServiceAccount) Yaml() ([]byte, error) {
return yaml.Marshal(r)
}
func (r *ServiceAccount) Filename() string {
return r.service.Name + ".serviceaccount.yaml"
}

111
generator/secret.go Normal file
View File

@@ -0,0 +1,111 @@
package generator
import (
"encoding/base64"
"fmt"
"katenary/utils"
"strings"
"github.com/compose-spec/compose-go/types"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
)
var _ DataMap = (*Secret)(nil)
var _ 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, LABEL_VALUES)
for value := range varDescriptons {
valueList = append(valueList, value)
}
// wrap values with quotes
for _, value := range service.Environment {
if value == nil {
continue
}
*value = fmt.Sprintf(`"%s"`, *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
}
// SetData sets the data of the secret.
func (s *Secret) SetData(data map[string]string) {
for key, value := range data {
s.AddData(key, fmt.Sprintf("%s", value))
}
}
// AddData adds a key value pair to the secret.
func (s *Secret) AddData(key string, value string) {
if value == "" {
return
}
s.Data[key] = []byte(`{{ tpl ` + value + ` $ | quote | b64enc }}`)
}
// Yaml returns the yaml representation of the secret.
func (s *Secret) Yaml() ([]byte, error) {
y, err := yaml.Marshal(s)
if 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
}
// Filename returns the filename of the secret.
func (s *Secret) Filename() string {
return s.service.Name + ".secret.yaml"
}

95
generator/service.go Normal file
View File

@@ -0,0 +1,95 @@
package generator
import (
"katenary/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"
"sigs.k8s.io/yaml"
)
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) {
name := s.service.Name
if len(serviceName) > 0 {
name = serviceName[0]
}
var finalport intstr.IntOrString
if targetPort := utils.GetServiceNameByPort(int(port.Target)); targetPort == "" {
finalport = intstr.FromInt(int(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,
})
}
// Yaml returns the yaml representation of the service.
func (s *Service) Yaml() ([]byte, error) {
y, err := yaml.Marshal(s)
lines := []string{}
for _, line := range strings.Split(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
}
// Filename returns the filename of the service.
func (s *Service) Filename() string {
return s.service.Name + ".service.yaml"
}

13
generator/types.go Normal file
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

@@ -1,77 +1,121 @@
package generator
import (
"katenary/helm"
"strings"
"github.com/compose-spec/compose-go/types"
)
var (
// Values is kept in memory to create a values.yaml file.
Values = make(map[string]map[string]interface{})
)
// Values is a map of all values for all services. Written to values.yaml.
// var Values = map[string]any{}
// AddValues adds values to the values.yaml map.
func AddValues(servicename string, values map[string]EnvVal) {
locker.Lock()
defer locker.Unlock()
if _, ok := Values[servicename]; !ok {
Values[servicename] = make(map[string]interface{})
}
for k, v := range values {
Values[servicename][k] = v
}
// 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"`
}
func AddEnvironment(servicename string, key string, val EnvVal) {
locker.Lock()
defer locker.Unlock()
if _, ok := Values[servicename]; !ok {
Values[servicename] = make(map[string]interface{})
}
if _, ok := Values[servicename]["environment"]; !ok {
Values[servicename]["environment"] = make(map[string]EnvVal)
}
Values[servicename]["environment"].(map[string]EnvVal)[key] = val
// PersistenceValue is a persistence configuration that will be saved in values.yaml.
type PersistenceValue struct {
Enabled bool `yaml:"enabled"`
StorageClass string `yaml:"storageClass"`
Size string `yaml:"size"`
AccessMode []string `yaml:"accessMode"`
}
// setEnvToValues will set the environment variables to the values.yaml map.
func setEnvToValues(name string, s *types.ServiceConfig, c *helm.Container) {
// crete the "environment" key
// IngressValue is a ingress configuration that will be saved in values.yaml.
type IngressValue struct {
Enabled bool `yaml:"enabled"`
Host string `yaml:"host"`
Path string `yaml:"path"`
Class string `yaml:"class"`
Annotations map[string]string `yaml:"annotations"`
}
env := make(map[string]EnvVal)
for k, v := range s.Environment {
env[k] = v
}
if len(env) == 0 {
return
// Value will be saved in values.yaml. It contains configuraiton for all deployment and services.
// The content will be lile:
//
// name_of_component:
// repository:
// image: image_name
// tag: image_tag
// persistence:
// enabled: true
// storageClass: storage_class_name
// ingress:
// enabled: true
// host: host_name
// path: path_name
// environment:
// ENV_VAR_1: value_1
// ENV_VAR_2: value_2
type Value struct {
Repository *RepositoryValue `yaml:"repository,omitempty"`
Persistence map[string]*PersistenceValue `yaml:"persistence,omitempty"`
Ingress *IngressValue `yaml:"ingress,omitempty"`
ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"`
Environment map[string]any `yaml:"environment,omitempty"`
Replicas *uint32 `yaml:"replicas,omitempty"`
CronJob *CronJobValue `yaml:"cronjob,omitempty"`
}
// 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"`
}
// 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,
}
for k, v := range env {
k = strings.ReplaceAll(k, ".", "_")
AddEnvironment(name, k, v)
// find the image tag
tag := ""
split := strings.Split(service.Image, ":")
v.Repository = &RepositoryValue{
Image: split[0],
}
//AddValues(name, map[string]EnvVal{"environment": valuesEnv})
for k := range env {
fixedK := strings.ReplaceAll(k, ".", "_")
v := "{{ tpl .Values." + name + ".environment." + fixedK + " . }}"
s.Environment[k] = &v
touched := false
for _, c := range c.Env {
if c.Name == k {
c.Value = v
touched = true
}
}
if !touched {
c.Env = append(c.Env, &helm.Value{Name: k, Value: v})
// 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[1]
}
v.Repository.Tag = tag
} else {
v.Repository.Tag = ""
}
return v
}
// AddPersistence adds persistence configuration to the Value.
func (v *Value) AddPersistence(volumeName string) {
if v.Persistence == nil {
v.Persistence = make(map[string]*PersistenceValue, 0)
}
v.Persistence[volumeName] = &PersistenceValue{
Enabled: true,
StorageClass: "-",
Size: "1Gi",
AccessMode: []string{"ReadWriteOnce"},
}
}
func (v *Value) AddIngress(host, path string) {
v.Ingress = &IngressValue{
Enabled: true,
Host: host,
Path: path,
Class: "-",
}
}

4
generator/version.go Normal file
View File

@@ -0,0 +1,4 @@
package generator
// Version is the version of katenary. It is set at compile time.
var Version = "master" // changed at compile time

119
generator/volume.go Normal file
View File

@@ -0,0 +1,119 @@
package generator
import (
"katenary/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"
"sigs.k8s.io/yaml"
)
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 {
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) + "-" + volumeName,
Labels: GetLabels(service.Name, appName),
Annotations: Annotations,
},
Spec: v1.PersistentVolumeClaimSpec{
AccessModes: []v1.PersistentVolumeAccessMode{
v1.ReadWriteOnce,
},
StorageClassName: utils.StrPtr(`{{ .Values.` + service.Name + `.persistence.` + volumeName + `.storageClass }}`),
Resources: v1.ResourceRequirements{
Requests: v1.ResourceList{
v1.ResourceStorage: resource.MustParse("1Gi"),
},
},
},
},
}
}
// Yaml marshals a VolumeClaim into yaml.
func (v *VolumeClaim) Yaml() ([]byte, error) {
serviceName := v.service.Name
if v.nameOverride != "" {
serviceName = v.nameOverride
}
volumeName := v.volumeName
out, err := yaml.Marshal(v)
if err != nil {
return nil, err
}
// replace 1Gi to {{ .Values.serviceName.volume.size }}
out = []byte(
strings.Replace(
string(out),
"1Gi",
utils.TplValue(serviceName, "persistence."+volumeName+".size"),
1,
),
)
out = []byte(
strings.Replace(
string(out),
"- ReadWriteOnce",
"{{- .Values."+
serviceName+
".persistence."+
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+".persistence."+volumeName+".storageClass \"-\" }}",
"{{- end }}",
)
}
}
out = []byte(strings.Join(lines, "\n"))
// add condition
out = []byte(
"{{- if .Values." +
serviceName +
".persistence." +
volumeName +
".enabled }}\n" +
string(out) +
"\n{{- end }}",
)
return out, nil
}
// Filename returns the suggested filename for a VolumeClaim.
func (v *VolumeClaim) Filename() string {
return v.service.Name + "." + v.volumeName + ".volumeclaim.yaml"
}

View File

@@ -1,236 +0,0 @@
package generator
import (
"katenary/helm"
"katenary/logger"
"katenary/tools"
"os"
"path/filepath"
"strings"
"github.com/compose-spec/compose-go/types"
"gopkg.in/yaml.v3"
)
var (
// VolumeValues is the map of volumes for each deployment
// containing volume configuration
VolumeValues = make(map[string]map[string]map[string]EnvVal)
)
// AddVolumeValues add a volume to the values.yaml map for the given deployment name.
func AddVolumeValues(deployment string, volname string, values map[string]EnvVal) {
locker.Lock()
defer locker.Unlock()
if _, ok := VolumeValues[deployment]; !ok {
VolumeValues[deployment] = make(map[string]map[string]EnvVal)
}
VolumeValues[deployment][volname] = values
}
// addVolumeFrom takes the LABEL_VOLUMEFROM to get volumes from another container. This can only work with
// container that has got LABEL_SAMEPOD as we need to get the volumes from another container in the same deployment.
func addVolumeFrom(deployment *helm.Deployment, container *helm.Container, s *types.ServiceConfig) {
labelfrom, ok := s.Labels[helm.LABEL_VOLUMEFROM]
if !ok {
return
}
// decode Yaml from the label
var volumesFrom map[string]map[string]string
err := yaml.Unmarshal([]byte(labelfrom), &volumesFrom)
if err != nil {
logger.ActivateColors = true
logger.Red(err.Error())
logger.ActivateColors = false
return
}
// for each declared volume "from", we will find it from the deployment volumes and add it to the container.
// Then, to avoid duplicates, we will remove it from the ServiceConfig object.
for name, volumes := range volumesFrom {
for volumeName := range volumes {
initianame := volumeName
volumeName = tools.PathToName(volumeName)
// get the volume from the deployment container "name"
var ctn *helm.Container
for _, c := range deployment.Spec.Template.Spec.Containers {
if c.Name == name {
ctn = c
break
}
}
if ctn == nil {
logger.ActivateColors = true
logger.Redf("VolumeFrom: container %s not found", name)
logger.ActivateColors = false
continue
}
// get the volume from the container
for _, v := range ctn.VolumeMounts {
switch v := v.(type) {
case map[string]interface{}:
if v["name"] == volumeName {
if container.VolumeMounts == nil {
container.VolumeMounts = make([]interface{}, 0)
}
// make a copy of the volume mount and then add it to the VolumeMounts
var mountpoint = make(map[string]interface{})
for k, v := range v {
mountpoint[k] = v
}
container.VolumeMounts = append(container.VolumeMounts, mountpoint)
// remove the volume from the ServiceConfig
for i, vol := range s.Volumes {
if vol.Source == initianame {
s.Volumes = append(s.Volumes[:i], s.Volumes[i+1:]...)
i--
break
}
}
}
}
}
}
}
}
// prepareVolumes add the volumes of a service.
func prepareVolumes(
deployment, name string,
s *types.ServiceConfig,
container *helm.Container,
fileGeneratorChan HelmFileGenerator) []map[string]interface{} {
volumes := make([]map[string]interface{}, 0)
mountPoints := make([]interface{}, 0)
configMapsVolumes := make([]string, 0)
if v, ok := s.Labels[helm.LABEL_VOL_CM]; ok {
configMapsVolumes = strings.Split(v, ",")
for i, cm := range configMapsVolumes {
configMapsVolumes[i] = strings.TrimSpace(cm)
}
}
for _, vol := range s.Volumes {
volname := vol.Source
volepath := vol.Target
if volname == "" {
logger.ActivateColors = true
logger.Yellowf("Warning, volume source to %s is empty for %s -- skipping\n", volepath, name)
logger.ActivateColors = false
continue
}
isConfigMap := false
for _, cmVol := range configMapsVolumes {
if tools.GetRelPath(volname) == cmVol {
isConfigMap = true
break
}
}
// local volume cannt be mounted
if !isConfigMap && (strings.HasPrefix(volname, ".") || strings.HasPrefix(volname, "/")) {
logger.ActivateColors = true
logger.Redf("You cannot, at this time, have local volume in %s deployment\n", name)
logger.ActivateColors = false
continue
}
if isConfigMap {
// check if the volname path points on a file, if so, we need to add subvolume to the interface
stat, err := os.Stat(volname)
if err != nil {
logger.ActivateColors = true
logger.Redf("An error occured reading volume path %s\n", err.Error())
logger.ActivateColors = false
continue
}
pointToFile := ""
if !stat.IsDir() {
pointToFile = filepath.Base(volname)
}
// the volume is a path and it's explicitally asked to be a configmap in labels
cm := buildConfigMapFromPath(name, volname)
cm.K8sBase.Metadata.Name = helm.ReleaseNameTpl + "-" + name + "-" + tools.PathToName(volname)
// build a configmapRef for this volume
volname := tools.PathToName(volname)
volumes = append(volumes, map[string]interface{}{
"name": volname,
"configMap": map[string]string{
"name": cm.K8sBase.Metadata.Name,
},
})
if len(pointToFile) > 0 {
mountPoints = append(mountPoints, map[string]interface{}{
"name": volname,
"mountPath": volepath,
"subPath": pointToFile,
})
} else {
mountPoints = append(mountPoints, map[string]interface{}{
"name": volname,
"mountPath": volepath,
})
}
if cm != nil {
fileGeneratorChan <- cm
}
} else {
// It's a Volume. Mount this from PVC to declare.
volname = strings.ReplaceAll(volname, "-", "")
isEmptyDir := false
for _, v := range EmptyDirs {
v = strings.ReplaceAll(v, "-", "")
if v == volname {
volumes = append(volumes, map[string]interface{}{
"name": volname,
"emptyDir": map[string]string{},
})
mountPoints = append(mountPoints, map[string]interface{}{
"name": volname,
"mountPath": volepath,
})
container.VolumeMounts = append(container.VolumeMounts, mountPoints...)
isEmptyDir = true
break
}
}
if isEmptyDir {
continue
}
volumes = append(volumes, map[string]interface{}{
"name": volname,
"persistentVolumeClaim": map[string]string{
"claimName": helm.ReleaseNameTpl + "-" + volname,
},
})
mountPoints = append(mountPoints, map[string]interface{}{
"name": volname,
"mountPath": volepath,
})
logger.Yellow(ICON_STORE+" Generate volume values", volname, "for container named", name, "in deployment", deployment)
AddVolumeValues(deployment, volname, map[string]EnvVal{
"enabled": false,
"capacity": "1Gi",
})
if pvc := helm.NewPVC(deployment, volname); pvc != nil {
fileGeneratorChan <- pvc
}
}
}
// add the volume in the container and return the volume definition to add in Deployment
container.VolumeMounts = append(container.VolumeMounts, mountPoints...)
return volumes
}

View File

@@ -1,236 +0,0 @@
package generator
import (
"katenary/compose"
"katenary/generator/writers"
"katenary/helm"
"katenary/tools"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/compose-spec/compose-go/types"
"gopkg.in/yaml.v3"
)
// HelmFile represents a helm file from helm package that has got some necessary methods
// to generate a helm file.
type HelmFile interface {
GetType() string
GetPathRessource() string
}
// HelmFileGenerator is a chanel of HelmFile.
type HelmFileGenerator chan HelmFile
var PrefixRE = regexp.MustCompile(`\{\{.*\}\}-?`)
func portExists(port int, ports []types.ServicePortConfig) bool {
for _, p := range ports {
if p.Target == uint32(port) {
log.Println("portExists:", port, p.Target)
return true
}
}
return false
}
// Generate get a parsed compose file, and generate the helm files.
func Generate(p *compose.Parser, katernayVersion, appName, appVersion, chartVersion, composeFile, dirName string) {
// make the appname global (yes... ugly but easy)
helm.Appname = appName
helm.Version = katernayVersion
templatesDir := filepath.Join(dirName, "templates")
// try to create the directory
err := os.MkdirAll(templatesDir, 0755)
if err != nil {
log.Fatal(err)
}
generators := make(map[string]HelmFileGenerator)
// remove skipped services from the parsed data
for i, service := range p.Data.Services {
if v, ok := service.Labels[helm.LABEL_IGNORE]; !ok || v != "true" {
continue
}
p.Data.Services = append(p.Data.Services[:i], p.Data.Services[i+1:]...)
i--
// find this service in others as "depends_on" and remove it
for _, service2 := range p.Data.Services {
delete(service2.DependsOn, service.Name)
}
}
for i, service := range p.Data.Services {
n := service.Name
// if the service port is declared in labels, add it to the service.
if ports, ok := service.Labels[helm.LABEL_PORT]; ok {
if service.Ports == nil {
service.Ports = make([]types.ServicePortConfig, 0)
}
for _, port := range strings.Split(ports, ",") {
port = strings.TrimSpace(port)
target, err := strconv.Atoi(port)
if err != nil {
log.Fatal(err)
}
if portExists(target, service.Ports) {
continue
}
service.Ports = append(service.Ports, types.ServicePortConfig{
Target: uint32(target),
})
}
}
// find port and store it in servicesMap
for _, port := range service.Ports {
target := int(port.Target)
if target != 0 {
servicesMap[n] = target
break
}
}
// manage emptyDir volumes
if empty, ok := service.Labels[helm.LABEL_EMPTYDIRS]; ok {
//split empty list by coma
emptyDirs := strings.Split(empty, ",")
for i, emptyDir := range emptyDirs {
emptyDirs[i] = strings.TrimSpace(emptyDir)
}
//append them in EmptyDirs
EmptyDirs = append(EmptyDirs, emptyDirs...)
}
p.Data.Services[i] = service
}
// for all services in linked map, and not in samePods map, generate the service
for _, s := range p.Data.Services {
name := s.Name
// do not make a deployment for services declared to be in the same pod than another
if _, ok := s.Labels[helm.LABEL_SAMEPOD]; ok {
continue
}
// find services that is in the same pod
linked := make(map[string]types.ServiceConfig, 0)
for _, service := range p.Data.Services {
n := service.Name
if linkname, ok := service.Labels[helm.LABEL_SAMEPOD]; ok && linkname == name {
linked[n] = service
delete(s.DependsOn, n)
}
}
generators[name] = CreateReplicaObject(name, s, linked)
}
// to generate notes, we need to keep an Ingresses list
ingresses := make(map[string]*helm.Ingress)
for n, generator := range generators { // generators is a map : name -> generator
for helmFile := range generator { // generator is a chan
if helmFile == nil { // generator finished
break
}
kind := helmFile.(helm.Kinded).Get()
kind = strings.ToLower(kind)
// Add a SHA inside the generated file, it's only
// to make it easy to check it the compose file corresponds to the
// generated helm chart
helmFile.(helm.Signable).BuildSHA(composeFile)
// Some types need special fixes in yaml generation
switch c := helmFile.(type) {
case *helm.Storage:
// For storage, we need to add a "condition" to activate it
writers.BuildStorage(c, n, templatesDir)
case *helm.Deployment:
// for the deployment, we need to fix persitence volumes
// to be activated only when the storage is "enabled",
// either we use an "emptyDir"
writers.BuildDeployment(c, n, templatesDir)
case *helm.Service:
// Change the type for service if it's an "exposed" port
writers.BuildService(c, n, templatesDir)
case *helm.Ingress:
// we need to make ingresses "activable" from values
ingresses[n] = c // keep it to generate notes
writers.BuildIngress(c, n, templatesDir)
case *helm.ConfigMap, *helm.Secret:
// there could be several files, so let's force the filename
name := c.(helm.Named).Name() + "." + c.GetType()
suffix := c.GetPathRessource()
suffix = tools.PathToName(suffix)
name += suffix
name = PrefixRE.ReplaceAllString(name, "")
writers.BuildConfigMap(c, kind, n, name, templatesDir)
default:
name := c.(helm.Named).Name() + "." + c.GetType()
name = PrefixRE.ReplaceAllString(name, "")
fname := filepath.Join(templatesDir, name+".yaml")
fp, err := os.Create(fname)
if err != nil {
log.Fatal(err)
}
defer fp.Close()
enc := yaml.NewEncoder(fp)
enc.SetIndent(writers.IndentSize)
enc.Encode(c)
}
}
}
// Create the values.yaml file
valueFile, err := os.Create(filepath.Join(dirName, "values.yaml"))
if err != nil {
log.Fatal(err)
}
defer valueFile.Close()
enc := yaml.NewEncoder(valueFile)
enc.SetIndent(writers.IndentSize)
enc.Encode(Values)
// Create tht Chart.yaml file
chartFile, err := os.Create(filepath.Join(dirName, "Chart.yaml"))
if err != nil {
log.Fatal(err)
}
defer chartFile.Close()
chartFile.WriteString(`# Create on ` + time.Now().Format(time.RFC3339) + "\n")
chartFile.WriteString(`# Katenary command line: ` + strings.Join(os.Args, " ") + "\n")
enc = yaml.NewEncoder(chartFile)
enc.SetIndent(writers.IndentSize)
enc.Encode(map[string]interface{}{
"apiVersion": "v2",
"name": appName,
"description": "A helm chart for " + appName,
"type": "application",
"version": chartVersion,
"appVersion": appVersion,
})
// And finally, create a NOTE.txt file
noteFile, err := os.Create(filepath.Join(templatesDir, "NOTES.txt"))
if err != nil {
log.Fatal(err)
}
defer noteFile.Close()
noteFile.WriteString(helm.GenerateNotesFile(ingresses))
}

View File

@@ -1,18 +0,0 @@
package writers
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// BuildConfigMap writes the configMap.
func BuildConfigMap(c interface{}, kind, servicename, name, templatesDir string) {
fname := filepath.Join(templatesDir, name+"."+kind+".yaml")
fp, _ := os.Create(fname)
enc := yaml.NewEncoder(fp)
enc.SetIndent(IndentSize)
enc.Encode(c)
fp.Close()
}

View File

@@ -1,44 +0,0 @@
package writers
import (
"bytes"
"katenary/helm"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// BuildDeployment builds a deployment.
func BuildDeployment(deployment *helm.Deployment, name, templatesDir string) {
kind := "deployment"
fname := filepath.Join(templatesDir, name+"."+kind+".yaml")
fp, _ := os.Create(fname)
buffer := bytes.NewBuffer(nil)
enc := yaml.NewEncoder(buffer)
enc.SetIndent(IndentSize)
enc.Encode(deployment)
_content := string(buffer.Bytes())
content := strings.Split(string(_content), "\n")
dataname := ""
component := deployment.Spec.Selector["matchLabels"].(map[string]string)[helm.K+"/component"]
n := 0 // will be count of lines only on "persistentVolumeClaim" line, to indent "else" and "end" at the right place
for _, line := range content {
if strings.Contains(line, "name:") {
dataname = strings.Split(line, ":")[1]
dataname = strings.TrimSpace(dataname)
} else if strings.Contains(line, "persistentVolumeClaim") {
n = CountSpaces(line)
line = strings.Repeat(" ", n) + "{{- if .Values." + component + ".persistence." + dataname + ".enabled }}\n" + line
} else if strings.Contains(line, "claimName") {
spaces := strings.Repeat(" ", n)
line += "\n" + spaces + "{{ else }}"
line += "\n" + spaces + "emptyDir: {}"
line += "\n" + spaces + "{{- end }}"
}
fp.WriteString(line + "\n")
}
fp.Close()
}

View File

@@ -1,101 +0,0 @@
package writers
import (
"bytes"
"katenary/helm"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
const (
classAndVersionCondition = `{{- if and .Values.__name__.ingress.class (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}` + "\n"
versionCondition118 = `{{- if semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion }}` + "\n"
versionCondition119 = `{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}` + "\n"
apiVersion = `{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}`
)
// BuildIngress generates the ingress yaml file with conditions.
func BuildIngress(ingress *helm.Ingress, name, templatesDir string) {
// Set the backend for 1.18
for _, b := range ingress.Spec.Rules {
for _, p := range b.Http.Paths {
p.Backend.ServiceName = p.Backend.Service.Name
if n, ok := p.Backend.Service.Port["number"]; ok {
p.Backend.ServicePort = n
}
}
}
kind := "ingress"
buffer := bytes.NewBuffer(nil)
fname := filepath.Join(templatesDir, name+"."+kind+".yaml")
enc := yaml.NewEncoder(buffer)
enc.SetIndent(IndentSize)
buffer.WriteString("{{- if .Values." + name + ".ingress.enabled -}}\n")
enc.Encode(ingress)
buffer.WriteString("{{- end -}}")
fp, err := os.Create(fname)
if err != nil {
panic(err)
}
defer fp.Close()
content := string(buffer.Bytes())
lines := strings.Split(content, "\n")
backendHit := false
for _, l := range lines {
// apiVersion is a pain...
if strings.Contains(l, "apiVersion:") {
l = apiVersion
}
// add annotations linked to the Values
if strings.Contains(l, "annotations:") {
n := CountSpaces(l) + IndentSize
l += "\n" + strings.Repeat(" ", n) + "{{- range $k, $v := .Values.__name__.ingress.annotations }}\n"
l += strings.Repeat(" ", n) + "{{ $k }}: {{ $v }}\n"
l += strings.Repeat(" ", n) + "{{- end }}"
l = strings.ReplaceAll(l, "__name__", name)
}
// pathTyype is ony for 1.19+
if strings.Contains(l, "pathType:") {
n := CountSpaces(l)
l = strings.Repeat(" ", n) + versionCondition118 +
l + "\n" +
strings.Repeat(" ", n) + "{{- end }}"
}
if strings.Contains(l, "ingressClassName") {
// should be set only if the version of Kubernetes is 1.18-0 or higher
cond := strings.ReplaceAll(classAndVersionCondition, "__name__", name)
l = ` ` + cond + l + "\n" + ` {{- end }}`
}
// manage the backend format following the Kubernetes 1.19-0 version or higher
if strings.Contains(l, "service:") {
n := CountSpaces(l)
l = strings.Repeat(" ", n) + versionCondition119 + l
}
if strings.Contains(l, "serviceName:") || strings.Contains(l, "servicePort:") {
n := CountSpaces(l)
if !backendHit {
l = strings.Repeat(" ", n) + "{{- else }}\n" + l
} else {
l = l + "\n" + strings.Repeat(" ", n) + "{{- end }}\n"
}
backendHit = true
}
fp.WriteString(l + "\n")
}
}

View File

@@ -1,24 +0,0 @@
package writers
import (
"katenary/helm"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// BuildService writes the service (external or not).
func BuildService(service *helm.Service, name, templatesDir string) {
kind := "service"
suffix := ""
if service.Spec.Type == "NodePort" {
suffix = "-external"
}
fname := filepath.Join(templatesDir, name+suffix+"."+kind+".yaml")
fp, _ := os.Create(fname)
enc := yaml.NewEncoder(fp)
enc.SetIndent(IndentSize)
enc.Encode(service)
fp.Close()
}

View File

@@ -1,32 +0,0 @@
package writers
import (
"katenary/helm"
"log"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// BuildStorage writes the persistentVolumeClaim.
func BuildStorage(storage *helm.Storage, name, templatesDir string) {
kind := "pvc"
name = storage.Metadata.Labels[helm.K+"/component"]
pvcname := storage.Metadata.Labels[helm.K+"/pvc-name"]
fname := filepath.Join(templatesDir, name+"-"+pvcname+"."+kind+".yaml")
fp, err := os.Create(fname)
if err != nil {
log.Fatal(err)
}
defer fp.Close()
volname := storage.K8sBase.Metadata.Labels[helm.K+"/pvc-name"]
fp.WriteString("{{ if .Values." + name + ".persistence." + volname + ".enabled }}\n")
enc := yaml.NewEncoder(fp)
enc.SetIndent(IndentSize)
if err := enc.Encode(storage); err != nil {
log.Fatal(err)
}
fp.WriteString("{{- end -}}")
}

View File

@@ -1,17 +0,0 @@
package writers
// IndentSize set the indentation size for yaml output. Could ba changed by command line argument.
var IndentSize = 2
// CountSpaces returns the number of spaces from the begining of the line.
func CountSpaces(line string) int {
var spaces int
for _, char := range line {
if char == ' ' {
spaces++
} else {
break
}
}
return spaces
}