diff --git a/.gitignore b/.gitignore index e6c096f..91482e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ dist/* .cache/* chart/* -docker-compose.yaml +*.yaml +*.yml ./katenary *.env docker-compose* diff --git a/README.md b/README.md index 0310947..9d68f4b 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Katenary Logo -Katenary is a tool to help transforming `docker-compose` files to a working Helm Chart for Kubernetes. +Katenary is a tool to help to transform `docker-compose` files to a working Helm Chart for Kubernetes. -> **Important Note:** Katenary is a tool to help building Helm Chart from a docker-compose file, but docker-compose doesn't propose as many features as what can do Kubernetes. So, we strongly recommend to use Katenary as a "bootstrap" tool and then to manually enhance the generated helm chart. +> **Important Note:** Katenary is a tool to help to build Helm Chart from a docker-compose file, but docker-compose doesn't propose as many features as what can do Kubernetes. So, we strongly recommend to use Katenary as a "bootstrap" tool and then to manually enhance the generated helm chart. This project is partially made at [Smile](https://www.smile.eu) @@ -59,7 +59,7 @@ Then place the `katenary` binary file inside your PATH. We strongly recommand to add the "completion" call to you SHELL using the common bashrc, or whatever the profile file you use. -E.g. : +E.g.: ```bash # bash in ~/.bashrc file @@ -122,11 +122,11 @@ What can be interpreted by Katenary: - Services with "image" section (cannot work with "build" section) - **Named Volumes** are transformed to persistent volume claims - note that local volume will break the transformation to Helm Chart because there is (for now) no way to make it working (see below for resolution) - 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) -- `env_file` list will create a configMap object per environemnt file (⚠ todo: the "to-service" label doesn't work with configMap for now) +- `depends_on` will add init containers to wait for the depending on service (using the first port) +- `env_file` list will create a configMap object per environemnt file (⚠ to-do: the "to-service" label doesn't work with configMap for now) - some labels can help to bind values, for example: - - `katenary.io/ingress: 80` will expose the port 80 in a ingress - - `katenary.io/mapenv: |`: allow to map environment to something else than the given value in the compose file + - `katenary.io/ingress: 80` will expose the port 80 in an ingress + - `katenary.io/mapenv: |`: allow mapping environment to something else than the given value in the compose file Exemple of a possible `docker-compose.yaml` file: @@ -173,20 +173,32 @@ services: These labels could be found by `katenary show-labels`, and can be placed as "labels" inside your docker-compose file: ``` -katenary.io/ignore : ignore the container, it will not yied any object in the helm chart -katenary.io/secret-vars : secret variables to push on a secret file -katenary.io/secret-envfiles : set the given file names as a secret instead of configmap -katenary.io/mapenv : map environment variable to a template string (yaml style) +# Labels +katenary.io/ignore : ignore the container, it will not yied any object in the helm chart (bool) +katenary.io/secret-vars : secret variables to push on a secret file (coma separated) +katenary.io/secret-envfiles : set the given file names as a secret instead of configmap (coma separated) +katenary.io/mapenv : map environment variable to a template string (yaml style, object) katenary.io/ports : set the ports to expose as a service (coma separated) katenary.io/ingress : set the port to expose in an ingress (coma separated) katenary.io/configmap-volumes : specifies that the volumes points on a configmap (coma separated) -katenary.io/same-pod : specifies that the pod should be deployed in the same pod than the given service name -katenary.io/empty-dirs : specifies that the given volume names should be "emptyDir" instead of persistentVolumeClaim (coma separated) -katenary.io/healthcheck : specifies that the container should be monitored by a healthcheck, **it overrides the docker-compose healthcheck**. +katenary.io/same-pod : specifies that the pod should be deployed in the same pod than the + given service name (string) +katenary.io/volume-from : specifies that the volumes to be mounted from the given service (yaml style) +katenary.io/empty-dirs : specifies that the given volume names should be "emptyDir" instead of + persistentVolumeClaim (coma separated) +katenary.io/crontabs : specifies a cronjobs to create (yaml style, array) - this will create a + cronjob, a service account, a role and a rolebinding to start the command with "kubectl" + The form is the following: + - command: the command to run + schedule: the schedule to run the command (e.g. "@daily" or "*/1 * * * *") + image: the image to use for the command (default to "bitnami/kubectl") + allPods: true if you want to run the command on all pods (default to false) +katenary.io/healthcheck : specifies that the container should be monitored by a healthcheck, + **it overrides the docker-compose healthcheck**. You can use these form of label values: - - "http://[not used address][:port][/path]" to specify an http healthcheck - - "tcp://[not used address]:port" to specify a tcp healthcheck - - other string is condidered as a "command" healthcheck + -> http://[ignored][:port][/path] to specify an http healthcheck + -> tcp://[ignored]:port to specify a tcp healthcheck + -> other string is condidered as a "command" healthcheck ``` # What a name... diff --git a/cmd/katenary/main.go b/cmd/katenary/main.go index 712f057..ceaea8e 100644 --- a/cmd/katenary/main.go +++ b/cmd/katenary/main.go @@ -50,6 +50,7 @@ func main() { } // convert command, need some flags + var composeFiles *[]string convertCmd := &cobra.Command{ Use: "convert", Short: "Convert docker-compose to helm chart", @@ -61,9 +62,7 @@ func main() { "- if it's not defined, so the 0.0.1 version is used", Run: func(c *cobra.Command, args []string) { force := c.Flag("force").Changed - // TODO: is there a way to get typed values from cobra? appversion := c.Flag("app-version").Value.String() - composeFile := c.Flag("compose-file").Value.String() appName := c.Flag("app-name").Value.String() chartVersion := c.Flag("chart-version").Value.String() chartDir := c.Flag("output-dir").Value.String() @@ -71,17 +70,17 @@ func main() { if err != nil { writers.IndentSize = indentation } - Convert(composeFile, appversion, appName, chartDir, chartVersion, force) + Convert(*composeFiles, appversion, appName, chartDir, chartVersion, force) }, } + composeFiles = convertCmd.Flags().StringArrayP( + "compose-file", "c", []string{ComposeFile}, "compose file to convert, can be use several times to override previous file. Order is important!") convertCmd.Flags().BoolP( "force", "f", false, "force overwrite of existing output files") convertCmd.Flags().StringP( "app-version", "a", AppVersion, "app version") convertCmd.Flags().StringP( "chart-version", "v", ChartVersion, "chart version") - convertCmd.Flags().StringP( - "compose-file", "c", ComposeFile, "docker compose file") convertCmd.Flags().StringP( "app-name", "n", AppName, "application name") convertCmd.Flags().StringP( diff --git a/cmd/katenary/utils.go b/cmd/katenary/utils.go index bc0d8b4..118932e 100644 --- a/cmd/katenary/utils.go +++ b/cmd/katenary/utils.go @@ -93,19 +93,24 @@ func detectGitVersion() (string, error) { return defaulVersion, errors.New("git log failed") } -func Convert(composeFile, appVersion, appName, chartDir, chartVersion string, force bool) { +func Convert(composeFile []string, appVersion, appName, chartDir, chartVersion string, force bool) { if len(composeFile) == 0 { fmt.Println("No compose file given") return } - _, err := os.Stat(composeFile) - if err != nil { - fmt.Println("No compose file found") - os.Exit(1) + + composeFiles := composeFile + ComposeFile = composeFiles[0] + + for _, cf := range composeFiles { + if _, err := os.Stat(cf); err != nil { + fmt.Printf("Compose file %s not found\n", cf) + return + } } // Parse the compose file now - p := compose.NewParser(composeFile) + p := compose.NewParser(composeFiles) p.Parse(appName) dirname := filepath.Join(chartDir, appName) diff --git a/compose/parser.go b/compose/parser.go index 86b0cb5..2653b5b 100644 --- a/compose/parser.go +++ b/compose/parser.go @@ -26,32 +26,37 @@ var ( ) // NewParser create a Parser and parse the file given in filename. If filename is empty, we try to parse the content[0] argument that should be a valid YAML content. -func NewParser(filename string, content ...string) *Parser { +func NewParser(filename []string, content ...string) *Parser { p := &Parser{} if len(content) > 0 { // mainly for the tests... - dir := filepath.Dir(filename) + dir := filepath.Dir(filename[0]) err := os.MkdirAll(dir, 0755) if err != nil { log.Fatal(err) } p.temporary = &dir - ioutil.WriteFile(filename, []byte(content[0]), 0644) - cli.DefaultFileNames = []string{filename} + ioutil.WriteFile(filename[0], []byte(content[0]), 0644) + cli.DefaultFileNames = filename } // if filename is not in cli Default files, add it if len(filename) > 0 { found := false - for _, f := range cli.DefaultFileNames { - if f == filename { - found = true - break + for _, defaultFileName := range cli.DefaultFileNames { + for _, givenFileName := range filename { + if defaultFileName == givenFileName { + found = true + break + } } } // add the file at first position if !found { - cli.DefaultFileNames = append([]string{filename}, cli.DefaultFileNames...) + cli.DefaultFileNames = append([]string{filename[0]}, cli.DefaultFileNames...) + } + if len(filename) > 1 { + cli.DefaultOverrideFileNames = append(filename[1:], cli.DefaultOverrideFileNames...) } } diff --git a/generator/crontabs.go b/generator/crontabs.go new file mode 100644 index 0000000..efacb79 --- /dev/null +++ b/generator/crontabs.go @@ -0,0 +1,110 @@ +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 +} diff --git a/generator/main.go b/generator/main.go index 42caf60..46a80e2 100644 --- a/generator/main.go +++ b/generator/main.go @@ -6,6 +6,7 @@ import ( "katenary/compose" "katenary/helm" "katenary/logger" + "katenary/tools" "log" "net/url" "os" @@ -28,6 +29,8 @@ const ( ICON_CONF = "📝" ICON_STORE = "⚡" ICON_INGRESS = "🌐" + ICON_RBAC = "🔑" + ICON_CRON = "🕒" ) // Values is kept in memory to create a values.yaml file. @@ -71,6 +74,7 @@ func buildDeployment(name string, s *types.ServiceConfig, linked map[string]type // Add selectors selectors := buildSelector(name, s) + selectors[helm.K+"/resource"] = "deployment" deployment.Spec.Selector = map[string]interface{}{ "matchLabels": selectors, } @@ -270,7 +274,7 @@ func buildConfigMapFromPath(name, path string) *helm.ConfigMap { files[filename] = string(c) } - cm := helm.NewConfigMap(name, GetRelPath(path)) + cm := helm.NewConfigMap(name, tools.GetRelPath(path)) cm.Data = files return cm } @@ -335,7 +339,7 @@ func prepareVolumes(deployment, name string, s *types.ServiceConfig, container * isConfigMap := false for _, cmVol := range configMapsVolumes { - if GetRelPath(volname) == cmVol { + if tools.GetRelPath(volname) == cmVol { isConfigMap = true break } @@ -364,10 +368,10 @@ func prepareVolumes(deployment, name string, s *types.ServiceConfig, container * // 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 + "-" + PathToName(volname) + cm.K8sBase.Metadata.Name = helm.ReleaseNameTpl + "-" + name + "-" + tools.PathToName(volname) // build a configmapRef for this volume - volname := PathToName(volname) + volname := tools.PathToName(volname) volumes = append(volumes, map[string]interface{}{ "name": volname, "configMap": map[string]string{ @@ -584,7 +588,7 @@ func prepareEnvFromFiles(name string, s *types.ServiceConfig, container *helm.Co // manage environment files (env_file in compose) for _, envfile := range s.EnvFile { - f := PathToName(envfile) + f := tools.PathToName(envfile) f = strings.ReplaceAll(f, ".env", "") isSecret := false for _, s := range secretsFiles { @@ -795,7 +799,14 @@ func setSecretVar(name string, s *types.ServiceConfig, c *helm.Container) *helm. // 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 { +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) @@ -841,6 +852,33 @@ func newContainerForDeployment(deployName, containerName string, deployment *hel 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 } @@ -867,7 +905,7 @@ func addVolumeFrom(deployment *helm.Deployment, container *helm.Container, s *ty for name, volumes := range volumesFrom { for volumeName := range volumes { initianame := volumeName - volumeName = PathToName(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 { diff --git a/generator/main_test.go b/generator/main_test.go index 7a57006..c90ca39 100644 --- a/generator/main_test.go +++ b/generator/main_test.go @@ -127,7 +127,7 @@ func setUp(t *testing.T) (string, *compose.Parser) { } composefile := filepath.Join(tmpwork, "docker-compose.yaml") - p := compose.NewParser(composefile, DOCKER_COMPOSE_YML) + p := compose.NewParser([]string{composefile}, DOCKER_COMPOSE_YML) // create envfile for "useenvfile" service err = os.Mkdir(filepath.Join(tmpwork, "config"), 0777) diff --git a/generator/writer.go b/generator/writer.go index 2cd0471..d7de1ce 100644 --- a/generator/writer.go +++ b/generator/writer.go @@ -4,6 +4,7 @@ import ( "katenary/compose" "katenary/generator/writers" "katenary/helm" + "katenary/tools" "log" "os" "path/filepath" @@ -128,6 +129,7 @@ func Generate(p *compose.Parser, katernayVersion, appName, appVersion, chartVers n := service.Name if linkname, ok := service.Labels[helm.LABEL_SAMEPOD]; ok && linkname == name { linked[n] = service + delete(s.DependsOn, n) } } @@ -173,15 +175,17 @@ func Generate(p *compose.Parser, katernayVersion, appName, appVersion, chartVers case *helm.ConfigMap, *helm.Secret: // there could be several files, so let's force the filename - name := c.(helm.Named).Name() + "-" + c.GetType() + name := c.(helm.Named).Name() + "." + c.GetType() suffix := c.GetPathRessource() - suffix = PathToName(suffix) + suffix = tools.PathToName(suffix) name += suffix name = PrefixRE.ReplaceAllString(name, "") writers.BuildConfigMap(c, kind, n, name, templatesDir) default: - fname := filepath.Join(templatesDir, n+"."+kind+".yaml") + 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) diff --git a/go.mod b/go.mod index 0d140ea..9301a9b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module katenary go 1.16 require ( + github.com/alessio/shellescape v1.4.1 github.com/compose-spec/compose-go v1.2.5 github.com/distribution/distribution/v3 v3.0.0-20220505155552-985711c1f414 // indirect github.com/kr/pretty v0.2.0 // indirect diff --git a/go.sum b/go.sum index ca5953c..f4c0e19 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/aws/aws-sdk-go v1.34.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.43.16/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= diff --git a/helm/configAndSecretMap.go b/helm/configAndSecretMap.go index 9b5f6b2..5a3a7c6 100644 --- a/helm/configAndSecretMap.go +++ b/helm/configAndSecretMap.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io/ioutil" + "katenary/tools" "strings" ) @@ -31,8 +32,7 @@ func NewConfigMap(name, path string) *ConfigMap { base.Metadata.Name = ReleaseNameTpl + "-" + name base.Metadata.Labels[K+"/component"] = name if path != "" { - //base.Metadata.Labels[K+"/path"] = path - base.Metadata.Labels[K+"/path"] = `{{ "` + path + `" | quote }}` + base.Metadata.Labels[K+"/path"] = tools.PathToName(path) } return &ConfigMap{ K8sBase: base, @@ -97,7 +97,7 @@ func NewSecret(name, path string) *Secret { base.Metadata.Name = ReleaseNameTpl + "-" + name base.Metadata.Labels[K+"/component"] = name if path != "" { - base.Metadata.Labels[K+"/path"] = `{{ "` + path + `" | quote }}` + base.Metadata.Labels[K+"/path"] = tools.PathToName(path) } return &Secret{ K8sBase: base, diff --git a/helm/cronTab.go b/helm/cronTab.go new file mode 100644 index 0000000..ff1e454 --- /dev/null +++ b/helm/cronTab.go @@ -0,0 +1,70 @@ +package helm + +type CronTab struct { + *K8sBase `yaml:",inline"` + Spec CronSpec `yaml:"spec"` +} +type CronSpec struct { + Schedule string `yaml:"schedule"` + JobTemplate JobTemplate `yaml:"jobTemplate"` + SuccessfulJobsHistoryLimit int `yaml:"successfulJobsHistoryLimit"` + FailedJobsHistoryLimit int `yaml:"failedJobsHistoryLimit"` + ConcurrencyPolicy string `yaml:"concurrencyPolicy"` +} +type JobTemplate struct { + Spec JobSpecDescription `yaml:"spec"` +} + +type JobSpecDescription struct { + Template JobSpecTemplate `yaml:"template"` +} + +type JobSpecTemplate struct { + Metadata Metadata `yaml:"metadata"` + Spec Job `yaml:"spec"` +} + +type Job struct { + ServiceAccount string `yaml:"serviceAccount,omitempty"` + ServiceAccountName string `yaml:"serviceAccountName,omitempty"` + Containers []Container `yaml:"containers"` + RestartPolicy string `yaml:"restartPolicy,omitempty"` +} + +func NewCrontab(name, image, command, schedule string, serviceAccount *ServiceAccount) *CronTab { + cron := &CronTab{ + K8sBase: NewBase(), + } + cron.K8sBase.ApiVersion = "batch/v1" + cron.K8sBase.Kind = "CronJob" + + cron.K8sBase.Metadata.Name = ReleaseNameTpl + "-" + name + cron.K8sBase.Metadata.Labels[K+"/component"] = name + cron.Spec.Schedule = schedule + cron.Spec.SuccessfulJobsHistoryLimit = 3 + cron.Spec.FailedJobsHistoryLimit = 3 + cron.Spec.ConcurrencyPolicy = "Forbid" + cron.Spec.JobTemplate.Spec.Template.Metadata = Metadata{ + Labels: cron.K8sBase.Metadata.Labels, + } + cron.Spec.JobTemplate.Spec.Template.Spec = Job{ + ServiceAccount: serviceAccount.Name(), + ServiceAccountName: serviceAccount.Name(), + RestartPolicy: "OnFailure", + } + if command != "" { + cron.AddCommand(command, image, name) + } + + return cron +} + +// AddCommand adds a command to the cron job +func (c *CronTab) AddCommand(command, image, name string) { + container := Container{ + Name: name, + Image: image, + Command: []string{"sh", "-c", command}, + } + c.Spec.JobTemplate.Spec.Template.Spec.Containers = append(c.Spec.JobTemplate.Spec.Template.Spec.Containers, container) +} diff --git a/helm/deployment.go b/helm/deployment.go index d1dec8a..649d1db 100644 --- a/helm/deployment.go +++ b/helm/deployment.go @@ -12,6 +12,7 @@ func NewDeployment(name string) *Deployment { d.K8sBase.ApiVersion = "apps/v1" d.K8sBase.Kind = "Deployment" d.K8sBase.Metadata.Labels[K+"/component"] = name + d.K8sBase.Metadata.Labels[K+"/resource"] = "deployment" return d } @@ -24,6 +25,13 @@ type DepSpec struct { func NewDepSpec() *DepSpec { return &DepSpec{ Replicas: 1, + Template: PodTemplate{ + Metadata: Metadata{ + Labels: map[string]string{ + K + "/resource": "deployment", + }, + }, + }, } } diff --git a/helm/labels.go b/helm/labels.go index d9fc397..9bcf77d 100644 --- a/helm/labels.go +++ b/helm/labels.go @@ -7,17 +7,19 @@ import ( const ReleaseNameTpl = "{{ .Release.Name }}" const ( - LABEL_MAP_ENV = K + "/mapenv" - LABEL_ENV_SECRET = K + "/secret-envfiles" - LABEL_PORT = K + "/ports" - LABEL_INGRESS = K + "/ingress" - LABEL_VOL_CM = K + "/configmap-volumes" - LABEL_HEALTHCHECK = K + "/healthcheck" - LABEL_SAMEPOD = K + "/same-pod" - LABEL_VOLUMEFROM = K + "/volume-from" - LABEL_EMPTYDIRS = K + "/empty-dirs" - LABEL_IGNORE = K + "/ignore" - LABEL_SECRETVARS = K + "/secret-vars" + LABEL_MAP_ENV = K + "/mapenv" + LABEL_ENV_SECRET = K + "/secret-envfiles" + LABEL_PORT = K + "/ports" + LABEL_CONTAINER_PORT = K + "/container-ports" + LABEL_INGRESS = K + "/ingress" + LABEL_VOL_CM = K + "/configmap-volumes" + LABEL_HEALTHCHECK = K + "/healthcheck" + LABEL_SAMEPOD = K + "/same-pod" + LABEL_VOLUMEFROM = K + "/volume-from" + LABEL_EMPTYDIRS = K + "/empty-dirs" + LABEL_IGNORE = K + "/ignore" + LABEL_SECRETVARS = K + "/secret-vars" + LABEL_CRON = K + "/crontabs" //deprecated: use LABEL_MAP_ENV instead LABEL_ENV_SERVICE = K + "/env-to-service" @@ -25,37 +27,48 @@ const ( // GetLabelsDocumentation returns the documentation for the labels. func GetLabelsDocumentation() string { - t, _ := template.New("labels").Parse(` -# Labels -{{.LABEL_IGNORE | printf "%-33s"}}: ignore the container, it will not yied any object in the helm chart -{{.LABEL_SECRETVARS | printf "%-33s"}}: secret variables to push on a secret file -{{.LABEL_ENV_SECRET | printf "%-33s"}}: set the given file names as a secret instead of configmap -{{.LABEL_MAP_ENV | printf "%-33s"}}: map environment variable to a template string (yaml style) -{{.LABEL_PORT | printf "%-33s"}}: set the ports to expose as a service (coma separated) -{{.LABEL_INGRESS | printf "%-33s"}}: set the port to expose in an ingress (coma separated) -{{.LABEL_VOL_CM | printf "%-33s"}}: specifies that the volumes points on a configmap (coma separated) -{{.LABEL_SAMEPOD | printf "%-33s"}}: specifies that the pod should be deployed in the same pod than the given service name -{{.LABEL_VOLUMEFROM | printf "%-33s"}}: specifies that the volumes to be mounted from the given service (yaml style) -{{.LABEL_EMPTYDIRS | printf "%-33s"}}: specifies that the given volume names should be "emptyDir" instead of persistentVolumeClaim (coma separated) -{{.LABEL_HEALTHCHECK | printf "%-33s"}}: specifies that the container should be monitored by a healthcheck, **it overrides the docker-compose healthcheck**. + t, _ := template.New("labels").Parse(`# Labels +{{.LABEL_IGNORE | printf "%-33s"}}: ignore the container, it will not yied any object in the helm chart (bool) +{{.LABEL_SECRETVARS | printf "%-33s"}}: secret variables to push on a secret file (coma separated) +{{.LABEL_ENV_SECRET | printf "%-33s"}}: set the given file names as a secret instead of configmap (coma separated) +contaienr in {{.LABEL_MAP_ENV | printf "%-33s"}}: map environment variable to a template string (yaml style, object) +{{.LABEL_PORT | printf "%-33s"}}: set the ports to assign on the container in pod + expose as a service (coma separated) +{{.LABEL_CONTAINER_PORT | printf "%-33s"}}: set the ports to assign on the contaienr in pod but avoid service (coma separated) +{{.LABEL_INGRESS | printf "%-33s"}}: set the port to expose in an ingress (coma separated) +{{.LABEL_VOL_CM | printf "%-33s"}}: specifies that the volumes points on a configmap (coma separated) +{{.LABEL_SAMEPOD | printf "%-33s"}}: specifies that the pod should be deployed in the same pod than the +{{ printf "%-34s" ""} } given service name (string) +{{.LABEL_VOLUMEFROM | printf "%-33s"}}: specifies that the volumes to be mounted from the given service (yaml style) +{{.LABEL_EMPTYDIRS | printf "%-33s"}}: specifies that the given volume names should be "emptyDir" instead of +{{ printf "%-34s" ""} } persistentVolumeClaim (coma separated) +{{.LABEL_CRON | printf "%-33s"}}: specifies a cronjobs to create (yaml style, array) - this will create a +{{ printf "%-34s" ""}} cronjob, a service account, a role and a rolebinding to start the command with "kubectl" +{{ printf "%-34s" ""}} The form is the following: +{{ printf "%-34s" ""}} - command: the command to run +{{ printf "%-34s" ""}} schedule: the schedule to run the command (e.g. "@daily" or "*/1 * * * *") +{{ printf "%-34s" ""}} image: the image to use for the command (default to "bitnami/kubectl") +{{ printf "%-34s" ""}} allPods: true if you want to run the command on all pods (default to false) +{{.LABEL_HEALTHCHECK | printf "%-33s"}}: specifies that the container should be monitored by a healthcheck, +{{ printf "%-34s" ""}} **it overrides the docker-compose healthcheck**. {{ printf "%-34s" ""}} You can use these form of label values: -{{ printf "%-35s" ""}}- "http://[not used address][:port][/path]" to specify an http healthcheck -{{ printf "%-35s" ""}}- "tcp://[not used address]:port" to specify a tcp healthcheck -{{ printf "%-35s" ""}}- other string is condidered as a "command" healthcheck - `) +{{ printf "%-35s" ""}} -> http://[ignored][:port][/path] to specify an http healthcheck +{{ printf "%-35s" ""}} -> tcp://[ignored]:port to specify a tcp healthcheck +{{ printf "%-35s" ""}} -> other string is condidered as a "command" healthcheck`) buff := bytes.NewBuffer(nil) t.Execute(buff, map[string]string{ - "LABEL_ENV_SECRET": LABEL_ENV_SECRET, - "LABEL_PORT": LABEL_PORT, - "LABEL_INGRESS": LABEL_INGRESS, - "LABEL_VOL_CM": LABEL_VOL_CM, - "LABEL_HEALTHCHECK": LABEL_HEALTHCHECK, - "LABEL_SAMEPOD": LABEL_SAMEPOD, - "LABEL_VOLUMEFROM": LABEL_VOLUMEFROM, - "LABEL_EMPTYDIRS": LABEL_EMPTYDIRS, - "LABEL_IGNORE": LABEL_IGNORE, - "LABEL_MAP_ENV": LABEL_MAP_ENV, - "LABEL_SECRETVARS": LABEL_SECRETVARS, + "LABEL_ENV_SECRET": LABEL_ENV_SECRET, + "LABEL_PORT": LABEL_PORT, + "LABEL_CONTAINER_PORT": LABEL_CONTAINER_PORT, + "LABEL_INGRESS": LABEL_INGRESS, + "LABEL_VOL_CM": LABEL_VOL_CM, + "LABEL_HEALTHCHECK": LABEL_HEALTHCHECK, + "LABEL_SAMEPOD": LABEL_SAMEPOD, + "LABEL_VOLUMEFROM": LABEL_VOLUMEFROM, + "LABEL_EMPTYDIRS": LABEL_EMPTYDIRS, + "LABEL_IGNORE": LABEL_IGNORE, + "LABEL_MAP_ENV": LABEL_MAP_ENV, + "LABEL_SECRETVARS": LABEL_SECRETVARS, + "LABEL_CRON": LABEL_CRON, }) return buff.String() } diff --git a/helm/role.go b/helm/role.go new file mode 100644 index 0000000..111058a --- /dev/null +++ b/helm/role.go @@ -0,0 +1,38 @@ +package helm + +type Rule struct { + ApiGroup []string `yaml:"apiGroups,omitempty"` + Resources []string `yaml:"resources,omitempty"` + Verbs []string `yaml:"verbs,omitempty"` +} + +type Role struct { + *K8sBase `yaml:",inline"` + Rules []Rule `yaml:"rules,omitempty"` +} + +func NewCronRole(name string) *Role { + role := &Role{ + K8sBase: NewBase(), + } + + role.K8sBase.Metadata.Name = ReleaseNameTpl + "-" + name + "-cron-executor" + role.K8sBase.Kind = "Role" + role.K8sBase.ApiVersion = "rbac.authorization.k8s.io/v1" + role.K8sBase.Metadata.Labels[K+"/component"] = name + + role.Rules = []Rule{ + { + ApiGroup: []string{""}, + Resources: []string{"pods", "pods/log"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + ApiGroup: []string{""}, + Resources: []string{"pods/exec"}, + Verbs: []string{"create"}, + }, + } + + return role +} diff --git a/helm/roleBinding.go b/helm/roleBinding.go new file mode 100644 index 0000000..a99d8ef --- /dev/null +++ b/helm/roleBinding.go @@ -0,0 +1,44 @@ +package helm + +type RoleRef struct { + Kind string `yaml:"kind"` + Name string `yaml:"name"` + APIGroup string `yaml:"apiGroup"` +} + +type Subject struct { + Kind string `yaml:"kind"` + Name string `yaml:"name"` + Namespace string `yaml:"namespace"` +} + +type RoleBinding struct { + *K8sBase `yaml:",inline"` + RoleRef RoleRef `yaml:"roleRef,omitempty"` + Subjects []Subject `yaml:"subjects,omitempty"` +} + +func NewRoleBinding(name string, user *ServiceAccount, role *Role) *RoleBinding { + rb := &RoleBinding{ + K8sBase: NewBase(), + } + + rb.K8sBase.Kind = "RoleBinding" + rb.K8sBase.Metadata.Name = ReleaseNameTpl + "-" + name + "-cron-allow" + rb.K8sBase.ApiVersion = "rbac.authorization.k8s.io/v1" + rb.K8sBase.Metadata.Labels[K+"/component"] = name + + rb.RoleRef.Kind = "Role" + rb.RoleRef.Name = role.Metadata.Name + rb.RoleRef.APIGroup = "rbac.authorization.k8s.io" + + rb.Subjects = []Subject{ + { + Kind: "ServiceAccount", + Name: user.Metadata.Name, + Namespace: "{{ .Release.Namespace }}", + }, + } + + return rb +} diff --git a/helm/service.go b/helm/service.go index f1a7ec1..78a0f78 100644 --- a/helm/service.go +++ b/helm/service.go @@ -1,5 +1,7 @@ package helm +import "strconv" + // Service is a Kubernetes service. type Service struct { *K8sBase `yaml:",inline"` @@ -24,6 +26,7 @@ type ServicePort struct { Protocol string `yaml:"protocol"` Port int `yaml:"port"` TargetPort int `yaml:"targetPort"` + Name string `yaml:"name"` } // NewServicePort creates a new initialized service port. @@ -32,6 +35,7 @@ func NewServicePort(port, target int) *ServicePort { Protocol: "TCP", Port: port, TargetPort: port, + Name: "port-" + strconv.Itoa(target), } } diff --git a/helm/serviceAccount.go b/helm/serviceAccount.go new file mode 100644 index 0000000..e7b44c5 --- /dev/null +++ b/helm/serviceAccount.go @@ -0,0 +1,18 @@ +package helm + +// ServiceAccount defines a service account +type ServiceAccount struct { + *K8sBase `yaml:",inline"` +} + +// NewServiceAccount creates a new service account with a given name. +func NewServiceAccount(name string) *ServiceAccount { + sa := &ServiceAccount{ + K8sBase: NewBase(), + } + sa.K8sBase.Kind = "ServiceAccount" + sa.K8sBase.ApiVersion = "v1" + sa.K8sBase.Metadata.Name = ReleaseNameTpl + "-" + name + "-cron-user" + sa.K8sBase.Metadata.Labels[K+"/component"] = name + return sa +} diff --git a/generator/utils.go b/tools/path.go similarity index 96% rename from generator/utils.go rename to tools/path.go index 5c24c70..016acae 100644 --- a/generator/utils.go +++ b/tools/path.go @@ -1,4 +1,4 @@ -package generator +package tools import ( "katenary/compose" diff --git a/generator/utils_test.go b/tools/path_test.go similarity index 93% rename from generator/utils_test.go rename to tools/path_test.go index f7c64e3..cbda344 100644 --- a/generator/utils_test.go +++ b/tools/path_test.go @@ -1,4 +1,4 @@ -package generator +package tools import ( "katenary/compose"