Big fixes

- Add icon for external service creation log
- Rebase the concurrency
- Add more labels to manage ports
- Change the "ingress" label behavior
- Set more "conditional tests" in generated files
- Many others fixes
This commit is contained in:
Patrice
2021-12-02 10:21:05 +00:00
parent 769c9b6c6c
commit bced2a1be6
3 changed files with 165 additions and 74 deletions

View File

@@ -54,8 +54,8 @@ What can be interpreted by Katenary:
- if `ports` and/or `expose` section, katenary will create Services and bind the port to the corresponding container port - if `ports` and/or `expose` section, katenary will create Services and bind the port to the corresponding container port
- `depends_on` will add init containers to wait for the depending service (using the first port) - `depends_on` will add init containers to wait for the depending service (using the first port)
- `env_file` list will create a configMap object per environemnt file (⚠ todo: the "to-service" label doesn't work with configMap for now) - `env_file` list will create a configMap object per environemnt file (⚠ todo: the "to-service" label doesn't work with configMap for now)
- some labels can help to bind values: - some labels can help to bind values, for example:
- `katenary.io/expose-ingress: true` will expose the first port or expose to an ingress - `katenary.io/ingress: 80` will expose the port 80 in a ingress
- `katenary.io/to-service: VARNAME` will convert the value to a variable `{{ .Release.Name }}-VARNAME` - it's usefull when you want to pass the name of a service as a variable (think about the service name for mysql to pass to a container that wants to connect to this) - `katenary.io/to-service: VARNAME` will convert the value to a variable `{{ .Release.Name }}-VARNAME` - it's usefull when you want to pass the name of a service as a variable (think about the service name for mysql to pass to a container that wants to connect to this)
Exemple of a possible `docker-compose.yaml` file: Exemple of a possible `docker-compose.yaml` file:
@@ -77,6 +77,8 @@ services:
labels: labels:
# explain to katenary that "DB_HOST" value is variable (using release name) # explain to katenary that "DB_HOST" value is variable (using release name)
katenary.io/to-servie: DB_HOST katenary.io/to-servie: DB_HOST
# expose the port 80 as an ingress
katenary.io/ingress: 80
database: database:
image: mariabd:10 image: mariabd:10
env_file: env_file:
@@ -92,5 +94,6 @@ services:
# Labels # Labels
- `katenary.io/to-service` binds the given (coma separated) variables names to {{ .Release.Name }}-value - `katenary.io/to-service` binds the given (coma separated) variables names to {{ .Release.Name }}-value
- `katenary.io/expose-ingress`: create an ingress and bind it to the service - `katenary.io/ingress`: create an ingress and bind it to the given port
- `katenary.io/as-secret`: force the creation of a secret for the given coma separated list of "env_file" - `katenary.io/as-secret`: force the creation of a secret for the given coma separated list of "env_file"
- `katenary.io/service-ports` is a coma separated list of ports if you want to avoid the "ports" section in your docker-compose for any reason

View File

@@ -9,6 +9,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time"
"errors" "errors"
) )
@@ -17,6 +18,15 @@ var servicesMap = make(map[string]int)
var serviceWaiters = make(map[string][]chan int) var serviceWaiters = make(map[string][]chan int)
var locker = &sync.Mutex{} var locker = &sync.Mutex{}
const (
ICON_PACKAGE = "📦"
ICON_SERVICE = "🔌"
ICON_SECRET = "🔏"
ICON_CONF = "📝"
ICON_STORE = "⚡"
ICON_INGRESS = "🌐"
)
// Values is kept in memory to create a values.yaml file. // Values is kept in memory to create a values.yaml file.
var Values = make(map[string]map[string]interface{}) var Values = make(map[string]map[string]interface{})
var VolumeValues = make(map[string]map[string]map[string]interface{}) var VolumeValues = make(map[string]map[string]map[string]interface{})
@@ -34,20 +44,43 @@ echo "Done"
` `
// Create a Deployment for a given compose.Service. It returns a list of objects: a Deployment and a possible Service (kubernetes represnetation as maps). // Create a Deployment for a given compose.Service. It returns a list of objects: a Deployment and a possible Service (kubernetes represnetation as maps).
func CreateReplicaObject(name string, s compose.Service) (ret []interface{}) { func CreateReplicaObject(name string, s compose.Service) chan interface{} {
// fetch label to specific exposed port, and add them in "ports" section
if portlabel, ok := s.Labels[helm.K+"/service-ports"]; ok {
services := strings.Split(portlabel, ",")
for _, serviceport := range services {
portexists := false
for _, found := range s.Ports {
if found == serviceport {
portexists = true
}
}
if !portexists {
s.Ports = append(s.Ports, serviceport)
}
}
}
ret := make(chan interface{}, len(s.Ports)+len(s.Expose)+1)
go parseService(name, s, ret)
return ret
}
// This function will try to yied deployment and services based on a service from the compose file structure.
func parseService(name string, s compose.Service, ret chan interface{}) {
Magenta(ICON_PACKAGE+" Generating deployment for ", name)
Magenta("Generating deployment for ", name)
o := helm.NewDeployment(name) o := helm.NewDeployment(name)
ret = append(ret, o)
container := helm.NewContainer(name, s.Image, s.Environment, s.Labels) container := helm.NewContainer(name, s.Image, s.Environment, s.Labels)
// prepare secrets
secretsFiles := make([]string, 0) secretsFiles := make([]string, 0)
if v, ok := s.Labels[helm.K+"/as-secret"]; ok { if v, ok := s.Labels[helm.K+"/as-secret"]; ok {
secretsFiles = strings.Split(v, ",") secretsFiles = strings.Split(v, ",")
} }
// manage environment files (env_file in compose)
for _, envfile := range s.EnvFiles { for _, envfile := range s.EnvFiles {
f := strings.ReplaceAll(envfile, "_", "-") f := strings.ReplaceAll(envfile, "_", "-")
f = strings.ReplaceAll(f, ".env", "") f = strings.ReplaceAll(f, ".env", "")
@@ -61,10 +94,10 @@ func CreateReplicaObject(name string, s compose.Service) (ret []interface{}) {
} }
var store helm.InlineConfig var store helm.InlineConfig
if !isSecret { if !isSecret {
Bluef("Generating configMap %s\n", cf) Bluef(ICON_CONF+" Generating configMap %s\n", cf)
store = helm.NewConfigMap(cf) store = helm.NewConfigMap(cf)
} else { } else {
Bluef("Generating secret %s\n", cf) Bluef(ICON_SECRET+" Generating secret %s\n", cf)
store = helm.NewSecret(cf) store = helm.NewSecret(cf)
} }
if err := store.AddEnvFile(envfile); err != nil { if err := store.AddEnvFile(envfile); err != nil {
@@ -77,19 +110,16 @@ func CreateReplicaObject(name string, s compose.Service) (ret []interface{}) {
}, },
}) })
ret = append(ret, store) ret <- store
if isSecret {
Greenf("Done secret %s\n", cf)
} else {
Greenf("Done configMap %s\n", cf)
}
} }
// check the image, and make it "variable" in values.yaml
container.Image = "{{ .Values." + name + ".image }}" container.Image = "{{ .Values." + name + ".image }}"
Values[name] = map[string]interface{}{ Values[name] = map[string]interface{}{
"image": s.Image, "image": s.Image,
} }
// manage ports
exists := make(map[int]string) exists := make(map[int]string)
for _, port := range s.Ports { for _, port := range s.Ports {
_p := strings.Split(port, ":") _p := strings.Split(port, ":")
@@ -110,6 +140,8 @@ func CreateReplicaObject(name string, s compose.Service) (ret []interface{}) {
}) })
exists[portNumber] = name exists[portNumber] = name
} }
// manage the "expose" section to be a NodePort in Kubernetes
for _, port := range s.Expose { for _, port := range s.Expose {
if _, exist := exists[port]; exist { if _, exist := exists[port]; exist {
continue continue
@@ -120,6 +152,7 @@ func CreateReplicaObject(name string, s compose.Service) (ret []interface{}) {
}) })
} }
// Prepare volumes
volumes := make([]map[string]interface{}, 0) volumes := make([]map[string]interface{}, 0)
mountPoints := make([]interface{}, 0) mountPoints := make([]interface{}, 0)
for _, volume := range s.Volumes { for _, volume := range s.Volumes {
@@ -127,12 +160,14 @@ func CreateReplicaObject(name string, s compose.Service) (ret []interface{}) {
volname := parts[0] volname := parts[0]
volepath := parts[1] volepath := parts[1]
if strings.HasPrefix(volname, ".") || strings.HasPrefix(volname, "/") { if strings.HasPrefix(volname, ".") || strings.HasPrefix(volname, "/") {
// local volume cannt be mounted
// TODO: propose a way to make configMap for some files or directory
Redf("You cannot, at this time, have local volume in %s service", name) Redf("You cannot, at this time, have local volume in %s service", name)
os.Exit(1) continue
//os.Exit(1)
} }
pvc := helm.NewPVC(name, volname) pvc := helm.NewPVC(name, volname)
ret = append(ret, pvc)
volumes = append(volumes, map[string]interface{}{ volumes = append(volumes, map[string]interface{}{
"name": volname, "name": volname,
"persistentVolumeClaim": map[string]string{ "persistentVolumeClaim": map[string]string{
@@ -144,7 +179,7 @@ func CreateReplicaObject(name string, s compose.Service) (ret []interface{}) {
"mountPath": volepath, "mountPath": volepath,
}) })
Yellow("Generate volume values for ", volname) Yellow(ICON_STORE+" Generate volume values for ", volname, " in deployment ", name)
locker.Lock() locker.Lock()
if _, ok := VolumeValues[name]; !ok { if _, ok := VolumeValues[name]; !ok {
VolumeValues[name] = make(map[string]map[string]interface{}) VolumeValues[name] = make(map[string]map[string]interface{})
@@ -154,71 +189,101 @@ func CreateReplicaObject(name string, s compose.Service) (ret []interface{}) {
"capacity": "1Gi", "capacity": "1Gi",
} }
locker.Unlock() locker.Unlock()
ret <- pvc
} }
container.VolumeMounts = mountPoints container.VolumeMounts = mountPoints
o.Spec.Template.Spec.Volumes = volumes o.Spec.Template.Spec.Volumes = volumes
o.Spec.Template.Spec.Containers = []*helm.Container{container} o.Spec.Template.Spec.Containers = []*helm.Container{container}
// Add some labels
o.Spec.Selector = map[string]interface{}{ o.Spec.Selector = map[string]interface{}{
"matchLabels": buildSelector(name, s), "matchLabels": buildSelector(name, s),
} }
o.Spec.Template.Metadata.Labels = buildSelector(name, s) o.Spec.Template.Metadata.Labels = buildSelector(name, s)
wait := &sync.WaitGroup{} // Now, for "depends_on" section, it's a bit tricky...
// 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) initContainers := make([]*helm.Container, 0)
for _, dp := range s.DependsOn { for _, dp := range s.DependsOn {
//if len(s.Ports) == 0 && len(s.Expose) == 0 {
// Redf("No port exposed for %s that is in dependency", name)
// os.Exit(1)
//}
c := helm.NewContainer("check-"+dp, "busybox", nil, s.Labels) c := helm.NewContainer("check-"+dp, "busybox", nil, s.Labels)
command := strings.ReplaceAll(strings.TrimSpace(dependScript), "__service__", dp) command := strings.ReplaceAll(strings.TrimSpace(dependScript), "__service__", dp)
wait.Add(1) foundPort := -1
go func(dp string) { if defaultPort, err := getPort(dp); err != nil {
defer wait.Done() // BUG: Sometimes the chan remains opened
p := -1 foundPort := <-waitPort(dp)
if defaultPort, err := getPort(dp); err != nil { if foundPort == -1 {
p = <-waitPort(dp) log.Fatalf(
} else { "ERROR, the %s service is waiting for %s port number, "+
p = defaultPort "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(p)) } else {
foundPort = defaultPort
}
command = strings.ReplaceAll(command, "__port__", strconv.Itoa(foundPort))
c.Command = []string{ c.Command = []string{
"sh", "sh",
"-c", "-c",
command, command,
} }
initContainers = append(initContainers, c) initContainers = append(initContainers, c)
}(dp)
} }
wait.Wait()
o.Spec.Template.Spec.InitContainers = initContainers o.Spec.Template.Spec.InitContainers = initContainers
// Then, create services for "ports" and "expose" section
if len(s.Ports) > 0 || len(s.Expose) > 0 { if len(s.Ports) > 0 || len(s.Expose) > 0 {
ks := createService(name, s) for _, s := range createService(name, s) {
ret = append(ret, ks...) ret <- s
}
} }
// Special case, it there is no "ports", so there is no associated services...
// But... some other deployment can wait for it, so we alert that this deployment hasn't got any
// associated service.
if len(s.Ports) == 0 {
locker.Lock()
// alert any current or **futur** waiters that this service is not exposed
go func() {
for {
select {
case <-time.Tick(1 * time.Millisecond):
for _, c := range serviceWaiters[name] {
c <- -1
close(c)
}
}
}
}()
locker.Unlock()
}
// add the volumes in Values
if len(VolumeValues[name]) > 0 { if len(VolumeValues[name]) > 0 {
locker.Lock()
Values[name]["persistence"] = VolumeValues[name] Values[name]["persistence"] = VolumeValues[name]
locker.Unlock()
} }
Green("Done deployment ", name) // the deployment is ready, give it
ret <- o
return // and then, we can say that it's the end
ret <- nil
} }
// Create a service (k8s). // Create a service (k8s).
func createService(name string, s compose.Service) []interface{} { func createService(name string, s compose.Service) []interface{} {
ret := make([]interface{}, 0) ret := make([]interface{}, 0)
Magenta("Generating service for ", name) Magenta(ICON_SERVICE+" Generating service for ", name)
ks := helm.NewService(name) ks := helm.NewService(name)
defaultPort := 0
for i, p := range s.Ports { for i, p := range s.Ports {
port := strings.Split(p, ":") port := strings.Split(p, ":")
@@ -226,27 +291,27 @@ func createService(name string, s compose.Service) []interface{} {
target := src target := src
if len(port) > 1 { if len(port) > 1 {
target, _ = strconv.Atoi(port[1]) target, _ = strconv.Atoi(port[1])
log.Println(target)
} }
ks.Spec.Ports = append(ks.Spec.Ports, helm.NewServicePort(target, target)) ks.Spec.Ports = append(ks.Spec.Ports, helm.NewServicePort(target, target))
if i == 0 { if i == 0 {
defaultPort = target
detected(name, target) detected(name, target)
} }
} }
ks.Spec.Selector = buildSelector(name, s) ks.Spec.Selector = buildSelector(name, s)
ret = append(ret, ks) ret = append(ret, ks)
if v, ok := s.Labels[helm.K+"/expose-ingress"]; ok && v == "true" { if v, ok := s.Labels[helm.K+"/ingress"]; ok {
Cyanf("Create an ingress for %d port on %s service\n", defaultPort, name) port, err := strconv.Atoi(v)
ing := createIngress(name, defaultPort, s) if err != nil {
log.Fatalf("The given port \"%v\" as ingress port in %s service is not an integer\n", v, name)
}
Cyanf(ICON_INGRESS+" Create an ingress for port %d on %s service\n", port, name)
ing := createIngress(name, port, s)
ret = append(ret, ing) ret = append(ret, ing)
Green("Done ingress ", name)
} }
Green("Done service ", name)
if len(s.Expose) > 0 { if len(s.Expose) > 0 {
Magenta("Generating service for ", name+"-external") Magenta(ICON_SERVICE+" Generating service for ", name+"-external")
ks := helm.NewService(name + "-external") ks := helm.NewService(name + "-external")
ks.Spec.Type = "NodePort" ks.Spec.Type = "NodePort"
for _, p := range s.Expose { for _, p := range s.Expose {
@@ -264,7 +329,7 @@ func createIngress(name string, port int, s compose.Service) *helm.Ingress {
ingress := helm.NewIngress(name) ingress := helm.NewIngress(name)
Values[name]["ingress"] = map[string]interface{}{ Values[name]["ingress"] = map[string]interface{}{
"class": "nginx", "class": "nginx",
"host": "chart.example.tld", "host": name + "." + helm.Appname + ".tld",
"enabled": false, "enabled": false,
} }
ingress.Spec.Rules = []helm.IngressRule{ ingress.Spec.Rules = []helm.IngressRule{
@@ -291,7 +356,8 @@ func createIngress(name string, port int, s compose.Service) *helm.Ingress {
return ingress return ingress
} }
// This function is called when a possible service is detected, it append the port in a map to make others to be able to get the service name. It also try to send the data to any "waiter" for this service. // This function is called when a possible service is detected, it append the port in a map to make others
// to be able to get the service name. It also try to send the data to any "waiter" for this service.
func detected(name string, port int) { func detected(name string, port int) {
locker.Lock() locker.Lock()
servicesMap[name] = port servicesMap[name] = port
@@ -300,6 +366,7 @@ func detected(name string, port int) {
for _, c := range cx { for _, c := range cx {
if v, ok := servicesMap[name]; ok { if v, ok := servicesMap[name]; ok {
c <- v c <- v
close(c)
} }
} }
}() }()
@@ -321,6 +388,7 @@ func waitPort(name string) chan int {
go func() { go func() {
if v, ok := servicesMap[name]; ok { if v, ok := servicesMap[name]; ok {
c <- v c <- v
close(c)
} }
}() }()
locker.Unlock() locker.Unlock()

52
main.go
View File

@@ -10,7 +10,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -22,7 +21,6 @@ var Version = "master"
var ChartsDir = "chart" var ChartsDir = "chart"
func main() { func main() {
flag.StringVar(&ChartsDir, "chart-dir", ChartsDir, "set the chart directory") flag.StringVar(&ChartsDir, "chart-dir", ChartsDir, "set the chart directory")
flag.StringVar(&ComposeFile, "compose", ComposeFile, "set the compose file to parse") flag.StringVar(&ComposeFile, "compose", ComposeFile, "set the compose file to parse")
flag.StringVar(&AppName, "appname", AppName, "sive the helm chart app name") flag.StringVar(&AppName, "appname", AppName, "sive the helm chart app name")
@@ -61,28 +59,31 @@ func main() {
helm.Version = Version helm.Version = Version
p := compose.NewParser(ComposeFile) p := compose.NewParser(ComposeFile)
p.Parse(AppName) p.Parse(AppName)
wait := sync.WaitGroup{}
files := make(map[string][]interface{}) files := make(map[string]chan interface{})
//wait := sync.WaitGroup{}
for name, s := range p.Data.Services { for name, s := range p.Data.Services {
wait.Add(1) //wait.Add(1)
// it's mandatory to build in goroutines because some dependencies can // it's mandatory to build in goroutines because some dependencies can
// wait for a port number discovery. // wait for a port number discovery.
// So the entire services are built in parallel. // So the entire services are built in parallel.
go func(name string, s compose.Service) { //go func(name string, s compose.Service) {
o := generator.CreateReplicaObject(name, s) // defer wait.Done()
files[name] = o o := generator.CreateReplicaObject(name, s)
wait.Done() files[name] = o
}(name, s) //}(name, s)
} }
wait.Wait() //wait.Wait()
// to generate notes, we need to keep an Ingresses list // to generate notes, we need to keep an Ingresses list
ingresses := make(map[string]*helm.Ingress) ingresses := make(map[string]*helm.Ingress)
for n, f := range files { for n, f := range files {
for _, c := range f { for c := range f {
if c == nil {
break
}
kind := c.(helm.Kinded).Get() kind := c.(helm.Kinded).Get()
kind = strings.ToLower(kind) kind = strings.ToLower(kind)
c.(helm.Signable).BuildSHA(ComposeFile) c.(helm.Signable).BuildSHA(ComposeFile)
@@ -121,6 +122,7 @@ func main() {
fp.WriteString(line + "\n") fp.WriteString(line + "\n")
} }
fp.Close() fp.Close()
case *helm.Service: case *helm.Service:
suffix := "" suffix := ""
if c.Spec.Type == "NodePort" { if c.Spec.Type == "NodePort" {
@@ -129,18 +131,36 @@ func main() {
fname := filepath.Join(templatesDir, n+suffix+"."+kind+".yaml") fname := filepath.Join(templatesDir, n+suffix+"."+kind+".yaml")
fp, _ := os.Create(fname) fp, _ := os.Create(fname)
enc := yaml.NewEncoder(fp) enc := yaml.NewEncoder(fp)
enc.SetIndent(2)
enc.Encode(c) enc.Encode(c)
fp.Close() fp.Close()
case *helm.Ingress: case *helm.Ingress:
buffer := bytes.NewBuffer(nil)
fname := filepath.Join(templatesDir, n+"."+kind+".yaml") fname := filepath.Join(templatesDir, n+"."+kind+".yaml")
fp, _ := os.Create(fname)
ingresses[n] = c // keep it to generate notes ingresses[n] = c // keep it to generate notes
enc := yaml.NewEncoder(fp) enc := yaml.NewEncoder(buffer)
enc.SetIndent(2) enc.SetIndent(2)
fp.WriteString("{{- if .Values." + n + ".ingress.enabled -}}\n") buffer.WriteString("{{- if .Values." + n + ".ingress.enabled -}}\n")
enc.Encode(c) enc.Encode(c)
fp.WriteString("{{- end -}}") buffer.WriteString("{{- end -}}")
fp, _ := os.Create(fname)
content := string(buffer.Bytes())
lines := strings.Split(content, "\n")
for _, l := range lines {
if strings.Contains(l, "ingressClassName") {
p := strings.Split(l, ":")
condition := p[1]
condition = strings.ReplaceAll(condition, "'", "")
condition = strings.ReplaceAll(condition, "{{", "")
condition = strings.ReplaceAll(condition, "}}", "")
condition = strings.TrimSpace(condition)
condition = "{{- if " + condition + " }}"
l = " " + condition + "\n" + l + "\n {{- end }}"
}
fp.WriteString(l + "\n")
}
fp.Close() fp.Close()
default: default: