From 95f3abfa742a9abdc609691a863d6195391cb716 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 22 Nov 2024 14:54:36 +0100 Subject: [PATCH] feat(volume): add "exchange volumes" This volumes are "emptyDir" and can have init command. For example, in a "same-pod", it allow the user to copy data from image to a directory that is mounted on others pods. --- generator/chart.go | 13 ++- generator/deployment.go | 102 +++++++++++++++--- generator/generator.go | 10 +- generator/katenaryfile/main.go | 30 +++--- generator/labels/katenaryLabels.go | 1 + generator/labels/katenaryLabelsDoc.yaml | 36 +++++++ .../labels/labelStructs/exchangeVolume.go | 20 ++++ 7 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 generator/labels/labelStructs/exchangeVolume.go diff --git a/generator/chart.go b/generator/chart.go index 6795f45..b1f7751 100644 --- a/generator/chart.go +++ b/generator/chart.go @@ -209,10 +209,21 @@ func (chart *HelmChart) generateDeployment(service types.ServiceConfig, deployme // generate the cronjob if needed chart.setCronJob(service, appName) + if exchange, ok := service.Labels[labels.LabelExchangeVolume]; ok { + // we need to add a volume and a mount point + ex, err := labelStructs.NewExchangeVolumes(exchange) + if err != nil { + return err + } + for _, exchangeVolume := range ex { + d.AddLegacyVolume("exchange-"+exchangeVolume.Name, exchangeVolume.Type) + d.exchangesVolumes[service.Name] = exchangeVolume + } + } + // get the same-pod label if exists, add it to the list. // We later will copy some parts to the target deployment and remove this one. if samePod, ok := service.Labels[labels.LabelSamePod]; ok && samePod != "" { - log.Printf("Found same-pod label for %s", service.Name) podToMerge[samePod] = &service } diff --git a/generator/deployment.go b/generator/deployment.go index 5100481..51f0a61 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -33,12 +33,13 @@ type ConfigMapMount struct { // Deployment is a kubernetes Deployment. type Deployment struct { *appsv1.Deployment `yaml:",inline"` - chart *HelmChart `yaml:"-"` - configMaps map[string]*ConfigMapMount `yaml:"-"` - volumeMap map[string]string `yaml:"-"` // keep map of fixed named to original volume name - service *types.ServiceConfig `yaml:"-"` - defaultTag string `yaml:"-"` - isMainApp bool `yaml:"-"` + chart *HelmChart `yaml:"-"` + configMaps map[string]*ConfigMapMount `yaml:"-"` + volumeMap map[string]string `yaml:"-"` // keep map of fixed named to original volume name + service *types.ServiceConfig `yaml:"-"` + defaultTag string `yaml:"-"` + isMainApp bool `yaml:"-"` + exchangesVolumes map[string]*labelStructs.ExchangeVolume `yaml:"-"` } // NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name. @@ -90,8 +91,9 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { }, }, }, - configMaps: make(map[string]*ConfigMapMount), - volumeMap: make(map[string]string), + configMaps: make(map[string]*ConfigMapMount), + volumeMap: make(map[string]string), + exchangesVolumes: map[string]*labelStructs.ExchangeVolume{}, } // add containers @@ -212,6 +214,27 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { } } +func (d *Deployment) AddLegacyVolume(name, kind string) { + // ensure the volume is not present + for _, v := range d.Spec.Template.Spec.Volumes { + if v.Name == name { + return + } + } + + // init + if d.Spec.Template.Spec.Volumes == nil { + d.Spec.Template.Spec.Volumes = []corev1.Volume{} + } + + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: name, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }) +} + func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) { // find the volume in the binded deployment for _, bindedVolume := range binded.Spec.Template.Spec.Volumes { @@ -276,14 +299,27 @@ func (d *Deployment) Filename() string { } // SetEnvFrom sets the environment variables to a configmap. The configmap is created. -func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { +func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string, samePod ...bool) { if len(service.Environment) == 0 { return } + inSamePod := false + if len(samePod) > 0 && samePod[0] { + inSamePod = true + } drop := []string{} secrets := []string{} + defer func() { + c, index := d.BindMapFilesToContainer(service, secrets, appName) + if c == nil || index == -1 { + log.Println("Container not found for service ", service.Name) + return + } + d.Spec.Template.Spec.Containers[index] = *c + }() + // secrets from label labelSecrets, err := labelStructs.SecretsFrom(service.Labels[labels.LabelSecrets]) if err != nil { @@ -308,6 +344,10 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { secrets = append(secrets, secret) } + if inSamePod { + return + } + // for each values from label "values", add it to Values map and change the envFrom // value to {{ .Values.. }} for _, value := range labelValues { @@ -330,10 +370,26 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { for _, value := range drop { delete(service.Environment, value) } +} +func (d *Deployment) BindMapFilesToContainer(service types.ServiceConfig, secrets []string, appName string) (*corev1.Container, int) { fromSources := []corev1.EnvFromSource{} - if len(service.Environment) > 0 { + envSize := len(service.Environment) + + for _, secret := range secrets { + for k := range service.Environment { + if k == secret { + envSize-- + } + } + } + + if envSize > 0 { + if service.Name == "db" { + log.Println("Service ", service.Name, " has environment variables") + log.Println(service.Environment) + } fromSources = append(fromSources, corev1.EnvFromSource{ ConfigMapRef: &corev1.ConfigMapEnvSource{ LocalObjectReference: corev1.LocalObjectReference{ @@ -356,7 +412,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) if container == nil { utils.Warn("Container not found for service " + service.Name) - return + return nil, -1 } container.EnvFrom = append(container.EnvFrom, fromSources...) @@ -364,8 +420,30 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { if container.Env == nil { container.Env = []corev1.EnvVar{} } + return container, index +} - d.Spec.Template.Spec.Containers[index] = *container +func (d *Deployment) MountExchangeVolumes() { + for name, ex := range d.exchangesVolumes { + for i, c := range d.Spec.Template.Spec.Containers { + c.VolumeMounts = append(c.VolumeMounts, corev1.VolumeMount{ + Name: "exchange-" + ex.Name, + MountPath: ex.MountPath, + }) + if len(ex.Init) > 0 && name == c.Name { + d.Spec.Template.Spec.InitContainers = append(d.Spec.Template.Spec.InitContainers, corev1.Container{ + Command: []string{"/bin/sh", "-c", ex.Init}, + Image: c.Image, + Name: "exhange-init-" + name, + VolumeMounts: []corev1.VolumeMount{{ + Name: "exchange-" + ex.Name, + MountPath: ex.MountPath, + }}, + }) + } + d.Spec.Template.Spec.Containers[i] = c + } + } } // Yaml returns the yaml representation of the deployment. diff --git a/generator/generator.go b/generator/generator.go index 75b1831..88f5444 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -79,6 +79,11 @@ func Generate(project *types.Project) (*HelmChart, error) { } } + // if we have built exchange volumes, we need to moint them in each deployment + for _, d := range deployments { + d.MountExchangeVolumes() + } + // drop all "same-pod" deployments because the containers and volumes are already // in the target deployment for _, service := range podToMerge { @@ -87,7 +92,10 @@ func Generate(project *types.Project) (*HelmChart, error) { if target, ok := deployments[samepod]; ok { target.AddContainer(*service) target.BindFrom(*service, deployments[service.Name]) - target.SetEnvFrom(*service, appName) + target.SetEnvFrom(*service, appName, true) + // copy all init containers + initContainers := deployments[service.Name].Spec.Template.Spec.InitContainers + target.Spec.Template.Spec.InitContainers = append(target.Spec.Template.Spec.InitContainers, initContainers...) delete(deployments, service.Name) } else { log.Printf("service %[1]s is declared as %[2]s, but %[2]s is not defined", service.Name, labels.LabelSamePod) diff --git a/generator/katenaryfile/main.go b/generator/katenaryfile/main.go index d45a97f..98ebddc 100644 --- a/generator/katenaryfile/main.go +++ b/generator/katenaryfile/main.go @@ -25,20 +25,21 @@ type StringOrMap any // Service is a struct that contains the service configuration for katenary type Service struct { - MainApp *bool `json:"main-app,omitempty" jsonschema:"title=Is this service the main application"` - Values []StringOrMap `json:"values,omitempty" jsonschema:"description=Environment variables to be set in values.yaml with or without a description"` - Secrets *labelStructs.Secrets `json:"secrets,omitempty" jsonschema:"title=Secrets,description=Environment variables to be set as secrets"` - Ports *labelStructs.Ports `json:"ports,omitempty" jsonschema:"title=Ports,description=Ports to be exposed in services"` - Ingress *labelStructs.Ingress `json:"ingress,omitempty" jsonschema:"title=Ingress,description=Ingress configuration"` - HealthCheck *labelStructs.HealthCheck `json:"health-check,omitempty" jsonschema:"title=Health Check,description=Health check configuration that respects the kubernetes api"` - SamePod *string `json:"same-pod,omitempty" jsonschema:"title=Same Pod,description=Service that should be in the same pod"` - Description *string `json:"description,omitempty" jsonschema:"title=Description,description=Description of the service that will be injected in the values.yaml file"` - Ignore *bool `json:"ignore,omitempty" jsonschema:"title=Ignore,description=Ignore the service in the conversion"` - Dependencies []labelStructs.Dependency `json:"dependencies,omitempty" jsonschema:"title=Dependencies,description=Services that should be injected in the Chart.yaml file"` - ConfigMapFile *labelStructs.ConfigMapFile `json:"configmap-files,omitempty" jsonschema:"title=ConfigMap Files,description=Files that should be injected as ConfigMap"` - MapEnv *labelStructs.MapEnv `json:"map-env,omitempty" jsonschema:"title=Map Env,description=Map environment variables to another value"` - CronJob *labelStructs.CronJob `json:"cron-job,omitempty" jsonschema:"title=Cron Job,description=Cron Job configuration"` - EnvFrom *labelStructs.EnvFrom `json:"env-from,omitempty" jsonschema:"title=Env From,description=Inject environment variables from another service"` + MainApp *bool `json:"main-app,omitempty" jsonschema:"title=Is this service the main application"` + Values []StringOrMap `json:"values,omitempty" jsonschema:"description=Environment variables to be set in values.yaml with or without a description"` + Secrets *labelStructs.Secrets `json:"secrets,omitempty" jsonschema:"title=Secrets,description=Environment variables to be set as secrets"` + Ports *labelStructs.Ports `json:"ports,omitempty" jsonschema:"title=Ports,description=Ports to be exposed in services"` + Ingress *labelStructs.Ingress `json:"ingress,omitempty" jsonschema:"title=Ingress,description=Ingress configuration"` + HealthCheck *labelStructs.HealthCheck `json:"health-check,omitempty" jsonschema:"title=Health Check,description=Health check configuration that respects the kubernetes api"` + SamePod *string `json:"same-pod,omitempty" jsonschema:"title=Same Pod,description=Service that should be in the same pod"` + Description *string `json:"description,omitempty" jsonschema:"title=Description,description=Description of the service that will be injected in the values.yaml file"` + Ignore *bool `json:"ignore,omitempty" jsonschema:"title=Ignore,description=Ignore the service in the conversion"` + Dependencies []labelStructs.Dependency `json:"dependencies,omitempty" jsonschema:"title=Dependencies,description=Services that should be injected in the Chart.yaml file"` + ConfigMapFile *labelStructs.ConfigMapFile `json:"configmap-files,omitempty" jsonschema:"title=ConfigMap Files,description=Files that should be injected as ConfigMap"` + MapEnv *labelStructs.MapEnv `json:"map-env,omitempty" jsonschema:"title=Map Env,description=Map environment variables to another value"` + CronJob *labelStructs.CronJob `json:"cron-job,omitempty" jsonschema:"title=Cron Job,description=Cron Job configuration"` + EnvFrom *labelStructs.EnvFrom `json:"env-from,omitempty" jsonschema:"title=Env From,description=Inject environment variables from another service"` + ExchangeVolumes []*labelStructs.ExchangeVolume `json:"exchange-volumes,omitempty" jsonschema:"title=Exchange Volumes,description=Exchange volumes between services"` } // OverrideWithConfig overrides the project with the katenary.yaml file. It @@ -89,6 +90,7 @@ func OverrideWithConfig(project *types.Project) { getLabelContent(s.MapEnv, &project.Services[i], labels.LabelMapEnv) getLabelContent(s.CronJob, &project.Services[i], labels.LabelCronJob) getLabelContent(s.EnvFrom, &project.Services[i], labels.LabelEnvFrom) + getLabelContent(s.ExchangeVolumes, &project.Services[i], labels.LabelExchangeVolume) } } fmt.Println(utils.IconInfo, "Katenary file loaded successfully, the services are now configured.") diff --git a/generator/labels/katenaryLabels.go b/generator/labels/katenaryLabels.go index 8d22293..0bc0494 100644 --- a/generator/labels/katenaryLabels.go +++ b/generator/labels/katenaryLabels.go @@ -32,6 +32,7 @@ const ( LabelConfigMapFiles Label = KatenaryLabelPrefix + "/configmap-files" LabelCronJob Label = KatenaryLabelPrefix + "/cronjob" LabelEnvFrom Label = KatenaryLabelPrefix + "/env-from" + LabelExchangeVolume Label = KatenaryLabelPrefix + "/exchange-volumes" ) var ( diff --git a/generator/labels/katenaryLabelsDoc.yaml b/generator/labels/katenaryLabelsDoc.yaml index 3c9e436..63cb76c 100644 --- a/generator/labels/katenaryLabelsDoc.yaml +++ b/generator/labels/katenaryLabelsDoc.yaml @@ -284,4 +284,40 @@ {{ .KatenaryPrefix }}/env-from: |- - myservice1 +"exchange-volumes": + short: Add exchange volumes (empty directory on the node) to share data + type: "list of objects" + long: |- + This label allows sharing data between containres. The volume is created in + the node and mounted in the pod. It is useful to share data between containers + in a "same pod" logic. For example to let PHP-FPM and Nginx share the same direcotory. + + This will create: + - an `emptyDir` volume in the deployment + - a `voumeMount` in the pod for **each container** + - a `initContainer` for each definition + + Fields: + - name: the name of the volume (manadatory) + - mountPath: the path where the volume is mounted in the pod (optional, default is `/opt`) + - init: a command to run to initialize the volume with data (optional) + + !!! Warning + This is highly experimental. This is mainly useful when using the "same-pod" label. + + example: |- + nginx: + # ... + labels; + {{ .KatenaryPrefix }}/exchange-volumes: |- + - name: php-fpm + mountPath: /var/www/html + php: + # ... + labels: + {{ .KatenaryPrefix }}/exchange-volumes: |- + - name: php-fpm + mountPath: /opt + init: cp -ra /var/www/html/* /opt + # vim: ft=gotmpl.yaml diff --git a/generator/labels/labelStructs/exchangeVolume.go b/generator/labels/labelStructs/exchangeVolume.go new file mode 100644 index 0000000..1faa997 --- /dev/null +++ b/generator/labels/labelStructs/exchangeVolume.go @@ -0,0 +1,20 @@ +package labelStructs + +import "gopkg.in/yaml.v3" + +type ExchangeVolume struct { + Name string `yaml:"name" json:"name"` + MountPath string `yaml:"mountPath" json:"mountPath"` + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Init string `yaml:"init,omitempty" json:"init,omitempty"` +} + +func NewExchangeVolumes(data string) ([]*ExchangeVolume, error) { + mapping := []*ExchangeVolume{} + + if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + return nil, err + } + + return mapping, nil +}