2023-12-06 15:24:02 +01:00
package generator
import (
"bytes"
"fmt"
"regexp"
"strings"
2025-08-19 23:58:51 +02:00
"katenary.io/internal/generator/labels"
"katenary.io/internal/generator/labels/labelstructs"
2025-09-23 12:30:43 +02:00
"katenary.io/internal/logger"
2025-08-19 23:58:51 +02:00
"katenary.io/internal/utils"
2025-08-03 15:54:58 +02:00
2025-10-18 14:43:16 +02:00
"github.com/compose-spec/compose-go/v2/types"
2023-12-06 15:24:02 +01:00
corev1 "k8s.io/api/core/v1"
2025-01-19 23:15:53 +01:00
"sigs.k8s.io/yaml"
2023-12-06 15:24:02 +01:00
)
// 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:
//
2024-04-05 07:56:27 +02:00
// - Detect the service port name or leave the port number if not found.
2026-03-15 08:55:24 +01:00
// - Create a deployment for each service that are not ingore.
2024-04-05 07:56:27 +02:00
// - Create a service and ingresses for each service that has ports and/or declared ingresses.
// - Create a PVC or Configmap volumes for each volume.
// - Create init containers for each service which has dependencies to other services.
// - Create a chart dependencies.
// - Create a configmap and secrets from the environment variables.
// - Merge the same-pod services.
2023-12-06 15:24:02 +01:00
func Generate ( project * types . Project ) ( * HelmChart , error ) {
var (
appName = project . Name
deployments = make ( map [ string ] * Deployment , len ( project . Services ) )
services = make ( map [ string ] * Service )
2024-04-08 23:15:05 +02:00
podToMerge = make ( map [ string ] * types . ServiceConfig )
2023-12-06 15:24:02 +01:00
)
chart := NewChart ( appName )
// Add the compose files hash to the chart annotations
hash , err := utils . HashComposefiles ( project . ComposeFiles )
if err != nil {
return nil , err
}
2024-11-18 17:12:12 +01:00
Annotations [ labels . LabelName ( "compose-hash" ) ] = hash
2023-12-06 15:24:02 +01:00
chart . composeHash = & hash
2025-01-19 23:38:17 +01:00
// drop all services with the "ignore" label
dropIngoredServices ( project )
2025-06-26 23:23:03 +02:00
fixContainerNames ( project )
2025-01-19 23:15:53 +01:00
// rename all services name to remove dashes
2025-01-19 23:38:17 +01:00
if err := fixResourceNames ( project ) ; err != nil {
return nil , err
2025-01-19 23:15:53 +01:00
}
2023-12-06 15:24:02 +01:00
// find the "main-app" label, and set chart.AppVersion to the tag if exists
mainCount := 0
for _ , service := range project . Services {
if serviceIsMain ( service ) {
mainCount ++
if mainCount > 1 {
return nil , fmt . Errorf ( "found more than one main app" )
}
2024-05-07 13:18:00 +02:00
chart . setChartVersion ( service )
2023-12-06 15:24:02 +01:00
}
}
if mainCount == 0 {
chart . AppVersion = "0.1.0"
}
// first pass, create all deployments whatewer they are.
for _ , service := range project . Services {
2024-05-07 13:18:00 +02:00
err := chart . generateDeployment ( service , deployments , services , podToMerge , appName )
if err != nil {
2023-12-06 15:24:02 +01:00
return nil , err
}
}
// now we have all deployments, we can create PVC if needed (it's separated from
// the above loop because we need all deployments to not duplicate PVC for "same-pod" services)
2024-04-23 08:05:00 +02:00
// bind static volumes
for _ , service := range project . Services {
addStaticVolumes ( deployments , service )
}
2023-12-06 15:24:02 +01:00
for _ , service := range project . Services {
2024-05-07 13:18:00 +02:00
err := buildVolumes ( service , chart , deployments )
if err != nil {
2023-12-06 15:24:02 +01:00
return nil , err
}
}
2024-04-23 08:05:00 +02:00
2024-11-22 14:54:36 +01:00
// if we have built exchange volumes, we need to moint them in each deployment
for _ , d := range deployments {
d . MountExchangeVolumes ( )
}
2023-12-06 15:24:02 +01:00
// drop all "same-pod" deployments because the containers and volumes are already
// in the target deployment
2025-09-23 12:30:43 +02:00
drops := [ ] string { }
2024-04-08 23:15:05 +02:00
for _ , service := range podToMerge {
2024-11-18 17:12:12 +01:00
if samepod , ok := service . Labels [ labels . LabelSamePod ] ; ok && samepod != "" {
2023-12-06 15:24:02 +01:00
// move this deployment volumes to the target deployment
if target , ok := deployments [ samepod ] ; ok {
2024-04-08 23:15:05 +02:00
target . AddContainer ( * service )
target . BindFrom ( * service , deployments [ service . Name ] )
2024-11-22 14:54:36 +01:00
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 ... )
2025-09-23 12:30:43 +02:00
drops = append ( drops , service . Name )
2023-12-06 15:24:02 +01:00
} else {
2025-09-23 12:30:43 +02:00
err := fmt . Errorf ( "service %s is declared as %s, but %s is not defined" , service . Name , labels . LabelSamePod , samepod )
logger . Failure ( err . Error ( ) )
return nil , err
2023-12-06 15:24:02 +01:00
}
}
}
// create init containers for all DependsOn
for _ , s := range project . Services {
for _ , d := range s . GetDependencies ( ) {
if dep , ok := deployments [ d ] ; ok {
2025-06-04 14:29:13 +02:00
err := deployments [ s . Name ] . DependsOn ( dep , d )
if err != nil {
2025-09-23 12:30:43 +02:00
logger . Info ( fmt . Sprintf ( "error creating init container for service %[1]s: %[2]s" , s . Name , err ) )
2025-06-04 14:29:13 +02:00
}
2023-12-06 15:24:02 +01:00
} else {
2025-09-23 12:30:43 +02:00
err := fmt . Errorf ( "service %[1]s depends on %[2]s, but %[2]s is not defined" , s . Name , d )
logger . Failure ( err . Error ( ) )
return nil , err
2023-12-06 15:24:02 +01:00
}
}
}
2026-03-15 08:55:24 +01:00
2026-03-16 22:20:24 +01:00
// warn users if dependent service has no ports
for _ , s := range project . Services {
for _ , d := range s . GetDependencies ( ) {
if dep , ok := deployments [ d ] ; ok {
if len ( dep . service . Ports ) == 0 {
logger . Warnf ( "Service %s is used in depends_on but has no ports declared. No Kubernetes Service will be created for it. Add katenary.v3/ports label if you need to create a Service." , d )
}
}
}
}
2026-03-15 08:55:24 +01:00
// set ServiceAccountName for deployments that need it
for _ , d := range deployments {
d . SetServiceAccountName ( )
}
2025-09-23 12:30:43 +02:00
for _ , name := range drops {
delete ( deployments , name )
}
2024-11-26 16:11:12 +01:00
// it's now time to get "value-from", before makeing the secrets and configmaps!
for _ , s := range project . Services {
chart . setEnvironmentValuesFrom ( s , deployments )
}
2023-12-06 15:24:02 +01:00
2026-03-15 08:55:24 +01:00
// generate RBAC resources for services that need K8s API access (non-legacy depends_on)
if err := chart . generateRBAC ( deployments ) ; err != nil {
logger . Fatalf ( "error generating RBAC: %s" , err )
}
2023-12-06 15:24:02 +01:00
// generate configmaps with environment variables
2025-06-04 14:29:13 +02:00
if err := chart . generateConfigMapsAndSecrets ( project ) ; err != nil {
2026-03-08 22:38:03 +01:00
logger . Fatalf ( "error generating configmaps and secrets: %s" , err )
2025-06-04 14:29:13 +02:00
}
2023-12-06 15:24:02 +01:00
// 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 {
2024-05-07 13:18:00 +02:00
chart . setSharedConf ( s , deployments )
2023-12-06 15:24:02 +01:00
}
2024-11-26 16:11:12 +01:00
// remove all "boundEnv" from the values
for _ , d := range deployments {
if len ( d . boundEnvVar ) == 0 {
continue
}
for _ , e := range d . boundEnvVar {
delete ( chart . Values [ d . service . Name ] . ( * Value ) . Environment , e )
}
}
2023-12-06 15:24:02 +01:00
// generate yaml files
for _ , d := range deployments {
2024-04-23 08:05:00 +02:00
y , err := d . Yaml ( )
if err != nil {
return nil , err
}
2023-12-06 15:24:02 +01:00
chart . Templates [ d . Filename ( ) ] = & ChartTemplate {
Content : y ,
Servicename : d . service . Name ,
}
}
// generate all services
for _ , s := range services {
2024-04-08 23:15:05 +02:00
// add the service ports to the target service if it's a "same-pod" service
if samePod , ok := podToMerge [ s . service . Name ] ; ok {
// get the target service
target := services [ samePod . Name ]
// merge the services
s . Spec . Ports = append ( s . Spec . Ports , target . Spec . Ports ... )
}
2023-12-06 15:24:02 +01:00
y , _ := s . Yaml ( )
chart . Templates [ s . Filename ( ) ] = & ChartTemplate {
Content : y ,
Servicename : s . service . Name ,
}
}
2024-04-08 23:15:05 +02:00
// drop all "same-pod" services
for _ , s := range podToMerge {
// get the target service
target := services [ s . Name ]
2024-11-22 16:11:55 +01:00
if target != nil {
delete ( chart . Templates , target . Filename ( ) )
}
2024-04-08 23:15:05 +02:00
}
2023-12-06 15:24:02 +01:00
// 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
}
2025-01-19 23:38:17 +01:00
// dropIngoredServices removes all services with the "ignore" label set to true (or yes).
func dropIngoredServices ( project * types . Project ) {
2025-10-18 14:43:16 +02:00
for name , service := range project . Services {
2025-01-19 23:38:17 +01:00
if isIgnored ( service ) {
2025-10-18 14:43:16 +02:00
delete ( project . Services , name )
2025-01-19 23:38:17 +01:00
}
}
}
// fixResourceNames renames all services and related resources to remove dashes.
func fixResourceNames ( project * types . Project ) error {
// rename all services name to remove dashes
for i , service := range project . Services {
if service . Name != utils . AsResourceName ( service . Name ) {
fixed := utils . AsResourceName ( service . Name )
for j , s := range project . Services {
// for the same-pod services, we need to keep the original name
if samepod , ok := s . Labels [ labels . LabelSamePod ] ; ok && samepod == service . Name {
s . Labels [ labels . LabelSamePod ] = fixed
project . Services [ j ] = s
}
2025-09-23 12:30:43 +02:00
2025-01-19 23:38:17 +01:00
// also, the value-from label should be updated
2025-07-15 21:13:22 +02:00
if valuefrom , ok := s . Labels [ labels . LabelValuesFrom ] ; ok {
2025-07-06 14:34:16 +02:00
vf , err := labelstructs . GetValueFrom ( valuefrom )
2025-01-19 23:38:17 +01:00
if err != nil {
return err
}
for varname , bind := range * vf {
bind := strings . ReplaceAll ( bind , service . Name , fixed )
( * vf ) [ varname ] = bind
}
output , err := yaml . Marshal ( vf )
if err != nil {
return err
}
2025-07-15 21:13:22 +02:00
s . Labels [ labels . LabelValuesFrom ] = string ( output )
2025-01-19 23:38:17 +01:00
}
}
service . Name = fixed
}
2025-09-23 12:30:43 +02:00
// rename depends_on
for _ , d := range service . GetDependencies ( ) {
depname := utils . AsResourceName ( d )
dep := service . DependsOn [ d ]
delete ( service . DependsOn , d )
service . DependsOn [ depname ] = dep
}
project . Services [ i ] = service
2025-01-19 23:38:17 +01:00
}
return nil
}
2024-10-17 17:08:42 +02:00
// serviceIsMain returns true if the service is the main app.
func serviceIsMain ( service types . ServiceConfig ) bool {
2024-11-18 17:12:12 +01:00
if main , ok := service . Labels [ labels . LabelMainApp ] ; ok {
2024-10-17 17:08:42 +02:00
return main == "true" || main == "yes" || main == "1"
}
return false
}
func addStaticVolumes ( deployments map [ string ] * Deployment , service types . ServiceConfig ) {
// add the bound configMaps files to the deployment containers
var d * Deployment
var ok bool
if d , ok = deployments [ service . Name ] ; ! ok {
2026-03-08 22:46:26 +01:00
logger . Warnf ( "service %s not found in deployments" , service . Name )
2024-10-17 17:08:42 +02:00
return
}
2025-06-26 23:23:03 +02:00
container , index := utils . GetContainerByName ( service . ContainerName , d . Spec . Template . Spec . Containers )
2024-10-17 17:08:42 +02:00
if container == nil { // may append for the same-pod services
return
}
for volumeName , config := range d . configMaps {
var y [ ] byte
var err error
if y , err = config . configMap . Yaml ( ) ; err != nil {
2026-03-08 22:38:03 +01:00
logger . Fatal ( err )
2024-10-17 17:08:42 +02:00
}
2025-09-14 23:23:11 +02:00
2024-10-17 17:08:42 +02:00
// add the configmap to the chart
d . chart . Templates [ config . configMap . Filename ( ) ] = & ChartTemplate {
Content : y ,
Servicename : d . service . Name ,
}
// add the moint path to the container
for _ , m := range config . mountPath {
2025-09-14 23:23:11 +02:00
// volumeName can be empty, in this case we generate a name
if volumeName == "" {
volumeName = utils . PathToName ( m . subPath )
}
2024-10-17 17:08:42 +02:00
container . VolumeMounts = append ( container . VolumeMounts , corev1 . VolumeMount {
Name : utils . PathToName ( volumeName ) ,
MountPath : m . mountPath ,
SubPath : m . subPath ,
} )
}
d . Spec . Template . Spec . Volumes = append ( d . Spec . Template . Spec . Volumes , corev1 . Volume {
Name : utils . PathToName ( volumeName ) ,
VolumeSource : corev1 . VolumeSource {
ConfigMap : & corev1 . ConfigMapVolumeSource {
LocalObjectReference : corev1 . LocalObjectReference {
Name : config . configMap . Name ,
} ,
} ,
} ,
} )
}
d . Spec . Template . Spec . Containers [ index ] = * container
}
2023-12-06 15:24:02 +01:00
// 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 ) , " " ) )
2025-06-26 23:57:19 +02:00
line = bytes . ReplaceAll ( line , [ ] byte ( "__indent__" ) , fmt . Appendf ( nil , "%d" , len ( startSpaces ) ) )
2023-12-06 15:24:02 +01:00
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
}
2024-04-05 07:56:27 +02:00
// buildVolumes creates the volumes for the service.
2023-12-06 15:24:02 +01:00
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" :
2024-11-21 11:12:38 +01:00
v . Source = utils . AsResourceName ( v . Source )
2023-12-06 15:24:02 +01:00
pvc := NewVolumeClaim ( service , v . Source , appName )
// if the service is integrated in another deployment, we need to add the volume
// to the target deployment
2024-11-18 17:12:12 +01:00
if override , ok := service . Labels [ labels . LabelSamePod ] ; ok {
2023-12-06 15:24:02 +01:00
pvc . nameOverride = override
2024-04-10 04:54:16 +02:00
pvc . Spec . StorageClassName = utils . StrPtr ( ` {{ .Values . ` + override + ` .persistence. ` + v . Source + ` .storageClass }} ` )
2023-12-06 15:24:02 +01:00
chart . Values [ override ] . ( * Value ) . AddPersistence ( v . Source )
}
y , _ := pvc . Yaml ( )
chart . Templates [ pvc . Filename ( ) ] = & ChartTemplate {
Content : y ,
2024-05-06 21:11:36 +02:00
Servicename : service . Name ,
2023-12-06 15:24:02 +01:00
}
2024-04-19 11:13:24 +02:00
}
}
2023-12-06 15:24:02 +01:00
2024-04-23 08:05:00 +02:00
return nil
}
2024-04-05 07:56:27 +02:00
// samePodVolume returns true if the volume is already in the target deployment.
2023-12-06 15:24:02 +01:00
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
}
2025-06-04 14:29:13 +02:00
if len ( service . Volumes ) == 0 {
2023-12-06 15:24:02 +01:00
return false
}
targetDeployment := ""
2024-11-18 17:12:12 +01:00
if targetName , ok := service . Labels [ labels . LabelSamePod ] ; ! ok {
2023-12-06 15:24:02 +01:00
return false
} else {
targetDeployment = targetName
}
// get the target deployment
2024-05-07 13:18:00 +02:00
target := findDeployment ( targetDeployment , deployments )
2023-12-06 15:24:02 +01:00
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 {
2026-03-08 22:46:26 +01:00
logger . Warnf ( "found same pod volume %s in deployment %s and %s" , tv . Name , service . Name , targetDeployment )
2023-12-06 15:24:02 +01:00
return true
}
}
return false
}
2025-06-26 23:23:03 +02:00
2026-03-15 08:55:24 +01:00
// generateRBAC creates RBAC resources (ServiceAccount, Role, RoleBinding) for services that need K8s API access.
// A service needs RBAC if it has non-legacy depends_on relationships.
func ( chart * HelmChart ) generateRBAC ( deployments map [ string ] * Deployment ) error {
serviceMap := make ( map [ string ] bool )
for _ , d := range deployments {
if ! d . needsServiceAccount {
continue
}
sa := NewServiceAccount ( * d . service , chart . Name )
role := NewRestrictedRole ( * d . service , chart . Name )
rb := NewRestrictedRoleBinding ( * d . service , chart . Name )
var buf bytes . Buffer
saYaml , err := yaml . Marshal ( sa . ServiceAccount )
if err != nil {
return fmt . Errorf ( "error marshaling ServiceAccount for %s: %w" , d . service . Name , err )
}
buf . Write ( saYaml )
buf . WriteString ( "---\n" )
roleYaml , err := yaml . Marshal ( role . Role )
if err != nil {
return fmt . Errorf ( "error marshaling Role for %s: %w" , d . service . Name , err )
}
buf . Write ( roleYaml )
buf . WriteString ( "---\n" )
rbYaml , err := yaml . Marshal ( rb . RoleBinding )
if err != nil {
return fmt . Errorf ( "error marshaling RoleBinding for %s: %w" , d . service . Name , err )
}
buf . Write ( rbYaml )
filename := d . service . Name + "/depends-on.rbac.yaml"
chart . Templates [ filename ] = & ChartTemplate {
Content : buf . Bytes ( ) ,
Servicename : d . service . Name ,
}
serviceMap [ d . service . Name ] = true
}
for svcName := range serviceMap {
logger . Log ( logger . IconPackage , "Creating RBAC" , svcName )
}
return nil
}
2025-06-26 23:23:03 +02:00
func fixContainerNames ( project * types . Project ) {
// fix container names to be unique
for i , service := range project . Services {
if service . ContainerName == "" {
service . ContainerName = utils . FixedResourceName ( service . Name )
} else {
service . ContainerName = utils . FixedResourceName ( service . ContainerName )
}
project . Services [ i ] = service
}
}