Files
katenary/generator/main.go

663 lines
18 KiB
Go
Raw Normal View History

2021-11-30 12:04:28 +01:00
package generator
import (
"fmt"
"io/ioutil"
"katenary/compose"
2021-12-01 08:31:51 +01:00
"katenary/helm"
"katenary/logger"
"log"
2022-04-05 09:17:18 +02:00
"net/url"
2021-11-30 15:35:32 +01:00
"os"
"path/filepath"
2021-11-30 12:04:28 +01:00
"strconv"
"strings"
"sync"
"github.com/compose-spec/compose-go/types"
2021-11-30 12:04:28 +01:00
)
const (
ICON_PACKAGE = "📦"
ICON_SERVICE = "🔌"
ICON_SECRET = "🔏"
ICON_CONF = "📝"
ICON_STORE = "⚡"
ICON_INGRESS = "🌐"
)
2021-11-30 15:45:36 +01:00
// Values is kept in memory to create a values.yaml file.
2022-04-04 13:52:28 +02:00
var (
Values = make(map[string]map[string]interface{})
VolumeValues = make(map[string]map[string]map[string]interface{})
EmptyDirs = []string{}
servicesMap = make(map[string]int)
serviceWaiters = make(map[string][]chan int)
locker = &sync.Mutex{}
dependScript = `
2021-11-30 12:04:28 +01:00
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
2021-11-30 12:04:28 +01:00
done
echo
echo "Done"
`
2022-04-04 13:52:28 +02:00
madeDeployments = make(map[string]helm.Deployment, 0)
)
2021-11-30 15:45:36 +01:00
// 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 types.ServiceConfig, linked map[string]types.ServiceConfig) chan interface{} {
2022-04-04 13:52:28 +02:00
ret := make(chan interface{}, len(s.Ports)+len(s.Expose)+2)
go parseService(name, s, linked, 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 types.ServiceConfig, linked map[string]types.ServiceConfig, ret chan interface{}) {
logger.Magenta(ICON_PACKAGE+" Generating deployment for ", name)
2021-11-30 12:04:28 +01:00
2022-04-05 09:17:18 +02:00
deployment := helm.NewDeployment(name)
2021-11-30 12:04:28 +01:00
container := helm.NewContainer(name, s.Image, s.Environment, s.Labels)
prepareContainer(container, s, name)
prepareEnvFromFiles(name, s, container, ret)
// Set the containers to the deployment
2022-04-05 09:17:18 +02:00
deployment.Spec.Template.Spec.Containers = []*helm.Container{container}
2021-11-30 17:29:42 +01:00
// Prepare volumes
madePVC := make(map[string]bool)
2022-04-05 09:17:18 +02:00
deployment.Spec.Template.Spec.Volumes = prepareVolumes(name, name, s, container, madePVC, ret)
// Now, for "depends_on" section, it's a bit tricky to get dependencies, see the function below.
2022-04-05 09:17:18 +02:00
deployment.Spec.Template.Spec.InitContainers = prepareInitContainers(name, s, container)
// Add selectors
selectors := buildSelector(name, s)
2022-04-05 09:17:18 +02:00
deployment.Spec.Selector = map[string]interface{}{
"matchLabels": selectors,
2021-11-30 12:04:28 +01:00
}
2022-04-05 09:17:18 +02:00
deployment.Spec.Template.Metadata.Labels = selectors
2021-11-30 12:04:28 +01:00
// Now, the linked services
for lname, link := range linked {
container := helm.NewContainer(lname, link.Image, link.Environment, link.Labels)
prepareContainer(container, link, lname)
prepareEnvFromFiles(lname, link, container, ret)
2022-04-05 09:17:18 +02:00
deployment.Spec.Template.Spec.Containers = append(deployment.Spec.Template.Spec.Containers, container)
deployment.Spec.Template.Spec.Volumes = append(deployment.Spec.Template.Spec.Volumes, prepareVolumes(name, lname, link, container, madePVC, ret)...)
deployment.Spec.Template.Spec.InitContainers = append(deployment.Spec.Template.Spec.InitContainers, prepareInitContainers(lname, link, container)...)
//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...)
}
}
// Remove duplicates in volumes
volumes := make([]map[string]interface{}, 0)
done := make(map[string]bool)
2022-04-05 09:17:18 +02:00
for _, vol := range deployment.Spec.Template.Spec.Volumes {
name := vol["name"].(string)
if _, ok := done[name]; ok {
continue
} else {
done[name] = true
volumes = append(volumes, vol)
}
}
2022-04-05 09:17:18 +02:00
deployment.Spec.Template.Spec.Volumes = volumes
2021-11-30 12:04:28 +01:00
// Then, create Services and possible Ingresses for ingress labels, "ports" and "expose" section
2021-11-30 12:04:28 +01:00
if len(s.Ports) > 0 || len(s.Expose) > 0 {
for _, s := range generateServicesAndIngresses(name, s) {
ret <- s
}
2021-11-30 12:04:28 +01:00
}
// add the volumes in Values
2021-11-30 17:29:42 +01:00
if len(VolumeValues[name]) > 0 {
AddValues(name, map[string]interface{}{"persistence": VolumeValues[name]})
2021-11-30 17:29:42 +01:00
}
// the deployment is ready, give it
2022-04-05 09:17:18 +02:00
ret <- deployment
2021-11-30 15:35:32 +01:00
// and then, we can say that it's the end
ret <- nil
2021-11-30 12:04:28 +01:00
}
// 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)
}
container.Image = "{{ .Values." + servicename + ".image }}"
container.Command = service.Command
AddValues(servicename, map[string]interface{}{"image": service.Image})
prepareProbes(servicename, service, container)
generateContainerPorts(service, servicename, container)
}
2021-11-30 15:45:36 +01:00
// Create a service (k8s).
func generateServicesAndIngresses(name string, s types.ServiceConfig) []interface{} {
2021-11-30 12:04:28 +01:00
ret := make([]interface{}, 0) // can handle helm.Service or helm.Ingress
logger.Magenta(ICON_SERVICE+" Generating service for ", name)
2021-12-01 15:17:34 +01:00
ks := helm.NewService(name)
2021-11-30 15:35:32 +01:00
for _, p := range s.Ports {
2022-04-04 13:52:28 +02:00
target := int(p.Target)
ks.Spec.Ports = append(ks.Spec.Ports, helm.NewServicePort(target, target))
2021-11-30 12:04:28 +01:00
}
ks.Spec.Selector = buildSelector(name, s)
2021-12-01 08:31:51 +01:00
ret = append(ret, ks)
2021-12-02 14:56:51 +01:00
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)
2021-12-01 08:31:51 +01:00
ret = append(ret, ing)
2021-11-30 12:04:28 +01:00
}
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)
}
2021-12-01 08:31:51 +01:00
return ret
2021-11-30 12:04:28 +01:00
}
2021-11-30 15:45:36 +01:00
// Create an ingress.
func createIngress(name string, port int, s types.ServiceConfig) *helm.Ingress {
2021-11-30 12:04:28 +01:00
ingress := helm.NewIngress(name)
ingressVal := map[string]interface{}{
2021-11-30 15:35:32 +01:00
"class": "nginx",
"host": name + "." + helm.Appname + ".tld",
2021-11-30 12:04:28 +01:00
"enabled": false,
}
AddValues(name, map[string]interface{}{"ingress": ingressVal})
2021-11-30 12:04:28 +01:00
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{
2021-11-30 12:04:28 +01:00
Service: helm.IngressService{
Name: helm.ReleaseNameTpl + "-" + name,
2021-11-30 12:04:28 +01:00
Port: map[string]interface{}{
"number": port,
},
},
},
}},
},
},
}
2021-11-30 15:35:32 +01:00
ingress.SetIngressClass(name)
2021-11-30 12:04:28 +01:00
2021-12-01 08:31:51 +01:00
return ingress
2021-11-30 12:04:28 +01:00
}
// Build the selector for the service.
func buildSelector(name string, s types.ServiceConfig) map[string]string {
2021-11-30 12:04:28 +01:00
return map[string]string{
"katenary.io/component": name,
"katenary.io/release": helm.ReleaseNameTpl,
2021-11-30 12:04:28 +01:00
}
}
// buildCMFromPath generates a ConfigMap from a path.
func buildCMFromPath(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)
}
}
cm := helm.NewConfigMap("")
cm.Data = files
return cm
}
// 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,
})
}
}
// prepareVolumes add the volumes of a service.
func prepareVolumes(deployment, name string, s types.ServiceConfig, container *helm.Container, madePVC map[string]bool, ret chan interface{}) []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 _, vol := range s.Volumes {
volname := vol.Source
volepath := vol.Target
2022-04-05 09:33:49 +02:00
if volname == "" {
logger.ActivateColors = true
logger.Yellowf("Warning, volume source to %s is empty for %s -- skipping\n", volepath, name)
logger.ActivateColors = false
continue
}
isCM := false
for _, cmVol := range configMapsVolumes {
cmVol = strings.TrimSpace(cmVol)
if volname == cmVol {
isCM = true
break
}
}
if !isCM && (strings.HasPrefix(volname, ".") || strings.HasPrefix(volname, "/")) {
// local volume cannt be mounted
logger.ActivateColors = true
logger.Redf("You cannot, at this time, have local volume in %s deployment\n", name)
logger.ActivateColors = false
continue
}
if isCM {
// check if the volname path points on a file, if so, we need to add subvolume to the interface
2022-04-04 13:52:28 +02:00
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)
volname = filepath.Dir(volname)
}
// the volume is a path and it's explicitally asked to be a configmap in labels
cm := buildCMFromPath(volname)
volname = strings.Replace(volname, "./", "", 1)
2022-02-17 11:04:04 +01:00
volname = strings.ReplaceAll(volname, "/", "-")
volname = strings.ReplaceAll(volname, ".", "-")
cm.K8sBase.Metadata.Name = helm.ReleaseNameTpl + "-" + volname + "-" + name
// build a configmap from the volume path
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,
})
}
ret <- cm
} else {
// rmove minus sign from volume name
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 = 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]interface{}{
"enabled": false,
"capacity": "1Gi",
})
if _, ok := madePVC[deployment+volname]; !ok {
madePVC[deployment+volname] = true
pvc := helm.NewPVC(deployment, volname)
ret <- pvc
}
}
}
container.VolumeMounts = mountPoints
return volumes
}
// 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)
2022-04-03 16:09:33 +02:00
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
}
2022-04-04 13:52:28 +02:00
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
}
// prepareProbes generate http/tcp/command probes for a service.
func prepareProbes(name string, s types.ServiceConfig, container *helm.Container) {
2022-05-05 12:21:09 +02:00
// first, check if there a label for the probe
2022-04-05 09:17:18 +02:00
if check, ok := s.Labels[helm.LABEL_HEALTHCHECK]; ok {
check = strings.TrimSpace(check)
p := helm.NewProbeFromService(&s)
2022-04-05 09:17:18 +02:00
// get the port of the "url" check
if checkurl, err := url.Parse(check); err == nil {
if err == nil {
container.LivenessProbe = buildProtoProbe(p, checkurl)
2022-04-05 09:17:18 +02:00
}
} else {
// it's a command
container.LivenessProbe = p
2022-05-05 09:24:51 +02:00
container.LivenessProbe.Exec = &helm.Exec{
Command: []string{
"sh",
"-c",
check,
2022-04-05 09:17:18 +02:00
},
}
}
return // label overrides everything
}
2022-05-05 12:21:09 +02:00
2022-04-05 09:17:18 +02:00
// 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 {
2022-04-05 09:17:18 +02:00
port, err := strconv.Atoi(u.Port())
if err != nil {
port = 80
}
2022-05-05 09:24:51 +02:00
path := "/"
if u.Path != "" {
path = u.Path
}
2022-04-05 09:17:18 +02:00
switch u.Scheme {
case "http", "https":
probe.HttpGet = &helm.HttpGet{
2022-05-05 09:24:51 +02:00
Path: path,
2022-04-05 09:17:18 +02:00
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)
}
2022-05-05 09:24:51 +02:00
return probe
2022-04-05 09:17:18 +02:00
}
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)
2022-04-05 09:17:18 +02:00
switch first {
case "CMD", "CMD-SHELL":
// CMD or CMD-SHELL
2022-05-05 09:24:51 +02:00
p.Exec = &helm.Exec{
Command: s.HealthCheck.Test[1:],
2022-04-05 09:17:18 +02:00
}
2022-05-05 09:24:51 +02:00
return p
2022-04-05 09:17:18 +02:00
default:
// badly made but it should work...
2022-05-05 09:24:51 +02:00
p.Exec = &helm.Exec{
Command: []string(s.HealthCheck.Test),
2022-04-05 09:17:18 +02:00
}
2022-05-05 09:24:51 +02:00
return p
2022-04-05 09:17:18 +02:00
}
}
// prepareEnvFromFiles generate configMap or secrets from environment files.
func prepareEnvFromFiles(name string, s types.ServiceConfig, container *helm.Container, ret chan interface{}) {
// prepare secrets
secretsFiles := make([]string, 0)
if v, ok := s.Labels[helm.LABEL_ENV_SECRET]; ok {
secretsFiles = strings.Split(v, ",")
}
// manage environment files (env_file in compose)
for _, envfile := range s.EnvFile {
f := strings.ReplaceAll(envfile, "_", "-")
f = strings.ReplaceAll(f, ".env", "")
f = strings.ReplaceAll(f, ".", "")
f = strings.ReplaceAll(f, "/", "")
cf := f + "-" + name
isSecret := false
for _, s := range secretsFiles {
if s == envfile {
isSecret = true
}
}
var store helm.InlineConfig
if !isSecret {
logger.Bluef(ICON_CONF+" Generating configMap %s\n", cf)
store = helm.NewConfigMap(cf)
} else {
logger.Bluef(ICON_SECRET+" Generating secret %s\n", cf)
store = helm.NewSecret(cf)
}
envfile = filepath.Join(compose.GetCurrentDir(), envfile)
if err := store.AddEnvFile(envfile); 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,
},
})
2022-05-05 08:05:15 +02:00
// 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:]...)
}
}
}
}
ret <- store
}
}
// AddValues adds values to the values.yaml map.
func AddValues(servicename string, values map[string]interface{}) {
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
}
}
// AddVolumeValues add a volume to the values.yaml map for the given deployment name.
func AddVolumeValues(deployment string, volname string, values map[string]interface{}) {
locker.Lock()
defer locker.Unlock()
if _, ok := VolumeValues[deployment]; !ok {
VolumeValues[deployment] = make(map[string]map[string]interface{})
}
VolumeValues[deployment][volname] = values
}
2022-05-05 08:05:15 +02:00
func readEnvFile(envfilename string) map[string]string {
env := make(map[string]string)
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
}