From 475a025d9e671e5abee418548d6eff03a5af04c4 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 6 Dec 2023 15:24:02 +0100 Subject: [PATCH 01/97] Go to Katenary V3 This is the next-gen of Katenary --- .gitignore | 1 + .gitmodules | 9 + Makefile | 132 ++- README.md | 10 - cmd/katenary/main.go | 335 ++++--- cmd/katenary/utils.go | 149 --- compose/parser.go | 96 -- doc/docs/coding.md | 72 ++ doc/docs/dependencies.md | 15 + doc/docs/labels.md | 588 ++++++------ doc/docs/packages/cmd/katenary.md | 8 + doc/docs/packages/generator.md | 893 ++++++++++++++++++ doc/docs/packages/generator/extrafiles.md | 28 + doc/docs/packages/parser.md | 20 + doc/docs/packages/update.md | 55 ++ doc/docs/packages/utils.md | 187 ++++ doc/docs/statics/main.css | 14 +- doc/mkdocs.yml | 15 +- doc/requirements.txt | 2 +- examples/basic/README.md | 10 - examples/basic/chart/basic/Chart.yaml | 8 - .../basic/chart/basic/templates/NOTES.txt | 8 - .../basic/templates/database.deployment.yaml | 39 - .../basic/templates/database.service.yaml | 19 - .../basic/templates/webapp.deployment.yaml | 48 - .../chart/basic/templates/webapp.ingress.yaml | 34 - .../chart/basic/templates/webapp.service.yaml | 19 - examples/basic/chart/basic/values.yaml | 8 - examples/basic/docker-compose.yaml | 31 - examples/cronjobs/chart/README.md | 49 + examples/cronjobs/chart/templates/NOTES.txt | 27 + .../cronjobs/chart/templates/_helpers.tpl | 36 + examples/ghost/README.md | 9 - examples/ghost/chart/ghost/Chart.yaml | 8 - .../ghost/chart/ghost/templates/NOTES.txt | 8 - .../ghost/templates/blog.deployment.yaml | 33 - .../chart/ghost/templates/blog.ingress.yaml | 42 - .../chart/ghost/templates/blog.service.yaml | 19 - examples/ghost/chart/ghost/values.yaml | 6 - examples/ghost/docker-compose.yaml | 30 - examples/multidir/chart/README.md | 37 + examples/multidir/chart/templates/NOTES.txt | 27 + .../multidir/chart/templates/_helpers.tpl | 36 + examples/multidir/conf/example1.conf | 1 + examples/multidir/conf/otherdir/example.conf | 2 + examples/same-pod/README.md | 13 - examples/same-pod/chart/same-pod/Chart.yaml | 8 - .../chart/same-pod/templates/NOTES.txt | 8 - .../http.config-nginx-http.configmap.yaml | 23 - .../http.config-php-php.configmap.yaml | 30 - .../same-pod/templates/http.deployment.yaml | 52 - .../same-pod/templates/http.ingress.yaml | 34 - .../same-pod/templates/http.service.yaml | 19 - examples/same-pod/chart/same-pod/values.yaml | 8 - examples/same-pod/config/nginx/default.conf | 10 - examples/same-pod/config/php/www.conf | 17 - examples/same-pod/docker-compose.yaml | 38 - examples/shareenv/chart/README.md | 37 + examples/shareenv/chart/templates/NOTES.txt | 27 + .../shareenv/chart/templates/_helpers.tpl | 36 + examples/somevolumes/chart/README.md | 37 + .../somevolumes/chart/templates/NOTES.txt | 27 + .../somevolumes/chart/templates/_helpers.tpl | 36 + generator/chart.go | 60 ++ generator/configMap.go | 224 +++++ generator/container.go | 200 ---- generator/converter.go | 638 +++++++++++++ generator/cronJob.go | 133 +++ generator/crontabs.go | 110 --- generator/deployment.go | 595 +++++++++++- generator/doc.go | 18 + generator/env.go | 154 --- generator/extrafiles/doc.go | 2 + generator/extrafiles/notes.go | 11 + generator/extrafiles/notes.tpl | 27 + generator/extrafiles/readme.go | 99 ++ generator/extrafiles/readme.tpl | 32 + generator/generator.go | 658 +++++++++++++ generator/globals.go | 19 + generator/helmHelper.tpl | 36 + generator/helper.go | 19 + generator/ingress.go | 175 ++++ generator/katenaryLabels.go | 229 +++++ generator/labels.go | 36 + generator/main.go | 304 ------ generator/main_test.go | 397 -------- generator/rbac.go | 139 +++ generator/secret.go | 111 +++ generator/service.go | 95 ++ generator/types.go | 13 + generator/values.go | 156 +-- generator/version.go | 4 + generator/volume.go | 119 +++ generator/volumes.go | 236 ----- generator/writer.go | 236 ----- generator/writers/configmap.go | 18 - generator/writers/deployment.go | 44 - generator/writers/ingress.go | 101 -- generator/writers/service.go | 24 - generator/writers/storage.go | 32 - generator/writers/utils.go | 17 - go.mod | 54 +- go.sum | 438 +++++---- helm/configAndSecretMap.go | 155 --- helm/container.go | 65 -- helm/cronTab.go | 70 -- helm/deployment.go | 47 - helm/ingress.go | 54 -- helm/k8sbase.go | 73 -- helm/labels.go | 77 -- helm/notes.go | 25 - helm/probe.go | 104 -- helm/role.go | 38 - helm/roleBinding.go | 44 - helm/service.go | 55 -- helm/serviceAccount.go | 18 - helm/storage.go | 54 -- helm/types.go | 41 - logger/color_test.go | 9 - logger/utils.go | 96 -- parser/main.go | 29 + test/bats | 1 + test/examples.bats | 9 + test/test_helper/bats-assert | 1 + test/test_helper/bats-support | 1 + tools/path.go | 25 - tools/path_test.go | 14 - update/main.go | 8 + utils/doc.go | 2 + utils/hash.go | 26 + utils/icons.go | 31 + utils/utils.go | 163 ++++ 132 files changed, 6410 insertions(+), 4621 deletions(-) create mode 100644 .gitmodules delete mode 100644 cmd/katenary/utils.go delete mode 100644 compose/parser.go create mode 100644 doc/docs/coding.md create mode 100644 doc/docs/dependencies.md create mode 100644 doc/docs/packages/cmd/katenary.md create mode 100644 doc/docs/packages/generator.md create mode 100644 doc/docs/packages/generator/extrafiles.md create mode 100644 doc/docs/packages/parser.md create mode 100644 doc/docs/packages/update.md create mode 100644 doc/docs/packages/utils.md delete mode 100644 examples/basic/README.md delete mode 100644 examples/basic/chart/basic/Chart.yaml delete mode 100644 examples/basic/chart/basic/templates/NOTES.txt delete mode 100644 examples/basic/chart/basic/templates/database.deployment.yaml delete mode 100644 examples/basic/chart/basic/templates/database.service.yaml delete mode 100644 examples/basic/chart/basic/templates/webapp.deployment.yaml delete mode 100644 examples/basic/chart/basic/templates/webapp.ingress.yaml delete mode 100644 examples/basic/chart/basic/templates/webapp.service.yaml delete mode 100644 examples/basic/chart/basic/values.yaml delete mode 100644 examples/basic/docker-compose.yaml create mode 100644 examples/cronjobs/chart/README.md create mode 100644 examples/cronjobs/chart/templates/NOTES.txt create mode 100644 examples/cronjobs/chart/templates/_helpers.tpl delete mode 100644 examples/ghost/README.md delete mode 100644 examples/ghost/chart/ghost/Chart.yaml delete mode 100644 examples/ghost/chart/ghost/templates/NOTES.txt delete mode 100644 examples/ghost/chart/ghost/templates/blog.deployment.yaml delete mode 100644 examples/ghost/chart/ghost/templates/blog.ingress.yaml delete mode 100644 examples/ghost/chart/ghost/templates/blog.service.yaml delete mode 100644 examples/ghost/chart/ghost/values.yaml delete mode 100644 examples/ghost/docker-compose.yaml create mode 100644 examples/multidir/chart/README.md create mode 100644 examples/multidir/chart/templates/NOTES.txt create mode 100644 examples/multidir/chart/templates/_helpers.tpl create mode 100644 examples/multidir/conf/example1.conf create mode 100644 examples/multidir/conf/otherdir/example.conf delete mode 100644 examples/same-pod/README.md delete mode 100644 examples/same-pod/chart/same-pod/Chart.yaml delete mode 100644 examples/same-pod/chart/same-pod/templates/NOTES.txt delete mode 100644 examples/same-pod/chart/same-pod/templates/http.config-nginx-http.configmap.yaml delete mode 100644 examples/same-pod/chart/same-pod/templates/http.config-php-php.configmap.yaml delete mode 100644 examples/same-pod/chart/same-pod/templates/http.deployment.yaml delete mode 100644 examples/same-pod/chart/same-pod/templates/http.ingress.yaml delete mode 100644 examples/same-pod/chart/same-pod/templates/http.service.yaml delete mode 100644 examples/same-pod/chart/same-pod/values.yaml delete mode 100644 examples/same-pod/config/nginx/default.conf delete mode 100644 examples/same-pod/config/php/www.conf delete mode 100644 examples/same-pod/docker-compose.yaml create mode 100644 examples/shareenv/chart/README.md create mode 100644 examples/shareenv/chart/templates/NOTES.txt create mode 100644 examples/shareenv/chart/templates/_helpers.tpl create mode 100644 examples/somevolumes/chart/README.md create mode 100644 examples/somevolumes/chart/templates/NOTES.txt create mode 100644 examples/somevolumes/chart/templates/_helpers.tpl create mode 100644 generator/chart.go create mode 100644 generator/configMap.go delete mode 100644 generator/container.go create mode 100644 generator/converter.go create mode 100644 generator/cronJob.go delete mode 100644 generator/crontabs.go create mode 100644 generator/doc.go delete mode 100644 generator/env.go create mode 100644 generator/extrafiles/doc.go create mode 100644 generator/extrafiles/notes.go create mode 100644 generator/extrafiles/notes.tpl create mode 100644 generator/extrafiles/readme.go create mode 100644 generator/extrafiles/readme.tpl create mode 100644 generator/generator.go create mode 100644 generator/globals.go create mode 100644 generator/helmHelper.tpl create mode 100644 generator/helper.go create mode 100644 generator/ingress.go create mode 100644 generator/katenaryLabels.go create mode 100644 generator/labels.go delete mode 100644 generator/main.go delete mode 100644 generator/main_test.go create mode 100644 generator/rbac.go create mode 100644 generator/secret.go create mode 100644 generator/service.go create mode 100644 generator/types.go create mode 100644 generator/version.go create mode 100644 generator/volume.go delete mode 100644 generator/volumes.go delete mode 100644 generator/writer.go delete mode 100644 generator/writers/configmap.go delete mode 100644 generator/writers/deployment.go delete mode 100644 generator/writers/ingress.go delete mode 100644 generator/writers/service.go delete mode 100644 generator/writers/storage.go delete mode 100644 generator/writers/utils.go delete mode 100644 helm/configAndSecretMap.go delete mode 100644 helm/container.go delete mode 100644 helm/cronTab.go delete mode 100644 helm/deployment.go delete mode 100644 helm/ingress.go delete mode 100644 helm/k8sbase.go delete mode 100644 helm/labels.go delete mode 100644 helm/notes.go delete mode 100644 helm/probe.go delete mode 100644 helm/role.go delete mode 100644 helm/roleBinding.go delete mode 100644 helm/service.go delete mode 100644 helm/serviceAccount.go delete mode 100644 helm/storage.go delete mode 100644 helm/types.go delete mode 100644 logger/color_test.go delete mode 100644 logger/utils.go create mode 100644 parser/main.go create mode 160000 test/bats create mode 100644 test/examples.bats create mode 160000 test/test_helper/bats-assert create mode 160000 test/test_helper/bats-support delete mode 100644 tools/path.go delete mode 100644 tools/path_test.go create mode 100644 utils/doc.go create mode 100644 utils/hash.go create mode 100644 utils/icons.go create mode 100644 utils/utils.go diff --git a/.gitignore b/.gitignore index f604376..6bf1379 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.venv dist/* .cache/* chart/* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b7efcb4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "test/bats"] + path = test/bats + url = https://github.com/bats-core/bats-core.git +[submodule "test/test_helper/bats-support"] + path = test/test_helper/bats-support + url = https://github.com/bats-core/bats-support.git +[submodule "test/test_helper/bats-assert"] + path = test/test_helper/bats-assert + url = https://github.com/bats-core/bats-assert.git diff --git a/Makefile b/Makefile index 5b9903f..f507674 100644 --- a/Makefile +++ b/Makefile @@ -4,27 +4,51 @@ VERSION=$(shell git describe --exact-match --tags $(CUR_SHA) 2>/dev/null || echo CTN:=$(shell which podman 2>&1 1>/dev/null && echo "podman" || echo "docker") PREFIX=~/.local +GOVERSION=1.21 GO=container OUT=katenary -BLD_CMD=go build -ldflags="-X 'main.Version=$(VERSION)'" -o $(OUT) ./cmd/katenary/*.go +BLD_CMD=go build -ldflags="-X 'katenary/generator.Version=$(VERSION)'" -o $(OUT) ./cmd/katenary GOOS=linux GOARCH=amd64 +SIGNER=metal3d@gmail.com -BUILD_IMAGE=docker.io/golang:1.18-alpine +BUILD_IMAGE=docker.io/golang:$(GOVERSION)-alpine +# SHELL=/bin/bash -.PHONY: help clean build +# List of source files +SOURCES=$(wildcard ./*.go ./*/*.go ./*/*/*.go) +# List of binaries to build and sign +BINARIES=dist/katenary-linux-amd64 dist/katenary-linux-arm64 dist/katenary.exe dist/katenary-darwin-amd64 dist/katenary-freebsd-amd64 dist/katenary-freebsd-arm64 +# List of signatures to build +ASC_BINARIES=$(patsubst %,%.asc,$(BINARIES)) +# defaults +SHELL := bash +# strict mode +.SHELLFLAGS := -eu -o pipefail -c +# One session per target .ONESHELL: +.DELETE_ON_ERROR: +MAKEFLAGS += --warn-undefined-variables +MAKEFLAGS += --no-builtin-rules +.PHONY: help clean build install + +all: build + help: - @cat < Build on host using go" +else + @echo "=> Build in container using" $(CTN) +endif + echo $(BLD_CMD) +ifeq ($(GO),local) + $(BLD_CMD) +else ifeq ($(CTN),podman) + @podman run -e CGO_ENABLED=0 -e GOOS=$(GOOS) -e GOARCH=$(GOARCH) \ + --rm -v $(PWD):/go/src/katenary:z -w /go/src/katenary --userns keep-id -it $(BUILD_IMAGE) $(BLD_CMD) +else + @docker run -e CGO_ENABLED=0 -e GOOS=$(GOOS) -e GOARCH=$(GOARCH) \ + --rm -v $(PWD):/go/src/katenary:z -w /go/src/katenary --user $(shell id -u):$(shell id -g) -e HOME=/tmp -it $(BUILD_IMAGE) $(BLD_CMD) +endif + echo "=> Stripping if possible" + strip $(OUT) 2>/dev/null || echo "=> No strip available" + + +## Release build +dist: prepare $(BINARIES) $(ASC_BINARIES) + +prepare: pull mkdir -p dist dist/katenary-linux-amd64: @@ -69,7 +112,6 @@ dist/katenary-linux-amd64: @echo -e "\033[1;32mBuilding katenary $(VERSION) for linux-amd64...\033[0m" $(MAKE) katenary GOOS=linux GOARCH=amd64 OUT=$@ - dist/katenary-linux-arm64: @echo @echo -e "\033[1;32mBuilding katenary $(VERSION) for linux-arm...\033[0m" @@ -94,30 +136,16 @@ dist/katenary-freebsd-arm64: @echo @echo -e "\033[1;32mBuilding katenary $(VERSION) for freebsd-arm64...\033[0m" $(MAKE) katenary GOOS=freebsd GOARCH=arm64 OUT=$@ - -katenary: $(wildcard */*.go Makefile go.mod go.sum) -ifeq ($(GO),local) - @echo "=> Build in host using go" -else - @echo "=> Build in container using" $(CTN) -endif - echo $(BLD_CMD) -ifeq ($(GO),local) - $(BLD_CMD) -else ifeq ($(CTN),podman) - @podman run -e CGO_ENABLED=0 -e GOOS=$(GOOS) -e GOARCH=$(GOARCH) \ - --rm -v $(PWD):/go/src/katenary:z -w /go/src/katenary --userns keep-id -it $(BUILD_IMAGE) $(BLD_CMD) -else - @docker run -e CGO_ENABLED=0 -e GOOS=$(GOOS) -e GOARCH=$(GOARCH) \ - --rm -v $(PWD):/go/src/katenary:z -w /go/src/katenary --user $(shell id -u):$(shell id -g) -e HOME=/tmp -it $(BUILD_IMAGE) $(BLD_CMD) -endif - echo "=> Stripping if possible" - strip $(OUT) 2>/dev/null || echo "=> No strip available" +gpg-sign: + rm -f dist/*.asc + $(MAKE) $(ASC_BINARIES) +dist/%.asc: dist/% + gpg --armor --detach-sign --default-key $(SIGNER) $< &>/dev/null || exit 1 install: build - cp katenary $(PREFIX)/bin/katenary + install -Dm755 katenary $(PREFIX)/bin/katenary uninstall: rm -f $(PREFIX)/bin/katenary @@ -131,8 +159,6 @@ test: @echo -e "\033[1;33mTesting katenary $(VERSION)...\033[0m" go test -v ./... - -.ONESHELL: push-release: build-all @rm -f release.id # read personal access token from .git-credentials @@ -154,3 +180,37 @@ push-release: build-all https://uploads.github.com/repos/metal3d/katenary/releases/$$(cat release.id)/assets?name=$$(basename $$i) done @rm -f release.id + + +__label_doc: + @echo "=> Generating labels doc..." + # short label doc + go run ./cmd/katenary help-labels -m | \ + sed -i ' + /START_LABEL_DOC/,/STOP_LABEL_DOC/{/ +| Label name | Description | Type | +| ----------------------------- | ------------------------------------------------------ | --------------------- | +| `katenary.v3/configmap-files` | Add files to the configmap. | list of strings | +| `katenary.v3/cronjob` | Create a cronjob from the service. | object | +| `katenary.v3/dependencies` | Add Helm dependencies to the service. | list of objects | +| `katenary.v3/description` | Description of the service | string | +| `katenary.v3/env-from` | Add environment variables from antoher service. | list of strings | +| `katenary.v3/health-check` | Health check to be added to the deployment. | object | +| `katenary.v3/ignore` | Ignore the service | bool | +| `katenary.v3/ingress` | Ingress rules to be added to the service. | object | +| `katenary.v3/main-app` | Mark the service as the main app. | bool | +| `katenary.v3/map-env` | Map env vars from the service to the deployment. | object | +| `katenary.v3/ports` | Ports to be added to the service. | list of uint32 | +| `katenary.v3/same-pod` | Move the same-pod deployment to the target deployment. | string | +| `katenary.v3/secrets` | Env vars to be set as secrets. | list of string | +| `katenary.v3/values` | Environment variables to be added to the values.yaml | list of string or map | -HealthCheck label defines how to make LivenessProbe on Kubernetes. + + +## Detailed description + + +### katenary.v3/configmap-files + +Add files to the configmap. + +**Type**: `list of strings` + +It makes a file or directory to be converted to one or more ConfigMaps +and mounted in the pod. The file or directory is relative to the +service directory. + +If it is a directory, all files inside it are added to the ConfigMap. + +If the directory as subdirectories, so one configmap per subpath are created. !!! Warning - This overrides the compose file healthcheck + It is not intended to be used to store an entire project in configmaps. + It is intended to be used to store configuration files that are not managed + by the application, like nginx configuration files. Keep in mind that your + project sources should be stored in an application image or in a storage. + +**Example:** + +```yaml +volumes + - ./conf.d:/etc/nginx/conf.d +labels: + katenary.v3/configmap-files: |- + - ./conf.d +``` + +### katenary.v3/cronjob + +Create a cronjob from the service. + +**Type**: `object` + +This adds a cronjob to the chart. + +The label value is a YAML object with the following attributes: +- command: the command to be executed +- schedule: the cron schedule (cron format or @every where "every" is a + duration like 1h30m, daily, hourly...) +- rbac: false (optionnal), if true, it will create a role, a rolebinding and + a serviceaccount to make your cronjob able to connect the Kubernetes API + +**Example:** + +```yaml +labels: + katenary.v3/cronjob: |- + command: echo "hello world" + schedule: "* */1 * * *" # or @hourly for example +``` + +### katenary.v3/dependencies + +Add Helm dependencies to the service. + +**Type**: `list of objects` + +Set the service to be, actually, a Helm dependency. This means that the +service will not be exported as template. The dependencies are added to +the Chart.yaml file and the values are added to the values.yaml file. + +It's a list of objects with the following attributes: + +- name: the name of the dependency +- repository: the repository of the dependency +- alias: the name of the dependency in values.yaml (optional) +- values: the values to be set in values.yaml (optional) !!! Info - The hostname is set to "localhost" by convention, but Katenary will ignore the hostname in tcp and http tests because it will create a LivenessProbe. + Katenary doesn't update the helm depenedencies by default. + + Use `--helm-update` (or `-u`) flag to update the dependencies. + + example: katenary convert -u -Some example of usage: +By setting an alias, it is possible to change the name of the dependency +in values.yaml. + +**Example:** ```yaml -services: - mariadb: - image: mariadb - labels: - katenary.io/healthcheck: tcp://localhost:3306 +labels: + katenary.v3/dependencies: |- + - name: mariadb + repository: oci://registry-1.docker.io/bitnamicharts - webapp: - image: nginx - labels: - katenary.io/healthcheck: http://localhost:80 + ## optional, it changes the name of the section in values.yaml + # alias: mydatabase - example: - image: yourimage - labels: - katenary.io/healthcheck: "test -f /opt/installed" + ## optional, it adds the values to values.yaml + values: + auth: + database: mydatabasename + username: myuser + password: the secret password ``` -## crontabs +### katenary.v3/description -Crontabs label proposes to create a complete CronTab object with needed RBAC to make it possible to run command inside the pod(s) with `kubectl`. Katenary will make the job for you. You only need to provide the command(s) to call. +Description of the service -It's a YAML array in multiline label. +**Type**: `string` + +This replaces the default comment in values.yaml file to the given description. +It is useful to document the service and configuration. + +The value can be set with a documentation in multiline format. + +**Example:** ```yaml -services: - mariadb: - image: mariadb - labels: - katenary.io/crontabs: | - - command: mysqldump -B myapp -uroot -p$${MYSQL_ROOT_PASSWORD} > dump.sql - schedule: "@every 1h" -``` -The object is: -``` -command: Command to run -schedule: the cron form schedule string -allPods: boolean (default false) to activate the cront on each pod -image: image name to use (default is bitnami/kubectl) - with corresponding tag to your kubernetes version +labels: + katenary.v3/description: |- + This is a description of the service. + It can be multiline. ``` -## empty-dirs +### katenary.v3/env-from -You sometime don't need to create a PersistentVolumeClaim. For example when a volume in your compose file is actually made to share the data between 2 or more containers. +Add environment variables from antoher service. -In this case, an "emptyDir" volume is appreciated. +**Type**: `list of strings` + +It adds environment variables from another service to the current service. + +**Example:** ```yaml -services: - webapp: - image: nginx - volumes: - - websource:/var/www/html - labels: - # sources is actually an empty directory on the node - katenary.io/empty-dirs: websource +service1: + image: nginx:1.19 + environment: + FOO: bar - php: - image: php:7-fpm - volumes: - - sources:/var/www/html - labels: - # in the same pod than webapp - katenary.io/same-pod: webapp - # see the corresponding section, get the volume - # fro webapp - katenary.io/volume-from: | - sources: - webapp: websource +service2: + image: php:7.4-fpm + labels: + # get the congigMap from service1 where FOO is + # defined inside this service too + katenary.v3/env-from: |- + - myservice1 ``` -## volume-from +### katenary.v3/health-check -We see this in the [empty-dir](#empty-dir) section, this label defines that the corresponding volume should be shared in this pod. +Health check to be added to the deployment. + +**Type**: `object` + +Health check to be added to the deployment. + +**Example:** ```yaml -services: - webapp: - image: nginx - volumes: - - datasource:/var/www/html - - app: - image: php - volumes: - - data:/opt/data - labels: - katenary.io/volume-from: | - # data in this container... - data: - # ... correspond to "datasource" in "webapp" container - webapp: datasource +labels: + katenary.v3/health-check: |- + httpGet: + path: /health + port: 8080 ``` -This implies that the declared volume in "webapp" will be mounted to "app" pods. +### katenary.v3/ignore + +Ignore the service + +**Type**: `bool` + +Ingoring a service to not be exported in helm chart. + +**Example:** + +```yaml +labels: + katenary.v3/ignore: "true" +``` + +### katenary.v3/ingress + +Ingress rules to be added to the service. + +**Type**: `object` + +Declare an ingress rule for the service. The port should be exposed or +declared with `katenary.v3/ports`. + +**Example:** + +```yaml +labels: + katenary.v3/ingress: |- + port: 80 + hostname: mywebsite.com (optional) +``` + +### katenary.v3/main-app + +Mark the service as the main app. + +**Type**: `bool` + +This makes the service to be the main application. Its image tag is +considered to be the + +Chart appVersion and to be the defaultvalue in Pod container +image attribute. !!! Warning - This is possible with Kubernetes volumes restrictions. So, it works in these cases: + This label cannot be repeated in others services. If this label is + set in more than one service as true, Katenary will return an error. - - if the volume class is Read Write Many - - or if you mount the volume in the same pod (so in the same node) - - and/or the volume is an emptyDir - - -## same-pod - -It's sometimes important and/or necessary to declare that 2 services are in the same pod. For example, using PHP-FPM and NGinx. In this case, you can declare that both services are in the same pod. - -You must declare this label only on "supplementary" services and always use the same master service for the entire pod declaration. +**Example:** ```yaml -services: - web: - image: nginx - - php: - image: php:8-fpm - labels: - katenary.io/same-pod: web +ghost: + image: ghost:1.25.5 + labels: + # The chart is now named ghost, and the appVersion is 1.25.5. + # In Deployment, the image attribute is set to ghost:1.25.5 if + # you don't change the "tag" attribute in values.yaml + katenary.v3/main-app: true ``` -The above example will create a `web` deployment, the PHP container is added in the `web` pod. +### katenary.v3/map-env -## configmap-volumes +Map env vars from the service to the deployment. -This label proposes to declare a file or directory where content is actually static and can be mounted as configMap volume. +**Type**: `object` -It's a comma separated label, you can declare several volumes. +Because you may need to change the variable for Kubernetes, this label +forces the value to another. It is also particullary helpful to use a template +value instead. For example, you could bind the value to a service name +with Helm attributes: +`{{ tpl .Release.Name . }}`. -For example, in `static/index.html`: +If you use `__APP__` in the value, it will be replaced by the Chart name. -```html - -Hello - -``` - -And a compose file (snippet): +**Example:** ```yaml -serivces: - web: - image: nginx - volumes: - - ./static:/usr/share/nginx/html:z - labels: - katenary.io/configmap-volumes: ./statics +env: + DB_HOST: database + RUNNING: docker + OTHER: value +labels: + katenary.v3/map-env: |- + RUNNING: kubernetes + DB_HOST: '{{ include "__APP__.fullname" . }}-database' ``` -What will make Katenary: +### katenary.v3/ports -- create a configmap containing the "index.html" file as data -- declare the volume in the `web` deployment file -- mount the configmap in `/usr/share/nginx/html` directory of the container +Ports to be added to the service. -## ingress +**Type**: `list of uint32` -Declare which port to use to create an ingress. The hostname will be declared in `values.yaml` file. +Only useful for services without exposed port. It is mandatory if the +service is a dependency of another service. + +**Example:** ```yaml -serivces: - web: - image: nginx - ports: - - 8080:80 - labels: - katenary.io/ingress: 80 +labels: + katenary.v3/ports: |- + - 8080 + - 8081 ``` -!!! Info - A port **must** be declared, in `ports` section or with `katenary.io/ports` label. This to force the creation of a `Service`. +### katenary.v3/same-pod -## ports and container-ports +Move the same-pod deployment to the target deployment. -It's sometimes not mandatory to declare a port in compose file, or maybe you want to avoid to expose them in the compose file. But Katenary will sometimes need to know the ports to create service, for example to allow `depends_on` directive. +**Type**: `string` -In this case, you can declare the ports in the corresponding label: +This will make the service to be included in another service pod. Some services +must work together in the same pod, like a sidecar or a proxy or nginx + php-fpm. + +Note that volume and VolumeMount are copied from the source to the target +deployment. + +**Example:** ```yaml -serivces: - web: - image: nginx - labels: - katenary.io/ports: 80,443 +web: + image: nginx:1.19 + +php: + image: php:7.4-fpm + labels: + katenary.v3/same-pod: web ``` -This will leave Katenary creating the service to open these ports to others pods. +### katenary.v3/secrets -Sometimes, you need to have `containerPort` in pods but **avoid the service declaration**, so you can use this label: +Env vars to be set as secrets. + +**Type**: `list of string` + +This label allows setting the environment variables as secrets. The variable +is removed from the environment and added to a secret object. + +The variable can be set to the `katenary.v3/values` too, +so the secret value can be configured in values.yaml + +**Example:** ```yaml -services: - php: - image: php:8-fpm - labels: - katenary.io/container-ports: 9000 +env: + PASSWORD: a very secret password + NOT_A_SECRET: a public value +labels: + katenary.v3/secrets: |- + - PASSWORD ``` -That will only declare the container port in the pod, but not in the service. +### katenary.v3/values -!!! Info - It's very useful when you need to declare ports in conjonction with `same-pod`. Katenary would create a service with all the pods ports inside. The `container-ports` label will make the ports to be ignored in the service creation. +Environment variables to be added to the values.yaml -## mapenv +**Type**: `list of string or map` -Environment variables are working great for your compose stack but you sometimes need to change them in Helm. This label allows you to remap the value for Helm. +By default, all environment variables in the "env" and environment +files are added to configmaps with the static values set. This label +allows to add environment variables to the values.yaml file. -For example, when you use an environment variable to point on another service. +Note that the value inside the configmap is `{{ tpl vaname . }}`, so +you can set the value to a template that will be rendered with the +values.yaml file. + +The value can be set with a documentation. This may help to understand +the purpose of the variable. + +**Example:** ```yaml -serivces: - php: - image: php - environment: - DB_HOST: database - - database: - image: mariadb - labels: - katenary.io/ports: 3306 +env: + FOO: bar + DB_NAME: mydb + TO_CONFIGURE: something that can be changed in values.yaml + A_COMPLEX_VALUE: example +labels: + katenary.v3/values: |- + # simple values, set as is in values.yaml + - TO_CONFIGURE + # complex values, set as a template in values.yaml with a documentation + - A_COMPLEX_VALUE: |- + This is the documentation for the variable to + configure in values.yaml. + It can be, of course, a multiline text. ``` -The above example will break when you'll start it in Kubernetes because the `database` service will not be named like this, it will be renamed to `{{ .Release.Name }}-database`. So, you can declare the rewrite: - -```yaml -services: - php: - image: php - environment: - DB_HOST: database - labels: - katenary.io/mapenv: | - DB_HOST: "{{ .Release.Name }}"-database - database: - image: mariadb - labels: - katenary.io/ports: 3306 - -``` - -It's also useful when you want to change a variable value to another when you deploy on Kubernetes. - -## secret-envfiles - -Katenary binds all "environemnt files" to config maps. But some of these files can be bound as sercrets. - -In this case, declare the files as is: - -```yaml -services: - app: - image: #... - env_file: - - ./env/whatever - - ./env/sensitives - labels: - katenary.io/secret-envfiles: ./env/sensitives -``` - -## secret-vars - -If you have some environemnt variables to declare as secret, you can list them in the `secret-vars` label. - -```yaml -services: - database: - image: mariadb - environemnt: - MYSQL_PASSWORD: foobar - MYSQL_ROOT_PASSWORD: longpasswordhere - MYSQL_USER: john - MYSQL_DATABASE: appdb - labels: - katenary.io/secret-vars: MYSQL_ROOT_PASSWORD,MYSQL_PASSWORD -``` - -## ignore - -Simply ignore the service to not be exported in the Helm Chart. - -```yaml -serivces: - - # this service is able to answer HTTP - # on port 5000 - webapp: - image: myapp - labels: - # declare the port - katenary.io/ports: 5000 - # the ingress controller is a web proxy, so... - katenary.io/ingress: 5000 - - - # with local Docker, I want to access my webapp - # with "myapp.locahost" so I use a nice proxy on - # port 80 - proxy: - image: quay.io/pathwae/proxy - ports: - - 80:80 - environemnt: - CONFIG: | - myapp.localhost: webapp:5000 - labels: - # I don't need it in Helm, it's only - # for local test! - katenary.io/ignore: true -``` + diff --git a/doc/docs/packages/cmd/katenary.md b/doc/docs/packages/cmd/katenary.md new file mode 100644 index 0000000..03a3bb2 --- /dev/null +++ b/doc/docs/packages/cmd/katenary.md @@ -0,0 +1,8 @@ + + +# katenary + +``` go +import "katenary/cmd/katenary" +``` + diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md new file mode 100644 index 0000000..5fd4773 --- /dev/null +++ b/doc/docs/packages/generator.md @@ -0,0 +1,893 @@ + + +# generator + +``` go +import "katenary/generator" +``` + +The generator package generates kubernetes objects from a compose file +and transforms them into a helm chart. + +The generator package is the core of katenary. It is responsible for +generating kubernetes objects from a compose file and transforming them +into a helm chart. Convertion manipulates Yaml representation of +kubernetes object to add conditions, labels, annotations, etc. to the +objects. It also create the values to be set to the values.yaml file. + +The generate.Convert() create an HelmChart object and call “Generate()” +method to convert from a compose file to a helm chart. It saves the helm +chart in the given directory. + +If you want to change or override the write behavior, you can use the +HelmChart.Generate() function and implement your own write function. +This function returns the helm chart object containing all kubernetes +objects and helm chart ingormation. It does not write the helm chart to +the disk. + +TODO: Manage cronjob + rbac TODO: create note.txt TODO: manage emptyDirs + +## Constants + +``` go +const KATENARY_PREFIX = "katenary.v3/" +``` + +## Variables + +``` go +var ( + + // Standard annotationss + Annotations = map[string]string{ + KATENARY_PREFIX + "version": Version, + } +) +``` + +Version is the version of katenary. It is set at compile time. + +``` go +var Version = "master" // changed at compile time +``` + +## func Convert + +``` go +func Convert(config ConvertOptions, dockerComposeFile ...string) +``` + +Convert a compose (docker, podman…) project to a helm chart. It calls +Generate() to generate the chart and then write it to the disk. + +## func GetLabelHelp + +``` go +func GetLabelHelp(asMarkdown bool) string +``` + +Generate the help for the labels. + +## func GetLabelHelpFor + +``` go +func GetLabelHelpFor(labelname string, asMarkdown bool) string +``` + +GetLabelHelpFor returns the help for a specific label. + +## func GetLabelNames + +``` go +func GetLabelNames() []string +``` + +GetLabelNames returns a sorted list of all katenary label names. + +## func GetLabels + +``` go +func GetLabels(serviceName, appName string) map[string]string +``` + +## func GetMatchLabels + +``` go +func GetMatchLabels(serviceName, appName string) map[string]string +``` + +## func Helper + +``` go +func Helper(name string) string +``` + +Helper returns the \_helpers.tpl file for a chart. + +## func NewCronJob + +``` go +func NewCronJob(service types.ServiceConfig, chart *HelmChart, appName string) (*CronJob, *RBAC) +``` + +NewCronJob creates a new CronJob from a compose service. The appName is +the name of the application taken from the project name. + +## type ChartTemplate + +ChartTemplate is a template of a chart. It contains the content of the +template and the name of the service. This is used internally to +generate the templates. + +TODO: maybe we can set it private. + +``` go +type ChartTemplate struct { + Content []byte + Servicename string +} +``` + +## type ConfigMap + +ConfigMap is a kubernetes ConfigMap. Implements the DataMap interface. + +``` go +type ConfigMap struct { + *corev1.ConfigMap + // contains filtered or unexported fields +} +``` + +### func NewConfigMap + +``` go +func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap +``` + +NewConfigMap creates a new ConfigMap from a compose service. The appName +is the name of the application taken from the project name. The +ConfigMap is filled by environment variables and labels “map-env”. + +### func NewConfigMapFromFiles + +``` go +func NewConfigMapFromFiles(service types.ServiceConfig, appName string, path string) *ConfigMap +``` + +NewConfigMapFromFiles creates a new ConfigMap from a compose service. +This path is the path to the file or directory. If the path is a +directory, all files in the directory are added to the ConfigMap. Each +subdirectory are ignored. Note that the Generate() function will create +the subdirectories ConfigMaps. + +### func (*ConfigMap) AddData + +``` go +func (c *ConfigMap) AddData(key string, value string) +``` + +AddData adds a key value pair to the configmap. Append or overwrite the +value if the key already exists. + +### func (*ConfigMap) AppendDir + +``` go +func (c *ConfigMap) AppendDir(path string) +``` + +AddFile adds files from given path to the configmap. It is not +recursive, to add all files in a directory, you need to call this +function for each subdirectory. + +### func (*ConfigMap) Filename + +``` go +func (c *ConfigMap) Filename() string +``` + +Filename returns the filename of the configmap. If the configmap is used +for files, the filename contains the path. + +### func (*ConfigMap) SetData + +``` go +func (c *ConfigMap) SetData(data map[string]string) +``` + +SetData sets the data of the configmap. It replaces the entire data. + +### func (*ConfigMap) Yaml + +``` go +func (c *ConfigMap) Yaml() ([]byte, error) +``` + +Yaml returns the yaml representation of the configmap + +## type ConvertOptions + +ConvertOptions are the options to convert a compose project to a helm +chart. + +``` go +type ConvertOptions struct { + Force bool // Force the chart directory deletion if it already exists. + OutputDir string // The output directory of the chart. + Profiles []string // Profile to use for the conversion. + HelmUpdate bool // If true, the "helm dep update" command will be run after the chart generation. + AppVersion *string // Set the chart "appVersion" field. If nil, the version will be set to 0.1.0. + ChartVersion string // Set the chart "version" field. +} +``` + +## type CronJob + +CronJob is a kubernetes CronJob. + +``` go +type CronJob struct { + *batchv1.CronJob + // contains filtered or unexported fields +} +``` + +### func (*CronJob) Filename + +``` go +func (c *CronJob) Filename() string +``` + +Filename returns the filename of the cronjob. + +Implements the Yaml interface. + +### func (*CronJob) Yaml + +``` go +func (c *CronJob) Yaml() ([]byte, error) +``` + +Yaml returns the yaml representation of the cronjob. + +Implements the Yaml interface. + +## type CronJobValue + +CronJobValue is a cronjob configuration that will be saved in +values.yaml. + +``` go +type CronJobValue struct { + Repository *RepositoryValue `yaml:"repository,omitempty"` + Environment map[string]any `yaml:"environment,omitempty"` + ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"` + Schedule string `yaml:"schedule"` +} +``` + +## type DataMap + +DataMap is a kubernetes ConfigMap or Secret. It can be used to add data +to the ConfigMap or Secret. + +``` go +type DataMap interface { + SetData(map[string]string) + AddData(string, string) +} +``` + +### func NewFileMap + +``` go +func NewFileMap(service types.ServiceConfig, appName string, kind string) DataMap +``` + +NewFileMap creates a new DataMap from a compose service. The appName is +the name of the application taken from the project name. + +## type Dependency + +Dependency is a dependency of a chart to other charts. + +``` go +type Dependency struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Repository string `yaml:"repository"` + Alias string `yaml:"alias,omitempty"` + Values map[string]any `yaml:"-"` // do not export to Chart.yaml +} +``` + +## type Deployment + +Deployment is a kubernetes Deployment. + +``` go +type Deployment struct { + *appsv1.Deployment `yaml:",inline"` + // contains filtered or unexported fields +} +``` + +### func NewDeployment + +``` go +func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment +``` + +NewDeployment creates a new Deployment from a compose service. The +appName is the name of the application taken from the project name. It +also creates the Values map that will be used to create the values.yaml +file. + +### func (*Deployment) AddContainer + +``` go +func (d *Deployment) AddContainer(service types.ServiceConfig) +``` + +AddContainer adds a container to the deployment. + +### func (*Deployment) AddHealthCheck + +``` go +func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) +``` + +### func (*Deployment) AddIngress + +``` go +func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *Ingress +``` + +AddIngress adds an ingress to the deployment. It creates the ingress +object. + +### func (*Deployment) AddVolumes + +``` go +func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) +``` + +AddVolumes adds a volume to the deployment. It does not create the PVC, +it only adds the volumes to the deployment. If the volume is a bind +volume it will warn the user that it is not supported yet. + +### func (*Deployment) BindFrom + +``` go +func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) +``` + +### func (*Deployment) DependsOn + +``` go +func (d *Deployment) DependsOn(to *Deployment) error +``` + +DependsOn adds a initContainer to the deployment that will wait for the +service to be up. + +### func (*Deployment) Filename + +``` go +func (d *Deployment) Filename() string +``` + +### func (*Deployment) SetEnvFrom + +``` go +func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) +``` + +SetEnvFrom sets the environment variables to a configmap. The configmap +is created. + +### func (*Deployment) Yaml + +``` go +func (d *Deployment) Yaml() ([]byte, error) +``` + +Yaml returns the yaml representation of the deployment. + +## type FileMapUsage + +FileMapUsage is the usage of the filemap. + +``` go +type FileMapUsage uint8 +``` + +FileMapUsage constants. + +``` go +const ( + FileMapUsageConfigMap FileMapUsage = iota // pure configmap for key:values. + FileMapUsageFiles // files in a configmap. +) +``` + +## type HelmChart + +HelmChart is a Helm Chart representation. It contains all the tempaltes, +values, versions, helpers… + +``` go +type HelmChart struct { + Name string `yaml:"name"` + ApiVersion string `yaml:"apiVersion"` + Version string `yaml:"version"` + AppVersion string `yaml:"appVersion"` + Description string `yaml:"description"` + Dependencies []Dependency `yaml:"dependencies,omitempty"` + Templates map[string]*ChartTemplate `yaml:"-"` // do not export to yaml + Helper string `yaml:"-"` // do not export to yaml + Values map[string]any `yaml:"-"` // do not export to yaml + VolumeMounts map[string]any `yaml:"-"` // do not export to yaml + // contains filtered or unexported fields +} +``` + +### func Generate + +``` go +func Generate(project *types.Project) (*HelmChart, error) +``` + +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: + +1. Detect the service port name or leave the port number if not found. + +2. Create a deployment for each service that are not ingnore. + +3. Create a service and ingresses for each service that has ports + and/or declared ingresses. + +4. Create a PVC or Configmap volumes for each volume. + +5. Create init containers for each service which has dependencies to + other services. + +6. Create a chart dependencies. + +7. Create a configmap and secrets from the environment variables. + +8. Merge the same-pod services. + +### func NewChart + +``` go +func NewChart(name string) *HelmChart +``` + +NewChart creates a new empty chart with the given name. + +## type Help + +Help is the documentation of a label. + +``` go +type Help struct { + Short string `yaml:"short"` + Long string `yaml:"long"` + Example string `yaml:"example"` + Type string `yaml:"type"` +} +``` + +## type Ingress + +``` go +type Ingress struct { + *networkv1.Ingress + // contains filtered or unexported fields +} +``` + +### func NewIngress + +``` go +func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress +``` + +NewIngress creates a new Ingress from a compose service. + +### func (*Ingress) Filename + +``` go +func (ingress *Ingress) Filename() string +``` + +### func (*Ingress) Yaml + +``` go +func (ingress *Ingress) Yaml() ([]byte, error) +``` + +## type IngressValue + +IngressValue is a ingress configuration that will be saved in +values.yaml. + +``` go +type IngressValue struct { + Enabled bool `yaml:"enabled"` + Host string `yaml:"host"` + Path string `yaml:"path"` + Class string `yaml:"class"` + Annotations map[string]string `yaml:"annotations"` +} +``` + +## type Label + +Label is a katenary label to find in compose files. + +``` go +type Label = string +``` + +Known labels. + +``` go +const ( + LABEL_MAIN_APP Label = KATENARY_PREFIX + "main-app" + LABEL_VALUES Label = KATENARY_PREFIX + "values" + LABEL_SECRETS Label = KATENARY_PREFIX + "secrets" + LABEL_PORTS Label = KATENARY_PREFIX + "ports" + LABEL_INGRESS Label = KATENARY_PREFIX + "ingress" + LABEL_MAP_ENV Label = KATENARY_PREFIX + "map-env" + LABEL_HEALTHCHECK Label = KATENARY_PREFIX + "health-check" + LABEL_SAME_POD Label = KATENARY_PREFIX + "same-pod" + LABEL_DESCRIPTION Label = KATENARY_PREFIX + "description" + LABEL_IGNORE Label = KATENARY_PREFIX + "ignore" + LABEL_DEPENDENCIES Label = KATENARY_PREFIX + "dependencies" + LABEL_CM_FILES Label = KATENARY_PREFIX + "configmap-files" + LABEL_CRONJOB Label = KATENARY_PREFIX + "cronjob" + LABEL_ENV_FROM Label = KATENARY_PREFIX + "env-from" +) +``` + +## type LabelType + +LabelType identifies the type of label to generate in objects. TODO: is +this still needed? + +``` go +type LabelType uint8 +``` + +``` go +const ( + DeploymentLabel LabelType = iota + ServiceLabel +) +``` + +## type PersistenceValue + +PersistenceValue is a persistence configuration that will be saved in +values.yaml. + +``` go +type PersistenceValue struct { + Enabled bool `yaml:"enabled"` + StorageClass string `yaml:"storageClass"` + Size string `yaml:"size"` + AccessMode []string `yaml:"accessMode"` +} +``` + +## type RBAC + +RBAC is a kubernetes RBAC containing a role, a rolebinding and an +associated serviceaccount. + +``` go +type RBAC struct { + RoleBinding *RoleBinding + Role *Role + ServiceAccount *ServiceAccount +} +``` + +### func NewRBAC + +``` go +func NewRBAC(service types.ServiceConfig, appName string) *RBAC +``` + +NewRBAC creates a new RBAC from a compose service. The appName is the +name of the application taken from the project name. + +## type RepositoryValue + +RepositoryValue is a docker repository image and tag that will be saved +in values.yaml. + +``` go +type RepositoryValue struct { + Image string `yaml:"image"` + Tag string `yaml:"tag"` +} +``` + +## type Role + +Role is a kubernetes Role. + +``` go +type Role struct { + *rbacv1.Role + // contains filtered or unexported fields +} +``` + +### func (*Role) Filename + +``` go +func (r *Role) Filename() string +``` + +### func (*Role) Yaml + +``` go +func (r *Role) Yaml() ([]byte, error) +``` + +## type RoleBinding + +RoleBinding is a kubernetes RoleBinding. + +``` go +type RoleBinding struct { + *rbacv1.RoleBinding + // contains filtered or unexported fields +} +``` + +### func (*RoleBinding) Filename + +``` go +func (r *RoleBinding) Filename() string +``` + +### func (*RoleBinding) Yaml + +``` go +func (r *RoleBinding) Yaml() ([]byte, error) +``` + +## type Secret + +Secret is a kubernetes Secret. + +Implements the DataMap interface. + +``` go +type Secret struct { + *corev1.Secret + // contains filtered or unexported fields +} +``` + +### func NewSecret + +``` go +func NewSecret(service types.ServiceConfig, appName string) *Secret +``` + +NewSecret creates a new Secret from a compose service + +### func (*Secret) AddData + +``` go +func (s *Secret) AddData(key string, value string) +``` + +AddData adds a key value pair to the secret. + +### func (*Secret) Filename + +``` go +func (s *Secret) Filename() string +``` + +Filename returns the filename of the secret. + +### func (*Secret) SetData + +``` go +func (s *Secret) SetData(data map[string]string) +``` + +SetData sets the data of the secret. + +### func (*Secret) Yaml + +``` go +func (s *Secret) Yaml() ([]byte, error) +``` + +Yaml returns the yaml representation of the secret. + +## type Service + +Service is a kubernetes Service. + +``` go +type Service struct { + *v1.Service `yaml:",inline"` + // contains filtered or unexported fields +} +``` + +### func NewService + +``` go +func NewService(service types.ServiceConfig, appName string) *Service +``` + +NewService creates a new Service from a compose service. + +### func (*Service) AddPort + +``` go +func (s *Service) AddPort(port types.ServicePortConfig, serviceName ...string) +``` + +AddPort adds a port to the service. + +### func (*Service) Filename + +``` go +func (s *Service) Filename() string +``` + +Filename returns the filename of the service. + +### func (*Service) Yaml + +``` go +func (s *Service) Yaml() ([]byte, error) +``` + +Yaml returns the yaml representation of the service. + +## type ServiceAccount + +ServiceAccount is a kubernetes ServiceAccount. + +``` go +type ServiceAccount struct { + *corev1.ServiceAccount + // contains filtered or unexported fields +} +``` + +### func (*ServiceAccount) Filename + +``` go +func (r *ServiceAccount) Filename() string +``` + +### func (*ServiceAccount) Yaml + +``` go +func (r *ServiceAccount) Yaml() ([]byte, error) +``` + +## type Value + +Value will be saved in values.yaml. It contains configuraiton for all +deployment and services. The content will be lile: + + name_of_component: + repository: + image: image_name + tag: image_tag + persistence: + enabled: true + storageClass: storage_class_name + ingress: + enabled: true + host: host_name + path: path_name + environment: + ENV_VAR_1: value_1 + ENV_VAR_2: value_2 + +``` go +type Value struct { + Repository *RepositoryValue `yaml:"repository,omitempty"` + Persistence map[string]*PersistenceValue `yaml:"persistence,omitempty"` + Ingress *IngressValue `yaml:"ingress,omitempty"` + ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"` + Environment map[string]any `yaml:"environment,omitempty"` + Replicas *uint32 `yaml:"replicas,omitempty"` + CronJob *CronJobValue `yaml:"cronjob,omitempty"` +} +``` + +### func NewValue + +``` go +func NewValue(service types.ServiceConfig, main ...bool) *Value +``` + +NewValue creates a new Value from a compose service. The value contains +the necessary information to deploy the service (image, tag, replicas, +etc.). + +If \`main\` is true, the tag will be empty because it will be set in the +helm chart appVersion. + +### func (*Value) AddIngress + +``` go +func (v *Value) AddIngress(host, path string) +``` + +### func (*Value) AddPersistence + +``` go +func (v *Value) AddPersistence(volumeName string) +``` + +AddPersistence adds persistence configuration to the Value. + +## type VolumeClaim + +VolumeClaim is a kubernetes VolumeClaim. This is a +PersistentVolumeClaim. + +``` go +type VolumeClaim struct { + *v1.PersistentVolumeClaim + // contains filtered or unexported fields +} +``` + +### func NewVolumeClaim + +``` go +func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *VolumeClaim +``` + +NewVolumeClaim creates a new VolumeClaim from a compose service. + +### func (*VolumeClaim) Filename + +``` go +func (v *VolumeClaim) Filename() string +``` + +Filename returns the suggested filename for a VolumeClaim. + +### func (*VolumeClaim) Yaml + +``` go +func (v *VolumeClaim) Yaml() ([]byte, error) +``` + +Yaml marshals a VolumeClaim into yaml. + +## type Yaml + +Yaml is a kubernetes object that can be converted to yaml. + +``` go +type Yaml interface { + Yaml() ([]byte, error) + Filename() string +} +``` + +Generated by [gomarkdoc](https://github.com/princjef/gomarkdoc) diff --git a/doc/docs/packages/generator/extrafiles.md b/doc/docs/packages/generator/extrafiles.md new file mode 100644 index 0000000..ca80dd0 --- /dev/null +++ b/doc/docs/packages/generator/extrafiles.md @@ -0,0 +1,28 @@ + + +# extrafiles + +``` go +import "katenary/generator/extrafiles" +``` + +extrafiles package provides function to generate the Chart files that +are not objects. Like README.md and notes.txt… + +## func NotesFile + +``` go +func NotesFile() string +``` + +NoteTXTFile returns the content of the note.txt file. + +## func ReadMeFile + +``` go +func ReadMeFile(charname, description string, values map[string]any) string +``` + +ReadMeFile returns the content of the README.md file. + +Generated by [gomarkdoc](https://github.com/princjef/gomarkdoc) diff --git a/doc/docs/packages/parser.md b/doc/docs/packages/parser.md new file mode 100644 index 0000000..3283326 --- /dev/null +++ b/doc/docs/packages/parser.md @@ -0,0 +1,20 @@ + + +# parser + +``` go +import "katenary/parser" +``` + +Parser package is a wrapper around compose-go to parse compose files. + +## func Parse + +``` go +func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, error) +``` + +Parse compose files and return a project. The project is parsed with +dotenv, osenv and profiles. + +Generated by [gomarkdoc](https://github.com/princjef/gomarkdoc) diff --git a/doc/docs/packages/update.md b/doc/docs/packages/update.md new file mode 100644 index 0000000..c6fab01 --- /dev/null +++ b/doc/docs/packages/update.md @@ -0,0 +1,55 @@ + + +# update + +``` go +import "katenary/update" +``` + +Update package is used to check if a new version of katenary is +available. + +## Variables + +``` go +var Version = "master" // reset by cmd/main.go +``` + +## func DownloadFile + +``` go +func DownloadFile(url, exe string) error +``` + +DownloadFile will download a url to a local file. It also ensure that +the file is executable. + +## func DownloadLatestVersion + +``` go +func DownloadLatestVersion(assets []Asset) error +``` + +DownloadLatestVersion will download the latest version of katenary. + +## type Asset + +Asset is a github asset from release url. + +``` go +type Asset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` +} +``` + +### func CheckLatestVersion + +``` go +func CheckLatestVersion() (string, []Asset, error) +``` + +CheckLatestVersion check katenary latest version from release and +propose to download it + +Generated by [gomarkdoc](https://github.com/princjef/gomarkdoc) diff --git a/doc/docs/packages/utils.md b/doc/docs/packages/utils.md new file mode 100644 index 0000000..b497f03 --- /dev/null +++ b/doc/docs/packages/utils.md @@ -0,0 +1,187 @@ + + +# utils + +``` go +import "katenary/utils" +``` + +Utils package provides some utility functions used in katenary. It +defines some constants and functions used in the whole project. + +## Constants + +Icons used in katenary. + +``` go +const ( + IconSuccess Icon = "✅" + IconFailure = "❌" + IconWarning = "⚠️'" + IconNote = "📝" + IconWorld = "🌐" + IconPlug = "🔌" + IconPackage = "📦" + IconCabinet = "🗄️" + IconInfo = "❕" + IconSecret = "🔒" + IconConfig = "🔧" + IconDependency = "🔗" +) +``` + +## func CountStartingSpaces + +``` go +func CountStartingSpaces(line string) int +``` + +CountStartingSpaces counts the number of spaces at the beginning of a +string. + +## func GetContainerByName + +``` go +func GetContainerByName(name string, containers []corev1.Container) (*corev1.Container, int) +``` + +GetContainerByName returns a container by name and its index in the +array. It returns nil, -1 if not found. + +## func GetKind + +``` go +func GetKind(path string) (kind string) +``` + +GetKind returns the kind of the resource from the file path. + +## func GetServiceNameByPort + +``` go +func GetServiceNameByPort(port int) string +``` + +GetServiceNameByPort returns the service name for a port. It the service +name is not found, it returns an empty string. + +## func GetValuesFromLabel + +``` go +func GetValuesFromLabel(service types.ServiceConfig, LabelValues string) map[string]*EnvConfig +``` + +GetValuesFromLabel returns a map of values from a label. + +## func HashComposefiles + +``` go +func HashComposefiles(files []string) (string, error) +``` + +HashComposefiles returns a hash of the compose files. + +## func Int32Ptr + +``` go +func Int32Ptr(i int32) *int32 +``` + +Int32Ptr returns a pointer to an int32. + +## func MapKeys + +``` go +func MapKeys(m map[string]interface{}) []string +``` + +## func PathToName + +``` go +func PathToName(path string) string +``` + +PathToName converts a path to a kubernetes complient name. + +## func StrPtr + +``` go +func StrPtr(s string) *string +``` + +StrPtr returns a pointer to a string. + +## func TplName + +``` go +func TplName(serviceName, appname string, suffix ...string) string +``` + +TplName returns the name of the kubernetes resource as a template +string. It is used in the templates and defined in \_helper.tpl file. + +## func TplValue + +``` go +func TplValue(serviceName, variable string, pipes ...string) string +``` + +GetContainerByName returns a container by name and its index in the +array. + +## func Warn + +``` go +func Warn(msg ...interface{}) +``` + +Warn prints a warning message + +## func WordWrap + +``` go +func WordWrap(text string, lineWidth int) string +``` + +WordWrap wraps a string to a given line width. Warning: it may break the +string. You need to check the result. + +## func Wrap + +``` go +func Wrap(src, above, below string) string +``` + +Wrap wraps a string with a string above and below. It will respect the +indentation of the src string. + +## func WrapBytes + +``` go +func WrapBytes(src, above, below []byte) []byte +``` + +WrapBytes wraps a byte array with a byte array above and below. It will +respect the indentation of the src string. + +## type EnvConfig + +EnvConfig is a struct to hold the description of an environment +variable. + +``` go +type EnvConfig struct { + Description string + Service types.ServiceConfig +} +``` + +## type Icon + +Icon is a unicode icon + +``` go +type Icon string +``` + +Generated by [gomarkdoc](https://github.com/princjef/gomarkdoc) diff --git a/doc/docs/statics/main.css b/doc/docs/statics/main.css index 9c1b7b0..94fca4d 100644 --- a/doc/docs/statics/main.css +++ b/doc/docs/statics/main.css @@ -27,7 +27,7 @@ button.md-clipboard:hover::after { article a, article a:visited { - color: var(--md-code-hl-number-color); + color: var(--md-code-hl-number-color) !important; } .md-center { @@ -53,3 +53,15 @@ pre code.hljs { background-color: var(--code-bg-color); color: var(--code-fg-color); } + +table tbody code { + text-align: left; + white-space: nowrap; + font-size: 1em !important; + background-color: transparent !important; + color: var(--md-code-hl-special-color) !important; +} + +h3[id*="katenaryio"] { + color: var(--md-code-hl-special-color); +} diff --git a/doc/mkdocs.yml b/doc/mkdocs.yml index 3409b25..e45174d 100644 --- a/doc/mkdocs.yml +++ b/doc/mkdocs.yml @@ -18,8 +18,8 @@ markdown_extensions: - admonition - attr_list - pymdownx.emoji: - emoji_generator: !!python/name:materialx.emoji.to_svg - emoji_index: !!python/name:materialx.emoji.twemoji + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg - pymdownx.highlight: anchor_linenums: true use_pygments: false @@ -28,7 +28,7 @@ extra_css: - statics/main.css extra_javascript: - statics/addons.js -copyright: Copyright © 2021 - 2022 - Katenary authors +copyright: Copyright © 2021 - 2023 - Katenary authors extra: generator: false social: @@ -38,3 +38,12 @@ nav: - "Home": index.md - usage.md - labels.md + - Behind the scene: + - coding.md + - dependencies.md + - Go Packages: + - packages/generator.md + - packages/parser.md + - packages/update.md + - packages/utils.md + - packages/generator/extrafiles.md diff --git a/doc/requirements.txt b/doc/requirements.txt index 2ea3f00..234bde6 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,4 +1,4 @@ -mkdocs==1.3.0 +mkdocs>=1.3.0 Jinja2>=2.10.2 MarkupSafe>=2.0 pymdown-extensions>=9.5 diff --git a/examples/basic/README.md b/examples/basic/README.md deleted file mode 100644 index 1b3974a..0000000 --- a/examples/basic/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Basic example - -This is a basic example of what can do Katenary with standard docker-compose file. - -In this example: - -- `depends_on` yield a `initContainer` in the webapp ddeployment to wait for database -- so we need to declare the listened port inside `database` container as we don't use it with docker-compose- also, we needed to declare that `DB_HOST` is actually a service name using `mapenv` label - -Take a look on [chart/basic](chart/basic) directory to see what `katenary convert` command has generated. diff --git a/examples/basic/chart/basic/Chart.yaml b/examples/basic/chart/basic/Chart.yaml deleted file mode 100644 index 88573d5..0000000 --- a/examples/basic/chart/basic/Chart.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Create on 2022-02-17T10:27:30+01:00 -# Katenary command line: katenary convert -apiVersion: v2 -appVersion: 0.0.1 -description: A helm chart for basic -name: basic -type: application -version: 0.1.0 diff --git a/examples/basic/chart/basic/templates/NOTES.txt b/examples/basic/chart/basic/templates/NOTES.txt deleted file mode 100644 index f4ec230..0000000 --- a/examples/basic/chart/basic/templates/NOTES.txt +++ /dev/null @@ -1,8 +0,0 @@ - -Congratulations, - -Your application is now deployed. This may take a while to be up and responding. - -{{ if .Values.webapp.ingress.enabled -}} -- webapp is accessible on : http://{{ .Values.webapp.ingress.host }} -{{- end }} diff --git a/examples/basic/chart/basic/templates/database.deployment.yaml b/examples/basic/chart/basic/templates/database.deployment.yaml deleted file mode 100644 index 1f18276..0000000 --- a/examples/basic/chart/basic/templates/database.deployment.yaml +++ /dev/null @@ -1,39 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: '{{ .Release.Name }}-database' - labels: - katenary.io/component: database - katenary.io/project: basic - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: b9f12bb7d1e97901c1d7680394209525763f6640 - katenary.io/version: master-3619cc4 -spec: - replicas: 1 - selector: - matchLabels: - katenary.io/component: database - katenary.io/release: '{{ .Release.Name }}' - template: - metadata: - labels: - katenary.io/component: database - katenary.io/release: '{{ .Release.Name }}' - spec: - containers: - - name: database - image: '{{ .Values.database.image }}' - ports: - - name: database - containerPort: 3306 - env: - - name: MARIADB_PASSWORD - value: foo - - name: MARIADB_DATABASE - value: myapp - - name: MARIADB_ROOT_PASSWORD - value: foobar - - name: MARIADB_USER - value: foo - diff --git a/examples/basic/chart/basic/templates/database.service.yaml b/examples/basic/chart/basic/templates/database.service.yaml deleted file mode 100644 index ffde282..0000000 --- a/examples/basic/chart/basic/templates/database.service.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: '{{ .Release.Name }}-database' - labels: - katenary.io/component: database - katenary.io/project: basic - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: b9f12bb7d1e97901c1d7680394209525763f6640 - katenary.io/version: master-3619cc4 -spec: - selector: - katenary.io/component: database - katenary.io/release: '{{ .Release.Name }}' - ports: - - protocol: TCP - port: 3306 - targetPort: 3306 diff --git a/examples/basic/chart/basic/templates/webapp.deployment.yaml b/examples/basic/chart/basic/templates/webapp.deployment.yaml deleted file mode 100644 index d53d242..0000000 --- a/examples/basic/chart/basic/templates/webapp.deployment.yaml +++ /dev/null @@ -1,48 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: '{{ .Release.Name }}-webapp' - labels: - katenary.io/component: webapp - katenary.io/project: basic - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: b9f12bb7d1e97901c1d7680394209525763f6640 - katenary.io/version: master-3619cc4 -spec: - replicas: 1 - selector: - matchLabels: - katenary.io/component: webapp - katenary.io/release: '{{ .Release.Name }}' - template: - metadata: - labels: - katenary.io/component: webapp - katenary.io/release: '{{ .Release.Name }}' - spec: - initContainers: - - name: check-database - image: busybox - command: - - sh - - -c - - |- - OK=0 - echo "Checking database port" - while [ $OK != 1 ]; do - echo -n "." - nc -z {{ .Release.Name }}-database 3306 2>&1 >/dev/null && OK=1 || sleep 1 - done - echo - echo "Done" - containers: - - name: webapp - image: '{{ .Values.webapp.image }}' - ports: - - name: webapp - containerPort: 80 - env: - - name: DB_HOST - value: '{{ .Release.Name }}-database' - diff --git a/examples/basic/chart/basic/templates/webapp.ingress.yaml b/examples/basic/chart/basic/templates/webapp.ingress.yaml deleted file mode 100644 index 6bb2544..0000000 --- a/examples/basic/chart/basic/templates/webapp.ingress.yaml +++ /dev/null @@ -1,34 +0,0 @@ -{{- if .Values.webapp.ingress.enabled -}} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: '{{ .Release.Name }}-webapp' - labels: - katenary.io/component: webapp - katenary.io/project: basic - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: b9f12bb7d1e97901c1d7680394209525763f6640 - katenary.io/version: master-3619cc4 -spec: - {{- if and .Values.webapp.ingress.class (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} - ingressClassName: '{{ .Values.webapp.ingress.class }}' - {{- end }} - rules: - - host: '{{ .Values.webapp.ingress.host }}' - http: - paths: - - path: / - pathType: Prefix - backend: - {{- if semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion }} - service: - name: '{{ .Release.Name }}-webapp' - port: - number: 80 - {{- else }} - serviceName: '{{ .Release.Name }}-webapp' - servicePort: 80 - {{- end }} - -{{- end -}} diff --git a/examples/basic/chart/basic/templates/webapp.service.yaml b/examples/basic/chart/basic/templates/webapp.service.yaml deleted file mode 100644 index 60b5080..0000000 --- a/examples/basic/chart/basic/templates/webapp.service.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: '{{ .Release.Name }}-webapp' - labels: - katenary.io/component: webapp - katenary.io/project: basic - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: b9f12bb7d1e97901c1d7680394209525763f6640 - katenary.io/version: master-3619cc4 -spec: - selector: - katenary.io/component: webapp - katenary.io/release: '{{ .Release.Name }}' - ports: - - protocol: TCP - port: 80 - targetPort: 80 diff --git a/examples/basic/chart/basic/values.yaml b/examples/basic/chart/basic/values.yaml deleted file mode 100644 index 36216b1..0000000 --- a/examples/basic/chart/basic/values.yaml +++ /dev/null @@ -1,8 +0,0 @@ -database: - image: mariadb:10 -webapp: - image: php:7-apache - ingress: - class: nginx - enabled: false - host: webapp.basic.tld diff --git a/examples/basic/docker-compose.yaml b/examples/basic/docker-compose.yaml deleted file mode 100644 index dd24083..0000000 --- a/examples/basic/docker-compose.yaml +++ /dev/null @@ -1,31 +0,0 @@ -version: "3" - -# this example is absolutely not working, it's an example to see how it is converted -# by Katenary -services: - webapp: - image: php:7-apache - environment: - DB_HOST: database - ports: - - "8080:80" - labels: - # expose an ingress - katenary.io/ingress: 80 - # DB_HOST is actually a service name - katenary.io/mapenv: | - DB_HOST: "{{ .Release.Name }}-database" - depends_on: - - database - - database: - image: mariadb:10 - environment: - MARIADB_ROOT_PASSWORD: foobar - MARIADB_USER: foo - MARIADB_PASSWORD: foo - MARIADB_DATABASE: myapp - labels: - # because we don't provide "ports" or "expose", alert katenary - # to use the mysql port for service declaration - katenary.io/ports: 3306 diff --git a/examples/cronjobs/chart/README.md b/examples/cronjobs/chart/README.md new file mode 100644 index 0000000..7da7cf1 --- /dev/null +++ b/examples/cronjobs/chart/README.md @@ -0,0 +1,49 @@ +# cronjobs + +A Helm chart for cronjobs + +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +# Standard Helm install +$ helm install my-release cronjobs + +# To use a custom namespace and force the creation of the namespace +$ helm install my-release --namespace my-namespace --create-namespace cronjobs + +# To use a custom values file +$ helm install my-release -f my-values.yaml cronjobs +``` + +See the [Helm documentation](https://helm.sh/docs/intro/using_helm/) for more information on installing and managing the chart. + +## Configuration + +The following table lists the configurable parameters of the cronjobs chart and their default values. + +| Parameter | Default | +| ----------------------------------- | -------------- | +| `app.imagePullPolicy` | `IfNotPresent` | +| `app.replicas` | `1` | +| `app.repository.image` | `nginx` | +| `app.repository.tag` | `` | +| `backup.cronjob.imagePullPolicy` | `IfNotPresent` | +| `backup.cronjob.repository.image` | `alpine` | +| `backup.cronjob.repository.tag` | `1` | +| `backup.cronjob.schedule` | `@hourly` | +| `backup.imagePullPolicy` | `IfNotPresent` | +| `backup.replicas` | `1` | +| `backup.repository.image` | `alpine` | +| `backup.repository.tag` | `1` | +| `withrbac.cronjob.imagePullPolicy` | `IfNotPresent` | +| `withrbac.cronjob.repository.image` | `busybox` | +| `withrbac.cronjob.repository.tag` | `` | +| `withrbac.cronjob.schedule` | `@daily` | +| `withrbac.imagePullPolicy` | `IfNotPresent` | +| `withrbac.replicas` | `1` | +| `withrbac.repository.image` | `busybox` | +| `withrbac.repository.tag` | `` | + + diff --git a/examples/cronjobs/chart/templates/NOTES.txt b/examples/cronjobs/chart/templates/NOTES.txt new file mode 100644 index 0000000..3121a00 --- /dev/null +++ b/examples/cronjobs/chart/templates/NOTES.txt @@ -0,0 +1,27 @@ +Your release is named {{ .Release.Name }}. + +To learn more about the release, try: + + $ helm -n {{ .Release.Namespace }} status {{ .Release.Name }} + $ helm -n {{ .Release.Namespace }} get all {{ .Release.Name }} + +To delete the release, run: + + $ helm -n {{ .Release.Namespace }} delete {{ .Release.Name }} + +You can see this notes again by running: + + $ helm -n {{ .Release.Namespace }} get notes {{ .Release.Name }} + +{{- $count := 0 -}} +{{- range $s, $v := .Values -}} +{{- if and $v $v.ingress -}} +{{- $count = add $count 1 -}} +{{- if eq $count 1 }} + +The ingress list is: +{{ end }} + - {{ $s }}: http://{{ $v.ingress.host }}{{ $v.ingress.path }} +{{- end -}} +{{ end -}} + diff --git a/examples/cronjobs/chart/templates/_helpers.tpl b/examples/cronjobs/chart/templates/_helpers.tpl new file mode 100644 index 0000000..2bba0e2 --- /dev/null +++ b/examples/cronjobs/chart/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{- define "cronjobs.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "cronjobs.name" -}} +{{- if .Values.nameOverride -}} +{{- .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "cronjobs.labels" -}} +{{ include "cronjobs.selectorLabels" .}} +{{ if .Chart.Version -}} +{{ printf "katenary.v3/chart-version: %s" .Chart.Version }} +{{- end }} +{{ if .Chart.AppVersion -}} +{{ printf "katenary.v3/app-version: %s" .Chart.AppVersion }} +{{- end }} +{{- end -}} + +{{- define "cronjobs.selectorLabels" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{ printf "katenary.v3/name: %s" $name }} +{{ printf "katenary.v3/instance: %s" .Release.Name }} +{{- end -}} diff --git a/examples/ghost/README.md b/examples/ghost/README.md deleted file mode 100644 index 58135d4..0000000 --- a/examples/ghost/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Example with Ghost - -[Ghost](https://ghost.org/) is a simple but powerfull blog engine. It is very nice to test some behaviors with Docker or Podman. - -The given `docker-compose.yaml` file here declares a stand-alone blog service. To help using it, we use [Patwae](https://pathwae.net) reverse-proxy to listend http://ghost.example.localhost - -The problem to solve is that the `url` environment variable correspond to the Ingress host when we will convert it to Helm Chart. So, we use the `mapenv` label to declare that `url` is actually `{{ .Values.blog.ingress.host }}` value. - -Note that we also `ignore` pathwae because we don't need it in our Helm Chart. diff --git a/examples/ghost/chart/ghost/Chart.yaml b/examples/ghost/chart/ghost/Chart.yaml deleted file mode 100644 index f4732b0..0000000 --- a/examples/ghost/chart/ghost/Chart.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Create on 2022-05-05T14:16:27+02:00 -# Katenary command line: /tmp/go-build669507924/b001/exe/main convert -apiVersion: v2 -appVersion: 0.0.1 -description: A helm chart for ghost -name: ghost -type: application -version: 0.1.0 diff --git a/examples/ghost/chart/ghost/templates/NOTES.txt b/examples/ghost/chart/ghost/templates/NOTES.txt deleted file mode 100644 index 10ce5b3..0000000 --- a/examples/ghost/chart/ghost/templates/NOTES.txt +++ /dev/null @@ -1,8 +0,0 @@ - -Congratulations, - -Your application is now deployed. This may take a while to be up and responding. - -{{ if .Values.blog.ingress.enabled -}} -- blog is accessible on : http://{{ .Values.blog.ingress.host }} -{{- end }} diff --git a/examples/ghost/chart/ghost/templates/blog.deployment.yaml b/examples/ghost/chart/ghost/templates/blog.deployment.yaml deleted file mode 100644 index 6378e0d..0000000 --- a/examples/ghost/chart/ghost/templates/blog.deployment.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: '{{ .Release.Name }}-blog' - labels: - katenary.io/component: blog - katenary.io/project: ghost - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: 0c2bbf548ff569c3dc5d77dc158e98bbe86fb5d4 - katenary.io/version: master -spec: - replicas: 1 - selector: - matchLabels: - katenary.io/component: blog - katenary.io/release: '{{ .Release.Name }}' - template: - metadata: - labels: - katenary.io/component: blog - katenary.io/release: '{{ .Release.Name }}' - spec: - containers: - - name: blog - image: '{{ .Values.blog.image }}' - ports: - - name: blog - containerPort: 2368 - env: - - name: url - value: http://{{ .Values.blog.ingress.host }} - diff --git a/examples/ghost/chart/ghost/templates/blog.ingress.yaml b/examples/ghost/chart/ghost/templates/blog.ingress.yaml deleted file mode 100644 index 43c804d..0000000 --- a/examples/ghost/chart/ghost/templates/blog.ingress.yaml +++ /dev/null @@ -1,42 +0,0 @@ -{{- if .Values.blog.ingress.enabled -}} -{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} -apiVersion: networking.k8s.io/v1 -{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} -apiVersion: networking.k8s.io/v1beta1 -{{- else -}} -apiVersion: extensions/v1beta1 -{{- end }} -kind: Ingress -metadata: - name: '{{ .Release.Name }}-blog' - labels: - katenary.io/component: blog - katenary.io/project: ghost - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: 0c2bbf548ff569c3dc5d77dc158e98bbe86fb5d4 - katenary.io/version: master -spec: - {{- if and .Values.blog.ingress.class (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} - ingressClassName: '{{ .Values.blog.ingress.class }}' - {{- end }} - rules: - - host: '{{ .Values.blog.ingress.host }}' - http: - paths: - - path: / - {{- if semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion }} - pathType: Prefix - {{- end }} - backend: - {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }} - service: - name: '{{ .Release.Name }}-blog' - port: - number: 2368 - {{- else }} - serviceName: '{{ .Release.Name }}-blog' - servicePort: 2368 - {{- end }} - -{{- end -}} diff --git a/examples/ghost/chart/ghost/templates/blog.service.yaml b/examples/ghost/chart/ghost/templates/blog.service.yaml deleted file mode 100644 index 5c54299..0000000 --- a/examples/ghost/chart/ghost/templates/blog.service.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: '{{ .Release.Name }}-blog' - labels: - katenary.io/component: blog - katenary.io/project: ghost - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: 0c2bbf548ff569c3dc5d77dc158e98bbe86fb5d4 - katenary.io/version: master -spec: - selector: - katenary.io/component: blog - katenary.io/release: '{{ .Release.Name }}' - ports: - - protocol: TCP - port: 2368 - targetPort: 2368 diff --git a/examples/ghost/chart/ghost/values.yaml b/examples/ghost/chart/ghost/values.yaml deleted file mode 100644 index 6ef57af..0000000 --- a/examples/ghost/chart/ghost/values.yaml +++ /dev/null @@ -1,6 +0,0 @@ -blog: - image: ghost - ingress: - class: nginx - enabled: false - host: blog.ghost.tld diff --git a/examples/ghost/docker-compose.yaml b/examples/ghost/docker-compose.yaml deleted file mode 100644 index 67472f7..0000000 --- a/examples/ghost/docker-compose.yaml +++ /dev/null @@ -1,30 +0,0 @@ -version: "3" - -services: - blog: - image: ghost - environment: - # this is OK for local test, but not with Helm - # because the URL depends on Ingress - url: http://ghost.example.localhost - labels: - katenary.io/ports: 2368 - katenary.io/ingress: 2368 - # ... so we declare that "url" is actually - # the ingress host - katenary.io/mapenv: | - url: http://{{ .Values.blog.ingress.host }} - - proxy: - # A simple proxy for localhost - image: quay.io/pathwae/proxy - environment: - CONFIG: | - ghost.example.localhost: - to: http://blog:2368 - ports: - - 80:80 - labels: - # we don't want this in Helm because we will use - # an ingress - katenary.io/ignore: true diff --git a/examples/multidir/chart/README.md b/examples/multidir/chart/README.md new file mode 100644 index 0000000..93ed0ac --- /dev/null +++ b/examples/multidir/chart/README.md @@ -0,0 +1,37 @@ +# multidir + +A Helm chart for multidir + +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +# Standard Helm install +$ helm install my-release multidir + +# To use a custom namespace and force the creation of the namespace +$ helm install my-release --namespace my-namespace --create-namespace multidir + +# To use a custom values file +$ helm install my-release -f my-values.yaml multidir +``` + +See the [Helm documentation](https://helm.sh/docs/intro/using_helm/) for more information on installing and managing the chart. + +## Configuration + +The following table lists the configurable parameters of the multidir chart and their default values. + +| Parameter | Default | +| ---------------------- | -------------- | +| `bar.imagePullPolicy` | `IfNotPresent` | +| `bar.replicas` | `1` | +| `bar.repository.image` | `alpine` | +| `bar.repository.tag` | `` | +| `foo.imagePullPolicy` | `IfNotPresent` | +| `foo.replicas` | `1` | +| `foo.repository.image` | `alpine` | +| `foo.repository.tag` | `` | + + diff --git a/examples/multidir/chart/templates/NOTES.txt b/examples/multidir/chart/templates/NOTES.txt new file mode 100644 index 0000000..3121a00 --- /dev/null +++ b/examples/multidir/chart/templates/NOTES.txt @@ -0,0 +1,27 @@ +Your release is named {{ .Release.Name }}. + +To learn more about the release, try: + + $ helm -n {{ .Release.Namespace }} status {{ .Release.Name }} + $ helm -n {{ .Release.Namespace }} get all {{ .Release.Name }} + +To delete the release, run: + + $ helm -n {{ .Release.Namespace }} delete {{ .Release.Name }} + +You can see this notes again by running: + + $ helm -n {{ .Release.Namespace }} get notes {{ .Release.Name }} + +{{- $count := 0 -}} +{{- range $s, $v := .Values -}} +{{- if and $v $v.ingress -}} +{{- $count = add $count 1 -}} +{{- if eq $count 1 }} + +The ingress list is: +{{ end }} + - {{ $s }}: http://{{ $v.ingress.host }}{{ $v.ingress.path }} +{{- end -}} +{{ end -}} + diff --git a/examples/multidir/chart/templates/_helpers.tpl b/examples/multidir/chart/templates/_helpers.tpl new file mode 100644 index 0000000..a0db3ab --- /dev/null +++ b/examples/multidir/chart/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{- define "multidir.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "multidir.name" -}} +{{- if .Values.nameOverride -}} +{{- .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "multidir.labels" -}} +{{ include "multidir.selectorLabels" .}} +{{ if .Chart.Version -}} +{{ printf "katenary.v3/chart-version: %s" .Chart.Version }} +{{- end }} +{{ if .Chart.AppVersion -}} +{{ printf "katenary.v3/app-version: %s" .Chart.AppVersion }} +{{- end }} +{{- end -}} + +{{- define "multidir.selectorLabels" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{ printf "katenary.v3/name: %s" $name }} +{{ printf "katenary.v3/instance: %s" .Release.Name }} +{{- end -}} diff --git a/examples/multidir/conf/example1.conf b/examples/multidir/conf/example1.conf new file mode 100644 index 0000000..1ce1e1f --- /dev/null +++ b/examples/multidir/conf/example1.conf @@ -0,0 +1 @@ +A file containing configuration here diff --git a/examples/multidir/conf/otherdir/example.conf b/examples/multidir/conf/otherdir/example.conf new file mode 100644 index 0000000..a34c637 --- /dev/null +++ b/examples/multidir/conf/otherdir/example.conf @@ -0,0 +1,2 @@ +variable: foo +example: bar diff --git a/examples/same-pod/README.md b/examples/same-pod/README.md deleted file mode 100644 index e066d7d..0000000 --- a/examples/same-pod/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Make it possible to bind several containers in one pod - -In this example, we need to make nginx and php-fpm to run inside the same "pod". The reason is that we configured FPM to listen an unix socket instead of the 9000 port. - -Because NGinx will need to connect to the unix socket wich is a file, both containers should share the same node and work together. - -So, in the docker-compose file, we need to declare: -- `katenary.io/empty-dirs: socket` where `socket` is the "volume name", this will avoid the creation of a PVC -- `katenary.io/same-pod: http` in `php` container to declare that this will be added in the `containers` section of the `http` deployment - -You can note that we also use `configmap-volumes` to declare our configuration as `configMap`. - -Take a look on [chart/same-pod](chart/same-pod) directory to see the result of the `katenary convert` command. diff --git a/examples/same-pod/chart/same-pod/Chart.yaml b/examples/same-pod/chart/same-pod/Chart.yaml deleted file mode 100644 index 146c029..0000000 --- a/examples/same-pod/chart/same-pod/Chart.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Create on 2022-02-17T11:36:02+01:00 -# Katenary command line: katenary convert --force -apiVersion: v2 -appVersion: 0.0.1 -description: A helm chart for same-pod -name: same-pod -type: application -version: 0.1.0 diff --git a/examples/same-pod/chart/same-pod/templates/NOTES.txt b/examples/same-pod/chart/same-pod/templates/NOTES.txt deleted file mode 100644 index dffd887..0000000 --- a/examples/same-pod/chart/same-pod/templates/NOTES.txt +++ /dev/null @@ -1,8 +0,0 @@ - -Congratulations, - -Your application is now deployed. This may take a while to be up and responding. - -{{ if .Values.http.ingress.enabled -}} -- http is accessible on : http://{{ .Values.http.ingress.host }} -{{- end }} diff --git a/examples/same-pod/chart/same-pod/templates/http.config-nginx-http.configmap.yaml b/examples/same-pod/chart/same-pod/templates/http.config-nginx-http.configmap.yaml deleted file mode 100644 index e090e01..0000000 --- a/examples/same-pod/chart/same-pod/templates/http.config-nginx-http.configmap.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: '{{ .Release.Name }}-config-nginx-http' - labels: - katenary.io/component: "" - katenary.io/project: same-pod - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: 74e67695bfdbb829f15531321e158808018280e0 - katenary.io/version: master-bf44d44 -data: - default.conf: | - upstream _php { - server unix:/sock/fpm.sock; - } - server { - listen 80; - location ~ ^/index\.php(/|$) { - fastcgi_pass _php; - include fastcgi_params; - } - } diff --git a/examples/same-pod/chart/same-pod/templates/http.config-php-php.configmap.yaml b/examples/same-pod/chart/same-pod/templates/http.config-php-php.configmap.yaml deleted file mode 100644 index 99093be..0000000 --- a/examples/same-pod/chart/same-pod/templates/http.config-php-php.configmap.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: '{{ .Release.Name }}-config-php-php' - labels: - katenary.io/component: "" - katenary.io/project: same-pod - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: 74e67695bfdbb829f15531321e158808018280e0 - katenary.io/version: master-bf44d44 -data: - www.conf: | - [www] - user = www-data - group = www-data - - listen = /sock/fpm.sock - - pm = dynamic - pm.max_children = 5 - pm.start_servers = 2 - pm.min_spare_servers = 1 - pm.max_spare_servers = 3 - - access.log = /proc/self/fd/2 - log_limit = 8192 - clear_env = no - catch_workers_output = yes - decorate_workers_output = no diff --git a/examples/same-pod/chart/same-pod/templates/http.deployment.yaml b/examples/same-pod/chart/same-pod/templates/http.deployment.yaml deleted file mode 100644 index f1d86c0..0000000 --- a/examples/same-pod/chart/same-pod/templates/http.deployment.yaml +++ /dev/null @@ -1,52 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: '{{ .Release.Name }}-http' - labels: - katenary.io/component: http - katenary.io/project: same-pod - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: 74e67695bfdbb829f15531321e158808018280e0 - katenary.io/version: master-bf44d44 -spec: - replicas: 1 - selector: - matchLabels: - katenary.io/component: http - katenary.io/release: '{{ .Release.Name }}' - template: - metadata: - labels: - katenary.io/component: http - katenary.io/release: '{{ .Release.Name }}' - spec: - containers: - - name: http - image: '{{ .Values.http.image }}' - ports: - - name: http - containerPort: 80 - volumeMounts: - - mountPath: /sock - name: sock - - mountPath: /etc/nginx/conf.d - name: config-nginx - - name: php - image: '{{ .Values.php.image }}' - volumeMounts: - - mountPath: /sock - name: sock - - mountPath: /usr/local/etc/php-fpm.d/www.conf - name: config-php - subPath: www.conf - volumes: - - emptyDir: {} - name: sock - - configMap: - name: '{{ .Release.Name }}-config-nginx-http' - name: config-nginx - - configMap: - name: '{{ .Release.Name }}-config-php-php' - name: config-php - diff --git a/examples/same-pod/chart/same-pod/templates/http.ingress.yaml b/examples/same-pod/chart/same-pod/templates/http.ingress.yaml deleted file mode 100644 index 220838f..0000000 --- a/examples/same-pod/chart/same-pod/templates/http.ingress.yaml +++ /dev/null @@ -1,34 +0,0 @@ -{{- if .Values.http.ingress.enabled -}} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: '{{ .Release.Name }}-http' - labels: - katenary.io/component: http - katenary.io/project: same-pod - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: 74e67695bfdbb829f15531321e158808018280e0 - katenary.io/version: master-bf44d44 -spec: - {{- if and .Values.http.ingress.class (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} - ingressClassName: '{{ .Values.http.ingress.class }}' - {{- end }} - rules: - - host: '{{ .Values.http.ingress.host }}' - http: - paths: - - path: / - pathType: Prefix - backend: - {{- if semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion }} - service: - name: '{{ .Release.Name }}-http' - port: - number: 80 - {{- else }} - serviceName: '{{ .Release.Name }}-http' - servicePort: 80 - {{- end }} - -{{- end -}} diff --git a/examples/same-pod/chart/same-pod/templates/http.service.yaml b/examples/same-pod/chart/same-pod/templates/http.service.yaml deleted file mode 100644 index 88157dd..0000000 --- a/examples/same-pod/chart/same-pod/templates/http.service.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: '{{ .Release.Name }}-http' - labels: - katenary.io/component: http - katenary.io/project: same-pod - katenary.io/release: '{{ .Release.Name }}' - annotations: - katenary.io/docker-compose-sha1: 74e67695bfdbb829f15531321e158808018280e0 - katenary.io/version: master-bf44d44 -spec: - selector: - katenary.io/component: http - katenary.io/release: '{{ .Release.Name }}' - ports: - - protocol: TCP - port: 80 - targetPort: 80 diff --git a/examples/same-pod/chart/same-pod/values.yaml b/examples/same-pod/chart/same-pod/values.yaml deleted file mode 100644 index 68e0b65..0000000 --- a/examples/same-pod/chart/same-pod/values.yaml +++ /dev/null @@ -1,8 +0,0 @@ -http: - image: nginx:alpine - ingress: - class: nginx - enabled: false - host: http.same-pod.tld -php: - image: php:fpm diff --git a/examples/same-pod/config/nginx/default.conf b/examples/same-pod/config/nginx/default.conf deleted file mode 100644 index f0dbd67..0000000 --- a/examples/same-pod/config/nginx/default.conf +++ /dev/null @@ -1,10 +0,0 @@ -upstream _php { - server unix:/sock/fpm.sock; -} -server { - listen 80; - location ~ ^/index\.php(/|$) { - fastcgi_pass _php; - include fastcgi_params; - } -} diff --git a/examples/same-pod/config/php/www.conf b/examples/same-pod/config/php/www.conf deleted file mode 100644 index 3640421..0000000 --- a/examples/same-pod/config/php/www.conf +++ /dev/null @@ -1,17 +0,0 @@ -[www] -user = www-data -group = www-data - -listen = /sock/fpm.sock - -pm = dynamic -pm.max_children = 5 -pm.start_servers = 2 -pm.min_spare_servers = 1 -pm.max_spare_servers = 3 - -access.log = /proc/self/fd/2 -log_limit = 8192 -clear_env = no -catch_workers_output = yes -decorate_workers_output = no diff --git a/examples/same-pod/docker-compose.yaml b/examples/same-pod/docker-compose.yaml deleted file mode 100644 index a215527..0000000 --- a/examples/same-pod/docker-compose.yaml +++ /dev/null @@ -1,38 +0,0 @@ -version: "3" - -services: - - http: - image: nginx:alpine - ports: - - "8080:80" - volumes: - - "sock:/sock" - - "./config/nginx:/etc/nginx/conf.d:z" - - labels: - # the "sock" volume will need to be shared to the same pod, so let's - # declare that this is not a PVC - katenary.io/empty-dirs: sock - - # use ./config/nginx as a configMap - katenary.io/configmap-volumes: ./config/nginx - - # declare an ingress - katenary.io/ingress: 80 - - php: - image: php:fpm - volumes: - - "sock:/sock" - - "./config/php/www.conf:/usr/local/etc/php-fpm.d/www.conf:z" - labels: - # fpm will need to use a unix socket shared - # with nginx (http service above), so we want here - # make a single pod containing nginx and php - katenary.io/same-pod: http - # use the ./config/php files as a configMap - katenary.io/configmap-volumes: ./config/php/www.conf - -volumes: - sock: diff --git a/examples/shareenv/chart/README.md b/examples/shareenv/chart/README.md new file mode 100644 index 0000000..db87974 --- /dev/null +++ b/examples/shareenv/chart/README.md @@ -0,0 +1,37 @@ +# shareenv + +A Helm chart for shareenv + +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +# Standard Helm install +$ helm install my-release shareenv + +# To use a custom namespace and force the creation of the namespace +$ helm install my-release --namespace my-namespace --create-namespace shareenv + +# To use a custom values file +$ helm install my-release -f my-values.yaml shareenv +``` + +See the [Helm documentation](https://helm.sh/docs/intro/using_helm/) for more information on installing and managing the chart. + +## Configuration + +The following table lists the configurable parameters of the shareenv chart and their default values. + +| Parameter | Default | +| ----------------------- | -------------- | +| `app1.imagePullPolicy` | `IfNotPresent` | +| `app1.replicas` | `1` | +| `app1.repository.image` | `nginx` | +| `app1.repository.tag` | `1` | +| `app2.imagePullPolicy` | `IfNotPresent` | +| `app2.replicas` | `1` | +| `app2.repository.image` | `nginx` | +| `app2.repository.tag` | `1` | + + diff --git a/examples/shareenv/chart/templates/NOTES.txt b/examples/shareenv/chart/templates/NOTES.txt new file mode 100644 index 0000000..3121a00 --- /dev/null +++ b/examples/shareenv/chart/templates/NOTES.txt @@ -0,0 +1,27 @@ +Your release is named {{ .Release.Name }}. + +To learn more about the release, try: + + $ helm -n {{ .Release.Namespace }} status {{ .Release.Name }} + $ helm -n {{ .Release.Namespace }} get all {{ .Release.Name }} + +To delete the release, run: + + $ helm -n {{ .Release.Namespace }} delete {{ .Release.Name }} + +You can see this notes again by running: + + $ helm -n {{ .Release.Namespace }} get notes {{ .Release.Name }} + +{{- $count := 0 -}} +{{- range $s, $v := .Values -}} +{{- if and $v $v.ingress -}} +{{- $count = add $count 1 -}} +{{- if eq $count 1 }} + +The ingress list is: +{{ end }} + - {{ $s }}: http://{{ $v.ingress.host }}{{ $v.ingress.path }} +{{- end -}} +{{ end -}} + diff --git a/examples/shareenv/chart/templates/_helpers.tpl b/examples/shareenv/chart/templates/_helpers.tpl new file mode 100644 index 0000000..e51ea07 --- /dev/null +++ b/examples/shareenv/chart/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{- define "shareenv.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "shareenv.name" -}} +{{- if .Values.nameOverride -}} +{{- .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "shareenv.labels" -}} +{{ include "shareenv.selectorLabels" .}} +{{ if .Chart.Version -}} +{{ printf "katenary.v3/chart-version: %s" .Chart.Version }} +{{- end }} +{{ if .Chart.AppVersion -}} +{{ printf "katenary.v3/app-version: %s" .Chart.AppVersion }} +{{- end }} +{{- end -}} + +{{- define "shareenv.selectorLabels" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{ printf "katenary.v3/name: %s" $name }} +{{ printf "katenary.v3/instance: %s" .Release.Name }} +{{- end -}} diff --git a/examples/somevolumes/chart/README.md b/examples/somevolumes/chart/README.md new file mode 100644 index 0000000..edf4198 --- /dev/null +++ b/examples/somevolumes/chart/README.md @@ -0,0 +1,37 @@ +# somevolumes + +A Helm chart for somevolumes + +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +# Standard Helm install +$ helm install my-release somevolumes + +# To use a custom namespace and force the creation of the namespace +$ helm install my-release --namespace my-namespace --create-namespace somevolumes + +# To use a custom values file +$ helm install my-release -f my-values.yaml somevolumes +``` + +See the [Helm documentation](https://helm.sh/docs/intro/using_helm/) for more information on installing and managing the chart. + +## Configuration + +The following table lists the configurable parameters of the somevolumes chart and their default values. + +| Parameter | Default | +| ----------------------------------------------- | ----------------- | +| `site1.imagePullPolicy` | `IfNotPresent` | +| `site1.persistence.statics.accessMode[0].value` | `ReadWriteOnce` | +| `site1.persistence.statics.enabled` | `true` | +| `site1.persistence.statics.size` | `1Gi` | +| `site1.persistence.statics.storageClass` | `-` | +| `site1.replicas` | `1` | +| `site1.repository.image` | `docker.io/nginx` | +| `site1.repository.tag` | `1` | + + diff --git a/examples/somevolumes/chart/templates/NOTES.txt b/examples/somevolumes/chart/templates/NOTES.txt new file mode 100644 index 0000000..3121a00 --- /dev/null +++ b/examples/somevolumes/chart/templates/NOTES.txt @@ -0,0 +1,27 @@ +Your release is named {{ .Release.Name }}. + +To learn more about the release, try: + + $ helm -n {{ .Release.Namespace }} status {{ .Release.Name }} + $ helm -n {{ .Release.Namespace }} get all {{ .Release.Name }} + +To delete the release, run: + + $ helm -n {{ .Release.Namespace }} delete {{ .Release.Name }} + +You can see this notes again by running: + + $ helm -n {{ .Release.Namespace }} get notes {{ .Release.Name }} + +{{- $count := 0 -}} +{{- range $s, $v := .Values -}} +{{- if and $v $v.ingress -}} +{{- $count = add $count 1 -}} +{{- if eq $count 1 }} + +The ingress list is: +{{ end }} + - {{ $s }}: http://{{ $v.ingress.host }}{{ $v.ingress.path }} +{{- end -}} +{{ end -}} + diff --git a/examples/somevolumes/chart/templates/_helpers.tpl b/examples/somevolumes/chart/templates/_helpers.tpl new file mode 100644 index 0000000..978ffc8 --- /dev/null +++ b/examples/somevolumes/chart/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{- define "somevolumes.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "somevolumes.name" -}} +{{- if .Values.nameOverride -}} +{{- .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "somevolumes.labels" -}} +{{ include "somevolumes.selectorLabels" .}} +{{ if .Chart.Version -}} +{{ printf "katenary.v3/chart-version: %s" .Chart.Version }} +{{- end }} +{{ if .Chart.AppVersion -}} +{{ printf "katenary.v3/app-version: %s" .Chart.AppVersion }} +{{- end }} +{{- end -}} + +{{- define "somevolumes.selectorLabels" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{ printf "katenary.v3/name: %s" $name }} +{{ printf "katenary.v3/instance: %s" .Release.Name }} +{{- end -}} diff --git a/generator/chart.go b/generator/chart.go new file mode 100644 index 0000000..215a0c7 --- /dev/null +++ b/generator/chart.go @@ -0,0 +1,60 @@ +package generator + +// Dependency is a dependency of a chart to other charts. +type Dependency struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Repository string `yaml:"repository"` + Alias string `yaml:"alias,omitempty"` + Values map[string]any `yaml:"-"` // do not export to Chart.yaml +} + +// ChartTemplate is a template of a chart. It contains the content of the template and the name of the service. +// This is used internally to generate the templates. +// +// TODO: maybe we can set it private. +type ChartTemplate struct { + Content []byte + Servicename string +} + +// HelmChart is a Helm Chart representation. It contains all the +// tempaltes, values, versions, helpers... +type HelmChart struct { + Name string `yaml:"name"` + ApiVersion string `yaml:"apiVersion"` + Version string `yaml:"version"` + AppVersion string `yaml:"appVersion"` + Description string `yaml:"description"` + Dependencies []Dependency `yaml:"dependencies,omitempty"` + Templates map[string]*ChartTemplate `yaml:"-"` // do not export to yaml + Helper string `yaml:"-"` // do not export to yaml + Values map[string]any `yaml:"-"` // do not export to yaml + VolumeMounts map[string]any `yaml:"-"` // do not export to yaml + composeHash *string `yaml:"-"` // do not export to yaml +} + +// NewChart creates a new empty chart with the given name. +func NewChart(name string) *HelmChart { + return &HelmChart{ + Name: name, + Templates: make(map[string]*ChartTemplate, 0), + Description: "A Helm chart for " + name, + ApiVersion: "v2", + Version: "", + AppVersion: "", // set to 0.1.0 by default if no "main-app" label is found + Values: map[string]any{ + "pullSecrets": []string{}, + }, + } +} + +// ConvertOptions are the options to convert a compose project to a helm chart. +type ConvertOptions struct { + Force bool // Force the chart directory deletion if it already exists. + OutputDir string // The output directory of the chart. + Profiles []string // Profile to use for the conversion. + HelmUpdate bool // If true, the "helm dep update" command will be run after the chart generation. + AppVersion *string // Set the chart "appVersion" field. If nil, the version will be set to 0.1.0. + ChartVersion string // Set the chart "version" field. +} diff --git a/generator/configMap.go b/generator/configMap.go new file mode 100644 index 0000000..4ab4625 --- /dev/null +++ b/generator/configMap.go @@ -0,0 +1,224 @@ +package generator + +import ( + "katenary/utils" + "log" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/compose-spec/compose-go/types" + goyaml "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// only used to check interface implementation +var ( + _ DataMap = (*ConfigMap)(nil) + _ Yaml = (*ConfigMap)(nil) +) + +// NewFileMap creates a new DataMap from a compose service. The appName is the name of the application taken from the project name. +func NewFileMap(service types.ServiceConfig, appName string, kind string) DataMap { + switch kind { + case "configmap": + return NewConfigMap(service, appName) + default: + log.Fatalf("Unknown filemap kind: %s", kind) + } + return nil +} + +// FileMapUsage is the usage of the filemap. +type FileMapUsage uint8 + +// FileMapUsage constants. +const ( + FileMapUsageConfigMap FileMapUsage = iota // pure configmap for key:values. + FileMapUsageFiles // files in a configmap. +) + +// ConfigMap is a kubernetes ConfigMap. +// Implements the DataMap interface. +type ConfigMap struct { + *corev1.ConfigMap + service *types.ServiceConfig + usage FileMapUsage + path string +} + +// NewConfigMap creates a new ConfigMap from a compose service. The appName is the name of the application taken from the project name. +// The ConfigMap is filled by environment variables and labels "map-env". +func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { + + done := map[string]bool{} + drop := map[string]bool{} + secrets := []string{} + labelValues := []string{} + + cm := &ConfigMap{ + service: &service, + ConfigMap: &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + Kind: "ConfigMap", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Data: make(map[string]string), + }, + } + + // get the secrets from the labels + if v, ok := service.Labels[LABEL_SECRETS]; ok { + err := yaml.Unmarshal([]byte(v), &secrets) + if err != nil { + log.Fatal(err) + } + // drop the secrets from the environment + for _, secret := range secrets { + drop[secret] = true + } + } + // get the label values from the labels + varDescriptons := utils.GetValuesFromLabel(service, LABEL_VALUES) + for value := range varDescriptons { + labelValues = append(labelValues, value) + } + + // change the environment variables to the values defined in the values.yaml + for _, value := range labelValues { + if _, ok := service.Environment[value]; !ok { + done[value] = true + continue + } + //val := `{{ tpl .Values.` + service.Name + `.environment.` + value + ` $ }}` + val := utils.TplValue(service.Name, "environment."+value) + service.Environment[value] = &val + } + + // remove the variables that are already defined in the environment + if l, ok := service.Labels[LABEL_MAP_ENV]; ok { + envmap := make(map[string]string) + if err := goyaml.Unmarshal([]byte(l), &envmap); err != nil { + log.Fatal("Error parsing map-env", err) + } + for key, value := range envmap { + cm.AddData(key, strings.ReplaceAll(value, "__APP__", appName)) + done[key] = true + } + } + for key, env := range service.Environment { + if _, ok := done[key]; ok { + continue + } + if _, ok := drop[key]; ok { + continue + } + cm.AddData(key, *env) + } + + return cm +} + +// NewConfigMapFromFiles creates a new ConfigMap from a compose service. This path is the path to the +// file or directory. If the path is a directory, all files in the directory are added to the ConfigMap. +// Each subdirectory are ignored. Note that the Generate() function will create the subdirectories ConfigMaps. +func NewConfigMapFromFiles(service types.ServiceConfig, appName string, path string) *ConfigMap { + normalized := path + normalized = strings.TrimLeft(normalized, ".") + normalized = strings.TrimLeft(normalized, "/") + normalized = regexp.MustCompile(`[^a-zA-Z0-9-]+`).ReplaceAllString(normalized, "-") + + cm := &ConfigMap{ + path: path, + service: &service, + usage: FileMapUsageFiles, + ConfigMap: &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + Kind: "ConfigMap", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName) + "-" + normalized, + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Data: make(map[string]string), + }, + } + // cumulate the path to the WorkingDir + path = filepath.Join(service.WorkingDir, path) + path = filepath.Clean(path) + cm.AppendDir(path) + return cm +} + +// SetData sets the data of the configmap. It replaces the entire data. +func (c *ConfigMap) SetData(data map[string]string) { + c.Data = data +} + +// AddData adds a key value pair to the configmap. Append or overwrite the value if the key already exists. +func (c *ConfigMap) AddData(key string, value string) { + c.Data[key] = value +} + +// AddFile adds files from given path to the configmap. It is not recursive, to add all files in a directory, +// you need to call this function for each subdirectory. +func (c *ConfigMap) AppendDir(path string) { + // read all files in the path and add them to the configmap + stat, err := os.Stat(path) + if err != nil { + log.Fatalf("Path %s does not exist\n", path) + + } + // recursively read all files in the path and add them to the configmap + if stat.IsDir() { + files, err := os.ReadDir(path) + if err != nil { + log.Fatal(err) + } + for _, file := range files { + if file.IsDir() { + continue + } + path := filepath.Join(path, file.Name()) + content, err := os.ReadFile(path) + if err != nil { + log.Fatal(err) + } + // remove the path from the file + filename := filepath.Base(path) + c.AddData(filename, string(content)) + } + } else { + // add the file to the configmap + content, err := os.ReadFile(path) + if err != nil { + log.Fatal(err) + } + c.AddData(filepath.Base(path), string(content)) + } +} + +// Filename returns the filename of the configmap. If the configmap is used for files, the filename contains the path. +func (c *ConfigMap) Filename() string { + switch c.usage { + case FileMapUsageFiles: + return filepath.Join(c.service.Name, "statics", c.path, "configmap.yaml") + default: + return c.service.Name + ".configmap.yaml" + } +} + +// Yaml returns the yaml representation of the configmap +func (c *ConfigMap) Yaml() ([]byte, error) { + return yaml.Marshal(c) +} diff --git a/generator/container.go b/generator/container.go deleted file mode 100644 index ef15b6f..0000000 --- a/generator/container.go +++ /dev/null @@ -1,200 +0,0 @@ -package generator - -import ( - "fmt" - "katenary/helm" - "katenary/logger" - "log" - "os" - "strconv" - "strings" - - "github.com/compose-spec/compose-go/types" -) - -// 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 { - - buildCrontab(deployName, deployment, s, fileGeneratorChan) - - container := helm.NewContainer(containerName, s.Image, s.Environment, s.Labels) - - applyEnvMapLabel(s, container) - if secretFile := setSecretVar(containerName, s, container); secretFile != nil { - fileGeneratorChan <- secretFile - container.EnvFrom = append(container.EnvFrom, map[string]map[string]string{ - "secretRef": { - "name": secretFile.Metadata().Name, - }, - }) - } - setEnvToValues(containerName, s, container) - prepareContainer(container, s, containerName) - prepareEnvFromFiles(deployName, s, container, fileGeneratorChan) - - // add the container in deployment - if deployment.Spec.Template.Spec.Containers == nil { - deployment.Spec.Template.Spec.Containers = make([]*helm.Container, 0) - } - deployment.Spec.Template.Spec.Containers = append( - deployment.Spec.Template.Spec.Containers, - container, - ) - - // add the volumes - if deployment.Spec.Template.Spec.Volumes == nil { - deployment.Spec.Template.Spec.Volumes = make([]map[string]interface{}, 0) - } - // manage LABEL_VOLUMEFROM - addVolumeFrom(deployment, container, s) - // and then we can add other volumes - deployment.Spec.Template.Spec.Volumes = append( - deployment.Spec.Template.Spec.Volumes, - prepareVolumes(deployName, containerName, s, container, fileGeneratorChan)..., - ) - - // add init containers - if deployment.Spec.Template.Spec.InitContainers == nil { - deployment.Spec.Template.Spec.InitContainers = make([]*helm.Container, 0) - } - deployment.Spec.Template.Spec.InitContainers = append( - deployment.Spec.Template.Spec.InitContainers, - 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 -} - -// 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) - } - - // Get the image tag - imageParts := strings.Split(service.Image, ":") - tag := "" - if len(imageParts) == 2 { - container.Image = imageParts[0] - tag = imageParts[1] - } - - vtag := ".Values." + servicename + ".repository.tag" - container.Image = `{{ .Values.` + servicename + `.repository.image }}` + - `{{ if ne ` + vtag + ` "" }}:{{ ` + vtag + ` }}{{ end }}` - container.Command = service.Command - AddValues(servicename, map[string]EnvVal{ - "repository": map[string]EnvVal{ - "image": imageParts[0], - "tag": tag, - }, - }) - prepareProbes(servicename, service, container) - generateContainerPorts(service, servicename, container) -} - -// 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, - }) - } -} - -// 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) - 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 - } - 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 -} diff --git a/generator/converter.go b/generator/converter.go new file mode 100644 index 0000000..9af18ff --- /dev/null +++ b/generator/converter.go @@ -0,0 +1,638 @@ +package generator + +import ( + "bytes" + "errors" + "fmt" + "katenary/generator/extrafiles" + "katenary/parser" + "katenary/utils" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/compose-spec/compose-go/types" + goyaml "gopkg.in/yaml.v3" +) + +const headerHelp = `# This file is autogenerated by katenary +# +# DO NOT EDIT IT BY HAND UNLESS YOU KNOW WHAT YOU ARE DOING +# +# If you want to change the content of this file, you should edit the +# compose file and run katenary again. +# If you need to override some values, you can do it in a override file +# and use the -f flag to specify it when running the helm command. + + +` + +// Convert a compose (docker, podman...) project to a helm chart. +// It calls Generate() to generate the chart and then write it to the disk. +func Convert(config ConvertOptions, dockerComposeFile ...string) { + + var ( + templateDir = filepath.Join(config.OutputDir, "templates") + helpersPath = filepath.Join(config.OutputDir, "templates", "_helpers.tpl") + chartPath = filepath.Join(config.OutputDir, "Chart.yaml") + valuesPath = filepath.Join(config.OutputDir, "values.yaml") + readmePath = filepath.Join(config.OutputDir, "README.md") + notesPath = filepath.Join(templateDir, "NOTES.txt") + ) + + // the current working directory is the directory + currentDir, _ := os.Getwd() + // go to the root of the project + if err := os.Chdir(filepath.Dir(dockerComposeFile[0])); err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + defer os.Chdir(currentDir) // after the generation, go back to the original directory + + // repove the directory part of the docker-compose files + for i, f := range dockerComposeFile { + dockerComposeFile[i] = filepath.Base(f) + } + + // parse the compose files + project, err := parser.Parse(config.Profiles, dockerComposeFile...) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // check older version of labels + if err := checkOldLabels(project); err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + + if !config.Force { + // check if the chart directory exists + // if yes, prevent the user from overwriting it and ask for confirmation + if _, err := os.Stat(config.OutputDir); err == nil { + fmt.Print(utils.IconWarning, " The chart directory "+config.OutputDir+" already exists, do you want to overwrite it? [y/N] ") + var answer string + fmt.Scanln(&answer) + if strings.ToLower(answer) != "y" { + fmt.Println("Aborting") + os.Exit(126) // 126 is the exit code for "Command invoked cannot execute" + } + } + fmt.Println() // clean line + } + + // Build the objects ! + chart, err := Generate(project) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // if the app version is set from the command line, use it + if config.AppVersion != nil { + chart.AppVersion = *config.AppVersion + } + chart.Version = config.ChartVersion + + // remove the chart directory if it exists + os.RemoveAll(config.OutputDir) + + // create the chart directory + if err := os.MkdirAll(templateDir, 0755); err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + + for name, template := range chart.Templates { + t := template.Content + t = removeNewlinesInsideBrackets(t) + t = removeUnwantedLines(t) + t = addModeline(t) + + kind := utils.GetKind(name) + var icon utils.Icon + switch kind { + case "deployment": + icon = utils.IconPackage + case "service": + icon = utils.IconPlug + case "ingress": + icon = utils.IconWorld + case "volumeclaim": + icon = utils.IconCabinet + case "configmap": + icon = utils.IconConfig + case "secret": + icon = utils.IconSecret + default: + icon = utils.IconInfo + } + + servicename := template.Servicename + if err := os.MkdirAll(filepath.Join(templateDir, servicename), 0755); err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + fmt.Println(icon, "Creating", kind, servicename) + // if the name is a path, create the directory + if strings.Contains(name, string(filepath.Separator)) { + name = filepath.Join(templateDir, name) + err := os.MkdirAll(filepath.Dir(name), 0755) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + } else { + // remove the serivce name from the template name + name = strings.Replace(name, servicename+".", "", 1) + name = filepath.Join(templateDir, servicename, name) + } + f, err := os.Create(name) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + + f.Write(t) + f.Close() + } + + // calculate the sha1 hash of the services + + buf := bytes.NewBuffer(nil) + encoder := goyaml.NewEncoder(buf) + encoder.SetIndent(2) + if err := encoder.Encode(chart); err != nil { + fmt.Println(err) + os.Exit(1) + } + + yamlChart := buf.Bytes() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + // concat chart adding a comment with hash of services on top + yamlChart = append([]byte(fmt.Sprintf("# compose hash (sha1): %s\n", *chart.composeHash)), yamlChart...) + // add the list of compose files + files := []string{} + for _, file := range project.ComposeFiles { + base := filepath.Base(file) + files = append(files, base) + } + yamlChart = append([]byte(fmt.Sprintf("# compose files: %s\n", strings.Join(files, ", "))), yamlChart...) + // add generated date + yamlChart = append([]byte(fmt.Sprintf("# generated at: %s\n", time.Now().Format(time.RFC3339))), yamlChart...) + + // document Chart.yaml file + yamlChart = addChartDoc(yamlChart, project) + + f, err := os.Create(chartPath) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + f.Write(yamlChart) + f.Close() + + buf.Reset() + encoder = goyaml.NewEncoder(buf) + encoder.SetIndent(2) + if err = encoder.Encode(&chart.Values); err != nil { + fmt.Println(err) + os.Exit(1) + } + values := buf.Bytes() + values = addDescriptions(values, *project) + values = addDependencyDescription(values, chart.Dependencies) + values = addCommentsToValues(values) + values = addStorageClassHelp(values) + values = addImagePullSecretsHelp(values) + values = addImagePullPolicyHelp(values) + values = addVariablesDoc(values, project) + values = addMainTagAppDoc(values, project) + values = append([]byte(headerHelp), values...) + + f, err = os.Create(valuesPath) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + f.Write(values) + f.Close() + + f, err = os.Create(helpersPath) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + f.Write([]byte(chart.Helper)) + f.Close() + + readme := extrafiles.ReadMeFile(chart.Name, chart.Description, chart.Values) + f, err = os.Create(readmePath) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + f.Write([]byte(readme)) + f.Close() + + notes := extrafiles.NotesFile() + f, err = os.Create(notesPath) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + f.Write([]byte(notes)) + f.Close() + + if config.HelmUpdate { + if err := helmUpdate(config); err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } else if err := helmLint(config); err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } else { + fmt.Println(utils.IconSuccess, "Helm chart created successfully") + } + } +} + +const ingressClassHelp = `# Default value for ingress.class annotation +# class: "-" +# If the value is "-", controller will not set ingressClassName +# If the value is "", Ingress will be set to an empty string, so +# controller will use the default value for ingressClass +# If the value is specified, controller will set the named class e.g. "nginx" +# More info: https://kubernetes.io/docs/concepts/services-networking/ingress/#the-ingress-resource +` + +func addCommentsToValues(values []byte) []byte { + lines := strings.Split(string(values), "\n") + for i, line := range lines { + if strings.Contains(line, "ingress:") { + spaces := utils.CountStartingSpaces(line) + spacesString := strings.Repeat(" ", spaces) + // indent ingressClassHelper comment + ingressClassHelp := strings.ReplaceAll(ingressClassHelp, "\n", "\n"+spacesString) + ingressClassHelp = strings.TrimRight(ingressClassHelp, " ") + ingressClassHelp = spacesString + ingressClassHelp + lines[i] = ingressClassHelp + line + } + } + return []byte(strings.Join(lines, "\n")) +} + +const storageClassHelp = `# Storage class to use for PVCs +# storageClass: "-" means use default +# storageClass: "" means do not specify +# storageClass: "foo" means use that storageClass +# More info: https://kubernetes.io/docs/concepts/storage/storage-classes/ +` + +// addStorageClassHelp adds a comment to the values.yaml file to explain how to +// use the storageClass option. +func addStorageClassHelp(values []byte) []byte { + lines := strings.Split(string(values), "\n") + for i, line := range lines { + if strings.Contains(line, "storageClass:") { + spaces := utils.CountStartingSpaces(line) + spacesString := strings.Repeat(" ", spaces) + // indent ingressClassHelper comment + storageClassHelp := strings.ReplaceAll(storageClassHelp, "\n", "\n"+spacesString) + storageClassHelp = strings.TrimRight(storageClassHelp, " ") + storageClassHelp = spacesString + storageClassHelp + lines[i] = storageClassHelp + line + } + } + return []byte(strings.Join(lines, "\n")) +} + +// addModeline adds a modeline to the values.yaml file to make sure that vim +// will use the correct syntax highlighting. +func addModeline(values []byte) []byte { + modeline := "# vi" + "m: ft=gotmpl.yaml" + + // if the values ends by `{{- end }}` we need to add the modeline before + lines := strings.Split(string(values), "\n") + + if lines[len(lines)-1] == "{{- end }}" || lines[len(lines)-1] == "{{- end -}}" { + lines = lines[:len(lines)-1] + lines = append(lines, modeline, "{{- end }}") + return []byte(strings.Join(lines, "\n")) + } + + return append(values, []byte(modeline)...) +} + +// addDescriptions adds the description from the label to the values.yaml file on top +// of the service definition. +func addDescriptions(values []byte, project types.Project) []byte { + for _, service := range project.Services { + if description, ok := service.Labels[LABEL_DESCRIPTION]; ok { + // set it as comment + description = "\n# " + strings.ReplaceAll(description, "\n", "\n# ") + + values = regexp.MustCompile( + `(?m)^`+service.Name+`:$`, + ).ReplaceAll(values, []byte(description+"\n"+service.Name+":")) + } else { + // set it as comment + description = "\n# " + service.Name + " configuration" + + values = regexp.MustCompile( + `(?m)^`+service.Name+`:$`, + ).ReplaceAll( + values, + []byte(description+"\n"+service.Name+":"), + ) + } + } + return values +} + +func addDependencyDescription(values []byte, dependencies []Dependency) []byte { + for _, d := range dependencies { + name := d.Name + if d.Alias != "" { + name = d.Alias + } + + values = regexp.MustCompile( + `(?m)^`+name+`:$`, + ).ReplaceAll( + values, + []byte("\n# "+d.Name+" helm dependency configuration\n"+name+":"), + ) + } + return values +} + +const imagePullSecretHelp = ` +# imagePullSecrets allows you to specify a name of an image pull secret. +# You must provide a list of object with the name field set to the name of the +# e.g. +# pullSecrets: +# - name: regcred +# You are, for now, repsonsible for creating the secret. +# More info: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +` + +func addImagePullSecretsHelp(values []byte) []byte { + // add imagePullSecrets help + lines := strings.Split(string(values), "\n") + for i, line := range lines { + if strings.Contains(line, "pullSecrets:") { + spaces := utils.CountStartingSpaces(line) + spacesString := strings.Repeat(" ", spaces) + // indent imagePullSecretHelp comment + imagePullSecretHelp := strings.ReplaceAll(imagePullSecretHelp, "\n", "\n"+spacesString) + imagePullSecretHelp = strings.TrimRight(imagePullSecretHelp, " ") + imagePullSecretHelp = spacesString + imagePullSecretHelp + lines[i] = imagePullSecretHelp + line + } + } + return []byte(strings.Join(lines, "\n")) +} + +func addChartDoc(values []byte, project *types.Project) []byte { + chartDoc := fmt.Sprintf(`# This is the main values.yaml file for the %s chart. +# More information can be found in the chart's README.md file. +# +`, project.Name) + + lines := strings.Split(string(values), "\n") + for i, line := range lines { + if regexp.MustCompile(`(?m)^name:`).MatchString(line) { + doc := fmt.Sprintf("\n# Name of the chart (required), basically the name of the project.\n") + lines[i] = doc + line + } else if regexp.MustCompile(`(?m)^version:`).MatchString(line) { + doc := fmt.Sprintf("\n# Version of the chart (required)\n") + lines[i] = doc + line + } else if strings.Contains(line, "appVersion:") { + spaces := utils.CountStartingSpaces(line) + doc := fmt.Sprintf( + "\n%s# Version of the application (required).\n%s# This should be the main application version.\n", + strings.Repeat(" ", spaces), + strings.Repeat(" ", spaces), + ) + lines[i] = doc + line + } else if strings.Contains(line, "dependencies:") { + spaces := utils.CountStartingSpaces(line) + doc := fmt.Sprintf("\n"+ + "%s# Dependencies are external charts that this chart will depend on.\n"+ + "%s# More information can be found in the chart's README.md file.\n", + strings.Repeat(" ", spaces), + strings.Repeat(" ", spaces), + ) + lines[i] = doc + line + } + } + return []byte(chartDoc + strings.Join(lines, "\n")) + +} + +const imagePullPolicyHelp = `# imagePullPolicy allows you to specify a policy to cache or always pull an image. +# You must provide a string value with one of the following values: +# - Always -> will always pull the image +# - Never -> will never pull the image, the image should be present on the node +# - IfNotPresent -> will pull the image only if it is not present on the node +# More info: https://kubernetes.io/docs/concepts/containers/images/#updating-images +` + +func addImagePullPolicyHelp(values []byte) []byte { + // add imagePullPolicy help + lines := strings.Split(string(values), "\n") + for i, line := range lines { + if strings.Contains(line, "imagePullPolicy:") { + spaces := utils.CountStartingSpaces(line) + spacesString := strings.Repeat(" ", spaces) + // indent imagePullPolicyHelp comment + imagePullPolicyHelp := strings.ReplaceAll(imagePullPolicyHelp, "\n", "\n"+spacesString) + imagePullPolicyHelp = strings.TrimRight(imagePullPolicyHelp, " ") + imagePullPolicyHelp = spacesString + imagePullPolicyHelp + lines[i] = imagePullPolicyHelp + line + } + } + return []byte(strings.Join(lines, "\n")) +} + +func addVariablesDoc(values []byte, project *types.Project) []byte { + + lines := strings.Split(string(values), "\n") + + currentService := "" + for _, service := range project.Services { + variables := utils.GetValuesFromLabel(service, LABEL_VALUES) + for i, line := range lines { + if regexp.MustCompile(`(?m)^` + service.Name + `:`).MatchString(line) { + currentService = service.Name + } + for varname, variable := range variables { + if variable == nil { + continue + } + spaces := utils.CountStartingSpaces(line) + if regexp.MustCompile(`(?m)\s*`+varname+`:`).MatchString(line) && currentService == service.Name { + + // add # to the beginning of the Description + doc := strings.ReplaceAll("\n"+variable.Description, "\n", "\n"+strings.Repeat(" ", spaces)+"# ") + doc = strings.TrimRight(doc, " ") + doc += "\n" + line + + lines[i] = doc + } + } + } + } + return []byte(strings.Join(lines, "\n")) +} + +const mainTagAppDoc = `This is the version of the main application. +Leave it to blank to use the Chart "AppVersion" value.` + +func addMainTagAppDoc(values []byte, project *types.Project) []byte { + lines := strings.Split(string(values), "\n") + + for _, service := range project.Services { + inService := false + inRegistry := false + // read the label LabelMainApp + if v, ok := service.Labels[LABEL_MAIN_APP]; !ok { + continue + } else if v == "false" || v == "no" || v == "0" { + continue + } else { + fmt.Printf("%s Adding main tag app doc %s\n", utils.IconConfig, service.Name) + } + + for i, line := range lines { + if regexp.MustCompile(`^` + service.Name + `:`).MatchString(line) { + inService = true + } + if inService && regexp.MustCompile(`^\s*repository:.*`).MatchString(line) { + inRegistry = true + } + if inService && inRegistry { + if regexp.MustCompile(`^\s*tag: .*`).MatchString(line) { + spaces := utils.CountStartingSpaces(line) + doc := strings.ReplaceAll(mainTagAppDoc, "\n", "\n"+strings.Repeat(" ", spaces)+"# ") + doc = strings.Repeat(" ", spaces) + "# " + doc + + lines[i] = doc + "\n" + line + "\n" + break + } + } + } + } + + return []byte(strings.Join(lines, "\n")) +} + +func removeNewlinesInsideBrackets(values []byte) []byte { + re, err := regexp.Compile(`(?s)\{\{(.*?)\}\}`) + if err != nil { + log.Fatal(err) + } + return re.ReplaceAllFunc(values, func(b []byte) []byte { + // get the first match + matches := re.FindSubmatch(b) + replacement := bytes.ReplaceAll(matches[1], []byte("\n"), []byte(" ")) + // remove repeated spaces + replacement = regexp.MustCompile(`\s+`).ReplaceAll(replacement, []byte(" ")) + // remove newlines inside brackets + return bytes.ReplaceAll(b, matches[1], replacement) + + }) +} + +var unwantedLines = []string{ + "creationTimestamp:", + "status:", +} + +func removeUnwantedLines(values []byte) []byte { + lines := strings.Split(string(values), "\n") + output := []string{} + for _, line := range lines { + next := false + for _, unwanted := range unwantedLines { + if strings.Contains(line, unwanted) { + next = true + } + } + if !next { + output = append(output, line) + } + } + return []byte(strings.Join(output, "\n")) +} + +// check if the project makes use of older labels (kanetary.[^v3]) +func checkOldLabels(project *types.Project) error { + badServices := make([]string, 0) + for _, service := range project.Services { + for label := range service.Labels { + if strings.Contains(label, "katenary.") && !strings.Contains(label, KATENARY_PREFIX) { + badServices = append(badServices, fmt.Sprintf("- %s: %s", service.Name, label)) + } + } + } + if len(badServices) > 0 { + message := fmt.Sprintf(` Old labels detected in project "%s". + + The current version of katenary uses labels with the prefix "%s" which are not compatible with previous versions. + Your project is not compatible with this version. + + Please upgrade your labels to follow the current version + + Services to upgrade: +%s`, + project.Name, + KATENARY_PREFIX[0:len(KATENARY_PREFIX)-1], + strings.Join(badServices, "\n"), + ) + + return errors.New(utils.WordWrap(message, 80)) + + } + return nil +} + +func helmUpdate(config ConvertOptions) error { + + // lookup for "helm" binary + fmt.Println(utils.IconInfo, "Updating helm dependencies...") + helm, err := exec.LookPath("helm") + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + // run "helm dependency update" + cmd := exec.Command(helm, "dependency", "update", config.OutputDir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func helmLint(config ConvertOptions) error { + + fmt.Println(utils.IconInfo, "Linting...") + helm, err := exec.LookPath("helm") + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + cmd := exec.Command(helm, "lint", config.OutputDir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + +} diff --git a/generator/cronJob.go b/generator/cronJob.go new file mode 100644 index 0000000..91e409c --- /dev/null +++ b/generator/cronJob.go @@ -0,0 +1,133 @@ +package generator + +import ( + "katenary/utils" + "log" + "strings" + + "github.com/compose-spec/compose-go/types" + goyaml "gopkg.in/yaml.v3" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// only used to check interface implementation +var ( + _ Yaml = (*CronJob)(nil) +) + +// CronJob is a kubernetes CronJob. +type CronJob struct { + *batchv1.CronJob + service *types.ServiceConfig +} + +// NewCronJob creates a new CronJob from a compose service. The appName is the name of the application taken from the project name. +func NewCronJob(service types.ServiceConfig, chart *HelmChart, appName string) (*CronJob, *RBAC) { + var labels, ok = service.Labels[LABEL_CRONJOB] + if !ok { + return nil, nil + } + mapping := struct { + Image string `yaml:"image,omitempty"` + Command string `yaml:"command"` + Schedule string `yaml:"schedule"` + Rbac bool `yaml:"rbac"` + }{ + Image: "", + Command: "", + Schedule: "", + Rbac: false, + } + if err := goyaml.Unmarshal([]byte(labels), &mapping); err != nil { + log.Fatalf("Error parsing cronjob labels: %s", err) + return nil, nil + } + + if _, ok := chart.Values[service.Name]; !ok { + chart.Values[service.Name] = NewValue(service, false) + } + if chart.Values[service.Name].(*Value).CronJob == nil { + chart.Values[service.Name].(*Value).CronJob = &CronJobValue{} + } + chart.Values[service.Name].(*Value).CronJob.Schedule = mapping.Schedule + chart.Values[service.Name].(*Value).CronJob.ImagePullPolicy = "IfNotPresent" + chart.Values[service.Name].(*Value).CronJob.Environment = map[string]any{} + + image, tag := mapping.Image, "" + if image == "" { // if image is not set, use the image from the service + image = service.Image + } + + if strings.Contains(image, ":") { + image = strings.Split(service.Image, ":")[0] + tag = strings.Split(service.Image, ":")[1] + } + + chart.Values[service.Name].(*Value).CronJob.Repository = &RepositoryValue{ + Image: image, + Tag: tag, + } + + cronjob := &CronJob{ + CronJob: &batchv1.CronJob{ + TypeMeta: metav1.TypeMeta{ + Kind: "CronJob", + APIVersion: "batch/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "{{ .Values." + service.Name + ".cronjob.schedule }}", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "cronjob", + Image: "{{ .Values." + service.Name + ".cronjob.repository.image }}:{{ default .Values." + service.Name + ".cronjob.repository.tag \"latest\" }}", + Command: []string{ + "sh", + "-c", + mapping.Command, + }, + }, + }, + }, + }, + }, + }, + }, + }, + service: &service, + } + + var rbac *RBAC + if mapping.Rbac { + rbac = NewRBAC(service, appName) + // add the service account to the cronjob + cronjob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName = utils.TplName(service.Name, appName) + } + + return cronjob, rbac +} + +// Filename returns the filename of the cronjob. +// +// Implements the Yaml interface. +func (c *CronJob) Filename() string { + return c.service.Name + ".cronjob.yaml" +} + +// Yaml returns the yaml representation of the cronjob. +// +// Implements the Yaml interface. +func (c *CronJob) Yaml() ([]byte, error) { + return yaml.Marshal(c) +} diff --git a/generator/crontabs.go b/generator/crontabs.go deleted file mode 100644 index efacb79..0000000 --- a/generator/crontabs.go +++ /dev/null @@ -1,110 +0,0 @@ -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/deployment.go b/generator/deployment.go index 0cbb727..627bfd3 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -1,70 +1,569 @@ package generator import ( - "katenary/helm" - "katenary/logger" + "fmt" + "katenary/utils" + "log" + "os" + "path/filepath" + "regexp" + "strings" + "time" "github.com/compose-spec/compose-go/types" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" ) -// This function will try to yied deployment and services based on a service from the compose file structure. -func buildDeployment(name string, s *types.ServiceConfig, linked map[string]types.ServiceConfig, fileGeneratorChan HelmFileGenerator) { +var _ Yaml = (*Deployment)(nil) - logger.Magenta(ICON_PACKAGE+" Generating deployment for ", name) - deployment := helm.NewDeployment(name) +// Deployment is a kubernetes Deployment. +type Deployment struct { + *appsv1.Deployment `yaml:",inline"` + chart *HelmChart `yaml:"-"` + configMaps map[string]bool `yaml:"-"` + service *types.ServiceConfig `yaml:"-"` + defaultTag string `yaml:"-"` + isMainApp bool `yaml:"-"` +} - newContainerForDeployment(name, name, deployment, s, fileGeneratorChan) +// NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name. +// It also creates the Values map that will be used to create the values.yaml file. +func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { - // Add selectors - selectors := buildSelector(name, s) - selectors[helm.K+"/resource"] = "deployment" - deployment.Spec.Selector = map[string]interface{}{ - "matchLabels": selectors, + ports := []corev1.ContainerPort{} + for _, port := range service.Ports { + ports = append(ports, corev1.ContainerPort{ + ContainerPort: int32(port.Target), + }) } - deployment.Spec.Template.Metadata.Labels = selectors - // Now, the linked services (same pod) - for lname, link := range linked { - newContainerForDeployment(name, lname, deployment, &link, fileGeneratorChan) - // 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...) + isMainApp := false + if mainLabel, ok := service.Labels[LABEL_MAIN_APP]; ok { + main := strings.ToLower(mainLabel) + isMainApp = main == "true" || main == "yes" || main == "1" + } + + defaultTag := `default "latest"` + if isMainApp { + defaultTag = `default .Chart.AppVersion "latest"` + } + + chart.Values[service.Name] = NewValue(service, isMainApp) + appName := chart.Name + + dep := &Deployment{ + isMainApp: isMainApp, + defaultTag: defaultTag, + service: &service, + chart: chart, + Deployment: &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: "apps/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: utils.Int32Ptr(1), + Selector: &metav1.LabelSelector{ + MatchLabels: GetMatchLabels(service.Name, appName), + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: GetMatchLabels(service.Name, appName), + }, + }, + }, + }, + configMaps: map[string]bool{}, + } + + // add containers + dep.AddContainer(service) + + // add volumes + dep.AddVolumes(service, appName) + + if service.Environment != nil { + dep.SetEnvFrom(service, appName) + } + + return dep +} + +// DependsOn adds a initContainer to the deployment that will wait for the service to be up. +func (d *Deployment) DependsOn(to *Deployment) error { + // Add a initContainer with busybox:latest using netcat to check if the service is up + // it will wait until the service responds to all ports + for _, container := range to.Spec.Template.Spec.Containers { + commands := []string{} + for _, port := range container.Ports { + command := fmt.Sprintf("until nc -z %s %d; do\n sleep 1;\ndone", to.Name, port.ContainerPort) + commands = append(commands, command) + } + + command := []string{"/bin/sh", "-c", strings.Join(commands, "\n")} + d.Spec.Template.Spec.InitContainers = append(d.Spec.Template.Spec.InitContainers, corev1.Container{ + Name: "wait-for-" + to.service.Name, + Image: "busybox:latest", + Command: command, + }) + } + + return nil +} + +// AddContainer adds a container to the deployment. +func (d *Deployment) AddContainer(service types.ServiceConfig) { + ports := []corev1.ContainerPort{} + + for _, port := range service.Ports { + name := utils.GetServiceNameByPort(int(port.Target)) + if name == "" { + utils.Warn("Port name not found for port ", port.Target, " in service ", service.Name, ". Using port number instead") + } + ports = append(ports, corev1.ContainerPort{ + ContainerPort: int32(port.Target), + Name: name, + }) + } + + container := corev1.Container{ + Image: utils.TplValue(service.Name, "repository.image") + ":" + + utils.TplValue(service.Name, "repository.tag", d.defaultTag), + Ports: ports, + Name: service.Name, + ImagePullPolicy: corev1.PullIfNotPresent, + } + if _, ok := d.chart.Values[service.Name]; !ok { + d.chart.Values[service.Name] = NewValue(service, d.isMainApp) + } + d.chart.Values[service.Name].(*Value).ImagePullPolicy = string(corev1.PullIfNotPresent) + + // add an imagePullSecret, it actually does not work because the secret is not + // created but it add the reference in the YAML file. We'll change it in Yaml() + // method. + d.Spec.Template.Spec.ImagePullSecrets = []corev1.LocalObjectReference{{ + Name: `{{ .Values.pullSecrets | toYaml | indent __indent__ }}`, + }} + + d.AddHealthCheck(service, &container) + + d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, container) +} + +// AddIngress adds an ingress to the deployment. It creates the ingress object. +func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *Ingress { + return NewIngress(service, d.chart) +} + +// AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. +// If the volume is a bind volume it will warn the user that it is not supported yet. +func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { + + tobind := map[string]bool{} + if v, ok := service.Labels[LABEL_CM_FILES]; ok { + binds := []string{} + if err := yaml.Unmarshal([]byte(v), &binds); err != nil { + log.Fatal(err) + } + for _, bind := range binds { + tobind[bind] = true } } - // Remove duplicates in volumes - volumes := make([]map[string]interface{}, 0) - done := make(map[string]bool) - for _, vol := range deployment.Spec.Template.Spec.Volumes { - name := vol["name"].(string) - if _, ok := done[name]; ok { + isSamePod := false + if v, ok := service.Labels[LABEL_SAME_POD]; !ok { + isSamePod = false + } else { + isSamePod = v != "" + } + + for _, volume := range service.Volumes { + // not declared as a bind volume, skip + if _, ok := tobind[volume.Source]; !isSamePod && volume.Type == "bind" && !ok { + utils.Warn( + "Bind volumes are not supported yet, " + + "excepting for those declared as " + + LABEL_CM_FILES + + ", skipping volume " + volume.Source + + " from service " + service.Name, + ) continue - } else { - done[name] = true - volumes = append(volumes, vol) } - } - deployment.Spec.Template.Spec.Volumes = volumes - // Then, create Services and possible Ingresses for ingress labels, "ports" and "expose" section - if len(s.Ports) > 0 || len(s.Expose) > 0 { - for _, s := range generateServicesAndIngresses(name, s) { - if s != nil { - fileGeneratorChan <- s + container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) + if container == nil { + utils.Warn("Container not found for volume", volume.Source) + continue + } + + // ensure that the volume is not already present in the container + for _, vm := range container.VolumeMounts { + if vm.Name == volume.Source { + continue } } + + switch volume.Type { + case "volume": + // Add volume to container + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: volume.Source, + MountPath: volume.Target, + }) + // Add volume to values.yaml only if it the service is not in the same pod that another service. + // If it is in the same pod, the volume will be added to the other service later + if _, ok := service.Labels[LABEL_SAME_POD]; !ok { + d.chart.Values[service.Name].(*Value).AddPersistence(volume.Source) + } + // Add volume to deployment + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volume.Source, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: utils.TplName(service.Name, appName, volume.Source), + }, + }, + }) + case "bind": + // Add volume to container + cm := NewConfigMapFromFiles(service, appName, volume.Source) + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: utils.PathToName(volume.Source), + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: cm.ObjectMeta.Name, + }, + }, + }, + }) + // add the mount path to the container + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: utils.PathToName(volume.Source), + MountPath: volume.Target, + }) + + d.configMaps[utils.PathToName(volume.Source)] = true + // add all subdirectories to the list of directories + stat, err := os.Stat(volume.Source) + if err != nil { + log.Fatal(err) + } + if stat.IsDir() { + files, err := os.ReadDir(volume.Source) + if err != nil { + log.Fatal(err) + } + for _, file := range files { + if file.IsDir() { + cm := NewConfigMapFromFiles(service, appName, filepath.Join(volume.Source, file.Name())) + name := utils.PathToName(volume.Source) + "-" + file.Name() + d.configMaps[name] = true + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: utils.PathToName(volume.Source) + "-" + file.Name(), + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: cm.ObjectMeta.Name, + }, + }, + }, + }) + // add the mount path to the container + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: name, + MountPath: filepath.Join(volume.Target, file.Name()), + }) + } + } + } + } + + d.Spec.Template.Spec.Containers[index] = *container } - - // add the volumes in Values - if len(VolumeValues[name]) > 0 { - AddValues(name, map[string]EnvVal{"persistence": VolumeValues[name]}) - } - - // the deployment is ready, give it - fileGeneratorChan <- deployment - - // and then, we can say that it's the end - fileGeneratorChan <- nil +} + +func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) { + log.Printf("In %s deployment, add volumes for service %s from binded deployment %s", d.Name, service.Name, binded.Name) + // find the volume in the binded deployment + for _, bindedVolume := range binded.Spec.Template.Spec.Volumes { + log.Println("bindedVolume.Name found", bindedVolume.Name) + skip := false + for _, targetVol := range d.Spec.Template.Spec.Volumes { + if targetVol.Name == bindedVolume.Name { + log.Println("Volume", bindedVolume.Name, "already exists in deployment", d.Name) + skip = true + break + } + } + if !skip { + // add the volume to the current deployment + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, bindedVolume) + log.Println("d.Spec.Template.Spec.Volumes", d.Spec.Template.Spec.Volumes) + // get the container + + } + // add volume mount to the container + targetContainer, ti := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) + sourceContainer, _ := utils.GetContainerByName(service.Name, binded.Spec.Template.Spec.Containers) + for _, bindedMount := range sourceContainer.VolumeMounts { + if bindedMount.Name == bindedVolume.Name { + log.Println("bindedMount.Name found", bindedMount.Name) + targetContainer.VolumeMounts = append(targetContainer.VolumeMounts, bindedMount) + } + } + d.Spec.Template.Spec.Containers[ti] = *targetContainer + } +} + +// SetEnvFrom sets the environment variables to a configmap. The configmap is created. +func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { + + if len(service.Environment) == 0 { + return + } + + drop := []string{} + secrets := []string{} + + // secrets from label + labelSecrets := []string{} + if v, ok := service.Labels[LABEL_SECRETS]; ok { + err := yaml.Unmarshal([]byte(v), &labelSecrets) + if err != nil { + log.Fatal(err) + } + } + + // values from label + varDescriptons := utils.GetValuesFromLabel(service, LABEL_VALUES) + labelValues := []string{} + for v := range varDescriptons { + labelValues = append(labelValues, v) + } + + for _, secret := range labelSecrets { + // get the secret name + _, ok := service.Environment[secret] + if !ok { + drop = append(drop, secret) + utils.Warn("Secret " + secret + " not found in service " + service.Name + " - skpped") + continue + } + secrets = append(secrets, secret) + } + + // for each values from label "values", add it to Values map and change the envFrom + // value to {{ .Values.. }} + for _, value := range labelValues { + // get the environment variable name + val, ok := service.Environment[value] + if !ok { + drop = append(drop, value) + utils.Warn("Environment variable " + value + " not found in service " + service.Name + " - skpped") + continue + } + if d.chart.Values[service.Name].(*Value).Environment == nil { + d.chart.Values[service.Name].(*Value).Environment = make(map[string]any) + } + d.chart.Values[service.Name].(*Value).Environment[value] = *val + // set the environment variable to bind to the values.yaml file + v := utils.TplValue(service.Name, "environment."+value) + service.Environment[value] = &v + } + + for _, value := range drop { + delete(service.Environment, value) + } + + fromSources := []corev1.EnvFromSource{} + + if len(service.Environment) > 0 { + fromSources = append(fromSources, corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: utils.TplName(service.Name, appName), + }, + }, + }) + } + + if len(secrets) > 0 { + fromSources = append(fromSources, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: utils.TplName(service.Name, appName), + }, + }, + }) + } + + container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) + if container == nil { + utils.Warn("Container not found for service " + service.Name) + return + } + + container.EnvFrom = append(container.EnvFrom, fromSources...) + + if container.Env == nil { + container.Env = []corev1.EnvVar{} + } + + d.Spec.Template.Spec.Containers[index] = *container +} + +func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) { + + // get the label for healthcheck + if v, ok := service.Labels[LABEL_HEALTHCHECK]; ok { + probes := struct { + LivenessProbe *corev1.Probe `yaml:"livenessProbe"` + ReadinessProbe *corev1.Probe `yaml:"readinessProbe"` + }{} + err := yaml.Unmarshal([]byte(v), &probes) + if err != nil { + log.Fatal(err) + } + container.LivenessProbe = probes.LivenessProbe + container.ReadinessProbe = probes.ReadinessProbe + return + } + + if service.HealthCheck != nil { + period := 30.0 + if service.HealthCheck.Interval != nil { + period = time.Duration(*service.HealthCheck.Interval).Seconds() + } + container.LivenessProbe = &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{ + Command: service.HealthCheck.Test[1:], + }, + }, + PeriodSeconds: int32(period), + } + } +} + +// Yaml returns the yaml representation of the deployment. +func (d *Deployment) Yaml() ([]byte, error) { + serviceName := d.service.Name + y, err := yaml.Marshal(d) + if err != nil { + return nil, err + } + + // for each volume mount, add a condition "if values has persistence" + changing := false + content := strings.Split(string(y), "\n") + spaces := "" + volumeName := "" + + // this loop add condition for each volume mount + for line, volume := range content { + // find the volume name + for i := line; i < len(content); i++ { + if strings.Contains(content[i], "name: ") { + volumeName = strings.TrimSpace(strings.Replace(content[i], "name: ", "", 1)) + break + } + + } + if volumeName == "" { + continue + } + + if _, ok := d.configMaps[volumeName]; ok { + continue + } + + if strings.Contains(volume, "- mountPath: ") { + spaces = strings.Repeat(" ", utils.CountStartingSpaces(volume)) + content[line] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + volumeName + `.enabled }}` + "\n" + volume + changing = true + } + if strings.Contains(volume, "name: ") && changing { + content[line] = volume + "\n" + spaces + "{{- end }}" + changing = false + } + } + + changing = false + inVolumes := false + volumeName = "" + // this loop changes imagePullPolicy to {{ .Values..imagePullPolicy }} + // and the volume definition adding the condition "if values has persistence" + for i, line := range content { + + if strings.Contains(line, "imagePullPolicy:") { + spaces = strings.Repeat(" ", utils.CountStartingSpaces(line)) + content[i] = spaces + "imagePullPolicy: {{ .Values." + serviceName + ".imagePullPolicy }}" + } + + // find the volume name + for i := i; i < len(content); i++ { + if strings.Contains(content[i], "- name: ") { + volumeName = strings.TrimSpace(strings.Replace(content[i], "- name: ", "", 1)) + break + } + } + if strings.Contains(line, "volumes:") { + inVolumes = true + } + + if volumeName == "" { + continue + } + + if _, ok := d.configMaps[volumeName]; ok { + continue + } + + if strings.Contains(line, "- name: ") && inVolumes { + spaces = strings.Repeat(" ", utils.CountStartingSpaces(line)) + content[i] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + volumeName + `.enabled }}` + "\n" + line + changing = true + } + if strings.Contains(line, "claimName: ") && changing { + content[i] = line + "\n" + spaces + "{{- end }}" + changing = false + } + } + + // for impagePullSecrets, replace the name with the value from values.yaml + inpullsecrets := false + for i, line := range content { + if strings.Contains(line, "imagePullSecrets:") { + inpullsecrets = true + } + if inpullsecrets && strings.Contains(line, "- name: ") && inpullsecrets { + line = strings.Replace(line, "- name: ", "", 1) + line = strings.ReplaceAll(line, "'", "") + content[i] = line + inpullsecrets = false + } + } + + // Find the replicas line and replace it with the value from values.yaml + for i, line := range content { + if strings.Contains(line, "replicas:") { + line = regexp.MustCompile("replicas: .*$").ReplaceAllString(line, "replicas: {{ .Values."+serviceName+".replicas }}") + content[i] = line + } + } + + return []byte(strings.Join(content, "\n")), nil +} + +func (d *Deployment) Filename() string { + return d.service.Name + ".deployment.yaml" } diff --git a/generator/doc.go b/generator/doc.go new file mode 100644 index 0000000..095c2e7 --- /dev/null +++ b/generator/doc.go @@ -0,0 +1,18 @@ +/* +The generator package generates kubernetes objects from a compose file and transforms them into a helm chart. + +The generator package is the core of katenary. It is responsible for generating kubernetes objects from a compose file and transforming them into a helm chart. +Convertion manipulates Yaml representation of kubernetes object to add conditions, labels, annotations, etc. to the objects. It also create the values to be set to +the values.yaml file. + +The generate.Convert() create an HelmChart object and call "Generate()" method to convert from a compose file to a helm chart. +It saves the helm chart in the given directory. + +If you want to change or override the write behavior, you can use the HelmChart.Generate() function and implement your own write function. This function returns +the helm chart object containing all kubernetes objects and helm chart ingormation. It does not write the helm chart to the disk. + +TODO: Manage cronjob + rbac +TODO: create note.txt +TODO: manage emptyDirs +*/ +package generator diff --git a/generator/env.go b/generator/env.go deleted file mode 100644 index d12f605..0000000 --- a/generator/env.go +++ /dev/null @@ -1,154 +0,0 @@ -package generator - -import ( - "fmt" - "io/ioutil" - "katenary/compose" - "katenary/helm" - "katenary/logger" - "katenary/tools" - "os" - "path/filepath" - "strings" - - "github.com/compose-spec/compose-go/types" - "gopkg.in/yaml.v3" -) - -// applyEnvMapLabel will get all LABEL_MAP_ENV to rebuild the env map with tpl. -func applyEnvMapLabel(s *types.ServiceConfig, c *helm.Container) { - - locker.Lock() - defer locker.Unlock() - mapenv, ok := s.Labels[helm.LABEL_MAP_ENV] - if !ok { - return - } - - // the mapenv is a YAML string - var envmap map[string]EnvVal - err := yaml.Unmarshal([]byte(mapenv), &envmap) - if err != nil { - logger.ActivateColors = true - logger.Red(err.Error()) - logger.ActivateColors = false - return - } - - // add in envmap - for k, v := range envmap { - vstring := fmt.Sprintf("%v", v) - s.Environment[k] = &vstring - touched := false - if c.Env != nil { - c.Env = make([]*helm.Value, 0) - } - for _, env := range c.Env { - if env.Name == k { - env.Value = v - touched = true - } - } - if !touched { - c.Env = append(c.Env, &helm.Value{Name: k, Value: v}) - } - } -} - -// readEnvFile read environment file and add to the values.yaml map. -func readEnvFile(envfilename string) map[string]EnvVal { - env := make(map[string]EnvVal) - 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 -} - -// prepareEnvFromFiles generate configMap or secrets from environment files. -func prepareEnvFromFiles(name string, s *types.ServiceConfig, container *helm.Container, fileGeneratorChan HelmFileGenerator) { - - // prepare secrets - secretsFiles := make([]string, 0) - if v, ok := s.Labels[helm.LABEL_ENV_SECRET]; ok { - secretsFiles = strings.Split(v, ",") - } - - var secretVars []string - if v, ok := s.Labels[helm.LABEL_SECRETVARS]; ok { - secretVars = strings.Split(v, ",") - } - - for i, s := range secretVars { - secretVars[i] = strings.TrimSpace(s) - } - - // manage environment files (env_file in compose) - for _, envfile := range s.EnvFile { - f := tools.PathToName(envfile) - f = strings.ReplaceAll(f, ".env", "") - isSecret := false - for _, s := range secretsFiles { - s = strings.TrimSpace(s) - if s == envfile { - isSecret = true - } - } - var store helm.InlineConfig - if !isSecret { - logger.Bluef(ICON_CONF+" Generating configMap from %s\n", envfile) - store = helm.NewConfigMap(name, envfile) - } else { - logger.Bluef(ICON_SECRET+" Generating secret from %s\n", envfile) - store = helm.NewSecret(name, envfile) - } - - envfile = filepath.Join(compose.GetCurrentDir(), envfile) - if err := store.AddEnvFile(envfile, secretVars); 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, - }, - }) - - // 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:]...) - i-- - } - } - } - } - - if store != nil { - fileGeneratorChan <- store.(HelmFile) - } - } -} diff --git a/generator/extrafiles/doc.go b/generator/extrafiles/doc.go new file mode 100644 index 0000000..5033525 --- /dev/null +++ b/generator/extrafiles/doc.go @@ -0,0 +1,2 @@ +/* extrafiles package provides function to generate the Chart files that are not objects. Like README.md and notes.txt... */ +package extrafiles diff --git a/generator/extrafiles/notes.go b/generator/extrafiles/notes.go new file mode 100644 index 0000000..373e7ee --- /dev/null +++ b/generator/extrafiles/notes.go @@ -0,0 +1,11 @@ +package extrafiles + +import _ "embed" + +//go:embed notes.tpl +var notesTemplate string + +// NoteTXTFile returns the content of the note.txt file. +func NotesFile() string { + return notesTemplate +} diff --git a/generator/extrafiles/notes.tpl b/generator/extrafiles/notes.tpl new file mode 100644 index 0000000..3121a00 --- /dev/null +++ b/generator/extrafiles/notes.tpl @@ -0,0 +1,27 @@ +Your release is named {{ .Release.Name }}. + +To learn more about the release, try: + + $ helm -n {{ .Release.Namespace }} status {{ .Release.Name }} + $ helm -n {{ .Release.Namespace }} get all {{ .Release.Name }} + +To delete the release, run: + + $ helm -n {{ .Release.Namespace }} delete {{ .Release.Name }} + +You can see this notes again by running: + + $ helm -n {{ .Release.Namespace }} get notes {{ .Release.Name }} + +{{- $count := 0 -}} +{{- range $s, $v := .Values -}} +{{- if and $v $v.ingress -}} +{{- $count = add $count 1 -}} +{{- if eq $count 1 }} + +The ingress list is: +{{ end }} + - {{ $s }}: http://{{ $v.ingress.host }}{{ $v.ingress.path }} +{{- end -}} +{{ end -}} + diff --git a/generator/extrafiles/readme.go b/generator/extrafiles/readme.go new file mode 100644 index 0000000..865d203 --- /dev/null +++ b/generator/extrafiles/readme.go @@ -0,0 +1,99 @@ +package extrafiles + +import ( + "bytes" + "fmt" + "sort" + "strings" + "text/template" + + _ "embed" + + "gopkg.in/yaml.v3" +) + +type chart struct { + Name string + Description string + Values []string +} + +//go:embed readme.tpl +var readmeTemplate string + +// ReadMeFile returns the content of the README.md file. +func ReadMeFile(charname, description string, values map[string]any) string { + + // values is a yaml structure with keys and structured values... + // we want to make list of dot separated keys and their values + + vv := map[string]any{} + out, _ := yaml.Marshal(values) + yaml.Unmarshal(out, &vv) + + result := make(map[string]string) + parseValues("", vv, result) + + funcMap := template.FuncMap{ + "repeat": func(s string, count int) string { + return strings.Repeat(s, count) + }, + } + tpl, err := template.New("readme").Funcs(funcMap).Parse(readmeTemplate) + if err != nil { + panic(err) + } + + valuesLines := []string{} + maxParamLen := 0 + maxDefaultLen := 0 + for key, value := range result { + if len(key) > maxParamLen { + maxParamLen = len(key) + } + if len(value) > maxDefaultLen { + maxDefaultLen = len(value) + } + } + for key, value := range result { + valuesLines = append(valuesLines, fmt.Sprintf("| %-*s | %-*s |", maxParamLen, key, maxDefaultLen, value)) + } + sort.Strings(valuesLines) + + buf := &bytes.Buffer{} + err = tpl.Execute(buf, map[string]any{ + "DescrptionPadding": maxParamLen, + "DefaultPadding": maxDefaultLen, + "Chart": chart{ + Name: charname, + Description: description, + Values: valuesLines, + }, + }) + if err != nil { + panic(err) + } + + return buf.String() +} + +func parseValues(prefix string, values map[string]interface{}, result map[string]string) { + for key, value := range values { + path := key + if prefix != "" { + path = prefix + "." + key + } + + switch v := value.(type) { + case []interface{}: + for i, u := range v { + parseValues(fmt.Sprintf("%s[%d]", path, i), map[string]interface{}{"value": u}, result) + } + case map[string]interface{}: + parseValues(path, v, result) + default: + strValue := fmt.Sprintf("`%v`", value) + result["`"+path+"`"] = strValue + } + } +} diff --git a/generator/extrafiles/readme.tpl b/generator/extrafiles/readme.tpl new file mode 100644 index 0000000..5ca4116 --- /dev/null +++ b/generator/extrafiles/readme.tpl @@ -0,0 +1,32 @@ +# {{ .Chart.Name }} + +{{ .Chart.Description }} + +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +# Standard Helm install +$ helm install my-release {{ .Chart.Name }} + +# To use a custom namespace and force the creation of the namespace +$ helm install my-release --namespace my-namespace --create-namespace {{ .Chart.Name }} + +# To use a custom values file +$ helm install my-release -f my-values.yaml {{ .Chart.Name }} +``` + +See the [Helm documentation](https://helm.sh/docs/intro/using_helm/) for more information on installing and managing the chart. + +## Configuration + +The following table lists the configurable parameters of the {{ .Chart.Name }} chart and their default values. + +| {{ printf "%-*s" .DescrptionPadding "Parameter" }} | {{ printf "%-*s" .DefaultPadding "Default" }} | +| {{ repeat "-" .DescrptionPadding }} | {{ repeat "-" .DefaultPadding }} | +{{- range .Chart.Values }} +{{ . }} +{{- end }} + + diff --git a/generator/generator.go b/generator/generator.go new file mode 100644 index 0000000..192ef74 --- /dev/null +++ b/generator/generator.go @@ -0,0 +1,658 @@ +package generator + +// TODO: configmap from files 20% + +import ( + "bytes" + "fmt" + "katenary/utils" + "log" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/compose-spec/compose-go/types" + goyaml "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +// 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: +// +// 1. Detect the service port name or leave the port number if not found. +// +// 2. Create a deployment for each service that are not ingnore. +// +// 3. Create a service and ingresses for each service that has ports and/or declared ingresses. +// +// 4. Create a PVC or Configmap volumes for each volume. +// +// 5. Create init containers for each service which has dependencies to other services. +// +// 6. Create a chart dependencies. +// +// 7. Create a configmap and secrets from the environment variables. +// +// 8. Merge the same-pod services. +func Generate(project *types.Project) (*HelmChart, error) { + + var ( + appName = project.Name + deployments = make(map[string]*Deployment, len(project.Services)) + services = make(map[string]*Service) + podToMerge = make(map[string]*Deployment) + ) + chart := NewChart(appName) + + // Add the compose files hash to the chart annotations + hash, err := utils.HashComposefiles(project.ComposeFiles) + if err != nil { + return nil, err + } + Annotations[KATENARY_PREFIX+"compose-hash"] = hash + chart.composeHash = &hash + + // find the "main-app" label, and set chart.AppVersion to the tag if exists + mainCount := 0 + for _, service := range project.Services { + if serviceIsMain(service) { + log.Printf("Found main app %s", service.Name) + mainCount++ + if mainCount > 1 { + return nil, fmt.Errorf("found more than one main app") + } + setChartVersion(chart, service) + } + } + if mainCount == 0 { + chart.AppVersion = "0.1.0" + } + + // first pass, create all deployments whatewer they are. + for _, service := range project.Services { + // check the "ports" label from container and add it to the service + if err := fixPorts(&service); err != nil { + return nil, err + } + + // isgnored service + if isIgnored(service) { + fmt.Printf("%s Ignoring service %s\n", utils.IconInfo, service.Name) + continue + } + + // helm dependency + if isHelmDependency, err := setDependencies(chart, service); err != nil { + return nil, err + } else if isHelmDependency { + continue + } + + // create all deployments + d := NewDeployment(service, chart) + deployments[service.Name] = d + + // generate the cronjob if needed + setCronJob(service, chart, appName) + + // 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[LABEL_SAME_POD]; ok && samePod != "" { + podToMerge[samePod] = d + } + + // create the needed service for the container port + if len(service.Ports) > 0 { + s := NewService(service, appName) + services[service.Name] = s + } + + // create all ingresses + if ingress := d.AddIngress(service, appName); ingress != nil { + y, _ := ingress.Yaml() + chart.Templates[ingress.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + } + } + + // 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) + for _, service := range project.Services { + if err := buildVolumes(service, chart, deployments); err != nil { + return nil, err + } + } + // drop all "same-pod" deployments because the containers and volumes are already + // in the target deployment + for _, service := range project.Services { + if samepod, ok := service.Labels[LABEL_SAME_POD]; ok && samepod != "" { + // move this deployment volumes to the target deployment + if target, ok := deployments[samepod]; ok { + target.AddContainer(service) + target.BindFrom(service, deployments[service.Name]) + delete(deployments, service.Name) + } else { + log.Printf("service %[1]s is declared as %[2]s, but %[2]s is not defined", service.Name, LABEL_SAME_POD) + } + } + } + + // create init containers for all DependsOn + for _, s := range project.Services { + for _, d := range s.GetDependencies() { + if dep, ok := deployments[d]; ok { + deployments[s.Name].DependsOn(dep) + } else { + log.Printf("service %[1]s depends on %[2]s, but %[2]s is not defined", s.Name, d) + } + } + } + + // generate configmaps with environment variables + generateConfigMapsAndSecrets(project, chart) + + // 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 { + setSharedConf(s, chart, deployments) + } + + // generate yaml files + for _, d := range deployments { + y, _ := d.Yaml() + chart.Templates[d.Filename()] = &ChartTemplate{ + Content: y, + Servicename: d.service.Name, + } + } + + // generate all services + for _, s := range services { + y, _ := s.Yaml() + chart.Templates[s.Filename()] = &ChartTemplate{ + Content: y, + Servicename: s.service.Name, + } + } + + // 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 +} + +// 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), " ")) + line = bytes.ReplaceAll(line, []byte("__indent__"), []byte(fmt.Sprintf("%d", len(startSpaces)))) + 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 +} + +func serviceIsMain(service types.ServiceConfig) bool { + if main, ok := service.Labels[LABEL_MAIN_APP]; ok { + return main == "true" || main == "yes" || main == "1" + } + return false +} + +func setChartVersion(chart *HelmChart, service types.ServiceConfig) { + if chart.Version == "" { + image := service.Image + parts := strings.Split(image, ":") + if len(parts) > 1 { + chart.AppVersion = parts[1] + } else { + chart.AppVersion = "0.1.0" + } + } +} + +func fixPorts(service *types.ServiceConfig) error { + // check the "ports" label from container and add it to the service + if portsLabel, ok := service.Labels[LABEL_PORTS]; ok { + ports := []uint32{} + if err := goyaml.Unmarshal([]byte(portsLabel), &ports); err != nil { + // maybe it's a string, comma separated + parts := strings.Split(portsLabel, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + port, err := strconv.ParseUint(part, 10, 32) + if err != nil { + return err + } + ports = append(ports, uint32(port)) + } + } + for _, port := range ports { + service.Ports = append(service.Ports, types.ServicePortConfig{ + Target: port, + }) + } + } + return nil +} + +func setCronJob(service types.ServiceConfig, chart *HelmChart, appName string) *CronJob { + if _, ok := service.Labels[LABEL_CRONJOB]; !ok { + return nil + } + cronjob, rbac := NewCronJob(service, chart, appName) + y, _ := cronjob.Yaml() + chart.Templates[cronjob.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + + if rbac != nil { + y, _ := rbac.RoleBinding.Yaml() + chart.Templates[rbac.RoleBinding.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + y, _ = rbac.Role.Yaml() + chart.Templates[rbac.Role.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + y, _ = rbac.ServiceAccount.Yaml() + chart.Templates[rbac.ServiceAccount.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + } + + return cronjob +} + +func setDependencies(chart *HelmChart, service types.ServiceConfig) (bool, error) { + // helm dependency + if v, ok := service.Labels[LABEL_DEPENDENCIES]; ok { + d := Dependency{} + if err := yaml.Unmarshal([]byte(v), &d); err != nil { + return false, err + } + fmt.Printf("%s Adding dependency to %s\n", utils.IconDependency, d.Name) + chart.Dependencies = append(chart.Dependencies, d) + + name := d.Name + if d.Alias != "" { + name = d.Alias + } + // add the dependency env vars to the values.yaml + chart.Values[name] = d.Values + return true, nil + } + return false, nil +} + +func isIgnored(service types.ServiceConfig) bool { + if v, ok := service.Labels[LABEL_IGNORE]; ok { + return v == "true" || v == "yes" || v == "1" + } + return false +} + +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": + pvc := NewVolumeClaim(service, v.Source, appName) + + // if the service is integrated in another deployment, we need to add the volume + // to the target deployment + if override, ok := service.Labels[LABEL_SAME_POD]; ok { + pvc.nameOverride = override + pvc.PersistentVolumeClaim.Spec.StorageClassName = utils.StrPtr(`{{ .Values.` + override + `.persistence.` + v.Source + `.storageClass }}`) + chart.Values[override].(*Value).AddPersistence(v.Source) + } + y, _ := pvc.Yaml() + chart.Templates[pvc.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, //TODO, use name + } + + case "bind": + // ensure the path is in labels + bindPath := map[string]string{} + if _, ok := service.Labels[LABEL_CM_FILES]; ok { + files := []string{} + if err := yaml.Unmarshal([]byte(service.Labels[LABEL_CM_FILES]), &files); err != nil { + return err + } + for _, f := range files { + bindPath[f] = f + } + } + if _, ok := bindPath[v.Source]; !ok { + continue + } + + cm := NewConfigMapFromFiles(service, appName, v.Source) + var err error + var y []byte + if y, err = cm.Yaml(); err != nil { + log.Fatal(err) + } + chart.Templates[cm.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + + // continue with subdirectories + stat, err := os.Stat(v.Source) + if err != nil { + return err + } + if stat.IsDir() { + files, err := filepath.Glob(filepath.Join(v.Source, "*")) + if err != nil { + return err + } + for _, f := range files { + if f == v.Source { + continue + } + if stat, err := os.Stat(f); err != nil || !stat.IsDir() { + continue + } + cm := NewConfigMapFromFiles(service, appName, f) + var err error + var y []byte + if y, err = cm.Yaml(); err != nil { + log.Fatal(err) + } + log.Printf("Adding configmap %s %s", cm.Filename(), f) + chart.Templates[cm.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + } + } + + } + } + return nil +} + +func generateConfigMapsAndSecrets(project *types.Project, chart *HelmChart) error { + appName := chart.Name + for _, s := range project.Services { + if s.Environment == nil || len(s.Environment) == 0 { + continue + } + + originalEnv := types.MappingWithEquals{} + secretsVar := types.MappingWithEquals{} + + // copy env to originalEnv + for k, v := range s.Environment { + originalEnv[k] = v + } + + if v, ok := s.Labels[LABEL_SECRETS]; ok { + list := []string{} + if err := yaml.Unmarshal([]byte(v), &list); err != nil { + log.Fatal("error unmarshaling secrets label:", err) + } + for _, secret := range list { + if secret == "" { + continue + } + if _, ok := s.Environment[secret]; !ok { + fmt.Printf("%s secret %s not found in environment", utils.IconWarning, secret) + continue + } + secretsVar[secret] = s.Environment[secret] + } + } + + if len(secretsVar) > 0 { + s.Environment = secretsVar + sec := NewSecret(s, appName) + y, _ := sec.Yaml() + name := sec.service.Name + chart.Templates[name+".secret.yaml"] = &ChartTemplate{ + Content: y, + Servicename: s.Name, + } + } + + // remove secrets from env + s.Environment = originalEnv // back to original + for k := range secretsVar { + delete(s.Environment, k) + } + if len(s.Environment) > 0 { + cm := NewConfigMap(s, appName) + y, _ := cm.Yaml() + name := cm.service.Name + chart.Templates[name+".configmap.yaml"] = &ChartTemplate{ + Content: y, + Servicename: s.Name, + } + } + } + return nil +} + +func mergePods(target, from *Deployment, services map[string]*Service, chart *HelmChart) { + + targetName := target.service.Name + fromName := from.service.Name + + // copy the volumes from the source deployment + for _, v := range from.Spec.Template.Spec.Volumes { + // ensure that the volume is not already present + found := false + for _, tv := range target.Spec.Template.Spec.Volumes { + if tv.Name == v.Name { + found = true + break + } + } + if found { + continue + } + target.Spec.Template.Spec.Volumes = append(target.Spec.Template.Spec.Volumes, v) + } + // copy the containers from the source deployment + for _, c := range from.Spec.Template.Spec.Containers { + target.Spec.Template.Spec.Containers = append(target.Spec.Template.Spec.Containers, c) + } + // copy the init containers from the source deployment + for _, c := range from.Spec.Template.Spec.InitContainers { + target.Spec.Template.Spec.InitContainers = append(target.Spec.Template.Spec.InitContainers, c) + } + // drop the deployment from the chart + delete(chart.Templates, fromName+".deployment.yaml") + + // rewite the target deployment + y, err := target.Yaml() + if err != nil { + log.Fatal("error rewriting deployment:", err) + } + chart.Templates[target.Filename()] = &ChartTemplate{ + Content: y, + Servicename: targetName, + } + + // now, if the source deployment has a service, we need to merge it with the target service + if _, ok := chart.Templates[targetName+".service.yaml"]; ok { + container, _ := utils.GetContainerByName(fromName, target.Spec.Template.Spec.Containers) + if container.Ports == nil || len(container.Ports) == 0 { + return + } + targetService := services[targetName] + for _, port := range container.Ports { + targetService.AddPort(types.ServicePortConfig{ + Target: uint32(port.ContainerPort), + Protocol: "TCP", + }, port.Name) + } + // rewrite the tartget service + y, _ := targetService.Yaml() + chart.Templates[targetName+".service.yaml"] = &ChartTemplate{ + Content: y, + Servicename: target.service.Name, + } + + // and remove the source service from the chart + delete(chart.Templates, fromName+".service.yaml") + + // In Valuses, remove the "replicas" key from the source service + if v, ok := chart.Values[fromName]; ok { + // if v is a Value + if v, ok := v.(*Value); ok { + v.Replicas = nil + } + } + } +} + +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 + } + + if service.Volumes == nil || len(service.Volumes) == 0 { + return false + } + + targetDeployment := "" + if targetName, ok := service.Labels[LABEL_SAME_POD]; !ok { + return false + } else { + targetDeployment = targetName + } + + // get the target deployment + var target *Deployment + for _, d := range deployments { + if d.service.Name == targetDeployment { + target = d + break + } + } + 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 { + log.Printf("found same pod volume %s in deployment %s and %s", tv.Name, service.Name, targetDeployment) + return true + } + } + return false +} + +func setSharedConf(service types.ServiceConfig, chart *HelmChart, deployments map[string]*Deployment) { + // if the service has the "shared-conf" label, we need to add the configmap + // to the chart and add the env vars to the service + if _, ok := service.Labels[LABEL_ENV_FROM]; !ok { + return + } + fromservices := []string{} + if err := yaml.Unmarshal([]byte(service.Labels[LABEL_ENV_FROM]), &fromservices); err != nil { + log.Fatal("error unmarshaling env-from label:", err) + } + // find the configmap in the chart templates + for _, fromservice := range fromservices { + if _, ok := chart.Templates[fromservice+".configmap.yaml"]; !ok { + log.Printf("configmap %s not found in chart templates", fromservice) + continue + } + // find the corresponding target deployment + var target *Deployment + for _, d := range deployments { + if d.service.Name == service.Name { + target = d + break + } + } + if target == nil { + continue + } + // add the configmap to the service + for i, c := range target.Spec.Template.Spec.Containers { + if c.Name != service.Name { + continue + } + c.EnvFrom = append(c.EnvFrom, corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: utils.TplName(fromservice, chart.Name), + }, + }, + }) + target.Spec.Template.Spec.Containers[i] = c + } + } + +} diff --git a/generator/globals.go b/generator/globals.go new file mode 100644 index 0000000..edab2a4 --- /dev/null +++ b/generator/globals.go @@ -0,0 +1,19 @@ +package generator + +import "regexp" + +var ( + // regexp to all tpl strings + tplValueRegexp = regexp.MustCompile(`\{\{.*\}\}-`) + + // find all labels starting by __replace_ and ending with ":" + // and get the value between the quotes + // ?s => multiline + // (?P.+?) => named capture group to "inc" variable (so we could use $inc in the replace) + replaceLabelRegexp = regexp.MustCompile(`(?s)__replace_.+?: '(?P.+?)'`) + + // Standard annotationss + Annotations = map[string]string{ + KATENARY_PREFIX + "version": Version, + } +) diff --git a/generator/helmHelper.tpl b/generator/helmHelper.tpl new file mode 100644 index 0000000..8d40010 --- /dev/null +++ b/generator/helmHelper.tpl @@ -0,0 +1,36 @@ +{{- define "__APP__.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "__APP__.name" -}} +{{- if .Values.nameOverride -}} +{{- .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "__APP__.labels" -}} +{{ include "__APP__.selectorLabels" .}} +{{ if .Chart.Version -}} +{{ printf "__PREFIX__chart-version: %s" .Chart.Version }} +{{- end }} +{{ if .Chart.AppVersion -}} +{{ printf "__PREFIX__app-version: %s" .Chart.AppVersion }} +{{- end }} +{{- end -}} + +{{- define "__APP__.selectorLabels" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{ printf "__PREFIX__name: %s" $name }} +{{ printf "__PREFIX__instance: %s" .Release.Name }} +{{- end -}} diff --git a/generator/helper.go b/generator/helper.go new file mode 100644 index 0000000..51487af --- /dev/null +++ b/generator/helper.go @@ -0,0 +1,19 @@ +package generator + +import ( + _ "embed" + "strings" +) + +// helmHelper is a template for the _helpers.tpl file in the chart templates directory. +// +//go:embed helmHelper.tpl +var helmHelper string + +// Helper returns the _helpers.tpl file for a chart. +func Helper(name string) string { + helmHelper := strings.ReplaceAll(helmHelper, "__APP__", name) + helmHelper = strings.ReplaceAll(helmHelper, "__PREFIX__", KATENARY_PREFIX) + helmHelper = strings.ReplaceAll(helmHelper, "__VERSION__", "0.1.0") + return helmHelper +} diff --git a/generator/ingress.go b/generator/ingress.go new file mode 100644 index 0000000..569dc93 --- /dev/null +++ b/generator/ingress.go @@ -0,0 +1,175 @@ +package generator + +import ( + "katenary/utils" + "log" + "strings" + + "github.com/compose-spec/compose-go/types" + goyaml "gopkg.in/yaml.v3" + networkv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +var _ Yaml = (*Ingress)(nil) + +type Ingress struct { + *networkv1.Ingress + service *types.ServiceConfig `yaml:"-"` +} + +// NewIngress creates a new Ingress from a compose service. +func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress { + + appName := Chart.Name + + // parse the KATENARY_PREFIX/ingress label from the service + if service.Labels == nil { + service.Labels = make(map[string]string) + } + var label string + var ok bool + if label, ok = service.Labels[LABEL_INGRESS]; !ok { + return nil + } + + mapping := map[string]interface{}{ + "enabled": false, + "host": service.Name + ".tld", + "path": "/", + "class": "-", + } + if err := goyaml.Unmarshal([]byte(label), &mapping); err != nil { + log.Fatalf("Failed to parse ingress label: %s\n", err) + } + + // create the ingress + pathType := networkv1.PathTypeImplementationSpecific + serviceName := `{{ include "` + appName + `.fullname" . }}-` + service.Name + if v, ok := mapping["port"]; ok { + if port, ok := v.(int); ok { + mapping["port"] = int32(port) + } + } else { + log.Fatalf("No port provided for ingress target in service %s\n", service.Name) + } + + // Add the ingress host to the values.yaml + if Chart.Values[service.Name] == nil { + Chart.Values[service.Name] = &Value{} + } + Chart.Values[service.Name].(*Value).Ingress = &IngressValue{ + Enabled: mapping["enabled"].(bool), + Path: mapping["path"].(string), + Host: mapping["host"].(string), + Class: mapping["class"].(string), + Annotations: map[string]string{}, + } + + //ingressClassName := `{{ .Values.` + service.Name + `.ingress.class }}` + ingressClassName := utils.TplValue(service.Name, "ingress.class") + + servicePortName := utils.GetServiceNameByPort(int(mapping["port"].(int32))) + ingressService := &networkv1.IngressServiceBackend{ + Name: serviceName, + Port: networkv1.ServiceBackendPort{}, + } + if servicePortName != "" { + ingressService.Port.Name = servicePortName + } else { + ingressService.Port.Number = mapping["port"].(int32) + } + + ing := &Ingress{ + service: &service, + Ingress: &networkv1.Ingress{ + TypeMeta: metav1.TypeMeta{ + Kind: "Ingress", + APIVersion: "networking.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Spec: networkv1.IngressSpec{ + IngressClassName: &ingressClassName, + Rules: []networkv1.IngressRule{ + { + Host: utils.TplValue(service.Name, "ingress.host"), + IngressRuleValue: networkv1.IngressRuleValue{ + HTTP: &networkv1.HTTPIngressRuleValue{ + Paths: []networkv1.HTTPIngressPath{ + { + Path: utils.TplValue(service.Name, "ingress.path"), + PathType: &pathType, + Backend: networkv1.IngressBackend{ + Service: ingressService, + }, + }, + }, + }, + }, + }, + }, + TLS: []networkv1.IngressTLS{ + { + Hosts: []string{ + `{{ tpl .Values.` + service.Name + `.ingress.host . }}`, + }, + SecretName: `{{ include "` + appName + `.fullname" . }}-` + service.Name + `-tls`, + }, + }, + }, + }, + } + + return ing +} + +func (ingress *Ingress) Yaml() ([]byte, error) { + serviceName := ingress.service.Name + ret, err := yaml.Marshal(ingress) + if err != nil { + return nil, err + } + + lines := strings.Split(string(ret), "\n") + out := []string{ + `{{- if .Values.` + serviceName + `.ingress.enabled -}}`, + } + for _, line := range lines { + if strings.Contains(line, "loadBalancer: ") { + continue + } + + if strings.Contains(line, "labels:") { + // add annotations above labels from values.yaml + content := `` + + ` {{- if .Values.` + serviceName + `.ingress.annotations -}}` + "\n" + + ` {{- toYaml .Values.` + serviceName + `.ingress.annotations | nindent 4 }}` + "\n" + + ` {{- end }}` + "\n" + + line + + out = append(out, content) + } else if strings.Contains(line, "ingressClassName: ") { + content := utils.Wrap( + line, + `{{- if ne .Values.`+serviceName+`.ingress.class "-" }}`, + `{{- end }}`, + ) + out = append(out, content) + } else { + out = append(out, line) + } + } + out = append(out, `{{- end -}}`) + ret = []byte(strings.Join(out, "\n")) + return ret, nil + +} + +func (ingress *Ingress) Filename() string { + return ingress.service.Name + ".ingress.yaml" +} diff --git a/generator/katenaryLabels.go b/generator/katenaryLabels.go new file mode 100644 index 0000000..dbd41c0 --- /dev/null +++ b/generator/katenaryLabels.go @@ -0,0 +1,229 @@ +package generator + +import ( + "bytes" + _ "embed" + "fmt" + "katenary/utils" + "regexp" + "sort" + "strings" + "text/tabwriter" + "text/template" + + "sigs.k8s.io/yaml" +) + +var ( + // Set the documentation of labels here + // + //go:embed katenaryLabelsDoc.yaml + labelFullHelpYAML []byte + + // parsed yaml + labelFullHelp map[string]Help +) + +// Label is a katenary label to find in compose files. +type Label = string + +// Help is the documentation of a label. +type Help struct { + Short string `yaml:"short"` + Long string `yaml:"long"` + Example string `yaml:"example"` + Type string `yaml:"type"` +} + +const KATENARY_PREFIX = "katenary.v3/" + +// Known labels. +const ( + LABEL_MAIN_APP Label = KATENARY_PREFIX + "main-app" + LABEL_VALUES Label = KATENARY_PREFIX + "values" + LABEL_SECRETS Label = KATENARY_PREFIX + "secrets" + LABEL_PORTS Label = KATENARY_PREFIX + "ports" + LABEL_INGRESS Label = KATENARY_PREFIX + "ingress" + LABEL_MAP_ENV Label = KATENARY_PREFIX + "map-env" + LABEL_HEALTHCHECK Label = KATENARY_PREFIX + "health-check" + LABEL_SAME_POD Label = KATENARY_PREFIX + "same-pod" + LABEL_DESCRIPTION Label = KATENARY_PREFIX + "description" + LABEL_IGNORE Label = KATENARY_PREFIX + "ignore" + LABEL_DEPENDENCIES Label = KATENARY_PREFIX + "dependencies" + LABEL_CM_FILES Label = KATENARY_PREFIX + "configmap-files" + LABEL_CRONJOB Label = KATENARY_PREFIX + "cronjob" + LABEL_ENV_FROM Label = KATENARY_PREFIX + "env-from" +) + +func init() { + if err := yaml.Unmarshal(labelFullHelpYAML, &labelFullHelp); err != nil { + panic(err) + } +} + +// Generate the help for the labels. +func GetLabelHelp(asMarkdown bool) string { + names := GetLabelNames() // sorted + if !asMarkdown { + return generatePlainHelp(names) + } + return generateMarkdownHelp(names) +} + +func generatePlainHelp(names []string) string { + var builder strings.Builder + for _, name := range names { + help := labelFullHelp[name] + fmt.Fprintf(&builder, "%s%s:\t%s\t%s\n", KATENARY_PREFIX, name, help.Type, help.Short) + } + + // use tabwriter to align the help text + buf := new(strings.Builder) + w := tabwriter.NewWriter(buf, 0, 8, 0, '\t', tabwriter.AlignRight) + fmt.Fprintln(w, builder.String()) + w.Flush() + + head := "To get more information about a label, use `katenary help-label \ne.g. katenary help-label dependencies\n\n" + return head + buf.String() +} + +func generateMarkdownHelp(names []string) string { + var builder strings.Builder + var maxNameLength, maxDescriptionLength, maxTypeLength int + + max := func(a, b int) int { + if a > b { + return a + } + return b + } + for _, name := range names { + help := labelFullHelp[name] + maxNameLength = max(maxNameLength, len(name)+2+len(KATENARY_PREFIX)) + maxDescriptionLength = max(maxDescriptionLength, len(help.Short)) + maxTypeLength = max(maxTypeLength, len(help.Type)) + } + + fmt.Fprintf(&builder, "%s\n", generateTableHeader(maxNameLength, maxDescriptionLength, maxTypeLength)) + fmt.Fprintf(&builder, "%s\n", generateTableHeaderSeparator(maxNameLength, maxDescriptionLength, maxTypeLength)) + + for _, name := range names { + help := labelFullHelp[name] + fmt.Fprintf(&builder, "| %-*s | %-*s | %-*s |\n", + maxNameLength, "`"+KATENARY_PREFIX+name+"`", // enclose in backticks + maxDescriptionLength, help.Short, + maxTypeLength, help.Type, + ) + } + + return builder.String() +} + +func generateTableHeader(maxNameLength, maxDescriptionLength, maxTypeLength int) string { + return fmt.Sprintf( + "| %-*s | %-*s | %-*s |", + maxNameLength, "Label name", + maxDescriptionLength, "Description", + maxTypeLength, "Type", + ) +} + +func generateTableHeaderSeparator(maxNameLength, maxDescriptionLength, maxTypeLength int) string { + return fmt.Sprintf( + "| %s | %s | %s |", + strings.Repeat("-", maxNameLength), + strings.Repeat("-", maxDescriptionLength), + strings.Repeat("-", maxTypeLength), + ) +} + +// GetLabelHelpFor returns the help for a specific label. +func GetLabelHelpFor(labelname string, asMarkdown bool) string { + + help, ok := labelFullHelp[labelname] + if !ok { + return "No help available for " + labelname + "." + } + + help.Long = strings.TrimPrefix(help.Long, "\n") + help.Example = strings.TrimPrefix(help.Example, "\n") + help.Short = strings.TrimPrefix(help.Short, "\n") + + // get help template + helpTemplate := getHelpTemplate(asMarkdown) + + if asMarkdown { + // enclose templates in backticks + help.Long = regexp.MustCompile(`\{\{(.*?)\}\}`).ReplaceAllString(help.Long, "`{{$1}}`") + help.Long = strings.ReplaceAll(help.Long, "__APP__", "`__APP__`") + } else { + help.Long = strings.ReplaceAll(help.Long, " \n", "\n") + help.Long = strings.ReplaceAll(help.Long, "`", "") + help.Long = strings.ReplaceAll(help.Long, "", "") + help.Long = strings.ReplaceAll(help.Long, "", "") + help.Long = utils.WordWrap(help.Long, 80) + } + + var buf bytes.Buffer + template.Must(template.New("shorthelp").Parse(help.Long)).Execute(&buf, struct { + KATENARY_PREFIX string + }{ + KATENARY_PREFIX: KATENARY_PREFIX, + }) + help.Long = buf.String() + buf.Reset() + + template.Must(template.New("example").Parse(help.Example)).Execute(&buf, struct { + KATENARY_PREFIX string + }{ + KATENARY_PREFIX: KATENARY_PREFIX, + }) + help.Example = buf.String() + buf.Reset() + + template.Must(template.New("complete").Parse(helpTemplate)).Execute(&buf, struct { + Name string + Help Help + KATENARY_PREFIX string + }{ + Name: labelname, + Help: help, + KATENARY_PREFIX: KATENARY_PREFIX, + }) + + return buf.String() +} + +// GetLabelNames returns a sorted list of all katenary label names. +func GetLabelNames() []string { + var names []string + for name := range labelFullHelp { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func getHelpTemplate(asMarkdown bool) string { + if asMarkdown { + return `## {{ .KATENARY_PREFIX }}{{ .Name }} + +{{ .Help.Short }} + +**Type**: ` + "`" + `{{ .Help.Type }}` + "`" + ` + +{{ .Help.Long }} + +**Example:**` + "\n\n```yaml\n" + `{{ .Help.Example }}` + "\n```\n" + } + + return `{{ .KATENARY_PREFIX }}{{ .Name }}: {{ .Help.Short }} +Type: {{ .Help.Type }} + +{{ .Help.Long }} + +Example: +{{ .Help.Example }} +` + +} diff --git a/generator/labels.go b/generator/labels.go new file mode 100644 index 0000000..bbf06e5 --- /dev/null +++ b/generator/labels.go @@ -0,0 +1,36 @@ +package generator + +import ( + "fmt" +) + +// LabelType identifies the type of label to generate in objects. +// TODO: is this still needed? +type LabelType uint8 + +const ( + DeploymentLabel LabelType = iota + ServiceLabel +) + +func GetLabels(serviceName, appName string) map[string]string { + labels := map[string]string{ + KATENARY_PREFIX + "component": serviceName, + } + + key := `{{- include "%s.labels" . | nindent __indent__ }}` + labels[`__replace_`+serviceName] = fmt.Sprintf(key, appName) + + return labels +} + +func GetMatchLabels(serviceName, appName string) map[string]string { + labels := map[string]string{ + KATENARY_PREFIX + "component": serviceName, + } + + key := `{{- include "%s.selectorLabels" . | nindent __indent__ }}` + labels[`__replace_`+serviceName] = fmt.Sprintf(key, appName) + + return labels +} diff --git a/generator/main.go b/generator/main.go deleted file mode 100644 index dd09adc..0000000 --- a/generator/main.go +++ /dev/null @@ -1,304 +0,0 @@ -package generator - -import ( - "fmt" - "io/ioutil" - "katenary/helm" - "katenary/logger" - "katenary/tools" - "log" - "net/url" - "os" - "path/filepath" - "runtime" - "strconv" - "strings" - "sync" - - "github.com/compose-spec/compose-go/types" -) - -type EnvVal = helm.EnvValue - -const ( - ICON_PACKAGE = "📦" - ICON_SERVICE = "🔌" - ICON_SECRET = "🔏" - ICON_CONF = "📝" - ICON_STORE = "⚡" - ICON_INGRESS = "🌐" - ICON_RBAC = "🔑" - ICON_CRON = "🕒" -) - -var ( - EmptyDirs = []string{} - servicesMap = make(map[string]int) - locker = &sync.Mutex{} - - dependScript = ` -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 -done -echo -echo "Done" -` - - madeDeployments = make(map[string]helm.Deployment, 0) -) - -// Create a Deployment for a given compose.Service. It returns a list chan -// of HelmFileGenerator which will be used to generate the files (deployment, secrets, configMap...). -func CreateReplicaObject(name string, s types.ServiceConfig, linked map[string]types.ServiceConfig) HelmFileGenerator { - ret := make(chan HelmFile, runtime.NumCPU()) - // there is a bug woth typs.ServiceConfig if we use the pointer. So we need to dereference it. - go buildDeployment(name, &s, linked, ret) - return ret -} - -// Create a service (k8s). -func generateServicesAndIngresses(name string, s *types.ServiceConfig) []HelmFile { - - ret := make([]HelmFile, 0) // can handle helm.Service or helm.Ingress - logger.Magenta(ICON_SERVICE+" Generating service for ", name) - ks := helm.NewService(name) - - for _, p := range s.Ports { - target := int(p.Target) - ks.Spec.Ports = append(ks.Spec.Ports, helm.NewServicePort(target, target)) - } - ks.Spec.Selector = buildSelector(name, s) - - ret = append(ret, ks) - 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) - ret = append(ret, ing) - } - - 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) - } - - return ret -} - -// Create an ingress. -func createIngress(name string, port int, s *types.ServiceConfig) *helm.Ingress { - ingress := helm.NewIngress(name) - - annotations := map[string]string{} - ingressVal := map[string]interface{}{ - "class": "nginx", - "host": name + "." + helm.Appname + ".tld", - "enabled": false, - "annotations": annotations, - } - - // add Annotations in values - AddValues(name, map[string]EnvVal{"ingress": ingressVal}) - - 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{ - Service: helm.IngressService{ - Name: helm.ReleaseNameTpl + "-" + name, - Port: map[string]interface{}{ - "number": port, - }, - }, - }, - }}, - }, - }, - } - ingress.SetIngressClass(name) - - return ingress -} - -// Build the selector for the service. -func buildSelector(name string, s *types.ServiceConfig) map[string]string { - return map[string]string{ - "katenary.io/component": name, - "katenary.io/release": helm.ReleaseNameTpl, - } -} - -// buildConfigMapFromPath generates a ConfigMap from a path. -func buildConfigMapFromPath(name, 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) - } - } else { - c, _ := ioutil.ReadFile(path) - _, filename := filepath.Split(path) - files[filename] = string(c) - } - - cm := helm.NewConfigMap(name, tools.GetRelPath(path)) - cm.Data = files - return cm -} - -// prepareProbes generate http/tcp/command probes for a service. -func prepareProbes(name string, s *types.ServiceConfig, container *helm.Container) { - // first, check if there a label for the probe - if check, ok := s.Labels[helm.LABEL_HEALTHCHECK]; ok { - check = strings.TrimSpace(check) - p := helm.NewProbeFromService(s) - // get the port of the "url" check - if checkurl, err := url.Parse(check); err == nil { - if err == nil { - container.LivenessProbe = buildProtoProbe(p, checkurl) - } - } else { - // it's a command - container.LivenessProbe = p - container.LivenessProbe.Exec = &helm.Exec{ - Command: []string{ - "sh", - "-c", - check, - }, - } - } - return // label overrides everything - } - - // 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 { - port, err := strconv.Atoi(u.Port()) - if err != nil { - port = 80 - } - - path := "/" - if u.Path != "" { - path = u.Path - } - - switch u.Scheme { - case "http", "https": - probe.HttpGet = &helm.HttpGet{ - Path: path, - 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) - } - return probe -} - -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) - switch first { - case "CMD", "CMD-SHELL": - // CMD or CMD-SHELL - p.Exec = &helm.Exec{ - Command: s.HealthCheck.Test[1:], - } - return p - default: - // badly made but it should work... - p.Exec = &helm.Exec{ - Command: []string(s.HealthCheck.Test), - } - return p - } -} - -func setSecretVar(name string, s *types.ServiceConfig, c *helm.Container) *helm.Secret { - // get the list of secret vars - secretvars, ok := s.Labels[helm.LABEL_SECRETVARS] - if !ok { - return nil - } - - store := helm.NewSecret(name, "") - for _, secretvar := range strings.Split(secretvars, ",") { - secretvar = strings.TrimSpace(secretvar) - // get the value from env - _, ok := s.Environment[secretvar] - if !ok { - continue - } - // add the secret - store.AddEnv(secretvar, ".Values."+name+".environment."+secretvar) - AddEnvironment(name, secretvar, *s.Environment[secretvar]) - - // Finally remove the secret var from the environment on the service - // and the helm container definition. - defer func(secretvar string) { // defered because AddEnvironment locks the memory - locker.Lock() - defer locker.Unlock() - - for i, env := range c.Env { - if env.Name == secretvar { - c.Env = append(c.Env[:i], c.Env[i+1:]...) - i-- - } - } - - delete(s.Environment, secretvar) - }(secretvar) - } - return store -} diff --git a/generator/main_test.go b/generator/main_test.go deleted file mode 100644 index c90ca39..0000000 --- a/generator/main_test.go +++ /dev/null @@ -1,397 +0,0 @@ -package generator - -import ( - "io/ioutil" - "katenary/compose" - "katenary/helm" - "katenary/logger" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/compose-spec/compose-go/cli" -) - -const DOCKER_COMPOSE_YML = `version: '3' -services: - # first service, very simple - http: - image: nginx - ports: - - "80:80" - - # second service, with environment variables - http2: - image: nginx - environment: - SOME_ENV_VAR: some_value - ANOTHER_ENV_VAR: another_value - - # third service with ingress label - web: - image: nginx - ports: - - "80:80" - labels: - katenary.io/ingress: 80 - - web2: - image: nginx - command: ["/bin/sh", "-c", "while true; do echo hello; sleep 1; done"] - - # fourth service is a php service depending on database - php: - image: php:7.2-apache - depends_on: - - database - environment: - SOME_ENV_VAR: some_value - ANOTHER_ENV_VAR: another_value - DB_HOST: database - labels: - katenary.io/mapenv: | - DB_HOST: {{ .Release.Name }}-database - - database: - image: mysql:5.7 - environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: database - MYSQL_USER: user - MYSQL_PASSWORD: password - volumes: - - data:/var/lib/mysql - labels: - katenary.io/ports: 3306 - - - # try to deploy 2 services but one is in the same pod than the other - http3: - image: nginx - - http4: - image: nginx - labels: - katenary.io/same-pod: http3 - - # unmapped volumes - novol: - image: nginx - volumes: - - /tmp/data - labels: - katenary.io/ports: 80 - - # use = sign for environment variables - eqenv: - image: nginx - environment: - - SOME_ENV_VAR=some_value - - ANOTHER_ENV_VAR=another_value - - # use environment file - useenvfile: - image: nginx - env_file: - - config/env - -volumes: - data: -` - -var defaultCliFiles = cli.DefaultFileNames -var TMP_DIR = "" -var TMPWORK_DIR = "" - -func init() { - logger.NOLOG = len(os.Getenv("NOLOG")) < 1 -} - -func setUp(t *testing.T) (string, *compose.Parser) { - - // cleanup "made" files - helm.ResetMadePVC() - - cli.DefaultFileNames = defaultCliFiles - - // create a temporary directory - tmp, err := os.MkdirTemp(os.TempDir(), "katenary-test-") - if err != nil { - t.Fatal(err) - } - - tmpwork, err := os.MkdirTemp(os.TempDir(), "katenary-test-work-") - if err != nil { - t.Fatal(err) - } - - composefile := filepath.Join(tmpwork, "docker-compose.yaml") - p := compose.NewParser([]string{composefile}, DOCKER_COMPOSE_YML) - - // create envfile for "useenvfile" service - err = os.Mkdir(filepath.Join(tmpwork, "config"), 0777) - if err != nil { - t.Fatal(err) - } - envfile := filepath.Join(tmpwork, "config", "env") - fp, err := os.Create(envfile) - if err != nil { - t.Fatal("MKFILE", err) - } - fp.WriteString("FILEENV1=some_value\n") - fp.WriteString("FILEENV2=another_value\n") - fp.Close() - - TMP_DIR = tmp - TMPWORK_DIR = tmpwork - - p.Parse("testapp") - - Generate(p, "test-0", "testapp", "1.2.3", "4.5.6", DOCKER_COMPOSE_YML, tmp) - - return tmp, p -} - -func tearDown() { - if len(TMP_DIR) > 0 { - os.RemoveAll(TMP_DIR) - } - if len(TMPWORK_DIR) > 0 { - os.RemoveAll(TMPWORK_DIR) - } -} - -// Check if the web2 service has got a command. -func TestCommand(t *testing.T) { - tmp, p := setUp(t) - defer tearDown() - - for _, service := range p.Data.Services { - name := service.Name - if name == "web2" { - // Ensure that the command is correctly set - // The command should be a string array - path := filepath.Join(tmp, "templates", name+".deployment.yaml") - path = filepath.Join(tmp, "templates", name+".deployment.yaml") - fp, _ := os.Open(path) - defer fp.Close() - lines, _ := ioutil.ReadAll(fp) - next := false - commands := make([]string, 0) - for _, line := range strings.Split(string(lines), "\n") { - if strings.Contains(line, "command") { - next = true - continue - } - if next { - commands = append(commands, line) - } - } - ok := 0 - for _, command := range commands { - if strings.Contains(command, "- /bin/sh") { - ok++ - } - if strings.Contains(command, "- -c") { - ok++ - } - if strings.Contains(command, "while true; do") { - ok++ - } - } - if ok != 3 { - t.Error("Command is not correctly set") - } - } - } -} - -// Check if environment is correctly set. -func TestEnvs(t *testing.T) { - tmp, p := setUp(t) - defer tearDown() - - for _, service := range p.Data.Services { - name := service.Name - - if name == "php" { - // the "DB_HOST" environment variable inside the template must be set to '{{ .Release.Name }}-database' - path := filepath.Join(tmp, "templates", name+".deployment.yaml") - // read the file and find the DB_HOST variable - matched := false - fp, _ := os.Open(path) - defer fp.Close() - lines, _ := ioutil.ReadAll(fp) - next := false - for _, line := range strings.Split(string(lines), "\n") { - if !next && strings.Contains(line, "name: DB_HOST") { - next = true - continue - } else if next && strings.Contains(line, "value:") { - matched = true - if !strings.Contains(line, "{{ tpl .Values.php.environment.DB_HOST . }}") { - t.Error("DB_HOST variable should be set to {{ tpl .Values.php.environment.DB_HOST . }}", line, string(lines)) - } - break - } - } - if !matched { - t.Error("DB_HOST variable not found in ", path) - t.Log(string(lines)) - } - } - } -} - -// Check if the same pod is not deployed twice. -func TestSamePod(t *testing.T) { - tmp, p := setUp(t) - defer tearDown() - - for _, service := range p.Data.Services { - name := service.Name - path := filepath.Join(tmp, "templates", name+".deployment.yaml") - - if _, found := service.Labels[helm.LABEL_SAMEPOD]; found { - // fail if the service has a deployment - if _, err := os.Stat(path); err == nil { - t.Error("Service ", name, " should not have a deployment") - } - continue - } - - // others should have a deployment file - t.Log("Checking ", name, " deployment file") - _, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - } -} - -// Check if the ports are correctly set. -func TestPorts(t *testing.T) { - tmp, p := setUp(t) - defer tearDown() - - for _, service := range p.Data.Services { - name := service.Name - path := "" - - // if the service has a port found in helm.LABEL_PORT or ports, so the service file should exist - hasPort := false - if _, found := service.Labels[helm.LABEL_PORT]; found { - hasPort = true - } - if service.Ports != nil { - hasPort = true - } - if hasPort { - path = filepath.Join(tmp, "templates", name+".service.yaml") - t.Log("Checking ", name, " service file") - _, err := os.Stat(path) - if err != nil { - t.Error(err) - } - } - } -} - -// Check if the volumes are correctly set. -func TestPVC(t *testing.T) { - tmp, p := setUp(t) - defer tearDown() - - for _, service := range p.Data.Services { - name := service.Name - path := filepath.Join(tmp, "templates", name+"-data.pvc.yaml") - - // the "database" service should have a pvc file in templates (name-data.pvc.yaml) - if name == "database" { - path = filepath.Join(tmp, "templates", name+"-data.pvc.yaml") - t.Log("Checking ", name, " pvc file") - _, err := os.Stat(path) - if err != nil { - list, _ := filepath.Glob(tmp + "/templates/*") - t.Log(list) - t.Fatal(err) - } - } - } -} - -//Check if web service has got a ingress. -func TestIngress(t *testing.T) { - tmp, p := setUp(t) - defer tearDown() - - for _, service := range p.Data.Services { - name := service.Name - path := filepath.Join(tmp, "templates", name+".ingress.yaml") - - // the "web" service should have a ingress file in templates (name.ingress.yaml) - if name == "web" { - path = filepath.Join(tmp, "templates", name+".ingress.yaml") - t.Log("Checking ", name, " ingress file") - _, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - } - } -} - -// Check unmapped volumes -func TestUnmappedVolumes(t *testing.T) { - tmp, p := setUp(t) - defer tearDown() - - for _, service := range p.Data.Services { - name := service.Name - if name == "novol" { - path := filepath.Join(tmp, "templates", name+".deployment.yaml") - fp, _ := os.Open(path) - defer fp.Close() - lines, _ := ioutil.ReadAll(fp) - for _, line := range strings.Split(string(lines), "\n") { - if strings.Contains(line, "novol-data") { - t.Error("novol service should not have a volume") - } - } - } - } -} - -// Check if service using equal sign for environment works -func TestEqualSignOnEnv(t *testing.T) { - tmp, p := setUp(t) - defer tearDown() - - // if the name is eqenv, the service should habe environment - for _, service := range p.Data.Services { - name := service.Name - if name == "eqenv" { - path := filepath.Join(tmp, "templates", name+".deployment.yaml") - fp, _ := os.Open(path) - defer fp.Close() - lines, _ := ioutil.ReadAll(fp) - match := 0 - for _, line := range strings.Split(string(lines), "\n") { - // we must find the line with the environment variable name - if strings.Contains(line, "SOME_ENV_VAR") { - // we must find the line with the environment variable value - match++ - } - if strings.Contains(line, "ANOTHER_ENV_VAR") { - // we must find the line with the environment variable value - match++ - } - } - if match != 4 { // because the value points on .Values... - t.Error("eqenv service should have 2 environment variables") - t.Log(string(lines)) - } - } - } -} diff --git a/generator/rbac.go b/generator/rbac.go new file mode 100644 index 0000000..8d0df76 --- /dev/null +++ b/generator/rbac.go @@ -0,0 +1,139 @@ +package generator + +import ( + "katenary/utils" + + "github.com/compose-spec/compose-go/types" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +var ( + _ Yaml = (*RoleBinding)(nil) + _ Yaml = (*Role)(nil) + _ Yaml = (*ServiceAccount)(nil) +) + +// RBAC is a kubernetes RBAC containing a role, a rolebinding and an associated serviceaccount. +type RBAC struct { + RoleBinding *RoleBinding + Role *Role + ServiceAccount *ServiceAccount +} + +// NewRBAC creates a new RBAC from a compose service. The appName is the name of the application taken from the project name. +func NewRBAC(service types.ServiceConfig, appName string) *RBAC { + role := &Role{ + Role: &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: "rbac.authorization.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"", "extensions", "apps"}, + Resources: []string{"*"}, + Verbs: []string{"*"}, + }, + }, + }, + service: &service, + } + + rolebinding := &RoleBinding{ + RoleBinding: &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "RoleBinding", + APIVersion: "rbac.authorization.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: utils.TplName(service.Name, appName), + Namespace: "{{ .Release.Namespace }}", + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "Role", + Name: utils.TplName(service.Name, appName), + APIGroup: "rbac.authorization.k8s.io", + }, + }, + service: &service, + } + + serviceaccount := &ServiceAccount{ + ServiceAccount: &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{ + Kind: "ServiceAccount", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + }, + service: &service, + } + + return &RBAC{ + RoleBinding: rolebinding, + Role: role, + ServiceAccount: serviceaccount, + } +} + +// RoleBinding is a kubernetes RoleBinding. +type RoleBinding struct { + *rbacv1.RoleBinding + service *types.ServiceConfig +} + +func (r *RoleBinding) Yaml() ([]byte, error) { + return yaml.Marshal(r) +} + +func (r *RoleBinding) Filename() string { + return r.service.Name + ".rolebinding.yaml" +} + +// Role is a kubernetes Role. +type Role struct { + *rbacv1.Role + service *types.ServiceConfig +} + +func (r *Role) Yaml() ([]byte, error) { + return yaml.Marshal(r) +} + +func (r *Role) Filename() string { + return r.service.Name + ".role.yaml" +} + +// ServiceAccount is a kubernetes ServiceAccount. +type ServiceAccount struct { + *corev1.ServiceAccount + service *types.ServiceConfig +} + +func (r *ServiceAccount) Yaml() ([]byte, error) { + return yaml.Marshal(r) +} + +func (r *ServiceAccount) Filename() string { + return r.service.Name + ".serviceaccount.yaml" +} diff --git a/generator/secret.go b/generator/secret.go new file mode 100644 index 0000000..be98fed --- /dev/null +++ b/generator/secret.go @@ -0,0 +1,111 @@ +package generator + +import ( + "encoding/base64" + "fmt" + "katenary/utils" + "strings" + + "github.com/compose-spec/compose-go/types" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +var _ DataMap = (*Secret)(nil) +var _ Yaml = (*Secret)(nil) + +// Secret is a kubernetes Secret. +// +// Implements the DataMap interface. +type Secret struct { + *corev1.Secret + service types.ServiceConfig `yaml:"-"` +} + +// NewSecret creates a new Secret from a compose service +func NewSecret(service types.ServiceConfig, appName string) *Secret { + secret := &Secret{ + service: service, + Secret: &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Data: make(map[string][]byte), + }, + } + + // check if the value should be in values.yaml + valueList := []string{} + varDescriptons := utils.GetValuesFromLabel(service, LABEL_VALUES) + for value := range varDescriptons { + valueList = append(valueList, value) + } + + // wrap values with quotes + for _, value := range service.Environment { + if value == nil { + continue + } + *value = fmt.Sprintf(`"%s"`, *value) + } + + for _, value := range valueList { + if val, ok := service.Environment[value]; ok { + value = strings.TrimPrefix(value, `"`) + *val = `.Values.` + service.Name + `.environment.` + value + } + } + + for key, value := range service.Environment { + if value == nil { + continue + } + secret.AddData(key, *value) + } + + return secret +} + +// SetData sets the data of the secret. +func (s *Secret) SetData(data map[string]string) { + for key, value := range data { + s.AddData(key, fmt.Sprintf("%s", value)) + } + +} + +// AddData adds a key value pair to the secret. +func (s *Secret) AddData(key string, value string) { + if value == "" { + return + } + s.Data[key] = []byte(`{{ tpl ` + value + ` $ | quote | b64enc }}`) +} + +// Yaml returns the yaml representation of the secret. +func (s *Secret) Yaml() ([]byte, error) { + y, err := yaml.Marshal(s) + if err != nil { + return nil, err + } + + // replace the b64 value by the real value + for _, value := range s.Data { + encoded := base64.StdEncoding.EncodeToString([]byte(value)) + y = []byte(strings.ReplaceAll(string(y), encoded, string(value))) + } + + return y, nil +} + +// Filename returns the filename of the secret. +func (s *Secret) Filename() string { + return s.service.Name + ".secret.yaml" +} diff --git a/generator/service.go b/generator/service.go new file mode 100644 index 0000000..90c1422 --- /dev/null +++ b/generator/service.go @@ -0,0 +1,95 @@ +package generator + +import ( + "katenary/utils" + "regexp" + "strings" + + "github.com/compose-spec/compose-go/types" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/yaml" +) + +var _ Yaml = (*Service)(nil) + +// Service is a kubernetes Service. +type Service struct { + *v1.Service `yaml:",inline"` + service *types.ServiceConfig `yaml:"-"` +} + +// NewService creates a new Service from a compose service. +func NewService(service types.ServiceConfig, appName string) *Service { + + ports := []v1.ServicePort{} + + s := &Service{ + service: &service, + Service: &v1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Spec: v1.ServiceSpec{ + Selector: GetMatchLabels(service.Name, appName), + Ports: ports, + }, + }, + } + for _, port := range service.Ports { + s.AddPort(port) + } + + return s +} + +// AddPort adds a port to the service. +func (s *Service) AddPort(port types.ServicePortConfig, serviceName ...string) { + name := s.service.Name + if len(serviceName) > 0 { + name = serviceName[0] + } + + var finalport intstr.IntOrString + + if targetPort := utils.GetServiceNameByPort(int(port.Target)); targetPort == "" { + finalport = intstr.FromInt(int(port.Target)) + } else { + finalport = intstr.FromString(targetPort) + name = targetPort + } + + s.Spec.Ports = append(s.Spec.Ports, v1.ServicePort{ + Protocol: v1.ProtocolTCP, + Port: int32(port.Target), + TargetPort: finalport, + Name: name, + }) +} + +// Yaml returns the yaml representation of the service. +func (s *Service) Yaml() ([]byte, error) { + y, err := yaml.Marshal(s) + lines := []string{} + for _, line := range strings.Split(string(y), "\n") { + if regexp.MustCompile(`^\s*loadBalancer:\s*`).MatchString(line) { + continue + } + lines = append(lines, line) + } + y = []byte(strings.Join(lines, "\n")) + + return y, err +} + +// Filename returns the filename of the service. +func (s *Service) Filename() string { + return s.service.Name + ".service.yaml" +} diff --git a/generator/types.go b/generator/types.go new file mode 100644 index 0000000..1699242 --- /dev/null +++ b/generator/types.go @@ -0,0 +1,13 @@ +package generator + +// DataMap is a kubernetes ConfigMap or Secret. It can be used to add data to the ConfigMap or Secret. +type DataMap interface { + SetData(map[string]string) + AddData(string, string) +} + +// Yaml is a kubernetes object that can be converted to yaml. +type Yaml interface { + Yaml() ([]byte, error) + Filename() string +} diff --git a/generator/values.go b/generator/values.go index 02b2861..055a6b3 100644 --- a/generator/values.go +++ b/generator/values.go @@ -1,77 +1,121 @@ package generator import ( - "katenary/helm" "strings" "github.com/compose-spec/compose-go/types" ) -var ( - // Values is kept in memory to create a values.yaml file. - Values = make(map[string]map[string]interface{}) -) +// Values is a map of all values for all services. Written to values.yaml. +// var Values = map[string]any{} -// AddValues adds values to the values.yaml map. -func AddValues(servicename string, values map[string]EnvVal) { - 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 - } +// RepositoryValue is a docker repository image and tag that will be saved in values.yaml. +type RepositoryValue struct { + Image string `yaml:"image"` + Tag string `yaml:"tag"` } -func AddEnvironment(servicename string, key string, val EnvVal) { - locker.Lock() - defer locker.Unlock() - - if _, ok := Values[servicename]; !ok { - Values[servicename] = make(map[string]interface{}) - } - - if _, ok := Values[servicename]["environment"]; !ok { - Values[servicename]["environment"] = make(map[string]EnvVal) - } - Values[servicename]["environment"].(map[string]EnvVal)[key] = val - +// PersistenceValue is a persistence configuration that will be saved in values.yaml. +type PersistenceValue struct { + Enabled bool `yaml:"enabled"` + StorageClass string `yaml:"storageClass"` + Size string `yaml:"size"` + AccessMode []string `yaml:"accessMode"` } -// setEnvToValues will set the environment variables to the values.yaml map. -func setEnvToValues(name string, s *types.ServiceConfig, c *helm.Container) { - // crete the "environment" key +// IngressValue is a ingress configuration that will be saved in values.yaml. +type IngressValue struct { + Enabled bool `yaml:"enabled"` + Host string `yaml:"host"` + Path string `yaml:"path"` + Class string `yaml:"class"` + Annotations map[string]string `yaml:"annotations"` +} - env := make(map[string]EnvVal) - for k, v := range s.Environment { - env[k] = v - } - if len(env) == 0 { - return +// Value will be saved in values.yaml. It contains configuraiton for all deployment and services. +// The content will be lile: +// +// name_of_component: +// repository: +// image: image_name +// tag: image_tag +// persistence: +// enabled: true +// storageClass: storage_class_name +// ingress: +// enabled: true +// host: host_name +// path: path_name +// environment: +// ENV_VAR_1: value_1 +// ENV_VAR_2: value_2 +type Value struct { + Repository *RepositoryValue `yaml:"repository,omitempty"` + Persistence map[string]*PersistenceValue `yaml:"persistence,omitempty"` + Ingress *IngressValue `yaml:"ingress,omitempty"` + ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"` + Environment map[string]any `yaml:"environment,omitempty"` + Replicas *uint32 `yaml:"replicas,omitempty"` + CronJob *CronJobValue `yaml:"cronjob,omitempty"` +} + +// CronJobValue is a cronjob configuration that will be saved in values.yaml. +type CronJobValue struct { + Repository *RepositoryValue `yaml:"repository,omitempty"` + Environment map[string]any `yaml:"environment,omitempty"` + ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"` + Schedule string `yaml:"schedule"` +} + +// NewValue creates a new Value from a compose service. +// The value contains the necessary information to deploy the service (image, tag, replicas, etc.). +// +// If `main` is true, the tag will be empty because +// it will be set in the helm chart appVersion. +func NewValue(service types.ServiceConfig, main ...bool) *Value { + replicas := uint32(1) + v := &Value{ + Replicas: &replicas, } - for k, v := range env { - k = strings.ReplaceAll(k, ".", "_") - AddEnvironment(name, k, v) + // find the image tag + tag := "" + split := strings.Split(service.Image, ":") + v.Repository = &RepositoryValue{ + Image: split[0], } - //AddValues(name, map[string]EnvVal{"environment": valuesEnv}) - for k := range env { - fixedK := strings.ReplaceAll(k, ".", "_") - v := "{{ tpl .Values." + name + ".environment." + fixedK + " . }}" - s.Environment[k] = &v - touched := false - for _, c := range c.Env { - if c.Name == k { - c.Value = v - touched = true - } - } - if !touched { - c.Env = append(c.Env, &helm.Value{Name: k, Value: v}) + // for main service, the tag should the appVersion. So here we set it to empty. + if len(main) > 0 && !main[0] { + if len(split) > 1 { + tag = split[1] } + v.Repository.Tag = tag + } else { + v.Repository.Tag = "" + } + + return v +} + +// AddPersistence adds persistence configuration to the Value. +func (v *Value) AddPersistence(volumeName string) { + if v.Persistence == nil { + v.Persistence = make(map[string]*PersistenceValue, 0) + } + v.Persistence[volumeName] = &PersistenceValue{ + Enabled: true, + StorageClass: "-", + Size: "1Gi", + AccessMode: []string{"ReadWriteOnce"}, + } +} + +func (v *Value) AddIngress(host, path string) { + v.Ingress = &IngressValue{ + Enabled: true, + Host: host, + Path: path, + Class: "-", } } diff --git a/generator/version.go b/generator/version.go new file mode 100644 index 0000000..9602118 --- /dev/null +++ b/generator/version.go @@ -0,0 +1,4 @@ +package generator + +// Version is the version of katenary. It is set at compile time. +var Version = "master" // changed at compile time diff --git a/generator/volume.go b/generator/volume.go new file mode 100644 index 0000000..8269b2c --- /dev/null +++ b/generator/volume.go @@ -0,0 +1,119 @@ +package generator + +import ( + "katenary/utils" + "strings" + + "github.com/compose-spec/compose-go/types" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +var _ Yaml = (*VolumeClaim)(nil) + +// VolumeClaim is a kubernetes VolumeClaim. This is a PersistentVolumeClaim. +type VolumeClaim struct { + *v1.PersistentVolumeClaim + service *types.ServiceConfig `yaml:"-"` + volumeName string + nameOverride string +} + +// NewVolumeClaim creates a new VolumeClaim from a compose service. +func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *VolumeClaim { + return &VolumeClaim{ + volumeName: volumeName, + service: &service, + PersistentVolumeClaim: &v1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "PersistentVolumeClaim", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName) + "-" + volumeName, + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Spec: v1.PersistentVolumeClaimSpec{ + AccessModes: []v1.PersistentVolumeAccessMode{ + v1.ReadWriteOnce, + }, + StorageClassName: utils.StrPtr(`{{ .Values.` + service.Name + `.persistence.` + volumeName + `.storageClass }}`), + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceStorage: resource.MustParse("1Gi"), + }, + }, + }, + }, + } +} + +// Yaml marshals a VolumeClaim into yaml. +func (v *VolumeClaim) Yaml() ([]byte, error) { + serviceName := v.service.Name + if v.nameOverride != "" { + serviceName = v.nameOverride + } + volumeName := v.volumeName + out, err := yaml.Marshal(v) + + if err != nil { + return nil, err + } + + // replace 1Gi to {{ .Values.serviceName.volume.size }} + out = []byte( + strings.Replace( + string(out), + "1Gi", + utils.TplValue(serviceName, "persistence."+volumeName+".size"), + 1, + ), + ) + + out = []byte( + strings.Replace( + string(out), + "- ReadWriteOnce", + "{{- .Values."+ + serviceName+ + ".persistence."+ + volumeName+ + ".accessMode | toYaml | nindent __indent__ }}", + 1, + ), + ) + + lines := strings.Split(string(out), "\n") + for i, line := range lines { + if strings.Contains(line, "storageClass") { + lines[i] = utils.Wrap( + line, + "{{- if ne .Values."+serviceName+".persistence."+volumeName+".storageClass \"-\" }}", + "{{- end }}", + ) + } + } + out = []byte(strings.Join(lines, "\n")) + + // add condition + out = []byte( + "{{- if .Values." + + serviceName + + ".persistence." + + volumeName + + ".enabled }}\n" + + string(out) + + "\n{{- end }}", + ) + + return out, nil +} + +// Filename returns the suggested filename for a VolumeClaim. +func (v *VolumeClaim) Filename() string { + return v.service.Name + "." + v.volumeName + ".volumeclaim.yaml" +} diff --git a/generator/volumes.go b/generator/volumes.go deleted file mode 100644 index 28975ce..0000000 --- a/generator/volumes.go +++ /dev/null @@ -1,236 +0,0 @@ -package generator - -import ( - "katenary/helm" - "katenary/logger" - "katenary/tools" - "os" - "path/filepath" - "strings" - - "github.com/compose-spec/compose-go/types" - "gopkg.in/yaml.v3" -) - -var ( - // VolumeValues is the map of volumes for each deployment - // containing volume configuration - VolumeValues = make(map[string]map[string]map[string]EnvVal) -) - -// AddVolumeValues add a volume to the values.yaml map for the given deployment name. -func AddVolumeValues(deployment string, volname string, values map[string]EnvVal) { - locker.Lock() - defer locker.Unlock() - - if _, ok := VolumeValues[deployment]; !ok { - VolumeValues[deployment] = make(map[string]map[string]EnvVal) - } - VolumeValues[deployment][volname] = values -} - -// addVolumeFrom takes the LABEL_VOLUMEFROM to get volumes from another container. This can only work with -// container that has got LABEL_SAMEPOD as we need to get the volumes from another container in the same deployment. -func addVolumeFrom(deployment *helm.Deployment, container *helm.Container, s *types.ServiceConfig) { - labelfrom, ok := s.Labels[helm.LABEL_VOLUMEFROM] - if !ok { - return - } - - // decode Yaml from the label - var volumesFrom map[string]map[string]string - err := yaml.Unmarshal([]byte(labelfrom), &volumesFrom) - if err != nil { - logger.ActivateColors = true - logger.Red(err.Error()) - logger.ActivateColors = false - return - } - - // for each declared volume "from", we will find it from the deployment volumes and add it to the container. - // Then, to avoid duplicates, we will remove it from the ServiceConfig object. - for name, volumes := range volumesFrom { - for volumeName := range volumes { - initianame := 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 { - if c.Name == name { - ctn = c - break - } - } - if ctn == nil { - logger.ActivateColors = true - logger.Redf("VolumeFrom: container %s not found", name) - logger.ActivateColors = false - continue - } - // get the volume from the container - for _, v := range ctn.VolumeMounts { - switch v := v.(type) { - case map[string]interface{}: - if v["name"] == volumeName { - if container.VolumeMounts == nil { - container.VolumeMounts = make([]interface{}, 0) - } - // make a copy of the volume mount and then add it to the VolumeMounts - var mountpoint = make(map[string]interface{}) - for k, v := range v { - mountpoint[k] = v - } - container.VolumeMounts = append(container.VolumeMounts, mountpoint) - - // remove the volume from the ServiceConfig - for i, vol := range s.Volumes { - if vol.Source == initianame { - s.Volumes = append(s.Volumes[:i], s.Volumes[i+1:]...) - i-- - break - } - } - } - } - } - } - } -} - -// prepareVolumes add the volumes of a service. -func prepareVolumes( - deployment, name string, - s *types.ServiceConfig, - container *helm.Container, - fileGeneratorChan HelmFileGenerator) []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 i, cm := range configMapsVolumes { - configMapsVolumes[i] = strings.TrimSpace(cm) - } - } - - for _, vol := range s.Volumes { - - volname := vol.Source - volepath := vol.Target - - if volname == "" { - logger.ActivateColors = true - logger.Yellowf("Warning, volume source to %s is empty for %s -- skipping\n", volepath, name) - logger.ActivateColors = false - continue - } - - isConfigMap := false - for _, cmVol := range configMapsVolumes { - if tools.GetRelPath(volname) == cmVol { - isConfigMap = true - break - } - } - - // local volume cannt be mounted - if !isConfigMap && (strings.HasPrefix(volname, ".") || strings.HasPrefix(volname, "/")) { - logger.ActivateColors = true - logger.Redf("You cannot, at this time, have local volume in %s deployment\n", name) - logger.ActivateColors = false - continue - } - if isConfigMap { - // check if the volname path points on a file, if so, we need to add subvolume to the interface - 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) - } - - // 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 + "-" + tools.PathToName(volname) - - // build a configmapRef for this volume - volname := tools.PathToName(volname) - 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, - }) - } - if cm != nil { - fileGeneratorChan <- cm - } - } else { - // It's a Volume. Mount this from PVC to declare. - - 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 = append(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]EnvVal{ - "enabled": false, - "capacity": "1Gi", - }) - - if pvc := helm.NewPVC(deployment, volname); pvc != nil { - fileGeneratorChan <- pvc - } - } - } - // add the volume in the container and return the volume definition to add in Deployment - container.VolumeMounts = append(container.VolumeMounts, mountPoints...) - return volumes -} diff --git a/generator/writer.go b/generator/writer.go deleted file mode 100644 index d7de1ce..0000000 --- a/generator/writer.go +++ /dev/null @@ -1,236 +0,0 @@ -package generator - -import ( - "katenary/compose" - "katenary/generator/writers" - "katenary/helm" - "katenary/tools" - "log" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/compose-spec/compose-go/types" - "gopkg.in/yaml.v3" -) - -// HelmFile represents a helm file from helm package that has got some necessary methods -// to generate a helm file. -type HelmFile interface { - GetType() string - GetPathRessource() string -} - -// HelmFileGenerator is a chanel of HelmFile. -type HelmFileGenerator chan HelmFile - -var PrefixRE = regexp.MustCompile(`\{\{.*\}\}-?`) - -func portExists(port int, ports []types.ServicePortConfig) bool { - for _, p := range ports { - if p.Target == uint32(port) { - log.Println("portExists:", port, p.Target) - return true - } - } - return false -} - -// Generate get a parsed compose file, and generate the helm files. -func Generate(p *compose.Parser, katernayVersion, appName, appVersion, chartVersion, composeFile, dirName string) { - - // make the appname global (yes... ugly but easy) - helm.Appname = appName - helm.Version = katernayVersion - templatesDir := filepath.Join(dirName, "templates") - - // try to create the directory - err := os.MkdirAll(templatesDir, 0755) - if err != nil { - log.Fatal(err) - } - - generators := make(map[string]HelmFileGenerator) - - // remove skipped services from the parsed data - for i, service := range p.Data.Services { - if v, ok := service.Labels[helm.LABEL_IGNORE]; !ok || v != "true" { - continue - } - p.Data.Services = append(p.Data.Services[:i], p.Data.Services[i+1:]...) - i-- - - // find this service in others as "depends_on" and remove it - for _, service2 := range p.Data.Services { - delete(service2.DependsOn, service.Name) - } - } - - for i, service := range p.Data.Services { - n := service.Name - - // if the service port is declared in labels, add it to the service. - if ports, ok := service.Labels[helm.LABEL_PORT]; ok { - if service.Ports == nil { - service.Ports = make([]types.ServicePortConfig, 0) - } - for _, port := range strings.Split(ports, ",") { - port = strings.TrimSpace(port) - target, err := strconv.Atoi(port) - if err != nil { - log.Fatal(err) - } - if portExists(target, service.Ports) { - continue - } - service.Ports = append(service.Ports, types.ServicePortConfig{ - Target: uint32(target), - }) - } - } - // find port and store it in servicesMap - for _, port := range service.Ports { - target := int(port.Target) - if target != 0 { - servicesMap[n] = target - break - } - } - - // manage emptyDir volumes - if empty, ok := service.Labels[helm.LABEL_EMPTYDIRS]; ok { - //split empty list by coma - emptyDirs := strings.Split(empty, ",") - for i, emptyDir := range emptyDirs { - emptyDirs[i] = strings.TrimSpace(emptyDir) - } - //append them in EmptyDirs - EmptyDirs = append(EmptyDirs, emptyDirs...) - } - p.Data.Services[i] = service - - } - - // for all services in linked map, and not in samePods map, generate the service - for _, s := range p.Data.Services { - name := s.Name - - // do not make a deployment for services declared to be in the same pod than another - if _, ok := s.Labels[helm.LABEL_SAMEPOD]; ok { - continue - } - - // find services that is in the same pod - linked := make(map[string]types.ServiceConfig, 0) - for _, service := range p.Data.Services { - n := service.Name - if linkname, ok := service.Labels[helm.LABEL_SAMEPOD]; ok && linkname == name { - linked[n] = service - delete(s.DependsOn, n) - } - } - - generators[name] = CreateReplicaObject(name, s, linked) - } - - // to generate notes, we need to keep an Ingresses list - ingresses := make(map[string]*helm.Ingress) - - for n, generator := range generators { // generators is a map : name -> generator - for helmFile := range generator { // generator is a chan - if helmFile == nil { // generator finished - break - } - kind := helmFile.(helm.Kinded).Get() - kind = strings.ToLower(kind) - - // Add a SHA inside the generated file, it's only - // to make it easy to check it the compose file corresponds to the - // generated helm chart - helmFile.(helm.Signable).BuildSHA(composeFile) - - // Some types need special fixes in yaml generation - switch c := helmFile.(type) { - case *helm.Storage: - // For storage, we need to add a "condition" to activate it - writers.BuildStorage(c, n, templatesDir) - - case *helm.Deployment: - // for the deployment, we need to fix persitence volumes - // to be activated only when the storage is "enabled", - // either we use an "emptyDir" - writers.BuildDeployment(c, n, templatesDir) - - case *helm.Service: - // Change the type for service if it's an "exposed" port - writers.BuildService(c, n, templatesDir) - - case *helm.Ingress: - // we need to make ingresses "activable" from values - ingresses[n] = c // keep it to generate notes - writers.BuildIngress(c, n, templatesDir) - - case *helm.ConfigMap, *helm.Secret: - // there could be several files, so let's force the filename - name := c.(helm.Named).Name() + "." + c.GetType() - suffix := c.GetPathRessource() - suffix = tools.PathToName(suffix) - name += suffix - name = PrefixRE.ReplaceAllString(name, "") - writers.BuildConfigMap(c, kind, n, name, templatesDir) - - default: - 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) - } - defer fp.Close() - enc := yaml.NewEncoder(fp) - enc.SetIndent(writers.IndentSize) - enc.Encode(c) - } - } - } - // Create the values.yaml file - valueFile, err := os.Create(filepath.Join(dirName, "values.yaml")) - if err != nil { - log.Fatal(err) - } - defer valueFile.Close() - enc := yaml.NewEncoder(valueFile) - enc.SetIndent(writers.IndentSize) - enc.Encode(Values) - - // Create tht Chart.yaml file - chartFile, err := os.Create(filepath.Join(dirName, "Chart.yaml")) - if err != nil { - log.Fatal(err) - } - defer chartFile.Close() - chartFile.WriteString(`# Create on ` + time.Now().Format(time.RFC3339) + "\n") - chartFile.WriteString(`# Katenary command line: ` + strings.Join(os.Args, " ") + "\n") - enc = yaml.NewEncoder(chartFile) - enc.SetIndent(writers.IndentSize) - enc.Encode(map[string]interface{}{ - "apiVersion": "v2", - "name": appName, - "description": "A helm chart for " + appName, - "type": "application", - "version": chartVersion, - "appVersion": appVersion, - }) - - // And finally, create a NOTE.txt file - noteFile, err := os.Create(filepath.Join(templatesDir, "NOTES.txt")) - if err != nil { - log.Fatal(err) - } - defer noteFile.Close() - noteFile.WriteString(helm.GenerateNotesFile(ingresses)) -} diff --git a/generator/writers/configmap.go b/generator/writers/configmap.go deleted file mode 100644 index d045f1d..0000000 --- a/generator/writers/configmap.go +++ /dev/null @@ -1,18 +0,0 @@ -package writers - -import ( - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -// BuildConfigMap writes the configMap. -func BuildConfigMap(c interface{}, kind, servicename, name, templatesDir string) { - fname := filepath.Join(templatesDir, name+"."+kind+".yaml") - fp, _ := os.Create(fname) - enc := yaml.NewEncoder(fp) - enc.SetIndent(IndentSize) - enc.Encode(c) - fp.Close() -} diff --git a/generator/writers/deployment.go b/generator/writers/deployment.go deleted file mode 100644 index 7f594ca..0000000 --- a/generator/writers/deployment.go +++ /dev/null @@ -1,44 +0,0 @@ -package writers - -import ( - "bytes" - "katenary/helm" - "os" - "path/filepath" - "strings" - - "gopkg.in/yaml.v3" -) - -// BuildDeployment builds a deployment. -func BuildDeployment(deployment *helm.Deployment, name, templatesDir string) { - kind := "deployment" - fname := filepath.Join(templatesDir, name+"."+kind+".yaml") - fp, _ := os.Create(fname) - buffer := bytes.NewBuffer(nil) - enc := yaml.NewEncoder(buffer) - enc.SetIndent(IndentSize) - enc.Encode(deployment) - _content := string(buffer.Bytes()) - content := strings.Split(string(_content), "\n") - dataname := "" - component := deployment.Spec.Selector["matchLabels"].(map[string]string)[helm.K+"/component"] - n := 0 // will be count of lines only on "persistentVolumeClaim" line, to indent "else" and "end" at the right place - for _, line := range content { - if strings.Contains(line, "name:") { - dataname = strings.Split(line, ":")[1] - dataname = strings.TrimSpace(dataname) - } else if strings.Contains(line, "persistentVolumeClaim") { - n = CountSpaces(line) - line = strings.Repeat(" ", n) + "{{- if .Values." + component + ".persistence." + dataname + ".enabled }}\n" + line - } else if strings.Contains(line, "claimName") { - spaces := strings.Repeat(" ", n) - line += "\n" + spaces + "{{ else }}" - line += "\n" + spaces + "emptyDir: {}" - line += "\n" + spaces + "{{- end }}" - } - fp.WriteString(line + "\n") - } - fp.Close() - -} diff --git a/generator/writers/ingress.go b/generator/writers/ingress.go deleted file mode 100644 index fbfdc60..0000000 --- a/generator/writers/ingress.go +++ /dev/null @@ -1,101 +0,0 @@ -package writers - -import ( - "bytes" - "katenary/helm" - "os" - "path/filepath" - "strings" - - "gopkg.in/yaml.v3" -) - -const ( - classAndVersionCondition = `{{- if and .Values.__name__.ingress.class (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}` + "\n" - versionCondition118 = `{{- if semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion }}` + "\n" - versionCondition119 = `{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}` + "\n" - apiVersion = `{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} -apiVersion: networking.k8s.io/v1 -{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} -apiVersion: networking.k8s.io/v1beta1 -{{- else -}} -apiVersion: extensions/v1beta1 -{{- end }}` -) - -// BuildIngress generates the ingress yaml file with conditions. -func BuildIngress(ingress *helm.Ingress, name, templatesDir string) { - // Set the backend for 1.18 - for _, b := range ingress.Spec.Rules { - for _, p := range b.Http.Paths { - p.Backend.ServiceName = p.Backend.Service.Name - if n, ok := p.Backend.Service.Port["number"]; ok { - p.Backend.ServicePort = n - } - } - } - kind := "ingress" - buffer := bytes.NewBuffer(nil) - fname := filepath.Join(templatesDir, name+"."+kind+".yaml") - enc := yaml.NewEncoder(buffer) - enc.SetIndent(IndentSize) - buffer.WriteString("{{- if .Values." + name + ".ingress.enabled -}}\n") - enc.Encode(ingress) - buffer.WriteString("{{- end -}}") - - fp, err := os.Create(fname) - if err != nil { - panic(err) - } - defer fp.Close() - - content := string(buffer.Bytes()) - lines := strings.Split(content, "\n") - - backendHit := false - for _, l := range lines { - // apiVersion is a pain... - if strings.Contains(l, "apiVersion:") { - l = apiVersion - } - - // add annotations linked to the Values - if strings.Contains(l, "annotations:") { - n := CountSpaces(l) + IndentSize - l += "\n" + strings.Repeat(" ", n) + "{{- range $k, $v := .Values.__name__.ingress.annotations }}\n" - l += strings.Repeat(" ", n) + "{{ $k }}: {{ $v }}\n" - l += strings.Repeat(" ", n) + "{{- end }}" - l = strings.ReplaceAll(l, "__name__", name) - } - - // pathTyype is ony for 1.19+ - if strings.Contains(l, "pathType:") { - n := CountSpaces(l) - l = strings.Repeat(" ", n) + versionCondition118 + - l + "\n" + - strings.Repeat(" ", n) + "{{- end }}" - } - - if strings.Contains(l, "ingressClassName") { - // should be set only if the version of Kubernetes is 1.18-0 or higher - cond := strings.ReplaceAll(classAndVersionCondition, "__name__", name) - l = ` ` + cond + l + "\n" + ` {{- end }}` - } - - // manage the backend format following the Kubernetes 1.19-0 version or higher - if strings.Contains(l, "service:") { - n := CountSpaces(l) - l = strings.Repeat(" ", n) + versionCondition119 + l - } - if strings.Contains(l, "serviceName:") || strings.Contains(l, "servicePort:") { - n := CountSpaces(l) - if !backendHit { - l = strings.Repeat(" ", n) + "{{- else }}\n" + l - } else { - l = l + "\n" + strings.Repeat(" ", n) + "{{- end }}\n" - } - backendHit = true - } - fp.WriteString(l + "\n") - } -} diff --git a/generator/writers/service.go b/generator/writers/service.go deleted file mode 100644 index c898e27..0000000 --- a/generator/writers/service.go +++ /dev/null @@ -1,24 +0,0 @@ -package writers - -import ( - "katenary/helm" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -// BuildService writes the service (external or not). -func BuildService(service *helm.Service, name, templatesDir string) { - kind := "service" - suffix := "" - if service.Spec.Type == "NodePort" { - suffix = "-external" - } - fname := filepath.Join(templatesDir, name+suffix+"."+kind+".yaml") - fp, _ := os.Create(fname) - enc := yaml.NewEncoder(fp) - enc.SetIndent(IndentSize) - enc.Encode(service) - fp.Close() -} diff --git a/generator/writers/storage.go b/generator/writers/storage.go deleted file mode 100644 index 2201c01..0000000 --- a/generator/writers/storage.go +++ /dev/null @@ -1,32 +0,0 @@ -package writers - -import ( - "katenary/helm" - "log" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -// BuildStorage writes the persistentVolumeClaim. -func BuildStorage(storage *helm.Storage, name, templatesDir string) { - kind := "pvc" - name = storage.Metadata.Labels[helm.K+"/component"] - pvcname := storage.Metadata.Labels[helm.K+"/pvc-name"] - fname := filepath.Join(templatesDir, name+"-"+pvcname+"."+kind+".yaml") - fp, err := os.Create(fname) - if err != nil { - log.Fatal(err) - } - defer fp.Close() - volname := storage.K8sBase.Metadata.Labels[helm.K+"/pvc-name"] - - fp.WriteString("{{ if .Values." + name + ".persistence." + volname + ".enabled }}\n") - enc := yaml.NewEncoder(fp) - enc.SetIndent(IndentSize) - if err := enc.Encode(storage); err != nil { - log.Fatal(err) - } - fp.WriteString("{{- end -}}") -} diff --git a/generator/writers/utils.go b/generator/writers/utils.go deleted file mode 100644 index 5bfa607..0000000 --- a/generator/writers/utils.go +++ /dev/null @@ -1,17 +0,0 @@ -package writers - -// IndentSize set the indentation size for yaml output. Could ba changed by command line argument. -var IndentSize = 2 - -// CountSpaces returns the number of spaces from the begining of the line. -func CountSpaces(line string) int { - var spaces int - for _, char := range line { - if char == ' ' { - spaces++ - } else { - break - } - } - return spaces -} diff --git a/go.mod b/go.mod index 326ba90..4af0223 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,48 @@ -module katenary +module katenary // github.com/metal3d/katenary -go 1.16 +go 1.20 require ( - github.com/alessio/shellescape v1.4.1 - github.com/compose-spec/compose-go v1.2.8 - github.com/distribution/distribution/v3 v3.0.0-20220505155552-985711c1f414 // indirect - github.com/kr/pretty v0.2.0 // indirect - github.com/spf13/cobra v1.5.0 - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - golang.org/x/mod v0.5.1 - golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + github.com/compose-spec/compose-go v1.14.0 + github.com/mitchellh/go-wordwrap v1.0.1 + github.com/spf13/cobra v1.7.0 + github.com/thediveo/netdb v1.0.2 + golang.org/x/mod v0.8.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.27.2 + k8s.io/apimachinery v0.27.2 + sigs.k8s.io/yaml v1.3.0 +) + +require ( + github.com/distribution/distribution/v3 v3.0.0-20230214150026-36d8c594d7aa // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/imdario/mergo v0.3.15 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mattn/go-shellwords v1.0.12 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/sync v0.2.0 // indirect + golang.org/x/sys v0.6.0 // indirect + golang.org/x/text v0.9.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/klog/v2 v2.90.1 // indirect + k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect ) diff --git a/go.sum b/go.sum index ae9b41e..86f3b95 100644 --- a/go.sum +++ b/go.sum @@ -1,191 +1,137 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go v56.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest/autorest v0.11.24/go.mod h1:G6kyRlFnTuSbEYkQGawPfsCswgme4iYf6rfSKUDzbCc= -github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= -github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= -github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= -github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE= -github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= -github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -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= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= -github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= -github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= -github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= -github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= -github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/compose-spec/compose-go v1.2.8 h1:ImPy82xn+rJKL5xmgEyesZEfqJmrzJ1WuZSHEhxMEFI= -github.com/compose-spec/compose-go v1.2.8/go.mod h1:813WrDd7NtOl9ZVqswlJ5iCQy3lxI3KYxKkY8EeHQ7w= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/compose-spec/compose-go v1.14.0 h1:/+tQxBEPIrfsi87Qh7/VjMzcJN3BRNER/RO71ku+u6E= +github.com/compose-spec/compose-go v1.14.0/go.mod h1:m0o4G6MQDHjjz9rY7No9FpnNi+9sKic262rzrwuCqic= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= -github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/distribution/distribution/v3 v3.0.0-20210316161203-a01c71e2477e/go.mod h1:xpWTC2KnJMiDLkoawhsPQcXjvwATEBcbq0xevG2YR9M= -github.com/distribution/distribution/v3 v3.0.0-20220505155552-985711c1f414 h1:KfVB1Z5fm10trO24Rn5Zzocd8sTm5k/gS24ijxQ1aJU= -github.com/distribution/distribution/v3 v3.0.0-20220505155552-985711c1f414/go.mod h1:2oyLKljQFnsI1tzJxjUg4GI+HEpDfzFP3LrGM04rKg0= -github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= +github.com/distribution/distribution/v3 v3.0.0-20230214150026-36d8c594d7aa h1:L9Ay/slwQ4ERSPaurC+TVkZrM0K98GNrEEo1En3e8as= +github.com/distribution/distribution/v3 v3.0.0-20230214150026-36d8c594d7aa/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= -github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= -github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= -github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= -github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= -github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= +github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= +github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= +github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= +github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= +github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= +github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= +github.com/onsi/ginkgo/v2 v2.8.1/go.mod h1:N1/NbDngAFcSLdyZ+/aYTYGSlq9qMCS/cNKGJjy+csc= +github.com/onsi/ginkgo/v2 v2.9.0/go.mod h1:4xkjoL/tZv4SMWeww56BU5kAt19mVB47gTWxmrTcxyk= +github.com/onsi/ginkgo/v2 v2.9.1 h1:zie5Ly042PD3bsCvsSOPvRnFwyo3rKe64TJlD6nu0mk= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= +github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc= +github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM= +github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= +github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= +github.com/onsi/gomega v1.26.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= +github.com/onsi/gomega v1.27.1/go.mod h1:aHX5xOykVYzWOV4WqQy0sy8BQptgukenXpCXfadcIAw= +github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw= +github.com/onsi/gomega v1.27.4 h1:Z2AnStgsdSayCMDiCU42qIz+HLqEPcgiOCXjAU/w+8E= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= -github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= -github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= -github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/thediveo/netdb v1.0.2 h1:icuZWO8btuubgjFFFhxWmXALATlQO6bqEer7DPxRPco= +github.com/thediveo/netdb v1.0.2/go.mod h1:Mz/McdR84D8xUX7rWk0cRgNLrLvqfDPzTAQKUeCR0OY= +github.com/xanzy/go-gitlab v0.81.0/go.mod h1:VMbY3JIWdZ/ckvHbQqkyd3iYk2aViKrNIQ23IbFMQDo= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -193,111 +139,155 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= -github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= -github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= -golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.29.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo= -gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= +k8s.io/api v0.27.2 h1:+H17AJpUMvl+clT+BPnKf0E3ksMAzoBBg7CntpSuADo= +k8s.io/api v0.27.2/go.mod h1:ENmbocXfBT2ADujUXcBhHV55RIT31IIEvkntP6vZKS4= +k8s.io/apimachinery v0.27.2 h1:vBjGaKKieaIreI+oQwELalVG4d8f3YAMNpWLzDXkxeg= +k8s.io/apimachinery v0.27.2/go.mod h1:XNfZ6xklnMCOGGFNqXG7bUrQCoR04dh/E7FprV6pb+E= +k8s.io/klog/v2 v2.90.1 h1:m4bYOKall2MmOiRaR1J+We67Do7vm9KiQVlT96lnHUw= +k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5 h1:kmDqav+P+/5e1i9tFfHq1qcF3sOrDp+YEkVDAHu7Jwk= +k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/helm/configAndSecretMap.go b/helm/configAndSecretMap.go deleted file mode 100644 index daba9ef..0000000 --- a/helm/configAndSecretMap.go +++ /dev/null @@ -1,155 +0,0 @@ -package helm - -import ( - "errors" - "fmt" - "io/ioutil" - "katenary/tools" - "strings" -) - -// InlineConfig is made to represent a configMap or a secret -type InlineConfig interface { - AddEnvFile(filename string, filter []string) error - AddEnv(key, val string) error - Metadata() *Metadata -} - -var _ InlineConfig = (*ConfigMap)(nil) -var _ InlineConfig = (*Secret)(nil) - -// ConfigMap is made to represent a configMap with data. -type ConfigMap struct { - *K8sBase `yaml:",inline"` - Data map[string]string `yaml:"data"` -} - -// NewConfigMap returns a new initialzed ConfigMap. -func NewConfigMap(name, path string) *ConfigMap { - base := NewBase() - base.ApiVersion = "v1" - base.Kind = "ConfigMap" - base.Metadata.Name = ReleaseNameTpl + "-" + name - base.Metadata.Labels[K+"/component"] = name - if path != "" { - base.Metadata.Labels[K+"/path"] = tools.PathToName(path) - } - return &ConfigMap{ - K8sBase: base, - Data: make(map[string]string), - } -} - -// Metadata returns the metadata of the configMap. -func (c *ConfigMap) Metadata() *Metadata { - return c.K8sBase.Metadata -} - -// AddEnvFile adds an environment file to the configMap. -func (c *ConfigMap) AddEnvFile(file string, filter []string) error { - content, err := ioutil.ReadFile(file) - if err != nil { - return err - } - - lines := strings.Split(string(content), "\n") - for _, l := range lines { - //Check if the line is a comment - l = strings.TrimSpace(l) - isComment := strings.HasPrefix(l, "#") - if len(l) == 0 || isComment { - continue - } - parts := strings.SplitN(l, "=", 2) - if len(parts) < 2 { - return errors.New("The environment file " + file + " is not valid") - } - - var skip bool - for _, filterEnv := range filter { - if parts[0] == filterEnv { - skip = true - } - } - if !skip { - // c.Data[parts[0]] = parts[1] - name := strings.ReplaceAll(c.Name(), ReleaseNameTpl+"-", "") - c.Data[parts[0]] = fmt.Sprintf("{{ tpl .Values.%s.environment.%s .}}", name, parts[0]) - } - } - return nil -} - -func (c *ConfigMap) AddEnv(key, val string) error { - c.Data[key] = val - return nil -} - -// Secret is made to represent a secret with data. -type Secret struct { - *K8sBase `yaml:",inline"` - Data map[string]string `yaml:"data"` -} - -// NewSecret returns a new initialzed Secret. -func NewSecret(name, path string) *Secret { - base := NewBase() - base.ApiVersion = "v1" - base.Kind = "Secret" - base.Metadata.Name = ReleaseNameTpl + "-" + name - base.Metadata.Labels[K+"/component"] = name - if path != "" { - base.Metadata.Labels[K+"/path"] = tools.PathToName(path) - } - return &Secret{ - K8sBase: base, - Data: make(map[string]string), - } -} - -// AddEnvFile adds an environment file to the secret. -func (s *Secret) AddEnvFile(file string, filter []string) error { - content, err := ioutil.ReadFile(file) - if err != nil { - return err - } - - lines := strings.Split(string(content), "\n") - for _, l := range lines { - l = strings.TrimSpace(l) - isComment := strings.HasPrefix(l, "#") - if len(l) == 0 || isComment { - continue - } - parts := strings.SplitN(l, "=", 2) - if len(parts) < 2 { - return errors.New("The environment file " + file + " is not valid") - } - - var skip bool - for _, filterEnv := range filter { - if parts[0] == filterEnv { - skip = true - } - } - if !skip { - //s.Data[parts[0]] = fmt.Sprintf(`{{ "%s" | b64enc }}`, parts[1]) - name := strings.ReplaceAll(s.Name(), ReleaseNameTpl+"-", "") - s.Data[parts[0]] = fmt.Sprintf("{{ tpl .Values.%s.environment.%s . | b64enc }}", name, parts[0]) - } - } - - return nil - -} - -// Metadata returns the metadata of the secret. -func (s *Secret) Metadata() *Metadata { - return s.K8sBase.Metadata -} - -// AddEnv adds an environment variable to the secret. -func (s *Secret) AddEnv(key, val string) error { - s.Data[key] = fmt.Sprintf(`{{ %s | b64enc }}`, val) - return nil -} diff --git a/helm/container.go b/helm/container.go deleted file mode 100644 index 05441fa..0000000 --- a/helm/container.go +++ /dev/null @@ -1,65 +0,0 @@ -package helm - -import ( - "katenary/logger" - "strings" - - "github.com/compose-spec/compose-go/types" -) - -type EnvValue interface{} - -// ContainerPort represent a port mapping. -type ContainerPort struct { - Name string - ContainerPort int `yaml:"containerPort"` -} - -// Value represent a environment variable with name and value. -type Value struct { - Name string `yaml:"name"` - Value EnvValue `yaml:"value"` -} - -// Container represent a container with name, image, and environment variables. It is used in Deployment. -type Container struct { - Name string `yaml:"name,omitempty"` - Image string `yaml:"image"` - Ports []*ContainerPort `yaml:"ports,omitempty"` - Env []*Value `yaml:"env,omitempty"` - EnvFrom []map[string]map[string]string `yaml:"envFrom,omitempty"` - Command []string `yaml:"command,omitempty"` - VolumeMounts []interface{} `yaml:"volumeMounts,omitempty"` - LivenessProbe *Probe `yaml:"livenessProbe,omitempty"` -} - -// NewContainer creates a new container with name, image, labels and environment variables. -func NewContainer(name, image string, environment types.MappingWithEquals, labels map[string]string) *Container { - container := &Container{ - Image: image, - Name: name, - EnvFrom: make([]map[string]map[string]string, 0), - } - - // find bound environment variable to a service - toServices := make([]string, 0) - if bound, ok := labels[LABEL_ENV_SERVICE]; ok { - toServices = strings.Split(bound, ",") - } - if len(toServices) > 0 { - // warn, it's deprecated now - logger.ActivateColors = true - logger.Yellowf( - "[deprecated] in \"%s\" service: label %s is deprecated and **ignored**, please use %s instead\n"+ - "e.g.\n"+ - " labels:\n"+ - " FOO: {{ .Release.Name }}-fooservice\n", - name, - LABEL_ENV_SERVICE, - LABEL_MAP_ENV, - ) - logger.ActivateColors = false - } - - return container -} diff --git a/helm/cronTab.go b/helm/cronTab.go deleted file mode 100644 index ff1e454..0000000 --- a/helm/cronTab.go +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 649d1db..0000000 --- a/helm/deployment.go +++ /dev/null @@ -1,47 +0,0 @@ -package helm - -// Deployment is a k8s deployment. -type Deployment struct { - *K8sBase `yaml:",inline"` - Spec *DepSpec `yaml:"spec"` -} - -func NewDeployment(name string) *Deployment { - d := &Deployment{K8sBase: NewBase(), Spec: NewDepSpec()} - d.K8sBase.Metadata.Name = ReleaseNameTpl + "-" + name - 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 -} - -type DepSpec struct { - Replicas int `yaml:"replicas"` - Selector map[string]interface{} `yaml:"selector"` - Template PodTemplate `yaml:"template"` -} - -func NewDepSpec() *DepSpec { - return &DepSpec{ - Replicas: 1, - Template: PodTemplate{ - Metadata: Metadata{ - Labels: map[string]string{ - K + "/resource": "deployment", - }, - }, - }, - } -} - -type PodSpec struct { - InitContainers []*Container `yaml:"initContainers,omitempty"` - Containers []*Container `yaml:"containers"` - Volumes []map[string]interface{} `yaml:"volumes,omitempty"` -} - -type PodTemplate struct { - Metadata Metadata `yaml:"metadata"` - Spec PodSpec `yaml:"spec"` -} diff --git a/helm/ingress.go b/helm/ingress.go deleted file mode 100644 index f2ad8e8..0000000 --- a/helm/ingress.go +++ /dev/null @@ -1,54 +0,0 @@ -package helm - -// Ingress is the kubernetes ingress object. -type Ingress struct { - *K8sBase `yaml:",inline"` - Spec IngressSpec -} - -func NewIngress(name string) *Ingress { - i := &Ingress{} - i.K8sBase = NewBase() - i.K8sBase.Metadata.Name = ReleaseNameTpl + "-" + name - i.K8sBase.Kind = "Ingress" - i.ApiVersion = "networking.k8s.io/v1" - i.K8sBase.Metadata.Labels[K+"/component"] = name - - return i -} - -func (i *Ingress) SetIngressClass(name string) { - class := "{{ .Values." + name + ".ingress.class }}" - i.Spec.IngressClassName = class -} - -type IngressSpec struct { - IngressClassName string `yaml:"ingressClassName,omitempty"` - Rules []IngressRule -} - -type IngressRule struct { - Host string - Http IngressHttp -} - -type IngressHttp struct { - Paths []IngressPath -} - -type IngressPath struct { - Path string - PathType string `yaml:"pathType"` - Backend *IngressBackend -} - -type IngressBackend struct { - Service IngressService - ServiceName string `yaml:"serviceName"` // for kubernetes version < 1.18 - ServicePort interface{} `yaml:"servicePort"` // for kubernetes version < 1.18 -} - -type IngressService struct { - Name string `yaml:"name"` - Port map[string]interface{} `yaml:"port"` -} diff --git a/helm/k8sbase.go b/helm/k8sbase.go deleted file mode 100644 index df95877..0000000 --- a/helm/k8sbase.go +++ /dev/null @@ -1,73 +0,0 @@ -package helm - -import ( - "crypto/sha1" - "fmt" - "io/ioutil" - "strings" -) - -// Metadata is the metadata for a kubernetes object. -type Metadata struct { - Name string `yaml:"name,omitempty"` - Labels map[string]string `yaml:"labels"` - Annotations map[string]string `yaml:"annotations,omitempty"` -} - -func NewMetadata() *Metadata { - return &Metadata{ - Name: "", - Labels: make(map[string]string), - Annotations: make(map[string]string), - } -} - -// K8sBase is the base for all kubernetes objects. -type K8sBase struct { - ApiVersion string `yaml:"apiVersion"` - Kind string `yaml:"kind"` - Metadata *Metadata `yaml:"metadata"` -} - -// NewBase is a factory for creating a new base object with metadata, labels and annotations set to the default. -func NewBase() *K8sBase { - b := &K8sBase{ - Metadata: NewMetadata(), - } - // add some information of the build - b.Metadata.Labels[K+"/project"] = "{{ .Chart.Name }}" - b.Metadata.Labels[K+"/release"] = ReleaseNameTpl - b.Metadata.Annotations[K+"/version"] = Version - return b -} - -func (k *K8sBase) BuildSHA(filename string) { - c, _ := ioutil.ReadFile(filename) - //sum := sha256.Sum256(c) - sum := sha1.Sum(c) - k.Metadata.Annotations[K+"/docker-compose-sha1"] = fmt.Sprintf("%x", string(sum[:])) -} - -// Get returns the Kind. -func (k *K8sBase) Get() string { - return k.Kind -} - -// Name returns the name of the object from Metadata. -func (k *K8sBase) Name() string { - return k.Metadata.Name -} - -func (k *K8sBase) GetType() string { - if n, ok := k.Metadata.Labels[K+"/type"]; ok { - return n - } - return strings.ToLower(k.Kind) -} - -func (k *K8sBase) GetPathRessource() string { - if p, ok := k.Metadata.Labels[K+"/path"]; ok { - return p - } - return "" -} diff --git a/helm/labels.go b/helm/labels.go deleted file mode 100644 index 62db879..0000000 --- a/helm/labels.go +++ /dev/null @@ -1,77 +0,0 @@ -package helm - -import ( - "bytes" - "html/template" -) - -const ReleaseNameTpl = "{{ .Release.Name }}" -const ( - 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" -) - -// GetLabelsDocumentation returns the documentation for the labels. -func GetLabelsDocumentation() string { - t, err := 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) -{{.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://[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`) - if err != nil { - panic(err) - } - buff := bytes.NewBuffer(nil) - t.Execute(buff, map[string]string{ - "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/notes.go b/helm/notes.go deleted file mode 100644 index 54a33ec..0000000 --- a/helm/notes.go +++ /dev/null @@ -1,25 +0,0 @@ -package helm - -import "strings" - -var NOTES = ` -Congratulations, - -Your application is now deployed. This may take a while to be up and responding. - -__list__ -` - -// GenerateNotesFile generates the notes file for the helm chart. -func GenerateNotesFile(ingressess map[string]*Ingress) string { - - list := make([]string, 0) - - for name, ing := range ingressess { - for _, r := range ing.Spec.Rules { - list = append(list, "{{ if .Values."+name+".ingress.enabled -}}\n- "+name+" is accessible on : http://"+r.Host+"\n{{- end }}") - } - } - - return strings.ReplaceAll(NOTES, "__list__", strings.Join(list, "\n")) -} diff --git a/helm/probe.go b/helm/probe.go deleted file mode 100644 index 38608a6..0000000 --- a/helm/probe.go +++ /dev/null @@ -1,104 +0,0 @@ -package helm - -import ( - "time" - - "github.com/compose-spec/compose-go/types" -) - -// Probe is a struct that can be used to create a Liveness or Readiness probe. -type Probe struct { - HttpGet *HttpGet `yaml:"httpGet,omitempty"` - Exec *Exec `yaml:"exec,omitempty"` - TCP *TCP `yaml:"tcpSocket,omitempty"` - Period float64 `yaml:"periodSeconds"` - InitialDelay float64 `yaml:"initialDelaySeconds"` - Success uint64 `yaml:"successThreshold"` - Failure uint64 `yaml:"failureThreshold"` -} - -// Create a new Probe object that can be apply to HttpProbe or TCPProbe. -func NewProbe(period, initialDelaySeconds float64, success, failure uint64) *Probe { - probe := &Probe{ - Period: period, - Success: success, - Failure: failure, - InitialDelay: initialDelaySeconds, - } - - // fix default values from - // https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ - if period == 0 { - probe.Period = 10 - } - if success == 0 { - probe.Success = 1 - } - if failure == 0 { - probe.Failure = 3 - } - return probe -} - -// NewProbeWithDuration creates a new Probe object with the given duration from types. -func NewProbeWithDuration(period, initialDelaySeconds *types.Duration, success, failure *uint64) *Probe { - - if period == nil { - d := types.Duration(0 * time.Second) - period = &d - } - - if initialDelaySeconds == nil { - d := types.Duration(0 * time.Second) - initialDelaySeconds = &d - } - - if success == nil { - s := uint64(0) - success = &s - } - - if failure == nil { - f := uint64(0) - failure = &f - } - - p, err := time.ParseDuration(period.String()) - if err != nil { - p = time.Second * 10 - } - - i, err := time.ParseDuration(initialDelaySeconds.String()) - if err != nil { - i = time.Second * 0 - } - - return NewProbe(p.Seconds(), i.Seconds(), *success, *failure) - -} - -// NewProbeFromService creates a new Probe object from a ServiceConfig. -func NewProbeFromService(s *types.ServiceConfig) *Probe { - if s == nil || s.HealthCheck == nil { - return NewProbe(0, 0, 0, 0) - } - - return NewProbeWithDuration(s.HealthCheck.Interval, s.HealthCheck.StartPeriod, nil, s.HealthCheck.Retries) - -} - -// HttpGet is a Probe configuration to check http health. -type HttpGet struct { - Path string `yaml:"path"` - Port int `yaml:"port"` -} - -// Execis a Probe configuration to check exec health. -type Exec struct { - Command []string `yaml:"command"` -} - -// TCP is a Probe configuration to check tcp health. -type TCP struct { - Port int `yaml:"port"` -} diff --git a/helm/role.go b/helm/role.go deleted file mode 100644 index 111058a..0000000 --- a/helm/role.go +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index a99d8ef..0000000 --- a/helm/roleBinding.go +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 78a0f78..0000000 --- a/helm/service.go +++ /dev/null @@ -1,55 +0,0 @@ -package helm - -import "strconv" - -// Service is a Kubernetes service. -type Service struct { - *K8sBase `yaml:",inline"` - Spec *ServiceSpec `yaml:"spec"` -} - -// NewService creates a new initialized service. -func NewService(name string) *Service { - s := &Service{ - K8sBase: NewBase(), - Spec: NewServiceSpec(), - } - s.K8sBase.Metadata.Name = ReleaseNameTpl + "-" + name - s.K8sBase.Kind = "Service" - s.K8sBase.ApiVersion = "v1" - s.K8sBase.Metadata.Labels[K+"/component"] = name - return s -} - -// ServicePort is a port on a service. -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. -func NewServicePort(port, target int) *ServicePort { - return &ServicePort{ - Protocol: "TCP", - Port: port, - TargetPort: port, - Name: "port-" + strconv.Itoa(target), - } -} - -// ServiceSpec is the spec for a service. -type ServiceSpec struct { - Selector map[string]string - Ports []*ServicePort - Type string `yaml:"type,omitempty"` -} - -// NewServiceSpec creates a new initialized service spec. -func NewServiceSpec() *ServiceSpec { - return &ServiceSpec{ - Selector: make(map[string]string), - Ports: make([]*ServicePort, 0), - } -} diff --git a/helm/serviceAccount.go b/helm/serviceAccount.go deleted file mode 100644 index e7b44c5..0000000 --- a/helm/serviceAccount.go +++ /dev/null @@ -1,18 +0,0 @@ -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/helm/storage.go b/helm/storage.go deleted file mode 100644 index e09e82f..0000000 --- a/helm/storage.go +++ /dev/null @@ -1,54 +0,0 @@ -package helm - -import "sync" - -var ( - made = make(map[string]bool) - locker = sync.Mutex{} -) - -// ResetMadePVC resets the cache of made PVCs. -// Useful in tests only. -func ResetMadePVC() { - locker.Lock() - defer locker.Unlock() - made = make(map[string]bool) -} - -// Storage is a struct for a PersistentVolumeClaim. -type Storage struct { - *K8sBase `yaml:",inline"` - Spec *PVCSpec -} - -// NewPVC creates a new PersistentVolumeClaim object. -func NewPVC(name, storageName string) *Storage { - locker.Lock() - defer locker.Unlock() - if _, ok := made[name+storageName]; ok { - return nil - } - made[name+storageName] = true - pvc := &Storage{} - pvc.K8sBase = NewBase() - pvc.K8sBase.Kind = "PersistentVolumeClaim" - pvc.K8sBase.Metadata.Labels[K+"/pvc-name"] = storageName - pvc.K8sBase.ApiVersion = "v1" - pvc.K8sBase.Metadata.Name = ReleaseNameTpl + "-" + storageName - pvc.K8sBase.Metadata.Labels[K+"/component"] = name - pvc.Spec = &PVCSpec{ - Resouces: map[string]interface{}{ - "requests": map[string]string{ - "storage": "{{ .Values." + name + ".persistence." + storageName + ".capacity }}", - }, - }, - AccessModes: []string{"ReadWriteOnce"}, - } - return pvc -} - -// PVCSpec is a struct for a PersistentVolumeClaim spec. -type PVCSpec struct { - Resouces map[string]interface{} `yaml:"resources"` - AccessModes []string `yaml:"accessModes"` -} diff --git a/helm/types.go b/helm/types.go deleted file mode 100644 index 9fcd3d7..0000000 --- a/helm/types.go +++ /dev/null @@ -1,41 +0,0 @@ -package helm - -import ( - "os" - "strings" -) - -const K = "katenary.io" - -var ( - Appname = "" // set at runtime - Version = "1.0" // should be set from main.Version -) - -// Kinded represent an object with a kind. -type Kinded interface { - // Get must resturn the kind name. - Get() string -} - -// Signable represents an object with a signature. -type Signable interface { - // BuildSHA must return the signature. - BuildSHA(filename string) -} - -// Named represents an object with a name. -type Named interface { - // Name must return the name of the object (from metadata). - Name() string -} - -// GetProjectName returns the name of the project. -func GetProjectName() string { - if len(Appname) > 0 { - return Appname - } - p, _ := os.Getwd() - path := strings.Split(p, string(os.PathSeparator)) - return path[len(path)-1] -} diff --git a/logger/color_test.go b/logger/color_test.go deleted file mode 100644 index 6f0dea0..0000000 --- a/logger/color_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package logger - -import "testing" - -func TestColor(t *testing.T) { - NOLOG = false - Red("Red text") - Grey("Grey text") -} diff --git a/logger/utils.go b/logger/utils.go deleted file mode 100644 index 18438e2..0000000 --- a/logger/utils.go +++ /dev/null @@ -1,96 +0,0 @@ -package logger - -import ( - "fmt" - "os" - "sync" -) - -type Color int - -var ActivateColors = false -var NOLOG = false - -const ( - GREY Color = 30 + iota - RED - GREEN - YELLOW - BLUE - MAGENTA - CYAN -) - -var waiter = sync.Mutex{} - -func color(c Color, args ...interface{}) { - if NOLOG { - return - } - if !ActivateColors { - fmt.Println(args...) - return - } - waiter.Lock() - fmt.Fprintf(os.Stdout, "\x1b[%dm", c) - fmt.Fprint(os.Stdout, args...) - fmt.Fprintf(os.Stdout, "\x1b[0m\n") - waiter.Unlock() -} - -func colorf(c Color, format string, args ...interface{}) { - if NOLOG { - return - } - if !ActivateColors { - fmt.Printf(format, args...) - return - } - waiter.Lock() - fmt.Fprintf(os.Stdout, "\x1b[%dm", c) - fmt.Fprintf(os.Stdout, format, args...) - fmt.Fprintf(os.Stdout, "\x1b[0m") - waiter.Unlock() -} - -func Grey(args ...interface{}) { - color(GREY, args...) -} - -func Red(args ...interface{}) { - color(RED, args...) -} -func Green(args ...interface{}) { - color(GREEN, args...) -} -func Yellow(args ...interface{}) { - color(YELLOW, args...) -} -func Blue(args ...interface{}) { - color(BLUE, args...) -} -func Magenta(args ...interface{}) { - color(MAGENTA, args...) -} -func Greyf(format string, args ...interface{}) { - colorf(GREY, format, args...) -} - -func Redf(format string, args ...interface{}) { - colorf(RED, format, args...) -} -func Greenf(format string, args ...interface{}) { - colorf(GREEN, format, args...) -} -func Yellowf(format string, args ...interface{}) { - colorf(YELLOW, format, args...) -} -func Bluef(format string, args ...interface{}) { - colorf(BLUE, format, args...) -} -func Magentaf(format string, args ...interface{}) { - colorf(MAGENTA, format, args...) -} -func Cyanf(format string, args ...interface{}) { - colorf(CYAN, format, args...) -} diff --git a/parser/main.go b/parser/main.go new file mode 100644 index 0000000..39cf582 --- /dev/null +++ b/parser/main.go @@ -0,0 +1,29 @@ +// Parser package is a wrapper around compose-go to parse compose files. +package parser + +import ( + "github.com/compose-spec/compose-go/cli" + "github.com/compose-spec/compose-go/types" +) + +// Parse compose files and return a project. The project is parsed with dotenv, osenv and profiles. +func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, error) { + + if len(dockerComposeFile) > 0 { + cli.DefaultFileNames = dockerComposeFile + } + + options, err := cli.NewProjectOptions(nil, + cli.WithProfiles(profiles), + cli.WithDefaultConfigPath, + cli.WithOsEnv, + cli.WithDotEnv, + cli.WithNormalization(true), + cli.WithInterpolation(true), + //cli.WithResolvedPaths(true), + ) + if err != nil { + return nil, err + } + return cli.ProjectFromOptions(options) +} diff --git a/test/bats b/test/bats new file mode 160000 index 0000000..e9fd17a --- /dev/null +++ b/test/bats @@ -0,0 +1 @@ +Subproject commit e9fd17a70721e447313691f239d297cecea6dfb7 diff --git a/test/examples.bats b/test/examples.bats new file mode 100644 index 0000000..4151f15 --- /dev/null +++ b/test/examples.bats @@ -0,0 +1,9 @@ +@test "generating and linting examples" { + for d in $(find ../examples/ -maxdepth 1 -mindepth 1 -type d); do + pushd $d + rm -rf chart + go run ../../cmd/katenary convert -f + helm lint chart + popd + done +} diff --git a/test/test_helper/bats-assert b/test/test_helper/bats-assert new file mode 160000 index 0000000..e2d855b --- /dev/null +++ b/test/test_helper/bats-assert @@ -0,0 +1 @@ +Subproject commit e2d855bc78619ee15b0c702b5c30fb074101159f diff --git a/test/test_helper/bats-support b/test/test_helper/bats-support new file mode 160000 index 0000000..9bf10e8 --- /dev/null +++ b/test/test_helper/bats-support @@ -0,0 +1 @@ +Subproject commit 9bf10e876dd6b624fe44423f0b35e064225f7556 diff --git a/tools/path.go b/tools/path.go deleted file mode 100644 index 560704b..0000000 --- a/tools/path.go +++ /dev/null @@ -1,25 +0,0 @@ -package tools - -import ( - "katenary/compose" - "regexp" - "strings" -) - -// replaceChars replaces some chars in a string. -const replaceChars = `[^a-zA-Z0-9_]+` - -// GetRelPath return the relative path from the root of the project. -func GetRelPath(path string) string { - return strings.Replace(path, compose.GetCurrentDir(), ".", 1) -} - -// PathToName transform a path to a yaml name. -func PathToName(path string) string { - path = strings.TrimPrefix(GetRelPath(path), "./") - path = regexp.MustCompile(replaceChars).ReplaceAllString(path, "-") - if path[0] == '-' { - path = path[1:] - } - return path -} diff --git a/tools/path_test.go b/tools/path_test.go deleted file mode 100644 index cbda344..0000000 --- a/tools/path_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package tools - -import ( - "katenary/compose" - "testing" -) - -func Test_PathToName(t *testing.T) { - path := compose.GetCurrentDir() + "/envéfoo.file" - name := PathToName(path) - if name != "env-foo-file" { - t.Error("Expected env-foo-file, got ", name) - } -} diff --git a/update/main.go b/update/main.go index bce0820..c9db8c9 100644 --- a/update/main.go +++ b/update/main.go @@ -1,3 +1,4 @@ +/* Update package is used to check if a new version of katenary is available.*/ package update import ( @@ -76,6 +77,13 @@ func CheckLatestVersion() (string, []Asset, error) { // DownloadLatestVersion will download the latest version of katenary. func DownloadLatestVersion(assets []Asset) error { + + defer func() { + if r := recover(); r != nil { + os.Rename(exe+".old", exe) + } + }() + // Download the latest version fmt.Println("Downloading the latest version...") diff --git a/utils/doc.go b/utils/doc.go new file mode 100644 index 0000000..b3896e7 --- /dev/null +++ b/utils/doc.go @@ -0,0 +1,2 @@ +// Utils package provides some utility functions used in katenary. It defines some constants and functions used in the whole project. +package utils diff --git a/utils/hash.go b/utils/hash.go new file mode 100644 index 0000000..c6f7746 --- /dev/null +++ b/utils/hash.go @@ -0,0 +1,26 @@ +package utils + +import ( + "crypto/sha1" + "encoding/hex" + "io" + "os" + "sort" +) + +// HashComposefiles returns a hash of the compose files. +func HashComposefiles(files []string) (string, error) { + sort.Strings(files) // ensure the order is always the same + sha := sha1.New() + for _, file := range files { + f, err := os.Open(file) + if err != nil { + return "", err + } + defer f.Close() + if _, err := io.Copy(sha, f); err != nil { + return "", err + } + } + return hex.EncodeToString(sha.Sum(nil)), nil +} diff --git a/utils/icons.go b/utils/icons.go new file mode 100644 index 0000000..3767db5 --- /dev/null +++ b/utils/icons.go @@ -0,0 +1,31 @@ +package utils + +import "fmt" + +// Icon is a unicode icon +type Icon string + +// Icons used in katenary. +const ( + IconSuccess Icon = "✅" + IconFailure = "❌" + IconWarning = "⚠️'" + IconNote = "📝" + IconWorld = "🌐" + IconPlug = "🔌" + IconPackage = "📦" + IconCabinet = "🗄️" + IconInfo = "❕" + IconSecret = "🔒" + IconConfig = "🔧" + IconDependency = "🔗" +) + +// Warn prints a warning message +func Warn(msg ...interface{}) { + orange := "\033[38;5;214m" + reset := "\033[0m" + fmt.Print(IconWarning, orange, " ") + fmt.Print(msg...) + fmt.Println(reset) +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..ecccc66 --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,163 @@ +package utils + +import ( + "log" + "path/filepath" + "strings" + + "github.com/compose-spec/compose-go/types" + "github.com/mitchellh/go-wordwrap" + "github.com/thediveo/netdb" + "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" +) + +// TplName returns the name of the kubernetes resource as a template string. +// It is used in the templates and defined in _helper.tpl file. +func TplName(serviceName, appname string, suffix ...string) string { + if len(suffix) > 0 { + suffix[0] = "-" + suffix[0] + } + return `{{ include "` + appname + `.fullname" . }}-` + serviceName + strings.Join(suffix, "-") +} + +// Int32Ptr returns a pointer to an int32. +func Int32Ptr(i int32) *int32 { return &i } + +// StrPtr returns a pointer to a string. +func StrPtr(s string) *string { return &s } + +// CountStartingSpaces counts the number of spaces at the beginning of a string. +func CountStartingSpaces(line string) int { + count := 0 + for _, char := range line { + if char == ' ' { + count++ + } else { + break + } + } + return count +} + +// GetKind returns the kind of the resource from the file path. +func GetKind(path string) (kind string) { + defer func() { + if r := recover(); r != nil { + kind = "" + } + }() + filename := filepath.Base(path) + parts := strings.Split(filename, ".") + if len(parts) == 2 { + kind = parts[0] + } else { + kind = strings.Split(path, ".")[1] + } + return +} + +// Wrap wraps a string with a string above and below. It will respect the indentation of the src string. +func Wrap(src, above, below string) string { + spaces := strings.Repeat(" ", CountStartingSpaces(src)) + return spaces + above + "\n" + src + "\n" + spaces + below +} + +// WrapBytes wraps a byte array with a byte array above and below. It will respect the indentation of the src string. +func WrapBytes(src, above, below []byte) []byte { + return []byte(Wrap(string(src), string(above), string(below))) +} + +// GetServiceNameByPort returns the service name for a port. It the service name is not found, it returns an empty string. +func GetServiceNameByPort(port int) string { + name := "" + info := netdb.ServiceByPort(port, "tcp") + if info != nil { + name = info.Name + } + return name +} + +// GetContainerByName returns a container by name and its index in the array. It returns nil, -1 if not found. +func GetContainerByName(name string, containers []corev1.Container) (*corev1.Container, int) { + for index, c := range containers { + if c.Name == name { + return &c, index + } + } + return nil, -1 +} + +// GetContainerByName returns a container by name and its index in the array. +func TplValue(serviceName, variable string, pipes ...string) string { + + if len(pipes) == 0 { + return `{{ tpl .Values.` + serviceName + `.` + variable + ` $ }}` + } else { + return `{{ tpl .Values.` + serviceName + `.` + variable + ` $ | ` + strings.Join(pipes, " | ") + ` }}` + } +} + +// PathToName converts a path to a kubernetes complient name. +func PathToName(path string) string { + if len(path) == 0 { + return "" + } + + path = filepath.Clean(path) + if path[0] == '/' || path[0] == '.' { + path = path[1:] + } + path = strings.Replace(path, "/", "_", -1) + path = strings.Replace(path, ".", "_", -1) + return path +} + +// EnvConfig is a struct to hold the description of an environment variable. +type EnvConfig struct { + Description string + Service types.ServiceConfig +} + +// GetValuesFromLabel returns a map of values from a label. +func GetValuesFromLabel(service types.ServiceConfig, LabelValues string) map[string]*EnvConfig { + descriptions := make(map[string]*EnvConfig) + if v, ok := service.Labels[LabelValues]; ok { + labelContent := []any{} + err := yaml.Unmarshal([]byte(v), &labelContent) + if err != nil { + log.Printf("Error parsing label %s: %s", v, err) + log.Fatal(err) + } + for _, value := range labelContent { + switch value.(type) { + case string: + descriptions[value.(string)] = nil + case map[string]interface{}: + for k, v := range value.(map[string]interface{}) { + descriptions[k] = &EnvConfig{Service: service, Description: v.(string)} + } + case map[interface{}]interface{}: + for k, v := range value.(map[interface{}]interface{}) { + descriptions[k.(string)] = &EnvConfig{Service: service, Description: v.(string)} + } + default: + log.Fatalf("Unknown type in label: %s %T", LabelValues, value) + } + } + } + return descriptions +} + +// WordWrap wraps a string to a given line width. Warning: it may break the string. You need to check the result. +func WordWrap(text string, lineWidth int) string { + return wordwrap.WrapString(text, uint(lineWidth)) +} + +func MapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} From 3a0cf1a7db86b6067d6983bd62acdc93b77901bf Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 14:15:28 +0200 Subject: [PATCH 02/97] Set test(s) to PHONY --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f507674..2ecd77b 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ SHELL := bash .DELETE_ON_ERROR: MAKEFLAGS += --warn-undefined-variables MAKEFLAGS += --no-builtin-rules -.PHONY: help clean build install +.PHONY: help clean build install tests test all: build From 6e33fa474a1829affff0a365218b0493d1a7d4ae Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 14:15:50 +0200 Subject: [PATCH 03/97] Get the newest version of go-compose - better integration of compose file - needed to impose to not resolve path in "parser" package to get them from the working directory. This should be improved later --- go.mod | 13 +++++++------ go.sum | 29 ++++++++++++++++------------- parser/main.go | 1 + 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 4af0223..83092ae 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module katenary // github.com/metal3d/katenary go 1.20 require ( - github.com/compose-spec/compose-go v1.14.0 + github.com/compose-spec/compose-go v1.20.2 github.com/mitchellh/go-wordwrap v1.0.1 - github.com/spf13/cobra v1.7.0 + github.com/spf13/cobra v1.8.0 github.com/thediveo/netdb v1.0.2 - golang.org/x/mod v0.8.0 + golang.org/x/mod v0.11.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.27.2 k8s.io/apimachinery v0.27.2 @@ -15,13 +15,13 @@ require ( ) require ( - github.com/distribution/distribution/v3 v3.0.0-20230214150026-36d8c594d7aa // indirect + github.com/distribution/reference v0.5.0 // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/imdario/mergo v0.3.15 // indirect + github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect @@ -35,8 +35,9 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect golang.org/x/net v0.8.0 // indirect - golang.org/x/sync v0.2.0 // indirect + golang.org/x/sync v0.3.0 // indirect golang.org/x/sys v0.6.0 // indirect golang.org/x/text v0.9.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 86f3b95..3d3f2fb 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,14 @@ cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1h github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/compose-spec/compose-go v1.14.0 h1:/+tQxBEPIrfsi87Qh7/VjMzcJN3BRNER/RO71ku+u6E= -github.com/compose-spec/compose-go v1.14.0/go.mod h1:m0o4G6MQDHjjz9rY7No9FpnNi+9sKic262rzrwuCqic= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/compose-spec/compose-go v1.20.2 h1:u/yfZHn4EaHGdidrZycWpxXgFffjYULlTbRfJ51ykjQ= +github.com/compose-spec/compose-go v1.20.2/go.mod h1:+MdqXV4RA7wdFsahh/Kb8U0pAJqkg7mr4PM9tFKU8RM= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/distribution/v3 v3.0.0-20230214150026-36d8c594d7aa h1:L9Ay/slwQ4ERSPaurC+TVkZrM0K98GNrEEo1En3e8as= -github.com/distribution/distribution/v3 v3.0.0-20230214150026-36d8c594d7aa/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI= +github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= +github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -53,8 +53,8 @@ github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrj github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= -github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -114,8 +114,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -128,7 +128,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/thediveo/netdb v1.0.2 h1:icuZWO8btuubgjFFFhxWmXALATlQO6bqEer7DPxRPco= github.com/thediveo/netdb v1.0.2/go.mod h1:Mz/McdR84D8xUX7rWk0cRgNLrLvqfDPzTAQKUeCR0OY= github.com/xanzy/go-gitlab v0.81.0/go.mod h1:VMbY3JIWdZ/ckvHbQqkyd3iYk2aViKrNIQ23IbFMQDo= @@ -148,14 +148,17 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -185,8 +188,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= -golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/parser/main.go b/parser/main.go index 39cf582..9538e7a 100644 --- a/parser/main.go +++ b/parser/main.go @@ -20,6 +20,7 @@ func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, erro cli.WithDotEnv, cli.WithNormalization(true), cli.WithInterpolation(true), + cli.WithResolvedPaths(false), //cli.WithResolvedPaths(true), ) if err != nil { From f9cf3972d5cb45d69ef3bacc5cb77677ddba1198 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 14:18:16 +0200 Subject: [PATCH 04/97] Removed Smile sponsorship Thanks for the given time. --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 5e64f5d..f89d8c1 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,6 @@ Katenary is a tool to help to transform `docker-compose` files to a working Helm > **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) - -
-Smile Logo -
- # Install You can download the binaries from the [Release](https://github.com/metal3d/katenary/releases) section. Copy the binary and rename it to `katenary`. Place the binary inside your `PATH`. You should now be able to call the `katenary` command. From d135f5bc0a966163004d28dd78700ee5990f3c3c Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 14:24:43 +0200 Subject: [PATCH 05/97] Remove the Smile sponsorship One more time, thanks a lot for the adventure --- doc/overrides/partials/footer.html | 7 ------- 1 file changed, 7 deletions(-) diff --git a/doc/overrides/partials/footer.html b/doc/overrides/partials/footer.html index f4d6186..4e78a27 100644 --- a/doc/overrides/partials/footer.html +++ b/doc/overrides/partials/footer.html @@ -52,12 +52,5 @@ From ed9b4681ad61b2438be78fae938ceca706176243 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 16:15:24 +0200 Subject: [PATCH 06/97] Unignore some yaml files katenaryLabelsDoc.yaml is a file that is used to generate the documentation, it is not a file that should be ignored. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6bf1379..3cdc952 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/* chart/* *.yaml *.yml +!generator/*.yaml !doc/mkdocs.yaml !.readthedocs.yaml ./katenary From 5a358f0a6a43a7afb7083bbb7d9a83ff707dfcd0 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 16:16:28 +0200 Subject: [PATCH 07/97] Add labels docs This file was ignored by error in .gitignore --- generator/katenaryLabelsDoc.yaml | 287 +++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 generator/katenaryLabelsDoc.yaml diff --git a/generator/katenaryLabelsDoc.yaml b/generator/katenaryLabelsDoc.yaml new file mode 100644 index 0000000..ea34133 --- /dev/null +++ b/generator/katenaryLabelsDoc.yaml @@ -0,0 +1,287 @@ +# Labels documentation. +# +# To create a label documentation: +# +# "labelname": +# type: the label type (bool, string, array, object...) +# short: a short description +# long: |- +# A multiline description to explain the label behavior +# example: |- +# yamlsyntax: here +# +# This file is embed in the Katenary binary and parsed in kanetaryLabels.go init() function. +# +# Note: +# - The short and long texts are parsed with text/template, so you can use template syntax. +# That means that if you want to display double brackets, you need to enclose them to +# prevent template to try to expand the content, for example : +# This is an {{ "{{ example }}" }}. +# +# This will display "This is an {{ exemple }}" in the output. +# - Use {{ .KATENARY_PREFIX }} to let Katenary replace it with the label prefix (e.g. "katenary.v3/") + +"main-app": + short: "Mark the service as the main app." + long: |- + This makes the service to be the main application. Its image tag is + considered to be the + + Chart appVersion and to be the defaultvalue in Pod container + image attribute. + + !!! Warning + This label cannot be repeated in others services. If this label is + set in more than one service as true, Katenary will return an error. + example: |- + ghost: + image: ghost:1.25.5 + labels: + # The chart is now named ghost, and the appVersion is 1.25.5. + # In Deployment, the image attribute is set to ghost:1.25.5 if + # you don't change the "tag" attribute in values.yaml + {{ .KATENARY_PREFIX }}main-app: true + type: "bool" + +"values": + short: "Environment variables to be added to the values.yaml" + long: |- + By default, all environment variables in the "env" and environment + files are added to configmaps with the static values set. This label + allows to add environment variables to the values.yaml file. + + Note that the value inside the configmap is {{ "{{ tpl vaname . }}" }}, so + you can set the value to a template that will be rendered with the + values.yaml file. + + The value can be set with a documentation. This may help to understand + the purpose of the variable. + example: |- + env: + FOO: bar + DB_NAME: mydb + TO_CONFIGURE: something that can be changed in values.yaml + A_COMPLEX_VALUE: example + labels: + {{ .KATENARY_PREFIX }}values: |- + # simple values, set as is in values.yaml + - TO_CONFIGURE + # complex values, set as a template in values.yaml with a documentation + - A_COMPLEX_VALUE: |- + This is the documentation for the variable to + configure in values.yaml. + It can be, of course, a multiline text. + type: "list of string or map" + +"secrets": + short: "Env vars to be set as secrets." + long: |- + This label allows setting the environment variables as secrets. The variable + is removed from the environment and added to a secret object. + + The variable can be set to the {{ printf "%s%s" .KATENARY_PREFIX "values"}} too, + so the secret value can be configured in values.yaml + example: |- + env: + PASSWORD: a very secret password + NOT_A_SECRET: a public value + labels: + {{ .KATENARY_PREFIX }}secrets: |- + - PASSWORD + type: "list of string" + +"ports": + short: "Ports to be added to the service." + long: |- + Only useful for services without exposed port. It is mandatory if the + service is a dependency of another service. + example: |- + labels: + {{ .KATENARY_PREFIX }}ports: |- + - 8080 + - 8081 + type: "list of uint32" + +"ingress": + short: "Ingress rules to be added to the service." + long: |- + Declare an ingress rule for the service. The port should be exposed or + declared with {{ printf "%s%s" .KATENARY_PREFIX "ports" }}. + example: |- + labels: + {{ .KATENARY_PREFIX }}ingress: |- + port: 80 + hostname: mywebsite.com (optional) + type: "object" + +"map-env": + short: "Map env vars from the service to the deployment." + long: |- + Because you may need to change the variable for Kubernetes, this label + forces the value to another. It is also particullary helpful to use a template + value instead. For example, you could bind the value to a service name + with Helm attributes: + {{ "{{ tpl .Release.Name . }}" }}. + + If you use __APP__ in the value, it will be replaced by the Chart name. + example: |- + env: + DB_HOST: database + RUNNING: docker + OTHER: value + labels: + {{ .KATENARY_PREFIX }}map-env: |- + RUNNING: kubernetes + DB_HOST: '{{ "{{ include \"__APP__.fullname\" . }}" }}-database' + type: "object" + +"health-check": + short: "Health check to be added to the deployment." + long: "Health check to be added to the deployment." + example: |- + labels: + {{ .KATENARY_PREFIX }}health-check: |- + httpGet: + path: /health + port: 8080 + type: "object" + +"same-pod": + short: "Move the same-pod deployment to the target deployment." + long: |- + This will make the service to be included in another service pod. Some services + must work together in the same pod, like a sidecar or a proxy or nginx + php-fpm. + + Note that volume and VolumeMount are copied from the source to the target + deployment. + example: |- + web: + image: nginx:1.19 + + php: + image: php:7.4-fpm + labels: + {{ .KATENARY_PREFIX }}same-pod: web + type: "string" + +"description": + short: "Description of the service" + long: |- + This replaces the default comment in values.yaml file to the given description. + It is useful to document the service and configuration. + + The value can be set with a documentation in multiline format. + example: |- + labels: + {{ .KATENARY_PREFIX }}description: |- + This is a description of the service. + It can be multiline. + type: "string" + +"ignore": + short: "Ignore the service" + long: "Ingoring a service to not be exported in helm chart." + example: "labels:\n {{ .KATENARY_PREFIX }}ignore: \"true\"" + type: "bool" + +"dependencies": + short: "Add Helm dependencies to the service." + long: |- + Set the service to be, actually, a Helm dependency. This means that the + service will not be exported as template. The dependencies are added to + the Chart.yaml file and the values are added to the values.yaml file. + + It's a list of objects with the following attributes: + + - name: the name of the dependency + - repository: the repository of the dependency + - alias: the name of the dependency in values.yaml (optional) + - values: the values to be set in values.yaml (optional) + + !!! Info + Katenary doesn't update the helm depenedencies by default. + + Use `--helm-update` (or `-u`) flag to update the dependencies. + + example: katenary convert -u + + By setting an alias, it is possible to change the name of the dependency + in values.yaml. + example: |- + labels: + {{ .KATENARY_PREFIX }}dependencies: |- + - name: mariadb + repository: oci://registry-1.docker.io/bitnamicharts + + ## optional, it changes the name of the section in values.yaml + # alias: mydatabase + + ## optional, it adds the values to values.yaml + values: + auth: + database: mydatabasename + username: myuser + password: the secret password + type: "list of objects" + +"configmap-files": + short: "Add files to the configmap." + long: |- + It makes a file or directory to be converted to one or more ConfigMaps + and mounted in the pod. The file or directory is relative to the + service directory. + + If it is a directory, all files inside it are added to the ConfigMap. + + If the directory as subdirectories, so one configmap per subpath are created. + + !!! Warning + It is not intended to be used to store an entire project in configmaps. + It is intended to be used to store configuration files that are not managed + by the application, like nginx configuration files. Keep in mind that your + project sources should be stored in an application image or in a storage. + example: |- + volumes + - ./conf.d:/etc/nginx/conf.d + labels: + {{ .KATENARY_PREFIX }}configmap-files: |- + - ./conf.d + type: "list of strings" + +"cronjob": + short: "Create a cronjob from the service." + long: |- + This adds a cronjob to the chart. + + The label value is a YAML object with the following attributes: + - command: the command to be executed + - schedule: the cron schedule (cron format or @every where "every" is a + duration like 1h30m, daily, hourly...) + - rbac: false (optionnal), if true, it will create a role, a rolebinding and + a serviceaccount to make your cronjob able to connect the Kubernetes API + example: |- + labels: + {{ .KATENARY_PREFIX }}cronjob: |- + command: echo "hello world" + schedule: "* */1 * * *" # or @hourly for example + type: "object" + +"env-from": + short: "Add environment variables from antoher service." + type: "list of strings" + long: |- + It adds environment variables from another service to the current service. + example: |- + service1: + image: nginx:1.19 + environment: + FOO: bar + + service2: + image: php:7.4-fpm + labels: + # get the congigMap from service1 where FOO is + # defined inside this service too + {{ .KATENARY_PREFIX }}env-from: |- + - myservice1 +# vim: ft=gotmpl.yaml From 5d4f72e984b886d7f7dc2ddc071bdb2ec5a537ed Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 21:33:26 +0200 Subject: [PATCH 08/97] Fix unued variable, useless functions... --- generator/converter.go | 5 +++-- generator/deployment.go | 7 ------- generator/globals.go | 3 --- generator/secret.go | 2 +- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/generator/converter.go b/generator/converter.go index 9af18ff..e3e5bf1 100644 --- a/generator/converter.go +++ b/generator/converter.go @@ -388,6 +388,7 @@ const imagePullSecretHelp = ` func addImagePullSecretsHelp(values []byte) []byte { // add imagePullSecrets help lines := strings.Split(string(values), "\n") + for i, line := range lines { if strings.Contains(line, "pullSecrets:") { spaces := utils.CountStartingSpaces(line) @@ -411,10 +412,10 @@ func addChartDoc(values []byte, project *types.Project) []byte { lines := strings.Split(string(values), "\n") for i, line := range lines { if regexp.MustCompile(`(?m)^name:`).MatchString(line) { - doc := fmt.Sprintf("\n# Name of the chart (required), basically the name of the project.\n") + doc := "\n# Name of the chart (required), basically the name of the project.\n" lines[i] = doc + line } else if regexp.MustCompile(`(?m)^version:`).MatchString(line) { - doc := fmt.Sprintf("\n# Version of the chart (required)\n") + doc := "\n# Version of the chart (required)\n" lines[i] = doc + line } else if strings.Contains(line, "appVersion:") { spaces := utils.CountStartingSpaces(line) diff --git a/generator/deployment.go b/generator/deployment.go index 627bfd3..394ed46 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -33,13 +33,6 @@ type Deployment struct { // It also creates the Values map that will be used to create the values.yaml file. func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { - ports := []corev1.ContainerPort{} - for _, port := range service.Ports { - ports = append(ports, corev1.ContainerPort{ - ContainerPort: int32(port.Target), - }) - } - isMainApp := false if mainLabel, ok := service.Labels[LABEL_MAIN_APP]; ok { main := strings.ToLower(mainLabel) diff --git a/generator/globals.go b/generator/globals.go index edab2a4..e4e74ba 100644 --- a/generator/globals.go +++ b/generator/globals.go @@ -3,9 +3,6 @@ package generator import "regexp" var ( - // regexp to all tpl strings - tplValueRegexp = regexp.MustCompile(`\{\{.*\}\}-`) - // find all labels starting by __replace_ and ending with ":" // and get the value between the quotes // ?s => multiline diff --git a/generator/secret.go b/generator/secret.go index be98fed..eb020ed 100644 --- a/generator/secret.go +++ b/generator/secret.go @@ -76,7 +76,7 @@ func NewSecret(service types.ServiceConfig, appName string) *Secret { // SetData sets the data of the secret. func (s *Secret) SetData(data map[string]string) { for key, value := range data { - s.AddData(key, fmt.Sprintf("%s", value)) + s.AddData(key, value) } } From 76aa332dc2093a547cace2f64c24bf9c8b839a3a Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 21:34:15 +0200 Subject: [PATCH 09/97] Fix dependencies parsing The code was fetching a simple object while it should have been fetching an array of objects. --- generator/generator.go | 93 +++++------------------------------------- 1 file changed, 11 insertions(+), 82 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index 192ef74..869f9a8 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -321,19 +321,22 @@ func setCronJob(service types.ServiceConfig, chart *HelmChart, appName string) * func setDependencies(chart *HelmChart, service types.ServiceConfig) (bool, error) { // helm dependency if v, ok := service.Labels[LABEL_DEPENDENCIES]; ok { - d := Dependency{} + d := []Dependency{} if err := yaml.Unmarshal([]byte(v), &d); err != nil { return false, err } - fmt.Printf("%s Adding dependency to %s\n", utils.IconDependency, d.Name) - chart.Dependencies = append(chart.Dependencies, d) - name := d.Name - if d.Alias != "" { - name = d.Alias + for _, dep := range d { + fmt.Printf("%s Adding dependency to %s\n", utils.IconDependency, dep.Name) + chart.Dependencies = append(chart.Dependencies, dep) + name := dep.Name + if dep.Alias != "" { + name = dep.Alias + } + // add the dependency env vars to the values.yaml + chart.Values[name] = dep.Values } - // add the dependency env vars to the values.yaml - chart.Values[name] = d.Values + return true, nil } return false, nil @@ -495,80 +498,6 @@ func generateConfigMapsAndSecrets(project *types.Project, chart *HelmChart) erro return nil } -func mergePods(target, from *Deployment, services map[string]*Service, chart *HelmChart) { - - targetName := target.service.Name - fromName := from.service.Name - - // copy the volumes from the source deployment - for _, v := range from.Spec.Template.Spec.Volumes { - // ensure that the volume is not already present - found := false - for _, tv := range target.Spec.Template.Spec.Volumes { - if tv.Name == v.Name { - found = true - break - } - } - if found { - continue - } - target.Spec.Template.Spec.Volumes = append(target.Spec.Template.Spec.Volumes, v) - } - // copy the containers from the source deployment - for _, c := range from.Spec.Template.Spec.Containers { - target.Spec.Template.Spec.Containers = append(target.Spec.Template.Spec.Containers, c) - } - // copy the init containers from the source deployment - for _, c := range from.Spec.Template.Spec.InitContainers { - target.Spec.Template.Spec.InitContainers = append(target.Spec.Template.Spec.InitContainers, c) - } - // drop the deployment from the chart - delete(chart.Templates, fromName+".deployment.yaml") - - // rewite the target deployment - y, err := target.Yaml() - if err != nil { - log.Fatal("error rewriting deployment:", err) - } - chart.Templates[target.Filename()] = &ChartTemplate{ - Content: y, - Servicename: targetName, - } - - // now, if the source deployment has a service, we need to merge it with the target service - if _, ok := chart.Templates[targetName+".service.yaml"]; ok { - container, _ := utils.GetContainerByName(fromName, target.Spec.Template.Spec.Containers) - if container.Ports == nil || len(container.Ports) == 0 { - return - } - targetService := services[targetName] - for _, port := range container.Ports { - targetService.AddPort(types.ServicePortConfig{ - Target: uint32(port.ContainerPort), - Protocol: "TCP", - }, port.Name) - } - // rewrite the tartget service - y, _ := targetService.Yaml() - chart.Templates[targetName+".service.yaml"] = &ChartTemplate{ - Content: y, - Servicename: target.service.Name, - } - - // and remove the source service from the chart - delete(chart.Templates, fromName+".service.yaml") - - // In Valuses, remove the "replicas" key from the source service - if v, ok := chart.Values[fromName]; ok { - // if v is a Value - if v, ok := v.(*Value); ok { - v.Replicas = nil - } - } - } -} - 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 From dc47e73b4b9493f122a199ff93baa45b3ac48ff4 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 21:35:04 +0200 Subject: [PATCH 10/97] Temorary fixing overrides WIP: this breaks the -c flag, but it's a start We can now use compose.katenary.yaml file as a default override. --- parser/main.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/parser/main.go b/parser/main.go index 9538e7a..b8889d1 100644 --- a/parser/main.go +++ b/parser/main.go @@ -9,9 +9,7 @@ import ( // Parse compose files and return a project. The project is parsed with dotenv, osenv and profiles. func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, error) { - if len(dockerComposeFile) > 0 { - cli.DefaultFileNames = dockerComposeFile - } + cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames, "compose.katenary.yaml") options, err := cli.NewProjectOptions(nil, cli.WithProfiles(profiles), @@ -21,6 +19,7 @@ func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, erro cli.WithNormalization(true), cli.WithInterpolation(true), cli.WithResolvedPaths(false), + //cli.WithResolvedPaths(true), ) if err != nil { From 4ded4d4e0949be0eb072553e06001225a0136300 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 22:08:01 +0200 Subject: [PATCH 11/97] Set the compose file list using overrides This is a hack to force the compose package to read all files provided with the -c flag. --- parser/main.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/parser/main.go b/parser/main.go index b8889d1..c9ffe0f 100644 --- a/parser/main.go +++ b/parser/main.go @@ -11,6 +11,10 @@ func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, erro cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames, "compose.katenary.yaml") + if len(dockerComposeFile) == 0 { + cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames, dockerComposeFile...) + } + options, err := cli.NewProjectOptions(nil, cli.WithProfiles(profiles), cli.WithDefaultConfigPath, From 9a3fc6a2b4eb1e4428bd93ae5f101f5b44a7d47b Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 22:22:48 +0200 Subject: [PATCH 12/97] Fix "depends_on" check If "depends_on" is set, we need to ensure that the target service has got declared ports. It's necessary, at this time, to ensure the target is started (with an initContainer) As soon as Kubernetes proposes a better check, we will be able to fix this requirement. --- generator/deployment.go | 6 +++++- generator/generator.go | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/generator/deployment.go b/generator/deployment.go index 394ed46..eff2b84 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -91,11 +91,15 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { } // DependsOn adds a initContainer to the deployment that will wait for the service to be up. -func (d *Deployment) DependsOn(to *Deployment) error { +func (d *Deployment) DependsOn(to *Deployment, servicename string) error { // Add a initContainer with busybox:latest using netcat to check if the service is up // it will wait until the service responds to all ports for _, container := range to.Spec.Template.Spec.Containers { commands := []string{} + if len(container.Ports) == 0 { + utils.Warn("No ports found for service ", servicename, ". You should declare a port in the service or use "+LABEL_PORTS+" label.") + os.Exit(1) + } for _, port := range container.Ports { command := fmt.Sprintf("until nc -z %s %d; do\n sleep 1;\ndone", to.Name, port.ContainerPort) commands = append(commands, command) diff --git a/generator/generator.go b/generator/generator.go index 869f9a8..c646d31 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -148,7 +148,7 @@ func Generate(project *types.Project) (*HelmChart, error) { for _, s := range project.Services { for _, d := range s.GetDependencies() { if dep, ok := deployments[d]; ok { - deployments[s.Name].DependsOn(dep) + deployments[s.Name].DependsOn(dep, d) } else { log.Printf("service %[1]s depends on %[2]s, but %[2]s is not defined", s.Name, d) } From 6ce52cc037c8cbfedf8283b80346f55d81ba00a1 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 22:37:22 +0200 Subject: [PATCH 13/97] Reindentation and change labels --- doc/docs/usage.md | 95 ++++++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/doc/docs/usage.md b/doc/docs/usage.md index b5a0016..81ef15d 100644 --- a/doc/docs/usage.md +++ b/doc/docs/usage.md @@ -55,15 +55,15 @@ See this compose file: version: "3" services: - webapp: - image: php:8-apache - depends_on: - - database + webapp: + image: php:8-apache + depends_on: + - database - database: - image: mariadb - environment: - MYSQL_ROOT_PASSWORD: foobar + database: + image: mariadb + environment: + MYSQL_ROOT_PASSWORD: foobar ``` In this case, `webapp` needs to know the `database` port because the `depends_on` points on it and Kubernetes has not (yet) solution to check the database startup. Katenary wants to create a `initContainer` to hit on the related service. So, instead of exposing the port in the compose definition, let's declare this to katenary with labels: @@ -73,17 +73,18 @@ In this case, `webapp` needs to know the `database` port because the `depends_on version: "3" services: - webapp: - image: php:8-apache - depends_on: - - database + webapp: + image: php:8-apache + depends_on: + - database - database: - image: mariadb - environment: - MYSQL_ROOT_PASSWORD: foobar - labels: - katenary.io/ports: 3306 + database: + image: mariadb + environment: + MYSQL_ROOT_PASSWORD: foobar + labels: + katenary.v3/ports: |- + - 3306 ``` ### Declare ingresses @@ -93,11 +94,13 @@ It's very common to have an `Ingress` on web application to deploy on Kuberenete ```yaml # ... services: - webapp: - image: ... - ports: 8080:5050 - labels: - katenary.io/ingress: 5050 + webapp: + image: ... + ports: 8080:5050 + labels: + katenary.v3/ingress: |- + port: 5050 + hostname: myapp.example.com ``` Note that the port to bind is the one used by the container, not the used locally. This is because Katenary create a service to bind the container itself. @@ -111,13 +114,13 @@ With a compose file, there is no problem as Docker/Podman allows to resolve the ```yaml services: - webapp: - image: php:7-apache - environment: - DB_HOST: database + webapp: + image: php:7-apache + environment: + DB_HOST: database - database: - image: mariadb + database: + image: mariadb ``` Katenary prefixes the services with `{{ .Release.Name }}` (to make it possible to install the application several times in a namespace), so you need to "remap" the environment variable to the right one. @@ -125,33 +128,33 @@ Katenary prefixes the services with `{{ .Release.Name }}` (to make it possible t ```yaml services: - webapp: - image: php:7-apache - environment: - DB_HOST: database - labels: - katenary.io/mapenv: | - DB_HOST: "{{ .Release.Name }}-database" + webapp: + image: php:7-apache + environment: + DB_HOST: database + labels: + katenary.v3/mapenv: |- + DB_HOST: "{{ .Release.Name }}-database" - database: - image: mariadb + database: + image: mariadb ``` !!! Warning - This is a "multiline" label that accepts YAML or JSON content, don't forget to add a pipe char (`|`) and to indent your content + This is a "multiline" label that accepts YAML or JSON content, don't forget to add a pipe char (`|` or `|-`) and to **indent** your content This label can be used to map others environment for any others reason. E.g. to change an informational environment variable. ```yaml services: - webapp: - #... - environment: - RUNNING: docker - labels: - katenary.io/mapenv: | - RUNNING: kubernetes + webapp: + #... + environment: + RUNNING: docker + labels: + katenary.v3/mapenv: |- + RUNNING: kubernetes ``` In the above example, `RUNNING` will be set to `kubernetes` when you'll deploy the application with helm, and it's `docker` for "podman" and "docker" executions. From eeb044bab0fb54c59783d2375f74de5c55eb2005 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 22:37:56 +0200 Subject: [PATCH 14/97] Add markdown editor configuration --- .editorconfig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.editorconfig b/.editorconfig index 7412791..dd72225 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,3 +4,8 @@ root = true indent_style=tab indent_size=4 +[*.md] +trim_trailing_whitespace = false +indent_style = space +indent_size = 2 + From 50c2f9d1dcc4a210370118e100446b5ee447a904 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 22:38:23 +0200 Subject: [PATCH 15/97] Use new labels in documentation, and fix content --- README.md | 97 ++++++++++++++++++++++++------------------------------- 1 file changed, 42 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index f89d8c1..513bee2 100644 --- a/README.md +++ b/README.md @@ -73,34 +73,27 @@ katenary completion fish | source # Usage ``` -Katenary aims to be a tool to convert docker-compose files to Helm Charts. -It will create deployments, services, volumes, secrets, and ingress resources. -But it will also create initContainers based on depend_on, healthcheck, and other features. -It's not magical, sometimes you'll need to fix the generated charts. -The general way to use it is to call one of these commands: +Katenary is a tool to convert compose files to Helm Charts. - katenary convert - katenary convert -c docker-compose.yml - katenary convert -c docker-compose.yml -o ./charts - -In case of, check the help of each command using: - katenary --help -or - "katenary help " +Each [command] and subcommand has got an "help" and "--help" flag to show more information. Usage: katenary [command] +Examples: + katenary convert -c docker-compose.yml -o ./charts + Available Commands: - completion Generate the autocompletion script for the specified shell - convert Convert docker-compose to helm chart - help Help about any command - show-labels Show labels of a resource - upgrade Upgrade katenary to the latest version if available - version Display version + completion Generates completion scripts + convert Converts a docker-compose file to a Helm Chart + hash-composefiles Print the hash of the composefiles + help Help about any command + help-labels Print the labels help for all or a specific label + version Print the version number of Katenary Flags: - -h, --help help for katenary + -h, --help help for katenary + -v, --version version for katenary Use "katenary [command] --help" for more information about a command. ``` @@ -118,9 +111,7 @@ 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 - `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 an ingress - - `katenary.io/mapenv: |`: allow mapping environment to something else than the given value in the compose file +- some labels can help to bind values, see examples below Exemple of a possible `docker-compose.yaml` file: @@ -140,11 +131,13 @@ services: - database labels: # expose the port 80 as an ingress - katenary.io/ingress: 80 + katenary.v3/ingress: |- + hostname: myapp.example.com + port: 80 # make adaptations, DB_HOST environment is actually the service name # to hit (note the yaml style, start with "|") - katenary.io/mapenv: | - DB_HOST: {{ .Release.Name }}-database + katenary.v3/mapenv: |- + DB_HOST: '{{ .Release.Name }}-database' database: image: mariadb:10 env_file: @@ -157,42 +150,36 @@ services: labels: # no need to declare this port in docker-compose # but katenary will need it - katenary.io/ports: 3306 + katenary.v3/ports: |- + - 3306 # these variables are secrets - katenary.io/secret-vars: MARIADB_ROOT_PASSWORD, MARIADB_PASSWORD + katenary.v3/secrets: |- + - MARIADB_ROOT_PASSWORD + - MARIADB_PASSWORD ``` # Labels -These labels could be found by `katenary show-labels`, and can be placed as "labels" inside your docker-compose file: +These labels could be found by `katenary help-labels`, and can be placed as "labels" inside your docker-compose file: ``` -# 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 (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://[ignored][:port][/path] to specify an http healthcheck - -> tcp://[ignored]:port to specify a tcp healthcheck - -> other string is condidered as a "command" healthcheck +To get more information about a label, use `katenary help-label +e.g. katenary help-label dependencies + +katenary.v3/configmap-files: list of strings Add files to the configmap. +katenary.v3/cronjob: object Create a cronjob from the service. +katenary.v3/dependencies: list of objects Add Helm dependencies to the service. +katenary.v3/description: string Description of the service +katenary.v3/env-from: list of strings Add environment variables from antoher service. +katenary.v3/health-check: object Health check to be added to the deployment. +katenary.v3/ignore: bool Ignore the service +katenary.v3/ingress: object Ingress rules to be added to the service. +katenary.v3/main-app: bool Mark the service as the main app. +katenary.v3/map-env: object Map env vars from the service to the deployment. +katenary.v3/ports: list of uint32 Ports to be added to the service. +katenary.v3/same-pod: string Move the same-pod deployment to the target deployment. +katenary.v3/secrets: list of string Env vars to be set as secrets. +katenary.v3/values: list of string or map Environment variables to be added to the values.yaml ``` # What a name... From ef7fcb61334c1445ed709af0448143e3a36db517 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 22:44:41 +0200 Subject: [PATCH 16/97] Some users want to use "host" instead of "hostname" We accept both, nothing more. --- generator/ingress.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/generator/ingress.go b/generator/ingress.go index 569dc93..29de1a9 100644 --- a/generator/ingress.go +++ b/generator/ingress.go @@ -59,10 +59,16 @@ func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress { if Chart.Values[service.Name] == nil { Chart.Values[service.Name] = &Value{} } + + // fix the ingress host => hostname + if hostname, ok := mapping["host"]; ok && hostname != "" { + mapping["hostname"] = hostname + } + Chart.Values[service.Name].(*Value).Ingress = &IngressValue{ Enabled: mapping["enabled"].(bool), Path: mapping["path"].(string), - Host: mapping["host"].(string), + Host: mapping["hostname"].(string), Class: mapping["class"].(string), Annotations: map[string]string{}, } From 3ae5ec99ff81677988191d3c30ab6d5062dbf5e8 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 23:26:54 +0200 Subject: [PATCH 17/97] Typo, format of markdown I prefer to limit 120 columns. A .nvimrc will be proposed to avoid having to wide markdown lines. --- doc/docs/coding.md | 35 +++++++++++++++++--------- doc/docs/dependencies.md | 5 ++-- doc/docs/index.md | 33 +++++++++++++++--------- doc/docs/labels.md | 2 +- doc/docs/packages/generator.md | 2 +- doc/docs/usage.md | 43 +++++++++++++++++++++----------- generator/katenaryLabelsDoc.yaml | 2 +- 7 files changed, 79 insertions(+), 43 deletions(-) diff --git a/doc/docs/coding.md b/doc/docs/coding.md index d631300..a63e93e 100644 --- a/doc/docs/coding.md +++ b/doc/docs/coding.md @@ -1,14 +1,19 @@ # How Katenary works behind the scene -This section is for developers who want to take part in Katenary. Here we describe how it works and the expected principles. +This section is for developers who want to take part in Katenary. Here we describe how it works and the expected +principles. ## A few important points -Katenary is developed in Go. The version currently supported is 1.20. For reasons of readability, the `any` type is preferred to `interface{}`. +Katenary is developed in Go. The version currently supported is 1.20. For reasons of readability, the `any` type is +preferred to `interface{}`. -Since version v3, Katenary uses, in addition to `go-compose`, the `k8s` library to generate objects that are guaranteed to work before transformation. Katenary adds Helm syntax entries to add loops, transformations and conditions. +Since version v3, Katenary uses, in addition to `go-compose`, the `k8s` library to generate objects that are guaranteed +to work before transformation. Katenary adds Helm syntax entries to add loops, transformations and conditions. -We really try to follow best practices and code principles. But, Katenary needs a lot of workarounds and string manipulation during the process. There are, also, some drawbacks using standard k8s packages that makes a lot of type checks when generating the objects. We need to finalize the values after object generation. +We really try to follow best practices and code principles. But, Katenary needs a lot of workarounds and string +manipulation during the process. There are, also, some drawbacks using standard k8s packages that makes a lot of type +checks when generating the objects. We need to finalize the values after object generation. **This makes the coding a bit harder than simply converting from YAML to YAML.** @@ -16,9 +21,12 @@ We really try to follow best practices and code principles. But, Katenary needs ## General principle -During conversion, the `generator` package is primarily responsible for creating "objects". The principle is to generate one `Deployment` per `compose` service. If the container coming from "compose" exposes ports (explicitly), then a service is created. +During conversion, the `generator` package is primarily responsible for creating "objects". The principle is to generate +one `Deployment` per `compose` service. If the container coming from "compose" exposes ports (explicitly), then a +service is created. -If the declaration of a container is to be integrated into another pod (via the `same-pod` label), this `Deployment` and its associated service are still created. They are deleted last, once the merge has been completed. +If the declaration of a container is to be integrated into another pod (via the `same-pod` label), this `Deployment` and +its associated service are still created. They are deleted last, once the merge has been completed. ## Conversion in "`generator`" package @@ -28,8 +36,8 @@ The generation is made by using a `HelmChart` object: ```golang chart := NewChart(appName string) -``` -Then, some processes are made to detect the "main app verion" (tag for the main service image), bootstrapping declared ports in labels, managing links to bind containers in one pods... +``` Then, some processes are made to detect the "main app verion" (tag for the main service image), bootstrapping +declared ports in labels, managing links to bind containers in one pods... Then, a loop basically makes this: @@ -44,12 +52,15 @@ for _, service := range project.Services { } ``` -**A lot** of string manipulations are made by each `Yaml()` methods. This is where you find the complex and impacting operations. The `Yaml` methods **don't return a valid YAML content**. This is a Helm Chart Yaml content with template conditions, vamues and calls to helper templates. +**A lot** of string manipulations are made by each `Yaml()` methods. This is where you find the complex and impacting +operations. The `Yaml` methods **don't return a valid YAML content**. This is a Helm Chart Yaml content with template +conditions, vamues and calls to helper templates. -> The `Yaml()` methods, in each object, need contribution, help, fixes, enhancements... -> They work, but there is a lot of complexity. Please, create issues, pull-requests and conversation in the GitHub repository. +> The `Yaml()` methods, in each object, need contribution, help, fixes, enhancements... They work, but there is a lot of +> complexity. Please, create issues, pull-requests and conversation in the GitHub repository. -The final step, before sending all templates to chart, is to bind the containers inside the same pod where it's specified. +The final step, before sending all templates to chart, is to bind the containers inside the same pod where it's +specified. For each source container linked to the destination: diff --git a/doc/docs/dependencies.md b/doc/docs/dependencies.md index 0f88491..36f8975 100644 --- a/doc/docs/dependencies.md +++ b/doc/docs/dependencies.md @@ -5,8 +5,9 @@ Katenary uses `compose-go` and several kubernetes official packages. - `github.com/compose-spec/compose-go`: to parse compose files. It ensures that: - the project respects the "compose" specification - katenary uses the "compose" struct exactly the same way that podman-compose or docker does -- `github.com/spf13/cobra`: to parse command line arguments, subcommands and flags. It also generates completion for bash, zsh, fish and powershell. -- `github.com/thediveo/netdb`: to get the standard names of a service from it's port number +- `github.com/spf13/cobra`: to parse command line arguments, subcommands and flags. It also generates completion for + bash, zsh, fish and powershell. +- `github.com/thediveo/netdb`: to get the standard names of a service from its port number - `gopkg.in/yaml.v3`: - to generate `Chart.yaml` and `values.yaml` files (only) - to parse Katenary labels in the compose file diff --git a/doc/docs/index.md b/doc/docs/index.md index d77acf5..202015b 100644 --- a/doc/docs/index.md +++ b/doc/docs/index.md @@ -1,35 +1,42 @@ -
- -
+
# Welcome to Katenary documentation + !!! Edit "Thanks to..." **Katenary is built with:**
:fontawesome-brands-golang:{ .go-logo } **Documentation is built with:**
- MkDocs using Material for MkDocs theme template. + MkDocs using Material for MkDocs theme template. > Special thanks to all contributors, testors, and of course packages and tools authors. -Katenary is a tool made to help you to transform "compose" files (`docker-compose.yml`, `podman-compose.yml`...) to a complete and production ready [Helm Chart](https://helm.sh). +Katenary is a tool made to help you to transform "compose" files (`docker-compose.yml`, `podman-compose.yml`...) to +complete and production ready [Helm Chart](https://helm.sh). -You'll be able to deploy your project in [:material-kubernetes: Kubernetes](https://kubernetes.io) in a few seconds (of course, more if you need to tweak with labels). +You'll be able to deploy your project in [:material-kubernetes: Kubernetes](https://kubernetes.io) in a few seconds +(of course, more if you need to tweak with labels). It uses your current file and optionnaly labels to configure the result. -It's an opensource project, under MIT licence, partially developped at [Smile](https://www.smile.eu). The project source code is hosted on the [:fontawesome-brands-github: Katenary GitHub Repository](https://github.com/metal3d/katenary). +It's an opensource project, under MIT licence, partially developped at [Smile](https://www.smile.eu). The project source +code is hosted on the [:fontawesome-brands-github: Katenary GitHub Repository](https://github.com/metal3d/katenary). ## Install Katenary -Katenary is developped in :fontawesome-brands-golang:{ .gopher } [Go](https://go.dev). The binary is statically linked, so you can simply download it from the [release page](https://github.com/metal3d/katenary/releases) of the project in GutHub. +Katenary is developped using the :fontawesome-brands-golang:{ .gopher } [Go](https://go.dev) language. +The binary is statically linked, so you can simply download it from the [release +page](https://github.com/metal3d/katenary/releases) of the project in GutHub. -You need to select the right binary for your operating system and architecture, and copy the binary in a directory that is in your `PATH`. +You need to select the right binary for your operating system and architecture, and copy the binary in a directory +that is in your `PATH`. -If you are a Linux user, you can use the "one line installation command" which will download the binary in your `$HOME/.local/bin` directory if it exists. +If you are a Linux user, you can use the "one line installation command" which will download the binary in your +`$HOME/.local/bin` directory if it exists. ```bash sh <(curl -sSL https://raw.githubusercontent.com/metal3d/katenary/master/install.sh) @@ -42,11 +49,13 @@ sh <(curl -sSL https://raw.githubusercontent.com/metal3d/katenary/master/install !!! Note "You prefer to compile it, no need to install Go" - You can also build and install it yourself, the provided Makefile has got a `build` command that uses `podman` or `docker` to build the binary. + You can also build and install it yourself, the provided Makefile has got a `build` command that uses `podman` or + `docker` to build the binary. So, you don't need to install Go compiler :+1:. - But, note that the "master" branch is not the "stable" version. It's preferable to switch to a tag, or to use the releases. + But, note that the "master" branch is not the "stable" version. It's preferable to switch to a tag, or to use the + releases. ```bash git clone https://github.com/metal3d/katenary.git diff --git a/doc/docs/labels.md b/doc/docs/labels.md index 1365270..985ba09 100644 --- a/doc/docs/labels.md +++ b/doc/docs/labels.md @@ -353,7 +353,7 @@ Environment variables to be added to the values.yaml By default, all environment variables in the "env" and environment files are added to configmaps with the static values set. This label -allows to add environment variables to the values.yaml file. +allows adding environment variables to the values.yaml file. Note that the value inside the configmap is `{{ tpl vaname . }}`, so you can set the value to a template that will be rendered with the diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index 5fd4773..864a478 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -365,7 +365,7 @@ func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) ### func (*Deployment) DependsOn ``` go -func (d *Deployment) DependsOn(to *Deployment) error +func (d *Deployment) DependsOn(to *Deployment, servicename string) error ``` DependsOn adds a initContainer to the deployment that will wait for the diff --git a/doc/docs/usage.md b/doc/docs/usage.md index 81ef15d..6fe08ca 100644 --- a/doc/docs/usage.md +++ b/doc/docs/usage.md @@ -1,6 +1,8 @@ # Basic Usage -Basically, you can use `katenary` to transpose a docker-compose file (or any compose file compatible with `podman-compose` and `docker-compose`) to a configurable Helm Chart. This resulting helm chart can be installed with `helm` command to your Kubernetes cluster. +Basically, you can use `katenary` to transpose a docker-compose file (or any compose file compatible with +`podman-compose` and `docker-compose`) to a configurable Helm Chart. This resulting helm chart can be installed with +`helm` command to your Kubernetes cluster. Katenary transforms compose services this way: @@ -9,11 +11,14 @@ Katenary transforms compose services this way: - it a port is exposed, katenary creates a service (NodePort) - environment variables will be stored in `values.yaml` file - image, tags, and ingresses configuration are also stored in `values.yaml` file -- if named volumes are declared, katenary create PersistentVolumeClaims - not enabled in values file (a `emptyDir` is used by default) +- if named volumes are declared, katenary create PersistentVolumeClaims - not enabled in values file (a `emptyDir` is + used by default) - any other volume (local mount points) are ignored - `depends_on` needs that the pointed service declared a port. If not, you can use labels to inform katenary -Katenary can also configure containers grouping in pods, declare dependencies, ignore some services, force variables as secrets, mount files as `configMap`, and many others things. To adapt the helm chart generation, you will need to use some specific labels. +Katenary can also configure containers grouping in pods, declare dependencies, ignore some services, force variables as +secrets, mount files as `configMap`, and many others things. To adapt the helm chart generation, you will need to use +some specific labels. For more complete label usage, see [the labels page](labels.md). @@ -28,7 +33,8 @@ katenary convert It will search standard compose files in the current directory and try to create a helm chart in "chart" directory. !!! Info - Katenary uses the compose-go library which respects the Docker and Docker-Compose specification. Keep in mind that it will find files exactly the same way as `docker-compose` and `podman-compose` do it. + Katenary uses the compose-go library which respects the Docker and Docker-Compose specification. Keep in mind that + it will find files exactly the same way as `docker-compose` and `podman-compose` do it. Of course, you can provide others files than the default with (cummulative) `-c` options: @@ -47,7 +53,8 @@ Katenary proposes a lot of labels to configure the helm chart generation, but so ### Work with Depends On? -Kubernetes does not propose service or pod starting detection from others pods. But katenary will create init containers to make you able to wait for a service to respond. But you'll probably need to adapt a bit the compose file. +Kubernetes does not propose service or pod starting detection from others pods. But katenary will create init containers +to make you able to wait for a service to respond. But you'll probably need to adapt a bit the compose file. See this compose file: @@ -66,7 +73,9 @@ services: MYSQL_ROOT_PASSWORD: foobar ``` -In this case, `webapp` needs to know the `database` port because the `depends_on` points on it and Kubernetes has not (yet) solution to check the database startup. Katenary wants to create a `initContainer` to hit on the related service. So, instead of exposing the port in the compose definition, let's declare this to katenary with labels: +In this case, `webapp` needs to know the `database` port because the `depends_on` points on it and Kubernetes has not +(yet) solution to check the database startup. Katenary wants to create a `initContainer` to hit on the related service. +So, instead of exposing the port in the compose definition, let's declare this to katenary with labels: ```yaml @@ -89,7 +98,8 @@ services: ### Declare ingresses -It's very common to have an `Ingress` on web application to deploy on Kuberenetes. The `katenary.io/ingress` declare the port to bind. +It's very common to have an `Ingress` on web application to deploy on Kuberenetes. The `katenary.io/ingress` declare the +port to bind. ```yaml # ... @@ -103,12 +113,14 @@ services: hostname: myapp.example.com ``` -Note that the port to bind is the one used by the container, not the used locally. This is because Katenary create a service to bind the container itself. +Note that the port to bind is the one used by the container, not the used locally. This is because Katenary create a +service to bind the container itself. ### Map environment to helm values -A lot of framework needs to receive service host or IP in an environment variable to configure the connexion. For example, to connect a PHP application to a database. +A lot of framework needs to receive service host or IP in an environment variable to configure the connexion. For +example, to connect a PHP application to a database. With a compose file, there is no problem as Docker/Podman allows to resolve the name by container name: @@ -123,7 +135,8 @@ services: image: mariadb ``` -Katenary prefixes the services with `{{ .Release.Name }}` (to make it possible to install the application several times in a namespace), so you need to "remap" the environment variable to the right one. +Katenary prefixes the services with `{{ .Release.Name }}` (to make it possible to install the application several times +in a namespace), so you need to "remap" the environment variable to the right one. ```yaml @@ -140,10 +153,11 @@ services: image: mariadb ``` -!!! Warning - This is a "multiline" label that accepts YAML or JSON content, don't forget to add a pipe char (`|` or `|-`) and to **indent** your content +!!! Warning This is a "multiline" label that accepts YAML or JSON content, don't forget to add a pipe char (`|` or `|-`) +and to **indent** your content -This label can be used to map others environment for any others reason. E.g. to change an informational environment variable. +This label can be used to map others environment for any others reason. E.g. to change an informational environment +variable. ```yaml @@ -157,4 +171,5 @@ services: RUNNING: kubernetes ``` -In the above example, `RUNNING` will be set to `kubernetes` when you'll deploy the application with helm, and it's `docker` for "podman" and "docker" executions. +In the above example, `RUNNING` will be set to `kubernetes` when you'll deploy the application with helm, and it's +`docker` for "podman" and "docker" executions. diff --git a/generator/katenaryLabelsDoc.yaml b/generator/katenaryLabelsDoc.yaml index ea34133..36cd49b 100644 --- a/generator/katenaryLabelsDoc.yaml +++ b/generator/katenaryLabelsDoc.yaml @@ -48,7 +48,7 @@ long: |- By default, all environment variables in the "env" and environment files are added to configmaps with the static values set. This label - allows to add environment variables to the values.yaml file. + allows adding environment variables to the values.yaml file. Note that the value inside the configmap is {{ "{{ tpl vaname . }}" }}, so you can set the value to a template that will be rendered with the From 8e4b3be108908f458d6aaf2c85560a769c3ebb8d Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 23:28:04 +0200 Subject: [PATCH 18/97] Add nvimrc file Editorconfig seems to not allow the line wrapping. I'm using neovim with `set exrc`, so this local file is used to fix some behaviors. If a contributor can give solutions for a global configuration that works everywhere... --- .nvimrc | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .nvimrc diff --git a/.nvimrc b/.nvimrc new file mode 100644 index 0000000..1da8158 --- /dev/null +++ b/.nvimrc @@ -0,0 +1,2 @@ +" set markdown options to set fo+=a textwidth=120 +autocmd FileType markdown setlocal textwidth=120 fo+=a cc=+1 From fc67cb668df4e5e8054ef4992db367819b793006 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 3 Apr 2024 23:30:24 +0200 Subject: [PATCH 19/97] Fix line width to 120 columns --- README.md | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 513bee2..ca953b6 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,17 @@ 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 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. +> **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. # Install -You can download the binaries from the [Release](https://github.com/metal3d/katenary/releases) section. Copy the binary and rename it to `katenary`. Place the binary inside your `PATH`. You should now be able to call the `katenary` command. +You can download the binaries from the [Release](https://github.com/metal3d/katenary/releases) section. Copy the binary +and rename it to `katenary`. Place the binary inside your `PATH`. You should now be able to call the `katenary` command. -You can of course get the binary with `go install -u github.com/metal3d/katenary/cmd/katenary/...` but the `main` branch is continuously updated. It's preferable to use releases. +You can of course get the binary with `go install -u github.com/metal3d/katenary/cmd/katenary/...` but the `main` branch +is continuously updated. It's preferable to use releases. You can use this commands on Linux: @@ -31,7 +35,8 @@ You can then install it with: make install ``` -It will use the default PREFIX (`~/.local/`) to install the binary in the `bin` subdirectory. You can force the PREFIX value at install time, but maybe you need to use "sudo": +It will use the default PREFIX (`~/.local/`) to install the binary in the `bin` subdirectory. You can force the PREFIX +value at install time, but maybe you need to use "sudo": ```bash sudo make install PREFIX=/usr/local @@ -51,7 +56,8 @@ Then place the `katenary` binary file inside your PATH. # Tips -We strongly recommand to add the "completion" call to you SHELL using the common bashrc, or whatever the profile file you use. +We strongly recommand to add the "completion" call to you SHELL using the common bashrc, or whatever the profile file +you use. E.g.: @@ -98,19 +104,25 @@ Flags: Use "katenary [command] --help" for more information about a command. ``` -Katenary will try to find a `docker-compose.yaml` or `docker-compose.yml` file inside the current directory. It will check *the existence of the `chart` directory to create a new Helm Chart inside a named subdirectory. Katenary will ask you if you want to delete it before recreating. +Katenary will try to find a `docker-compose.yaml` or `docker-compose.yml` file inside the current directory. It will +check *the existence of the `chart` directory to create a new Helm Chart inside a named subdirectory. Katenary will ask +you if you want to delete it before recreating. It creates a subdirectory inside `chart` that is named with the `appname` option (default is `MyApp`) -> To respect the ability to install the same application in the same namespace, Katenary will create "variable" names like `{{ .Release.Name }}-servicename`. So, you will need to use some labels inside your docker-compose file to help katenary to build a correct helm chart. +> To respect the ability to install the same application in the same namespace, Katenary will create "variable" names +> like `{{ .Release.Name }}-servicename`. So, you will need to use some labels inside your docker-compose file to help +> katenary to build a correct helm chart. 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) +- **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 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) +- `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, see examples below Exemple of a possible `docker-compose.yaml` file: @@ -186,7 +198,8 @@ katenary.v3/values: list of string or map Environment variables to be added to Katenary is the stylized name of the project that comes from the "catenary" word. -A catenary is a curve formed by a wire, rope, or chain hanging freely from two points that are not in the same vertical line. For example, the anchor chain between a boat and the anchor. +A catenary is a curve formed by a wire, rope, or chain hanging freely from two points that are not in the same vertical +line. For example, the anchor chain between a boat and the anchor. This "curved link" represents what we try to do, the project is a "streched link from docker-compose to helm chart". From 50169c8fbc9a60cca8c86a49d231905bc7697e37 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 4 Apr 2024 09:50:17 +0200 Subject: [PATCH 20/97] Changing logo, fix doc, catchy text... --- .nvimrc | 2 - README.md | 17 +++- doc/docs/coding.md | 8 +- doc/docs/index.md | 68 ++++++++++++--- doc/docs/statics/Logo_Smile.png | Bin 22471 -> 0 bytes doc/docs/statics/logo-bright.svg | 35 ++++++++ doc/docs/statics/logo-dark.svg | 35 ++++++++ doc/docs/statics/logo.png | Bin 64493 -> 0 bytes doc/docs/statics/logo.svg | 145 +++++++++++++++++++++++++++++++ doc/mkdocs.yml | 2 +- misc/LogoMakr-1TEtSp.png | Bin 5079 -> 0 bytes misc/Logo_Smile.png | Bin 22471 -> 0 bytes misc/logo.png | Bin 64493 -> 0 bytes 13 files changed, 294 insertions(+), 18 deletions(-) delete mode 100644 .nvimrc delete mode 100644 doc/docs/statics/Logo_Smile.png create mode 100644 doc/docs/statics/logo-bright.svg create mode 100644 doc/docs/statics/logo-dark.svg delete mode 100644 doc/docs/statics/logo.png create mode 100644 doc/docs/statics/logo.svg delete mode 100644 misc/LogoMakr-1TEtSp.png delete mode 100644 misc/Logo_Smile.png delete mode 100644 misc/logo.png diff --git a/.nvimrc b/.nvimrc deleted file mode 100644 index 1da8158..0000000 --- a/.nvimrc +++ /dev/null @@ -1,2 +0,0 @@ -" set markdown options to set fo+=a textwidth=120 -autocmd FileType markdown setlocal textwidth=120 fo+=a cc=+1 diff --git a/README.md b/README.md index ca953b6..bff328c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,20 @@ -
- Katenary Logo +
+ Katenary Logo
+ +🚀 Unleash Productivity with Katenary! 🚀 + +Tired of manual conversions? Katenary harnesses the labels from your "compose" file to craft complete Helm Charts +effortlessly, saving you time and energy. + +🛠️ Simple autmated CLI: Katenary handles the grunt work, generating everything needed for seamless service binding +and Helm Chart creation. + +💡 Effortless Efficiency: You only need to add labels when it's necessary to precise things. Then call `katenary convert` and let the magic happen. + +# What ? + 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 to build Helm Chart from a docker-compose file, but docker-compose diff --git a/doc/docs/coding.md b/doc/docs/coding.md index a63e93e..a1e00de 100644 --- a/doc/docs/coding.md +++ b/doc/docs/coding.md @@ -36,8 +36,10 @@ The generation is made by using a `HelmChart` object: ```golang chart := NewChart(appName string) -``` Then, some processes are made to detect the "main app verion" (tag for the main service image), bootstrapping -declared ports in labels, managing links to bind containers in one pods... +``` + +Then, some processes are made to detect the "main app verion" (tag for the main service image), bootstrapping declared +ports in labels, managing links to bind containers in one pods... Then, a loop basically makes this: @@ -54,7 +56,7 @@ for _, service := range project.Services { **A lot** of string manipulations are made by each `Yaml()` methods. This is where you find the complex and impacting operations. The `Yaml` methods **don't return a valid YAML content**. This is a Helm Chart Yaml content with template -conditions, vamues and calls to helper templates. +conditions, values and calls to helper templates. > The `Yaml()` methods, in each object, need contribution, help, fixes, enhancements... They work, but there is a lot of > complexity. Please, create issues, pull-requests and conversation in the GitHub repository. diff --git a/doc/docs/index.md b/doc/docs/index.md index 202015b..3121831 100644 --- a/doc/docs/index.md +++ b/doc/docs/index.md @@ -1,18 +1,35 @@ -
+ + + # Welcome to Katenary documentation +🚀 Unleash Productivity with Katenary! 🚀 -!!! Edit "Thanks to..." - **Katenary is built with:** -
:fontawesome-brands-golang:{ .go-logo } +Tired of manual conversions? Katenary harnesses the labels from your "compose" file to craft complete Helm Charts +effortlessly, saving you time and energy. - **Documentation is built with:** -
- MkDocs using Material for MkDocs theme template. +🛠️ Simple autmated CLI: Katenary handles the grunt work, generating everything needed for seamless service binding +and Helm Chart creation. - > Special thanks to all contributors, testors, and of course packages and tools authors. +💡 Effortless Efficiency: You only need to add labels when it's necessary to precise things. Then call `katenary convert` and let the magic happen. + +# What ? Katenary is a tool made to help you to transform "compose" files (`docker-compose.yml`, `podman-compose.yml`...) to complete and production ready [Helm Chart](https://helm.sh). @@ -25,7 +42,6 @@ It uses your current file and optionnaly labels to configure the result. It's an opensource project, under MIT licence, partially developped at [Smile](https://www.smile.eu). The project source code is hosted on the [:fontawesome-brands-github: Katenary GitHub Repository](https://github.com/metal3d/katenary). - ## Install Katenary Katenary is developped using the :fontawesome-brands-golang:{ .gopher } [Go](https://go.dev) language. @@ -88,3 +104,35 @@ source <(katenary completion bash) Add this line in you `~/.profile` or `~/.bashrc` file to have completion at startup. +!!! Edit "Special thanks" + + **Katenary is built with:**
+ + > Special thanks to all contributors, testors, and of course packages and tools authors. + + :fontawesome-brands-golang:{ .go-logo } + + Go is an open source programming language that makes it easy to build simple, reliable, and efficient software. + Docker, Rancher, Helm, Kubernetes, Grafana, Prometheus, and many others are written in Go. Katenary uses Go-Compose + to parse compose files wich is the same library used by Podman-Compose and Docker-Compose. It also uses the + Kubernetes official packages to create Kubernetes objects before to generate the Helm Chart. + + **Thanks to everyone who contributes to all these projects.** + + **Everything was also possible because of:**
+ +
    + +
  • + Helm that is the main toppic of Katenary, Kubernetes is easier to use with it.
  • + +
  • Cobra that + makes command, subcommand and completion possible for Katenary with ease.
  • + +
+ + **Documentation is built with:**
+ + MkDocs using Material for MkDocs theme template. + diff --git a/doc/docs/statics/Logo_Smile.png b/doc/docs/statics/Logo_Smile.png deleted file mode 100644 index 6b9cfd3b40dd8cdad40f0598e1e1b5aee5ed893f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22471 zcmd?R^& z;JV1`yMjRYr2l@z6yP zgufD*RXO}ArehQ#)A$zC8_Ano6pb;J$&_T|fMre$XKq;$_q)3Ex7VMU)43==D*JPz z(_#9g*N&r#O+n<>O%CC&u04=UzX3=i?SL)HaE$ZF1q6!cLm>HS z2htGEZ3t&RR5MJ&(y42GmpEp$*+dV0?5zch{=UOAnr)_UXooKB#ZiMOrg(~ zJlm)=VqX?q@%F!0&+IW&g`sN)pzynq%38t-0 z@CXomPKyBwtcelghEud+R#Qq6rR_DPJFZss_XRl+jf)b4K&&n-{V7MlTx3la1@=FI z4o=sG)xTuF*M-%60@@=*h7HgW&D3hh0R51ia2q-wUX3_@P2L7O*aZ5+T6CC{Et%TZ zSA$a)LD>BbAA#f|cI2tJ>GtRZlNUs~Ha#XqA1(m({w+w%HS0WrSRbL#( z3Xxz{V@neKTap~T)WEQTB|OUWU?x!mOy3Kb$VK?@`8famEpS~vWL_~>0=QA=tE)$! zn$cXx6>v511Ex?eVo7s01W|Xbl%SS4;nQIrE8wI4~0K=fSkQ7SkKJRrwK0<*88>JL+ulR~)qC$gXS7LGLmJ z8}#Evy)L@YGn%+>?%)%rMVVAEg()5cd_ zJabIyOyep0FKNTuxKwdm=1$f6!h1{Nc=?QHJ?)kr6iAxeKsp#nPZC#VmnwMdz1l(D zRKs#u0#3>^>6$wTt?XWK6SdathuQIPvs5CCXLr8`$zyv zlw6(>KyVDA7Gdt@eE6KKN&L99hM_H^gXw)_*y}NTQ9s;15O$aOiHND|0Ry*~t}Cc( zMJ?z5maIOOKv0V^w6Z$L8osl1T<8qTq@%;>BD*=ji(enPXr&;Xw-fSSm$!7PS+~=C zO%2Nqj4*&vy5+6gIep>6QV;!-n$t~RC_(siD-g31ae)p4(}dx)D}0XeAKGX$Y+U2PSAE8RQBpG)o9uZL^3F^r5Pf!zd1O;MduytAnQ^fW zI!K)hQ;0EGH7my6l4^jQ5bU}CX#I32czjRNo4mp%ULM-YhSvC`wQ=$@XMR1}8?8gk zV!1Z5jhDv6xxv7~tLF%brzNN&3Kz^V@mCdp!=Cg6O3nD3uy=)Ob(EUNt;cxTC9YRS zHUmQ|<=O0X#T2&sQ=mbE^OML{I6>^cTi=}rz!5yA>r=r$)f(Huh+I(8F#M? z^(h+(OiTZUol7jHhQ0%~5%0g@(f6g-pQQ=&*sb#tLQ)8qSCkz`v`5SC9r;Gnj#Azo z683gPOnO-wFyCF&c<{T)EO=!^j3 zU^IT>TwUekr*lbYjB#nV7gb&AL|K{Vs*4bFLP})7X7J>_#Z%wUdtfX(f?nak*n}qx zbFEhVyC&9Y=ZcK!7BrSi zqi<4IdH$=Q+w<#h70i8EuX5+rYk0;KMuv9L9rjB?@rJd9!f3Z}?ko8{H?9?aEB3^@ zJ_X8ezoyLz?&$7yXJ&?rhr)hO?|j^~P3G(!Si)l>dPO(m0hJRM7~93DrX&|)l&0pm z6PV_hZ$CtKU(AMCBxX4+WzTxl8*h8eysSj(z-d17Q%Girv#+~0aIua z&)BEubz*JtZ~;1nTCeMLpIWd{`=smzs94%CFXbZiSMzP}R6p7*NRntU5<3Pm|3k=~ znBI2ZuY=5W(pABL2Z%qRNO#nyql{Jma-p&PY)V*VRszW1psP2i(V?kyBPA#M1fS@Y z{_F)pV`2_+c!MmxK>ARhd{n~u_>~c7>s57wgaOA#H3j0RMp;a2q2?_3bv5j}Ic`Ij zvqK@6YbgjM5T#rNKWm-Tq^YxfYhQjqj=Kvd~WzXLJTwgvaTQPO-a^{#*d;-~MN4isaYe?OArg=n=rMTnMf|7xZu%*0S z|EBorHP|*9ojZ#DKT{gW+$(J(r9l@7tppv%{>*nInk`ryvNA-2rr>@xLB>9`h;LS} z#*~LAK#sAD?Fz3vo*;5rhxNm8v4EQvur_EyEiR_nCoP<0(4ou*D;J%|Nw%_KHJ;Crk)r z<)6uP1$Mj5gdp`1g84F5LZ|4UxWOM*gW^vQs}A2EuUreYdHeESF)onDnS&+J&Sfr= z3Ug+i`Bo>tfO09bwK;=48qNAYPfNa%y!DRnpxC?0b*v0Vrx~iE^Z;%DF5`M?&U_P0 zvK1KIY$C8f^z-K^=+izu)F&({^U%M1=s&NKz4ebog9z~V!a-eTPISYIeX%@ts(Pf= zz61tcTz4iAIe}X}!M{1EaEw8u{SGJeZn?LZ)56v@JOV@|c!n52wn*bXppdzAGv4J% zBW`2>MZspRenis-eh4C5LothaW{~I+-s`%u>>R%z#v)2BS3%DNmZIA)l||{y0(cMN z^E4aHr1if6LOkA@+=6n8&&~XFiDT|U3bKi4bCXI9=Czbd9lmf$vh2BbBuy@AS^4L} z+}-HG>9vya7*R0oZx-gx|CIJCe)p% zeUHm&fBoFJiv2J-^S@LQNupQ8n)G1(B&e~BR>NqQNublj9)_YeLylyG_=Hjcvm%lE^$ zLUqms&Fy3UB-gJs-omX!+*G>HJ>$7|W2Z>HWxx$R|3(>~U9&`u=L*GkOjB?L1!|oC zy1IC(ckOWt-|^kk{3snqy!2XnrBgMK!_qLnG2HtpxVpdr$@A1$5gQ1}-RRckcX-C( z6dWK4S_JYZUG-3(;^kg#BGTQuL1yaR$zb15G9f&|LUnZ|$()Urh6PyfAW$N$WM%qK zo064A$n+*9nPAnW;TVuY1h<}e0<$Nve>kur@w3U$CWZuzA^F^q7ExtLKZ<;(LRMm2 zC33+RU?~?5pu|^Q%^^$8NOcp=UJ@`oQEf?VPQ!quVc3JqZQI7$s0ta|j*S7p2B652 zV1H&E=Cp4|RZRYZj_?%)VdtI%B3j3eG5vsU7bpTI$qTY*t;x=Wft?Kexl{=a*Ppcl zL;l9axvU35)3?nuY*ZV*zen%3>c!@1zN9dRC{ zVL1mVLzPV!N(U%-N^NY;z|b*klKGzvO8>4O7!(I8iytIXjldWDy?z{K;zlhj-r4FdcC}&K)$- zdA}cUm6Z|KDSjk!mxFpNf4tq4d)#~@rAKMw;Gp~IKJ|Aaf%f)n%_NIN&^e)$iElFD z@V-nsK9}qfob%AW8fjBqZn2j-@z=RNNlX3%5zr@gq3}%EJ<>##G?Oa7`mx;tjp?o? zKBAj}tm75mcb7X4mo+(p4B|HokC45n^!=uEPC{1*_!Oc4#uG=9=*vvma;<-#sK&@v z=w$=3_p*<@lHR?UTmu)GYexf%P{z2;?}W{R_n^YFm9e;={tqEX?z?F|T@0=5Kes;q zG8nB2kYfEtnRB+}iGhmMiAUXOGuzJ?m7*&OWlXS!8;|K)5=^7sNluiCIo4#FDgFAkCf zDjP)O&F{-9-kY0#d&*1-(y&ZLVsD8(HE& z)2T+KOEDha5;RJ!Vt8fBwFu~Vnr%giSt`?c{Gzqqzx#R(9&T|Qsx{zG-Adr;PI?rz z^v05D(EJ6dGVs*tvba73Mtg=yqzqHRG0hd-niUQyU>IVhJuN1l11`6OY*0Qha&yCX zV{amp3gta&J!{xCr;G<}a7KX#%^iSet0piiHrA1f7c@@Wt5$5!Zsmur&W^3qY+muL zyoF)a+w_3$q;D$c3zzhu$6f*usON74qZq?yw>Uaf^Vpp?C8c*UG>2>xi0!6ci}5(un&3uncsv^$zx{wv9av`u>3uklz15FIdd^%JBO zMvx77tZ(ke#LCD%PtLjMF0vgnFp4&2zk8KW%81L#&GoP7WgboBZ!ng5GcLCVxoTs! z(CdO3X+*9^0y|Wk*qEP4KYG`DRa8_K7MHU1w;ZO!++x!7+k5rv(FHQ#FdOMkhA0`~ zW(M?fNNOw!-;|dX*1aw=th{qEscRtP(?aH@i+S_`vu*@mnXU?dew%saG$k=r8>ExiUj)LjJGEq;6W zvuMWf57Zkrb!^G))kjZ_q_{L|M#HlOa~-K0k|fE$s{GJsu4+HCAE&wB)DYM0Ssnb7 zu8Z6Vo9pMih!Ej7N5-N*-6Dx55zz5(+kQU7Nxbh8u$ABMLN-gTBe=j;>c?y4=QX?C z+00qAfu1{rl9hb|jrb73PH3yMkSFYkhzV!%n zV!TE*<~NvZ=DE(Aiw3*)Z}sOmQ})X-qOQlNESy!OMouRUpQKI`Uh}io4P(f06*Tid zlGgLD<+hDt+&_kpl$b)a|CR^6>jdpHYgw>irE3oF-De{L0ZF?3z;MnomsdSxaskh8 zwLUKgDvUKFvX0jtD|}N9^PJuLA(;y(Iic6T1;@YSsqf@eNBCCQs~gh(<$VM24iz|A zFX2riZ{Hb&6Lv#Vva=YxcCd@wOr%GlK#(7|4li1D>jZ-PPTPJxl~XXaUCDbdCY%O# z2u{O~W`DO^zew|mPzR1RXFdw|@*r(6?G3y*^O*?(Mk_j_(s9o%Fg~ID`t{&ktFWpJ zk)-YVFK$2lB|H6HTnz$o1%>h!1+9TVHbvLdU^ZIT)4nOhr>?txL*j6*jL`bKuebjt znORY9gmpR{Wba_HfIa+{}Q2k@s|MQ@!n*biJPXBmA?}@T|1;b!kN^?`fX|DQ@XZrx5 zBayoK(Cpe%+>c8`Fe5Re`Z0vm%|P71)N2=keXF-9Uvg+E`ht#ff3_wC=eE7$X^nNJ z5tmL&$MiL7u0TsMHq;Ub*&9zMPM!zv;IHQq~ps)%OID_tbH=V z#we8;_hwvYk7gy5@6uaAdBt-IAZ^|480IjIqTwc#IaXaT&}whH=uP{e&>WLINw(}1 z!8)3;b<0Trgka)Y^P+1q6%k%3Bx~5gy&!u6NrNXPHgfmSlBi*4_o-C5)w6k$O!@1B zgY{bb6Ij^A(4OWf~_~KEJYk|gI8L+?bjaXNS&J-tIYjAq} zrDAETf?4!wEKMA7v5_KH)mH$K_KT*>Ci$1^qxM%kpKcXP$cc)JpTk%tjR82Y>DAS2 zUru$X>tW#N;PAGOSv25EVSAawy?Lk)nWIj(q?vYWFa)5SS+BqIy-A`^=1z8O18GW@ zL`xV-E^e=pzDozDdc>MZw>=kX{STEnez!B@IpQZcA!B{W4>_h3g#MPQpK&G)gy3A~8`bZhalN&!&hheI`2VOAc(Jhrc@Q zEk!Sb48q$+v6swtIPFA5CSyB=EJTE;{A*``4YE?tPUqBPfm!hEISKHse-3c~gcQke zV1@xy!1h>`5hNS~SSc6Ql>lH>UD8Q(c(_M8i_zVV_#Mypj_|Dp)+yq zdo@lx+u;s28nelb#80rSuTp~wU9C}DXK7hjxW1|8|IGzAaho`-tpF5`a)(=Obp zN-1CRvpbcINT0e-eaqlNy4!CUC#Xgj$F{%7R5dkFSqnt1` z&~E$FUR7VOs^2wCg9?+zX+QDn!-~7-2@E=n=FRlZB~L3GwV?d`4B+}6k<)7uN;~dy zc-yJ0!JxhdJhuYJ-vfLk@%1;Wy!wCfK73YeruzG?oeqW`qc-mUnABSTO7G;04^U0; z7rln$o2J?i&kxBHrZt>m9PmNJ8^YK<27Ca2#yBW z2!cc2@_LF}AhvUNA^YGp`ynfWx@;>ZzGPW|V^(K@;3<7v zZ6Rmk=}18lb{wecNkzME411dg`sGdZ@^QmXBl!L9=9M9io10p5AHo4H#qsA>Zpkt5 z`DYTvuDQ?W-d_fXd=N+z1;K2=C&r&%)9tvfoy-$U(IPiCo^IKFAif*6YpY;#O08T( z?!CHAA}C2u;vckwR?1JPF1|z@N!i2Jld7D;PzfOb#!0SWF-yJ zX08g>mi^KxB|o>_lEFY%5uOgIB)%l7gBU-nO?hY1!SRy-vbTk_eq>Sl)TK4)|gbMB_pVS&XJt*O!9|I)76aea4TwFmwC1(`#Q ziLq#CP2Hr#Z*mXR3M$e@P6C?qN&tD~ln-}4%4zqqI?6!#`KV*Dr(;5|4Lf;0(Itkq zfSnSX-j?8yo0yIDn|*TZmuq(}vauWv)m|P^#=%PqYsOeba5jb5;<{BL4k?vK+o@YI z8ec_iLEPG1mZat6X~LGDlK#kfZ~6p;fWfG?`x{br_%Wg5IWGe>6nKjY%Rt3e+qgAOg9fee0OTel+&mg-<0eXf|7=VlfVdk$$xlEd` ze|MwkEb`Z!zQO9wCB}IOT{Y=OHpN^uvFG$uV*aleKyba(;!QXgdt`GE`rD^2jXj*n zS^2>$r3G(xJ*1eV6>tXhid&pB#ODkQC1|u?mC4DS#0l3tduA9!7}}&krcU9eauFo9 zw$0N3o3l`G?t0|ee|L_rY(d8WP-lUmjX;$zhnQ$^-~782Z}zth)&9;V@7?ckvg>%< z|44~c>F#aI0$-55HEq8b%tZ(5ls9|)pqzQneq2cPlcaq@%wubJTnlygd2%D!sv`k& zt^3~ntk+`IS~K<=qrRpBr&yi$^ET-D-z{<;j?Mqk5ETwvX8cxLx7yN1>ir0(d1FmJ zrvPm}mE|8D5hwh^G1+}WaMNR(3#G4;-a!F4ZX%ea)mY18)))+DRGnYti@d%lo_&4- zZB`=swIy)`un7Xqdoqrqu#sS$7yu(<<3#FbU8iXLabFNn2^biz3Xgu#tGre7@-Wa= z9>37ncSxTS6APF1^r`#2DSZ>^#*&YRHxIi3{9|>e(31|=m6q!eCyN2Q(u<%*b_w!|M!D`@hBM66p4y9RsH1PX?3 zvlbN&FDv!~zmba$t|A<&i&?qd#%-;w^qn4$H4MyNsVHg!mkaeBGMC?Sym9j9p)N+_2)S3Sn5&M6b1C45`AJY8U3&XyG1(#I4YZv_cQ?d*d%M56ebs6PE0k-#2L_g8=uteOzY!CvVk77rR?lz zYfp`&0;s7FLhbFwL09E$`&uHdkzMEKg?JVUB3a_{R{wQXDgn-h=Sk2~ep@~>ZKdA} zFZ(P{#6a5Fjhw8ak5;FZ*=pU5PMeZ6mOmH#bAt;B-XNI(N!i-$_9ocnp~^`>^|K#I zs)ps~%BIE-B1*CH_FJ9||9Mn9RT2~$UoGBt(&^~5R^*j&PdinB1&*+ZaFy0O9v3Aq z$_chS?v`<~8_MtsXt*c-Tr!^i&1(%6%&B~AC2jqr>>ok%w;hc{w`rUF)W8)8T3n%7=B>RnDG40%jM79xNd+M+%w+~_r zCRmVhTe)7NoX_4e<)PAi0@RmOx?p?hF$N6kdpFhar*lKv{(3ngp@Kg?(gm+nW=A;G zVRHpw3PtNMwiAj}-B!W@48NaK8EGc?4T7=_&x^5$;eK0y^Vp)d12hBw{$)>0KgF)w zFN}1?T-dPH1ngM8efe+EVBudzV<<`sfEaH36oN5C(H5rtNK^2|_*dq0*3It?Yo0)p zaUI$}4!+ud$)Louv=UGIqZ6p%6QI83P8K&vWg@)o&9vopPm|^g^W7F2hBTX~4Q|{Q zwXs2%-`+7_V-#WuB7TEg*MBWel0Ut>Js7#?+yxT**qsDKeGy zRb~{gcvg#l)f9-Ub>tYttc2z43AZm0niY%^7<@Qhv3@Xdfp!tH&aU}a{SADE+va&x zm1F6ZlecxwSRwk}O;ME=8i=i|8a{5RK^Kkh%;a5b^+qtu-6p5Gf0E+M_3hZ4dB$@j z$(ABl^|eK@*3YmZ$tqfjk)Y+Q`(h&O$EX&80Py)WmT(?r6}>9L`k95_*8Qr3Zfa79jUPTKKl!?BL3AM# zf$`RYG)71rp1rBv3d3v&_|v73}JAL7}m9P?CrGS=y6Zfpga6_Ag4?@u9_w}R8!Wu?647W2`r)SVY9>#rpl7Ah zMD;&uJIoeRmyV-au}H8ZT=6}OIp_mU1m!&k75`JY`DvZeQJ34|Gp9lP%83!T*k={) zvWSr)dd>V^6|V3~kPG4@JF?5~0331;UTh9R7bP=o zzSuKkkTF~j2rI_Exm*ZfZzy%{PMoizy{;NhIlA}sShYz6svyOBJOsVQCjpkZruozY zv*lt*F54BKv56?`a?f491;=Ync)i8Eq8yvU>xUgf%OzRNSInh-u`iZw?`|^GM7jd* z)ZmM`COE0^KF4XmP;>vo#4mFXEk1%#01I$96)m27MmQvPi23fQqF((K@O9Yhf1rM& z5B6*#R5w5eB6pjDb=`(+d$bximh(c4t{x7#Zb#g5D-L~L<&xFAQ5K?q6<^JSC};>w z+U0Vu6;if}I6{V1vPUP9eOJ5k`QB>cX}FHWbpyS4x`kIr_FBY(zV8dVk(tD>Vjoo} zRzQ4k_CBl%CE)AG0-sPYU>LCKj@&x-)i69h;ZV>Fz$yZ&QHEWF-+ zyj+ANnfm>FC^}mfhB~&dM089#^C9H?u>+SxG*KeCue^1gtO3al?MNEXWG!FyU9TVe zeu$u9&2e44Xf-4S(a>Z#W=&G=12ysoW4<5QJ*A_zF3IM+j*B6q`O8GEZ}0kOE*3T6 z7R|V3s@NH+@x!a&b>F3HSO&wxcI`?BFmq}o8^Pm96nY>@uo2q7boAR)v4vHZO%NQ8 zk?DGM8_T<`IevPGwIuq2r9pStYD3;pmi=uNAU!pn|DvCRLavU>x;uK;skl+29qXR- z?<#6L<^v|LbJiY8*jpcb%iOL%BqyPGLxn&EBwNtpJoYjD|4I|G0PoFNf%hg~po+I{ zKy~&mP&wQ0&yIXcmPsd!y%m`|a4%>*|+pk!aVjo7_Wb3E?NS5-_M>Jf5 zxFzC8BEy>B!O1nh7%DHwS&44e-QW}7Z)6sroyHelK(sfZHMyKAoP;3x#+>?j*QlNI zKBctqjDs*PG(~w>h5qao7!53g4^plEj530S8FyAT!+3%79P@t&Rjqjl@ck99|?ViA#UFh2@ z^CN+;9ho}<&6#Ig122eK_va-X6=Vz=r>FN15pG@m9q8=GMLl7^(3X^(y?=1%B>W25 zNzuw=fsv$K#u!W=8qkyD6)ly(DmTz~e))`aS8E6SD@_&})#nGuom34uw||M0n+xdF z=i5(Ha*$5gJ2<4fp@E86q}4x@oxCwIb?by|LS{0&WQi}I%XOHlexGD4!KsxLxtSXJ z?TIm5Sip-Fta@et33+)19_&~qWisD&+Z#TJCELi%n-p{qH>PK9>y~tN-=(ZUHs6mk?yoF)-KJplQfine@Jod6;_FQ zPjOGxP_OhuCP_i(2-q^WvCjZXa zAPUyML)JwuJ+g%r;6V2}M)b^Y70@A+2^-B4aNrBn5PK$XI~2(@5&v#(Gg&;bTX{81A~!*BjY_lp-`A2oR;=4Scx{=s_`MoId7)3$ac+6v|bo=(0yjp+7tScz=cR-R>G-JxCjB&Bj z@WWeVf!KNf4MS+>A8uQ{bEy#J92PDl`axfwu8US=>+^qX#q||{+{1F`7qK5$BuQgT09Pk(v9h90RgYXU z@J0rF9RG9guY8-lg38XV**Pnzgjb$d9N$59@i7ZLg*IFNs- zxCS<3o*6iVX0g`m@(;#7-S|{ab(1S^C%*Eg?9qtbwXXH+#LLMcM-i~k0j6}O?q)*I z{-Efe;4t)6mib8)m5ueX@p{b$qp5*itc>ss+VeP(|B+8gp8jJ(*E?G5fU)&Y<2>5t zHxd-URDZf@UEE0^rHdiEWo;Yq zSkY)`GCg1OD;%=}47h!wwiXVsPi zz7_lR=6?c0R>1krv7l-5!|UDhW8am-Uq#ix3UoL(2x<3m(#OWFstoDtx73!OsLVL~?ktvkWI$t$tCkk!q6QKs_}@YhmAcnjG!@ zbFqI$n{F`J8taS)0jRW}nfLzF?4BMdux*p2(idIX0u)W~7s2(+&Y!yQgeaaQ?!F-f z9iiVOt?i*7Zvr(Zam)NEQuein>&LGaC313ZgJ4|ncgEJ1+L7~$xGX|pf&;%CnzT0^ zL~ODZ1(O>2g#w)bf)R#da_nl?{EPN%M#85kjeKM|Am!piWKv<#+b?zXAEgEjhHJkx zgJ{-9C?i5kXGofg29EHtvRB0O#MQ(Aq*6JD+%Kz@=+rchG{TlP;*|>Xy-F`Ap&GjCys zeMhJFqSg1%c>>KMkc-H9qS^$Z-L=}|C8{f?E|ErG!sd)j;cy_2O{@=-CtjfmnjgEy z(>b%+Le2QiRDj8dOq;LGcjq2L#sS*^zhG(xq{{l<-iyVbU`sgf?SGH|c*l@iREXT# z6F-yW?ABdyh~FlGBeADSZuXYfW5aFhxO3+P)o$z-|41_6Zo~6ENWA80Rg0I79K1a| zBU{VlkXm8MXkw`r8yt$RL%P4QZTmdGag>8GvNUgijZ=c-zC^h0<>_i;edQ3NdwLgF zTjd#1_wftc&l3}iw=Xx@fsk-ra9i1F{rY@y6Bb_>aN9vz*?WG9xBV7NoqzMvZ@_Ez z`X+WDOT6XLFho5i#ro*J^Q>n~PKAvf$3ZlZ(aXsHtkYSf>tfzI;V6Y_HQc2!!tY7L z95S~M*{sLbNKsJn?US>Dl0tM9(p~M3{q4rHY~HupRmRChA2y@#+2(*I_n%KYOW_)k|USwmB;(UUH$$}afD>L3XXO> zYlV7qZSk#a%`Fqt<3Yq)0>p3UOR~fti^QK8r?B8gYwAbhx`rseoD(E~4c{FzJkETJ zTW8|Ma%MCA83ZoZ`O~ODe_OPq!T<|@2ovseF^Su++xxmm$dR^Gx&(2uoFClZSe5*u z%z!yU{pZx~TW!9mu=o?GafCDew&Vv zU|bDg84QFBjDClSO7EPzw?{Pe`mo?bXBNWHD3usj8r%qt>_=gHr}VWZ-DH&E+Rz0B z?%&7|M#_WQ@AGsEE*>AlGg3qA{FahO_nwU=BYx9>54mjeeJ6oXUt4{W`Os!$S}OR){`td#yUNg zPK<(++zyv&otCn!=zq=u@_BTSv(6&P`-JyTwv-d^ByRy%N{4tUjq0(iUu=*8oC2CoE3P;c*9IPUqiNF1`Tzt`D( zn!Pr;Nvn#Ur>LCr&bYw~&(fd)A1ZnJqGYa>96m2vna4H04tQdZ`;5eOfCk8KcEI4< zxP2NM{ij>3x#Tt5iC`br1_N#TJOYu(Gf9DU&L@Et?++dhms>-7wpx~^P$Q$;z9e3` z#^@>MFZ11~L<@gArlH)lQ}=y7!FNQfnf?oOvW$1bMh4&;Ymx%?6#fQnj zu%v{q6`poL`TThAb~U7k?X#BJoPY=&)-X|7h!;8T*b3y>ziqbW{%!+!D};0^8^K1> z9aYD#4R4O=9TUFu`Z;Ad9BPzv@dg`x)NGS^4eRWL7!w%tEICQS+U%s|<~=#mt!!h# zUpsev=OS0%wZ`WzEi4=WNRq42$R%qY0FiZ*WE?Vtyl7D`bY27R{t>K^soAIx7reC= zZh`8Fd&-G>WAZahkAe(?G|^jFJWhhE9qp=J5(IT==pJ4ayAC(F49^xHH(qesc?S`Z6|Mq)>lZc zBxA43EK0N-nXAsfyO^(C%N8cnZPFbs?!t{)V|QjbS(q5sX4QUpb9@KeQ`vG0#57~q z7t1|{DNN|7p>W0$y3qcbWMRJn30VYA#2XNcP<3xPG-pP30Ty(4`*X#NNl`wNbj^qi zL|xQFM!`9|^FDQcD?5}i>@^ZGC`u~sQZU(Z3Z?enP}%%2n6vsI;SkUJWJd<3%`BxM9;rN;k3^lvHT|)0LHobgLzT6cm3Nel(qr ztwqM@rqRA6Tp9FA{4c@@KYmVl2499q0QUv}B;ZTFl8)z(I1YAFUJoL9{}Kc{ zV){>Ke)S6%=b-8OLs2N>0JYG9jM|@6BI>_sM`~E0BB^*0hn2|0X{VMXxzrg@Q>C-A zEsm|xpQd#EFU{nH-ylrmVCP&P>bpNXA8-Rqk%m5>p_aj=%I0&Tn6S(2nA&pWVKMO9 zS)H@Uz#79v9fMnyo<9=J&o7A!NA-vHk3Y*L&w&wKa}Am$P8NZNUzH+DJ&ZLlTR?t1 z>x9PUR;ne>tv4>V3L67XMM=h0En7S~PL-FgK8P(mLs>0HqA=i`7 zL^Et?TOaVrC|-2lI(5xe%vt>+l5{GRaZFjFw@B^YS3rzflKk>b%S_sXbEY{|Q14c( zBm*ksfuw~D_W_Gsjy%!xnf6BttxgjTq;Q*UR7aO8ou81K{+E~~w$FKA@wXjOKpM?b z>%4F~;HPFDyuIbPZ9u!6g1w;#TXU^awuqFWn)SX#b1cF2KiIRGXs8-}ll@Bfe6OFG~b-+RzJ zW_Dc;a|TrDLW~#z5*BU^yaic#L?N(nvUJhP={K24=m-5c@)^Cj@$Xd=%Vn+|0a=5C zCFMr&)~|gxXUF{nrxzwg_YzB9G1cSpEZ#rW=i*-2sD z4%f446Gb|S*XuM(itsYgNhATqv8~OiW(;$(nY;cn zt>|Ifh1+?+N>F+MP9i%sy4>nl%limn_RKFeL{<&8v*Xu|3X~?_E?h~y9f{qR{L^>u z`AQWrt#F@{a+4CRwc9p+RFt3L6qe08S)99Y(;^oPdbaP1N`3Y`XhVU0`=7x~;AUXv z?!b`{;=oFft7%swt6J`2dzpw!U61vr=RdjLMZ3Mao}=u)$!tNGQiMTO_9U6-KOWym zrzp!yujoqVeU^Qs_Yo%PA1;MZZvb4%t8cbVe=Ua?%`($2F?)a#z61xT=vn@(t2vN~ zyC0O~jj)9Vu_hxRDbPjRd_2OP2W6a0BiV0?yYEX==!|nrp1o9^WTy&XvmCx~Q7*z%_evsydQT?Q z2f0(bK5%3CxylR6n+%>sWe*vh{bBbUC>Q`Eb-L6q%u_zS2^ye zZa?5;>7$g6DRYWt#97u6KbfBUo+6jCJazJEa}Fy+s@%;Q;P`J_HGHho zIfIvO(ga_Ygt9dW?eL=f*RKJOD6X+sA>ZNaeBry#jU4t8DrJz`e(5AsoR$J-rS_KM z2A~MKcLKD(tuukv>mb~rOdQdlqHgV*|CEN6Cy5f4A(=$4hst|qp&d?g7_#(|M-p8q z@26L1;@2=;{$YlS6EwA{B%`FN(66H1np~~T`Z^?P!>4y}O9n}=`7~di6 z>zAs!N%2M)3V_2Q2blz&6hHF)o3-$|AzXgsV*A$Q3i)`s_wWgjX^hz+hY8C4oKbE^ z)t4FoH^QqQ4(DacFHXL7W!9A=JeU5TtWxcDkQU#;@WHdX5XdSHVIvgA zeOocy;4Tupuq#Ce_;?_SPIP6EC1}P+jT9KDstfGKgLe+dUVQX+r!@bnHIH_7IO5oo zcK1F{?R#7+)<@OW&&uo&4JI!6;@0D-&i8&g@WC_(y!NWih!D87VXJlK&`T+`fN>LS zocl+GuLYXA>z{m*HZP~ANm4wOY&2B&!XE{iX?`^X>scrH1@To3XHD+f9DKhon@Jw- z<1OJU{h5|sV^TTXNT-9Eh;tUnY1<8V@1hB;D6~~Fc{BBHXQ^zZnoKk!{#iw#JA{Yr zK)z_qEINoR$dQosB8Z2G`lrEwMh!{$e@RE?kEee*chOKk$0o_cv!)QC!pN68Yeo4Q zAq^1KD0i5y@&vqg;OpJ{%O)S7XuNJBey;>bnE9&?wpJkRJl7#_olFB;D8D&R=}l2b zSFevGVFqGinb6oZ)#y<#Pbu6|2zBtv~%8X zO>|)z9tescO&}soiqfU`-cb-E#n4fN4-p705Kxeghzf{wY0^s|0VF^GDI(IOm(T_2 z7y>H2@9^#JwSU82yZJGhT$6b-=gfJ}^E@|K8lrpvO~i?g5o_XqAROHWQ#LU{e$i?d z?WgWSU>S|4v%xApLew|XNkfMGuWT6nB47cCf?V z%vR6w?Y-};)8^5x-AQ4FXPmFg!P&X*7QKnOG#++!Tc~uBtuFs6I`Hd`chfjJ^ZqL# z98=h%Ax)im%NS;!jg3%@`}PcP+bC?>q0+_7$HI7etCimSE$UhjsBbcsZU&D9y6$=J z9aqFdAh?}CV=k^rc$eU#`lh)TCz&0Y{WC}LTcB^f7`XQZG!oIP0gGZgiQCLCuPo+E zX~Jf42|volicky!^uA-n>unqeI>NMW^^0GuOKsI+!q?2Ek`XpB>+5DHt0lp|8G~fH z`d=KebdO>8_fshj!qw}`tCkh|xL#o^qndWhX)$j~)o((PC-7`s&NtE=T*J2a+!xqne zX+TgU@X?*L$sju^8HR7gI&s5Q1704>CH(u&QFm2JRog?2&?w|W1m0NG!QbxVcAJju zi0?>~+Aa$%ea~i%WhAyT4pt-+h`|qe$YRtJT?G^R_rc|`3Xc+tzn@l8Vto+CRM1j*WYYRZW0@-enSiJFpeBzmXXb) zKC&Z$#j`GQiznd*Hgym9I{7l;^5}MFM~@EAoE5ys@oVBDxTscZwOJYsTp*IEnm%P) zZhKCp?a$c3cIaQ7VqekWo+MXKh>g;p;uUw1-LhXzOH^A0$rBnoPm6DSPDpsC zJ*I^Doo?2Z>Q+Wenq5iE6TQQkfQR--J|No@UfdaD8*`Zg?<&;i!ewZ2aEBV{l_T?X z9U@KZ?M#O>cKrb3|1lYq>qe2-?o<_Y`s#uyUDuO7K3v&~o?OXk zV?glSi-TtveY8z+P6jvRzA@)@ z8^b8!E-ByBHfHg~n1RB6#pky8vVh_0_VJpVrb;imhTq}(M@67kO!6im1mhq{v9Wv^ z!lfTx5q-x_8MW=W0-9C)t}FkQ4?kIWGEFo+2qXi3kMeXLIE}IFI7|yUg+rF3`FB!j zOkc-azDnC4su{t$(9s|Qc~&-Kfp%T z*%3y!-xr}q=ID#9tYn=~iuOAgz9pNOdH?xX#=zriQr8yHYQeRCEkKWGM+lR`noje_ z*dc=KOOlLeN$}YnkF(k7vuP?}4|e$1w}(nXG$@?XC#AneqWL!mW!-dh9-__7Yfn>& zpeJnPW?SWGOX{{x_vf0ad&q1x7KA<_K^k<+=!pNGjmcrU?`)-Vud{!ruraH=N2Uiw zqfC!ImA`%Vs8s^U9zlGx`hZh&=Rj)sXlmKV^7KijK3o&=u?Te+R8_XW9h?Z^5rZI& zW<6vbPbTzj{nC49U zm0(E6TqxxQvl0cR$L~snbW!T=aE27gl0HzsdOl#J;H z0F3-8>Xeg;KxN~JF`2stWlN?O9?2#{3E>t!Zfys+^P$59R-%O*X^zT&NNH4dG0WT3Imx z?;2+KgwpUB!}GFQ@*H?267(AdY9`Iu->eVL?-Pf)ZaXFok8?HGUio-n}#Nyl`#?4$MIPSAtZq4$9; z1XKl$u#N~K0*}=9&oZur02ZMn$~dZ-02o@oCfZXx{EM31Ck((tCm3mFQm%JgF~c0t zO9n{5K@<|qN=$YjbHs&-F5zfV8%#v&@|%#GR!Z^qxDgpZ1XG!YD<3isqI~W+Br1{0 z``l8F>Ng*!|6-T0o%~y02627j_5n!)Xn-;Ky5Tvg)Ll^iNgJ#B`CXO@WfDlN$dNZv z3k{V8^Wv|#UuijuhUY(X-N-$6Nw0tBGG6`bEf@U@$7P+-GBbOo?^yX$KqD7vavJ+yPLc5v+ z3{ydHk%lFB2BG>5?E786Ts{z&2FU$4YHXb0Uj*4LknaM%4SfGMpANK0QT!+8iRQn! zPyhe%d3F9bl;xbv1t7p+FUXa8a(jB(w|Nu>;bn zIul#GGLJV=UFO|*RTY6c1tn%EH{XL*1m01 z<`3!IfViN5WKdzL|7-iVRweIavpD5e>;ev?U5Rgh;5&!5A+pry?zc_z9yIo?Y%BNb z9rs3q)1QV%e?A%CuQ~-R?p@vcy>KFmi)$-iV>Li1j3ZpkjM!Rg*Yl&6=$@kmw}fAcJy0u zO#@EsHs)JeeBGPKtP5aYrIkXi@vTCS4(PF!*zYOcG39?`y?BT>yg+!TTbJ8Bn}zjA z8`b2=3!BwsCGgih9Fr~Wy0QGwU4Q- z*p~Iwc7Yb(b7ICP2HBpkZXmc1M+d`x7mAcN>Fy<5wi{hw01VG(HOnaU zwt|xH?Cfg8x@%=F;gA%ooS|dQ=BI70(AUE(NByq;43n9v^v}r2x+`?1j~aMx@4^Ri z=t!wTF|k;Ac;WVrruYo`$7Yh6~p=M_HC)axjhQzTcM&;A3ysJk#(MgeUt{K>DB+70~%|BZojoGhs0jd>b>&#t%*49PX`^ z#zBo6(|iQ=Ss`Ct2zwr2pQOzKdv8dCYTQsP*^b3S{a$^Hz>)!%&#!LLKv>PHzM7ftGwk0BE@)>fYvR_4!b3SxsQvJRd-o9yj%D{vKK4IH;Q4bDYW8 z8`fB)l8^prZrErB4U*MKToJRMamn`2#&vH?g(#{VwYnfzYIvVi6lV})Jq2(}1AOEe zzQp#%Ypk?I)M<6Of&0s&QQ=3ssiypSgd<(}>vkTuV%#OYfo9^TNXrDmLqJf~N`wsf z5Z9aC!C_)jLv1|5-0>23Gy{(0w-(+j14HJw!hZ??c2KZ>cJ`BM_-t*1UBq?n98&C5 zj>rZc!M41;rAIr#FvVH}Z;ku0vVw009*|*@02lr6cF*yr@2W0RL%tYflu(p`z>MDx^ z(Qmmav#~eZGPw?CmJorfEgXNyNAZun>sfakX;I4BdmbZW2ZaQr5X2J9dZBBBnW>zM zcJ|^sVkkxMg|Ff-Sd;D5lwS_ZgFu{@JO@nn`}~3N;{sC5_4@U z^gR>Z&e>gAW@@)>ALf^FtvENEkDN$sxc$B6L(w94jvak>j-dsv=75hL@b!Ov^^~p< ayLAR>M0j74DVwZ6uZX@bOb4rF7xo|I$r&O5 diff --git a/doc/docs/statics/logo-bright.svg b/doc/docs/statics/logo-bright.svg new file mode 100644 index 0000000..f1ef1ff --- /dev/null +++ b/doc/docs/statics/logo-bright.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/doc/docs/statics/logo-dark.svg b/doc/docs/statics/logo-dark.svg new file mode 100644 index 0000000..7f3ffa0 --- /dev/null +++ b/doc/docs/statics/logo-dark.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/doc/docs/statics/logo.png b/doc/docs/statics/logo.png deleted file mode 100644 index c9ac789ce1a9beef5502857b8b7e5614a8d10f3f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 64493 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
G@V@bMusG(?d`ZCAAMb4Uc><;H!h(pc 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 diff --git a/doc/docs/statics/logo.svg b/doc/docs/statics/logo.svg new file mode 100644 index 0000000..bc85b66 --- /dev/null +++ b/doc/docs/statics/logo.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/mkdocs.yml b/doc/mkdocs.yml index e45174d..6035216 100644 --- a/doc/mkdocs.yml +++ b/doc/mkdocs.yml @@ -3,7 +3,7 @@ docs_dir: ./docs theme: name: material custom_dir: overrides - logo: statics/logo.png + logo: statics/logo-dark.svg palette: - scheme: slate toggle: diff --git a/misc/LogoMakr-1TEtSp.png b/misc/LogoMakr-1TEtSp.png deleted file mode 100644 index f1c3a7ecfbce3d160960ff33ed579168dec20f8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5079 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 ayLAR>M0j74DVwZ6uZX@bOb4rF7xo|I$r&O5 diff --git a/misc/logo.png b/misc/logo.png deleted file mode 100644 index c9ac789ce1a9beef5502857b8b7e5614a8d10f3f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 64493 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
G@V@bMusG(?d`ZCAAMb4Uc><;H!h(pc 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 From 2aad5d4b9df84211b97d0ef1839d14ee0c1589fd Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 4 Apr 2024 09:53:33 +0200 Subject: [PATCH 21/97] Upgrading mod package --- go.mod | 2 +- go.sum | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 83092ae..82244c7 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/mitchellh/go-wordwrap v1.0.1 github.com/spf13/cobra v1.8.0 github.com/thediveo/netdb v1.0.2 - golang.org/x/mod v0.11.0 + golang.org/x/mod v0.16.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.27.2 k8s.io/apimachinery v0.27.2 diff --git a/go.sum b/go.sum index 3d3f2fb..4d75827 100644 --- a/go.sum +++ b/go.sum @@ -157,8 +157,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= -golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -248,7 +248,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 441b30a57047f09113215c967ffb565e6f26b145 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 5 Apr 2024 07:56:27 +0200 Subject: [PATCH 22/97] Code cleaning Using gofumpt. Add documentation. Some fixes on type checking and const icon type declaration. --- cmd/katenary/main.go | 7 +++---- doc/docs/statics/logo.svg | 6 +++--- generator/converter.go | 23 ++++++++------------- generator/cronJob.go | 5 +++-- generator/deployment.go | 9 +++----- generator/generator.go | 41 +++++++++++++++++++------------------ generator/ingress.go | 7 +++---- generator/katenaryLabels.go | 5 ++--- generator/labels.go | 4 ++++ generator/secret.go | 10 +++++---- generator/service.go | 4 ++-- generator/volume.go | 4 ++-- parser/main.go | 3 --- update/main.go | 20 +++++++++--------- utils/icons.go | 22 ++++++++++---------- utils/utils.go | 9 ++++---- 16 files changed, 86 insertions(+), 93 deletions(-) diff --git a/cmd/katenary/main.go b/cmd/katenary/main.go index fd427b3..247ae81 100644 --- a/cmd/katenary/main.go +++ b/cmd/katenary/main.go @@ -2,11 +2,12 @@ package main import ( "fmt" - "katenary/generator" - "katenary/utils" "os" "strings" + "katenary/generator" + "katenary/utils" + "github.com/compose-spec/compose-go/cli" "github.com/spf13/cobra" ) @@ -151,7 +152,6 @@ func generateConvertCommand() *cobra.Command { convertCmd.Flags().StringVarP(&givenAppVersion, "app-version", "a", "", "Specify the app version (in Chart.yaml)") convertCmd.Flags().StringVarP(&chartVersion, "chart-version", "v", chartVersion, "Specify the chart version (in Chart.yaml)") return convertCmd - } func generateVersionCommand() *cobra.Command { @@ -165,7 +165,6 @@ func generateVersionCommand() *cobra.Command { } func generateLabelHelpCommand() *cobra.Command { - markdown := false all := false cmd := &cobra.Command{ diff --git a/doc/docs/statics/logo.svg b/doc/docs/statics/logo.svg index bc85b66..8c590f0 100644 --- a/doc/docs/statics/logo.svg +++ b/doc/docs/statics/logo.svg @@ -120,7 +120,7 @@ diff --git a/generator/converter.go b/generator/converter.go index e3e5bf1..ade8b20 100644 --- a/generator/converter.go +++ b/generator/converter.go @@ -4,9 +4,6 @@ import ( "bytes" "errors" "fmt" - "katenary/generator/extrafiles" - "katenary/parser" - "katenary/utils" "log" "os" "os/exec" @@ -15,6 +12,10 @@ import ( "strings" "time" + "katenary/generator/extrafiles" + "katenary/parser" + "katenary/utils" + "github.com/compose-spec/compose-go/types" goyaml "gopkg.in/yaml.v3" ) @@ -34,7 +35,6 @@ const headerHelp = `# This file is autogenerated by katenary // Convert a compose (docker, podman...) project to a helm chart. // It calls Generate() to generate the chart and then write it to the disk. func Convert(config ConvertOptions, dockerComposeFile ...string) { - var ( templateDir = filepath.Join(config.OutputDir, "templates") helpersPath = filepath.Join(config.OutputDir, "templates", "_helpers.tpl") @@ -103,7 +103,7 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { os.RemoveAll(config.OutputDir) // create the chart directory - if err := os.MkdirAll(templateDir, 0755); err != nil { + if err := os.MkdirAll(templateDir, 0o755); err != nil { fmt.Println(utils.IconFailure, err) os.Exit(1) } @@ -134,7 +134,7 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { } servicename := template.Servicename - if err := os.MkdirAll(filepath.Join(templateDir, servicename), 0755); err != nil { + if err := os.MkdirAll(filepath.Join(templateDir, servicename), 0o755); err != nil { fmt.Println(utils.IconFailure, err) os.Exit(1) } @@ -142,7 +142,7 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { // if the name is a path, create the directory if strings.Contains(name, string(filepath.Separator)) { name = filepath.Join(templateDir, name) - err := os.MkdirAll(filepath.Dir(name), 0755) + err := os.MkdirAll(filepath.Dir(name), 0o755) if err != nil { fmt.Println(utils.IconFailure, err) os.Exit(1) @@ -163,7 +163,6 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { } // calculate the sha1 hash of the services - buf := bytes.NewBuffer(nil) encoder := goyaml.NewEncoder(buf) encoder.SetIndent(2) @@ -437,7 +436,6 @@ func addChartDoc(values []byte, project *types.Project) []byte { } } return []byte(chartDoc + strings.Join(lines, "\n")) - } const imagePullPolicyHelp = `# imagePullPolicy allows you to specify a policy to cache or always pull an image. @@ -466,7 +464,6 @@ func addImagePullPolicyHelp(values []byte) []byte { } func addVariablesDoc(values []byte, project *types.Project) []byte { - lines := strings.Split(string(values), "\n") currentService := "" @@ -550,7 +547,6 @@ func removeNewlinesInsideBrackets(values []byte) []byte { replacement = regexp.MustCompile(`\s+`).ReplaceAll(replacement, []byte(" ")) // remove newlines inside brackets return bytes.ReplaceAll(b, matches[1], replacement) - }) } @@ -607,8 +603,8 @@ func checkOldLabels(project *types.Project) error { return nil } +// helmUpdate runs "helm dependency update" on the output directory. func helmUpdate(config ConvertOptions) error { - // lookup for "helm" binary fmt.Println(utils.IconInfo, "Updating helm dependencies...") helm, err := exec.LookPath("helm") @@ -623,8 +619,8 @@ func helmUpdate(config ConvertOptions) error { return cmd.Run() } +// helmLint runs "helm lint" on the output directory. func helmLint(config ConvertOptions) error { - fmt.Println(utils.IconInfo, "Linting...") helm, err := exec.LookPath("helm") if err != nil { @@ -635,5 +631,4 @@ func helmLint(config ConvertOptions) error { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() - } diff --git a/generator/cronJob.go b/generator/cronJob.go index 91e409c..488ffd2 100644 --- a/generator/cronJob.go +++ b/generator/cronJob.go @@ -1,10 +1,11 @@ package generator import ( - "katenary/utils" "log" "strings" + "katenary/utils" + "github.com/compose-spec/compose-go/types" goyaml "gopkg.in/yaml.v3" batchv1 "k8s.io/api/batch/v1" @@ -26,7 +27,7 @@ type CronJob struct { // NewCronJob creates a new CronJob from a compose service. The appName is the name of the application taken from the project name. func NewCronJob(service types.ServiceConfig, chart *HelmChart, appName string) (*CronJob, *RBAC) { - var labels, ok = service.Labels[LABEL_CRONJOB] + labels, ok := service.Labels[LABEL_CRONJOB] if !ok { return nil, nil } diff --git a/generator/deployment.go b/generator/deployment.go index eff2b84..54b85db 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -2,7 +2,6 @@ package generator import ( "fmt" - "katenary/utils" "log" "os" "path/filepath" @@ -10,6 +9,8 @@ import ( "strings" "time" + "katenary/utils" + "github.com/compose-spec/compose-go/types" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -32,7 +33,6 @@ type Deployment struct { // NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name. // It also creates the Values map that will be used to create the values.yaml file. func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { - isMainApp := false if mainLabel, ok := service.Labels[LABEL_MAIN_APP]; ok { main := strings.ToLower(mainLabel) @@ -163,7 +163,6 @@ func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *In // AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. // If the volume is a bind volume it will warn the user that it is not supported yet. func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { - tobind := map[string]bool{} if v, ok := service.Labels[LABEL_CM_FILES]; ok { binds := []string{} @@ -323,7 +322,6 @@ func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) { // SetEnvFrom sets the environment variables to a configmap. The configmap is created. func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { - if len(service.Environment) == 0 { return } @@ -419,7 +417,6 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { } func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) { - // get the label for healthcheck if v, ok := service.Labels[LABEL_HEALTHCHECK]; ok { probes := struct { @@ -473,7 +470,6 @@ func (d *Deployment) Yaml() ([]byte, error) { volumeName = strings.TrimSpace(strings.Replace(content[i], "name: ", "", 1)) break } - } if volumeName == "" { continue @@ -561,6 +557,7 @@ func (d *Deployment) Yaml() ([]byte, error) { return []byte(strings.Join(content, "\n")), nil } +// Filename returns the filename of the deployment. func (d *Deployment) Filename() string { return d.service.Name + ".deployment.yaml" } diff --git a/generator/generator.go b/generator/generator.go index c646d31..811aeed 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -5,7 +5,6 @@ package generator import ( "bytes" "fmt" - "katenary/utils" "log" "os" "path/filepath" @@ -13,6 +12,8 @@ import ( "strconv" "strings" + "katenary/utils" + "github.com/compose-spec/compose-go/types" goyaml "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" @@ -24,23 +25,15 @@ import ( // // The Generate function will create the HelmChart object this way: // -// 1. Detect the service port name or leave the port number if not found. -// -// 2. Create a deployment for each service that are not ingnore. -// -// 3. Create a service and ingresses for each service that has ports and/or declared ingresses. -// -// 4. Create a PVC or Configmap volumes for each volume. -// -// 5. Create init containers for each service which has dependencies to other services. -// -// 6. Create a chart dependencies. -// -// 7. Create a configmap and secrets from the environment variables. -// -// 8. Merge the same-pod services. +// - Detect the service port name or leave the port number if not found. +// - Create a deployment for each service that are not ingnore. +// - 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. func Generate(project *types.Project) (*HelmChart, error) { - var ( appName = project.Name deployments = make(map[string]*Deployment, len(project.Services)) @@ -226,7 +219,6 @@ func computeNIndent(b []byte) []byte { // // 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 { @@ -239,6 +231,7 @@ func removeReplaceString(b []byte) []byte { return b } +// serviceIsMain returns true if the service is the main app. func serviceIsMain(service types.ServiceConfig) bool { if main, ok := service.Labels[LABEL_MAIN_APP]; ok { return main == "true" || main == "yes" || main == "1" @@ -246,6 +239,7 @@ func serviceIsMain(service types.ServiceConfig) bool { return false } +// setChartVersion sets the chart version from the service image tag. func setChartVersion(chart *HelmChart, service types.ServiceConfig) { if chart.Version == "" { image := service.Image @@ -258,6 +252,7 @@ func setChartVersion(chart *HelmChart, service types.ServiceConfig) { } } +// fixPorts checks the "ports" label from container and add it to the service. func fixPorts(service *types.ServiceConfig) error { // check the "ports" label from container and add it to the service if portsLabel, ok := service.Labels[LABEL_PORTS]; ok { @@ -286,6 +281,7 @@ func fixPorts(service *types.ServiceConfig) error { return nil } +// setCronJob creates a cronjob from the service labels. func setCronJob(service types.ServiceConfig, chart *HelmChart, appName string) *CronJob { if _, ok := service.Labels[LABEL_CRONJOB]; !ok { return nil @@ -318,6 +314,7 @@ func setCronJob(service types.ServiceConfig, chart *HelmChart, appName string) * return cronjob } +// setDependencies sets the dependencies from the service labels. func setDependencies(chart *HelmChart, service types.ServiceConfig) (bool, error) { // helm dependency if v, ok := service.Labels[LABEL_DEPENDENCIES]; ok { @@ -342,6 +339,7 @@ func setDependencies(chart *HelmChart, service types.ServiceConfig) (bool, error return false, nil } +// isIgnored returns true if the service is ignored. func isIgnored(service types.ServiceConfig) bool { if v, ok := service.Labels[LABEL_IGNORE]; ok { return v == "true" || v == "yes" || v == "1" @@ -349,6 +347,7 @@ func isIgnored(service types.ServiceConfig) bool { return false } +// buildVolumes creates the volumes for the service. func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map[string]*Deployment) error { appName := chart.Name for _, v := range service.Volumes { @@ -371,7 +370,7 @@ func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map y, _ := pvc.Yaml() chart.Templates[pvc.Filename()] = &ChartTemplate{ Content: y, - Servicename: service.Name, //TODO, use name + Servicename: service.Name, // TODO, use name } case "bind": @@ -437,6 +436,7 @@ func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map return nil } +// generateConfigMapsAndSecrets creates the configmaps and secrets from the environment variables. func generateConfigMapsAndSecrets(project *types.Project, chart *HelmChart) error { appName := chart.Name for _, s := range project.Services { @@ -498,6 +498,7 @@ func generateConfigMapsAndSecrets(project *types.Project, chart *HelmChart) erro return nil } +// samePodVolume returns true if the volume is already in the target deployment. 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 @@ -541,6 +542,7 @@ func samePodVolume(service types.ServiceConfig, v types.ServiceVolumeConfig, dep return false } +// setSharedConf sets the shared configmap to the service. func setSharedConf(service types.ServiceConfig, chart *HelmChart, deployments map[string]*Deployment) { // if the service has the "shared-conf" label, we need to add the configmap // to the chart and add the env vars to the service @@ -583,5 +585,4 @@ func setSharedConf(service types.ServiceConfig, chart *HelmChart, deployments ma target.Spec.Template.Spec.Containers[i] = c } } - } diff --git a/generator/ingress.go b/generator/ingress.go index 29de1a9..316743a 100644 --- a/generator/ingress.go +++ b/generator/ingress.go @@ -1,10 +1,11 @@ package generator import ( - "katenary/utils" "log" "strings" + "katenary/utils" + "github.com/compose-spec/compose-go/types" goyaml "gopkg.in/yaml.v3" networkv1 "k8s.io/api/networking/v1" @@ -21,7 +22,6 @@ type Ingress struct { // NewIngress creates a new Ingress from a compose service. func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress { - appName := Chart.Name // parse the KATENARY_PREFIX/ingress label from the service @@ -73,7 +73,7 @@ func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress { Annotations: map[string]string{}, } - //ingressClassName := `{{ .Values.` + service.Name + `.ingress.class }}` + // ingressClassName := `{{ .Values.` + service.Name + `.ingress.class }}` ingressClassName := utils.TplValue(service.Name, "ingress.class") servicePortName := utils.GetServiceNameByPort(int(mapping["port"].(int32))) @@ -173,7 +173,6 @@ func (ingress *Ingress) Yaml() ([]byte, error) { out = append(out, `{{- end -}}`) ret = []byte(strings.Join(out, "\n")) return ret, nil - } func (ingress *Ingress) Filename() string { diff --git a/generator/katenaryLabels.go b/generator/katenaryLabels.go index dbd41c0..6c7c37a 100644 --- a/generator/katenaryLabels.go +++ b/generator/katenaryLabels.go @@ -4,13 +4,14 @@ import ( "bytes" _ "embed" "fmt" - "katenary/utils" "regexp" "sort" "strings" "text/tabwriter" "text/template" + "katenary/utils" + "sigs.k8s.io/yaml" ) @@ -139,7 +140,6 @@ func generateTableHeaderSeparator(maxNameLength, maxDescriptionLength, maxTypeLe // GetLabelHelpFor returns the help for a specific label. func GetLabelHelpFor(labelname string, asMarkdown bool) string { - help, ok := labelFullHelp[labelname] if !ok { return "No help available for " + labelname + "." @@ -225,5 +225,4 @@ Type: {{ .Help.Type }} Example: {{ .Help.Example }} ` - } diff --git a/generator/labels.go b/generator/labels.go index bbf06e5..acb2982 100644 --- a/generator/labels.go +++ b/generator/labels.go @@ -13,6 +13,8 @@ const ( ServiceLabel ) +// GetLabels returns the labels for a service. It uses the appName to replace the __replace__ in the labels. +// This is used to generate the labels in the templates. func GetLabels(serviceName, appName string) map[string]string { labels := map[string]string{ KATENARY_PREFIX + "component": serviceName, @@ -24,6 +26,8 @@ func GetLabels(serviceName, appName string) map[string]string { return labels } +// GetMatchLabels returns the matchLabels for a service. It uses the appName to replace the __replace__ in the labels. +// This is used to generate the matchLabels in the templates. func GetMatchLabels(serviceName, appName string) map[string]string { labels := map[string]string{ KATENARY_PREFIX + "component": serviceName, diff --git a/generator/secret.go b/generator/secret.go index eb020ed..6a64b8d 100644 --- a/generator/secret.go +++ b/generator/secret.go @@ -3,17 +3,20 @@ package generator import ( "encoding/base64" "fmt" - "katenary/utils" "strings" + "katenary/utils" + "github.com/compose-spec/compose-go/types" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" ) -var _ DataMap = (*Secret)(nil) -var _ Yaml = (*Secret)(nil) +var ( + _ DataMap = (*Secret)(nil) + _ Yaml = (*Secret)(nil) +) // Secret is a kubernetes Secret. // @@ -78,7 +81,6 @@ func (s *Secret) SetData(data map[string]string) { for key, value := range data { s.AddData(key, value) } - } // AddData adds a key value pair to the secret. diff --git a/generator/service.go b/generator/service.go index 90c1422..a573f4d 100644 --- a/generator/service.go +++ b/generator/service.go @@ -1,10 +1,11 @@ package generator import ( - "katenary/utils" "regexp" "strings" + "katenary/utils" + "github.com/compose-spec/compose-go/types" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -22,7 +23,6 @@ type Service struct { // NewService creates a new Service from a compose service. func NewService(service types.ServiceConfig, appName string) *Service { - ports := []v1.ServicePort{} s := &Service{ diff --git a/generator/volume.go b/generator/volume.go index 8269b2c..5678c5c 100644 --- a/generator/volume.go +++ b/generator/volume.go @@ -1,9 +1,10 @@ package generator import ( - "katenary/utils" "strings" + "katenary/utils" + "github.com/compose-spec/compose-go/types" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -59,7 +60,6 @@ func (v *VolumeClaim) Yaml() ([]byte, error) { } volumeName := v.volumeName out, err := yaml.Marshal(v) - if err != nil { return nil, err } diff --git a/parser/main.go b/parser/main.go index c9ffe0f..0507a9c 100644 --- a/parser/main.go +++ b/parser/main.go @@ -8,7 +8,6 @@ import ( // Parse compose files and return a project. The project is parsed with dotenv, osenv and profiles. func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, error) { - cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames, "compose.katenary.yaml") if len(dockerComposeFile) == 0 { @@ -23,8 +22,6 @@ func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, erro cli.WithNormalization(true), cli.WithInterpolation(true), cli.WithResolvedPaths(false), - - //cli.WithResolvedPaths(true), ) if err != nil { return nil, err diff --git a/update/main.go b/update/main.go index c9db8c9..021cdb6 100644 --- a/update/main.go +++ b/update/main.go @@ -14,8 +14,10 @@ import ( "golang.org/x/mod/semver" ) -var exe, _ = os.Executable() -var Version = "master" // reset by cmd/main.go +var ( + exe, _ = os.Executable() + Version = "master" // reset by cmd/main.go +) // Asset is a github asset from release url. type Asset struct { @@ -25,7 +27,6 @@ type Asset struct { // CheckLatestVersion check katenary latest version from release and propose to download it func CheckLatestVersion() (string, []Asset, error) { - githuburl := "https://api.github.com/repos/metal3d/katenary/releases/latest" // Create a HTTP client with 1s timeout client := &http.Client{ @@ -45,7 +46,7 @@ func CheckLatestVersion() (string, []Asset, error) { defer resp.Body.Close() // Get tag_name from the json response - var release = struct { + release := struct { TagName string `json:"tag_name"` Assets []Asset `json:"assets"` PreRelease bool `json:"prerelease"` @@ -57,19 +58,19 @@ func CheckLatestVersion() (string, []Asset, error) { // if it's a prerelease, don't update if release.PreRelease { - return "", nil, errors.New("Prerelease detected, not updating") + return "", nil, errors.New("prerelease detected, not updating") } // no tag, don't update if release.TagName == "" { - return "", nil, errors.New("No release found") + return "", nil, errors.New("no release found") } // compare the current version, if the current version is the same or lower than the latest version, don't update versions := []string{Version, release.TagName} semver.Sort(versions) if versions[1] == Version { - return "", nil, errors.New("Current version is the latest version") + return "", nil, errors.New("current version is the latest version") } return release.TagName, release.Assets, nil @@ -77,7 +78,6 @@ func CheckLatestVersion() (string, []Asset, error) { // DownloadLatestVersion will download the latest version of katenary. func DownloadLatestVersion(assets []Asset) error { - defer func() { if r := recover(); r != nil { os.Rename(exe+".old", exe) @@ -117,7 +117,7 @@ func DownloadLatestVersion(assets []Asset) error { } default: fmt.Println("Unsupported OS") - err = errors.New("Unsupported OS") + err = errors.New("unsupported OS") } } if err == nil { @@ -138,7 +138,7 @@ func DownloadFile(url, exe string) error { return err } defer resp.Body.Close() - fp, err := os.OpenFile(exe, os.O_WRONLY|os.O_CREATE, 0755) + fp, err := os.OpenFile(exe, os.O_WRONLY|os.O_CREATE, 0o755) if err != nil { return err } diff --git a/utils/icons.go b/utils/icons.go index 3767db5..999e082 100644 --- a/utils/icons.go +++ b/utils/icons.go @@ -8,17 +8,17 @@ type Icon string // Icons used in katenary. const ( IconSuccess Icon = "✅" - IconFailure = "❌" - IconWarning = "⚠️'" - IconNote = "📝" - IconWorld = "🌐" - IconPlug = "🔌" - IconPackage = "📦" - IconCabinet = "🗄️" - IconInfo = "❕" - IconSecret = "🔒" - IconConfig = "🔧" - IconDependency = "🔗" + IconFailure Icon = "❌" + IconWarning Icon = "⚠️'" + IconNote Icon = "📝" + IconWorld Icon = "🌐" + IconPlug Icon = "🔌" + IconPackage Icon = "📦" + IconCabinet Icon = "🗄️" + IconInfo Icon = "❕" + IconSecret Icon = "🔒" + IconConfig Icon = "🔧" + IconDependency Icon = "🔗" ) // Warn prints a warning message diff --git a/utils/utils.go b/utils/utils.go index ecccc66..220d296 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -90,7 +90,6 @@ func GetContainerByName(name string, containers []corev1.Container) (*corev1.Con // GetContainerByName returns a container by name and its index in the array. func TplValue(serviceName, variable string, pipes ...string) string { - if len(pipes) == 0 { return `{{ tpl .Values.` + serviceName + `.` + variable + ` $ }}` } else { @@ -108,8 +107,8 @@ func PathToName(path string) string { if path[0] == '/' || path[0] == '.' { path = path[1:] } - path = strings.Replace(path, "/", "_", -1) - path = strings.Replace(path, ".", "_", -1) + path = strings.ReplaceAll(path, "/", "_") + path = strings.ReplaceAll(path, ".", "_") return path } @@ -130,9 +129,9 @@ func GetValuesFromLabel(service types.ServiceConfig, LabelValues string) map[str log.Fatal(err) } for _, value := range labelContent { - switch value.(type) { + switch val := value.(type) { case string: - descriptions[value.(string)] = nil + descriptions[val] = nil case map[string]interface{}: for k, v := range value.(map[string]interface{}) { descriptions[k] = &EnvConfig{Service: service, Description: v.(string)} From 984b50356a8f0af62571e84f557d506bfa59a2ef Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 5 Apr 2024 07:58:37 +0200 Subject: [PATCH 23/97] Do not pass by composed struct It useless to use the composed struct field as the current "object" is composed by this one. Get Name field directly. (Thanks staticcheck) --- generator/deployment.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/generator/deployment.go b/generator/deployment.go index 54b85db..caeb78e 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -236,7 +236,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { VolumeSource: corev1.VolumeSource{ ConfigMap: &corev1.ConfigMapVolumeSource{ LocalObjectReference: corev1.LocalObjectReference{ - Name: cm.ObjectMeta.Name, + Name: cm.Name, }, }, }, @@ -268,7 +268,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { VolumeSource: corev1.VolumeSource{ ConfigMap: &corev1.ConfigMapVolumeSource{ LocalObjectReference: corev1.LocalObjectReference{ - Name: cm.ObjectMeta.Name, + Name: cm.Name, }, }, }, From 45de7ab5432c1af89d29f396e42ef78d1c1c5ff6 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 8 Apr 2024 23:15:05 +0200 Subject: [PATCH 24/97] Fix samepod generation The container was not merged to the target deployment. It necessary to make one more loop to apply the container + remove the source deployment. --- generator/configMap.go | 7 +++---- generator/deployment.go | 7 +------ generator/generator.go | 25 +++++++++++++++++++------ 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/generator/configMap.go b/generator/configMap.go index 4ab4625..ac02590 100644 --- a/generator/configMap.go +++ b/generator/configMap.go @@ -1,13 +1,14 @@ package generator import ( - "katenary/utils" "log" "os" "path/filepath" "regexp" "strings" + "katenary/utils" + "github.com/compose-spec/compose-go/types" goyaml "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" @@ -53,7 +54,6 @@ type ConfigMap struct { // NewConfigMap creates a new ConfigMap from a compose service. The appName is the name of the application taken from the project name. // The ConfigMap is filled by environment variables and labels "map-env". func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { - done := map[string]bool{} drop := map[string]bool{} secrets := []string{} @@ -98,7 +98,7 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { done[value] = true continue } - //val := `{{ tpl .Values.` + service.Name + `.environment.` + value + ` $ }}` + // val := `{{ tpl .Values.` + service.Name + `.environment.` + value + ` $ }}` val := utils.TplValue(service.Name, "environment."+value) service.Environment[value] = &val } @@ -177,7 +177,6 @@ func (c *ConfigMap) AppendDir(path string) { stat, err := os.Stat(path) if err != nil { log.Fatalf("Path %s does not exist\n", path) - } // recursively read all files in the path and add them to the configmap if stat.IsDir() { diff --git a/generator/deployment.go b/generator/deployment.go index caeb78e..07760f8 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -124,6 +124,7 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) { name := utils.GetServiceNameByPort(int(port.Target)) if name == "" { utils.Warn("Port name not found for port ", port.Target, " in service ", service.Name, ". Using port number instead") + name = fmt.Sprintf("port-%d", port.Target) } ports = append(ports, corev1.ContainerPort{ ContainerPort: int32(port.Target), @@ -288,14 +289,11 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { } func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) { - log.Printf("In %s deployment, add volumes for service %s from binded deployment %s", d.Name, service.Name, binded.Name) // find the volume in the binded deployment for _, bindedVolume := range binded.Spec.Template.Spec.Volumes { - log.Println("bindedVolume.Name found", bindedVolume.Name) skip := false for _, targetVol := range d.Spec.Template.Spec.Volumes { if targetVol.Name == bindedVolume.Name { - log.Println("Volume", bindedVolume.Name, "already exists in deployment", d.Name) skip = true break } @@ -303,16 +301,13 @@ func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) { if !skip { // add the volume to the current deployment d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, bindedVolume) - log.Println("d.Spec.Template.Spec.Volumes", d.Spec.Template.Spec.Volumes) // get the container - } // add volume mount to the container targetContainer, ti := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) sourceContainer, _ := utils.GetContainerByName(service.Name, binded.Spec.Template.Spec.Containers) for _, bindedMount := range sourceContainer.VolumeMounts { if bindedMount.Name == bindedVolume.Name { - log.Println("bindedMount.Name found", bindedMount.Name) targetContainer.VolumeMounts = append(targetContainer.VolumeMounts, bindedMount) } } diff --git a/generator/generator.go b/generator/generator.go index 811aeed..1e42dcd 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -38,7 +38,7 @@ func Generate(project *types.Project) (*HelmChart, error) { appName = project.Name deployments = make(map[string]*Deployment, len(project.Services)) services = make(map[string]*Service) - podToMerge = make(map[string]*Deployment) + podToMerge = make(map[string]*types.ServiceConfig) ) chart := NewChart(appName) @@ -54,7 +54,6 @@ func Generate(project *types.Project) (*HelmChart, error) { mainCount := 0 for _, service := range project.Services { if serviceIsMain(service) { - log.Printf("Found main app %s", service.Name) mainCount++ if mainCount > 1 { return nil, fmt.Errorf("found more than one main app") @@ -96,7 +95,7 @@ func Generate(project *types.Project) (*HelmChart, error) { // 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[LABEL_SAME_POD]; ok && samePod != "" { - podToMerge[samePod] = d + podToMerge[samePod] = &service } // create the needed service for the container port @@ -124,12 +123,12 @@ func Generate(project *types.Project) (*HelmChart, error) { } // drop all "same-pod" deployments because the containers and volumes are already // in the target deployment - for _, service := range project.Services { + for _, service := range podToMerge { if samepod, ok := service.Labels[LABEL_SAME_POD]; ok && samepod != "" { // move this deployment volumes to the target deployment if target, ok := deployments[samepod]; ok { - target.AddContainer(service) - target.BindFrom(service, deployments[service.Name]) + target.AddContainer(*service) + target.BindFrom(*service, deployments[service.Name]) delete(deployments, service.Name) } else { log.Printf("service %[1]s is declared as %[2]s, but %[2]s is not defined", service.Name, LABEL_SAME_POD) @@ -168,6 +167,13 @@ func Generate(project *types.Project) (*HelmChart, error) { // generate all services for _, s := range services { + // 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...) + } y, _ := s.Yaml() chart.Templates[s.Filename()] = &ChartTemplate{ Content: y, @@ -175,6 +181,13 @@ func Generate(project *types.Project) (*HelmChart, error) { } } + // drop all "same-pod" services + for _, s := range podToMerge { + // get the target service + target := services[s.Name] + delete(chart.Templates, target.Filename()) + } + // compute all needed resplacements in YAML templates for n, v := range chart.Templates { v.Content = removeReplaceString(v.Content) From cc3c42b8fde856f14b14ade965773225b617b737 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 10 Apr 2024 04:47:52 +0200 Subject: [PATCH 25/97] Add venv to ignore list --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3cdc952..aaa7e66 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ chart/* *.yaml *.yml !generator/*.yaml +doc/venv/* !doc/mkdocs.yaml !.readthedocs.yaml ./katenary From 50701017061680446ab4d28e907dcab434f65b9f Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 10 Apr 2024 04:48:51 +0200 Subject: [PATCH 26/97] Add workflow image and zoom on click --- doc/docs/statics/addons.js | 30 +++++++++++++++++++++++++++++- doc/docs/statics/main.css | 33 +++++++++++++++++++++++++++++++++ doc/docs/statics/workflow.png | Bin 0 -> 127245 bytes 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 doc/docs/statics/workflow.png diff --git a/doc/docs/statics/addons.js b/doc/docs/statics/addons.js index a4e87bf..d4bebc4 100644 --- a/doc/docs/statics/addons.js +++ b/doc/docs/statics/addons.js @@ -1,5 +1,7 @@ +// Install the highlight.js in the documentation. Then +// highlight all the source code. function hljsInstall() { - const version = "11.5.1"; + const version = "11.9.0"; const theme = "github-dark"; const script = document.createElement("script"); @@ -15,6 +17,32 @@ function hljsInstall() { document.head.appendChild(script); } +// All images in an .zoomable div is zoomable, that +// meanse that we can click to zoom and unzoom. +// This needs specific CSS (see main.css). +function makeImagesZoomable() { + const zone = document.querySelectorAll(".zoomable"); + + zone.forEach((z, i) => { + const im = z.querySelectorAll("img"); + if (im.length == 0) { + return; + } + + const input = document.createElement("input"); + input.setAttribute("type", "checkbox"); + input.setAttribute("id", `image-zoom-${i}`); + z.appendChild(input); + + const label = document.createElement("label"); + label.setAttribute("for", `image-zoom-${i}`); + z.appendChild(label); + + label.appendChild(im[0]); + }); +} + document.addEventListener("DOMContentLoaded", () => { hljsInstall(); + makeImagesZoomable(); }); diff --git a/doc/docs/statics/main.css b/doc/docs/statics/main.css index 94fca4d..1be9c28 100644 --- a/doc/docs/statics/main.css +++ b/doc/docs/statics/main.css @@ -65,3 +65,36 @@ table tbody code { h3[id*="katenaryio"] { color: var(--md-code-hl-special-color); } + +#logo { + background-image: url("logo-dark.svg"); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + height: 8em; + width: 100%; + margin: 0 auto 2rem auto; +} + +/*Zoomable images*/ + +[data-md-color-scheme="slate"] #logo { + background-image: url("logo-bright.svg"); +} + +.zoomable input[type="checkbox"] { + display: none; +} + +@media all and (min-width: 1399px) { + .zoomable label img { + cursor: zoom-in; + transition: all 0.2s ease-in-out; + } + .zoomable input[type="checkbox"]:checked ~ label img { + transform: scale(2); + cursor: zoom-out; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.3); + z-index: 1; + } +} diff --git a/doc/docs/statics/workflow.png b/doc/docs/statics/workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..f0c486d2ca44c80c87f968e7e758dc8bef615612 GIT binary patch literal 127245 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 z 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 Date: Wed, 10 Apr 2024 04:49:36 +0200 Subject: [PATCH 27/97] Add documentation --- cmd/katenary/main.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/katenary/main.go b/cmd/katenary/main.go index 247ae81..413b1ad 100644 --- a/cmd/katenary/main.go +++ b/cmd/katenary/main.go @@ -1,3 +1,7 @@ +// Katenary CLI, main package. +// +// This package is not intended to be imported. It contains the +// main function that build the command line with `cobra` package. package main import ( From c7c18f01cd9f8cd43aeac761a3ee580322d65c36 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 10 Apr 2024 04:51:45 +0200 Subject: [PATCH 28/97] Fixup documentation - better gomarkdown generation that now fixed the escaped strings, no need to use pandoc anymore - added workflow image - upgraded versions of mkdocs requirements --- Makefile | 25 +- doc/docs/index.md | 22 +- doc/docs/packages/cmd/katenary.md | 6 +- doc/docs/packages/generator.md | 636 ++++++++++++---------- doc/docs/packages/generator/extrafiles.md | 16 +- doc/docs/packages/parser.md | 13 +- doc/docs/packages/update.md | 41 +- doc/docs/packages/utils.md | 165 +++--- doc/docs/usage.md | 44 +- doc/mkdocs.yml | 1 + doc/requirements.txt | 12 +- 11 files changed, 538 insertions(+), 443 deletions(-) diff --git a/Makefile b/Makefile index 2ecd77b..5178f56 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ VERSION=$(shell git describe --exact-match --tags $(CUR_SHA) 2>/dev/null || echo CTN:=$(shell which podman 2>&1 1>/dev/null && echo "podman" || echo "docker") PREFIX=~/.local -GOVERSION=1.21 +GOVERSION=1.22 GO=container OUT=katenary BLD_CMD=go build -ldflags="-X 'katenary/generator.Version=$(VERSION)'" -o $(OUT) ./cmd/katenary @@ -154,6 +154,15 @@ clean: rm -rf katenary dist/* release.id +serve-doc: __label_doc + @cd doc && \ + [ -d venv ] || python -m venv venv; \ + source venv/bin/activate && \ + echo "==> Installing requirements in the virtual env..." + pip install -qq -r requirements.txt && \ + echo "==> Serving doc with mkdocs..." && \ + mkdocs serve + tests: test test: @echo -e "\033[1;33mTesting katenary $(VERSION)...\033[0m" @@ -183,6 +192,8 @@ push-release: build-all __label_doc: + @command -v gomarkdoc || (echo "==> We need to install gomarkdoc..." && \ + go install github.com/princjef/gomarkdoc/cmd/gomarkdoc@latest) @echo "=> Generating labels doc..." # short label doc go run ./cmd/katenary help-labels -m | \ @@ -201,16 +212,6 @@ __label_doc: PACKAGES=$$(for f in $$(find . -name "*.go" -type f); do dirname $$f; done | sort -u) for pack in $$PACKAGES; do echo "-> Generating doc for $$pack" - #gomarkdoc -o doc/docs/packages/$$pack.md $$pack - gomarkdoc -f azure-devops $$pack | pandoc -t gfm -o doc/docs/packages/$$pack.md - # drop the Index section without removing the title - # - remove the Index section, but keep the following heading + gomarkdoc --repository.default-branch $(shell git branch --show-current) -o doc/docs/packages/$$pack.md $$pack sed -i '/^## Index/,/^##/ { /## Index/d; /^##/! d }' doc/docs/packages/$$pack.md - # fixes for markdown problem - # - there are \* on heading, replace to * - sed -i 's/\\\*/\*/g' doc/docs/packages/$$pack.md - ## parenthis in heading are escaped, replace to unescaped - sed -i 's/\\(/\(/g' doc/docs/packages/$$pack.md - sed -i 's/\\)/\)/g' doc/docs/packages/$$pack.md - ## list are badly formatted with 2 spaces, replace to 4 done diff --git a/doc/docs/index.md b/doc/docs/index.md index 3121831..70acac9 100644 --- a/doc/docs/index.md +++ b/doc/docs/index.md @@ -1,20 +1,3 @@ - - # Welcome to Katenary documentation @@ -29,6 +12,11 @@ and Helm Chart creation. 💡 Effortless Efficiency: You only need to add labels when it's necessary to precise things. Then call `katenary convert` and let the magic happen. +
+ +
+ + # What ? Katenary is a tool made to help you to transform "compose" files (`docker-compose.yml`, `podman-compose.yml`...) to diff --git a/doc/docs/packages/cmd/katenary.md b/doc/docs/packages/cmd/katenary.md index 03a3bb2..9261889 100644 --- a/doc/docs/packages/cmd/katenary.md +++ b/doc/docs/packages/cmd/katenary.md @@ -2,7 +2,11 @@ # katenary -``` go +```go import "katenary/cmd/katenary" ``` +Katenary CLI, main package. + +This package is not intended to be imported. It contains the main function that build the command line with \`cobra\` package. + diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index 864a478..82f9db8 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -2,40 +2,33 @@ # generator -``` go +```go import "katenary/generator" ``` -The generator package generates kubernetes objects from a compose file -and transforms them into a helm chart. +The generator package generates kubernetes objects from a compose file and transforms them into a helm chart. -The generator package is the core of katenary. It is responsible for -generating kubernetes objects from a compose file and transforming them -into a helm chart. Convertion manipulates Yaml representation of -kubernetes object to add conditions, labels, annotations, etc. to the -objects. It also create the values to be set to the values.yaml file. +The generator package is the core of katenary. It is responsible for generating kubernetes objects from a compose file and transforming them into a helm chart. Convertion manipulates Yaml representation of kubernetes object to add conditions, labels, annotations, etc. to the objects. It also create the values to be set to the values.yaml file. -The generate.Convert() create an HelmChart object and call “Generate()” -method to convert from a compose file to a helm chart. It saves the helm -chart in the given directory. +The generate.Convert\(\) create an HelmChart object and call "Generate\(\)" method to convert from a compose file to a helm chart. It saves the helm chart in the given directory. -If you want to change or override the write behavior, you can use the -HelmChart.Generate() function and implement your own write function. -This function returns the helm chart object containing all kubernetes -objects and helm chart ingormation. It does not write the helm chart to -the disk. +If you want to change or override the write behavior, you can use the HelmChart.Generate\(\) function and implement your own write function. This function returns the helm chart object containing all kubernetes objects and helm chart ingormation. It does not write the helm chart to the disk. -TODO: Manage cronjob + rbac TODO: create note.txt TODO: manage emptyDirs +TODO: Manage cronjob \+ rbac TODO: create note.txt TODO: manage emptyDirs ## Constants -``` go +
+ +```go const KATENARY_PREFIX = "katenary.v3/" ``` ## Variables -``` go + + +```go var ( // Standard annotationss @@ -45,172 +38,179 @@ var ( ) ``` -Version is the version of katenary. It is set at compile time. +Version is the version of katenary. It is set at compile time. -``` go +```go var Version = "master" // changed at compile time ``` -## func Convert + +## func [Convert]() -``` go +```go func Convert(config ConvertOptions, dockerComposeFile ...string) ``` -Convert a compose (docker, podman…) project to a helm chart. It calls -Generate() to generate the chart and then write it to the disk. +Convert a compose \(docker, podman...\) project to a helm chart. It calls Generate\(\) to generate the chart and then write it to the disk. -## func GetLabelHelp + +## func [GetLabelHelp]() -``` go +```go func GetLabelHelp(asMarkdown bool) string ``` Generate the help for the labels. -## func GetLabelHelpFor + +## func [GetLabelHelpFor]() -``` go +```go func GetLabelHelpFor(labelname string, asMarkdown bool) string ``` GetLabelHelpFor returns the help for a specific label. -## func GetLabelNames + +## func [GetLabelNames]() -``` go +```go func GetLabelNames() []string ``` GetLabelNames returns a sorted list of all katenary label names. -## func GetLabels + +## func [GetLabels]() -``` go +```go func GetLabels(serviceName, appName string) map[string]string ``` -## func GetMatchLabels +GetLabels returns the labels for a service. It uses the appName to replace the \_\_replace\_\_ in the labels. This is used to generate the labels in the templates. -``` go + +## func [GetMatchLabels]() + +```go func GetMatchLabels(serviceName, appName string) map[string]string ``` -## func Helper +GetMatchLabels returns the matchLabels for a service. It uses the appName to replace the \_\_replace\_\_ in the labels. This is used to generate the matchLabels in the templates. -``` go + +## func [Helper]() + +```go func Helper(name string) string ``` Helper returns the \_helpers.tpl file for a chart. -## func NewCronJob + +## func [NewCronJob]() -``` go +```go func NewCronJob(service types.ServiceConfig, chart *HelmChart, appName string) (*CronJob, *RBAC) ``` -NewCronJob creates a new CronJob from a compose service. The appName is -the name of the application taken from the project name. +NewCronJob creates a new CronJob from a compose service. The appName is the name of the application taken from the project name. -## type ChartTemplate + +## type [ChartTemplate]() -ChartTemplate is a template of a chart. It contains the content of the -template and the name of the service. This is used internally to -generate the templates. +ChartTemplate is a template of a chart. It contains the content of the template and the name of the service. This is used internally to generate the templates. TODO: maybe we can set it private. -``` go +```go type ChartTemplate struct { Content []byte Servicename string } ``` -## type ConfigMap + +## type [ConfigMap]() ConfigMap is a kubernetes ConfigMap. Implements the DataMap interface. -``` go +```go type ConfigMap struct { *corev1.ConfigMap // contains filtered or unexported fields } ``` -### func NewConfigMap + +### func [NewConfigMap]() -``` go +```go func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap ``` -NewConfigMap creates a new ConfigMap from a compose service. The appName -is the name of the application taken from the project name. The -ConfigMap is filled by environment variables and labels “map-env”. +NewConfigMap creates a new ConfigMap from a compose service. The appName is the name of the application taken from the project name. The ConfigMap is filled by environment variables and labels "map\-env". -### func NewConfigMapFromFiles + +### func [NewConfigMapFromFiles]() -``` go +```go func NewConfigMapFromFiles(service types.ServiceConfig, appName string, path string) *ConfigMap ``` -NewConfigMapFromFiles creates a new ConfigMap from a compose service. -This path is the path to the file or directory. If the path is a -directory, all files in the directory are added to the ConfigMap. Each -subdirectory are ignored. Note that the Generate() function will create -the subdirectories ConfigMaps. +NewConfigMapFromFiles creates a new ConfigMap from a compose service. This path is the path to the file or directory. If the path is a directory, all files in the directory are added to the ConfigMap. Each subdirectory are ignored. Note that the Generate\(\) function will create the subdirectories ConfigMaps. -### func (*ConfigMap) AddData + +### func \(\*ConfigMap\) [AddData]() -``` go +```go func (c *ConfigMap) AddData(key string, value string) ``` -AddData adds a key value pair to the configmap. Append or overwrite the -value if the key already exists. +AddData adds a key value pair to the configmap. Append or overwrite the value if the key already exists. -### func (*ConfigMap) AppendDir + +### func \(\*ConfigMap\) [AppendDir]() -``` go +```go func (c *ConfigMap) AppendDir(path string) ``` -AddFile adds files from given path to the configmap. It is not -recursive, to add all files in a directory, you need to call this -function for each subdirectory. +AddFile adds files from given path to the configmap. It is not recursive, to add all files in a directory, you need to call this function for each subdirectory. -### func (*ConfigMap) Filename + +### func \(\*ConfigMap\) [Filename]() -``` go +```go func (c *ConfigMap) Filename() string ``` -Filename returns the filename of the configmap. If the configmap is used -for files, the filename contains the path. +Filename returns the filename of the configmap. If the configmap is used for files, the filename contains the path. -### func (*ConfigMap) SetData + +### func \(\*ConfigMap\) [SetData]() -``` go +```go func (c *ConfigMap) SetData(data map[string]string) ``` SetData sets the data of the configmap. It replaces the entire data. -### func (*ConfigMap) Yaml + +### func \(\*ConfigMap\) [Yaml]() -``` go +```go func (c *ConfigMap) Yaml() ([]byte, error) ``` Yaml returns the yaml representation of the configmap -## type ConvertOptions + +## type [ConvertOptions]() -ConvertOptions are the options to convert a compose project to a helm -chart. +ConvertOptions are the options to convert a compose project to a helm chart. -``` go +```go type ConvertOptions struct { Force bool // Force the chart directory deletion if it already exists. OutputDir string // The output directory of the chart. @@ -221,20 +221,22 @@ type ConvertOptions struct { } ``` -## type CronJob + +## type [CronJob]() CronJob is a kubernetes CronJob. -``` go +```go type CronJob struct { *batchv1.CronJob // contains filtered or unexported fields } ``` -### func (*CronJob) Filename + +### func \(\*CronJob\) [Filename]() -``` go +```go func (c *CronJob) Filename() string ``` @@ -242,9 +244,10 @@ Filename returns the filename of the cronjob. Implements the Yaml interface. -### func (*CronJob) Yaml + +### func \(\*CronJob\) [Yaml]() -``` go +```go func (c *CronJob) Yaml() ([]byte, error) ``` @@ -252,12 +255,12 @@ Yaml returns the yaml representation of the cronjob. Implements the Yaml interface. -## type CronJobValue + +## type [CronJobValue]() -CronJobValue is a cronjob configuration that will be saved in -values.yaml. +CronJobValue is a cronjob configuration that will be saved in values.yaml. -``` go +```go type CronJobValue struct { Repository *RepositoryValue `yaml:"repository,omitempty"` Environment map[string]any `yaml:"environment,omitempty"` @@ -266,32 +269,33 @@ type CronJobValue struct { } ``` -## type DataMap + +## type [DataMap]() -DataMap is a kubernetes ConfigMap or Secret. It can be used to add data -to the ConfigMap or Secret. +DataMap is a kubernetes ConfigMap or Secret. It can be used to add data to the ConfigMap or Secret. -``` go +```go type DataMap interface { SetData(map[string]string) AddData(string, string) } ``` -### func NewFileMap + +### func [NewFileMap]() -``` go +```go func NewFileMap(service types.ServiceConfig, appName string, kind string) DataMap ``` -NewFileMap creates a new DataMap from a compose service. The appName is -the name of the application taken from the project name. +NewFileMap creates a new DataMap from a compose service. The appName is the name of the application taken from the project name. -## type Dependency + +## type [Dependency]() Dependency is a dependency of a chart to other charts. -``` go +```go type Dependency struct { Name string `yaml:"name"` Version string `yaml:"version"` @@ -301,122 +305,132 @@ type Dependency struct { } ``` -## type Deployment + +## type [Deployment]() Deployment is a kubernetes Deployment. -``` go +```go type Deployment struct { *appsv1.Deployment `yaml:",inline"` // contains filtered or unexported fields } ``` -### func NewDeployment + +### func [NewDeployment]() -``` go +```go func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment ``` -NewDeployment creates a new Deployment from a compose service. The -appName is the name of the application taken from the project name. It -also creates the Values map that will be used to create the values.yaml -file. +NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name. It also creates the Values map that will be used to create the values.yaml file. -### func (*Deployment) AddContainer + +### func \(\*Deployment\) [AddContainer]() -``` go +```go func (d *Deployment) AddContainer(service types.ServiceConfig) ``` AddContainer adds a container to the deployment. -### func (*Deployment) AddHealthCheck + +### func \(\*Deployment\) [AddHealthCheck]() -``` go +```go func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) ``` -### func (*Deployment) AddIngress -``` go + + +### func \(\*Deployment\) [AddIngress]() + +```go func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *Ingress ``` -AddIngress adds an ingress to the deployment. It creates the ingress -object. +AddIngress adds an ingress to the deployment. It creates the ingress object. -### func (*Deployment) AddVolumes + +### func \(\*Deployment\) [AddVolumes]() -``` go +```go func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) ``` -AddVolumes adds a volume to the deployment. It does not create the PVC, -it only adds the volumes to the deployment. If the volume is a bind -volume it will warn the user that it is not supported yet. +AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. If the volume is a bind volume it will warn the user that it is not supported yet. -### func (*Deployment) BindFrom + +### func \(\*Deployment\) [BindFrom]() -``` go +```go func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) ``` -### func (*Deployment) DependsOn -``` go + + +### func \(\*Deployment\) [DependsOn]() + +```go func (d *Deployment) DependsOn(to *Deployment, servicename string) error ``` -DependsOn adds a initContainer to the deployment that will wait for the -service to be up. +DependsOn adds a initContainer to the deployment that will wait for the service to be up. -### func (*Deployment) Filename + +### func \(\*Deployment\) [Filename]() -``` go +```go func (d *Deployment) Filename() string ``` -### func (*Deployment) SetEnvFrom +Filename returns the filename of the deployment. -``` go + +### func \(\*Deployment\) [SetEnvFrom]() + +```go func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) ``` -SetEnvFrom sets the environment variables to a configmap. The configmap -is created. +SetEnvFrom sets the environment variables to a configmap. The configmap is created. -### func (*Deployment) Yaml + +### func \(\*Deployment\) [Yaml]() -``` go +```go func (d *Deployment) Yaml() ([]byte, error) ``` Yaml returns the yaml representation of the deployment. -## type FileMapUsage + +## type [FileMapUsage]() FileMapUsage is the usage of the filemap. -``` go +```go type FileMapUsage uint8 ``` -FileMapUsage constants. +FileMapUsage constants. -``` go +```go const ( FileMapUsageConfigMap FileMapUsage = iota // pure configmap for key:values. FileMapUsageFiles // files in a configmap. ) ``` -## type HelmChart + +## type [HelmChart]() -HelmChart is a Helm Chart representation. It contains all the tempaltes, -values, versions, helpers… +HelmChart is a Helm Chart representation. It contains all the tempaltes, values, versions, helpers... -``` go +```go type HelmChart struct { Name string `yaml:"name"` ApiVersion string `yaml:"apiVersion"` @@ -432,48 +446,41 @@ type HelmChart struct { } ``` -### func Generate + +### func [Generate]() -``` go +```go func Generate(project *types.Project) (*HelmChart, error) ``` -Generate a chart from a compose project. This does not write files to -disk, it only creates the HelmChart object. +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: -1. Detect the service port name or leave the port number if not found. +- Detect the service port name or leave the port number if not found. +- Create a deployment for each service that are not ingnore. +- 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. -2. Create a deployment for each service that are not ingnore. + +### func [NewChart]() -3. Create a service and ingresses for each service that has ports - and/or declared ingresses. - -4. Create a PVC or Configmap volumes for each volume. - -5. Create init containers for each service which has dependencies to - other services. - -6. Create a chart dependencies. - -7. Create a configmap and secrets from the environment variables. - -8. Merge the same-pod services. - -### func NewChart - -``` go +```go func NewChart(name string) *HelmChart ``` NewChart creates a new empty chart with the given name. -## type Help + +## type [Help]() Help is the documentation of a label. -``` go +```go type Help struct { Short string `yaml:"short"` Long string `yaml:"long"` @@ -482,41 +489,51 @@ type Help struct { } ``` -## type Ingress + +## type [Ingress]() -``` go + + +```go type Ingress struct { *networkv1.Ingress // contains filtered or unexported fields } ``` -### func NewIngress + +### func [NewIngress]() -``` go +```go func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress ``` NewIngress creates a new Ingress from a compose service. -### func (*Ingress) Filename + +### func \(\*Ingress\) [Filename]() -``` go +```go func (ingress *Ingress) Filename() string ``` -### func (*Ingress) Yaml -``` go + + +### func \(\*Ingress\) [Yaml]() + +```go func (ingress *Ingress) Yaml() ([]byte, error) ``` -## type IngressValue -IngressValue is a ingress configuration that will be saved in -values.yaml. -``` go + +## type [IngressValue]() + +IngressValue is a ingress configuration that will be saved in values.yaml. + +```go type IngressValue struct { Enabled bool `yaml:"enabled"` Host string `yaml:"host"` @@ -526,17 +543,18 @@ type IngressValue struct { } ``` -## type Label + +## type [Label]() Label is a katenary label to find in compose files. -``` go +```go type Label = string ``` -Known labels. +Known labels. -``` go +```go const ( LABEL_MAIN_APP Label = KATENARY_PREFIX + "main-app" LABEL_VALUES Label = KATENARY_PREFIX + "values" @@ -555,28 +573,30 @@ const ( ) ``` -## type LabelType + +## type [LabelType]() -LabelType identifies the type of label to generate in objects. TODO: is -this still needed? +LabelType identifies the type of label to generate in objects. TODO: is this still needed? -``` go +```go type LabelType uint8 ``` -``` go + + +```go const ( DeploymentLabel LabelType = iota ServiceLabel ) ``` -## type PersistenceValue + +## type [PersistenceValue]() -PersistenceValue is a persistence configuration that will be saved in -values.yaml. +PersistenceValue is a persistence configuration that will be saved in values.yaml. -``` go +```go type PersistenceValue struct { Enabled bool `yaml:"enabled"` StorageClass string `yaml:"storageClass"` @@ -585,12 +605,12 @@ type PersistenceValue struct { } ``` -## type RBAC + +## type [RBAC]() -RBAC is a kubernetes RBAC containing a role, a rolebinding and an -associated serviceaccount. +RBAC is a kubernetes RBAC containing a role, a rolebinding and an associated serviceaccount. -``` go +```go type RBAC struct { RoleBinding *RoleBinding Role *Role @@ -598,213 +618,247 @@ type RBAC struct { } ``` -### func NewRBAC + +### func [NewRBAC]() -``` go +```go func NewRBAC(service types.ServiceConfig, appName string) *RBAC ``` -NewRBAC creates a new RBAC from a compose service. The appName is the -name of the application taken from the project name. +NewRBAC creates a new RBAC from a compose service. The appName is the name of the application taken from the project name. -## type RepositoryValue + +## type [RepositoryValue]() -RepositoryValue is a docker repository image and tag that will be saved -in values.yaml. +RepositoryValue is a docker repository image and tag that will be saved in values.yaml. -``` go +```go type RepositoryValue struct { Image string `yaml:"image"` Tag string `yaml:"tag"` } ``` -## type Role + +## type [Role]() Role is a kubernetes Role. -``` go +```go type Role struct { *rbacv1.Role // contains filtered or unexported fields } ``` -### func (*Role) Filename + +### func \(\*Role\) [Filename]() -``` go +```go func (r *Role) Filename() string ``` -### func (*Role) Yaml -``` go + + +### func \(\*Role\) [Yaml]() + +```go func (r *Role) Yaml() ([]byte, error) ``` -## type RoleBinding + + + +## type [RoleBinding]() RoleBinding is a kubernetes RoleBinding. -``` go +```go type RoleBinding struct { *rbacv1.RoleBinding // contains filtered or unexported fields } ``` -### func (*RoleBinding) Filename + +### func \(\*RoleBinding\) [Filename]() -``` go +```go func (r *RoleBinding) Filename() string ``` -### func (*RoleBinding) Yaml -``` go + + +### func \(\*RoleBinding\) [Yaml]() + +```go func (r *RoleBinding) Yaml() ([]byte, error) ``` -## type Secret + + + +## type [Secret]() Secret is a kubernetes Secret. Implements the DataMap interface. -``` go +```go type Secret struct { *corev1.Secret // contains filtered or unexported fields } ``` -### func NewSecret + +### func [NewSecret]() -``` go +```go func NewSecret(service types.ServiceConfig, appName string) *Secret ``` NewSecret creates a new Secret from a compose service -### func (*Secret) AddData + +### func \(\*Secret\) [AddData]() -``` go +```go func (s *Secret) AddData(key string, value string) ``` AddData adds a key value pair to the secret. -### func (*Secret) Filename + +### func \(\*Secret\) [Filename]() -``` go +```go func (s *Secret) Filename() string ``` Filename returns the filename of the secret. -### func (*Secret) SetData + +### func \(\*Secret\) [SetData]() -``` go +```go func (s *Secret) SetData(data map[string]string) ``` SetData sets the data of the secret. -### func (*Secret) Yaml + +### func \(\*Secret\) [Yaml]() -``` go +```go func (s *Secret) Yaml() ([]byte, error) ``` Yaml returns the yaml representation of the secret. -## type Service + +## type [Service]() Service is a kubernetes Service. -``` go +```go type Service struct { *v1.Service `yaml:",inline"` // contains filtered or unexported fields } ``` -### func NewService + +### func [NewService]() -``` go +```go func NewService(service types.ServiceConfig, appName string) *Service ``` NewService creates a new Service from a compose service. -### func (*Service) AddPort + +### func \(\*Service\) [AddPort]() -``` go +```go func (s *Service) AddPort(port types.ServicePortConfig, serviceName ...string) ``` AddPort adds a port to the service. -### func (*Service) Filename + +### func \(\*Service\) [Filename]() -``` go +```go func (s *Service) Filename() string ``` Filename returns the filename of the service. -### func (*Service) Yaml + +### func \(\*Service\) [Yaml]() -``` go +```go func (s *Service) Yaml() ([]byte, error) ``` Yaml returns the yaml representation of the service. -## type ServiceAccount + +## type [ServiceAccount]() ServiceAccount is a kubernetes ServiceAccount. -``` go +```go type ServiceAccount struct { *corev1.ServiceAccount // contains filtered or unexported fields } ``` -### func (*ServiceAccount) Filename + +### func \(\*ServiceAccount\) [Filename]() -``` go +```go func (r *ServiceAccount) Filename() string ``` -### func (*ServiceAccount) Yaml -``` go + + +### func \(\*ServiceAccount\) [Yaml]() + +```go func (r *ServiceAccount) Yaml() ([]byte, error) ``` -## type Value -Value will be saved in values.yaml. It contains configuraiton for all -deployment and services. The content will be lile: - name_of_component: - repository: - image: image_name - tag: image_tag - persistence: - enabled: true - storageClass: storage_class_name - ingress: - enabled: true - host: host_name - path: path_name - environment: - ENV_VAR_1: value_1 - ENV_VAR_2: value_2 + +## type [Value]() -``` go +Value will be saved in values.yaml. It contains configuraiton for all deployment and services. The content will be lile: + +``` +name_of_component: + repository: + image: image_name + tag: image_tag + persistence: + enabled: true + storageClass: storage_class_name + ingress: + enabled: true + host: host_name + path: path_name + environment: + ENV_VAR_1: value_1 + ENV_VAR_2: value_2 +``` + +```go type Value struct { Repository *RepositoryValue `yaml:"repository,omitempty"` Persistence map[string]*PersistenceValue `yaml:"persistence,omitempty"` @@ -816,78 +870,84 @@ type Value struct { } ``` -### func NewValue + +### func [NewValue]() -``` go +```go func NewValue(service types.ServiceConfig, main ...bool) *Value ``` -NewValue creates a new Value from a compose service. The value contains -the necessary information to deploy the service (image, tag, replicas, -etc.). +NewValue creates a new Value from a compose service. The value contains the necessary information to deploy the service \(image, tag, replicas, etc.\). -If \`main\` is true, the tag will be empty because it will be set in the -helm chart appVersion. +If \`main\` is true, the tag will be empty because it will be set in the helm chart appVersion. -### func (*Value) AddIngress + +### func \(\*Value\) [AddIngress]() -``` go +```go func (v *Value) AddIngress(host, path string) ``` -### func (*Value) AddPersistence -``` go + + +### func \(\*Value\) [AddPersistence]() + +```go func (v *Value) AddPersistence(volumeName string) ``` AddPersistence adds persistence configuration to the Value. -## type VolumeClaim + +## type [VolumeClaim]() -VolumeClaim is a kubernetes VolumeClaim. This is a -PersistentVolumeClaim. +VolumeClaim is a kubernetes VolumeClaim. This is a PersistentVolumeClaim. -``` go +```go type VolumeClaim struct { *v1.PersistentVolumeClaim // contains filtered or unexported fields } ``` -### func NewVolumeClaim + +### func [NewVolumeClaim]() -``` go +```go func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *VolumeClaim ``` NewVolumeClaim creates a new VolumeClaim from a compose service. -### func (*VolumeClaim) Filename + +### func \(\*VolumeClaim\) [Filename]() -``` go +```go func (v *VolumeClaim) Filename() string ``` Filename returns the suggested filename for a VolumeClaim. -### func (*VolumeClaim) Yaml + +### func \(\*VolumeClaim\) [Yaml]() -``` go +```go func (v *VolumeClaim) Yaml() ([]byte, error) ``` Yaml marshals a VolumeClaim into yaml. -## type Yaml + +## type [Yaml]() Yaml is a kubernetes object that can be converted to yaml. -``` go +```go type Yaml interface { Yaml() ([]byte, error) Filename() string } ``` -Generated by [gomarkdoc](https://github.com/princjef/gomarkdoc) +Generated by [gomarkdoc]() diff --git a/doc/docs/packages/generator/extrafiles.md b/doc/docs/packages/generator/extrafiles.md index ca80dd0..a0a6b84 100644 --- a/doc/docs/packages/generator/extrafiles.md +++ b/doc/docs/packages/generator/extrafiles.md @@ -2,27 +2,27 @@ # extrafiles -``` go +```go import "katenary/generator/extrafiles" ``` -extrafiles package provides function to generate the Chart files that -are not objects. Like README.md and notes.txt… +extrafiles package provides function to generate the Chart files that are not objects. Like README.md and notes.txt... -## func NotesFile +## func [NotesFile]() -``` go +```go func NotesFile() string ``` NoteTXTFile returns the content of the note.txt file. -## func ReadMeFile + +## func [ReadMeFile]() -``` go +```go func ReadMeFile(charname, description string, values map[string]any) string ``` ReadMeFile returns the content of the README.md file. -Generated by [gomarkdoc](https://github.com/princjef/gomarkdoc) +Generated by [gomarkdoc]() diff --git a/doc/docs/packages/parser.md b/doc/docs/packages/parser.md index 3283326..1acfc81 100644 --- a/doc/docs/packages/parser.md +++ b/doc/docs/packages/parser.md @@ -2,19 +2,18 @@ # parser -``` go +```go import "katenary/parser" ``` -Parser package is a wrapper around compose-go to parse compose files. +Parser package is a wrapper around compose\-go to parse compose files. -## func Parse +## func [Parse]() -``` go +```go func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, error) ``` -Parse compose files and return a project. The project is parsed with -dotenv, osenv and profiles. +Parse compose files and return a project. The project is parsed with dotenv, osenv and profiles. -Generated by [gomarkdoc](https://github.com/princjef/gomarkdoc) +Generated by [gomarkdoc]() diff --git a/doc/docs/packages/update.md b/doc/docs/packages/update.md index c6fab01..ff63677 100644 --- a/doc/docs/packages/update.md +++ b/doc/docs/packages/update.md @@ -2,54 +2,59 @@ # update -``` go +```go import "katenary/update" ``` -Update package is used to check if a new version of katenary is -available. +Update package is used to check if a new version of katenary is available. ## Variables -``` go -var Version = "master" // reset by cmd/main.go + + +```go +var ( + Version = "master" // reset by cmd/main.go +) ``` -## func DownloadFile + +## func [DownloadFile]() -``` go +```go func DownloadFile(url, exe string) error ``` -DownloadFile will download a url to a local file. It also ensure that -the file is executable. +DownloadFile will download a url to a local file. It also ensure that the file is executable. -## func DownloadLatestVersion + +## func [DownloadLatestVersion]() -``` go +```go func DownloadLatestVersion(assets []Asset) error ``` DownloadLatestVersion will download the latest version of katenary. -## type Asset + +## type [Asset]() Asset is a github asset from release url. -``` go +```go type Asset struct { Name string `json:"name"` URL string `json:"browser_download_url"` } ``` -### func CheckLatestVersion + +### func [CheckLatestVersion]() -``` go +```go func CheckLatestVersion() (string, []Asset, error) ``` -CheckLatestVersion check katenary latest version from release and -propose to download it +CheckLatestVersion check katenary latest version from release and propose to download it -Generated by [gomarkdoc](https://github.com/princjef/gomarkdoc) +Generated by [gomarkdoc]() diff --git a/doc/docs/packages/utils.md b/doc/docs/packages/utils.md index b497f03..35f5e0c 100644 --- a/doc/docs/packages/utils.md +++ b/doc/docs/packages/utils.md @@ -2,186 +2,193 @@ # utils -``` go +```go import "katenary/utils" ``` -Utils package provides some utility functions used in katenary. It -defines some constants and functions used in the whole project. +Utils package provides some utility functions used in katenary. It defines some constants and functions used in the whole project. -## Constants +## func [CountStartingSpaces]() -Icons used in katenary. - -``` go -const ( - IconSuccess Icon = "✅" - IconFailure = "❌" - IconWarning = "⚠️'" - IconNote = "📝" - IconWorld = "🌐" - IconPlug = "🔌" - IconPackage = "📦" - IconCabinet = "🗄️" - IconInfo = "❕" - IconSecret = "🔒" - IconConfig = "🔧" - IconDependency = "🔗" -) -``` - -## func CountStartingSpaces - -``` go +```go func CountStartingSpaces(line string) int ``` -CountStartingSpaces counts the number of spaces at the beginning of a -string. +CountStartingSpaces counts the number of spaces at the beginning of a string. -## func GetContainerByName + +## func [GetContainerByName]() -``` go +```go func GetContainerByName(name string, containers []corev1.Container) (*corev1.Container, int) ``` -GetContainerByName returns a container by name and its index in the -array. It returns nil, -1 if not found. +GetContainerByName returns a container by name and its index in the array. It returns nil, \-1 if not found. -## func GetKind + +## func [GetKind]() -``` go +```go func GetKind(path string) (kind string) ``` GetKind returns the kind of the resource from the file path. -## func GetServiceNameByPort + +## func [GetServiceNameByPort]() -``` go +```go func GetServiceNameByPort(port int) string ``` -GetServiceNameByPort returns the service name for a port. It the service -name is not found, it returns an empty string. +GetServiceNameByPort returns the service name for a port. It the service name is not found, it returns an empty string. -## func GetValuesFromLabel + +## func [GetValuesFromLabel]() -``` go +```go func GetValuesFromLabel(service types.ServiceConfig, LabelValues string) map[string]*EnvConfig ``` GetValuesFromLabel returns a map of values from a label. -## func HashComposefiles + +## func [HashComposefiles]() -``` go +```go func HashComposefiles(files []string) (string, error) ``` HashComposefiles returns a hash of the compose files. -## func Int32Ptr + +## func [Int32Ptr]() -``` go +```go func Int32Ptr(i int32) *int32 ``` Int32Ptr returns a pointer to an int32. -## func MapKeys + +## func [MapKeys]() -``` go +```go func MapKeys(m map[string]interface{}) []string ``` -## func PathToName -``` go + + +## func [PathToName]() + +```go func PathToName(path string) string ``` PathToName converts a path to a kubernetes complient name. -## func StrPtr + +## func [StrPtr]() -``` go +```go func StrPtr(s string) *string ``` StrPtr returns a pointer to a string. -## func TplName + +## func [TplName]() -``` go +```go func TplName(serviceName, appname string, suffix ...string) string ``` -TplName returns the name of the kubernetes resource as a template -string. It is used in the templates and defined in \_helper.tpl file. +TplName returns the name of the kubernetes resource as a template string. It is used in the templates and defined in \_helper.tpl file. -## func TplValue + +## func [TplValue]() -``` go +```go func TplValue(serviceName, variable string, pipes ...string) string ``` -GetContainerByName returns a container by name and its index in the -array. +GetContainerByName returns a container by name and its index in the array. -## func Warn + +## func [Warn]() -``` go +```go func Warn(msg ...interface{}) ``` Warn prints a warning message -## func WordWrap + +## func [WordWrap]() -``` go +```go func WordWrap(text string, lineWidth int) string ``` -WordWrap wraps a string to a given line width. Warning: it may break the -string. You need to check the result. +WordWrap wraps a string to a given line width. Warning: it may break the string. You need to check the result. -## func Wrap + +## func [Wrap]() -``` go +```go func Wrap(src, above, below string) string ``` -Wrap wraps a string with a string above and below. It will respect the -indentation of the src string. +Wrap wraps a string with a string above and below. It will respect the indentation of the src string. -## func WrapBytes + +## func [WrapBytes]() -``` go +```go func WrapBytes(src, above, below []byte) []byte ``` -WrapBytes wraps a byte array with a byte array above and below. It will -respect the indentation of the src string. +WrapBytes wraps a byte array with a byte array above and below. It will respect the indentation of the src string. -## type EnvConfig + +## type [EnvConfig]() -EnvConfig is a struct to hold the description of an environment -variable. +EnvConfig is a struct to hold the description of an environment variable. -``` go +```go type EnvConfig struct { Description string Service types.ServiceConfig } ``` -## type Icon + +## type [Icon]() Icon is a unicode icon -``` go +```go type Icon string ``` -Generated by [gomarkdoc](https://github.com/princjef/gomarkdoc) +Icons used in katenary. + +```go +const ( + IconSuccess Icon = "✅" + IconFailure Icon = "❌" + IconWarning Icon = "⚠️'" + IconNote Icon = "📝" + IconWorld Icon = "🌐" + IconPlug Icon = "🔌" + IconPackage Icon = "📦" + IconCabinet Icon = "🗄️" + IconInfo Icon = "❕" + IconSecret Icon = "🔒" + IconConfig Icon = "🔧" + IconDependency Icon = "🔗" +) +``` + +Generated by [gomarkdoc]() diff --git a/doc/docs/usage.md b/doc/docs/usage.md index 6fe08ca..715d5f5 100644 --- a/doc/docs/usage.md +++ b/doc/docs/usage.md @@ -4,24 +4,56 @@ Basically, you can use `katenary` to transpose a docker-compose file (or any com `podman-compose` and `docker-compose`) to a configurable Helm Chart. This resulting helm chart can be installed with `helm` command to your Kubernetes cluster. +!!! Warning "YAML in multiline label" + + Compose only accept text label. So, to put a complete YAML content in the target label, you need to use a pipe char (`|` or `|-`) + and to **indent** your content. + + For example : + + ```yaml + labels: + # your labels + foo: bar + # katenary labels with multiline + katenary.v3/ingress: |- + hostname: my.website.tld + port: 80 + katenary.v3/ports: |- + - 1234 + ``` + + Katenary transforms compose services this way: - Takes the service and create a "Deployment" file - if a port is declared, katenary creates a service (ClusterIP) -- it a port is exposed, katenary creates a service (NodePort) -- environment variables will be stored in `values.yaml` file +- if a port is exposed, katenary creates a service (NodePort) +- environment variables will be stored inside a configMap - image, tags, and ingresses configuration are also stored in `values.yaml` file -- if named volumes are declared, katenary create PersistentVolumeClaims - not enabled in values file (a `emptyDir` is - used by default) -- any other volume (local mount points) are ignored +- if named volumes are declared, katenary create PersistentVolumeClaims - not enabled in values file - `depends_on` needs that the pointed service declared a port. If not, you can use labels to inform katenary +For any other specific configuration, like binding local files as configMap, bind variables, add values with documentation, etc. You'll need to use labels. + Katenary can also configure containers grouping in pods, declare dependencies, ignore some services, force variables as secrets, mount files as `configMap`, and many others things. To adapt the helm chart generation, you will need to use some specific labels. For more complete label usage, see [the labels page](labels.md). +!!! Info "Overriding file" + + It could be sometimes more convinient to separate the + configuration related to Katenary inside a secondary file. + + Instead of adding labels inside the `compose.yaml` file, + you can create a file named `compose.katenary.yaml` and + declare your labels inside. Katenary will detect it by + default. + + **No need to precise the file in the command line.** + ## Make convertion After having installed `katenary`, the standard usage is to call: @@ -153,8 +185,6 @@ services: image: mariadb ``` -!!! Warning This is a "multiline" label that accepts YAML or JSON content, don't forget to add a pipe char (`|` or `|-`) -and to **indent** your content This label can be used to map others environment for any others reason. E.g. to change an informational environment variable. diff --git a/doc/mkdocs.yml b/doc/mkdocs.yml index 6035216..3c7a813 100644 --- a/doc/mkdocs.yml +++ b/doc/mkdocs.yml @@ -42,6 +42,7 @@ nav: - coding.md - dependencies.md - Go Packages: + - packages/cmd/katenary.md - packages/generator.md - packages/parser.md - packages/update.md diff --git a/doc/requirements.txt b/doc/requirements.txt index 234bde6..c9d5e41 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,6 +1,6 @@ -mkdocs>=1.3.0 -Jinja2>=2.10.2 -MarkupSafe>=2.0 -pymdown-extensions>=9.5 -mkdocs-material>=8.3.4 -mkdocs-material-extensions>=1.0.3 +mkdocs>=1.5.3 +Jinja2>=3.1.3 +MarkupSafe>=2.1.5 +pymdown-extensions>=10.7.1 +mkdocs-material>=9.5.17 +mkdocs-material-extensions>=1.3.1 From 564b939464701f6e70c523bc43d541c89a849cc8 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 10 Apr 2024 04:54:16 +0200 Subject: [PATCH 29/97] Remove useless composition call --- generator/generator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generator/generator.go b/generator/generator.go index 1e42dcd..6e8ba95 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -377,7 +377,7 @@ func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map // to the target deployment if override, ok := service.Labels[LABEL_SAME_POD]; ok { pvc.nameOverride = override - pvc.PersistentVolumeClaim.Spec.StorageClassName = utils.StrPtr(`{{ .Values.` + override + `.persistence.` + v.Source + `.storageClass }}`) + pvc.Spec.StorageClassName = utils.StrPtr(`{{ .Values.` + override + `.persistence.` + v.Source + `.storageClass }}`) chart.Values[override].(*Value).AddPersistence(v.Source) } y, _ := pvc.Yaml() From 3317459b0be5f2cf916402582bb5e5854f553183 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 10 Apr 2024 04:54:38 +0200 Subject: [PATCH 30/97] Code cleaning --- generator/extrafiles/readme.go | 1 - 1 file changed, 1 deletion(-) diff --git a/generator/extrafiles/readme.go b/generator/extrafiles/readme.go index 865d203..5d6c9db 100644 --- a/generator/extrafiles/readme.go +++ b/generator/extrafiles/readme.go @@ -23,7 +23,6 @@ var readmeTemplate string // ReadMeFile returns the content of the README.md file. func ReadMeFile(charname, description string, values map[string]any) string { - // values is a yaml structure with keys and structured values... // we want to make list of dot separated keys and their values From c780e6c2a2992ec689b97c6c49b1a09b867f0bbb Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 10 Apr 2024 13:53:58 +0200 Subject: [PATCH 31/97] Change doc, icon and logo --- doc/docs/coding.md | 9 --- doc/docs/index.md | 8 -- doc/docs/packages/generator.md | 4 +- doc/docs/statics/icon.svg | 121 ++++++++++++++++++++++++++++ doc/docs/statics/logo-bright.svg | 134 +++++++++++++++++++++++++------ doc/docs/statics/logo-dark.svg | 134 +++++++++++++++++++++++++------ doc/docs/usage.md | 16 ---- doc/fix.py | 48 +++++++++++ doc/mkdocs.yml | 5 +- generator/doc.go | 6 +- 10 files changed, 394 insertions(+), 91 deletions(-) create mode 100644 doc/docs/statics/icon.svg create mode 100644 doc/fix.py diff --git a/doc/docs/coding.md b/doc/docs/coding.md index a1e00de..d039527 100644 --- a/doc/docs/coding.md +++ b/doc/docs/coding.md @@ -35,14 +35,6 @@ The `generator` package is where object struct are defined, and where the `Gener The generation is made by using a `HelmChart` object: ```golang -chart := NewChart(appName string) -``` - -Then, some processes are made to detect the "main app verion" (tag for the main service image), bootstrapping declared -ports in labels, managing links to bind containers in one pods... - -Then, a loop basically makes this: - ```golang for _, service := range project.Services { dep := NewDeployment(service) @@ -52,7 +44,6 @@ for _, service := range project.Services { Servicename: service.Name, } } -``` **A lot** of string manipulations are made by each `Yaml()` methods. This is where you find the complex and impacting operations. The `Yaml` methods **don't return a valid YAML content**. This is a Helm Chart Yaml content with template diff --git a/doc/docs/index.md b/doc/docs/index.md index 70acac9..4d02118 100644 --- a/doc/docs/index.md +++ b/doc/docs/index.md @@ -42,9 +42,7 @@ that is in your `PATH`. If you are a Linux user, you can use the "one line installation command" which will download the binary in your `$HOME/.local/bin` directory if it exists. -```bash sh <(curl -sSL https://raw.githubusercontent.com/metal3d/katenary/master/install.sh) -``` !!! Info "Upgrading is integrated to the `katenary` command" Katenary propose a `upgrade` subcommand to update the current binary to the latest stable release. @@ -61,21 +59,17 @@ sh <(curl -sSL https://raw.githubusercontent.com/metal3d/katenary/master/install But, note that the "master" branch is not the "stable" version. It's preferable to switch to a tag, or to use the releases. -```bash git clone https://github.com/metal3d/katenary.git cd katenary make build make install -``` `make install` copies `./katenary` binary to your user binary path (`~/.local/bin`) You can install it in other directory by changing the `PREFIX` variable. E.g.: -```bash make build sudo make install PREFIX=/usr/local -``` Check if everything is OK using `katenary version` and / or `katenary help` @@ -84,10 +78,8 @@ Check if everything is OK using `katenary version` and / or `katenary help` Katenary uses the very nice project named `cobra` to manage flags, argument and auto-completion. You can activate it with: -```bash # replace "bash" by "zsh" if needed source <(katenary completion bash) -``` Add this line in you `~/.profile` or `~/.bashrc` file to have completion at startup. diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index 82f9db8..fb824ec 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -6,7 +6,7 @@ import "katenary/generator" ``` -The generator package generates kubernetes objects from a compose file and transforms them into a helm chart. +The generator package generates kubernetes objects from a "compose" file and transforms them into a helm chart. The generator package is the core of katenary. It is responsible for generating kubernetes objects from a compose file and transforming them into a helm chart. Convertion manipulates Yaml representation of kubernetes object to add conditions, labels, annotations, etc. to the objects. It also create the values to be set to the values.yaml file. @@ -14,8 +14,6 @@ The generate.Convert\(\) create an HelmChart object and call "Generate\(\)" meth If you want to change or override the write behavior, you can use the HelmChart.Generate\(\) function and implement your own write function. This function returns the helm chart object containing all kubernetes objects and helm chart ingormation. It does not write the helm chart to the disk. -TODO: Manage cronjob \+ rbac TODO: create note.txt TODO: manage emptyDirs - ## Constants diff --git a/doc/docs/statics/icon.svg b/doc/docs/statics/icon.svg new file mode 100644 index 0000000..ca35f25 --- /dev/null +++ b/doc/docs/statics/icon.svg @@ -0,0 +1,121 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/docs/statics/logo-bright.svg b/doc/docs/statics/logo-bright.svg index f1ef1ff..eb6da22 100644 --- a/doc/docs/statics/logo-bright.svg +++ b/doc/docs/statics/logo-bright.svg @@ -1,35 +1,121 @@ - - + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> + + + + image/svg+xml + + + + + id="defs211952" /> + + + + + + + + + + id="logo-group" + transform="translate(-331.21094,-211.65039)"> + d="m 773.83594,234.02344 c -0.4545,0.0375 -0.92821,0.1629 -1.40821,0.3789 -1.91999,0.864 -2.68851,2.68743 -1.72851,4.60743 l 21.4082,44.35351 0.0957,0.0957 -9.5996,21.02344 c -0.96,2.112 -0.28778,3.74342 1.82421,4.60742 0.576,0.288 1.15252,0.38477 1.72852,0.38477 1.248,0 2.2072,-0.76703 2.7832,-2.20703 l 31.10352,-68.35352 c 0.96,-2.112 0.28778,-3.64772 -1.82422,-4.51172 -2.112,-0.864 -3.64742,-0.28748 -4.60742,1.72852 l -17.85547,39.35937 -18.7207,-39.45507 c -0.648,-1.44 -1.83572,-2.12422 -3.19922,-2.01172 z" + style="font-size:96px;line-height:0;font-family:Comfortaa;-inkscape-font-specification:Comfortaa;fill:#ff7f2a;stroke-width:51.0236;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0" + id="path9" /> + d="m 692.07617,233.63477 c -15.26398,0 -26.68945,11.51921 -26.68945,26.7832 0,15.26398 11.13594,26.68945 25.91992,26.68945 8.44799,0 15.64875,-3.84038 19.96875,-9.98437 v 5.85547 c 0,2.11199 1.53614,3.64843 3.74414,3.64843 2.112,0 3.74414,-1.53644 3.74414,-3.64843 v -22.56055 c -0.096,-15.26399 -11.51951,-26.7832 -26.6875,-26.7832 z m 0,6.7207 c 11.03999,0 19.39063,8.63851 19.39063,20.0625 0,11.42399 -8.35064,19.96875 -19.39063,19.96875 -11.03999,0 -19.48828,-8.54476 -19.48828,-19.96875 0,-11.42399 8.44829,-20.0625 19.48828,-20.0625 z" + style="font-size:96px;line-height:0;font-family:Comfortaa;-inkscape-font-specification:Comfortaa;fill:#ff7f2a;stroke-width:51.0236;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0" + id="path8" /> + d="m 566.60352,233.63477 c -14.78399,0 -25.15235,11.13521 -25.15235,26.7832 0,15.64798 11.03981,26.68945 26.5918,26.68945 5.95199,0 13.24866,-2.59224 17.47265,-6.24023 1.536,-1.344 1.4406,-3.36079 -0.1914,-4.80078 -1.344,-1.056 -3.26508,-0.9606 -4.70508,0.1914 -2.784,2.4 -7.96818,4.22461 -12.57617,4.22461 -10.65599,0 -18.52799,-7.20007 -19.58399,-17.66406 h 38.78516 c 2.016,0 3.45508,-1.34338 3.45508,-3.35938 0,-15.07198 -9.59972,-25.82421 -24.0957,-25.82421 z m 0,6.62304 c 9.69599,0 16.22359,6.72003 17.18359,16.41602 h -35.13477 c 1.344,-9.69599 8.15919,-16.41602 17.95118,-16.41602 z" + style="font-size:96px;line-height:0;font-family:Comfortaa;-inkscape-font-specification:Comfortaa;fill:#ff7f2a;stroke-width:51.0236;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0" + id="path7" /> + + + + + + + diff --git a/doc/docs/statics/logo-dark.svg b/doc/docs/statics/logo-dark.svg index 7f3ffa0..eb6da22 100644 --- a/doc/docs/statics/logo-dark.svg +++ b/doc/docs/statics/logo-dark.svg @@ -1,35 +1,121 @@ - - + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> + + + + image/svg+xml + + + + + id="defs211952" /> + + + + + + + + + + id="logo-group" + transform="translate(-331.21094,-211.65039)"> + d="m 773.83594,234.02344 c -0.4545,0.0375 -0.92821,0.1629 -1.40821,0.3789 -1.91999,0.864 -2.68851,2.68743 -1.72851,4.60743 l 21.4082,44.35351 0.0957,0.0957 -9.5996,21.02344 c -0.96,2.112 -0.28778,3.74342 1.82421,4.60742 0.576,0.288 1.15252,0.38477 1.72852,0.38477 1.248,0 2.2072,-0.76703 2.7832,-2.20703 l 31.10352,-68.35352 c 0.96,-2.112 0.28778,-3.64772 -1.82422,-4.51172 -2.112,-0.864 -3.64742,-0.28748 -4.60742,1.72852 l -17.85547,39.35937 -18.7207,-39.45507 c -0.648,-1.44 -1.83572,-2.12422 -3.19922,-2.01172 z" + style="font-size:96px;line-height:0;font-family:Comfortaa;-inkscape-font-specification:Comfortaa;fill:#ff7f2a;stroke-width:51.0236;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0" + id="path9" /> + d="m 692.07617,233.63477 c -15.26398,0 -26.68945,11.51921 -26.68945,26.7832 0,15.26398 11.13594,26.68945 25.91992,26.68945 8.44799,0 15.64875,-3.84038 19.96875,-9.98437 v 5.85547 c 0,2.11199 1.53614,3.64843 3.74414,3.64843 2.112,0 3.74414,-1.53644 3.74414,-3.64843 v -22.56055 c -0.096,-15.26399 -11.51951,-26.7832 -26.6875,-26.7832 z m 0,6.7207 c 11.03999,0 19.39063,8.63851 19.39063,20.0625 0,11.42399 -8.35064,19.96875 -19.39063,19.96875 -11.03999,0 -19.48828,-8.54476 -19.48828,-19.96875 0,-11.42399 8.44829,-20.0625 19.48828,-20.0625 z" + style="font-size:96px;line-height:0;font-family:Comfortaa;-inkscape-font-specification:Comfortaa;fill:#ff7f2a;stroke-width:51.0236;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0" + id="path8" /> + d="m 566.60352,233.63477 c -14.78399,0 -25.15235,11.13521 -25.15235,26.7832 0,15.64798 11.03981,26.68945 26.5918,26.68945 5.95199,0 13.24866,-2.59224 17.47265,-6.24023 1.536,-1.344 1.4406,-3.36079 -0.1914,-4.80078 -1.344,-1.056 -3.26508,-0.9606 -4.70508,0.1914 -2.784,2.4 -7.96818,4.22461 -12.57617,4.22461 -10.65599,0 -18.52799,-7.20007 -19.58399,-17.66406 h 38.78516 c 2.016,0 3.45508,-1.34338 3.45508,-3.35938 0,-15.07198 -9.59972,-25.82421 -24.0957,-25.82421 z m 0,6.62304 c 9.69599,0 16.22359,6.72003 17.18359,16.41602 h -35.13477 c 1.344,-9.69599 8.15919,-16.41602 17.95118,-16.41602 z" + style="font-size:96px;line-height:0;font-family:Comfortaa;-inkscape-font-specification:Comfortaa;fill:#ff7f2a;stroke-width:51.0236;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0" + id="path7" /> + + + + + + + diff --git a/doc/docs/usage.md b/doc/docs/usage.md index 715d5f5..1808bdb 100644 --- a/doc/docs/usage.md +++ b/doc/docs/usage.md @@ -58,9 +58,7 @@ For more complete label usage, see [the labels page](labels.md). After having installed `katenary`, the standard usage is to call: -```bash katenary convert -``` It will search standard compose files in the current directory and try to create a helm chart in "chart" directory. @@ -71,9 +69,7 @@ It will search standard compose files in the current directory and try to create Of course, you can provide others files than the default with (cummulative) `-c` options: -```bash katenary convert -c file1.yaml -c file2.yaml -``` ## Some common labels to use @@ -90,7 +86,6 @@ to make you able to wait for a service to respond. But you'll probably need to a See this compose file: -```yaml version: "3" services: @@ -103,14 +98,12 @@ services: image: mariadb environment: MYSQL_ROOT_PASSWORD: foobar -``` In this case, `webapp` needs to know the `database` port because the `depends_on` points on it and Kubernetes has not (yet) solution to check the database startup. Katenary wants to create a `initContainer` to hit on the related service. So, instead of exposing the port in the compose definition, let's declare this to katenary with labels: -```yaml version: "3" services: @@ -126,14 +119,12 @@ services: labels: katenary.v3/ports: |- - 3306 -``` ### Declare ingresses It's very common to have an `Ingress` on web application to deploy on Kuberenetes. The `katenary.io/ingress` declare the port to bind. -```yaml # ... services: webapp: @@ -143,7 +134,6 @@ services: katenary.v3/ingress: |- port: 5050 hostname: myapp.example.com -``` Note that the port to bind is the one used by the container, not the used locally. This is because Katenary create a service to bind the container itself. @@ -156,7 +146,6 @@ example, to connect a PHP application to a database. With a compose file, there is no problem as Docker/Podman allows to resolve the name by container name: -```yaml services: webapp: image: php:7-apache @@ -165,13 +154,11 @@ services: database: image: mariadb -``` Katenary prefixes the services with `{{ .Release.Name }}` (to make it possible to install the application several times in a namespace), so you need to "remap" the environment variable to the right one. -```yaml services: webapp: image: php:7-apache @@ -183,13 +170,11 @@ services: database: image: mariadb -``` This label can be used to map others environment for any others reason. E.g. to change an informational environment variable. -```yaml services: webapp: @@ -199,7 +184,6 @@ services: labels: katenary.v3/mapenv: |- RUNNING: kubernetes -``` In the above example, `RUNNING` will be set to `kubernetes` when you'll deploy the application with helm, and it's `docker` for "podman" and "docker" executions. diff --git a/doc/fix.py b/doc/fix.py new file mode 100644 index 0000000..d9f6b84 --- /dev/null +++ b/doc/fix.py @@ -0,0 +1,48 @@ +""" Fix the markdown files to replace code blocs by lists when the code blocs are lists.""" + +import re +import sys +from typing import Tuple + +# get markdown bloc code +re_code = re.compile(r"```(.*?)```", re.DOTALL) + + +def fix(text: str) -> Tuple[str, bool]: + """Fix the markdown text to replace code blocs by lists when the code blocs are lists.""" + # in the text, get the code blocs + code_blocs = re_code.findall(text) + # for each code bloc, if lines begin by a "-", this is a list. So, + # make it a mkdocs list and remove the block code + fixed = False + for code in code_blocs: + lines = code.split("\n") + lines = [line.strip() for line in lines if line.strip()] + if all(line.startswith("-") for line in lines): + fixed = True + # make a mkdocs list + lines = [f"- {line[1:]}" for line in lines] + # replace the code bloc by the list + text = text.replace(f"```{code}```", "\n".join(lines)) + return text, fixed + + +def main(filename: str): + """Fix and rewrite the markdown file.""" + with open(filename, "r", encoding="utf-8") as f: + text = f.read() + content, fixed = fix(text) + + if not fixed: + return + + with open(sys.argv[1], "w", encoding="utf-8") as f: + f.write(content) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python fix.py ") + sys.exit(1) + + main(sys.argv[1]) diff --git a/doc/mkdocs.yml b/doc/mkdocs.yml index 3c7a813..475c4a5 100644 --- a/doc/mkdocs.yml +++ b/doc/mkdocs.yml @@ -3,7 +3,8 @@ docs_dir: ./docs theme: name: material custom_dir: overrides - logo: statics/logo-dark.svg + logo: statics/logo-bright.svg + favicon: statics/icon.svg palette: - scheme: slate toggle: @@ -28,7 +29,7 @@ extra_css: - statics/main.css extra_javascript: - statics/addons.js -copyright: Copyright © 2021 - 2023 - Katenary authors +copyright: Copyright © 2021 - 2024 - Katenary authors extra: generator: false social: diff --git a/generator/doc.go b/generator/doc.go index 095c2e7..333ac56 100644 --- a/generator/doc.go +++ b/generator/doc.go @@ -1,5 +1,5 @@ /* -The generator package generates kubernetes objects from a compose file and transforms them into a helm chart. +The generator package generates kubernetes objects from a "compose" file and transforms them into a helm chart. The generator package is the core of katenary. It is responsible for generating kubernetes objects from a compose file and transforming them into a helm chart. Convertion manipulates Yaml representation of kubernetes object to add conditions, labels, annotations, etc. to the objects. It also create the values to be set to @@ -10,9 +10,5 @@ It saves the helm chart in the given directory. If you want to change or override the write behavior, you can use the HelmChart.Generate() function and implement your own write function. This function returns the helm chart object containing all kubernetes objects and helm chart ingormation. It does not write the helm chart to the disk. - -TODO: Manage cronjob + rbac -TODO: create note.txt -TODO: manage emptyDirs */ package generator From d8bd66e66fda9b29b164e04dcf9b504beaebdf80 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 10 Apr 2024 14:19:07 +0200 Subject: [PATCH 32/97] Change license date, enhance and fix documentation --- LICENSE | 2 +- doc/docs/index.md | 68 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/LICENSE b/LICENSE index 45c7cbf..76b1ebf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Patrice Ferlet +Copyright (c) 2022-2024 Patrice Ferlet Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/doc/docs/index.md b/doc/docs/index.md index 4d02118..9493492 100644 --- a/doc/docs/index.md +++ b/doc/docs/index.md @@ -10,14 +10,15 @@ effortlessly, saving you time and energy. 🛠️ Simple autmated CLI: Katenary handles the grunt work, generating everything needed for seamless service binding and Helm Chart creation. -💡 Effortless Efficiency: You only need to add labels when it's necessary to precise things. Then call `katenary convert` and let the magic happen. +💡 Effortless Efficiency: You only need to add labels when it's necessary to precise things. Then call `katenary convert` +and let the magic happen.
-# What ? +# What is it? Katenary is a tool made to help you to transform "compose" files (`docker-compose.yml`, `podman-compose.yml`...) to complete and production ready [Helm Chart](https://helm.sh). @@ -42,7 +43,9 @@ that is in your `PATH`. If you are a Linux user, you can use the "one line installation command" which will download the binary in your `$HOME/.local/bin` directory if it exists. +```bash sh <(curl -sSL https://raw.githubusercontent.com/metal3d/katenary/master/install.sh) +``` !!! Info "Upgrading is integrated to the `katenary` command" Katenary propose a `upgrade` subcommand to update the current binary to the latest stable release. @@ -59,17 +62,23 @@ sh <(curl -sSL https://raw.githubusercontent.com/metal3d/katenary/master/install But, note that the "master" branch is not the "stable" version. It's preferable to switch to a tag, or to use the releases. +To compile it, you can use the following commands: + +```bash git clone https://github.com/metal3d/katenary.git cd katenary make build make install +``` `make install` copies `./katenary` binary to your user binary path (`~/.local/bin`) You can install it in other directory by changing the `PREFIX` variable. E.g.: +```bash make build sudo make install PREFIX=/usr/local +``` Check if everything is OK using `katenary version` and / or `katenary help` @@ -78,37 +87,59 @@ Check if everything is OK using `katenary version` and / or `katenary help` Katenary uses the very nice project named `cobra` to manage flags, argument and auto-completion. You can activate it with: + +```bash # replace "bash" by "zsh" if needed source <(katenary completion bash) +``` -Add this line in you `~/.profile` or `~/.bashrc` file to have completion at startup. +Add this line in you `~/.profile`, `~/.bash_aliases` or `~/.bashrc` file to have completion at startup. + + +## What a name... + +A catenary is the curve that a hanging chain or cable assumes under its own weight when supported only at its ends. +I, the maintainer, decided to name "Katenary" this project because it's like a chain that links a boat to a dock. +Making the link between the "compose" world and the "Kubernetes" world is the main goal of this project. + +Anyway, it's too late to change the name now :smile: + +!!! Note "But I like this name!" + + I spent time to find it :wink: + +## Special thanks to... + +I really want to thank all the contributors, testers, and of course, the authors of the packages and tools that are used +in this project. There is too many to list here. Katenary can works because of all these people. Open source is a great +thing! :heart: !!! Edit "Special thanks" **Katenary is built with:**
- > Special thanks to all contributors, testors, and of course packages and tools authors. - :fontawesome-brands-golang:{ .go-logo } - - Go is an open source programming language that makes it easy to build simple, reliable, and efficient software. - Docker, Rancher, Helm, Kubernetes, Grafana, Prometheus, and many others are written in Go. Katenary uses Go-Compose - to parse compose files wich is the same library used by Podman-Compose and Docker-Compose. It also uses the - Kubernetes official packages to create Kubernetes objects before to generate the Helm Chart. + + Go is an open source programming language that makes it easy to build simple, reliable, and efficient software. Because Docker, Podman, + Kubernetes, and Helm are written in Go, Katenary is also written in Go and borrows packages from these projects to + make it as efficient as possible. + + Thanks to Kubernetes to provide [Kind](https://kind.sigs.k8s.io) that is used to test Katenary locally. **Thanks to everyone who contributes to all these projects.** + Katenary can progress because of all these people. All contributions, as comments, issues, pull requests and + feedbacks are welcome. + **Everything was also possible because of:**
    - -
  • - Helm that is the main toppic of Katenary, Kubernetes is easier to use with it.
  • - -
  • Cobra that - makes command, subcommand and completion possible for Katenary with ease.
  • - +
  • + Helm that is the main toppic of Katenary, Kubernetes is easier to use with it.
  • +
  • Cobra that + makes command, subcommand and completion possible for Katenary with ease.
  • +
  • Podman, Docker, Kubernetes that are the main tools that Katenary is made for.
**Documentation is built with:**
@@ -116,3 +147,6 @@ Add this line in you `~/.profile` or `~/.bashrc` file to have completion at star MkDocs using Material for MkDocs theme template. +## License + +Katenary is an open source project under the MIT license. You can use it, modify it, and distribute it as you want. From 19a37ace18974f9444b12b180b14673e1742c5b6 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 10 Apr 2024 22:25:07 +0200 Subject: [PATCH 33/97] Better styles, logo, effects... - Make a SVG with classes to invert the color of strokes - Set a better logo + one vertical --- README.md | 2 +- doc/docs/index.md | 6 +- doc/docs/statics/addons.js | 2 +- doc/docs/statics/logo-bright.svg | 73 ++-- doc/docs/statics/logo-vertical.svg | 146 ++++++++ doc/docs/statics/main.css | 21 +- doc/docs/statics/workflow.svg | 574 +++++++++++++++++++++++++++++ doc/mkdocs.yml | 2 + doc/requirements.txt | 1 + 9 files changed, 785 insertions(+), 42 deletions(-) create mode 100644 doc/docs/statics/logo-vertical.svg create mode 100644 doc/docs/statics/workflow.svg diff --git a/README.md b/README.md index bff328c..51f3b0c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- Katenary Logo + Katenary Logo
diff --git a/doc/docs/index.md b/doc/docs/index.md index 9493492..00daa82 100644 --- a/doc/docs/index.md +++ b/doc/docs/index.md @@ -1,4 +1,6 @@ - +
+![Katenary Logo](statics/logo-vertical.svg) +
# Welcome to Katenary documentation @@ -14,7 +16,7 @@ and Helm Chart creation. and let the magic happen.
- +![](statics/workflow.svg)
diff --git a/doc/docs/statics/addons.js b/doc/docs/statics/addons.js index d4bebc4..64dafdc 100644 --- a/doc/docs/statics/addons.js +++ b/doc/docs/statics/addons.js @@ -24,7 +24,7 @@ function makeImagesZoomable() { const zone = document.querySelectorAll(".zoomable"); zone.forEach((z, i) => { - const im = z.querySelectorAll("img"); + const im = z.querySelectorAll("img,svg"); if (im.length == 0) { return; } diff --git a/doc/docs/statics/logo-bright.svg b/doc/docs/statics/logo-bright.svg index eb6da22..2f739c1 100644 --- a/doc/docs/statics/logo-bright.svg +++ b/doc/docs/statics/logo-bright.svg @@ -1,9 +1,9 @@ - - - - - - + transform="translate(-185.54797,-175.3735)"> + + + Katenary + - + d="m 558.98167,600.50526 -33.31908,17.65778 -13.63054,6.42952 -13.63053,-6.42952 -33.31907,-17.65778 c 0,5.32502 -2.24747,16.12101 0.77828,20.72433 1.84892,2.81293 6.84753,4.46784 9.82323,6.09432 8.26868,4.51957 16.72019,8.751 24.98932,13.27082 3.1083,1.69895 7.62988,5.16897 11.35877,5.21476 3.70778,0.0455 8.2451,-3.55102 11.35878,-5.19752 8.33909,-4.40967 16.72005,-8.76818 24.98932,-13.28806 3.05286,-1.66866 8.68533,-3.49778 10.14461,-6.69253 2.3911,-5.23485 0.45691,-14.46175 0.45691,-20.12612 m -46.19236,16.00215 c 3.59731,-0.51544 7.48607,-3.47354 10.60152,-5.12097 7.58911,-4.0131 15.20174,-8.00941 22.71755,-12.13797 3.53683,-1.94283 9.05374,-3.7013 11.65911,-6.69847 3.28982,-3.78453 1.93068,-13.45759 -2.58668,-15.74886 -4.7363,-2.40229 -8.80251,1.12086 -12.85868,3.25251 l -21.20306,11.2665 c -2.56736,1.35182 -6.01463,4.01777 -9.08702,4.01777 -3.29073,0 -7.12524,-2.97742 -9.84427,-4.47101 -6.50984,-3.57597 -13.17851,-6.90091 -19.68854,-10.47698 -2.85668,-1.56921 -7.10587,-4.85415 -10.60153,-4.52437 -7.2756,0.6864 -9.69308,11.97712 -5.60111,16.68444 2.43875,2.80547 7.56529,4.49972 10.90188,6.26408 7.83167,4.14137 15.6667,8.28474 23.4748,12.46456 3.21433,1.72068 8.18431,5.79216 12.11603,5.22877 m 30.29007,-45.33762 v -1.39693 l -18.17404,-8.90973 -12.11603,4.65427 -12.87329,-4.72164 -18.93129,8.9771 v 1.39693 l 21.20305,11.17542 9.84427,4.34169 9.84428,-4.33415 z" + id="path18" /> + + + Katenary + diff --git a/doc/docs/statics/logo-vertical.svg b/doc/docs/statics/logo-vertical.svg new file mode 100644 index 0000000..f277ec5 --- /dev/null +++ b/doc/docs/statics/logo-vertical.svg @@ -0,0 +1,146 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Katenary + + + + diff --git a/doc/docs/statics/main.css b/doc/docs/statics/main.css index 1be9c28..2394786 100644 --- a/doc/docs/statics/main.css +++ b/doc/docs/statics/main.css @@ -67,7 +67,7 @@ h3[id*="katenaryio"] { } #logo { - background-image: url("logo-dark.svg"); + background-image: url("logo-vertical.svg"); background-repeat: no-repeat; background-position: center; background-size: contain; @@ -78,8 +78,21 @@ h3[id*="katenaryio"] { /*Zoomable images*/ -[data-md-color-scheme="slate"] #logo { +/*[data-md-color-scheme="slate"] #logo { background-image: url("logo-bright.svg"); +}*/ + +.zoomable svg { + background-color: var(--md-default-bg-color); + padding: 1rem; +} + +[data-md-color-scheme="slate"] .zoomable svg { + background-color: var(--md-default-bg-color); +} + +[data-md-color-scheme="slate"] .zoomable svg .colorize { + fill: var(--md-typeset-color) !important; } .zoomable input[type="checkbox"] { @@ -87,11 +100,11 @@ h3[id*="katenaryio"] { } @media all and (min-width: 1399px) { - .zoomable label img { + .zoomable label > * { cursor: zoom-in; transition: all 0.2s ease-in-out; } - .zoomable input[type="checkbox"]:checked ~ label img { + .zoomable input[type="checkbox"]:checked ~ label > * { transform: scale(2); cursor: zoom-out; box-shadow: 0 0 8px rgba(0, 0, 0, 0.3); diff --git a/doc/docs/statics/workflow.svg b/doc/docs/statics/workflow.svg new file mode 100644 index 0000000..60b8cd6 --- /dev/null +++ b/doc/docs/statics/workflow.svg @@ -0,0 +1,574 @@ + + + +Katenary WorkflowKatenaryKatenary WorkflowPatrice FerletEnglishKatenaryDockerPodmanKubernetesHelmConverter diff --git a/doc/mkdocs.yml b/doc/mkdocs.yml index 475c4a5..81536df 100644 --- a/doc/mkdocs.yml +++ b/doc/mkdocs.yml @@ -1,5 +1,7 @@ site_name: Katenary documentation docs_dir: ./docs +plugins: + - inline-svg theme: name: material custom_dir: overrides diff --git a/doc/requirements.txt b/doc/requirements.txt index c9d5e41..b34c282 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -4,3 +4,4 @@ MarkupSafe>=2.1.5 pymdown-extensions>=10.7.1 mkdocs-material>=9.5.17 mkdocs-material-extensions>=1.3.1 +mkdocs-plugin-inline-svg-mod>=0.0.1 From 821c038206c1e454612804f7e30ea0bcc4b387ce Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 10 Apr 2024 22:53:32 +0200 Subject: [PATCH 34/97] Fix fonts --- doc/docs/statics/logo-vertical.svg | 62 ++++++++---------------------- doc/docs/statics/workflow.svg | 56 +++++---------------------- 2 files changed, 27 insertions(+), 91 deletions(-) diff --git a/doc/docs/statics/logo-vertical.svg b/doc/docs/statics/logo-vertical.svg index f277ec5..538e707 100644 --- a/doc/docs/statics/logo-vertical.svg +++ b/doc/docs/statics/logo-vertical.svg @@ -85,42 +85,6 @@ y="0" width="0" height="0" /> - - - - - - - - - - @@ -128,19 +92,27 @@ style="fill:#388ec7;fill-opacity:1;stroke:none;stroke-width:1.41128" d="m 650.16134,532.23713 -33.31908,17.65778 -13.63054,6.42952 -13.63053,-6.42952 -33.31907,-17.65778 c 0,5.32502 -2.24747,16.12101 0.77828,20.72433 1.84892,2.81293 6.84753,4.46784 9.82323,6.09432 8.26868,4.51957 16.72019,8.751 24.98932,13.27082 3.1083,1.69895 7.62988,5.16897 11.35877,5.21476 3.70778,0.0455 8.2451,-3.55102 11.35878,-5.19752 8.33909,-4.40967 16.72005,-8.76818 24.98932,-13.28806 3.05286,-1.66866 8.68533,-3.49778 10.14461,-6.69253 2.3911,-5.23485 0.45691,-14.46175 0.45691,-20.12612 m -46.19236,16.00215 c 3.59731,-0.51544 7.48607,-3.47354 10.60152,-5.12097 7.58911,-4.0131 15.20174,-8.00941 22.71755,-12.13797 3.53683,-1.94283 9.05374,-3.7013 11.65911,-6.69847 3.28982,-3.78453 1.93068,-13.45759 -2.58668,-15.74886 -4.7363,-2.40229 -8.80251,1.12086 -12.85868,3.25251 l -21.20306,11.2665 c -2.56736,1.35182 -6.01463,4.01777 -9.08702,4.01777 -3.29073,0 -7.12524,-2.97742 -9.84427,-4.47101 -6.50984,-3.57597 -13.17851,-6.90091 -19.68854,-10.47698 -2.85668,-1.56921 -7.10587,-4.85415 -10.60153,-4.52437 -7.2756,0.6864 -9.69308,11.97712 -5.60111,16.68444 2.43875,2.80547 7.56529,4.49972 10.90188,6.26408 7.83167,4.14137 15.6667,8.28474 23.4748,12.46456 3.21433,1.72068 8.18431,5.79216 12.11603,5.22877 m 30.29007,-45.33762 v -1.39693 l -18.17404,-8.90973 -12.11603,4.65427 -12.87329,-4.72164 -18.93129,8.9771 v 1.39693 l 21.20305,11.17542 9.84427,4.34169 9.84428,-4.33415 z" id="path17" /> - Katenary + + + + +
diff --git a/doc/docs/statics/workflow.svg b/doc/docs/statics/workflow.svg index 60b8cd6..6f9d68d 100644 --- a/doc/docs/statics/workflow.svg +++ b/doc/docs/statics/workflow.svg @@ -3,39 +3,17 @@ Katenary WorkflowKatenary WorkflowKatenary Date: Wed, 10 Apr 2024 22:55:58 +0200 Subject: [PATCH 35/97] fix fonts in svg, one more time... --- doc/docs/statics/logo-bright.svg | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/doc/docs/statics/logo-bright.svg b/doc/docs/statics/logo-bright.svg index 2f739c1..c6e07db 100644 --- a/doc/docs/statics/logo-bright.svg +++ b/doc/docs/statics/logo-bright.svg @@ -92,15 +92,11 @@ style="fill:#388ec7;fill-opacity:1;stroke:none;stroke-width:1.41128" d="m 650.16134,532.23713 -33.31908,17.65778 -13.63054,6.42952 -13.63053,-6.42952 -33.31907,-17.65778 c 0,5.32502 -2.24747,16.12101 0.77828,20.72433 1.84892,2.81293 6.84753,4.46784 9.82323,6.09432 8.26868,4.51957 16.72019,8.751 24.98932,13.27082 3.1083,1.69895 7.62988,5.16897 11.35877,5.21476 3.70778,0.0455 8.2451,-3.55102 11.35878,-5.19752 8.33909,-4.40967 16.72005,-8.76818 24.98932,-13.28806 3.05286,-1.66866 8.68533,-3.49778 10.14461,-6.69253 2.3911,-5.23485 0.45691,-14.46175 0.45691,-20.12612 m -46.19236,16.00215 c 3.59731,-0.51544 7.48607,-3.47354 10.60152,-5.12097 7.58911,-4.0131 15.20174,-8.00941 22.71755,-12.13797 3.53683,-1.94283 9.05374,-3.7013 11.65911,-6.69847 3.28982,-3.78453 1.93068,-13.45759 -2.58668,-15.74886 -4.7363,-2.40229 -8.80251,1.12086 -12.85868,3.25251 l -21.20306,11.2665 c -2.56736,1.35182 -6.01463,4.01777 -9.08702,4.01777 -3.29073,0 -7.12524,-2.97742 -9.84427,-4.47101 -6.50984,-3.57597 -13.17851,-6.90091 -19.68854,-10.47698 -2.85668,-1.56921 -7.10587,-4.85415 -10.60153,-4.52437 -7.2756,0.6864 -9.69308,11.97712 -5.60111,16.68444 2.43875,2.80547 7.56529,4.49972 10.90188,6.26408 7.83167,4.14137 15.6667,8.28474 23.4748,12.46456 3.21433,1.72068 8.18431,5.79216 12.11603,5.22877 m 30.29007,-45.33762 v -1.39693 l -18.17404,-8.90973 -12.11603,4.65427 -12.87329,-4.72164 -18.93129,8.9771 v 1.39693 l 21.20305,11.17542 9.84427,4.34169 9.84428,-4.33415 z" id="path17" /> - Katenary + - Katenary + From c41fa22c59143f2efa050311b74e6ea3c7de5221 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 11 Apr 2024 09:34:58 +0200 Subject: [PATCH 36/97] Update k8s.io/api + fix changed function --- generator/volume.go | 2 +- go.mod | 24 ++++++++++-------- go.sum | 62 +++++++++++++++++++++++++++------------------ 3 files changed, 52 insertions(+), 36 deletions(-) diff --git a/generator/volume.go b/generator/volume.go index 5678c5c..133f32d 100644 --- a/generator/volume.go +++ b/generator/volume.go @@ -42,7 +42,7 @@ func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *Vo v1.ReadWriteOnce, }, StorageClassName: utils.StrPtr(`{{ .Values.` + service.Name + `.persistence.` + volumeName + `.storageClass }}`), - Resources: v1.ResourceRequirements{ + Resources: v1.VolumeResourceRequirements{ Requests: v1.ResourceList{ v1.ResourceStorage: resource.MustParse("1Gi"), }, diff --git a/go.mod b/go.mod index 82244c7..4612b1a 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,18 @@ module katenary // github.com/metal3d/katenary -go 1.20 +go 1.21 + +toolchain go1.21.8 require ( github.com/compose-spec/compose-go v1.20.2 github.com/mitchellh/go-wordwrap v1.0.1 github.com/spf13/cobra v1.8.0 github.com/thediveo/netdb v1.0.2 - golang.org/x/mod v0.16.0 + golang.org/x/mod v0.17.0 gopkg.in/yaml.v3 v3.0.1 - k8s.io/api v0.27.2 - k8s.io/apimachinery v0.27.2 + k8s.io/api v0.29.3 + k8s.io/apimachinery v0.29.3 sigs.k8s.io/yaml v1.3.0 ) @@ -18,7 +20,7 @@ require ( github.com/distribution/reference v0.5.0 // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/go-logr/logr v1.2.3 // indirect + github.com/go-logr/logr v1.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/imdario/mergo v0.3.16 // indirect @@ -36,14 +38,14 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect - golang.org/x/net v0.8.0 // indirect + golang.org/x/net v0.19.0 // indirect golang.org/x/sync v0.3.0 // indirect - golang.org/x/sys v0.6.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/klog/v2 v2.90.1 // indirect - k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5 // indirect + k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect ) diff --git a/go.sum b/go.sum index 4d75827..c3759ba 100644 --- a/go.sum +++ b/go.sum @@ -16,11 +16,12 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -40,14 +41,16 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= @@ -61,8 +64,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= @@ -90,7 +95,8 @@ github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJo github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= github.com/onsi/ginkgo/v2 v2.8.1/go.mod h1:N1/NbDngAFcSLdyZ+/aYTYGSlq9qMCS/cNKGJjy+csc= github.com/onsi/ginkgo/v2 v2.9.0/go.mod h1:4xkjoL/tZv4SMWeww56BU5kAt19mVB47gTWxmrTcxyk= -github.com/onsi/ginkgo/v2 v2.9.1 h1:zie5Ly042PD3bsCvsSOPvRnFwyo3rKe64TJlD6nu0mk= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= @@ -103,7 +109,8 @@ github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmv github.com/onsi/gomega v1.26.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= github.com/onsi/gomega v1.27.1/go.mod h1:aHX5xOykVYzWOV4WqQy0sy8BQptgukenXpCXfadcIAw= github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw= -github.com/onsi/gomega v1.27.4 h1:Z2AnStgsdSayCMDiCU42qIz+HLqEPcgiOCXjAU/w+8E= +github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= +github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -111,6 +118,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -129,6 +137,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/thediveo/netdb v1.0.2 h1:icuZWO8btuubgjFFFhxWmXALATlQO6bqEer7DPxRPco= github.com/thediveo/netdb v1.0.2/go.mod h1:Mz/McdR84D8xUX7rWk0cRgNLrLvqfDPzTAQKUeCR0OY= github.com/xanzy/go-gitlab v0.81.0/go.mod h1:VMbY3JIWdZ/ckvHbQqkyd3iYk2aViKrNIQ23IbFMQDo= @@ -157,8 +166,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -178,8 +187,9 @@ golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -215,8 +225,9 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -235,8 +246,8 @@ golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -248,7 +259,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= +golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -266,6 +278,7 @@ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw google.golang.org/protobuf v1.29.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= @@ -280,17 +293,18 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= -k8s.io/api v0.27.2 h1:+H17AJpUMvl+clT+BPnKf0E3ksMAzoBBg7CntpSuADo= -k8s.io/api v0.27.2/go.mod h1:ENmbocXfBT2ADujUXcBhHV55RIT31IIEvkntP6vZKS4= -k8s.io/apimachinery v0.27.2 h1:vBjGaKKieaIreI+oQwELalVG4d8f3YAMNpWLzDXkxeg= -k8s.io/apimachinery v0.27.2/go.mod h1:XNfZ6xklnMCOGGFNqXG7bUrQCoR04dh/E7FprV6pb+E= -k8s.io/klog/v2 v2.90.1 h1:m4bYOKall2MmOiRaR1J+We67Do7vm9KiQVlT96lnHUw= -k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= -k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5 h1:kmDqav+P+/5e1i9tFfHq1qcF3sOrDp+YEkVDAHu7Jwk= -k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= +k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= +k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= +k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= +k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= -sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= From ab1561407603cce2753a8311e04a7559aa69d7eb Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 11 Apr 2024 09:35:25 +0200 Subject: [PATCH 37/97] Use "helm" filtype first for vim modeline + tests "helm" can be managed by vim/neovim plugins, so it's a good idea to add it as default, then use "gotmpl.yaml". Add basic tests... --- generator/basic_test.go | 57 ++++++++++++++++++++++++++++++++++++ generator/converter.go | 2 +- generator/tools_test.go | 64 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 generator/basic_test.go create mode 100644 generator/tools_test.go diff --git a/generator/basic_test.go b/generator/basic_test.go new file mode 100644 index 0000000..1c649a8 --- /dev/null +++ b/generator/basic_test.go @@ -0,0 +1,57 @@ +package generator + +import ( + "log" + "os" + "testing" + + "sigs.k8s.io/yaml" +) + +func setup(content string) string { + // write the _compose_file in temporary directory + tmpDir, err := os.MkdirTemp("", "katenary") + if err != nil { + panic(err) + } + os.WriteFile(tmpDir+"/compose.yml", []byte(content), 0o644) + return tmpDir +} + +func teardown(tmpDir string) { + // remove the temporary directory + log.Println("Removing temporary directory: ", tmpDir) + if err := os.RemoveAll(tmpDir); err != nil { + panic(err) + } +} + +func TestGenerate(t *testing.T) { + _compose_file := ` +services: + web: + image: nginx:1.29 +` + tmpDir := setup(_compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t) + + dt := DeploymentTest{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf("Failed to unmarshal the output: %s", err) + } + + if dt.Spec.Replicas != 1 { + t.Errorf("Expected replicas to be 1, got %d", dt.Spec.Replicas) + t.Errorf("Output: %s", output) + } + + if dt.Spec.Template.Spec.Containers[0].Image != "nginx:1.29" { + t.Errorf("Expected image to be nginx:1.29, got %s", dt.Spec.Template.Spec.Containers[0].Image) + } +} diff --git a/generator/converter.go b/generator/converter.go index ade8b20..d7eb267 100644 --- a/generator/converter.go +++ b/generator/converter.go @@ -317,7 +317,7 @@ func addStorageClassHelp(values []byte) []byte { // addModeline adds a modeline to the values.yaml file to make sure that vim // will use the correct syntax highlighting. func addModeline(values []byte) []byte { - modeline := "# vi" + "m: ft=gotmpl.yaml" + modeline := "# vi" + "m: ft=helm.gotmpl.yaml" // if the values ends by `{{- end }}` we need to add the modeline before lines := strings.Split(string(values), "\n") diff --git a/generator/tools_test.go b/generator/tools_test.go new file mode 100644 index 0000000..02b0f25 --- /dev/null +++ b/generator/tools_test.go @@ -0,0 +1,64 @@ +package generator + +import ( + "os/exec" + "testing" + + "katenary/parser" +) + +type DeploymentTest struct { + Spec struct { + Replicas int `yaml:"replicas"` + Template struct { + Spec struct { + Containers []struct { + Image string `yaml:"image"` + } `yaml:"containers"` + } `yaml:"spec"` + } `yaml:"template"` + } `yaml:"spec"` +} + +func _compile_test(t *testing.T) string { + _, err := parser.Parse(nil, "compose.yml") + if err != nil { + t.Errorf("Failed to parse the project: %s", err) + } + + force := false + outputDir := "./chart" + profiles := make([]string, 0) + helmdepUpdate := false + var appVersion *string + chartVersion := "0.1.0" + convertOptions := ConvertOptions{ + Force: force, + OutputDir: outputDir, + Profiles: profiles, + HelmUpdate: helmdepUpdate, + AppVersion: appVersion, + ChartVersion: chartVersion, + } + Convert(convertOptions, "compose.yml") + // launch helm lint to check the generated chart + if helmLint(convertOptions) != nil { + t.Errorf("Failed to lint the generated chart") + } + // try with helm template + var output string + if output, err = helmTemplate(convertOptions); err != nil { + t.Errorf("Failed to template the generated chart") + t.Errorf("Output: %s", output) + } + return output +} + +func helmTemplate(options ConvertOptions) (string, error) { + cmd := exec.Command("helm", "template", options.OutputDir) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), err + } + return string(output), nil +} From 3c743fb135cd625b562b82c62228614a2898a93d Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 19 Apr 2024 11:13:24 +0200 Subject: [PATCH 38/97] Fixup hard problems on bound volumes Recreated the method to bind local content to configMaps with subPath. That simplify a few how we can bound files and not only directory content. --- generator/configMap.go | 22 ++++++++- generator/deployment.go | 107 ++++++++++++++++++++++------------------ generator/generator.go | 85 ++++++++++++------------------- 3 files changed, 110 insertions(+), 104 deletions(-) diff --git a/generator/configMap.go b/generator/configMap.go index ac02590..73ff639 100644 --- a/generator/configMap.go +++ b/generator/configMap.go @@ -127,10 +127,10 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { return cm } -// NewConfigMapFromFiles creates a new ConfigMap from a compose service. This path is the path to the +// NewConfigMapFromDirectory creates a new ConfigMap from a compose service. This path is the path to the // file or directory. If the path is a directory, all files in the directory are added to the ConfigMap. // Each subdirectory are ignored. Note that the Generate() function will create the subdirectories ConfigMaps. -func NewConfigMapFromFiles(service types.ServiceConfig, appName string, path string) *ConfigMap { +func NewConfigMapFromDirectory(service types.ServiceConfig, appName string, path string) *ConfigMap { normalized := path normalized = strings.TrimLeft(normalized, ".") normalized = strings.TrimLeft(normalized, "/") @@ -178,6 +178,7 @@ func (c *ConfigMap) AppendDir(path string) { if err != nil { log.Fatalf("Path %s does not exist\n", path) } + log.Printf("Appending files from %s to configmap\n", path) // recursively read all files in the path and add them to the configmap if stat.IsDir() { files, err := os.ReadDir(path) @@ -207,6 +208,23 @@ func (c *ConfigMap) AppendDir(path string) { } } +func (c *ConfigMap) AppendFile(path string) { + // read all files in the path and add them to the configmap + stat, err := os.Stat(path) + if err != nil { + log.Fatalf("Path %s does not exist\n", path) + } + // recursively read all files in the path and add them to the configmap + if !stat.IsDir() { + // add the file to the configmap + content, err := os.ReadFile(path) + if err != nil { + log.Fatal(err) + } + c.AddData(filepath.Base(path), string(content)) + } +} + // Filename returns the filename of the configmap. If the configmap is used for files, the filename contains the path. func (c *ConfigMap) Filename() string { switch c.usage { diff --git a/generator/deployment.go b/generator/deployment.go index 07760f8..ff70224 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -20,14 +20,24 @@ import ( var _ Yaml = (*Deployment)(nil) +type mountPathConfig struct { + mountPath string + subPath string +} + +type ConfigMapMount struct { + configMap *ConfigMap + mountPath []mountPathConfig +} + // Deployment is a kubernetes Deployment. type Deployment struct { *appsv1.Deployment `yaml:",inline"` - chart *HelmChart `yaml:"-"` - configMaps map[string]bool `yaml:"-"` - service *types.ServiceConfig `yaml:"-"` - defaultTag string `yaml:"-"` - isMainApp bool `yaml:"-"` + chart *HelmChart `yaml:"-"` + configMaps map[string]*ConfigMapMount `yaml:"-"` + service *types.ServiceConfig `yaml:"-"` + defaultTag string `yaml:"-"` + isMainApp bool `yaml:"-"` } // NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name. @@ -74,7 +84,7 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { }, }, }, - configMaps: map[string]bool{}, + configMaps: map[string]*ConfigMapMount{}, } // add containers @@ -182,6 +192,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { isSamePod = v != "" } + container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) for _, volume := range service.Volumes { // not declared as a bind volume, skip if _, ok := tobind[volume.Source]; !isSamePod && volume.Type == "bind" && !ok { @@ -195,7 +206,6 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { continue } - container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) if container == nil { utils.Warn("Container not found for volume", volume.Source) continue @@ -231,61 +241,62 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { }) case "bind": // Add volume to container - cm := NewConfigMapFromFiles(service, appName, volume.Source) - d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ - Name: utils.PathToName(volume.Source), - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: cm.Name, - }, - }, - }, - }) - // add the mount path to the container - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: utils.PathToName(volume.Source), - MountPath: volume.Target, - }) - - d.configMaps[utils.PathToName(volume.Source)] = true - // add all subdirectories to the list of directories stat, err := os.Stat(volume.Source) if err != nil { log.Fatal(err) } + if stat.IsDir() { - files, err := os.ReadDir(volume.Source) + pathnme := utils.PathToName(volume.Source) + if _, ok := d.configMaps[pathnme]; !ok { + d.configMaps[pathnme] = &ConfigMapMount{ + mountPath: []mountPathConfig{}, + } + } + + // TODO: make it recursive to add all files in the directory and subdirectories + _, err := os.ReadDir(volume.Source) if err != nil { log.Fatal(err) } - for _, file := range files { - if file.IsDir() { - cm := NewConfigMapFromFiles(service, appName, filepath.Join(volume.Source, file.Name())) - name := utils.PathToName(volume.Source) + "-" + file.Name() - d.configMaps[name] = true - d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ - Name: utils.PathToName(volume.Source) + "-" + file.Name(), - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: cm.Name, - }, - }, - }, - }) - // add the mount path to the container - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: name, - MountPath: filepath.Join(volume.Target, file.Name()), - }) + cm := NewConfigMapFromDirectory(service, appName, volume.Source) + d.configMaps[pathnme] = &ConfigMapMount{ + configMap: cm, + mountPath: append(d.configMaps[pathnme].mountPath, mountPathConfig{ + mountPath: volume.Target, + }), + } + } else { + dirname := filepath.Dir(volume.Source) + pathnme := utils.PathToName(dirname) + var cm *ConfigMap + if v, ok := d.configMaps[pathnme]; !ok { + cm = NewConfigMap(*d.service, appName) + cm.usage = FileMapUsageFiles + cm.path = dirname + cm.Name = utils.TplName(service.Name, appName) + "-" + pathnme + d.configMaps[pathnme] = &ConfigMapMount{ + configMap: cm, + mountPath: []mountPathConfig{}, } + } else { + cm = v.configMap + } + + cm.AppendFile(volume.Source) + d.configMaps[pathnme] = &ConfigMapMount{ + configMap: cm, + mountPath: append(d.configMaps[pathnme].mountPath, mountPathConfig{ + mountPath: volume.Target, + subPath: filepath.Base(volume.Source), + }), } } } - d.Spec.Template.Spec.Containers[index] = *container } + + d.Spec.Template.Spec.Containers[index] = *container } func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) { diff --git a/generator/generator.go b/generator/generator.go index 6e8ba95..5d18d52 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -6,8 +6,6 @@ import ( "bytes" "fmt" "log" - "os" - "path/filepath" "regexp" "strconv" "strings" @@ -385,66 +383,45 @@ func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map Content: y, Servicename: service.Name, // TODO, use name } + } + } - case "bind": - // ensure the path is in labels - bindPath := map[string]string{} - if _, ok := service.Labels[LABEL_CM_FILES]; ok { - files := []string{} - if err := yaml.Unmarshal([]byte(service.Labels[LABEL_CM_FILES]), &files); err != nil { - return err - } - for _, f := range files { - bindPath[f] = f - } - } - if _, ok := bindPath[v.Source]; !ok { - continue - } - - cm := NewConfigMapFromFiles(service, appName, v.Source) - var err error + // add the bound configMaps files to the deployment containers + for _, d := range deployments { + container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) + for volumeName, config := range d.configMaps { var y []byte - if y, err = cm.Yaml(); err != nil { + var err error + if y, err = config.configMap.Yaml(); err != nil { log.Fatal(err) } - chart.Templates[cm.Filename()] = &ChartTemplate{ + // add the configmap to the chart + d.chart.Templates[config.configMap.Filename()] = &ChartTemplate{ Content: y, - Servicename: service.Name, - } - - // continue with subdirectories - stat, err := os.Stat(v.Source) - if err != nil { - return err - } - if stat.IsDir() { - files, err := filepath.Glob(filepath.Join(v.Source, "*")) - if err != nil { - return err - } - for _, f := range files { - if f == v.Source { - continue - } - if stat, err := os.Stat(f); err != nil || !stat.IsDir() { - continue - } - cm := NewConfigMapFromFiles(service, appName, f) - var err error - var y []byte - if y, err = cm.Yaml(); err != nil { - log.Fatal(err) - } - log.Printf("Adding configmap %s %s", cm.Filename(), f) - chart.Templates[cm.Filename()] = &ChartTemplate{ - Content: y, - Servicename: service.Name, - } - } + Servicename: d.service.Name, + } + // add the moint path to the container + for _, m := range config.mountPath { + 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 } return nil } From 57b274e345271120789ad80d2adba609ced09edc Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 19 Apr 2024 11:17:54 +0200 Subject: [PATCH 39/97] Fixing documentation --- doc/docs/packages/generator.md | 56 +++++++++++++++++++++++----------- doc/docs/usage.md | 14 +++++++-- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index fb824ec..a8ab1a1 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -149,14 +149,14 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap NewConfigMap creates a new ConfigMap from a compose service. The appName is the name of the application taken from the project name. The ConfigMap is filled by environment variables and labels "map\-env". - -### func [NewConfigMapFromFiles]() + +### func [NewConfigMapFromDirectory]() ```go -func NewConfigMapFromFiles(service types.ServiceConfig, appName string, path string) *ConfigMap +func NewConfigMapFromDirectory(service types.ServiceConfig, appName string, path string) *ConfigMap ``` -NewConfigMapFromFiles creates a new ConfigMap from a compose service. This path is the path to the file or directory. If the path is a directory, all files in the directory are added to the ConfigMap. Each subdirectory are ignored. Note that the Generate\(\) function will create the subdirectories ConfigMaps. +NewConfigMapFromDirectory creates a new ConfigMap from a compose service. This path is the path to the file or directory. If the path is a directory, all files in the directory are added to the ConfigMap. Each subdirectory are ignored. Note that the Generate\(\) function will create the subdirectories ConfigMaps. ### func \(\*ConfigMap\) [AddData]() @@ -176,8 +176,17 @@ func (c *ConfigMap) AppendDir(path string) AddFile adds files from given path to the configmap. It is not recursive, to add all files in a directory, you need to call this function for each subdirectory. + +### func \(\*ConfigMap\) [AppendFile]() + +```go +func (c *ConfigMap) AppendFile(path string) +``` + + + -### func \(\*ConfigMap\) [Filename]() +### func \(\*ConfigMap\) [Filename]() ```go func (c *ConfigMap) Filename() string @@ -195,7 +204,7 @@ func (c *ConfigMap) SetData(data map[string]string) SetData sets the data of the configmap. It replaces the entire data. -### func \(\*ConfigMap\) [Yaml]() +### func \(\*ConfigMap\) [Yaml]() ```go func (c *ConfigMap) Yaml() ([]byte, error) @@ -203,6 +212,17 @@ func (c *ConfigMap) Yaml() ([]byte, error) Yaml returns the yaml representation of the configmap + +## type [ConfigMapMount]() + + + +```go +type ConfigMapMount struct { + // contains filtered or unexported fields +} +``` + ## type [ConvertOptions]() @@ -304,7 +324,7 @@ type Dependency struct { ``` -## type [Deployment]() +## type [Deployment]() Deployment is a kubernetes Deployment. @@ -316,7 +336,7 @@ type Deployment struct { ``` -### func [NewDeployment]() +### func [NewDeployment]() ```go func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment @@ -325,7 +345,7 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name. It also creates the Values map that will be used to create the values.yaml file. -### func \(\*Deployment\) [AddContainer]() +### func \(\*Deployment\) [AddContainer]() ```go func (d *Deployment) AddContainer(service types.ServiceConfig) @@ -334,7 +354,7 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) AddContainer adds a container to the deployment. -### func \(\*Deployment\) [AddHealthCheck]() +### func \(\*Deployment\) [AddHealthCheck]() ```go func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) @@ -343,7 +363,7 @@ func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *core -### func \(\*Deployment\) [AddIngress]() +### func \(\*Deployment\) [AddIngress]() ```go func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *Ingress @@ -352,7 +372,7 @@ func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *In AddIngress adds an ingress to the deployment. It creates the ingress object. -### func \(\*Deployment\) [AddVolumes]() +### func \(\*Deployment\) [AddVolumes]() ```go func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) @@ -361,7 +381,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. If the volume is a bind volume it will warn the user that it is not supported yet. -### func \(\*Deployment\) [BindFrom]() +### func \(\*Deployment\) [BindFrom]() ```go func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) @@ -370,7 +390,7 @@ func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) -### func \(\*Deployment\) [DependsOn]() +### func \(\*Deployment\) [DependsOn]() ```go func (d *Deployment) DependsOn(to *Deployment, servicename string) error @@ -379,7 +399,7 @@ func (d *Deployment) DependsOn(to *Deployment, servicename string) error DependsOn adds a initContainer to the deployment that will wait for the service to be up. -### func \(\*Deployment\) [Filename]() +### func \(\*Deployment\) [Filename]() ```go func (d *Deployment) Filename() string @@ -388,7 +408,7 @@ func (d *Deployment) Filename() string Filename returns the filename of the deployment. -### func \(\*Deployment\) [SetEnvFrom]() +### func \(\*Deployment\) [SetEnvFrom]() ```go func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) @@ -397,7 +417,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) SetEnvFrom sets the environment variables to a configmap. The configmap is created. -### func \(\*Deployment\) [Yaml]() +### func \(\*Deployment\) [Yaml]() ```go func (d *Deployment) Yaml() ([]byte, error) @@ -445,7 +465,7 @@ type HelmChart struct { ``` -### func [Generate]() +### func [Generate]() ```go func Generate(project *types.Project) (*HelmChart, error) diff --git a/doc/docs/usage.md b/doc/docs/usage.md index 1808bdb..0b955ef 100644 --- a/doc/docs/usage.md +++ b/doc/docs/usage.md @@ -86,6 +86,7 @@ to make you able to wait for a service to respond. But you'll probably need to a See this compose file: +```yaml version: "3" services: @@ -98,12 +99,14 @@ services: image: mariadb environment: MYSQL_ROOT_PASSWORD: foobar +``` In this case, `webapp` needs to know the `database` port because the `depends_on` points on it and Kubernetes has not (yet) solution to check the database startup. Katenary wants to create a `initContainer` to hit on the related service. So, instead of exposing the port in the compose definition, let's declare this to katenary with labels: +```yaml version: "3" services: @@ -119,6 +122,7 @@ services: labels: katenary.v3/ports: |- - 3306 +``` ### Declare ingresses @@ -126,6 +130,7 @@ It's very common to have an `Ingress` on web application to deploy on Kuberenete port to bind. # ... +```yaml services: webapp: image: ... @@ -134,6 +139,7 @@ services: katenary.v3/ingress: |- port: 5050 hostname: myapp.example.com +``` Note that the port to bind is the one used by the container, not the used locally. This is because Katenary create a service to bind the container itself. @@ -146,6 +152,7 @@ example, to connect a PHP application to a database. With a compose file, there is no problem as Docker/Podman allows to resolve the name by container name: +```yaml services: webapp: image: php:7-apache @@ -154,11 +161,13 @@ services: database: image: mariadb +``` Katenary prefixes the services with `{{ .Release.Name }}` (to make it possible to install the application several times in a namespace), so you need to "remap" the environment variable to the right one. +```yaml services: webapp: image: php:7-apache @@ -170,12 +179,12 @@ services: database: image: mariadb - +``` This label can be used to map others environment for any others reason. E.g. to change an informational environment variable. - +```yaml services: webapp: #... @@ -184,6 +193,7 @@ services: labels: katenary.v3/mapenv: |- RUNNING: kubernetes +``` In the above example, `RUNNING` will be set to `kubernetes` when you'll deploy the application with helm, and it's `docker` for "podman" and "docker" executions. From 77e8be4e63f8bb3a592cc57f11a5a077ebc643db Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 19 Apr 2024 11:27:48 +0200 Subject: [PATCH 40/97] Container can be null In case of deployment with "same-pod" label, the container can be not found in the deployment. --- generator/generator.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/generator/generator.go b/generator/generator.go index 5d18d52..850753f 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -389,6 +389,9 @@ func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map // add the bound configMaps files to the deployment containers for _, d := range deployments { container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) + if container == nil { // may append for the same-pod services + break + } for volumeName, config := range d.configMaps { var y []byte var err error From 85f1b2d43c6d6965cac43c37cbc22e402bd667a9 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 19 Apr 2024 11:28:27 +0200 Subject: [PATCH 41/97] Fixes the ingress doc --- doc/docs/usage.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/doc/docs/usage.md b/doc/docs/usage.md index 0b955ef..dae8059 100644 --- a/doc/docs/usage.md +++ b/doc/docs/usage.md @@ -126,10 +126,11 @@ services: ### Declare ingresses -It's very common to have an `Ingress` on web application to deploy on Kuberenetes. The `katenary.io/ingress` declare the -port to bind. +It's very common to have an Ingress resource on web application to deploy on Kuberenetes. It allows to expose the +service to the outside of the cluster (you need to install an ingress controller). + +Katenary can create this resource for you. You just need to declare the hostname and the port to bind. -# ... ```yaml services: webapp: @@ -137,6 +138,7 @@ services: ports: 8080:5050 labels: katenary.v3/ingress: |- + # the target port is 5050 wich is the "service" port port: 5050 hostname: myapp.example.com ``` From 35f464a1cb0755b574cf57d42d5e1bf47328dd75 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 19 Apr 2024 12:11:18 +0200 Subject: [PATCH 42/97] Add footnotes and search --- doc/mkdocs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/mkdocs.yml b/doc/mkdocs.yml index 81536df..f56f2b9 100644 --- a/doc/mkdocs.yml +++ b/doc/mkdocs.yml @@ -1,6 +1,7 @@ site_name: Katenary documentation docs_dir: ./docs plugins: + - search - inline-svg theme: name: material @@ -19,6 +20,7 @@ theme: name: Switch to dark mode markdown_extensions: - admonition + - footnotes - attr_list - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji @@ -44,6 +46,7 @@ nav: - Behind the scene: - coding.md - dependencies.md + - FAQ: faq.md - Go Packages: - packages/cmd/katenary.md - packages/generator.md From 3bb635a6272599e885533391593864a57f7ffe2a Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 19 Apr 2024 12:11:43 +0200 Subject: [PATCH 43/97] Add FAQ page --- doc/docs/faq.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 doc/docs/faq.md diff --git a/doc/docs/faq.md b/doc/docs/faq.md new file mode 100644 index 0000000..a54805b --- /dev/null +++ b/doc/docs/faq.md @@ -0,0 +1,87 @@ +# Frequently Asked Questions + +## Why Katenary? + +The main author[^1] of Katenary is a big fan of Podman, Docker and makes a huge use of Compose. He uses it a lot in his daily work. When he started to work with Kubernetes, he wanted to have the same experience as with Docker Compose. He wanted to have a tool that could convert his `docker-compose` files to Kubernetes manifests, but also to Helm charts. + +Kompose was a good option. But the lacks of some options and configuration for the output Helm chart made him think about creating a new tool. He wanted to have a tool that could generate a complete Helm chart, with a lot of options and flexibility. + +[^1]: I'm talking about myself :sunglasses: - Patrice FERLET, aka metal3d, Tech Lead and DevOps Engineer at Klee Group. + +## What's the difference between Katenary and Kompose? + +[Kompose](https://kompose.io/) is a very nice tool, made by the Kubernetes community. It's a tool to convert `docker-compose` files to Kubernetes manifests. It's a very good tool, and it's more mature than Katenary. + +Kompose is able to genererate Helm charts, but [it could be not the case in future releases](https://github.com/kubernetes/kompose/issues/1716) for several reasons[^2]. + +[^2]: The author of Kompose explains that they have no bandwidth to maintain the Helm chart generation. It's a complex task, and we can confirm. Katenary takes a lot of time to be developed and maintained. This issue mentions Katenary as an alternative to Helm chart generation :smile: + +The project is focused on Kubernetes manifests and proposes to use "kusomize" to adapt the manifests. Helm seems to be not the priority. + +Anyway, before this decision, the Helm chart generation was not what we expected. We wanted to have a more complete chart, with more options and more flexibility. + +> That's why we decided to create Katenary. + +Kompose didn't manage to generate a values file, complexe volume binding, and many other things. It was also not able to manage dependencies between services. + +> Be sure that we don't want to compete with Kompose. We just want to propose a different approach to the problem. + +Kompose is an excellent tool, and we use it in some projects. It's a good choice if you want to convert your `docker-compose` files to Kubernetes manifests, but if you want to use Helm, Katenary is the tool you need. + +## Why not using "one label" for all the configuration? + +That was a dicsussion I had with my colleagues. The idea was to use a single label to store all the configuration. But, it's not a good idea. + +Sometimes, you will have a long list of things to configure, like ports, ingress, dependecies, etc. It's better to have a clear and readable configuration. Segmented labels are easier to read and to maintain. It also avoids to have too many indentation levels in the YAML file. + +It is also more flexible. You can add or remove labels without changing the others. + +## Why not using a configuration file? + +The idea was to keep the configuration at a same place, and using the go-compose library to read the labels. It's easier to have a single file to manage. + +By the way, Katenary auto accepts a `compose.katenary.yaml` file in the same directory. It's a way to separate the configuration from the compose file. It uses the [overrides mecanism](https://docs.docker.com/compose/multiple-compose-files/merge/) like "compose" does. + + +## Why not developping with Rust? + +Seriously... + +OK, I will answer. + +Rust is a good language. But, Podman, Docker, Kubernetes, Helm, and mostly all technologies around Kubernetes are written in Go. We have a large ecosystem in Go to manipulate, read, and write Kubernetes manifests as parsing Compose files. + +Go is better for this task. + +There is no reason to use Rust for this project. + +## Any chance to have a GUI? + +Yes, it's a possibility. But, it's not a priority. We have a lot of things to do before. We need to stabilize the project, to have a good documentation, to have a good test coverage, and to have a good community. + +But, in a not so far future, we could have a GUI. The choice of [Fyne.io](https://fyne.io) is already made and we tested some concepts. + + +## I'm rich (or not), I want to help you. How can I do? + +You can help us in many ways. + +- The first things we really need, more than money, more than anything else, is to have feedback. If you use Katenary, if you have some issues, if you have some ideas, please open an issue on the [GitHub repository](https://github.com/metal3d/katenary). +- The second things is to help us to fix issues. If you're a Go developper, or if you want to fix the documentation, your help is greatly appreciated. +- And then, of course, we need money, or sponsors. + +### If you're a company + +We will be happy to communicate your help by putting your logo on the website and in the documentaiton. You can sponsor us by giving us some money, or by giving us some time of your developers, or leaving us some time to work on the project. + +### If you're an individual + +All donators will be listed on the website and in the documentation. You can give us some money by using the [GitHub Sponsors]() + +All main contributors[^3] will be listed on the website and in the documentation. + +> If you want to be anonymous, please tell us. + + +[^3]: Main contributors are the people who have made a significant contribution to the project. It could be code, documentation, or any other help. There is no defined rules, at this time, to evaluate the contribution. It's a subjective decision. + From 58d19cce52d572cf13826b06130f6dae50f866ab Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 19 Apr 2024 22:12:09 +0200 Subject: [PATCH 44/97] Fix the chart app version --- generator/deployment.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generator/deployment.go b/generator/deployment.go index ff70224..26e7c7e 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -51,7 +51,7 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { defaultTag := `default "latest"` if isMainApp { - defaultTag = `default .Chart.AppVersion "latest"` + defaultTag = `default .Chart.AppVersion` } chart.Values[service.Name] = NewValue(service, isMainApp) From ec62a79d828a1e92483f09ea53b2c093974927bf Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 19 Apr 2024 22:26:45 +0200 Subject: [PATCH 45/97] Better override list and documentation --- cmd/katenary/main.go | 2 +- parser/main.go | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cmd/katenary/main.go b/cmd/katenary/main.go index 413b1ad..c1b4c5b 100644 --- a/cmd/katenary/main.go +++ b/cmd/katenary/main.go @@ -152,7 +152,7 @@ func generateConvertCommand() *cobra.Command { convertCmd.Flags().BoolVarP(&helmdepUpdate, "helm-update", "u", helmdepUpdate, "Update helm dependencies if helm is installed") convertCmd.Flags().StringSliceVarP(&profiles, "profile", "p", profiles, "Specify the profiles to use") convertCmd.Flags().StringVarP(&outputDir, "output-dir", "o", outputDir, "Specify the output directory") - convertCmd.Flags().StringSliceVarP(&dockerComposeFile, "compose-file", "c", cli.DefaultFileNames, "Specify an alternate compose files - can be specified multiple times or use coma to separate them") + convertCmd.Flags().StringSliceVarP(&dockerComposeFile, "compose-file", "c", cli.DefaultFileNames, "Specify an alternate compose files - can be specified multiple times or use coma to separate them.\nNote that overides files are also used whatever the files you specify here.\nThe overides files are:\n"+strings.Join(cli.DefaultOverrideFileNames, ", \n")+"\n") convertCmd.Flags().StringVarP(&givenAppVersion, "app-version", "a", "", "Specify the app version (in Chart.yaml)") convertCmd.Flags().StringVarP(&chartVersion, "chart-version", "v", chartVersion, "Specify the chart version (in Chart.yaml)") return convertCmd diff --git a/parser/main.go b/parser/main.go index 0507a9c..fe25920 100644 --- a/parser/main.go +++ b/parser/main.go @@ -6,10 +6,23 @@ import ( "github.com/compose-spec/compose-go/types" ) +func init() { + // prepend compose.katenary.yaml to the list of default override file names + cli.DefaultOverrideFileNames = append([]string{ + "compose.katenary.yml", + "compose.katenary.yaml", + }, cli.DefaultOverrideFileNames...) + cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames, + []string{ + "podman-compose.katenary.yml", + "podman-compose.katenary.yaml", + "podman-compose.yml", + "podman-compose.yaml", + }...) +} + // Parse compose files and return a project. The project is parsed with dotenv, osenv and profiles. func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, error) { - cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames, "compose.katenary.yaml") - if len(dockerComposeFile) == 0 { cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames, dockerComposeFile...) } From d48fd2f91188956a35d4cabf5531a8ef8ba42438 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Sun, 21 Apr 2024 16:34:21 +0200 Subject: [PATCH 46/97] make a better override + add more values (serviceAccount, nodeSelector...) --- generator/deployment.go | 55 +++++++++++++++++++++++++++++++++++------ generator/values.go | 2 ++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/generator/deployment.go b/generator/deployment.go index 26e7c7e..b674cef 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -81,6 +81,11 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { ObjectMeta: metav1.ObjectMeta{ Labels: GetMatchLabels(service.Name, appName), }, + Spec: corev1.PodSpec{ + NodeSelector: map[string]string{ + "katenary.v3/node-selector": "replace", + }, + }, }, }, }, @@ -161,6 +166,9 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) { Name: `{{ .Values.pullSecrets | toYaml | indent __indent__ }}`, }} + // add ServiceAccount to the deployment + d.Spec.Template.Spec.ServiceAccountName = `{{ .Values.` + service.Name + `.serviceAccount | quote }}` + d.AddHealthCheck(service, &container) d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, container) @@ -539,24 +547,55 @@ func (d *Deployment) Yaml() ([]byte, error) { } // for impagePullSecrets, replace the name with the value from values.yaml - inpullsecrets := false for i, line := range content { if strings.Contains(line, "imagePullSecrets:") { - inpullsecrets = true - } - if inpullsecrets && strings.Contains(line, "- name: ") && inpullsecrets { - line = strings.Replace(line, "- name: ", "", 1) - line = strings.ReplaceAll(line, "'", "") + spaces = strings.Repeat(" ", utils.CountStartingSpaces(line)) + line = spaces + "{{- if .Values.pullSecrets }}" + line += "\n" + spaces + "imagePullSecrets:\n" + line += spaces + "{{- .Values.pullSecrets | toYaml | nindent __indent__ }}" + line += "\n" + spaces + "{{- end }}" content[i] = line - inpullsecrets = false } } // Find the replicas line and replace it with the value from values.yaml for i, line := range content { + // manage nodeSelector + if strings.Contains(line, "nodeSelector:") { + spaces = strings.Repeat(" ", utils.CountStartingSpaces(line)) + pre := spaces + `{{- if .Values.` + serviceName + `.nodeSelector }}` + post := spaces + "{{- end }}" + ns := spaces + "nodeSelector:\n" + ns += spaces + ` {{- .Values.` + serviceName + `.nodeSelector | toYaml | nindent __indent__ }}` + //line = strings.Replace(line, "katenary.v3/node-selector: replace", ns, 1) + line = pre + "\n" + ns + "\n" + post + } + // manage replicas if strings.Contains(line, "replicas:") { line = regexp.MustCompile("replicas: .*$").ReplaceAllString(line, "replicas: {{ .Values."+serviceName+".replicas }}") - content[i] = line + } + + // manage serviceAccount, add condition to use the serviceAccount from values.yaml + if strings.Contains(line, "serviceAccountName:") { + spaces = strings.Repeat(" ", utils.CountStartingSpaces(line)) + pre := spaces + `{{- if ne .Values.` + serviceName + `.serviceAccount "" }}` + post := spaces + "{{- end }}" + line = strings.ReplaceAll(line, "'", "") + line = pre + "\n" + line + "\n" + post + } + + content[i] = line + } + + // find the katenary.v3/node-selector line, and remove it + for i, line := range content { + if strings.Contains(line, "katenary.v3/node-selector") { + content = append(content[:i], content[i+1:]...) + continue + } + if strings.Contains(line, "- name: '{{ .Values.pullSecrets ") { + content = append(content[:i], content[i+1:]...) + continue } } diff --git a/generator/values.go b/generator/values.go index 055a6b3..725db51 100644 --- a/generator/values.go +++ b/generator/values.go @@ -57,6 +57,8 @@ type Value struct { Environment map[string]any `yaml:"environment,omitempty"` Replicas *uint32 `yaml:"replicas,omitempty"` CronJob *CronJobValue `yaml:"cronjob,omitempty"` + NodeSelector map[string]string `yaml:"nodeSelector"` + ServiceAccount string `yaml:"serviceAccount"` } // CronJobValue is a cronjob configuration that will be saved in values.yaml. From 9826a541874d82d913052de48799e93d14504f06 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Sun, 21 Apr 2024 16:35:32 +0200 Subject: [PATCH 47/97] Fix notes.txt problems We were using a bad method to read the ingress values. It's not ensured by using the service names + checking the "ingress" key. --- generator/converter.go | 6 +++++- generator/extrafiles/notes.go | 28 ++++++++++++++++++++++---- generator/extrafiles/notes.tpl | 36 ++++++++++++++++++++++------------ generator/helmHelper.tpl | 4 ++-- 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/generator/converter.go b/generator/converter.go index d7eb267..71b9bd3 100644 --- a/generator/converter.go +++ b/generator/converter.go @@ -242,7 +242,11 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { f.Write([]byte(readme)) f.Close() - notes := extrafiles.NotesFile() + services := make([]string, 0) + for _, service := range project.Services { + services = append(services, service.Name) + } + notes := extrafiles.NotesFile(services) f, err = os.Create(notesPath) if err != nil { fmt.Println(utils.IconFailure, err) diff --git a/generator/extrafiles/notes.go b/generator/extrafiles/notes.go index 373e7ee..c2662e5 100644 --- a/generator/extrafiles/notes.go +++ b/generator/extrafiles/notes.go @@ -1,11 +1,31 @@ package extrafiles -import _ "embed" +import ( + _ "embed" + "fmt" + "strings" +) //go:embed notes.tpl var notesTemplate string -// NoteTXTFile returns the content of the note.txt file. -func NotesFile() string { - return notesTemplate +// NotesFile returns the content of the note.txt file. +func NotesFile(services []string) string { + // build a list of ingress URLs if there are any + ingresses := make([]string, len(services)) + for i, service := range services { + condition := fmt.Sprintf(`{{- if and .Values.%[1]s.ingress .Values.%[1]s.ingress.enabled }}`, service) + line := fmt.Sprintf(`{{- $count = add1 $count -}}{{- $listOfURL = printf "%%s\n- http://%%s" $listOfURL .Values.%s.ingress.host -}}`, service) + ingresses[i] = fmt.Sprintf("%s\n%s\n{{- end }}", condition, line) + } + + // inject the list of ingress URLs into the notes template + notes := strings.Split(notesTemplate, "\n") + for i, line := range notes { + if strings.Contains(line, "ingress_list") { + notes[i] = strings.Join(ingresses, "\n") + } + } + + return strings.Join(notes, "\n") } diff --git a/generator/extrafiles/notes.tpl b/generator/extrafiles/notes.tpl index 3121a00..3c527d8 100644 --- a/generator/extrafiles/notes.tpl +++ b/generator/extrafiles/notes.tpl @@ -1,27 +1,39 @@ -Your release is named {{ .Release.Name }}. +Thanks to have installed {{ .Chart.Name }} {{ .Chart.Version }} as {{ .Release.Name }} ({{.Chart.AppVersion }}). + +# Get release information To learn more about the release, try: $ helm -n {{ .Release.Namespace }} status {{ .Release.Name }} + $ helm -n {{ .Release.Namespace }} get values {{ .Release.Name }} $ helm -n {{ .Release.Namespace }} get all {{ .Release.Name }} -To delete the release, run: +# To delete the release - $ helm -n {{ .Release.Namespace }} delete {{ .Release.Name }} +Use helm uninstall command to delete the release. + + $ helm -n {{ .Release.Namespace }} uninstall {{ .Release.Name }} + +Note that some resources may still be in use after a release is deleted. For exemple, PersistentVolumeClaims are not deleted by default for some storage classes or if some annotations are set. + +# More information You can see this notes again by running: $ helm -n {{ .Release.Namespace }} get notes {{ .Release.Name }} {{- $count := 0 -}} -{{- range $s, $v := .Values -}} -{{- if and $v $v.ingress -}} -{{- $count = add $count 1 -}} -{{- if eq $count 1 }} +{{- $listOfURL := "" -}} +{{* DO NOT REMOVE, replaced by notes.go: ingress_list *}} +{{- if gt $count 0 }} -The ingress list is: -{{ end }} - - {{ $s }}: http://{{ $v.ingress.host }}{{ $v.ingress.path }} -{{- end -}} -{{ end -}} +# List of activated ingresses URL: +{{ $listOfURL }} +You can get these urls with kubectl: + + kubeclt get ingress -n {{ .Release.Namespace }} + +{{- end }} + +Thanks for using Helm! diff --git a/generator/helmHelper.tpl b/generator/helmHelper.tpl index 8d40010..f1b2515 100644 --- a/generator/helmHelper.tpl +++ b/generator/helmHelper.tpl @@ -22,10 +22,10 @@ {{- define "__APP__.labels" -}} {{ include "__APP__.selectorLabels" .}} {{ if .Chart.Version -}} -{{ printf "__PREFIX__chart-version: %s" .Chart.Version }} +{{ printf "__PREFIX__chart-version: '%s'" .Chart.Version }} {{- end }} {{ if .Chart.AppVersion -}} -{{ printf "__PREFIX__app-version: %s" .Chart.AppVersion }} +{{ printf "__PREFIX__app-version: '%s'" .Chart.AppVersion }} {{- end }} {{- end -}} From f291d17aa31aa516cc08c492544a4bade66e882c Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Sun, 21 Apr 2024 16:37:20 +0200 Subject: [PATCH 48/97] Fixup documentation after changing packages --- doc/docs/packages/generator.md | 30 ++++++++++++----------- doc/docs/packages/generator/extrafiles.md | 6 ++--- doc/docs/packages/parser.md | 2 +- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index a8ab1a1..acbced0 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -274,7 +274,7 @@ Yaml returns the yaml representation of the cronjob. Implements the Yaml interface. -## type [CronJobValue]() +## type [CronJobValue]() CronJobValue is a cronjob configuration that will be saved in values.yaml. @@ -345,7 +345,7 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name. It also creates the Values map that will be used to create the values.yaml file. -### func \(\*Deployment\) [AddContainer]() +### func \(\*Deployment\) [AddContainer]() ```go func (d *Deployment) AddContainer(service types.ServiceConfig) @@ -354,7 +354,7 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) AddContainer adds a container to the deployment. -### func \(\*Deployment\) [AddHealthCheck]() +### func \(\*Deployment\) [AddHealthCheck]() ```go func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) @@ -363,7 +363,7 @@ func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *core -### func \(\*Deployment\) [AddIngress]() +### func \(\*Deployment\) [AddIngress]() ```go func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *Ingress @@ -372,7 +372,7 @@ func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *In AddIngress adds an ingress to the deployment. It creates the ingress object. -### func \(\*Deployment\) [AddVolumes]() +### func \(\*Deployment\) [AddVolumes]() ```go func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) @@ -381,7 +381,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. If the volume is a bind volume it will warn the user that it is not supported yet. -### func \(\*Deployment\) [BindFrom]() +### func \(\*Deployment\) [BindFrom]() ```go func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) @@ -390,7 +390,7 @@ func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) -### func \(\*Deployment\) [DependsOn]() +### func \(\*Deployment\) [DependsOn]() ```go func (d *Deployment) DependsOn(to *Deployment, servicename string) error @@ -399,7 +399,7 @@ func (d *Deployment) DependsOn(to *Deployment, servicename string) error DependsOn adds a initContainer to the deployment that will wait for the service to be up. -### func \(\*Deployment\) [Filename]() +### func \(\*Deployment\) [Filename]() ```go func (d *Deployment) Filename() string @@ -408,7 +408,7 @@ func (d *Deployment) Filename() string Filename returns the filename of the deployment. -### func \(\*Deployment\) [SetEnvFrom]() +### func \(\*Deployment\) [SetEnvFrom]() ```go func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) @@ -417,7 +417,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) SetEnvFrom sets the environment variables to a configmap. The configmap is created. -### func \(\*Deployment\) [Yaml]() +### func \(\*Deployment\) [Yaml]() ```go func (d *Deployment) Yaml() ([]byte, error) @@ -855,7 +855,7 @@ func (r *ServiceAccount) Yaml() ([]byte, error) -## type [Value]() +## type [Value]() Value will be saved in values.yaml. It contains configuraiton for all deployment and services. The content will be lile: @@ -885,11 +885,13 @@ type Value struct { Environment map[string]any `yaml:"environment,omitempty"` Replicas *uint32 `yaml:"replicas,omitempty"` CronJob *CronJobValue `yaml:"cronjob,omitempty"` + NodeSelector map[string]string `yaml:"nodeSelector"` + ServiceAccount string `yaml:"serviceAccount"` } ``` -### func [NewValue]() +### func [NewValue]() ```go func NewValue(service types.ServiceConfig, main ...bool) *Value @@ -900,7 +902,7 @@ NewValue creates a new Value from a compose service. The value contains the nece If \`main\` is true, the tag will be empty because it will be set in the helm chart appVersion. -### func \(\*Value\) [AddIngress]() +### func \(\*Value\) [AddIngress]() ```go func (v *Value) AddIngress(host, path string) @@ -909,7 +911,7 @@ func (v *Value) AddIngress(host, path string) -### func \(\*Value\) [AddPersistence]() +### func \(\*Value\) [AddPersistence]() ```go func (v *Value) AddPersistence(volumeName string) diff --git a/doc/docs/packages/generator/extrafiles.md b/doc/docs/packages/generator/extrafiles.md index a0a6b84..aa76efe 100644 --- a/doc/docs/packages/generator/extrafiles.md +++ b/doc/docs/packages/generator/extrafiles.md @@ -8,13 +8,13 @@ import "katenary/generator/extrafiles" extrafiles package provides function to generate the Chart files that are not objects. Like README.md and notes.txt... -## func [NotesFile]() +## func [NotesFile]() ```go -func NotesFile() string +func NotesFile(services []string) string ``` -NoteTXTFile returns the content of the note.txt file. +NotesFile returns the content of the note.txt file. ## func [ReadMeFile]() diff --git a/doc/docs/packages/parser.md b/doc/docs/packages/parser.md index 1acfc81..834b50a 100644 --- a/doc/docs/packages/parser.md +++ b/doc/docs/packages/parser.md @@ -8,7 +8,7 @@ import "katenary/parser" Parser package is a wrapper around compose\-go to parse compose files. -## func [Parse]() +## func [Parse]() ```go func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, error) From 96214933435457ef2abd1af6aeb0713b4b36869a Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 22 Apr 2024 13:27:44 +0200 Subject: [PATCH 49/97] Add resources in containers and values --- generator/deployment.go | 13 +++++++++++++ generator/values.go | 1 + 2 files changed, 14 insertions(+) diff --git a/generator/deployment.go b/generator/deployment.go index b674cef..bfe5bfe 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -153,6 +153,9 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) { Ports: ports, Name: service.Name, ImagePullPolicy: corev1.PullIfNotPresent, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{}, + }, } if _, ok := d.chart.Values[service.Name]; !ok { d.chart.Values[service.Name] = NewValue(service, d.isMainApp) @@ -584,6 +587,16 @@ func (d *Deployment) Yaml() ([]byte, error) { line = pre + "\n" + line + "\n" + post } + if strings.Contains(line, "resources: {}") { + spaces = strings.Repeat(" ", utils.CountStartingSpaces(line)) + pre := spaces + `{{- if .Values.` + serviceName + `.resources }}` + post := spaces + "{{- end }}" + + line = strings.ReplaceAll(line, "resources: {}", "resources:") + line += "\n" + spaces + " {{ .Values." + serviceName + ".resources | toYaml | nindent __indent__ }}" + line = pre + "\n" + line + "\n" + post + } + content[i] = line } diff --git a/generator/values.go b/generator/values.go index 725db51..7db405f 100644 --- a/generator/values.go +++ b/generator/values.go @@ -59,6 +59,7 @@ type Value struct { CronJob *CronJobValue `yaml:"cronjob,omitempty"` NodeSelector map[string]string `yaml:"nodeSelector"` ServiceAccount string `yaml:"serviceAccount"` + Resources map[string]any `yaml:"resources"` } // CronJobValue is a cronjob configuration that will be saved in values.yaml. From 8ae9350d31199831074d8e0f6e57d92d2a7f3d3e Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 22 Apr 2024 13:28:22 +0200 Subject: [PATCH 50/97] Add YAML keys in the comments I eases developpers and admins to know the key to override when they create an override file or to use `--set` argument for Helm. --- generator/converter.go | 84 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/generator/converter.go b/generator/converter.go index 71b9bd3..bec529f 100644 --- a/generator/converter.go +++ b/generator/converter.go @@ -215,6 +215,8 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { values = addImagePullPolicyHelp(values) values = addVariablesDoc(values, project) values = addMainTagAppDoc(values, project) + values = addResourceHelp(values) + values = addYAMLSelectorPath(values) values = append([]byte(headerHelp), values...) f, err = os.Create(valuesPath) @@ -274,7 +276,6 @@ const ingressClassHelp = `# Default value for ingress.class annotation # If the value is "", Ingress will be set to an empty string, so # controller will use the default value for ingressClass # If the value is specified, controller will set the named class e.g. "nginx" -# More info: https://kubernetes.io/docs/concepts/services-networking/ingress/#the-ingress-resource ` func addCommentsToValues(values []byte) []byte { @@ -297,7 +298,6 @@ const storageClassHelp = `# Storage class to use for PVCs # storageClass: "-" means use default # storageClass: "" means do not specify # storageClass: "foo" means use that storageClass -# More info: https://kubernetes.io/docs/concepts/storage/storage-classes/ ` // addStorageClassHelp adds a comment to the values.yaml file to explain how to @@ -385,7 +385,6 @@ const imagePullSecretHelp = ` # pullSecrets: # - name: regcred # You are, for now, repsonsible for creating the secret. -# More info: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ ` func addImagePullSecretsHelp(values []byte) []byte { @@ -447,7 +446,6 @@ const imagePullPolicyHelp = `# imagePullPolicy allows you to specify a policy to # - Always -> will always pull the image # - Never -> will never pull the image, the image should be present on the node # - IfNotPresent -> will pull the image only if it is not present on the node -# More info: https://kubernetes.io/docs/concepts/containers/images/#updating-images ` func addImagePullPolicyHelp(values []byte) []byte { @@ -467,6 +465,36 @@ func addImagePullPolicyHelp(values []byte) []byte { return []byte(strings.Join(lines, "\n")) } +const resourceHelp = `# Resources allows you to specify the resource requests and limits for a service. +# Resources are used to specify the amount of CPU and memory that +# a container needs. +# +# e.g. +# resources: +# requests: +# memory: "64Mi" +# cpu: "250m" +# limits: +# memory: "128Mi" +# cpu: "500m" +` + +func addResourceHelp(values []byte) []byte { + lines := strings.Split(string(values), "\n") + for i, line := range lines { + if strings.Contains(line, "resources:") { + spaces := utils.CountStartingSpaces(line) + spacesString := strings.Repeat(" ", spaces) + // indent resourceHelp comment + resourceHelp := strings.ReplaceAll(resourceHelp, "\n", "\n"+spacesString) + resourceHelp = strings.TrimRight(resourceHelp, " ") + resourceHelp = spacesString + resourceHelp + lines[i] = resourceHelp + line + } + } + return []byte(strings.Join(lines, "\n")) +} + func addVariablesDoc(values []byte, project *types.Project) []byte { lines := strings.Split(string(values), "\n") @@ -636,3 +664,51 @@ func helmLint(config ConvertOptions) error { cmd.Stderr = os.Stderr return cmd.Run() } + +// keyRegExp checks if the line starts by a # +var keyRegExp = regexp.MustCompile(`^\s*[^#]+:.*`) + +// addYAMLSelectorPath adds a selector path to the yaml file for each key +// as comment. E.g. foo.ingress.host +func addYAMLSelectorPath(values []byte) []byte { + lines := strings.Split(string(values), "\n") + currentKey := "" + currentLevel := 0 + toReturn := []string{} + for _, line := range lines { + // if the line is a not a key, continue + if !keyRegExp.MatchString(line) { + toReturn = append(toReturn, line) + continue + } + // get the key + key := strings.TrimSpace(strings.Split(line, ":")[0]) + + // get the spaces + spaces := utils.CountStartingSpaces(line) + + if spaces/2 > currentLevel { + currentLevel++ + } else if spaces/2 < currentLevel { + currentLevel-- + } + currentKey = strings.Join(strings.Split(currentKey, ".")[:spaces/2], ".") + + if currentLevel == 0 { + currentKey = key + toReturn = append(toReturn, line) + continue + } + // if the key is not empty, add the selector path + if currentKey != "" { + currentKey += "." + } + currentKey += key + // add the selector path as comment + toReturn = append( + toReturn, + strings.Repeat(" ", spaces)+"# key: "+currentKey+"\n"+line, + ) + } + return []byte(strings.Join(toReturn, "\n")) +} From fd3ba6d577642af279d3ea8b46411287967950e9 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 22 Apr 2024 13:31:30 +0200 Subject: [PATCH 51/97] Fix doc after changes in source files --- doc/docs/packages/generator.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index acbced0..39632c5 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -274,7 +274,7 @@ Yaml returns the yaml representation of the cronjob. Implements the Yaml interface. -## type [CronJobValue]() +## type [CronJobValue]() CronJobValue is a cronjob configuration that will be saved in values.yaml. @@ -354,7 +354,7 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) AddContainer adds a container to the deployment. -### func \(\*Deployment\) [AddHealthCheck]() +### func \(\*Deployment\) [AddHealthCheck]() ```go func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) @@ -363,7 +363,7 @@ func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *core -### func \(\*Deployment\) [AddIngress]() +### func \(\*Deployment\) [AddIngress]() ```go func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *Ingress @@ -372,7 +372,7 @@ func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *In AddIngress adds an ingress to the deployment. It creates the ingress object. -### func \(\*Deployment\) [AddVolumes]() +### func \(\*Deployment\) [AddVolumes]() ```go func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) @@ -381,7 +381,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. If the volume is a bind volume it will warn the user that it is not supported yet. -### func \(\*Deployment\) [BindFrom]() +### func \(\*Deployment\) [BindFrom]() ```go func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) @@ -399,7 +399,7 @@ func (d *Deployment) DependsOn(to *Deployment, servicename string) error DependsOn adds a initContainer to the deployment that will wait for the service to be up. -### func \(\*Deployment\) [Filename]() +### func \(\*Deployment\) [Filename]() ```go func (d *Deployment) Filename() string @@ -408,7 +408,7 @@ func (d *Deployment) Filename() string Filename returns the filename of the deployment. -### func \(\*Deployment\) [SetEnvFrom]() +### func \(\*Deployment\) [SetEnvFrom]() ```go func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) @@ -417,7 +417,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) SetEnvFrom sets the environment variables to a configmap. The configmap is created. -### func \(\*Deployment\) [Yaml]() +### func \(\*Deployment\) [Yaml]() ```go func (d *Deployment) Yaml() ([]byte, error) @@ -855,7 +855,7 @@ func (r *ServiceAccount) Yaml() ([]byte, error) -## type [Value]() +## type [Value]() Value will be saved in values.yaml. It contains configuraiton for all deployment and services. The content will be lile: @@ -887,11 +887,12 @@ type Value struct { CronJob *CronJobValue `yaml:"cronjob,omitempty"` NodeSelector map[string]string `yaml:"nodeSelector"` ServiceAccount string `yaml:"serviceAccount"` + Resources map[string]any `yaml:"resources"` } ``` -### func [NewValue]() +### func [NewValue]() ```go func NewValue(service types.ServiceConfig, main ...bool) *Value @@ -902,7 +903,7 @@ NewValue creates a new Value from a compose service. The value contains the nece If \`main\` is true, the tag will be empty because it will be set in the helm chart appVersion. -### func \(\*Value\) [AddIngress]() +### func \(\*Value\) [AddIngress]() ```go func (v *Value) AddIngress(host, path string) @@ -911,7 +912,7 @@ func (v *Value) AddIngress(host, path string) -### func \(\*Value\) [AddPersistence]() +### func \(\*Value\) [AddPersistence]() ```go func (v *Value) AddPersistence(volumeName string) From 78d37c440563db0d8e95506555893c5d3df3b303 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 22 Apr 2024 13:55:53 +0200 Subject: [PATCH 52/97] Ease installation --- install.sh | 66 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/install.sh b/install.sh index 52de7ec..15da7fa 100644 --- a/install.sh +++ b/install.sh @@ -10,48 +10,56 @@ set -e OS=$(uname) ARCH=$(uname -m) -# Detect where to install the binary, local path is the prefered method -INSTALL_TYPE=$(echo $PATH | grep "$HOME/.local/bin" 2>&1 >/dev/null && echo "local" || echo "global") +# Detect the home directory "bin" directory, it is commonly: +# - $HOME/.local/bin +# - $HOME/.bin +# - $HOME/bin +COMON_INSTALL_PATHS="$HOME/.local/bin $HOME/.bin $HOME/bin" + +INSTALL_PATH="" +for p in $COMON_INSTALL_PATHS; do + if [ -d $p ]; then + INSTALL_PATH=$p + break + fi +done + +# check if the user has write access to the INSTALL_PATH +if [ -z "$INSTALL_PATH" ]; then + INSTALL_PATH="/usr/local/bin" + if [ ! -w $INSTALL_PATH ]; then + echo "You don't have write access to $INSTALL_PATH" + echo "Please, run with sudo or install locally" + exit 1 + fi +fi + +# ensure that $INSTALL_PATH is in the PATH +if ! echo $PATH | grep -q $INSTALL_PATH; then + echo "Sorry, $INSTALL_PATH is not in the PATH" + echo "Please, add it to your PATH in your shell configuration file" + echo "then restart your shell and run this script again" + exit 1 +fi # Where to download the binary BASE="https://github.com/metal3d/katenary/releases/latest/download/" - +# for compatibility with older ARM versions if [ $ARCH = "x86_64" ]; then ARCH="amd64" fi BIN_URL="$BASE/katenary-$OS-$ARCH" -if [ "$INSTALL_TYPE" = "local" ]; then - echo "Installing to local directory, installing in $HOME/.local/bin" - BIN_PATH="$HOME/.local/bin" -else - echo "Installing to global directory, installing in /usr/local/bin - we need to use sudo..." - answer="" - while [ "$answer" != "y" ] && [ "$answer" != "n" ]; do - echo -n "Are you OK? [y/N] " - read answer - # lower case answer - answer=$(echo $answer | tr '[:upper:]' '[:lower:]') - if [ "$answer" == "n" ] || [ -z "$answer" ]; then - echo "--> To install locally, please ensure that \$HOME/.local/bin is in your PATH" - echo "Cancelling installation" - exit 0 - fi - done - BIN_PATH="/usr/local/bin" -fi - echo echo "Downloading $BIN_URL" -USE_SUDO=$([ "$INSTALL_TYPE" = "local" ] && echo "" || echo "sudo") T=$(mktemp -u) -$USE_SUDO curl -SL -# $BIN_URL -o $T || (echo "Failed to download katenary" && rm -f $T && exit 1) +curl -SL -# $BIN_URL -o $T || (echo "Failed to download katenary" && rm -f $T && exit 1) -$USE_SUDO mv $T $BIN_PATH/katenary -$USE_SUDO chmod +x $BIN_PATH/katenary +mv $T $INSTALL_PATH/katenary +chmod +x $INSTALL_PATH/katenary echo -echo "Installed to $BIN_PATH/katenary" -echo "Installation complete! Run 'katenary --help' to get started." +echo "Installed to $INSTALL_PATH/katenary" +echo "Installation complete! Run 'katenary help' to get started." From 734b0ed39d80780ee67f6e2f52e9635310739dc4 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 22 Apr 2024 15:17:28 +0200 Subject: [PATCH 53/97] Try to embed logos --- README.md | 151 +++++++++++++++++++++----------------- doc/docs/statics/klee.svg | 1 + logo.svg | 19 +++++ 3 files changed, 103 insertions(+), 68 deletions(-) create mode 100644 doc/docs/statics/klee.svg create mode 100644 logo.svg diff --git a/README.md b/README.md index 51f3b0c..b8d3ab0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@
- Katenary Logo +Katenary Logo
+[![Documentation Status](https://readthedocs.org/projects/katenary/badge/?version=latest)](https://katenary.readthedocs.io/en/latest/?badge=latest) +[![Go Report Card](https://goreportcard.com/badge/github.com/metal3d/katenary)](https://goreportcard.com/report/github.com/metal3d/katenary) +[![GitHub release](https://img.shields.io/github/v/release/metal3d/katenary)](https://github.com/metal3d/katenary/releases) 🚀 Unleash Productivity with Katenary! 🚀 @@ -21,6 +24,18 @@ Katenary is a tool to help to transform `docker-compose` files to a working Helm > 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. + +Today, it's partially developped in collaboration with [Klee Group](https://www.kleegroup.com). Note that Katenary is +and **will stay an opensource and free (as freedom) project**. We are convinced that the best way to make it better is to +share it with the community. + + +
+Katenary Logo +
+ +The main developer is [Patrice FERLET](https://github.com/metal3d). + # Install You can download the binaries from the [Release](https://github.com/metal3d/katenary/releases) section. Copy the binary @@ -84,25 +99,25 @@ source <(katenary completion bash --no-description) source <(katenary completion zsh) # fish in ~/.config/fish/config.fish -katenary completion fish | source + katenary completion fish | source # powershell (as we don't provide any support on Windows yet, please avoid this...) -``` + ``` # Usage -``` -Katenary is a tool to convert compose files to Helm Charts. + ``` + Katenary is a tool to convert compose files to Helm Charts. -Each [command] and subcommand has got an "help" and "--help" flag to show more information. + Each [command] and subcommand has got an "help" and "--help" flag to show more information. -Usage: + Usage: katenary [command] -Examples: + Examples: katenary convert -c docker-compose.yml -o ./charts -Available Commands: + Available Commands: completion Generates completion scripts convert Converts a docker-compose file to a Helm Chart hash-composefiles Print the hash of the composefiles @@ -110,77 +125,77 @@ Available Commands: help-labels Print the labels help for all or a specific label version Print the version number of Katenary -Flags: + Flags: -h, --help help for katenary -v, --version version for katenary -Use "katenary [command] --help" for more information about a command. -``` + Use "katenary [command] --help" for more information about a command. + ``` -Katenary will try to find a `docker-compose.yaml` or `docker-compose.yml` file inside the current directory. It will -check *the existence of the `chart` directory to create a new Helm Chart inside a named subdirectory. Katenary will ask -you if you want to delete it before recreating. + Katenary will try to find a `docker-compose.yaml` or `docker-compose.yml` file inside the current directory. It will + check *the existence of the `chart` directory to create a new Helm Chart inside a named subdirectory. Katenary will ask + you if you want to delete it before recreating. It creates a subdirectory inside `chart` that is named with the `appname` option (default is `MyApp`) -> To respect the ability to install the same application in the same namespace, Katenary will create "variable" names -> like `{{ .Release.Name }}-servicename`. So, you will need to use some labels inside your docker-compose file to help -> katenary to build a correct helm chart. + > To respect the ability to install the same application in the same namespace, Katenary will create "variable" names + > like `{{ .Release.Name }}-servicename`. So, you will need to use some labels inside your docker-compose file to help + > katenary to build a correct helm chart. -What can be interpreted by Katenary: + 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 + - 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 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, see examples below + - `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, see examples below -Exemple of a possible `docker-compose.yaml` file: + Exemple of a possible `docker-compose.yaml` file: -```yaml -version: "3" -services: - webapp: - image: php:7-apache - environment: - # note that "database" is a service name - DB_HOST: database - expose: - - 80 - depends_on: - # this will create a init container waiting for 3306 port - # because it's the "exposed" port - - database - labels: - # expose the port 80 as an ingress - katenary.v3/ingress: |- - hostname: myapp.example.com - port: 80 - # make adaptations, DB_HOST environment is actually the service name - # to hit (note the yaml style, start with "|") - katenary.v3/mapenv: |- - DB_HOST: '{{ .Release.Name }}-database' - database: - image: mariadb:10 - env_file: - # this will create a configMap - - my_env.env - environment: - MARIADB_USER: foo - MARIADB_ROOT_PASSWORD: foobar - MARIADB_PASSWORD: bar - labels: - # no need to declare this port in docker-compose - # but katenary will need it - katenary.v3/ports: |- - - 3306 - # these variables are secrets - katenary.v3/secrets: |- - - MARIADB_ROOT_PASSWORD - - MARIADB_PASSWORD + ```yaml + version: "3" + services: +webapp: +image: php:7-apache +environment: +# note that "database" is a service name +DB_HOST: database +expose: +- 80 +depends_on: +# this will create a init container waiting for 3306 port +# because it's the "exposed" port +- database +labels: +# expose the port 80 as an ingress +katenary.v3/ingress: |- +hostname: myapp.example.com +port: 80 +# make adaptations, DB_HOST environment is actually the service name +# to hit (note the yaml style, start with "|") +katenary.v3/mapenv: |- +DB_HOST: '{{ .Release.Name }}-database' +database: +image: mariadb:10 +env_file: +# this will create a configMap +- my_env.env +environment: +MARIADB_USER: foo +MARIADB_ROOT_PASSWORD: foobar +MARIADB_PASSWORD: bar +labels: +# no need to declare this port in docker-compose +# but katenary will need it +katenary.v3/ports: |- +- 3306 +# these variables are secrets +katenary.v3/secrets: |- +- MARIADB_ROOT_PASSWORD +- MARIADB_PASSWORD ``` # Labels diff --git a/doc/docs/statics/klee.svg b/doc/docs/statics/klee.svg new file mode 100644 index 0000000..6fa9706 --- /dev/null +++ b/doc/docs/statics/klee.svg @@ -0,0 +1 @@ + diff --git a/logo.svg b/logo.svg new file mode 100644 index 0000000..575cd40 --- /dev/null +++ b/logo.svg @@ -0,0 +1,19 @@ + + +
+ + +
+
+
From cd946b2df695b5ca1b5397f3168a3dfa6f07d4b1 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 22 Apr 2024 15:20:29 +0200 Subject: [PATCH 54/97] Try another thing --- logo.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logo.svg b/logo.svg index 575cd40..2e9268f 100644 --- a/logo.svg +++ b/logo.svg @@ -13,7 +13,7 @@ } - +
From 6770f8176af8176677b7656d6ad8138a2687b07b Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 22 Apr 2024 15:29:32 +0200 Subject: [PATCH 55/97] Make pure svg calls --- logo.svg | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/logo.svg b/logo.svg index 2e9268f..ba4c4cb 100644 --- a/logo.svg +++ b/logo.svg @@ -1,19 +1,8 @@ - -
- -
-
+
From dc41826691e464434e3c0e6d32798541a507240c Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 22 Apr 2024 15:32:39 +0200 Subject: [PATCH 56/97] Back to normal --- README.md | 5 ----- logo.svg | 8 -------- 2 files changed, 13 deletions(-) delete mode 100644 logo.svg diff --git a/README.md b/README.md index b8d3ab0..418b438 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,6 @@ Today, it's partially developped in collaboration with [Klee Group](https://www. and **will stay an opensource and free (as freedom) project**. We are convinced that the best way to make it better is to share it with the community. - -
-Katenary Logo -
- The main developer is [Patrice FERLET](https://github.com/metal3d). # Install diff --git a/logo.svg b/logo.svg deleted file mode 100644 index ba4c4cb..0000000 --- a/logo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - From 12814f47323ae22c9a63036b76aa43a5d2ee3fe5 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 22 Apr 2024 15:36:49 +0200 Subject: [PATCH 57/97] Cleanup --- README.md | 122 +++++++++++++++++++++++++++--------------------------- 1 file changed, 60 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 418b438..21794d5 100644 --- a/README.md +++ b/README.md @@ -101,31 +101,31 @@ source <(katenary completion zsh) # Usage - ``` - Katenary is a tool to convert compose files to Helm Charts. +``` +Katenary is a tool to convert compose files to Helm Charts. - Each [command] and subcommand has got an "help" and "--help" flag to show more information. +Each [command] and subcommand has got an "help" and "--help" flag to show more information. - Usage: - katenary [command] +Usage: +katenary [command] - Examples: - katenary convert -c docker-compose.yml -o ./charts +Examples: +katenary convert -c docker-compose.yml -o ./charts - Available Commands: - completion Generates completion scripts - convert Converts a docker-compose file to a Helm Chart - hash-composefiles Print the hash of the composefiles - help Help about any command - help-labels Print the labels help for all or a specific label - version Print the version number of Katenary +Available Commands: +completion Generates completion scripts +convert Converts a docker-compose file to a Helm Chart +hash-composefiles Print the hash of the composefiles +help Help about any command +help-labels Print the labels help for all or a specific label +version Print the version number of Katenary - Flags: - -h, --help help for katenary - -v, --version version for katenary +Flags: +-h, --help help for katenary +-v, --version version for katenary - Use "katenary [command] --help" for more information about a command. - ``` +Use "katenary [command] --help" for more information about a command. +``` Katenary will try to find a `docker-compose.yaml` or `docker-compose.yml` file inside the current directory. It will check *the existence of the `chart` directory to create a new Helm Chart inside a named subdirectory. Katenary will ask @@ -150,47 +150,47 @@ to Helm Chart because there is (for now) no way to make it working (see below fo Exemple of a possible `docker-compose.yaml` file: - ```yaml - version: "3" - services: -webapp: -image: php:7-apache -environment: -# note that "database" is a service name -DB_HOST: database -expose: -- 80 -depends_on: -# this will create a init container waiting for 3306 port -# because it's the "exposed" port -- database -labels: -# expose the port 80 as an ingress -katenary.v3/ingress: |- -hostname: myapp.example.com -port: 80 -# make adaptations, DB_HOST environment is actually the service name -# to hit (note the yaml style, start with "|") -katenary.v3/mapenv: |- -DB_HOST: '{{ .Release.Name }}-database' -database: -image: mariadb:10 -env_file: -# this will create a configMap -- my_env.env -environment: -MARIADB_USER: foo -MARIADB_ROOT_PASSWORD: foobar -MARIADB_PASSWORD: bar -labels: -# no need to declare this port in docker-compose -# but katenary will need it -katenary.v3/ports: |- -- 3306 -# these variables are secrets -katenary.v3/secrets: |- -- MARIADB_ROOT_PASSWORD -- MARIADB_PASSWORD +```yaml +services: + webapp: + image: php:7-apache + environment: + # note that "database" is a "compose" service name + # so we need to adapt it with the map-env label + DB_HOST: database + expose: + - 80 + depends_on: + # this will create a init container waiting for 3306 port + # because it's the "exposed" port + - database + labels: + # expose the port 80 as an ingress + katenary.v3/ingress: |- + hostname: myapp.example.com + port: 80 + katenary.v3/mapenv: |- + # make adaptations, DB_HOST environment is actually the service name + DB_HOST: '{{ .Release.Name }}-database' + + database: + image: mariadb:10 + env_file: + # this will create a configMap + - my_env.env + environment: + MARIADB_USER: foo + MARIADB_ROOT_PASSWORD: foobar + MARIADB_PASSWORD: bar + labels: + # no need to declare this port in docker-compose + # but katenary will need it + katenary.v3/ports: |- + - 3306 + # these variables are secrets + katenary.v3/secrets: |- + - MARIADB_ROOT_PASSWORD + - MARIADB_PASSWORD ``` # Labels @@ -225,5 +225,3 @@ A catenary is a curve formed by a wire, rope, or chain hanging freely from two p line. For example, the anchor chain between a boat and the anchor. This "curved link" represents what we try to do, the project is a "streched link from docker-compose to helm chart". - - From 7e8cb579795bdcaf9015e6bd577f09c7238bbfc7 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 22 Apr 2024 15:43:02 +0200 Subject: [PATCH 58/97] Fixes --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 21794d5..52f1f44 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,11 @@ source <(katenary completion bash --no-description) source <(katenary completion zsh) # fish in ~/.config/fish/config.fish - katenary completion fish | source +katenary completion fish | source +# experimental # powershell (as we don't provide any support on Windows yet, please avoid this...) - ``` +``` # Usage @@ -159,7 +160,7 @@ services: # so we need to adapt it with the map-env label DB_HOST: database expose: - - 80 + - 80 depends_on: # this will create a init container waiting for 3306 port # because it's the "exposed" port From 50975ae94ab3b48c65b1e24391c9e2cb38d32f38 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 08:05:00 +0200 Subject: [PATCH 59/97] Fix static volume binding It is possible there are many things like this to fix. I made too much complexity on searching services in deployment while the map key is enough to get the righ deployment for a compose service. Need to check the "same-pod" possibilities later. --- generator/deployment.go | 38 +++++++++-------- generator/generator.go | 90 ++++++++++++++++++++++++----------------- 2 files changed, 74 insertions(+), 54 deletions(-) diff --git a/generator/deployment.go b/generator/deployment.go index bfe5bfe..2ee7f37 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -89,7 +89,7 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { }, }, }, - configMaps: map[string]*ConfigMapMount{}, + configMaps: make(map[string]*ConfigMapMount), } // add containers @@ -204,6 +204,10 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { } container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) + defer func(d *Deployment, container *corev1.Container, index int) { + d.Spec.Template.Spec.Containers[index] = *container + }(d, container, index) + for _, volume := range service.Volumes { // not declared as a bind volume, skip if _, ok := tobind[volume.Source]; !isSamePod && volume.Type == "bind" && !ok { @@ -279,35 +283,35 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { } } else { dirname := filepath.Dir(volume.Source) - pathnme := utils.PathToName(dirname) + pathname := utils.PathToName(dirname) var cm *ConfigMap - if v, ok := d.configMaps[pathnme]; !ok { + if v, ok := d.configMaps[pathname]; !ok { cm = NewConfigMap(*d.service, appName) cm.usage = FileMapUsageFiles cm.path = dirname - cm.Name = utils.TplName(service.Name, appName) + "-" + pathnme - d.configMaps[pathnme] = &ConfigMapMount{ + cm.Name = utils.TplName(service.Name, appName) + "-" + pathname + // assign a new mountPathConfig to the configMap + d.configMaps[pathname] = &ConfigMapMount{ configMap: cm, - mountPath: []mountPathConfig{}, + mountPath: []mountPathConfig{{ + mountPath: volume.Target, + subPath: filepath.Base(volume.Source), + }}, } } else { cm = v.configMap - } - - cm.AppendFile(volume.Source) - d.configMaps[pathnme] = &ConfigMapMount{ - configMap: cm, - mountPath: append(d.configMaps[pathnme].mountPath, mountPathConfig{ + mp := d.configMaps[pathname].mountPath + mp = append(mp, mountPathConfig{ mountPath: volume.Target, subPath: filepath.Base(volume.Source), - }), + }) + d.configMaps[pathname].mountPath = mp + } + cm.AppendFile(volume.Source) } } - } - - d.Spec.Template.Spec.Containers[index] = *container } func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) { @@ -496,7 +500,7 @@ func (d *Deployment) Yaml() ([]byte, error) { continue } - if strings.Contains(volume, "- mountPath: ") { + if strings.Contains(volume, "mountPath: ") { spaces = strings.Repeat(" ", utils.CountStartingSpaces(volume)) content[line] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + volumeName + `.enabled }}` + "\n" + volume changing = true diff --git a/generator/generator.go b/generator/generator.go index 850753f..f08e237 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -114,11 +114,16 @@ func Generate(project *types.Project) (*HelmChart, error) { // 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) + // bind static volumes + for _, service := range project.Services { + addStaticVolumes(deployments, service) + } for _, service := range project.Services { if err := buildVolumes(service, chart, deployments); err != nil { return nil, err } } + // drop all "same-pod" deployments because the containers and volumes are already // in the target deployment for _, service := range podToMerge { @@ -156,7 +161,10 @@ func Generate(project *types.Project) (*HelmChart, error) { // generate yaml files for _, d := range deployments { - y, _ := d.Yaml() + y, err := d.Yaml() + if err != nil { + return nil, err + } chart.Templates[d.Filename()] = &ChartTemplate{ Content: y, Servicename: d.service.Name, @@ -386,47 +394,55 @@ func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map } } - // add the bound configMaps files to the deployment containers - for _, d := range deployments { - container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) - if container == nil { // may append for the same-pod services - break - } - for volumeName, config := range d.configMaps { - var y []byte - var err error - if y, err = config.configMap.Yaml(); err != nil { - log.Fatal(err) - } - // 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 { - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: utils.PathToName(volumeName), - MountPath: m.mountPath, - SubPath: m.subPath, - }) - } + return nil +} - 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, - }, - }, - }, +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 { + log.Printf("service %s not found in deployments", service.Name) + return + } + + container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) + 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 { + log.Fatal(err) + } + // 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 { + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: utils.PathToName(volumeName), + MountPath: m.mountPath, + SubPath: m.subPath, }) } - d.Spec.Template.Spec.Containers[index] = *container + 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, + }, + }, + }, + }) } - return nil + + d.Spec.Template.Spec.Containers[index] = *container } // generateConfigMapsAndSecrets creates the configmaps and secrets from the environment variables. From d1186ee1e137dfa0635c7d7fa45c66753d5ce484 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 08:07:20 +0200 Subject: [PATCH 60/97] Refresh doc --- doc/docs/packages/generator.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index 39632c5..182d729 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -354,7 +354,7 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) AddContainer adds a container to the deployment. -### func \(\*Deployment\) [AddHealthCheck]() +### func \(\*Deployment\) [AddHealthCheck]() ```go func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) @@ -381,7 +381,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. If the volume is a bind volume it will warn the user that it is not supported yet. -### func \(\*Deployment\) [BindFrom]() +### func \(\*Deployment\) [BindFrom]() ```go func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) @@ -399,7 +399,7 @@ func (d *Deployment) DependsOn(to *Deployment, servicename string) error DependsOn adds a initContainer to the deployment that will wait for the service to be up. -### func \(\*Deployment\) [Filename]() +### func \(\*Deployment\) [Filename]() ```go func (d *Deployment) Filename() string @@ -408,7 +408,7 @@ func (d *Deployment) Filename() string Filename returns the filename of the deployment. -### func \(\*Deployment\) [SetEnvFrom]() +### func \(\*Deployment\) [SetEnvFrom]() ```go func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) @@ -417,7 +417,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) SetEnvFrom sets the environment variables to a configmap. The configmap is created. -### func \(\*Deployment\) [Yaml]() +### func \(\*Deployment\) [Yaml]() ```go func (d *Deployment) Yaml() ([]byte, error) From 49c1fa5fb0134c7fbb36aaaed3bb2c6cef2358f7 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 10:10:58 +0200 Subject: [PATCH 61/97] Explain what happens --- generator/deployment.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/generator/deployment.go b/generator/deployment.go index 2ee7f37..8129e41 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -282,6 +282,9 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { }), } } else { + // In case of a file, add it to the configmap and use "subPath" to mount it + // Note that the volumes and volume mounts are not added to the deployment yet, they will be added later + // in generate.go dirname := filepath.Dir(volume.Source) pathname := utils.PathToName(dirname) var cm *ConfigMap @@ -290,7 +293,6 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { cm.usage = FileMapUsageFiles cm.path = dirname cm.Name = utils.TplName(service.Name, appName) + "-" + pathname - // assign a new mountPathConfig to the configMap d.configMaps[pathname] = &ConfigMapMount{ configMap: cm, mountPath: []mountPathConfig{{ From c31299197f0148f3dfafa451a8cbc66daf8d0086 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 10:13:38 +0200 Subject: [PATCH 62/97] Remove the bats tests That was interesting but finally not so useful. We will make better tests in Go. The hard part will be to make them working in CI/CD. --- .gitmodules | 9 --------- test/bats | 1 - test/examples.bats | 9 --------- test/test_helper/bats-assert | 1 - test/test_helper/bats-support | 1 - 5 files changed, 21 deletions(-) delete mode 160000 test/bats delete mode 100644 test/examples.bats delete mode 160000 test/test_helper/bats-assert delete mode 160000 test/test_helper/bats-support diff --git a/.gitmodules b/.gitmodules index b7efcb4..e69de29 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +0,0 @@ -[submodule "test/bats"] - path = test/bats - url = https://github.com/bats-core/bats-core.git -[submodule "test/test_helper/bats-support"] - path = test/test_helper/bats-support - url = https://github.com/bats-core/bats-support.git -[submodule "test/test_helper/bats-assert"] - path = test/test_helper/bats-assert - url = https://github.com/bats-core/bats-assert.git diff --git a/test/bats b/test/bats deleted file mode 160000 index e9fd17a..0000000 --- a/test/bats +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e9fd17a70721e447313691f239d297cecea6dfb7 diff --git a/test/examples.bats b/test/examples.bats deleted file mode 100644 index 4151f15..0000000 --- a/test/examples.bats +++ /dev/null @@ -1,9 +0,0 @@ -@test "generating and linting examples" { - for d in $(find ../examples/ -maxdepth 1 -mindepth 1 -type d); do - pushd $d - rm -rf chart - go run ../../cmd/katenary convert -f - helm lint chart - popd - done -} diff --git a/test/test_helper/bats-assert b/test/test_helper/bats-assert deleted file mode 160000 index e2d855b..0000000 --- a/test/test_helper/bats-assert +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e2d855bc78619ee15b0c702b5c30fb074101159f diff --git a/test/test_helper/bats-support b/test/test_helper/bats-support deleted file mode 160000 index 9bf10e8..0000000 --- a/test/test_helper/bats-support +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9bf10e876dd6b624fe44423f0b35e064225f7556 From 6a7fedee7ed2f832e7ff4ddd5ca89211feae4bb6 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 14:24:06 +0200 Subject: [PATCH 63/97] We shouldn't quote encoded values Quoting before encoding in base64 adds the quotes in the encoded data. That's a bad behavior. --- generator/secret.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generator/secret.go b/generator/secret.go index 6a64b8d..16b7eb1 100644 --- a/generator/secret.go +++ b/generator/secret.go @@ -88,7 +88,7 @@ func (s *Secret) AddData(key string, value string) { if value == "" { return } - s.Data[key] = []byte(`{{ tpl ` + value + ` $ | quote | b64enc }}`) + s.Data[key] = []byte(`{{ tpl ` + value + ` $ | b64enc }}`) } // Yaml returns the yaml representation of the secret. From 77de53c999e9a0e72b769d627517db9f1c65d837 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 14:26:23 +0200 Subject: [PATCH 64/97] Create more tests --- generator/basic_test.go | 57 ----------------------------- generator/configMap_test.go | 42 ++++++++++++++++++++++ generator/deployment_test.go | 69 ++++++++++++++++++++++++++++++++++++ generator/ingress_test.go | 44 +++++++++++++++++++++++ generator/secret_test.go | 45 +++++++++++++++++++++++ generator/service_test.go | 49 +++++++++++++++++++++++++ generator/tools_test.go | 44 ++++++++++++++--------- 7 files changed, 276 insertions(+), 74 deletions(-) delete mode 100644 generator/basic_test.go create mode 100644 generator/configMap_test.go create mode 100644 generator/deployment_test.go create mode 100644 generator/ingress_test.go create mode 100644 generator/secret_test.go create mode 100644 generator/service_test.go diff --git a/generator/basic_test.go b/generator/basic_test.go deleted file mode 100644 index 1c649a8..0000000 --- a/generator/basic_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package generator - -import ( - "log" - "os" - "testing" - - "sigs.k8s.io/yaml" -) - -func setup(content string) string { - // write the _compose_file in temporary directory - tmpDir, err := os.MkdirTemp("", "katenary") - if err != nil { - panic(err) - } - os.WriteFile(tmpDir+"/compose.yml", []byte(content), 0o644) - return tmpDir -} - -func teardown(tmpDir string) { - // remove the temporary directory - log.Println("Removing temporary directory: ", tmpDir) - if err := os.RemoveAll(tmpDir); err != nil { - panic(err) - } -} - -func TestGenerate(t *testing.T) { - _compose_file := ` -services: - web: - image: nginx:1.29 -` - tmpDir := setup(_compose_file) - defer teardown(tmpDir) - - currentDir, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(currentDir) - - output := _compile_test(t) - - dt := DeploymentTest{} - if err := yaml.Unmarshal([]byte(output), &dt); err != nil { - t.Errorf("Failed to unmarshal the output: %s", err) - } - - if dt.Spec.Replicas != 1 { - t.Errorf("Expected replicas to be 1, got %d", dt.Spec.Replicas) - t.Errorf("Output: %s", output) - } - - if dt.Spec.Template.Spec.Containers[0].Image != "nginx:1.29" { - t.Errorf("Expected image to be nginx:1.29, got %s", dt.Spec.Template.Spec.Containers[0].Image) - } -} diff --git a/generator/configMap_test.go b/generator/configMap_test.go new file mode 100644 index 0000000..29fc922 --- /dev/null +++ b/generator/configMap_test.go @@ -0,0 +1,42 @@ +package generator + +import ( + "os" + "testing" + + v1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +func TestEnvInConfigMap(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + environment: + - FOO=bar + - BAR=baz +` + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/configmap.yaml") + configMap := v1.ConfigMap{} + if err := yaml.Unmarshal([]byte(output), &configMap); err != nil { + t.Errorf("Failed to unmarshal the output: %s", err) + } + data := configMap.Data + if len(data) != 2 { + t.Errorf("Expected 2 data, got %d", len(data)) + } + if data["FOO"] != "bar" { + t.Errorf("Expected FOO to be bar, got %s", data["FOO"]) + } + if data["BAR"] != "baz" { + t.Errorf("Expected BAR to be baz, got %s", data["BAR"]) + } +} diff --git a/generator/deployment_test.go b/generator/deployment_test.go new file mode 100644 index 0000000..b6a57e8 --- /dev/null +++ b/generator/deployment_test.go @@ -0,0 +1,69 @@ +package generator + +import ( + "os" + "testing" + + v1 "k8s.io/api/apps/v1" + "sigs.k8s.io/yaml" +) + +func TestGenerate(t *testing.T) { + _compose_file := ` +services: + web: + image: nginx:1.29 +` + tmpDir := setup(_compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + + // dt := DeploymentTest{} + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf("Failed to unmarshal the output: %s", err) + } + + if *dt.Spec.Replicas != 1 { + t.Errorf("Expected replicas to be 1, got %d", dt.Spec.Replicas) + t.Errorf("Output: %s", output) + } + + if dt.Spec.Template.Spec.Containers[0].Image != "nginx:1.29" { + t.Errorf("Expected image to be nginx:1.29, got %s", dt.Spec.Template.Spec.Containers[0].Image) + } +} + +func TestGenerateWithBoundVolume(t *testing.T) { + _compose_file := ` +services: + web: + image: nginx:1.29 + volumes: + - data:/var/www +volumes: + data: +` + tmpDir := setup(_compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf("Failed to unmarshal the output: %s", err) + } + + if dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name != "data" { + t.Errorf("Expected volume name to be data: %v", dt) + } +} diff --git a/generator/ingress_test.go b/generator/ingress_test.go new file mode 100644 index 0000000..0e05b93 --- /dev/null +++ b/generator/ingress_test.go @@ -0,0 +1,44 @@ +package generator + +import ( + "fmt" + "os" + "testing" + + v1 "k8s.io/api/networking/v1" + "sigs.k8s.io/yaml" +) + +func TestSimpleIngress(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + - 443:443 + labels: + %singress: |- + host: my.test.tld + port: 80 +` + composeFile = fmt.Sprintf(composeFile, KATENARY_PREFIX) + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/ingress.yaml", "--set", "web.ingress.enabled=true") + ingress := v1.Ingress{} + if err := yaml.Unmarshal([]byte(output), &ingress); err != nil { + t.Errorf("Failed to unmarshal the output: %s", err) + } + if len(ingress.Spec.Rules) != 1 { + t.Errorf("Expected 1 rule, got %d", len(ingress.Spec.Rules)) + } + if ingress.Spec.Rules[0].Host != "my.test.tld" { + t.Errorf("Expected host to be my.test.tld, got %s", ingress.Spec.Rules[0].Host) + } +} diff --git a/generator/secret_test.go b/generator/secret_test.go new file mode 100644 index 0000000..1399a0c --- /dev/null +++ b/generator/secret_test.go @@ -0,0 +1,45 @@ +package generator + +import ( + "fmt" + "os" + "testing" + + v1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +func TestCreateSecretFromEnvironment(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + environment: + - FOO=bar + - BAR=baz + labels: + %ssecrets: |- + - BAR +` + composeFile = fmt.Sprintf(composeFile, KATENARY_PREFIX) + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/secret.yaml") + secret := v1.Secret{} + if err := yaml.Unmarshal([]byte(output), &secret); err != nil { + t.Errorf("Failed to unmarshal the output: %s", err) + } + data := secret.Data + if len(data) != 1 { + t.Errorf("Expected 1 data, got %d", len(data)) + } + // v1.Secret.Data is decoded, no problem + if string(data["BAR"]) != "baz" { + t.Errorf("Expected BAR to be baz, got %s", data["BAR"]) + } +} diff --git a/generator/service_test.go b/generator/service_test.go new file mode 100644 index 0000000..4b5ab24 --- /dev/null +++ b/generator/service_test.go @@ -0,0 +1,49 @@ +package generator + +import ( + "os" + "testing" + + v1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +func TestBasicService(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + - 443:443 + ` + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/service.yaml") + service := v1.Service{} + if err := yaml.Unmarshal([]byte(output), &service); err != nil { + t.Errorf("Failed to unmarshal the output: %s", err) + } + + if len(service.Spec.Ports) != 2 { + t.Errorf("Expected 2 ports, got %d", len(service.Spec.Ports)) + } + + foundPort := 0 + for _, port := range service.Spec.Ports { + if port.Port == 80 && port.TargetPort.StrVal == "http" { + foundPort++ + } + if port.Port == 443 && port.TargetPort.StrVal == "https" { + foundPort++ + } + } + if foundPort != 2 { + t.Errorf("Expected 2 ports, got %d", foundPort) + } +} diff --git a/generator/tools_test.go b/generator/tools_test.go index 02b0f25..7d8caad 100644 --- a/generator/tools_test.go +++ b/generator/tools_test.go @@ -1,29 +1,36 @@ package generator import ( + "log" + "os" "os/exec" "testing" "katenary/parser" ) -type DeploymentTest struct { - Spec struct { - Replicas int `yaml:"replicas"` - Template struct { - Spec struct { - Containers []struct { - Image string `yaml:"image"` - } `yaml:"containers"` - } `yaml:"spec"` - } `yaml:"template"` - } `yaml:"spec"` +func setup(content string) string { + // write the _compose_file in temporary directory + tmpDir, err := os.MkdirTemp("", "katenary") + if err != nil { + panic(err) + } + os.WriteFile(tmpDir+"/compose.yml", []byte(content), 0o644) + return tmpDir } -func _compile_test(t *testing.T) string { +func teardown(tmpDir string) { + // remove the temporary directory + log.Println("Removing temporary directory: ", tmpDir) + if err := os.RemoveAll(tmpDir); err != nil { + panic(err) + } +} + +func _compile_test(t *testing.T, options ...string) string { _, err := parser.Parse(nil, "compose.yml") if err != nil { - t.Errorf("Failed to parse the project: %s", err) + t.Fatalf("Failed to parse the project: %s", err) } force := false @@ -47,15 +54,18 @@ func _compile_test(t *testing.T) string { } // try with helm template var output string - if output, err = helmTemplate(convertOptions); err != nil { + if output, err = helmTemplate(convertOptions, options...); err != nil { t.Errorf("Failed to template the generated chart") - t.Errorf("Output: %s", output) + t.Fatalf("Output %s", output) } return output } -func helmTemplate(options ConvertOptions) (string, error) { - cmd := exec.Command("helm", "template", options.OutputDir) +func helmTemplate(options ConvertOptions, arguments ...string) (string, error) { + args := []string{"template", options.OutputDir} + args = append(args, arguments...) + + cmd := exec.Command("helm", args...) output, err := cmd.CombinedOutput() if err != nil { return string(output), err From 46c878b56e957384a3a250179cc0d99d87cd0f2f Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 14:42:55 +0200 Subject: [PATCH 65/97] Add coverage files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index aaa7e66..39a20af 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ docker-compose* .credentials release.id configs/ +cover.html +cover.out +.sq +katenary From 8b01807568d3efefb0940a1f7d4e98cda739e25e Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 14:43:57 +0200 Subject: [PATCH 66/97] Add workflows --- .github/workflows/go-test.yaml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/go-test.yaml diff --git a/.github/workflows/go-test.yaml b/.github/workflows/go-test.yaml new file mode 100644 index 0000000..a4700e1 --- /dev/null +++ b/.github/workflows/go-test.yaml @@ -0,0 +1,24 @@ +name: Go-Tests + +on: + push: + branches: + - master + - develop +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.21 + - name: Install Helm + run: | + curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 + chmod 700 get_helm.sh + ./get_helm.sh + - name: Launch Test + run: | + go test -v ./... From 2e3dd5032ffb9a653d8fa628b7de92405d17d8c0 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 14:54:42 +0200 Subject: [PATCH 67/97] Add codecov --- .github/workflows/go-test.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go-test.yaml b/.github/workflows/go-test.yaml index a4700e1..4e146a2 100644 --- a/.github/workflows/go-test.yaml +++ b/.github/workflows/go-test.yaml @@ -21,4 +21,12 @@ jobs: ./get_helm.sh - name: Launch Test run: | - go test -v ./... + go vet ./... && go test -coverprofile=coverout.out -v ./... + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: metal3d/katenary + file: ./coverout.out + fail_ci_if_error: true + From c01cdf50c8a8cb2cdd792b005a1cdaa2135edeca Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 14:59:20 +0200 Subject: [PATCH 68/97] Only on PR and on push to master --- .github/workflows/go-test.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go-test.yaml b/.github/workflows/go-test.yaml index 4e146a2..4f8f87f 100644 --- a/.github/workflows/go-test.yaml +++ b/.github/workflows/go-test.yaml @@ -1,10 +1,12 @@ name: Go-Tests on: + pull_request: + branches: + - develop push: branches: - master - - develop jobs: test: runs-on: ubuntu-latest From a3e74355445a270bdc48a3da7b2940fae26b9e81 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 15:18:34 +0200 Subject: [PATCH 69/97] Add test for static volumes And moved a test from deployment_test that was not the right place to be created. --- generator/deployment_test.go | 29 ---------- generator/volume_test.go | 100 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 29 deletions(-) create mode 100644 generator/volume_test.go diff --git a/generator/deployment_test.go b/generator/deployment_test.go index b6a57e8..ba0a1d6 100644 --- a/generator/deployment_test.go +++ b/generator/deployment_test.go @@ -38,32 +38,3 @@ services: t.Errorf("Expected image to be nginx:1.29, got %s", dt.Spec.Template.Spec.Containers[0].Image) } } - -func TestGenerateWithBoundVolume(t *testing.T) { - _compose_file := ` -services: - web: - image: nginx:1.29 - volumes: - - data:/var/www -volumes: - data: -` - tmpDir := setup(_compose_file) - defer teardown(tmpDir) - - currentDir, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(currentDir) - - output := _compile_test(t, "-s", "templates/web/deployment.yaml") - - dt := v1.Deployment{} - if err := yaml.Unmarshal([]byte(output), &dt); err != nil { - t.Errorf("Failed to unmarshal the output: %s", err) - } - - if dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name != "data" { - t.Errorf("Expected volume name to be data: %v", dt) - } -} diff --git a/generator/volume_test.go b/generator/volume_test.go new file mode 100644 index 0000000..a0320eb --- /dev/null +++ b/generator/volume_test.go @@ -0,0 +1,100 @@ +package generator + +import ( + "fmt" + "os" + "testing" + + v1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +func TestGenerateWithBoundVolume(t *testing.T) { + _compose_file := ` +services: + web: + image: nginx:1.29 + volumes: + - data:/var/www +volumes: + data: +` + tmpDir := setup(_compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf("Failed to unmarshal the output: %s", err) + } + + if dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name != "data" { + t.Errorf("Expected volume name to be data: %v", dt) + } +} + +func TestWithStaticFiles(t *testing.T) { + _compose_file := ` +services: + web: + image: nginx:1.29 + volumes: + - ./static:/var/www + labels: + %sconfigmap-files: |- + - ./static +` + _compose_file = fmt.Sprintf(_compose_file, KATENARY_PREFIX) + tmpDir := setup(_compose_file) + defer teardown(tmpDir) + + // create a static directory with an index.html file + staticDir := tmpDir + "/static" + os.Mkdir(staticDir, 0o755) + indexFile, err := os.Create(staticDir + "/index.html") + if err != nil { + t.Errorf("Failed to create index.html: %s", err) + } + indexFile.WriteString("

Hello, World!

") + indexFile.Close() + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf("Failed to unmarshal the output: %s", err) + } + // get the volume mount path + volumeMountPath := dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].MountPath + if volumeMountPath != "/var/www" { + t.Errorf("Expected volume mount path to be /var/www, got %s", volumeMountPath) + } + + // read the configMap + output, err = helmTemplate(ConvertOptions{ + OutputDir: tmpDir + "/chart", + }, "-s", "templates/web/statics/static/configmap.yaml") + if err != nil { + t.Errorf("Failed to run helm template: %s", err) + } + configMap := corev1.ConfigMap{} + if err := yaml.Unmarshal([]byte(output), &configMap); err != nil { + t.Errorf("Failed to unmarshal the output: %s", err) + } + data := configMap.Data + if len(data) != 1 { + t.Errorf("Expected 1 data, got %d", len(data)) + } + if data["index.html"] != "

Hello, World!

" { + t.Errorf("Expected index.html to be

Hello, World!

, got %s", data["index.html"]) + } +} From e0c18ec2adf26cb719e40a9e461281e21797338f Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 23 Apr 2024 15:45:31 +0200 Subject: [PATCH 70/97] Avoid repetitions --- generator/configMap_test.go | 2 +- generator/deployment_test.go | 2 +- generator/ingress_test.go | 2 +- generator/secret_test.go | 2 +- generator/service_test.go | 2 +- generator/tools_test.go | 2 ++ generator/volume_test.go | 6 +++--- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/generator/configMap_test.go b/generator/configMap_test.go index 29fc922..6a29373 100644 --- a/generator/configMap_test.go +++ b/generator/configMap_test.go @@ -27,7 +27,7 @@ services: output := _compile_test(t, "-s", "templates/web/configmap.yaml") configMap := v1.ConfigMap{} if err := yaml.Unmarshal([]byte(output), &configMap); err != nil { - t.Errorf("Failed to unmarshal the output: %s", err) + t.Errorf(unmarshalError, err) } data := configMap.Data if len(data) != 2 { diff --git a/generator/deployment_test.go b/generator/deployment_test.go index ba0a1d6..f9df258 100644 --- a/generator/deployment_test.go +++ b/generator/deployment_test.go @@ -26,7 +26,7 @@ services: // dt := DeploymentTest{} dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { - t.Errorf("Failed to unmarshal the output: %s", err) + t.Errorf(unmarshalError, err) } if *dt.Spec.Replicas != 1 { diff --git a/generator/ingress_test.go b/generator/ingress_test.go index 0e05b93..ccdc080 100644 --- a/generator/ingress_test.go +++ b/generator/ingress_test.go @@ -33,7 +33,7 @@ services: output := _compile_test(t, "-s", "templates/web/ingress.yaml", "--set", "web.ingress.enabled=true") ingress := v1.Ingress{} if err := yaml.Unmarshal([]byte(output), &ingress); err != nil { - t.Errorf("Failed to unmarshal the output: %s", err) + t.Errorf(unmarshalError, err) } if len(ingress.Spec.Rules) != 1 { t.Errorf("Expected 1 rule, got %d", len(ingress.Spec.Rules)) diff --git a/generator/secret_test.go b/generator/secret_test.go index 1399a0c..fb30ebb 100644 --- a/generator/secret_test.go +++ b/generator/secret_test.go @@ -32,7 +32,7 @@ services: output := _compile_test(t, "-s", "templates/web/secret.yaml") secret := v1.Secret{} if err := yaml.Unmarshal([]byte(output), &secret); err != nil { - t.Errorf("Failed to unmarshal the output: %s", err) + t.Errorf(unmarshalError, err) } data := secret.Data if len(data) != 1 { diff --git a/generator/service_test.go b/generator/service_test.go index 4b5ab24..1bc7a89 100644 --- a/generator/service_test.go +++ b/generator/service_test.go @@ -27,7 +27,7 @@ services: output := _compile_test(t, "-s", "templates/web/service.yaml") service := v1.Service{} if err := yaml.Unmarshal([]byte(output), &service); err != nil { - t.Errorf("Failed to unmarshal the output: %s", err) + t.Errorf(unmarshalError, err) } if len(service.Spec.Ports) != 2 { diff --git a/generator/tools_test.go b/generator/tools_test.go index 7d8caad..b0fd7fa 100644 --- a/generator/tools_test.go +++ b/generator/tools_test.go @@ -9,6 +9,8 @@ import ( "katenary/parser" ) +const unmarshalError = "Failed to unmarshal the output: %s" + func setup(content string) string { // write the _compose_file in temporary directory tmpDir, err := os.MkdirTemp("", "katenary") diff --git a/generator/volume_test.go b/generator/volume_test.go index a0320eb..86659a8 100644 --- a/generator/volume_test.go +++ b/generator/volume_test.go @@ -31,7 +31,7 @@ volumes: dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { - t.Errorf("Failed to unmarshal the output: %s", err) + t.Errorf(unmarshalError, err) } if dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name != "data" { @@ -71,7 +71,7 @@ services: output := _compile_test(t, "-s", "templates/web/deployment.yaml") dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { - t.Errorf("Failed to unmarshal the output: %s", err) + t.Errorf(unmarshalError, err) } // get the volume mount path volumeMountPath := dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].MountPath @@ -88,7 +88,7 @@ services: } configMap := corev1.ConfigMap{} if err := yaml.Unmarshal([]byte(output), &configMap); err != nil { - t.Errorf("Failed to unmarshal the output: %s", err) + t.Errorf(unmarshalError, err) } data := configMap.Data if len(data) != 1 { From 531756d8ea6005bb92dc3e47993dbb147b2a4b27 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 24 Apr 2024 13:48:01 +0200 Subject: [PATCH 71/97] Use sonarcloud --- .github/workflows/go-test.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go-test.yaml b/.github/workflows/go-test.yaml index 4f8f87f..3ff2696 100644 --- a/.github/workflows/go-test.yaml +++ b/.github/workflows/go-test.yaml @@ -7,6 +7,7 @@ on: push: branches: - master + - develop jobs: test: runs-on: ubuntu-latest @@ -23,12 +24,17 @@ jobs: ./get_helm.sh - name: Launch Test run: | - go vet ./... && go test -coverprofile=coverout.out -v ./... + go vet ./... && go test -coverprofile=coverprofile.out -json -v ./... > gotest.json + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} slug: metal3d/katenary - file: ./coverout.out + file: ./coverprofile.out fail_ci_if_error: true From 8f4d69d6e216307aa47bdb9983da6c88404ffee0 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 24 Apr 2024 13:51:25 +0200 Subject: [PATCH 72/97] Add sonar profile --- sonar-project.properties | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..951e123 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,17 @@ +sonar.projectKey=metal3d_katenary +sonar.organization=metal3d + + +sonar.go.tests.reportPaths=gotest.json +sonar.go.coverage.reportPaths=coverprofile.out + +# This is the name and version displayed in the SonarCloud UI. +#sonar.projectName=katenary +#sonar.projectVersion=1.0 + + +# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. +#sonar.sources=. + +# Encoding of the source code. Default is default system encoding +#sonar.sourceEncoding=UTF-8 From 39d63c11b112ebce6a385d9be9391830e56345e1 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 24 Apr 2024 13:51:46 +0200 Subject: [PATCH 73/97] Remove coverage files --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 39a20af..d2c7bb2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ docker-compose* .credentials release.id configs/ -cover.html -cover.out +cover.* .sq katenary From 98c7c6ddc15e3d47f40ddc70246a8669db8dba11 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 24 Apr 2024 13:57:06 +0200 Subject: [PATCH 74/97] Use latest Go compiler --- go.mod | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 4612b1a..393337e 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module katenary // github.com/metal3d/katenary -go 1.21 +go 1.22 -toolchain go1.21.8 +toolchain go1.22.2 require ( github.com/compose-spec/compose-go v1.20.2 From f73d598bb461a220ad945e5797272aa35be20f14 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 24 Apr 2024 13:59:21 +0200 Subject: [PATCH 75/97] Standardization - changed variables that was uppercased, that's not OK for linters - cleanup some documentation - remove the "/" in label prefix, a function is now used to get the complete label (`labelName()`) - some cleanup in tpl files, and so on... --- .gitignore | 4 +- cmd/katenary/main.go | 2 +- doc/docs/index.md | 16 ++++- doc/docs/labels.md | 30 ++++---- doc/docs/packages/generator.md | 120 +++++++++++-------------------- generator/configMap.go | 6 +- generator/converter.go | 10 +-- generator/cronJob.go | 2 +- generator/deployment.go | 23 +++--- generator/generator.go | 28 ++++---- generator/globals.go | 2 +- generator/helmHelper.tpl | 8 +-- generator/helper.go | 2 +- generator/ingress.go | 3 +- generator/ingress_test.go | 4 +- generator/katenaryLabels.go | 68 ++++++++++-------- generator/katenaryLabelsDoc.yaml | 34 ++++----- generator/labels.go | 13 +--- generator/secret.go | 2 +- generator/secret_test.go | 4 +- generator/values.go | 16 ----- generator/volume_test.go | 4 +- 22 files changed, 181 insertions(+), 220 deletions(-) diff --git a/.gitignore b/.gitignore index d2c7bb2..5d8f05a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,6 @@ docker-compose* .credentials release.id configs/ -cover.* +cover* .sq -katenary +./katenary diff --git a/cmd/katenary/main.go b/cmd/katenary/main.go index c1b4c5b..4f2baf6 100644 --- a/cmd/katenary/main.go +++ b/cmd/katenary/main.go @@ -178,7 +178,7 @@ func generateLabelHelpCommand() *cobra.Command { If no label is specified, the help for all labels is printed. If a label is specified, the help for this label is printed. -The name of the label must be specified without the prefix ` + generator.KATENARY_PREFIX + `. +The name of the label must be specified without the prefix ` + generator.Prefix() + `. e.g. kanetary help-labels diff --git a/doc/docs/index.md b/doc/docs/index.md index 00daa82..fc3c4c7 100644 --- a/doc/docs/index.md +++ b/doc/docs/index.md @@ -22,7 +22,7 @@ and let the magic happen. # What is it? -Katenary is a tool made to help you to transform "compose" files (`docker-compose.yml`, `podman-compose.yml`...) to +Katenary is a tool made to help you to transform "compose" files (`compose.yaml`, `docker-compose.yml`, `podman-compose.yml`...) to complete and production ready [Helm Chart](https://helm.sh). You'll be able to deploy your project in [:material-kubernetes: Kubernetes](https://kubernetes.io) in a few seconds @@ -30,7 +30,19 @@ You'll be able to deploy your project in [:material-kubernetes: Kubernetes](http It uses your current file and optionnaly labels to configure the result. -It's an opensource project, under MIT licence, partially developped at [Smile](https://www.smile.eu). The project source +It's an opensource project, under MIT licence, originally partially developped at [Smile](https://www.smile.eu). + +Today, it's partially developped in collaboration with [Klee Group](https://www.kleegroup.com). Note that Katenary is +and **will stay an opensource and free (as freedom) project**. We are convinced that the best way to make it better is to +share it with the community. + +
+![](./statics/klee.svg) +
+ +The main developer is [Patrice FERLET](https://github.com/metal3d). + +The project source code is hosted on the [:fontawesome-brands-github: Katenary GitHub Repository](https://github.com/metal3d/katenary). ## Install Katenary diff --git a/doc/docs/labels.md b/doc/docs/labels.md index 985ba09..3f13b63 100644 --- a/doc/docs/labels.md +++ b/doc/docs/labels.md @@ -7,22 +7,22 @@ Katenary will try to Unmarshal these labels. ## Label list and types -| Label name | Description | Type | -| ----------------------------- | ------------------------------------------------------ | --------------------- | +| Label name | Description | Type | +| ---------------------------- | ------------------------------------------------------ | --------------------- | | `katenary.v3/configmap-files` | Add files to the configmap. | list of strings | -| `katenary.v3/cronjob` | Create a cronjob from the service. | object | -| `katenary.v3/dependencies` | Add Helm dependencies to the service. | list of objects | -| `katenary.v3/description` | Description of the service | string | -| `katenary.v3/env-from` | Add environment variables from antoher service. | list of strings | -| `katenary.v3/health-check` | Health check to be added to the deployment. | object | -| `katenary.v3/ignore` | Ignore the service | bool | -| `katenary.v3/ingress` | Ingress rules to be added to the service. | object | -| `katenary.v3/main-app` | Mark the service as the main app. | bool | -| `katenary.v3/map-env` | Map env vars from the service to the deployment. | object | -| `katenary.v3/ports` | Ports to be added to the service. | list of uint32 | -| `katenary.v3/same-pod` | Move the same-pod deployment to the target deployment. | string | -| `katenary.v3/secrets` | Env vars to be set as secrets. | list of string | -| `katenary.v3/values` | Environment variables to be added to the values.yaml | list of string or map | +| `katenary.v3/cronjob` | Create a cronjob from the service. | object | +| `katenary.v3/dependencies` | Add Helm dependencies to the service. | list of objects | +| `katenary.v3/description` | Description of the service | string | +| `katenary.v3/env-from` | Add environment variables from antoher service. | list of strings | +| `katenary.v3/health-check` | Health check to be added to the deployment. | object | +| `katenary.v3/ignore` | Ignore the service | bool | +| `katenary.v3/ingress` | Ingress rules to be added to the service. | object | +| `katenary.v3/main-app` | Mark the service as the main app. | bool | +| `katenary.v3/map-env` | Map env vars from the service to the deployment. | object | +| `katenary.v3/ports` | Ports to be added to the service. | list of uint32 | +| `katenary.v3/same-pod` | Move the same-pod deployment to the target deployment. | string | +| `katenary.v3/secrets` | Env vars to be set as secrets. | list of string | +| `katenary.v3/values` | Environment variables to be added to the values.yaml | list of string or map | diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index 182d729..4cd0260 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -14,14 +14,6 @@ The generate.Convert\(\) create an HelmChart object and call "Generate\(\)" meth If you want to change or override the write behavior, you can use the HelmChart.Generate\(\) function and implement your own write function. This function returns the helm chart object containing all kubernetes objects and helm chart ingormation. It does not write the helm chart to the disk. -## Constants - - - -```go -const KATENARY_PREFIX = "katenary.v3/" -``` - ## Variables @@ -31,7 +23,7 @@ var ( // Standard annotationss Annotations = map[string]string{ - KATENARY_PREFIX + "version": Version, + labelName("version"): Version, } ) ``` @@ -52,7 +44,7 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) Convert a compose \(docker, podman...\) project to a helm chart. It calls Generate\(\) to generate the chart and then write it to the disk. -## func [GetLabelHelp]() +## func [GetLabelHelp]() ```go func GetLabelHelp(asMarkdown bool) string @@ -61,7 +53,7 @@ func GetLabelHelp(asMarkdown bool) string Generate the help for the labels. -## func [GetLabelHelpFor]() +## func [GetLabelHelpFor]() ```go func GetLabelHelpFor(labelname string, asMarkdown bool) string @@ -70,7 +62,7 @@ func GetLabelHelpFor(labelname string, asMarkdown bool) string GetLabelHelpFor returns the help for a specific label. -## func [GetLabelNames]() +## func [GetLabelNames]() ```go func GetLabelNames() []string @@ -79,7 +71,7 @@ func GetLabelNames() []string GetLabelNames returns a sorted list of all katenary label names. -## func [GetLabels]() +## func [GetLabels]() ```go func GetLabels(serviceName, appName string) map[string]string @@ -88,7 +80,7 @@ func GetLabels(serviceName, appName string) map[string]string GetLabels returns the labels for a service. It uses the appName to replace the \_\_replace\_\_ in the labels. This is used to generate the labels in the templates. -## func [GetMatchLabels]() +## func [GetMatchLabels]() ```go func GetMatchLabels(serviceName, appName string) map[string]string @@ -114,6 +106,15 @@ func NewCronJob(service types.ServiceConfig, chart *HelmChart, appName string) ( NewCronJob creates a new CronJob from a compose service. The appName is the name of the application taken from the project name. + +## func [Prefix]() + +```go +func Prefix() string +``` + + + ## type [ChartTemplate]() @@ -274,7 +275,7 @@ Yaml returns the yaml representation of the cronjob. Implements the Yaml interface. -## type [CronJobValue]() +## type [CronJobValue]() CronJobValue is a cronjob configuration that will be saved in values.yaml. @@ -354,7 +355,7 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) AddContainer adds a container to the deployment. -### func \(\*Deployment\) [AddHealthCheck]() +### func \(\*Deployment\) [AddHealthCheck]() ```go func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) @@ -381,7 +382,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. If the volume is a bind volume it will warn the user that it is not supported yet. -### func \(\*Deployment\) [BindFrom]() +### func \(\*Deployment\) [BindFrom]() ```go func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) @@ -399,7 +400,7 @@ func (d *Deployment) DependsOn(to *Deployment, servicename string) error DependsOn adds a initContainer to the deployment that will wait for the service to be up. -### func \(\*Deployment\) [Filename]() +### func \(\*Deployment\) [Filename]() ```go func (d *Deployment) Filename() string @@ -408,7 +409,7 @@ func (d *Deployment) Filename() string Filename returns the filename of the deployment. -### func \(\*Deployment\) [SetEnvFrom]() +### func \(\*Deployment\) [SetEnvFrom]() ```go func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) @@ -417,7 +418,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) SetEnvFrom sets the environment variables to a configmap. The configmap is created. -### func \(\*Deployment\) [Yaml]() +### func \(\*Deployment\) [Yaml]() ```go func (d *Deployment) Yaml() ([]byte, error) @@ -529,7 +530,7 @@ func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress NewIngress creates a new Ingress from a compose service. -### func \(\*Ingress\) [Filename]() +### func \(\*Ingress\) [Filename]() ```go func (ingress *Ingress) Filename() string @@ -538,7 +539,7 @@ func (ingress *Ingress) Filename() string -### func \(\*Ingress\) [Yaml]() +### func \(\*Ingress\) [Yaml]() ```go func (ingress *Ingress) Yaml() ([]byte, error) @@ -570,42 +571,24 @@ Label is a katenary label to find in compose files. type Label = string ``` -Known labels. +Known labels. ```go const ( - LABEL_MAIN_APP Label = KATENARY_PREFIX + "main-app" - LABEL_VALUES Label = KATENARY_PREFIX + "values" - LABEL_SECRETS Label = KATENARY_PREFIX + "secrets" - LABEL_PORTS Label = KATENARY_PREFIX + "ports" - LABEL_INGRESS Label = KATENARY_PREFIX + "ingress" - LABEL_MAP_ENV Label = KATENARY_PREFIX + "map-env" - LABEL_HEALTHCHECK Label = KATENARY_PREFIX + "health-check" - LABEL_SAME_POD Label = KATENARY_PREFIX + "same-pod" - LABEL_DESCRIPTION Label = KATENARY_PREFIX + "description" - LABEL_IGNORE Label = KATENARY_PREFIX + "ignore" - LABEL_DEPENDENCIES Label = KATENARY_PREFIX + "dependencies" - LABEL_CM_FILES Label = KATENARY_PREFIX + "configmap-files" - LABEL_CRONJOB Label = KATENARY_PREFIX + "cronjob" - LABEL_ENV_FROM Label = KATENARY_PREFIX + "env-from" -) -``` - - -## type [LabelType]() - -LabelType identifies the type of label to generate in objects. TODO: is this still needed? - -```go -type LabelType uint8 -``` - - - -```go -const ( - DeploymentLabel LabelType = iota - ServiceLabel + LabelMainApp Label = katenaryLabelPrefix + "/main-app" + LabelValues Label = katenaryLabelPrefix + "/values" + LabelSecrets Label = katenaryLabelPrefix + "/secrets" + LabelPorts Label = katenaryLabelPrefix + "/ports" + LabelIngress Label = katenaryLabelPrefix + "/ingress" + LabelMapEnv Label = katenaryLabelPrefix + "/map-env" + LabelHealthCheck Label = katenaryLabelPrefix + "/health-check" + LabelSamePod Label = katenaryLabelPrefix + "/same-pod" + LabelDescription Label = katenaryLabelPrefix + "/description" + LabelIgnore Label = katenaryLabelPrefix + "/ignore" + LabelDependencies Label = katenaryLabelPrefix + "/dependencies" + LabelConfigMapFiles Label = katenaryLabelPrefix + "/configmap-files" + LabelCronJob Label = katenaryLabelPrefix + "/cronjob" + LabelEnvFrom Label = katenaryLabelPrefix + "/env-from" ) ``` @@ -855,26 +838,9 @@ func (r *ServiceAccount) Yaml() ([]byte, error) -## type [Value]() +## type [Value]() -Value will be saved in values.yaml. It contains configuraiton for all deployment and services. The content will be lile: - -``` -name_of_component: - repository: - image: image_name - tag: image_tag - persistence: - enabled: true - storageClass: storage_class_name - ingress: - enabled: true - host: host_name - path: path_name - environment: - ENV_VAR_1: value_1 - ENV_VAR_2: value_2 -``` +Value will be saved in values.yaml. It contains configuraiton for all deployment and services. ```go type Value struct { @@ -892,7 +858,7 @@ type Value struct { ``` -### func [NewValue]() +### func [NewValue]() ```go func NewValue(service types.ServiceConfig, main ...bool) *Value @@ -903,7 +869,7 @@ NewValue creates a new Value from a compose service. The value contains the nece If \`main\` is true, the tag will be empty because it will be set in the helm chart appVersion. -### func \(\*Value\) [AddIngress]() +### func \(\*Value\) [AddIngress]() ```go func (v *Value) AddIngress(host, path string) @@ -912,7 +878,7 @@ func (v *Value) AddIngress(host, path string) -### func \(\*Value\) [AddPersistence]() +### func \(\*Value\) [AddPersistence]() ```go func (v *Value) AddPersistence(volumeName string) diff --git a/generator/configMap.go b/generator/configMap.go index 73ff639..1c08ddf 100644 --- a/generator/configMap.go +++ b/generator/configMap.go @@ -76,7 +76,7 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { } // get the secrets from the labels - if v, ok := service.Labels[LABEL_SECRETS]; ok { + if v, ok := service.Labels[LabelSecrets]; ok { err := yaml.Unmarshal([]byte(v), &secrets) if err != nil { log.Fatal(err) @@ -87,7 +87,7 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { } } // get the label values from the labels - varDescriptons := utils.GetValuesFromLabel(service, LABEL_VALUES) + varDescriptons := utils.GetValuesFromLabel(service, LabelValues) for value := range varDescriptons { labelValues = append(labelValues, value) } @@ -104,7 +104,7 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { } // remove the variables that are already defined in the environment - if l, ok := service.Labels[LABEL_MAP_ENV]; ok { + if l, ok := service.Labels[LabelMapEnv]; ok { envmap := make(map[string]string) if err := goyaml.Unmarshal([]byte(l), &envmap); err != nil { log.Fatal("Error parsing map-env", err) diff --git a/generator/converter.go b/generator/converter.go index bec529f..00b887b 100644 --- a/generator/converter.go +++ b/generator/converter.go @@ -339,7 +339,7 @@ func addModeline(values []byte) []byte { // of the service definition. func addDescriptions(values []byte, project types.Project) []byte { for _, service := range project.Services { - if description, ok := service.Labels[LABEL_DESCRIPTION]; ok { + if description, ok := service.Labels[LabelDescription]; ok { // set it as comment description = "\n# " + strings.ReplaceAll(description, "\n", "\n# ") @@ -500,7 +500,7 @@ func addVariablesDoc(values []byte, project *types.Project) []byte { currentService := "" for _, service := range project.Services { - variables := utils.GetValuesFromLabel(service, LABEL_VALUES) + variables := utils.GetValuesFromLabel(service, LabelValues) for i, line := range lines { if regexp.MustCompile(`(?m)^` + service.Name + `:`).MatchString(line) { currentService = service.Name @@ -535,7 +535,7 @@ func addMainTagAppDoc(values []byte, project *types.Project) []byte { inService := false inRegistry := false // read the label LabelMainApp - if v, ok := service.Labels[LABEL_MAIN_APP]; !ok { + if v, ok := service.Labels[LabelMainApp]; !ok { continue } else if v == "false" || v == "no" || v == "0" { continue @@ -609,7 +609,7 @@ func checkOldLabels(project *types.Project) error { badServices := make([]string, 0) for _, service := range project.Services { for label := range service.Labels { - if strings.Contains(label, "katenary.") && !strings.Contains(label, KATENARY_PREFIX) { + if strings.Contains(label, "katenary.") && !strings.Contains(label, katenaryLabelPrefix) { badServices = append(badServices, fmt.Sprintf("- %s: %s", service.Name, label)) } } @@ -625,7 +625,7 @@ func checkOldLabels(project *types.Project) error { Services to upgrade: %s`, project.Name, - KATENARY_PREFIX[0:len(KATENARY_PREFIX)-1], + katenaryLabelPrefix[0:len(katenaryLabelPrefix)-1], strings.Join(badServices, "\n"), ) diff --git a/generator/cronJob.go b/generator/cronJob.go index 488ffd2..c0f6f3d 100644 --- a/generator/cronJob.go +++ b/generator/cronJob.go @@ -27,7 +27,7 @@ type CronJob struct { // NewCronJob creates a new CronJob from a compose service. The appName is the name of the application taken from the project name. func NewCronJob(service types.ServiceConfig, chart *HelmChart, appName string) (*CronJob, *RBAC) { - labels, ok := service.Labels[LABEL_CRONJOB] + labels, ok := service.Labels[LabelCronJob] if !ok { return nil, nil } diff --git a/generator/deployment.go b/generator/deployment.go index 8129e41..336a16a 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -44,7 +44,7 @@ type Deployment struct { // It also creates the Values map that will be used to create the values.yaml file. func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { isMainApp := false - if mainLabel, ok := service.Labels[LABEL_MAIN_APP]; ok { + if mainLabel, ok := service.Labels[LabelMainApp]; ok { main := strings.ToLower(mainLabel) isMainApp = main == "true" || main == "yes" || main == "1" } @@ -83,7 +83,7 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { }, Spec: corev1.PodSpec{ NodeSelector: map[string]string{ - "katenary.v3/node-selector": "replace", + labelName("node-selector"): "replace", }, }, }, @@ -112,7 +112,7 @@ func (d *Deployment) DependsOn(to *Deployment, servicename string) error { for _, container := range to.Spec.Template.Spec.Containers { commands := []string{} if len(container.Ports) == 0 { - utils.Warn("No ports found for service ", servicename, ". You should declare a port in the service or use "+LABEL_PORTS+" label.") + utils.Warn("No ports found for service ", servicename, ". You should declare a port in the service or use "+LabelPorts+" label.") os.Exit(1) } for _, port := range container.Ports { @@ -186,7 +186,7 @@ func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *In // If the volume is a bind volume it will warn the user that it is not supported yet. func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { tobind := map[string]bool{} - if v, ok := service.Labels[LABEL_CM_FILES]; ok { + if v, ok := service.Labels[LabelConfigMapFiles]; ok { binds := []string{} if err := yaml.Unmarshal([]byte(v), &binds); err != nil { log.Fatal(err) @@ -197,7 +197,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { } isSamePod := false - if v, ok := service.Labels[LABEL_SAME_POD]; !ok { + if v, ok := service.Labels[LabelSamePod]; !ok { isSamePod = false } else { isSamePod = v != "" @@ -214,7 +214,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { utils.Warn( "Bind volumes are not supported yet, " + "excepting for those declared as " + - LABEL_CM_FILES + + LabelConfigMapFiles + ", skipping volume " + volume.Source + " from service " + service.Name, ) @@ -242,7 +242,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { }) // Add volume to values.yaml only if it the service is not in the same pod that another service. // If it is in the same pod, the volume will be added to the other service later - if _, ok := service.Labels[LABEL_SAME_POD]; !ok { + if _, ok := service.Labels[LabelSamePod]; !ok { d.chart.Values[service.Name].(*Value).AddPersistence(volume.Source) } // Add volume to deployment @@ -354,7 +354,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { // secrets from label labelSecrets := []string{} - if v, ok := service.Labels[LABEL_SECRETS]; ok { + if v, ok := service.Labels[LabelSecrets]; ok { err := yaml.Unmarshal([]byte(v), &labelSecrets) if err != nil { log.Fatal(err) @@ -362,7 +362,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { } // values from label - varDescriptons := utils.GetValuesFromLabel(service, LABEL_VALUES) + varDescriptons := utils.GetValuesFromLabel(service, LabelValues) labelValues := []string{} for v := range varDescriptons { labelValues = append(labelValues, v) @@ -441,7 +441,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) { // get the label for healthcheck - if v, ok := service.Labels[LABEL_HEALTHCHECK]; ok { + if v, ok := service.Labels[LabelHealthCheck]; ok { probes := struct { LivenessProbe *corev1.Probe `yaml:"livenessProbe"` ReadinessProbe *corev1.Probe `yaml:"readinessProbe"` @@ -576,7 +576,6 @@ func (d *Deployment) Yaml() ([]byte, error) { post := spaces + "{{- end }}" ns := spaces + "nodeSelector:\n" ns += spaces + ` {{- .Values.` + serviceName + `.nodeSelector | toYaml | nindent __indent__ }}` - //line = strings.Replace(line, "katenary.v3/node-selector: replace", ns, 1) line = pre + "\n" + ns + "\n" + post } // manage replicas @@ -608,7 +607,7 @@ func (d *Deployment) Yaml() ([]byte, error) { // find the katenary.v3/node-selector line, and remove it for i, line := range content { - if strings.Contains(line, "katenary.v3/node-selector") { + if strings.Contains(line, labelName("node-selector")) { content = append(content[:i], content[i+1:]...) continue } diff --git a/generator/generator.go b/generator/generator.go index f08e237..892565a 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -45,7 +45,7 @@ func Generate(project *types.Project) (*HelmChart, error) { if err != nil { return nil, err } - Annotations[KATENARY_PREFIX+"compose-hash"] = hash + Annotations[labelName("compose-hash")] = hash chart.composeHash = &hash // find the "main-app" label, and set chart.AppVersion to the tag if exists @@ -92,7 +92,7 @@ func Generate(project *types.Project) (*HelmChart, error) { // 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[LABEL_SAME_POD]; ok && samePod != "" { + if samePod, ok := service.Labels[LabelSamePod]; ok && samePod != "" { podToMerge[samePod] = &service } @@ -127,14 +127,14 @@ func Generate(project *types.Project) (*HelmChart, error) { // drop all "same-pod" deployments because the containers and volumes are already // in the target deployment for _, service := range podToMerge { - if samepod, ok := service.Labels[LABEL_SAME_POD]; ok && samepod != "" { + if samepod, ok := service.Labels[LabelSamePod]; ok && samepod != "" { // move this deployment volumes to the target deployment if target, ok := deployments[samepod]; ok { target.AddContainer(*service) target.BindFrom(*service, deployments[service.Name]) delete(deployments, service.Name) } else { - log.Printf("service %[1]s is declared as %[2]s, but %[2]s is not defined", service.Name, LABEL_SAME_POD) + log.Printf("service %[1]s is declared as %[2]s, but %[2]s is not defined", service.Name, LabelSamePod) } } } @@ -252,7 +252,7 @@ func removeReplaceString(b []byte) []byte { // serviceIsMain returns true if the service is the main app. func serviceIsMain(service types.ServiceConfig) bool { - if main, ok := service.Labels[LABEL_MAIN_APP]; ok { + if main, ok := service.Labels[LabelMainApp]; ok { return main == "true" || main == "yes" || main == "1" } return false @@ -274,7 +274,7 @@ func setChartVersion(chart *HelmChart, service types.ServiceConfig) { // fixPorts checks the "ports" label from container and add it to the service. func fixPorts(service *types.ServiceConfig) error { // check the "ports" label from container and add it to the service - if portsLabel, ok := service.Labels[LABEL_PORTS]; ok { + if portsLabel, ok := service.Labels[LabelPorts]; ok { ports := []uint32{} if err := goyaml.Unmarshal([]byte(portsLabel), &ports); err != nil { // maybe it's a string, comma separated @@ -302,7 +302,7 @@ func fixPorts(service *types.ServiceConfig) error { // setCronJob creates a cronjob from the service labels. func setCronJob(service types.ServiceConfig, chart *HelmChart, appName string) *CronJob { - if _, ok := service.Labels[LABEL_CRONJOB]; !ok { + if _, ok := service.Labels[LabelCronJob]; !ok { return nil } cronjob, rbac := NewCronJob(service, chart, appName) @@ -336,7 +336,7 @@ func setCronJob(service types.ServiceConfig, chart *HelmChart, appName string) * // setDependencies sets the dependencies from the service labels. func setDependencies(chart *HelmChart, service types.ServiceConfig) (bool, error) { // helm dependency - if v, ok := service.Labels[LABEL_DEPENDENCIES]; ok { + if v, ok := service.Labels[LabelDependencies]; ok { d := []Dependency{} if err := yaml.Unmarshal([]byte(v), &d); err != nil { return false, err @@ -360,7 +360,7 @@ func setDependencies(chart *HelmChart, service types.ServiceConfig) (bool, error // isIgnored returns true if the service is ignored. func isIgnored(service types.ServiceConfig) bool { - if v, ok := service.Labels[LABEL_IGNORE]; ok { + if v, ok := service.Labels[LabelIgnore]; ok { return v == "true" || v == "yes" || v == "1" } return false @@ -381,7 +381,7 @@ func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map // if the service is integrated in another deployment, we need to add the volume // to the target deployment - if override, ok := service.Labels[LABEL_SAME_POD]; ok { + if override, ok := service.Labels[LabelSamePod]; ok { pvc.nameOverride = override pvc.Spec.StorageClassName = utils.StrPtr(`{{ .Values.` + override + `.persistence.` + v.Source + `.storageClass }}`) chart.Values[override].(*Value).AddPersistence(v.Source) @@ -461,7 +461,7 @@ func generateConfigMapsAndSecrets(project *types.Project, chart *HelmChart) erro originalEnv[k] = v } - if v, ok := s.Labels[LABEL_SECRETS]; ok { + if v, ok := s.Labels[LabelSecrets]; ok { list := []string{} if err := yaml.Unmarshal([]byte(v), &list); err != nil { log.Fatal("error unmarshaling secrets label:", err) @@ -523,7 +523,7 @@ func samePodVolume(service types.ServiceConfig, v types.ServiceVolumeConfig, dep } targetDeployment := "" - if targetName, ok := service.Labels[LABEL_SAME_POD]; !ok { + if targetName, ok := service.Labels[LabelSamePod]; !ok { return false } else { targetDeployment = targetName @@ -555,11 +555,11 @@ func samePodVolume(service types.ServiceConfig, v types.ServiceVolumeConfig, dep func setSharedConf(service types.ServiceConfig, chart *HelmChart, deployments map[string]*Deployment) { // if the service has the "shared-conf" label, we need to add the configmap // to the chart and add the env vars to the service - if _, ok := service.Labels[LABEL_ENV_FROM]; !ok { + if _, ok := service.Labels[LabelEnvFrom]; !ok { return } fromservices := []string{} - if err := yaml.Unmarshal([]byte(service.Labels[LABEL_ENV_FROM]), &fromservices); err != nil { + if err := yaml.Unmarshal([]byte(service.Labels[LabelEnvFrom]), &fromservices); err != nil { log.Fatal("error unmarshaling env-from label:", err) } // find the configmap in the chart templates diff --git a/generator/globals.go b/generator/globals.go index e4e74ba..057dcc7 100644 --- a/generator/globals.go +++ b/generator/globals.go @@ -11,6 +11,6 @@ var ( // Standard annotationss Annotations = map[string]string{ - KATENARY_PREFIX + "version": Version, + labelName("version"): Version, } ) diff --git a/generator/helmHelper.tpl b/generator/helmHelper.tpl index f1b2515..769c55d 100644 --- a/generator/helmHelper.tpl +++ b/generator/helmHelper.tpl @@ -22,15 +22,15 @@ {{- define "__APP__.labels" -}} {{ include "__APP__.selectorLabels" .}} {{ if .Chart.Version -}} -{{ printf "__PREFIX__chart-version: '%s'" .Chart.Version }} +{{ printf "__PREFIX__/chart-version: '%s'" .Chart.Version }} {{- end }} {{ if .Chart.AppVersion -}} -{{ printf "__PREFIX__app-version: '%s'" .Chart.AppVersion }} +{{ printf "__PREFIX__/app-version: '%s'" .Chart.AppVersion }} {{- end }} {{- end -}} {{- define "__APP__.selectorLabels" -}} {{- $name := default .Chart.Name .Values.nameOverride -}} -{{ printf "__PREFIX__name: %s" $name }} -{{ printf "__PREFIX__instance: %s" .Release.Name }} +{{ printf "__PREFIX__/name: %s" $name }} +{{ printf "__PREFIX__/instance: %s" .Release.Name }} {{- end -}} diff --git a/generator/helper.go b/generator/helper.go index 51487af..a6a03c2 100644 --- a/generator/helper.go +++ b/generator/helper.go @@ -13,7 +13,7 @@ var helmHelper string // Helper returns the _helpers.tpl file for a chart. func Helper(name string) string { helmHelper := strings.ReplaceAll(helmHelper, "__APP__", name) - helmHelper = strings.ReplaceAll(helmHelper, "__PREFIX__", KATENARY_PREFIX) + helmHelper = strings.ReplaceAll(helmHelper, "__PREFIX__", katenaryLabelPrefix) helmHelper = strings.ReplaceAll(helmHelper, "__VERSION__", "0.1.0") return helmHelper } diff --git a/generator/ingress.go b/generator/ingress.go index 316743a..72bb041 100644 --- a/generator/ingress.go +++ b/generator/ingress.go @@ -24,13 +24,12 @@ type Ingress struct { func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress { appName := Chart.Name - // parse the KATENARY_PREFIX/ingress label from the service if service.Labels == nil { service.Labels = make(map[string]string) } var label string var ok bool - if label, ok = service.Labels[LABEL_INGRESS]; !ok { + if label, ok = service.Labels[LabelIngress]; !ok { return nil } diff --git a/generator/ingress_test.go b/generator/ingress_test.go index ccdc080..c81b2d4 100644 --- a/generator/ingress_test.go +++ b/generator/ingress_test.go @@ -18,11 +18,11 @@ services: - 80:80 - 443:443 labels: - %singress: |- + %s/ingress: |- host: my.test.tld port: 80 ` - composeFile = fmt.Sprintf(composeFile, KATENARY_PREFIX) + composeFile = fmt.Sprintf(composeFile, katenaryLabelPrefix) tmpDir := setup(composeFile) defer teardown(tmpDir) diff --git a/generator/katenaryLabels.go b/generator/katenaryLabels.go index 6c7c37a..d26d08d 100644 --- a/generator/katenaryLabels.go +++ b/generator/katenaryLabels.go @@ -36,24 +36,28 @@ type Help struct { Type string `yaml:"type"` } -const KATENARY_PREFIX = "katenary.v3/" +const katenaryLabelPrefix = "katenary.v3" + +func Prefix() string { + return katenaryLabelPrefix +} // Known labels. const ( - LABEL_MAIN_APP Label = KATENARY_PREFIX + "main-app" - LABEL_VALUES Label = KATENARY_PREFIX + "values" - LABEL_SECRETS Label = KATENARY_PREFIX + "secrets" - LABEL_PORTS Label = KATENARY_PREFIX + "ports" - LABEL_INGRESS Label = KATENARY_PREFIX + "ingress" - LABEL_MAP_ENV Label = KATENARY_PREFIX + "map-env" - LABEL_HEALTHCHECK Label = KATENARY_PREFIX + "health-check" - LABEL_SAME_POD Label = KATENARY_PREFIX + "same-pod" - LABEL_DESCRIPTION Label = KATENARY_PREFIX + "description" - LABEL_IGNORE Label = KATENARY_PREFIX + "ignore" - LABEL_DEPENDENCIES Label = KATENARY_PREFIX + "dependencies" - LABEL_CM_FILES Label = KATENARY_PREFIX + "configmap-files" - LABEL_CRONJOB Label = KATENARY_PREFIX + "cronjob" - LABEL_ENV_FROM Label = KATENARY_PREFIX + "env-from" + LabelMainApp Label = katenaryLabelPrefix + "/main-app" + LabelValues Label = katenaryLabelPrefix + "/values" + LabelSecrets Label = katenaryLabelPrefix + "/secrets" + LabelPorts Label = katenaryLabelPrefix + "/ports" + LabelIngress Label = katenaryLabelPrefix + "/ingress" + LabelMapEnv Label = katenaryLabelPrefix + "/map-env" + LabelHealthCheck Label = katenaryLabelPrefix + "/health-check" + LabelSamePod Label = katenaryLabelPrefix + "/same-pod" + LabelDescription Label = katenaryLabelPrefix + "/description" + LabelIgnore Label = katenaryLabelPrefix + "/ignore" + LabelDependencies Label = katenaryLabelPrefix + "/dependencies" + LabelConfigMapFiles Label = katenaryLabelPrefix + "/configmap-files" + LabelCronJob Label = katenaryLabelPrefix + "/cronjob" + LabelEnvFrom Label = katenaryLabelPrefix + "/env-from" ) func init() { @@ -62,6 +66,10 @@ func init() { } } +func labelName(name string) Label { + return Label(katenaryLabelPrefix + "/" + name) +} + // Generate the help for the labels. func GetLabelHelp(asMarkdown bool) string { names := GetLabelNames() // sorted @@ -75,7 +83,7 @@ func generatePlainHelp(names []string) string { var builder strings.Builder for _, name := range names { help := labelFullHelp[name] - fmt.Fprintf(&builder, "%s%s:\t%s\t%s\n", KATENARY_PREFIX, name, help.Type, help.Short) + fmt.Fprintf(&builder, "%s:\t%s\t%s\n", labelName(name), help.Type, help.Short) } // use tabwriter to align the help text @@ -100,7 +108,7 @@ func generateMarkdownHelp(names []string) string { } for _, name := range names { help := labelFullHelp[name] - maxNameLength = max(maxNameLength, len(name)+2+len(KATENARY_PREFIX)) + maxNameLength = max(maxNameLength, len(name)+2+len(katenaryLabelPrefix)) maxDescriptionLength = max(maxDescriptionLength, len(help.Short)) maxTypeLength = max(maxTypeLength, len(help.Type)) } @@ -111,7 +119,7 @@ func generateMarkdownHelp(names []string) string { for _, name := range names { help := labelFullHelp[name] fmt.Fprintf(&builder, "| %-*s | %-*s | %-*s |\n", - maxNameLength, "`"+KATENARY_PREFIX+name+"`", // enclose in backticks + maxNameLength, "`"+labelName(name)+"`", // enclose in backticks maxDescriptionLength, help.Short, maxTypeLength, help.Type, ) @@ -166,29 +174,29 @@ func GetLabelHelpFor(labelname string, asMarkdown bool) string { var buf bytes.Buffer template.Must(template.New("shorthelp").Parse(help.Long)).Execute(&buf, struct { - KATENARY_PREFIX string + KatenaryPrefix string }{ - KATENARY_PREFIX: KATENARY_PREFIX, + KatenaryPrefix: katenaryLabelPrefix, }) help.Long = buf.String() buf.Reset() template.Must(template.New("example").Parse(help.Example)).Execute(&buf, struct { - KATENARY_PREFIX string + KatenaryPrefix string }{ - KATENARY_PREFIX: KATENARY_PREFIX, + KatenaryPrefix: katenaryLabelPrefix, }) help.Example = buf.String() buf.Reset() template.Must(template.New("complete").Parse(helpTemplate)).Execute(&buf, struct { - Name string - Help Help - KATENARY_PREFIX string + Name string + Help Help + KatenaryPrefix string }{ - Name: labelname, - Help: help, - KATENARY_PREFIX: KATENARY_PREFIX, + Name: labelname, + Help: help, + KatenaryPrefix: katenaryLabelPrefix, }) return buf.String() @@ -206,7 +214,7 @@ func GetLabelNames() []string { func getHelpTemplate(asMarkdown bool) string { if asMarkdown { - return `## {{ .KATENARY_PREFIX }}{{ .Name }} + return `## {{ .KatenaryPrefix }}/{{ .Name }} {{ .Help.Short }} @@ -217,7 +225,7 @@ func getHelpTemplate(asMarkdown bool) string { **Example:**` + "\n\n```yaml\n" + `{{ .Help.Example }}` + "\n```\n" } - return `{{ .KATENARY_PREFIX }}{{ .Name }}: {{ .Help.Short }} + return `{{ .KatenaryPrefix }}/{{ .Name }}: {{ .Help.Short }} Type: {{ .Help.Type }} {{ .Help.Long }} diff --git a/generator/katenaryLabelsDoc.yaml b/generator/katenaryLabelsDoc.yaml index 36cd49b..9553343 100644 --- a/generator/katenaryLabelsDoc.yaml +++ b/generator/katenaryLabelsDoc.yaml @@ -19,7 +19,7 @@ # This is an {{ "{{ example }}" }}. # # This will display "This is an {{ exemple }}" in the output. -# - Use {{ .KATENARY_PREFIX }} to let Katenary replace it with the label prefix (e.g. "katenary.v3/") +# - Use {{ .KatenaryPrefix }} to let Katenary replace it with the label prefix (e.g. "katenary.v3") "main-app": short: "Mark the service as the main app." @@ -40,7 +40,7 @@ # The chart is now named ghost, and the appVersion is 1.25.5. # In Deployment, the image attribute is set to ghost:1.25.5 if # you don't change the "tag" attribute in values.yaml - {{ .KATENARY_PREFIX }}main-app: true + {{ .KatenaryPrefix }}/main-app: true type: "bool" "values": @@ -63,7 +63,7 @@ TO_CONFIGURE: something that can be changed in values.yaml A_COMPLEX_VALUE: example labels: - {{ .KATENARY_PREFIX }}values: |- + {{ .KatenaryPrefix }}/values: |- # simple values, set as is in values.yaml - TO_CONFIGURE # complex values, set as a template in values.yaml with a documentation @@ -79,14 +79,14 @@ This label allows setting the environment variables as secrets. The variable is removed from the environment and added to a secret object. - The variable can be set to the {{ printf "%s%s" .KATENARY_PREFIX "values"}} too, + The variable can be set to the {{ printf "%s/%s" .KatenaryPrefix "values"}} too, so the secret value can be configured in values.yaml example: |- env: PASSWORD: a very secret password NOT_A_SECRET: a public value labels: - {{ .KATENARY_PREFIX }}secrets: |- + {{ .KatenaryPrefix }}/secrets: |- - PASSWORD type: "list of string" @@ -97,7 +97,7 @@ service is a dependency of another service. example: |- labels: - {{ .KATENARY_PREFIX }}ports: |- + {{ .KatenaryPrefix }}/ports: |- - 8080 - 8081 type: "list of uint32" @@ -106,10 +106,10 @@ short: "Ingress rules to be added to the service." long: |- Declare an ingress rule for the service. The port should be exposed or - declared with {{ printf "%s%s" .KATENARY_PREFIX "ports" }}. + declared with {{ printf "%s/%s" .KatenaryPrefix "ports" }}. example: |- labels: - {{ .KATENARY_PREFIX }}ingress: |- + {{ .KatenaryPrefix }}/ingress: |- port: 80 hostname: mywebsite.com (optional) type: "object" @@ -130,7 +130,7 @@ RUNNING: docker OTHER: value labels: - {{ .KATENARY_PREFIX }}map-env: |- + {{ .KatenaryPrefix }}/map-env: |- RUNNING: kubernetes DB_HOST: '{{ "{{ include \"__APP__.fullname\" . }}" }}-database' type: "object" @@ -140,7 +140,7 @@ long: "Health check to be added to the deployment." example: |- labels: - {{ .KATENARY_PREFIX }}health-check: |- + {{ .KatenaryPrefix }}/health-check: |- httpGet: path: /health port: 8080 @@ -161,7 +161,7 @@ php: image: php:7.4-fpm labels: - {{ .KATENARY_PREFIX }}same-pod: web + {{ .KatenaryPrefix }}/same-pod: web type: "string" "description": @@ -173,7 +173,7 @@ The value can be set with a documentation in multiline format. example: |- labels: - {{ .KATENARY_PREFIX }}description: |- + {{ .KatenaryPrefix }}/description: |- This is a description of the service. It can be multiline. type: "string" @@ -181,7 +181,7 @@ "ignore": short: "Ignore the service" long: "Ingoring a service to not be exported in helm chart." - example: "labels:\n {{ .KATENARY_PREFIX }}ignore: \"true\"" + example: "labels:\n {{ .KatenaryPrefix }}/ignore: \"true\"" type: "bool" "dependencies": @@ -209,7 +209,7 @@ in values.yaml. example: |- labels: - {{ .KATENARY_PREFIX }}dependencies: |- + {{ .KatenaryPrefix }}/dependencies: |- - name: mariadb repository: oci://registry-1.docker.io/bitnamicharts @@ -244,7 +244,7 @@ volumes - ./conf.d:/etc/nginx/conf.d labels: - {{ .KATENARY_PREFIX }}configmap-files: |- + {{ .KatenaryPrefix }}/configmap-files: |- - ./conf.d type: "list of strings" @@ -261,7 +261,7 @@ a serviceaccount to make your cronjob able to connect the Kubernetes API example: |- labels: - {{ .KATENARY_PREFIX }}cronjob: |- + {{ .KatenaryPrefix }}/cronjob: |- command: echo "hello world" schedule: "* */1 * * *" # or @hourly for example type: "object" @@ -282,6 +282,6 @@ labels: # get the congigMap from service1 where FOO is # defined inside this service too - {{ .KATENARY_PREFIX }}env-from: |- + {{ .KatenaryPrefix }}/env-from: |- - myservice1 # vim: ft=gotmpl.yaml diff --git a/generator/labels.go b/generator/labels.go index acb2982..7a828a0 100644 --- a/generator/labels.go +++ b/generator/labels.go @@ -4,20 +4,13 @@ import ( "fmt" ) -// LabelType identifies the type of label to generate in objects. -// TODO: is this still needed? -type LabelType uint8 - -const ( - DeploymentLabel LabelType = iota - ServiceLabel -) +var componentLabel = labelName("component") // GetLabels returns the labels for a service. It uses the appName to replace the __replace__ in the labels. // This is used to generate the labels in the templates. func GetLabels(serviceName, appName string) map[string]string { labels := map[string]string{ - KATENARY_PREFIX + "component": serviceName, + componentLabel: serviceName, } key := `{{- include "%s.labels" . | nindent __indent__ }}` @@ -30,7 +23,7 @@ func GetLabels(serviceName, appName string) map[string]string { // This is used to generate the matchLabels in the templates. func GetMatchLabels(serviceName, appName string) map[string]string { labels := map[string]string{ - KATENARY_PREFIX + "component": serviceName, + componentLabel: serviceName, } key := `{{- include "%s.selectorLabels" . | nindent __indent__ }}` diff --git a/generator/secret.go b/generator/secret.go index 16b7eb1..9a6122f 100644 --- a/generator/secret.go +++ b/generator/secret.go @@ -46,7 +46,7 @@ func NewSecret(service types.ServiceConfig, appName string) *Secret { // check if the value should be in values.yaml valueList := []string{} - varDescriptons := utils.GetValuesFromLabel(service, LABEL_VALUES) + varDescriptons := utils.GetValuesFromLabel(service, LabelValues) for value := range varDescriptons { valueList = append(valueList, value) } diff --git a/generator/secret_test.go b/generator/secret_test.go index fb30ebb..93949b6 100644 --- a/generator/secret_test.go +++ b/generator/secret_test.go @@ -18,10 +18,10 @@ services: - FOO=bar - BAR=baz labels: - %ssecrets: |- + %s/secrets: |- - BAR ` - composeFile = fmt.Sprintf(composeFile, KATENARY_PREFIX) + composeFile = fmt.Sprintf(composeFile, katenaryLabelPrefix) tmpDir := setup(composeFile) defer teardown(tmpDir) diff --git a/generator/values.go b/generator/values.go index 7db405f..8b26b39 100644 --- a/generator/values.go +++ b/generator/values.go @@ -33,22 +33,6 @@ type IngressValue struct { } // Value will be saved in values.yaml. It contains configuraiton for all deployment and services. -// The content will be lile: -// -// name_of_component: -// repository: -// image: image_name -// tag: image_tag -// persistence: -// enabled: true -// storageClass: storage_class_name -// ingress: -// enabled: true -// host: host_name -// path: path_name -// environment: -// ENV_VAR_1: value_1 -// ENV_VAR_2: value_2 type Value struct { Repository *RepositoryValue `yaml:"repository,omitempty"` Persistence map[string]*PersistenceValue `yaml:"persistence,omitempty"` diff --git a/generator/volume_test.go b/generator/volume_test.go index 86659a8..9d327d3 100644 --- a/generator/volume_test.go +++ b/generator/volume_test.go @@ -47,10 +47,10 @@ services: volumes: - ./static:/var/www labels: - %sconfigmap-files: |- + %s/configmap-files: |- - ./static ` - _compose_file = fmt.Sprintf(_compose_file, KATENARY_PREFIX) + _compose_file = fmt.Sprintf(_compose_file, katenaryLabelPrefix) tmpDir := setup(_compose_file) defer teardown(tmpDir) From 15a2f25e51d694bcebd8155a8451f58bc3e76106 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 24 Apr 2024 14:23:31 +0200 Subject: [PATCH 76/97] Exclude doc from the analysis --- sonar-project.properties | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sonar-project.properties b/sonar-project.properties index 951e123..1ba7714 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -5,6 +5,9 @@ sonar.organization=metal3d sonar.go.tests.reportPaths=gotest.json sonar.go.coverage.reportPaths=coverprofile.out +# excludde +sonar.exclusions=doc/** + # This is the name and version displayed in the SonarCloud UI. #sonar.projectName=katenary #sonar.projectVersion=1.0 From da7d92bbfad3b63882d2e3678f16ccd55357b997 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 24 Apr 2024 20:55:27 +0200 Subject: [PATCH 77/97] Add more tests, refactor to fix problems Signed-off-by: Patrice Ferlet --- cmd/katenary/main.go | 8 +- cmd/katenary/main_test.go | 17 ++++ generator/cronJob.go | 24 +++--- generator/cronJob_test.go | 115 ++++++++++++++++++++++++++++ generator/deployment_test.go | 101 +++++++++++++++++++++++- generator/katenaryLabels_test.go | 76 ++++++++++++++++++ generator/labelStructs/configMap.go | 8 ++ generator/volume_test.go | 100 ++++++++++++++++++++++-- 8 files changed, 429 insertions(+), 20 deletions(-) create mode 100644 cmd/katenary/main_test.go create mode 100644 generator/cronJob_test.go create mode 100644 generator/katenaryLabels_test.go create mode 100644 generator/labelStructs/configMap.go diff --git a/cmd/katenary/main.go b/cmd/katenary/main.go index 4f2baf6..3f84d90 100644 --- a/cmd/katenary/main.go +++ b/cmd/katenary/main.go @@ -22,7 +22,11 @@ Each [command] and subcommand has got an "help" and "--help" flag to show more i ` func main() { - // The base command + rootCmd := buildRootCmd() + rootCmd.Execute() +} + +func buildRootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "katenary", Long: longHelp, @@ -42,7 +46,7 @@ func main() { generateLabelHelpCommand(), ) - rootCmd.Execute() + return rootCmd } const completionHelp = `To load completions: diff --git a/cmd/katenary/main_test.go b/cmd/katenary/main_test.go new file mode 100644 index 0000000..1b0f947 --- /dev/null +++ b/cmd/katenary/main_test.go @@ -0,0 +1,17 @@ +package main + +import "testing" + +func TestBuildCommand(t *testing.T) { + rootCmd := buildRootCmd() + if rootCmd == nil { + t.Errorf("Expected rootCmd to be defined") + } + if rootCmd.Use != "katenary" { + t.Errorf("Expected rootCmd.Use to be katenary, got %s", rootCmd.Use) + } + numCommands := 5 + if len(rootCmd.Commands()) != numCommands { + t.Errorf("Expected %d command, got %d", numCommands, len(rootCmd.Commands())) + } +} diff --git a/generator/cronJob.go b/generator/cronJob.go index c0f6f3d..5020c3a 100644 --- a/generator/cronJob.go +++ b/generator/cronJob.go @@ -4,6 +4,7 @@ import ( "log" "strings" + labelstructs "katenary/generator/labelStructs" "katenary/utils" "github.com/compose-spec/compose-go/types" @@ -31,17 +32,18 @@ func NewCronJob(service types.ServiceConfig, chart *HelmChart, appName string) ( if !ok { return nil, nil } - mapping := struct { - Image string `yaml:"image,omitempty"` - Command string `yaml:"command"` - Schedule string `yaml:"schedule"` - Rbac bool `yaml:"rbac"` - }{ - Image: "", - Command: "", - Schedule: "", - Rbac: false, - } + //mapping := struct { + // Image string `yaml:"image,omitempty"` + // Command string `yaml:"command"` + // Schedule string `yaml:"schedule"` + // Rbac bool `yaml:"rbac"` + //}{ + // Image: "", + // Command: "", + // Schedule: "", + // Rbac: false, + //} + var mapping labelstructs.CronJob if err := goyaml.Unmarshal([]byte(labels), &mapping); err != nil { log.Fatalf("Error parsing cronjob labels: %s", err) return nil, nil diff --git a/generator/cronJob_test.go b/generator/cronJob_test.go new file mode 100644 index 0000000..cb7ee0c --- /dev/null +++ b/generator/cronJob_test.go @@ -0,0 +1,115 @@ +package generator + +import ( + "os" + "strings" + "testing" + + v1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +func TestBasicCronJob(t *testing.T) { + compose_file := ` +services: + cron: + image: fedora + labels: + katenary.v3/cronjob: | + image: alpine + command: echo hello + schedule: "*/1 * * * *" + rbac: false +` + tmpDir := setup(compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/cron/cronjob.yaml") + cronJob := batchv1.CronJob{} + if err := yaml.Unmarshal([]byte(output), &cronJob); err != nil { + t.Errorf(unmarshalError, err) + } + if cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image != "alpine:latest" { + t.Errorf("Expected image to be alpine, got %s", cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) + } + combinedCommand := strings.Join(cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command, " ") + if combinedCommand != "sh -c echo hello" { + t.Errorf("Expected command to be sh -c echo hello, got %s", combinedCommand) + } + if cronJob.Spec.Schedule != "*/1 * * * *" { + t.Errorf("Expected schedule to be */1 * * * *, got %s", cronJob.Spec.Schedule) + } + + // ensure that there are a deployment for the fedora Container + var err error + output, err = helmTemplate(ConvertOptions{ + OutputDir: "./chart", + }, "-s", "templates/cron/deployment.yaml") + if err != nil { + t.Errorf("Error: %s", err) + } + deployment := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &deployment); err != nil { + t.Errorf(unmarshalError, err) + } + if deployment.Spec.Template.Spec.Containers[0].Image != "fedora:latest" { + t.Errorf("Expected image to be fedora, got %s", deployment.Spec.Template.Spec.Containers[0].Image) + } +} + +func TestCronJobbWithRBAC(t *testing.T) { + compose_file := ` +services: + cron: + image: fedora + labels: + katenary.v3/cronjob: | + image: alpine + command: echo hello + schedule: "*/1 * * * *" + rbac: true +` + + tmpDir := setup(compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/cron/cronjob.yaml") + cronJob := batchv1.CronJob{} + if err := yaml.Unmarshal([]byte(output), &cronJob); err != nil { + t.Errorf(unmarshalError, err) + } + if cronJob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName == "" { + t.Errorf("Expected ServiceAccountName to be set") + } + + // find the service account file + output, err := helmTemplate(ConvertOptions{ + OutputDir: "./chart", + }, "-s", "templates/cron/serviceaccount.yaml") + if err != nil { + t.Errorf("Error: %s", err) + } + serviceAccount := corev1.ServiceAccount{} + + if err := yaml.Unmarshal([]byte(output), &serviceAccount); err != nil { + t.Errorf(unmarshalError, err) + } + if serviceAccount.Name == "" { + t.Errorf("Expected ServiceAccountName to be set") + } + + // ensure that the serviceAccount is equal to the cronJob + if serviceAccount.Name != cronJob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName { + t.Errorf("Expected ServiceAccountName to be %s, got %s", cronJob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName, serviceAccount.Name) + } +} diff --git a/generator/deployment_test.go b/generator/deployment_test.go index f9df258..3dddb0a 100644 --- a/generator/deployment_test.go +++ b/generator/deployment_test.go @@ -5,16 +5,17 @@ import ( "testing" v1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" "sigs.k8s.io/yaml" ) func TestGenerate(t *testing.T) { - _compose_file := ` + compose_file := ` services: web: image: nginx:1.29 ` - tmpDir := setup(_compose_file) + tmpDir := setup(compose_file) defer teardown(tmpDir) currentDir, _ := os.Getwd() @@ -38,3 +39,99 @@ services: t.Errorf("Expected image to be nginx:1.29, got %s", dt.Spec.Template.Spec.Containers[0].Image) } } + +func TestGenerateOneDeploymentWithSamePod(t *testing.T) { + compose_file := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + + fpm: + image: php:fpm + ports: + - 9000:9000 + labels: + katenary.v3/same-pod: web +` + + tmpDir := setup(compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf(unmarshalError, err) + } + + if len(dt.Spec.Template.Spec.Containers) != 2 { + t.Errorf("Expected 2 containers, got %d", len(dt.Spec.Template.Spec.Containers)) + } + // endsure that the fpm service is not created + + var err error + output, err = helmTemplate(ConvertOptions{ + OutputDir: "./chart", + }, "-s", "templates/fpm/deployment.yaml") + if err == nil { + t.Errorf("Expected error, got nil") + } + + // ensure that the web service is created and has got 2 ports + output, err = helmTemplate(ConvertOptions{ + OutputDir: "./chart", + }, "-s", "templates/web/service.yaml") + if err != nil { + t.Errorf("Error: %s", err) + } + service := corev1.Service{} + if err := yaml.Unmarshal([]byte(output), &service); err != nil { + t.Errorf(unmarshalError, err) + } + + if len(service.Spec.Ports) != 2 { + t.Errorf("Expected 2 ports, got %d", len(service.Spec.Ports)) + } +} + +func TestDependsOn(t *testing.T) { + compose_file := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + depends_on: + - database + + database: + image: mariadb:10.5 + ports: + - 3306:3306 +` + tmpDir := setup(compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf(unmarshalError, err) + } + + if len(dt.Spec.Template.Spec.Containers) != 1 { + t.Errorf("Expected 1 container, got %d", len(dt.Spec.Template.Spec.Containers)) + } + // find an init container + if len(dt.Spec.Template.Spec.InitContainers) != 1 { + t.Errorf("Expected 1 init container, got %d", len(dt.Spec.Template.Spec.InitContainers)) + } +} diff --git a/generator/katenaryLabels_test.go b/generator/katenaryLabels_test.go new file mode 100644 index 0000000..4cfbcc3 --- /dev/null +++ b/generator/katenaryLabels_test.go @@ -0,0 +1,76 @@ +package generator + +import ( + _ "embed" + "reflect" + "testing" +) + +var testingKatenaryPrefix = Prefix() + +func TestPrefix(t *testing.T) { + tests := []struct { + name string + want string + }{ + { + name: "TestPrefix", + want: "katenary.v3", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Prefix(); got != tt.want { + t.Errorf("Prefix() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_labelName(t *testing.T) { + type args struct { + name string + } + tests := []struct { + name string + args args + want Label + }{ + { + name: "Test_labelName", + args: args{ + name: "main-app", + }, + want: testingKatenaryPrefix + "/main-app", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := labelName(tt.args.name); !reflect.DeepEqual(got, tt.want) { + t.Errorf("labelName() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetLabelHelp(t *testing.T) { + help := GetLabelHelp(false) + if help == "" { + t.Errorf("GetLabelHelp() = %v, want %v", help, "Help") + } + help = GetLabelHelp(true) + if help == "" { + t.Errorf("GetLabelHelp() = %v, want %v", help, "Help") + } +} + +func TestGetLabelHelpFor(t *testing.T) { + help := GetLabelHelpFor("main-app", false) + if help == "" { + t.Errorf("GetLabelHelpFor() = %v, want %v", help, "Help") + } + help = GetLabelHelpFor("main-app", true) + if help == "" { + t.Errorf("GetLabelHelpFor() = %v, want %v", help, "Help") + } +} diff --git a/generator/labelStructs/configMap.go b/generator/labelStructs/configMap.go new file mode 100644 index 0000000..5457618 --- /dev/null +++ b/generator/labelStructs/configMap.go @@ -0,0 +1,8 @@ +package labelstructs + +type CronJob struct { + Image string `yaml:"image,omitempty"` + Command string `yaml:"command"` + Schedule string `yaml:"schedule"` + Rbac bool `yaml:"rbac"` +} diff --git a/generator/volume_test.go b/generator/volume_test.go index 9d327d3..d2f49b9 100644 --- a/generator/volume_test.go +++ b/generator/volume_test.go @@ -11,7 +11,7 @@ import ( ) func TestGenerateWithBoundVolume(t *testing.T) { - _compose_file := ` + compose_file := ` services: web: image: nginx:1.29 @@ -20,7 +20,7 @@ services: volumes: data: ` - tmpDir := setup(_compose_file) + tmpDir := setup(compose_file) defer teardown(tmpDir) currentDir, _ := os.Getwd() @@ -40,7 +40,7 @@ volumes: } func TestWithStaticFiles(t *testing.T) { - _compose_file := ` + compose_file := ` services: web: image: nginx:1.29 @@ -50,8 +50,8 @@ services: %s/configmap-files: |- - ./static ` - _compose_file = fmt.Sprintf(_compose_file, katenaryLabelPrefix) - tmpDir := setup(_compose_file) + compose_file = fmt.Sprintf(compose_file, katenaryLabelPrefix) + tmpDir := setup(compose_file) defer teardown(tmpDir) // create a static directory with an index.html file @@ -98,3 +98,93 @@ services: t.Errorf("Expected index.html to be

Hello, World!

, got %s", data["index.html"]) } } + +func TestWithFileMapping(t *testing.T) { + compose_file := ` +services: + web: + image: nginx:1.29 + volumes: + - ./static/index.html:/var/www/index.html + labels: + %s/configmap-files: |- + - ./static/index.html +` + compose_file = fmt.Sprintf(compose_file, katenaryLabelPrefix) + tmpDir := setup(compose_file) + defer teardown(tmpDir) + + // create a static directory with an index.html file + staticDir := tmpDir + "/static" + os.Mkdir(staticDir, 0o755) + indexFile, err := os.Create(staticDir + "/index.html") + if err != nil { + t.Errorf("Failed to create index.html: %s", err) + } + indexFile.WriteString("

Hello, World!

") + indexFile.Close() + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf(unmarshalError, err) + } + + // get the volume mount path + volumeMountPath := dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].MountPath + if volumeMountPath != "/var/www/index.html" { + t.Errorf("Expected volume mount path to be /var/www/index.html, got %s", volumeMountPath) + } + // but this time, we need a subpath + subPath := dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].SubPath + if subPath != "index.html" { + t.Errorf("Expected subpath to be index.html, got %s", subPath) + } +} + +func TestBindFrom(t *testing.T) { + compose_file := ` +services: + web: + image: nginx:1.29 + volumes: + - data:/var/www + + fpm: + image: php:fpm + volumes: + - data:/var/www + labels: + %[1]s/ports: | + - 9000 + %[1]s/same-pod: web + +volumes: + data: +` + + compose_file = fmt.Sprintf(compose_file, katenaryLabelPrefix) + tmpDir := setup(compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf(unmarshalError, err) + } + // both containers should have the same volume mount + if dt.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name != "data" { + t.Errorf("Expected volume name to be data: %v", dt) + } + if dt.Spec.Template.Spec.Containers[1].VolumeMounts[0].Name != "data" { + t.Errorf("Expected volume name to be data: %v", dt) + } +} From 451a1341bd55af9755cf5230317782aa065b3c5b Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 24 Apr 2024 21:52:59 +0200 Subject: [PATCH 78/97] Do not check coverage on test file dude --- sonar-project.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/sonar-project.properties b/sonar-project.properties index 1ba7714..0d39c02 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,6 +7,7 @@ sonar.go.coverage.reportPaths=coverprofile.out # excludde sonar.exclusions=doc/** +sonar.coverage.exclusions=doc/**,**/*_test.go # This is the name and version displayed in the SonarCloud UI. #sonar.projectName=katenary From 0aa702394727a42ef4ed002cf6f3471335c86387 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 24 Apr 2024 21:53:24 +0200 Subject: [PATCH 79/97] Avoid repetition --- generator/converter.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/generator/converter.go b/generator/converter.go index 00b887b..236e75f 100644 --- a/generator/converter.go +++ b/generator/converter.go @@ -257,16 +257,18 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { f.Write([]byte(notes)) f.Close() - if config.HelmUpdate { - if err := helmUpdate(config); err != nil { + executeAndHandleError := func(fn func(ConvertOptions) error, config ConvertOptions, message string) { + if err := fn(config); err != nil { fmt.Println(utils.IconFailure, err) os.Exit(1) - } else if err := helmLint(config); err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } else { - fmt.Println(utils.IconSuccess, "Helm chart created successfully") } + fmt.Println(utils.IconSuccess, message) + } + + if config.HelmUpdate { + executeAndHandleError(helmUpdate, config, "Helm dependencies updated") + executeAndHandleError(helmLint, config, "Helm chart linted") + fmt.Println(utils.IconSuccess, "Helm chart created successfully") } } From d01a35e2d46faa1b13f08dacf736daf55af5f6d0 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 24 Apr 2024 23:06:45 +0200 Subject: [PATCH 80/97] Use real types to parse labels We were using `yaml.Unmarshal` on basic types or inline structs. This was not efficient and not clear to defined what we expect in labels. We now use types to unmarshal the labels. Only the `values` label is, at this time, parsed by GetValuesFromLabel because this `utils` function is clearly a special case. --- doc/docs/packages/generator.md | 75 +++----- doc/docs/packages/generator/labelStructs.md | 199 ++++++++++++++++++++ doc/docs/statics/logo-bright.png | Bin 0 -> 15020 bytes doc/docs/statics/logo-vertical.png | Bin 0 -> 14830 bytes doc/mkdocs.yml | 6 +- generator/chart.go | 11 +- generator/configMap.go | 15 +- generator/converter.go | 3 +- generator/cronJob.go | 18 +- generator/deployment.go | 20 +- generator/generator.go | 19 +- generator/ingress.go | 40 ++-- generator/ingress_test.go | 2 +- generator/labelStructs/configMap.go | 17 +- generator/labelStructs/cronJob.go | 18 ++ generator/labelStructs/dependencies.go | 21 +++ generator/labelStructs/doc.go | 2 + generator/labelStructs/envFrom.go | 14 ++ generator/labelStructs/ingress.go | 33 ++++ generator/labelStructs/mapenv.go | 14 ++ generator/labelStructs/ports.go | 14 ++ generator/labelStructs/probes.go | 19 ++ generator/labelStructs/secrets.go | 13 ++ 23 files changed, 435 insertions(+), 138 deletions(-) create mode 100644 doc/docs/packages/generator/labelStructs.md create mode 100644 doc/docs/statics/logo-bright.png create mode 100644 doc/docs/statics/logo-vertical.png create mode 100644 generator/labelStructs/cronJob.go create mode 100644 generator/labelStructs/dependencies.go create mode 100644 generator/labelStructs/doc.go create mode 100644 generator/labelStructs/envFrom.go create mode 100644 generator/labelStructs/ingress.go create mode 100644 generator/labelStructs/mapenv.go create mode 100644 generator/labelStructs/ports.go create mode 100644 generator/labelStructs/probes.go create mode 100644 generator/labelStructs/secrets.go diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index 4cd0260..7e9b482 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -35,7 +35,7 @@ var Version = "master" // changed at compile time ``` -## func [Convert]() +## func [Convert]() ```go func Convert(config ConvertOptions, dockerComposeFile ...string) @@ -116,7 +116,7 @@ func Prefix() string -## type [ChartTemplate]() +## type [ChartTemplate]() ChartTemplate is a template of a chart. It contains the content of the template and the name of the service. This is used internally to generate the templates. @@ -151,7 +151,7 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap NewConfigMap creates a new ConfigMap from a compose service. The appName is the name of the application taken from the project name. The ConfigMap is filled by environment variables and labels "map\-env". -### func [NewConfigMapFromDirectory]() +### func [NewConfigMapFromDirectory]() ```go func NewConfigMapFromDirectory(service types.ServiceConfig, appName string, path string) *ConfigMap @@ -160,7 +160,7 @@ func NewConfigMapFromDirectory(service types.ServiceConfig, appName string, path NewConfigMapFromDirectory creates a new ConfigMap from a compose service. This path is the path to the file or directory. If the path is a directory, all files in the directory are added to the ConfigMap. Each subdirectory are ignored. Note that the Generate\(\) function will create the subdirectories ConfigMaps. -### func \(\*ConfigMap\) [AddData]() +### func \(\*ConfigMap\) [AddData]() ```go func (c *ConfigMap) AddData(key string, value string) @@ -169,7 +169,7 @@ func (c *ConfigMap) AddData(key string, value string) AddData adds a key value pair to the configmap. Append or overwrite the value if the key already exists. -### func \(\*ConfigMap\) [AppendDir]() +### func \(\*ConfigMap\) [AppendDir]() ```go func (c *ConfigMap) AppendDir(path string) @@ -178,7 +178,7 @@ func (c *ConfigMap) AppendDir(path string) AddFile adds files from given path to the configmap. It is not recursive, to add all files in a directory, you need to call this function for each subdirectory. -### func \(\*ConfigMap\) [AppendFile]() +### func \(\*ConfigMap\) [AppendFile]() ```go func (c *ConfigMap) AppendFile(path string) @@ -187,7 +187,7 @@ func (c *ConfigMap) AppendFile(path string) -### func \(\*ConfigMap\) [Filename]() +### func \(\*ConfigMap\) [Filename]() ```go func (c *ConfigMap) Filename() string @@ -196,7 +196,7 @@ func (c *ConfigMap) Filename() string Filename returns the filename of the configmap. If the configmap is used for files, the filename contains the path. -### func \(\*ConfigMap\) [SetData]() +### func \(\*ConfigMap\) [SetData]() ```go func (c *ConfigMap) SetData(data map[string]string) @@ -205,7 +205,7 @@ func (c *ConfigMap) SetData(data map[string]string) SetData sets the data of the configmap. It replaces the entire data. -### func \(\*ConfigMap\) [Yaml]() +### func \(\*ConfigMap\) [Yaml]() ```go func (c *ConfigMap) Yaml() ([]byte, error) @@ -214,7 +214,7 @@ func (c *ConfigMap) Yaml() ([]byte, error) Yaml returns the yaml representation of the configmap -## type [ConfigMapMount]() +## type [ConfigMapMount]() @@ -225,7 +225,7 @@ type ConfigMapMount struct { ``` -## type [ConvertOptions]() +## type [ConvertOptions]() ConvertOptions are the options to convert a compose project to a helm chart. @@ -253,7 +253,7 @@ type CronJob struct { ``` -### func \(\*CronJob\) [Filename]() +### func \(\*CronJob\) [Filename]() ```go func (c *CronJob) Filename() string @@ -264,7 +264,7 @@ Filename returns the filename of the cronjob. Implements the Yaml interface. -### func \(\*CronJob\) [Yaml]() +### func \(\*CronJob\) [Yaml]() ```go func (c *CronJob) Yaml() ([]byte, error) @@ -309,23 +309,8 @@ func NewFileMap(service types.ServiceConfig, appName string, kind string) DataMa NewFileMap creates a new DataMap from a compose service. The appName is the name of the application taken from the project name. - -## type [Dependency]() - -Dependency is a dependency of a chart to other charts. - -```go -type Dependency struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - Repository string `yaml:"repository"` - Alias string `yaml:"alias,omitempty"` - Values map[string]any `yaml:"-"` // do not export to Chart.yaml -} -``` - -## type [Deployment]() +## type [Deployment]() Deployment is a kubernetes Deployment. @@ -337,7 +322,7 @@ type Deployment struct { ``` -### func [NewDeployment]() +### func [NewDeployment]() ```go func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment @@ -346,7 +331,7 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name. It also creates the Values map that will be used to create the values.yaml file. -### func \(\*Deployment\) [AddContainer]() +### func \(\*Deployment\) [AddContainer]() ```go func (d *Deployment) AddContainer(service types.ServiceConfig) @@ -355,7 +340,7 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) AddContainer adds a container to the deployment. -### func \(\*Deployment\) [AddHealthCheck]() +### func \(\*Deployment\) [AddHealthCheck]() ```go func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) @@ -364,7 +349,7 @@ func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *core -### func \(\*Deployment\) [AddIngress]() +### func \(\*Deployment\) [AddIngress]() ```go func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *Ingress @@ -373,7 +358,7 @@ func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *In AddIngress adds an ingress to the deployment. It creates the ingress object. -### func \(\*Deployment\) [AddVolumes]() +### func \(\*Deployment\) [AddVolumes]() ```go func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) @@ -382,7 +367,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. If the volume is a bind volume it will warn the user that it is not supported yet. -### func \(\*Deployment\) [BindFrom]() +### func \(\*Deployment\) [BindFrom]() ```go func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) @@ -391,7 +376,7 @@ func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) -### func \(\*Deployment\) [DependsOn]() +### func \(\*Deployment\) [DependsOn]() ```go func (d *Deployment) DependsOn(to *Deployment, servicename string) error @@ -400,7 +385,7 @@ func (d *Deployment) DependsOn(to *Deployment, servicename string) error DependsOn adds a initContainer to the deployment that will wait for the service to be up. -### func \(\*Deployment\) [Filename]() +### func \(\*Deployment\) [Filename]() ```go func (d *Deployment) Filename() string @@ -409,7 +394,7 @@ func (d *Deployment) Filename() string Filename returns the filename of the deployment. -### func \(\*Deployment\) [SetEnvFrom]() +### func \(\*Deployment\) [SetEnvFrom]() ```go func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) @@ -418,7 +403,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) SetEnvFrom sets the environment variables to a configmap. The configmap is created. -### func \(\*Deployment\) [Yaml]() +### func \(\*Deployment\) [Yaml]() ```go func (d *Deployment) Yaml() ([]byte, error) @@ -445,7 +430,7 @@ const ( ``` -## type [HelmChart]() +## type [HelmChart]() HelmChart is a Helm Chart representation. It contains all the tempaltes, values, versions, helpers... @@ -456,7 +441,7 @@ type HelmChart struct { Version string `yaml:"version"` AppVersion string `yaml:"appVersion"` Description string `yaml:"description"` - Dependencies []Dependency `yaml:"dependencies,omitempty"` + Dependencies []labelStructs.Dependency `yaml:"dependencies,omitempty"` Templates map[string]*ChartTemplate `yaml:"-"` // do not export to yaml Helper string `yaml:"-"` // do not export to yaml Values map[string]any `yaml:"-"` // do not export to yaml @@ -466,7 +451,7 @@ type HelmChart struct { ``` -### func [Generate]() +### func [Generate]() ```go func Generate(project *types.Project) (*HelmChart, error) @@ -486,7 +471,7 @@ The Generate function will create the HelmChart object this way: - Merge the same\-pod services. -### func [NewChart]() +### func [NewChart]() ```go func NewChart(name string) *HelmChart @@ -530,7 +515,7 @@ func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress NewIngress creates a new Ingress from a compose service. -### func \(\*Ingress\) [Filename]() +### func \(\*Ingress\) [Filename]() ```go func (ingress *Ingress) Filename() string @@ -539,7 +524,7 @@ func (ingress *Ingress) Filename() string -### func \(\*Ingress\) [Yaml]() +### func \(\*Ingress\) [Yaml]() ```go func (ingress *Ingress) Yaml() ([]byte, error) diff --git a/doc/docs/packages/generator/labelStructs.md b/doc/docs/packages/generator/labelStructs.md new file mode 100644 index 0000000..8a2d547 --- /dev/null +++ b/doc/docs/packages/generator/labelStructs.md @@ -0,0 +1,199 @@ + + +# labelStructs + +```go +import "katenary/generator/labelStructs" +``` + +labelStructs is a package that contains the structs used to represent the labels in the yaml files. + +## type [ConfigMapFile]() + + + +```go +type ConfigMapFile []string +``` + + +### func [ConfigMapFileFrom]() + +```go +func ConfigMapFileFrom(data string) (ConfigMapFile, error) +``` + + + + +## type [CronJob]() + + + +```go +type CronJob struct { + Image string `yaml:"image,omitempty"` + Command string `yaml:"command"` + Schedule string `yaml:"schedule"` + Rbac bool `yaml:"rbac"` +} +``` + + +### func [CronJobFrom]() + +```go +func CronJobFrom(data string) (*CronJob, error) +``` + + + + +## type [Dependency]() + +Dependency is a dependency of a chart to other charts. + +```go +type Dependency struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Repository string `yaml:"repository"` + Alias string `yaml:"alias,omitempty"` + Values map[string]any `yaml:"-"` // do not export to Chart.yaml +} +``` + + +### func [DependenciesFrom]() + +```go +func DependenciesFrom(data string) ([]Dependency, error) +``` + +DependenciesFrom returns a slice of dependencies from the given string. + + +## type [EnvFrom]() + + + +```go +type EnvFrom []string +``` + + +### func [EnvFromFrom]() + +```go +func EnvFromFrom(data string) (EnvFrom, error) +``` + +EnvFromFrom returns a EnvFrom from the given string. + + +## type [Ingress]() + + + +```go +type Ingress struct { + // Hostname is the hostname to match against the request. It can contain wildcards. + Hostname string `yaml:"hostname"` + // Path is the path to match against the request. It can contain wildcards. + Path string `yaml:"path"` + // Enabled is a flag to enable or disable the ingress. + Enabled bool `yaml:"enabled"` + // Class is the ingress class to use. + Class string `yaml:"class"` + // Port is the port to use. + Port *int32 `yaml:"port,omitempty"` + // Annotations is a list of key-value pairs to add to the ingress. + Annotations map[string]string `yaml:"annotations,omitempty"` +} +``` + + +### func [IngressFrom]() + +```go +func IngressFrom(data string) (*Ingress, error) +``` + +IngressFrom creates a new Ingress from a compose service. + + +## type [MapEnv]() + + + +```go +type MapEnv map[string]string +``` + + +### func [MapEnvFrom]() + +```go +func MapEnvFrom(data string) (MapEnv, error) +``` + +MapEnvFrom returns a MapEnv from the given string. + + +## type [Ports]() + + + +```go +type Ports []uint32 +``` + + +### func [PortsFrom]() + +```go +func PortsFrom(data string) (Ports, error) +``` + +PortsFrom returns a Ports from the given string. + + +## type [Probe]() + + + +```go +type Probe struct { + LivenessProbe *corev1.Probe `yaml:"livenessProbe,omitempty"` + ReadinessProbe *corev1.Probe `yaml:"readinessProbe,omitempty"` +} +``` + + +### func [ProbeFrom]() + +```go +func ProbeFrom(data string) (*Probe, error) +``` + + + + +## type [Secrets]() + + + +```go +type Secrets []string +``` + + +### func [SecretsFrom]() + +```go +func SecretsFrom(data string) (Secrets, error) +``` + + + +Generated by [gomarkdoc]() diff --git a/doc/docs/statics/logo-bright.png b/doc/docs/statics/logo-bright.png new file mode 100644 index 0000000000000000000000000000000000000000..4201c0393d81c2d14b61f4ec3b625089161d4fa5 GIT binary patch literal 15020 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

Hw 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 literal 0 HcmV?d00001 diff --git a/doc/docs/statics/logo-vertical.png b/doc/docs/statics/logo-vertical.png new file mode 100644 index 0000000000000000000000000000000000000000..ffbc5ef0b7b0d020afcb3147bf6be4f1a60377d6 GIT binary patch literal 14830 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 hostname - if hostname, ok := mapping["host"]; ok && hostname != "" { - mapping["hostname"] = hostname - } - Chart.Values[service.Name].(*Value).Ingress = &IngressValue{ - Enabled: mapping["enabled"].(bool), - Path: mapping["path"].(string), - Host: mapping["hostname"].(string), - Class: mapping["class"].(string), - Annotations: map[string]string{}, + Enabled: mapping.Enabled, + Path: mapping.Path, + Host: mapping.Hostname, + Class: mapping.Class, + Annotations: mapping.Annotations, } // ingressClassName := `{{ .Values.` + service.Name + `.ingress.class }}` ingressClassName := utils.TplValue(service.Name, "ingress.class") - servicePortName := utils.GetServiceNameByPort(int(mapping["port"].(int32))) + servicePortName := utils.GetServiceNameByPort(int(*mapping.Port)) ingressService := &networkv1.IngressServiceBackend{ Name: serviceName, Port: networkv1.ServiceBackendPort{}, @@ -83,7 +69,7 @@ func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress { if servicePortName != "" { ingressService.Port.Name = servicePortName } else { - ingressService.Port.Number = mapping["port"].(int32) + ingressService.Port.Number = *mapping.Port } ing := &Ingress{ diff --git a/generator/ingress_test.go b/generator/ingress_test.go index c81b2d4..7a039e1 100644 --- a/generator/ingress_test.go +++ b/generator/ingress_test.go @@ -19,7 +19,7 @@ services: - 443:443 labels: %s/ingress: |- - host: my.test.tld + hostname: my.test.tld port: 80 ` composeFile = fmt.Sprintf(composeFile, katenaryLabelPrefix) diff --git a/generator/labelStructs/configMap.go b/generator/labelStructs/configMap.go index 5457618..2b5112f 100644 --- a/generator/labelStructs/configMap.go +++ b/generator/labelStructs/configMap.go @@ -1,8 +1,13 @@ -package labelstructs +package labelStructs -type CronJob struct { - Image string `yaml:"image,omitempty"` - Command string `yaml:"command"` - Schedule string `yaml:"schedule"` - Rbac bool `yaml:"rbac"` +import "gopkg.in/yaml.v3" + +type ConfigMapFile []string + +func ConfigMapFileFrom(data string) (ConfigMapFile, error) { + var mapping ConfigMapFile + if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + return nil, err + } + return mapping, nil } diff --git a/generator/labelStructs/cronJob.go b/generator/labelStructs/cronJob.go new file mode 100644 index 0000000..8ec5dbd --- /dev/null +++ b/generator/labelStructs/cronJob.go @@ -0,0 +1,18 @@ +package labelStructs + +import "gopkg.in/yaml.v3" + +type CronJob struct { + Image string `yaml:"image,omitempty"` + Command string `yaml:"command"` + Schedule string `yaml:"schedule"` + Rbac bool `yaml:"rbac"` +} + +func CronJobFrom(data string) (*CronJob, error) { + var mapping CronJob + if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + return nil, err + } + return &mapping, nil +} diff --git a/generator/labelStructs/dependencies.go b/generator/labelStructs/dependencies.go new file mode 100644 index 0000000..bc94b30 --- /dev/null +++ b/generator/labelStructs/dependencies.go @@ -0,0 +1,21 @@ +package labelStructs + +import "gopkg.in/yaml.v3" + +// Dependency is a dependency of a chart to other charts. +type Dependency struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Repository string `yaml:"repository"` + Alias string `yaml:"alias,omitempty"` + Values map[string]any `yaml:"-"` // do not export to Chart.yaml +} + +// DependenciesFrom returns a slice of dependencies from the given string. +func DependenciesFrom(data string) ([]Dependency, error) { + var mapping []Dependency + if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + return nil, err + } + return mapping, nil +} diff --git a/generator/labelStructs/doc.go b/generator/labelStructs/doc.go new file mode 100644 index 0000000..5373fcf --- /dev/null +++ b/generator/labelStructs/doc.go @@ -0,0 +1,2 @@ +// labelStructs is a package that contains the structs used to represent the labels in the yaml files. +package labelStructs diff --git a/generator/labelStructs/envFrom.go b/generator/labelStructs/envFrom.go new file mode 100644 index 0000000..f2c8f2f --- /dev/null +++ b/generator/labelStructs/envFrom.go @@ -0,0 +1,14 @@ +package labelStructs + +import "gopkg.in/yaml.v3" + +type EnvFrom []string + +// EnvFromFrom returns a EnvFrom from the given string. +func EnvFromFrom(data string) (EnvFrom, error) { + var mapping EnvFrom + if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + return nil, err + } + return mapping, nil +} diff --git a/generator/labelStructs/ingress.go b/generator/labelStructs/ingress.go new file mode 100644 index 0000000..b01cd36 --- /dev/null +++ b/generator/labelStructs/ingress.go @@ -0,0 +1,33 @@ +package labelStructs + +import "gopkg.in/yaml.v3" + +type Ingress struct { + // Hostname is the hostname to match against the request. It can contain wildcards. + Hostname string `yaml:"hostname"` + // Path is the path to match against the request. It can contain wildcards. + Path string `yaml:"path"` + // Enabled is a flag to enable or disable the ingress. + Enabled bool `yaml:"enabled"` + // Class is the ingress class to use. + Class string `yaml:"class"` + // Port is the port to use. + Port *int32 `yaml:"port,omitempty"` + // Annotations is a list of key-value pairs to add to the ingress. + Annotations map[string]string `yaml:"annotations,omitempty"` +} + +// IngressFrom creates a new Ingress from a compose service. +func IngressFrom(data string) (*Ingress, error) { + mapping := Ingress{ + Hostname: "", + Path: "/", + Enabled: false, + Class: "-", + Port: nil, + } + if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + return nil, err + } + return &mapping, nil +} diff --git a/generator/labelStructs/mapenv.go b/generator/labelStructs/mapenv.go new file mode 100644 index 0000000..6b4cdfa --- /dev/null +++ b/generator/labelStructs/mapenv.go @@ -0,0 +1,14 @@ +package labelStructs + +import "gopkg.in/yaml.v3" + +type MapEnv map[string]string + +// MapEnvFrom returns a MapEnv from the given string. +func MapEnvFrom(data string) (MapEnv, error) { + var mapping MapEnv + if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + return nil, err + } + return mapping, nil +} diff --git a/generator/labelStructs/ports.go b/generator/labelStructs/ports.go new file mode 100644 index 0000000..253a075 --- /dev/null +++ b/generator/labelStructs/ports.go @@ -0,0 +1,14 @@ +package labelStructs + +import "gopkg.in/yaml.v3" + +type Ports []uint32 + +// PortsFrom returns a Ports from the given string. +func PortsFrom(data string) (Ports, error) { + var mapping Ports + if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + return nil, err + } + return mapping, nil +} diff --git a/generator/labelStructs/probes.go b/generator/labelStructs/probes.go new file mode 100644 index 0000000..2860c23 --- /dev/null +++ b/generator/labelStructs/probes.go @@ -0,0 +1,19 @@ +package labelStructs + +import ( + "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" +) + +type Probe struct { + LivenessProbe *corev1.Probe `yaml:"livenessProbe,omitempty"` + ReadinessProbe *corev1.Probe `yaml:"readinessProbe,omitempty"` +} + +func ProbeFrom(data string) (*Probe, error) { + var mapping Probe + if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + return nil, err + } + return &mapping, nil +} diff --git a/generator/labelStructs/secrets.go b/generator/labelStructs/secrets.go new file mode 100644 index 0000000..e5cfb36 --- /dev/null +++ b/generator/labelStructs/secrets.go @@ -0,0 +1,13 @@ +package labelStructs + +import "gopkg.in/yaml.v3" + +type Secrets []string + +func SecretsFrom(data string) (Secrets, error) { + var mapping Secrets + if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + return nil, err + } + return mapping, nil +} From e4f67dbd31796ffa6fa6b24b52cdf321965c1694 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 25 Apr 2024 00:18:04 +0200 Subject: [PATCH 81/97] Fix the parsing of probes --- generator/labelStructs/probes.go | 43 +++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/generator/labelStructs/probes.go b/generator/labelStructs/probes.go index 2860c23..91aae6f 100644 --- a/generator/labelStructs/probes.go +++ b/generator/labelStructs/probes.go @@ -1,6 +1,9 @@ package labelStructs import ( + "encoding/json" + "log" + "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" ) @@ -11,9 +14,43 @@ type Probe struct { } func ProbeFrom(data string) (*Probe, error) { - var mapping Probe - if err := yaml.Unmarshal([]byte(data), &mapping); err != nil { + mapping := Probe{} + tmp := map[string]any{} + err := yaml.Unmarshal([]byte(data), &tmp) + if err != nil { return nil, err } - return &mapping, nil + + if livenessProbe, ok := tmp["livenessProbe"]; ok { + livenessProbeBytes, err := json.Marshal(livenessProbe) + if err != nil { + log.Printf("Error marshalling livenessProbe: %v", err) + return nil, err + } + livenessProbe := &corev1.Probe{} + err = json.Unmarshal(livenessProbeBytes, livenessProbe) + if err != nil { + log.Printf("Error unmarshalling livenessProbe: %v", err) + return nil, err + } + mapping.LivenessProbe = livenessProbe + } + + if readinessProbe, ok := tmp["readinessProbe"]; ok { + readinessProbeBytes, err := json.Marshal(readinessProbe) + if err != nil { + log.Printf("Error marshalling readinessProbe: %v", err) + return nil, err + } + readinessProbe := &corev1.Probe{} + err = json.Unmarshal(readinessProbeBytes, readinessProbe) + if err != nil { + log.Printf("Error unmarshalling readinessProbe: %v", err) + return nil, err + } + mapping.ReadinessProbe = readinessProbe + + } + + return &mapping, err } From ccfebd1a700583e73581511aa60b9550fc400fa7 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 25 Apr 2024 00:18:57 +0200 Subject: [PATCH 82/97] We need helm linting at this time Because the linting makes the dependency update. We will need to split linting and dep update later. --- generator/tools_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/generator/tools_test.go b/generator/tools_test.go index b0fd7fa..6fbdd6b 100644 --- a/generator/tools_test.go +++ b/generator/tools_test.go @@ -38,7 +38,7 @@ func _compile_test(t *testing.T, options ...string) string { force := false outputDir := "./chart" profiles := make([]string, 0) - helmdepUpdate := false + helmdepUpdate := true var appVersion *string chartVersion := "0.1.0" convertOptions := ConvertOptions{ @@ -50,6 +50,7 @@ func _compile_test(t *testing.T, options ...string) string { ChartVersion: chartVersion, } Convert(convertOptions, "compose.yml") + // launch helm lint to check the generated chart if helmLint(convertOptions) != nil { t.Errorf("Failed to lint the generated chart") From d98268f45b1323aac48fd9585eb3e2c52f885993 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 25 Apr 2024 00:20:04 +0200 Subject: [PATCH 83/97] Add more tests on probes and dependencies --- generator/deployment_test.go | 196 +++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/generator/deployment_test.go b/generator/deployment_test.go index 3dddb0a..4f3ce05 100644 --- a/generator/deployment_test.go +++ b/generator/deployment_test.go @@ -1,6 +1,7 @@ package generator import ( + "fmt" "os" "testing" @@ -135,3 +136,198 @@ services: t.Errorf("Expected 1 init container, got %d", len(dt.Spec.Template.Spec.InitContainers)) } } + +func TestHelmDependencies(t *testing.T) { + compose_file := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + + mariadb: + image: mariadb:10.5 + ports: + - 3306:3306 + labels: + %s/dependencies: | + - name: mariadb + repository: oci://registry-1.docker.io/bitnamicharts + version: 18.x.X + + ` + compose_file = fmt.Sprintf(compose_file, Prefix()) + tmpDir := setup(compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf(unmarshalError, err) + } + + // ensure that there is no mariasb deployment + _, err := helmTemplate(ConvertOptions{ + OutputDir: "./chart", + }, "-s", "templates/mariadb/deployment.yaml") + if err == nil { + t.Errorf("Expected error, got nil") + } + + // check that Chart.yaml has the dependency + chart := HelmChart{} + chartFile := "./chart/Chart.yaml" + if _, err := os.Stat(chartFile); os.IsNotExist(err) { + t.Errorf("Chart.yaml does not exist") + } + chartContent, err := os.ReadFile(chartFile) + if err != nil { + t.Errorf("Error reading Chart.yaml: %s", err) + } + if err := yaml.Unmarshal(chartContent, &chart); err != nil { + t.Errorf(unmarshalError, err) + } + + if len(chart.Dependencies) != 1 { + t.Errorf("Expected 1 dependency, got %d", len(chart.Dependencies)) + } +} + +func TestLivenessProbesFromHealthCheck(t *testing.T) { + compose_file := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost"] + interval: 5s + timeout: 3s + retries: 3 + ` + tmpDir := setup(compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf(unmarshalError, err) + } + + if dt.Spec.Template.Spec.Containers[0].LivenessProbe == nil { + t.Errorf("Expected liveness probe to be set") + } +} + +func TestProbesFromLabels(t *testing.T) { + compose_file := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + labels: + %s/health-check: | + livenessProbe: + httpGet: + path: /healthz + port: 80 + readinessProbe: + httpGet: + path: /ready + port: 80 + ` + compose_file = fmt.Sprintf(compose_file, Prefix()) + tmpDir := setup(compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf(unmarshalError, err) + } + + if dt.Spec.Template.Spec.Containers[0].LivenessProbe == nil { + t.Errorf("Expected liveness probe to be set") + } + if dt.Spec.Template.Spec.Containers[0].ReadinessProbe == nil { + t.Errorf("Expected readiness probe to be set") + } + t.Logf("LivenessProbe: %+v", dt.Spec.Template.Spec.Containers[0].LivenessProbe) + + // ensure that the liveness probe is set to /healthz + if dt.Spec.Template.Spec.Containers[0].LivenessProbe.HTTPGet.Path != "/healthz" { + t.Errorf("Expected liveness probe path to be /healthz, got %s", dt.Spec.Template.Spec.Containers[0].LivenessProbe.HTTPGet.Path) + } + + // ensure that the readiness probe is set to /ready + if dt.Spec.Template.Spec.Containers[0].ReadinessProbe.HTTPGet.Path != "/ready" { + t.Errorf("Expected readiness probe path to be /ready, got %s", dt.Spec.Template.Spec.Containers[0].ReadinessProbe.HTTPGet.Path) + } +} + +func TestSetValues(t *testing.T) { + compose_file := ` +services: + web: + image: nginx:1.29 + environment: + FOO: bar + BAZ: qux + labels: + %s/values: | + - FOO +` + + compose_file = fmt.Sprintf(compose_file, Prefix()) + tmpDir := setup(compose_file) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := _compile_test(t, "-s", "templates/web/deployment.yaml") + dt := v1.Deployment{} + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf(unmarshalError, err) + } + + // readh the values.yaml, we must have FOO in web environment but not BAZ + valuesFile := "./chart/values.yaml" + if _, err := os.Stat(valuesFile); os.IsNotExist(err) { + t.Errorf("values.yaml does not exist") + } + valuesContent, err := os.ReadFile(valuesFile) + if err != nil { + t.Errorf("Error reading values.yaml: %s", err) + } + mapping := struct { + Web struct { + Environment map[string]string `yaml:"environment"` + } `yaml:"web"` + }{} + if err := yaml.Unmarshal(valuesContent, &mapping); err != nil { + t.Errorf(unmarshalError, err) + } + + if _, ok := mapping.Web.Environment["FOO"]; !ok { + t.Errorf("Expected FOO in web environment") + } + if _, ok := mapping.Web.Environment["BAZ"]; ok { + t.Errorf("Expected BAZ not in web environment") + } +} From 4367a017696c830f3824a64d2bd165f0f2e40423 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Mon, 6 May 2024 21:11:36 +0200 Subject: [PATCH 84/97] Big refactorization - reduce complexity - use better tools to format the code - add more tests - and too many things to list here We are rewriting for V3, so these commits are sometimes big and not fully detailed. Of course, further work will be more documented. --- cmd/katenary/main.go | 6 +- doc/docs/packages/cmd/katenary.md | 12 - doc/docs/packages/generator.md | 112 +++--- doc/docs/packages/generator/extrafiles.md | 2 +- doc/docs/packages/generator/labelStructs.md | 4 +- doc/docs/packages/utils.md | 50 ++- doc/docs/statics/main.css | 5 - generator/chart.go | 97 +++++- generator/configMap.go | 34 +- generator/converter.go | 357 +++++++++----------- generator/cronJob.go | 6 +- generator/cronJob_test.go | 8 +- generator/deployment.go | 222 ++++++------ generator/deployment_test.go | 57 ++-- generator/extrafiles/readme.go | 3 +- generator/generator.go | 10 +- generator/ingress.go | 6 +- generator/katenaryLabels.go | 4 +- generator/katenaryLabels_test.go | 10 +- generator/rbac.go | 4 +- generator/secret.go | 6 +- generator/service.go | 4 +- generator/values.go | 13 +- generator/volume.go | 28 +- update/update_test.go | 7 +- utils/utils.go | 28 +- 26 files changed, 582 insertions(+), 513 deletions(-) delete mode 100644 doc/docs/packages/cmd/katenary.md diff --git a/cmd/katenary/main.go b/cmd/katenary/main.go index 3f84d90..3243a6f 100644 --- a/cmd/katenary/main.go +++ b/cmd/katenary/main.go @@ -9,11 +9,11 @@ import ( "os" "strings" - "katenary/generator" - "katenary/utils" - "github.com/compose-spec/compose-go/cli" "github.com/spf13/cobra" + + "katenary/generator" + "katenary/utils" ) const longHelp = `Katenary is a tool to convert compose files to Helm Charts. diff --git a/doc/docs/packages/cmd/katenary.md b/doc/docs/packages/cmd/katenary.md deleted file mode 100644 index 9261889..0000000 --- a/doc/docs/packages/cmd/katenary.md +++ /dev/null @@ -1,12 +0,0 @@ - - -# katenary - -```go -import "katenary/cmd/katenary" -``` - -Katenary CLI, main package. - -This package is not intended to be imported. It contains the main function that build the command line with \`cobra\` package. - diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index 7e9b482..924a942 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -35,7 +35,7 @@ var Version = "master" // changed at compile time ``` -## func [Convert]() +## func [Convert]() ```go func Convert(config ConvertOptions, dockerComposeFile ...string) @@ -116,16 +116,14 @@ func Prefix() string -## type [ChartTemplate]() +## type [ChartTemplate]() ChartTemplate is a template of a chart. It contains the content of the template and the name of the service. This is used internally to generate the templates. -TODO: maybe we can set it private. - ```go type ChartTemplate struct { - Content []byte Servicename string + Content []byte } ``` @@ -151,25 +149,25 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap NewConfigMap creates a new ConfigMap from a compose service. The appName is the name of the application taken from the project name. The ConfigMap is filled by environment variables and labels "map\-env". -### func [NewConfigMapFromDirectory]() +### func [NewConfigMapFromDirectory]() ```go -func NewConfigMapFromDirectory(service types.ServiceConfig, appName string, path string) *ConfigMap +func NewConfigMapFromDirectory(service types.ServiceConfig, appName, path string) *ConfigMap ``` NewConfigMapFromDirectory creates a new ConfigMap from a compose service. This path is the path to the file or directory. If the path is a directory, all files in the directory are added to the ConfigMap. Each subdirectory are ignored. Note that the Generate\(\) function will create the subdirectories ConfigMaps. -### func \(\*ConfigMap\) [AddData]() +### func \(\*ConfigMap\) [AddData]() ```go -func (c *ConfigMap) AddData(key string, value string) +func (c *ConfigMap) AddData(key, value string) ``` AddData adds a key value pair to the configmap. Append or overwrite the value if the key already exists. -### func \(\*ConfigMap\) [AppendDir]() +### func \(\*ConfigMap\) [AppendDir]() ```go func (c *ConfigMap) AppendDir(path string) @@ -178,7 +176,7 @@ func (c *ConfigMap) AppendDir(path string) AddFile adds files from given path to the configmap. It is not recursive, to add all files in a directory, you need to call this function for each subdirectory. -### func \(\*ConfigMap\) [AppendFile]() +### func \(\*ConfigMap\) [AppendFile]() ```go func (c *ConfigMap) AppendFile(path string) @@ -187,7 +185,7 @@ func (c *ConfigMap) AppendFile(path string) -### func \(\*ConfigMap\) [Filename]() +### func \(\*ConfigMap\) [Filename]() ```go func (c *ConfigMap) Filename() string @@ -196,7 +194,7 @@ func (c *ConfigMap) Filename() string Filename returns the filename of the configmap. If the configmap is used for files, the filename contains the path. -### func \(\*ConfigMap\) [SetData]() +### func \(\*ConfigMap\) [SetData]() ```go func (c *ConfigMap) SetData(data map[string]string) @@ -205,7 +203,7 @@ func (c *ConfigMap) SetData(data map[string]string) SetData sets the data of the configmap. It replaces the entire data. -### func \(\*ConfigMap\) [Yaml]() +### func \(\*ConfigMap\) [Yaml]() ```go func (c *ConfigMap) Yaml() ([]byte, error) @@ -225,18 +223,18 @@ type ConfigMapMount struct { ``` -## type [ConvertOptions]() +## type [ConvertOptions]() ConvertOptions are the options to convert a compose project to a helm chart. ```go type ConvertOptions struct { - Force bool // Force the chart directory deletion if it already exists. - OutputDir string // The output directory of the chart. - Profiles []string // Profile to use for the conversion. - HelmUpdate bool // If true, the "helm dep update" command will be run after the chart generation. - AppVersion *string // Set the chart "appVersion" field. If nil, the version will be set to 0.1.0. - ChartVersion string // Set the chart "version" field. + AppVersion *string + OutputDir string + ChartVersion string + Profiles []string + Force bool + HelmUpdate bool } ``` @@ -275,7 +273,7 @@ Yaml returns the yaml representation of the cronjob. Implements the Yaml interface. -## type [CronJobValue]() +## type [CronJobValue]() CronJobValue is a cronjob configuration that will be saved in values.yaml. @@ -304,7 +302,7 @@ type DataMap interface { ### func [NewFileMap]() ```go -func NewFileMap(service types.ServiceConfig, appName string, kind string) DataMap +func NewFileMap(service types.ServiceConfig, appName, kind string) DataMap ``` NewFileMap creates a new DataMap from a compose service. The appName is the name of the application taken from the project name. @@ -340,7 +338,7 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) AddContainer adds a container to the deployment. -### func \(\*Deployment\) [AddHealthCheck]() +### func \(\*Deployment\) [AddHealthCheck]() ```go func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) @@ -367,7 +365,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. If the volume is a bind volume it will warn the user that it is not supported yet. -### func \(\*Deployment\) [BindFrom]() +### func \(\*Deployment\) [BindFrom]() ```go func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) @@ -385,7 +383,7 @@ func (d *Deployment) DependsOn(to *Deployment, servicename string) error DependsOn adds a initContainer to the deployment that will wait for the service to be up. -### func \(\*Deployment\) [Filename]() +### func \(\*Deployment\) [Filename]() ```go func (d *Deployment) Filename() string @@ -394,7 +392,7 @@ func (d *Deployment) Filename() string Filename returns the filename of the deployment. -### func \(\*Deployment\) [SetEnvFrom]() +### func \(\*Deployment\) [SetEnvFrom]() ```go func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) @@ -403,7 +401,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) SetEnvFrom sets the environment variables to a configmap. The configmap is created. -### func \(\*Deployment\) [Yaml]() +### func \(\*Deployment\) [Yaml]() ```go func (d *Deployment) Yaml() ([]byte, error) @@ -430,28 +428,29 @@ const ( ``` -## type [HelmChart]() +## type [HelmChart]() HelmChart is a Helm Chart representation. It contains all the tempaltes, values, versions, helpers... ```go type HelmChart struct { + Templates map[string]*ChartTemplate `yaml:"-"` + Values map[string]any `yaml:"-"` + VolumeMounts map[string]any `yaml:"-"` + Name string `yaml:"name"` ApiVersion string `yaml:"apiVersion"` Version string `yaml:"version"` AppVersion string `yaml:"appVersion"` Description string `yaml:"description"` + Helper string `yaml:"-"` Dependencies []labelStructs.Dependency `yaml:"dependencies,omitempty"` - Templates map[string]*ChartTemplate `yaml:"-"` // do not export to yaml - Helper string `yaml:"-"` // do not export to yaml - Values map[string]any `yaml:"-"` // do not export to yaml - VolumeMounts map[string]any `yaml:"-"` // do not export to yaml // contains filtered or unexported fields } ``` -### func [Generate]() +### func [Generate]() ```go func Generate(project *types.Project) (*HelmChart, error) @@ -471,7 +470,7 @@ The Generate function will create the HelmChart object this way: - Merge the same\-pod services. -### func [NewChart]() +### func [NewChart]() ```go func NewChart(name string) *HelmChart @@ -479,6 +478,15 @@ func NewChart(name string) *HelmChart NewChart creates a new empty chart with the given name. + +### func \(\*HelmChart\) [SaveTemplates]() + +```go +func (chart *HelmChart) SaveTemplates(templateDir string) +``` + +SaveTemplates the templates of the chart to the given directory. + ## type [Help]() @@ -533,17 +541,17 @@ func (ingress *Ingress) Yaml() ([]byte, error) -## type [IngressValue]() +## type [IngressValue]() IngressValue is a ingress configuration that will be saved in values.yaml. ```go type IngressValue struct { - Enabled bool `yaml:"enabled"` + Annotations map[string]string `yaml:"annotations"` Host string `yaml:"host"` Path string `yaml:"path"` Class string `yaml:"class"` - Annotations map[string]string `yaml:"annotations"` + Enabled bool `yaml:"enabled"` } ``` @@ -578,16 +586,16 @@ const ( ``` -## type [PersistenceValue]() +## type [PersistenceValue]() PersistenceValue is a persistence configuration that will be saved in values.yaml. ```go type PersistenceValue struct { - Enabled bool `yaml:"enabled"` StorageClass string `yaml:"storageClass"` Size string `yaml:"size"` AccessMode []string `yaml:"accessMode"` + Enabled bool `yaml:"enabled"` } ``` @@ -614,7 +622,7 @@ func NewRBAC(service types.ServiceConfig, appName string) *RBAC NewRBAC creates a new RBAC from a compose service. The appName is the name of the application taken from the project name. -## type [RepositoryValue]() +## type [RepositoryValue]() RepositoryValue is a docker repository image and tag that will be saved in values.yaml. @@ -712,7 +720,7 @@ NewSecret creates a new Secret from a compose service ### func \(\*Secret\) [AddData]() ```go -func (s *Secret) AddData(key string, value string) +func (s *Secret) AddData(key, value string) ``` AddData adds a key value pair to the secret. @@ -823,7 +831,7 @@ func (r *ServiceAccount) Yaml() ([]byte, error) -## type [Value]() +## type [Value]() Value will be saved in values.yaml. It contains configuraiton for all deployment and services. @@ -832,18 +840,18 @@ type Value struct { Repository *RepositoryValue `yaml:"repository,omitempty"` Persistence map[string]*PersistenceValue `yaml:"persistence,omitempty"` Ingress *IngressValue `yaml:"ingress,omitempty"` - ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"` Environment map[string]any `yaml:"environment,omitempty"` Replicas *uint32 `yaml:"replicas,omitempty"` CronJob *CronJobValue `yaml:"cronjob,omitempty"` NodeSelector map[string]string `yaml:"nodeSelector"` - ServiceAccount string `yaml:"serviceAccount"` Resources map[string]any `yaml:"resources"` + ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"` + ServiceAccount string `yaml:"serviceAccount"` } ``` -### func [NewValue]() +### func [NewValue]() ```go func NewValue(service types.ServiceConfig, main ...bool) *Value @@ -854,7 +862,7 @@ NewValue creates a new Value from a compose service. The value contains the nece If \`main\` is true, the tag will be empty because it will be set in the helm chart appVersion. -### func \(\*Value\) [AddIngress]() +### func \(\*Value\) [AddIngress]() ```go func (v *Value) AddIngress(host, path string) @@ -863,7 +871,7 @@ func (v *Value) AddIngress(host, path string) -### func \(\*Value\) [AddPersistence]() +### func \(\*Value\) [AddPersistence]() ```go func (v *Value) AddPersistence(volumeName string) @@ -872,7 +880,7 @@ func (v *Value) AddPersistence(volumeName string) AddPersistence adds persistence configuration to the Value. -## type [VolumeClaim]() +## type [VolumeClaim]() VolumeClaim is a kubernetes VolumeClaim. This is a PersistentVolumeClaim. @@ -884,7 +892,7 @@ type VolumeClaim struct { ``` -### func [NewVolumeClaim]() +### func [NewVolumeClaim]() ```go func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *VolumeClaim @@ -893,7 +901,7 @@ func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *Vo NewVolumeClaim creates a new VolumeClaim from a compose service. -### func \(\*VolumeClaim\) [Filename]() +### func \(\*VolumeClaim\) [Filename]() ```go func (v *VolumeClaim) Filename() string @@ -902,7 +910,7 @@ func (v *VolumeClaim) Filename() string Filename returns the suggested filename for a VolumeClaim. -### func \(\*VolumeClaim\) [Yaml]() +### func \(\*VolumeClaim\) [Yaml]() ```go func (v *VolumeClaim) Yaml() ([]byte, error) diff --git a/doc/docs/packages/generator/extrafiles.md b/doc/docs/packages/generator/extrafiles.md index aa76efe..123cd95 100644 --- a/doc/docs/packages/generator/extrafiles.md +++ b/doc/docs/packages/generator/extrafiles.md @@ -17,7 +17,7 @@ func NotesFile(services []string) string NotesFile returns the content of the note.txt file. -## func [ReadMeFile]() +## func [ReadMeFile]() ```go func ReadMeFile(charname, description string, values map[string]any) string diff --git a/doc/docs/packages/generator/labelStructs.md b/doc/docs/packages/generator/labelStructs.md index 8a2d547..4b86499 100644 --- a/doc/docs/packages/generator/labelStructs.md +++ b/doc/docs/packages/generator/labelStructs.md @@ -158,7 +158,7 @@ func PortsFrom(data string) (Ports, error) PortsFrom returns a Ports from the given string. -## type [Probe]() +## type [Probe]() @@ -170,7 +170,7 @@ type Probe struct { ``` -### func [ProbeFrom]() +### func [ProbeFrom]() ```go func ProbeFrom(data string) (*Probe, error) diff --git a/doc/docs/packages/utils.md b/doc/docs/packages/utils.md index 35f5e0c..bb31f13 100644 --- a/doc/docs/packages/utils.md +++ b/doc/docs/packages/utils.md @@ -8,7 +8,16 @@ import "katenary/utils" Utils package provides some utility functions used in katenary. It defines some constants and functions used in the whole project. -## func [CountStartingSpaces]() +## func [Confirm]() + +```go +func Confirm(question string, icon ...Icon) bool +``` + +Confirm asks a question and returns true if the answer is y. + + +## func [CountStartingSpaces]() ```go func CountStartingSpaces(line string) int @@ -16,8 +25,17 @@ func CountStartingSpaces(line string) int CountStartingSpaces counts the number of spaces at the beginning of a string. + +## func [EncodeBasicYaml]() + +```go +func EncodeBasicYaml(data any) ([]byte, error) +``` + +EncodeBasicYaml encodes a basic yaml from an interface. + -## func [GetContainerByName]() +## func [GetContainerByName]() ```go func GetContainerByName(name string, containers []corev1.Container) (*corev1.Container, int) @@ -26,7 +44,7 @@ func GetContainerByName(name string, containers []corev1.Container) (*corev1.Con GetContainerByName returns a container by name and its index in the array. It returns nil, \-1 if not found. -## func [GetKind]() +## func [GetKind]() ```go func GetKind(path string) (kind string) @@ -35,7 +53,7 @@ func GetKind(path string) (kind string) GetKind returns the kind of the resource from the file path. -## func [GetServiceNameByPort]() +## func [GetServiceNameByPort]() ```go func GetServiceNameByPort(port int) string @@ -44,7 +62,7 @@ func GetServiceNameByPort(port int) string GetServiceNameByPort returns the service name for a port. It the service name is not found, it returns an empty string. -## func [GetValuesFromLabel]() +## func [GetValuesFromLabel]() ```go func GetValuesFromLabel(service types.ServiceConfig, LabelValues string) map[string]*EnvConfig @@ -62,7 +80,7 @@ func HashComposefiles(files []string) (string, error) HashComposefiles returns a hash of the compose files. -## func [Int32Ptr]() +## func [Int32Ptr]() ```go func Int32Ptr(i int32) *int32 @@ -71,7 +89,7 @@ func Int32Ptr(i int32) *int32 Int32Ptr returns a pointer to an int32. -## func [MapKeys]() +## func [MapKeys]() ```go func MapKeys(m map[string]interface{}) []string @@ -80,7 +98,7 @@ func MapKeys(m map[string]interface{}) []string -## func [PathToName]() +## func [PathToName]() ```go func PathToName(path string) string @@ -89,7 +107,7 @@ func PathToName(path string) string PathToName converts a path to a kubernetes complient name. -## func [StrPtr]() +## func [StrPtr]() ```go func StrPtr(s string) *string @@ -98,7 +116,7 @@ func StrPtr(s string) *string StrPtr returns a pointer to a string. -## func [TplName]() +## func [TplName]() ```go func TplName(serviceName, appname string, suffix ...string) string @@ -107,7 +125,7 @@ func TplName(serviceName, appname string, suffix ...string) string TplName returns the name of the kubernetes resource as a template string. It is used in the templates and defined in \_helper.tpl file. -## func [TplValue]() +## func [TplValue]() ```go func TplValue(serviceName, variable string, pipes ...string) string @@ -125,7 +143,7 @@ func Warn(msg ...interface{}) Warn prints a warning message -## func [WordWrap]() +## func [WordWrap]() ```go func WordWrap(text string, lineWidth int) string @@ -134,7 +152,7 @@ func WordWrap(text string, lineWidth int) string WordWrap wraps a string to a given line width. Warning: it may break the string. You need to check the result. -## func [Wrap]() +## func [Wrap]() ```go func Wrap(src, above, below string) string @@ -143,7 +161,7 @@ func Wrap(src, above, below string) string Wrap wraps a string with a string above and below. It will respect the indentation of the src string. -## func [WrapBytes]() +## func [WrapBytes]() ```go func WrapBytes(src, above, below []byte) []byte @@ -152,14 +170,14 @@ func WrapBytes(src, above, below []byte) []byte WrapBytes wraps a byte array with a byte array above and below. It will respect the indentation of the src string. -## type [EnvConfig]() +## type [EnvConfig]() EnvConfig is a struct to hold the description of an environment variable. ```go type EnvConfig struct { - Description string Service types.ServiceConfig + Description string } ``` diff --git a/doc/docs/statics/main.css b/doc/docs/statics/main.css index 2394786..36cfaca 100644 --- a/doc/docs/statics/main.css +++ b/doc/docs/statics/main.css @@ -77,11 +77,6 @@ h3[id*="katenaryio"] { } /*Zoomable images*/ - -/*[data-md-color-scheme="slate"] #logo { - background-image: url("logo-bright.svg"); -}*/ - .zoomable svg { background-color: var(--md-default-bg-color); padding: 1rem; diff --git a/generator/chart.go b/generator/chart.go index 3e5904f..e9a13fa 100644 --- a/generator/chart.go +++ b/generator/chart.go @@ -1,30 +1,46 @@ package generator -import "katenary/generator/labelStructs" +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "katenary/generator/labelStructs" + "katenary/utils" +) + +// ConvertOptions are the options to convert a compose project to a helm chart. +type ConvertOptions struct { + AppVersion *string + OutputDir string + ChartVersion string + Profiles []string + Force bool + HelmUpdate bool +} // ChartTemplate is a template of a chart. It contains the content of the template and the name of the service. // This is used internally to generate the templates. -// -// TODO: maybe we can set it private. type ChartTemplate struct { - Content []byte Servicename string + Content []byte } // HelmChart is a Helm Chart representation. It contains all the // tempaltes, values, versions, helpers... type HelmChart struct { + Templates map[string]*ChartTemplate `yaml:"-"` + Values map[string]any `yaml:"-"` + VolumeMounts map[string]any `yaml:"-"` + composeHash *string `yaml:"-"` Name string `yaml:"name"` ApiVersion string `yaml:"apiVersion"` Version string `yaml:"version"` AppVersion string `yaml:"appVersion"` Description string `yaml:"description"` + Helper string `yaml:"-"` Dependencies []labelStructs.Dependency `yaml:"dependencies,omitempty"` - Templates map[string]*ChartTemplate `yaml:"-"` // do not export to yaml - Helper string `yaml:"-"` // do not export to yaml - Values map[string]any `yaml:"-"` // do not export to yaml - VolumeMounts map[string]any `yaml:"-"` // do not export to yaml - composeHash *string `yaml:"-"` // do not export to yaml } // NewChart creates a new empty chart with the given name. @@ -42,12 +58,59 @@ func NewChart(name string) *HelmChart { } } -// ConvertOptions are the options to convert a compose project to a helm chart. -type ConvertOptions struct { - Force bool // Force the chart directory deletion if it already exists. - OutputDir string // The output directory of the chart. - Profiles []string // Profile to use for the conversion. - HelmUpdate bool // If true, the "helm dep update" command will be run after the chart generation. - AppVersion *string // Set the chart "appVersion" field. If nil, the version will be set to 0.1.0. - ChartVersion string // Set the chart "version" field. +// SaveTemplates the templates of the chart to the given directory. +func (chart *HelmChart) SaveTemplates(templateDir string) { + for name, template := range chart.Templates { + t := template.Content + t = removeNewlinesInsideBrackets(t) + t = removeUnwantedLines(t) + t = addModeline(t) + + kind := utils.GetKind(name) + var icon utils.Icon + switch kind { + case "deployment": + icon = utils.IconPackage + case "service": + icon = utils.IconPlug + case "ingress": + icon = utils.IconWorld + case "volumeclaim": + icon = utils.IconCabinet + case "configmap": + icon = utils.IconConfig + case "secret": + icon = utils.IconSecret + default: + icon = utils.IconInfo + } + + servicename := template.Servicename + if err := os.MkdirAll(filepath.Join(templateDir, servicename), 0o755); err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + fmt.Println(icon, "Creating", kind, servicename) + // if the name is a path, create the directory + if strings.Contains(name, string(filepath.Separator)) { + name = filepath.Join(templateDir, name) + err := os.MkdirAll(filepath.Dir(name), 0o755) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + } else { + // remove the serivce name from the template name + name = strings.Replace(name, servicename+".", "", 1) + name = filepath.Join(templateDir, servicename, name) + } + f, err := os.Create(name) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + + f.Write(t) + f.Close() + } } diff --git a/generator/configMap.go b/generator/configMap.go index 66dca6a..5b726d4 100644 --- a/generator/configMap.go +++ b/generator/configMap.go @@ -7,13 +7,13 @@ import ( "regexp" "strings" - "katenary/generator/labelStructs" - "katenary/utils" - "github.com/compose-spec/compose-go/types" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" + + "katenary/generator/labelStructs" + "katenary/utils" ) // only used to check interface implementation @@ -23,7 +23,7 @@ var ( ) // NewFileMap creates a new DataMap from a compose service. The appName is the name of the application taken from the project name. -func NewFileMap(service types.ServiceConfig, appName string, kind string) DataMap { +func NewFileMap(service types.ServiceConfig, appName, kind string) DataMap { switch kind { case "configmap": return NewConfigMap(service, appName) @@ -47,8 +47,8 @@ const ( type ConfigMap struct { *corev1.ConfigMap service *types.ServiceConfig - usage FileMapUsage path string + usage FileMapUsage } // NewConfigMap creates a new ConfigMap from a compose service. The appName is the name of the application taken from the project name. @@ -75,13 +75,13 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { } // get the secrets from the labels - if secrets, err := labelStructs.SecretsFrom(service.Labels[LabelSecrets]); err != nil { + secrets, err := labelStructs.SecretsFrom(service.Labels[LabelSecrets]) + if err != nil { log.Fatal(err) - } else { - // drop the secrets from the environment - for _, secret := range secrets { - drop[secret] = true - } + } + // drop the secrets from the environment + for _, secret := range secrets { + drop[secret] = true } // get the label values from the labels varDescriptons := utils.GetValuesFromLabel(service, LabelValues) @@ -95,7 +95,6 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { done[value] = true continue } - // val := `{{ tpl .Values.` + service.Name + `.environment.` + value + ` $ }}` val := utils.TplValue(service.Name, "environment."+value) service.Environment[value] = &val } @@ -112,10 +111,9 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { } } for key, env := range service.Environment { - if _, ok := done[key]; ok { - continue - } - if _, ok := drop[key]; ok { + _, isDropped := drop[key] + _, isDone := done[key] + if isDropped || isDone { continue } cm.AddData(key, *env) @@ -127,7 +125,7 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { // NewConfigMapFromDirectory creates a new ConfigMap from a compose service. This path is the path to the // file or directory. If the path is a directory, all files in the directory are added to the ConfigMap. // Each subdirectory are ignored. Note that the Generate() function will create the subdirectories ConfigMaps. -func NewConfigMapFromDirectory(service types.ServiceConfig, appName string, path string) *ConfigMap { +func NewConfigMapFromDirectory(service types.ServiceConfig, appName, path string) *ConfigMap { normalized := path normalized = strings.TrimLeft(normalized, ".") normalized = strings.TrimLeft(normalized, "/") @@ -163,7 +161,7 @@ func (c *ConfigMap) SetData(data map[string]string) { } // AddData adds a key value pair to the configmap. Append or overwrite the value if the key already exists. -func (c *ConfigMap) AddData(key string, value string) { +func (c *ConfigMap) AddData(key, value string) { c.Data[key] = value } diff --git a/generator/converter.go b/generator/converter.go index 4a9a224..684301e 100644 --- a/generator/converter.go +++ b/generator/converter.go @@ -12,13 +12,12 @@ import ( "strings" "time" + "github.com/compose-spec/compose-go/types" + "katenary/generator/extrafiles" "katenary/generator/labelStructs" "katenary/parser" "katenary/utils" - - "github.com/compose-spec/compose-go/types" - goyaml "gopkg.in/yaml.v3" ) const headerHelp = `# This file is autogenerated by katenary @@ -76,10 +75,11 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { // check if the chart directory exists // if yes, prevent the user from overwriting it and ask for confirmation if _, err := os.Stat(config.OutputDir); err == nil { - fmt.Print(utils.IconWarning, " The chart directory "+config.OutputDir+" already exists, do you want to overwrite it? [y/N] ") - var answer string - fmt.Scanln(&answer) - if strings.ToLower(answer) != "y" { + overwrite := utils.Confirm( + "The chart directory "+config.OutputDir+" already exists, do you want to overwrite it?", + utils.IconWarning, + ) + if !overwrite { fmt.Println("Aborting") os.Exit(126) // 126 is the exit code for "Command invoked cannot execute" } @@ -109,168 +109,27 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { os.Exit(1) } - for name, template := range chart.Templates { - t := template.Content - t = removeNewlinesInsideBrackets(t) - t = removeUnwantedLines(t) - t = addModeline(t) + // write the templates to the disk + chart.SaveTemplates(templateDir) - kind := utils.GetKind(name) - var icon utils.Icon - switch kind { - case "deployment": - icon = utils.IconPackage - case "service": - icon = utils.IconPlug - case "ingress": - icon = utils.IconWorld - case "volumeclaim": - icon = utils.IconCabinet - case "configmap": - icon = utils.IconConfig - case "secret": - icon = utils.IconSecret - default: - icon = utils.IconInfo - } + // write the Chart.yaml file + buildCharYamlFile(chart, project, chartPath) - servicename := template.Servicename - if err := os.MkdirAll(filepath.Join(templateDir, servicename), 0o755); err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } - fmt.Println(icon, "Creating", kind, servicename) - // if the name is a path, create the directory - if strings.Contains(name, string(filepath.Separator)) { - name = filepath.Join(templateDir, name) - err := os.MkdirAll(filepath.Dir(name), 0o755) - if err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } - } else { - // remove the serivce name from the template name - name = strings.Replace(name, servicename+".", "", 1) - name = filepath.Join(templateDir, servicename, name) - } - f, err := os.Create(name) - if err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } + // build and write the values.yaml file + buildValues(chart, project, valuesPath) - f.Write(t) - f.Close() - } - - // calculate the sha1 hash of the services - buf := bytes.NewBuffer(nil) - encoder := goyaml.NewEncoder(buf) - encoder.SetIndent(2) - if err := encoder.Encode(chart); err != nil { - fmt.Println(err) - os.Exit(1) - } - - yamlChart := buf.Bytes() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - // concat chart adding a comment with hash of services on top - yamlChart = append([]byte(fmt.Sprintf("# compose hash (sha1): %s\n", *chart.composeHash)), yamlChart...) - // add the list of compose files - files := []string{} - for _, file := range project.ComposeFiles { - base := filepath.Base(file) - files = append(files, base) - } - yamlChart = append([]byte(fmt.Sprintf("# compose files: %s\n", strings.Join(files, ", "))), yamlChart...) - // add generated date - yamlChart = append([]byte(fmt.Sprintf("# generated at: %s\n", time.Now().Format(time.RFC3339))), yamlChart...) - - // document Chart.yaml file - yamlChart = addChartDoc(yamlChart, project) - - f, err := os.Create(chartPath) - if err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } - f.Write(yamlChart) - f.Close() - - buf.Reset() - encoder = goyaml.NewEncoder(buf) - encoder.SetIndent(2) - if err = encoder.Encode(&chart.Values); err != nil { - fmt.Println(err) - os.Exit(1) - } - values := buf.Bytes() - values = addDescriptions(values, *project) - values = addDependencyDescription(values, chart.Dependencies) - values = addCommentsToValues(values) - values = addStorageClassHelp(values) - values = addImagePullSecretsHelp(values) - values = addImagePullPolicyHelp(values) - values = addVariablesDoc(values, project) - values = addMainTagAppDoc(values, project) - values = addResourceHelp(values) - values = addYAMLSelectorPath(values) - values = append([]byte(headerHelp), values...) - - f, err = os.Create(valuesPath) - if err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } - f.Write(values) - f.Close() - - f, err = os.Create(helpersPath) - if err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } - f.Write([]byte(chart.Helper)) - f.Close() + // write the _helpers.tpl to the disk + writeContent(helpersPath, []byte(chart.Helper)) + // write the readme to the disk readme := extrafiles.ReadMeFile(chart.Name, chart.Description, chart.Values) - f, err = os.Create(readmePath) - if err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } - f.Write([]byte(readme)) - f.Close() + writeContent(readmePath, []byte(readme)) - services := make([]string, 0) - for _, service := range project.Services { - services = append(services, service.Name) - } - notes := extrafiles.NotesFile(services) - f, err = os.Create(notesPath) - if err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } - f.Write([]byte(notes)) - f.Close() + // get the list of services to write in the notes + buildNotesFile(project, notesPath) - executeAndHandleError := func(fn func(ConvertOptions) error, config ConvertOptions, message string) { - if err := fn(config); err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } - fmt.Println(utils.IconSuccess, message) - } - - if config.HelmUpdate { - executeAndHandleError(helmUpdate, config, "Helm dependencies updated") - executeAndHandleError(helmLint, config, "Helm chart linted") - fmt.Println(utils.IconSuccess, "Helm chart created successfully") - } + // call helm update if needed + callHelmUpdate(config) } const ingressClassHelp = `# Default value for ingress.class annotation @@ -501,31 +360,38 @@ func addResourceHelp(values []byte) []byte { func addVariablesDoc(values []byte, project *types.Project) []byte { lines := strings.Split(string(values), "\n") - currentService := "" for _, service := range project.Services { - variables := utils.GetValuesFromLabel(service, LabelValues) - for i, line := range lines { - if regexp.MustCompile(`(?m)^` + service.Name + `:`).MatchString(line) { - currentService = service.Name + lines = addDocToVariable(service, lines) + } + return []byte(strings.Join(lines, "\n")) +} + +func addDocToVariable(service types.ServiceConfig, lines []string) []string { + currentService := "" + variables := utils.GetValuesFromLabel(service, LabelValues) + for i, line := range lines { + // if the line is a service, it is a name followed by a colon + if regexp.MustCompile(`(?m)^` + service.Name + `:`).MatchString(line) { + currentService = service.Name + } + // for each variable in the service, add the description + for varname, variable := range variables { + if variable == nil { + continue } - for varname, variable := range variables { - if variable == nil { - continue - } - spaces := utils.CountStartingSpaces(line) - if regexp.MustCompile(`(?m)\s*`+varname+`:`).MatchString(line) && currentService == service.Name { + spaces := utils.CountStartingSpaces(line) + if regexp.MustCompile(`(?m)\s*`+varname+`:`).MatchString(line) && currentService == service.Name { - // add # to the beginning of the Description - doc := strings.ReplaceAll("\n"+variable.Description, "\n", "\n"+strings.Repeat(" ", spaces)+"# ") - doc = strings.TrimRight(doc, " ") - doc += "\n" + line + // add # to the beginning of the Description + doc := strings.ReplaceAll("\n"+variable.Description, "\n", "\n"+strings.Repeat(" ", spaces)+"# ") + doc = strings.TrimRight(doc, " ") + doc += "\n" + line - lines[i] = doc - } + lines[i] = doc } } } - return []byte(strings.Join(lines, "\n")) + return lines } const mainTagAppDoc = `This is the version of the main application. @@ -535,8 +401,6 @@ func addMainTagAppDoc(values []byte, project *types.Project) []byte { lines := strings.Split(string(values), "\n") for _, service := range project.Services { - inService := false - inRegistry := false // read the label LabelMainApp if v, ok := service.Labels[LabelMainApp]; !ok { continue @@ -546,29 +410,36 @@ func addMainTagAppDoc(values []byte, project *types.Project) []byte { fmt.Printf("%s Adding main tag app doc %s\n", utils.IconConfig, service.Name) } - for i, line := range lines { - if regexp.MustCompile(`^` + service.Name + `:`).MatchString(line) { - inService = true - } - if inService && regexp.MustCompile(`^\s*repository:.*`).MatchString(line) { - inRegistry = true - } - if inService && inRegistry { - if regexp.MustCompile(`^\s*tag: .*`).MatchString(line) { - spaces := utils.CountStartingSpaces(line) - doc := strings.ReplaceAll(mainTagAppDoc, "\n", "\n"+strings.Repeat(" ", spaces)+"# ") - doc = strings.Repeat(" ", spaces) + "# " + doc - - lines[i] = doc + "\n" + line + "\n" - break - } - } - } + lines = addMainAppDoc(lines, service) } return []byte(strings.Join(lines, "\n")) } +func addMainAppDoc(lines []string, service types.ServiceConfig) []string { + inService := false + inRegistry := false + for i, line := range lines { + if regexp.MustCompile(`^` + service.Name + `:`).MatchString(line) { + inService = true + } + if inService && regexp.MustCompile(`^\s*repository:.*`).MatchString(line) { + inRegistry = true + } + if inService && inRegistry { + if regexp.MustCompile(`^\s*tag: .*`).MatchString(line) { + spaces := utils.CountStartingSpaces(line) + doc := strings.ReplaceAll(mainTagAppDoc, "\n", "\n"+strings.Repeat(" ", spaces)+"# ") + doc = strings.Repeat(" ", spaces) + "# " + doc + + lines[i] = doc + "\n" + line + "\n" + break + } + } + } + return lines +} + func removeNewlinesInsideBrackets(values []byte) []byte { re, err := regexp.Compile(`(?s)\{\{(.*?)\}\}`) if err != nil { @@ -715,3 +586,89 @@ func addYAMLSelectorPath(values []byte) []byte { } return []byte(strings.Join(toReturn, "\n")) } + +func writeContent(path string, content []byte) { + f, err := os.Create(path) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + defer f.Close() + f.Write(content) +} + +func buildValues(chart *HelmChart, project *types.Project, valuesPath string) { + values, err := utils.EncodeBasicYaml(&chart.Values) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + values = addDescriptions(values, *project) + values = addDependencyDescription(values, chart.Dependencies) + values = addCommentsToValues(values) + values = addStorageClassHelp(values) + values = addImagePullSecretsHelp(values) + values = addImagePullPolicyHelp(values) + values = addVariablesDoc(values, project) + values = addMainTagAppDoc(values, project) + values = addResourceHelp(values) + values = addYAMLSelectorPath(values) + values = append([]byte(headerHelp), values...) + + // add vim modeline + values = append(values, []byte("\n# vim: ft=yaml\n")...) + + // write the values to the disk + writeContent(valuesPath, values) +} + +func buildNotesFile(project *types.Project, notesPath string) { + // get the list of services to write in the notes + services := make([]string, 0) + for _, service := range project.Services { + services = append(services, service.Name) + } + // write the notes to the disk + notes := extrafiles.NotesFile(services) + writeContent(notesPath, []byte(notes)) +} + +func buildCharYamlFile(chart *HelmChart, project *types.Project, chartPath string) { + // calculate the sha1 hash of the services + yamlChart, err := utils.EncodeBasicYaml(chart) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + // concat chart adding a comment with hash of services on top + yamlChart = append([]byte(fmt.Sprintf("# compose hash (sha1): %s\n", *chart.composeHash)), yamlChart...) + // add the list of compose files + files := []string{} + for _, file := range project.ComposeFiles { + base := filepath.Base(file) + files = append(files, base) + } + yamlChart = append([]byte(fmt.Sprintf("# compose files: %s\n", strings.Join(files, ", "))), yamlChart...) + // add generated date + yamlChart = append([]byte(fmt.Sprintf("# generated at: %s\n", time.Now().Format(time.RFC3339))), yamlChart...) + + // document Chart.yaml file + yamlChart = addChartDoc(yamlChart, project) + + writeContent(chartPath, yamlChart) +} + +func callHelmUpdate(config ConvertOptions) { + executeAndHandleError := func(fn func(ConvertOptions) error, config ConvertOptions, message string) { + if err := fn(config); err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + fmt.Println(utils.IconSuccess, message) + } + if config.HelmUpdate { + executeAndHandleError(helmUpdate, config, "Helm dependencies updated") + executeAndHandleError(helmLint, config, "Helm chart linted") + fmt.Println(utils.IconSuccess, "Helm chart created successfully") + } +} diff --git a/generator/cronJob.go b/generator/cronJob.go index 55552d9..69411ea 100644 --- a/generator/cronJob.go +++ b/generator/cronJob.go @@ -4,14 +4,14 @@ import ( "log" "strings" - "katenary/generator/labelStructs" - "katenary/utils" - "github.com/compose-spec/compose-go/types" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" + + "katenary/generator/labelStructs" + "katenary/utils" ) // only used to check interface implementation diff --git a/generator/cronJob_test.go b/generator/cronJob_test.go index cb7ee0c..b892726 100644 --- a/generator/cronJob_test.go +++ b/generator/cronJob_test.go @@ -12,7 +12,7 @@ import ( ) func TestBasicCronJob(t *testing.T) { - compose_file := ` + composeFile := ` services: cron: image: fedora @@ -23,7 +23,7 @@ services: schedule: "*/1 * * * *" rbac: false ` - tmpDir := setup(compose_file) + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() @@ -64,7 +64,7 @@ services: } func TestCronJobbWithRBAC(t *testing.T) { - compose_file := ` + composeFile := ` services: cron: image: fedora @@ -76,7 +76,7 @@ services: rbac: true ` - tmpDir := setup(compose_file) + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() diff --git a/generator/deployment.go b/generator/deployment.go index fb593c3..5e2c637 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -9,14 +9,14 @@ import ( "strings" "time" - "katenary/generator/labelStructs" - "katenary/utils" - "github.com/compose-spec/compose-go/types" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" + + "katenary/generator/labelStructs" + "katenary/utils" ) var _ Yaml = (*Deployment)(nil) @@ -204,117 +204,127 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { isSamePod = v != "" } + for _, volume := range service.Volumes { + d.bindVolumes(volume, isSamePod, tobind, service, appName) + } +} + +func (d *Deployment) bindVolumes(volume types.ServiceVolumeConfig, isSamePod bool, tobind map[string]bool, service types.ServiceConfig, appName string) { container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) defer func(d *Deployment, container *corev1.Container, index int) { d.Spec.Template.Spec.Containers[index] = *container }(d, container, index) + if _, ok := tobind[volume.Source]; !isSamePod && volume.Type == "bind" && !ok { + utils.Warn( + "Bind volumes are not supported yet, " + + "excepting for those declared as " + + LabelConfigMapFiles + + ", skipping volume " + volume.Source + + " from service " + service.Name, + ) + return + } - for _, volume := range service.Volumes { - // not declared as a bind volume, skip - if _, ok := tobind[volume.Source]; !isSamePod && volume.Type == "bind" && !ok { - utils.Warn( - "Bind volumes are not supported yet, " + - "excepting for those declared as " + - LabelConfigMapFiles + - ", skipping volume " + volume.Source + - " from service " + service.Name, - ) - continue - } + if container == nil { + utils.Warn("Container not found for volume", volume.Source) + return + } - if container == nil { - utils.Warn("Container not found for volume", volume.Source) - continue - } - - // ensure that the volume is not already present in the container - for _, vm := range container.VolumeMounts { - if vm.Name == volume.Source { - continue - } - } - - switch volume.Type { - case "volume": - // Add volume to container - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: volume.Source, - MountPath: volume.Target, - }) - // Add volume to values.yaml only if it the service is not in the same pod that another service. - // If it is in the same pod, the volume will be added to the other service later - if _, ok := service.Labels[LabelSamePod]; !ok { - d.chart.Values[service.Name].(*Value).AddPersistence(volume.Source) - } - // Add volume to deployment - d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ - Name: volume.Source, - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: utils.TplName(service.Name, appName, volume.Source), - }, - }, - }) - case "bind": - // Add volume to container - stat, err := os.Stat(volume.Source) - if err != nil { - log.Fatal(err) - } - - if stat.IsDir() { - pathnme := utils.PathToName(volume.Source) - if _, ok := d.configMaps[pathnme]; !ok { - d.configMaps[pathnme] = &ConfigMapMount{ - mountPath: []mountPathConfig{}, - } - } - - // TODO: make it recursive to add all files in the directory and subdirectories - _, err := os.ReadDir(volume.Source) - if err != nil { - log.Fatal(err) - } - cm := NewConfigMapFromDirectory(service, appName, volume.Source) - d.configMaps[pathnme] = &ConfigMapMount{ - configMap: cm, - mountPath: append(d.configMaps[pathnme].mountPath, mountPathConfig{ - mountPath: volume.Target, - }), - } - } else { - // In case of a file, add it to the configmap and use "subPath" to mount it - // Note that the volumes and volume mounts are not added to the deployment yet, they will be added later - // in generate.go - dirname := filepath.Dir(volume.Source) - pathname := utils.PathToName(dirname) - var cm *ConfigMap - if v, ok := d.configMaps[pathname]; !ok { - cm = NewConfigMap(*d.service, appName) - cm.usage = FileMapUsageFiles - cm.path = dirname - cm.Name = utils.TplName(service.Name, appName) + "-" + pathname - d.configMaps[pathname] = &ConfigMapMount{ - configMap: cm, - mountPath: []mountPathConfig{{ - mountPath: volume.Target, - subPath: filepath.Base(volume.Source), - }}, - } - } else { - cm = v.configMap - mp := d.configMaps[pathname].mountPath - mp = append(mp, mountPathConfig{ - mountPath: volume.Target, - subPath: filepath.Base(volume.Source), - }) - d.configMaps[pathname].mountPath = mp - - } - cm.AppendFile(volume.Source) - } + // ensure that the volume is not already present in the container + for _, vm := range container.VolumeMounts { + if vm.Name == volume.Source { + return } } + + switch volume.Type { + case "volume": + // Add volume to container + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: volume.Source, + MountPath: volume.Target, + }) + // Add volume to values.yaml only if it the service is not in the same pod that another service. + // If it is in the same pod, the volume will be added to the other service later + if _, ok := service.Labels[LabelSamePod]; !ok { + d.chart.Values[service.Name].(*Value).AddPersistence(volume.Source) + } + // Add volume to deployment + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volume.Source, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: utils.TplName(service.Name, appName, volume.Source), + }, + }, + }) + case "bind": + // Add volume to container + stat, err := os.Stat(volume.Source) + if err != nil { + log.Fatal(err) + } + + if stat.IsDir() { + d.appendDirectoryToConfigMap(service, appName, volume) + } else { + d.appendFileToConfigMap(service, appName, volume) + } + } +} + +func (d *Deployment) appendDirectoryToConfigMap(service types.ServiceConfig, appName string, volume types.ServiceVolumeConfig) { + pathnme := utils.PathToName(volume.Source) + if _, ok := d.configMaps[pathnme]; !ok { + d.configMaps[pathnme] = &ConfigMapMount{ + mountPath: []mountPathConfig{}, + } + } + + // TODO: make it recursive to add all files in the directory and subdirectories + _, err := os.ReadDir(volume.Source) + if err != nil { + log.Fatal(err) + } + cm := NewConfigMapFromDirectory(service, appName, volume.Source) + d.configMaps[pathnme] = &ConfigMapMount{ + configMap: cm, + mountPath: append(d.configMaps[pathnme].mountPath, mountPathConfig{ + mountPath: volume.Target, + }), + } +} + +func (d *Deployment) appendFileToConfigMap(service types.ServiceConfig, appName string, volume types.ServiceVolumeConfig) { + // In case of a file, add it to the configmap and use "subPath" to mount it + // Note that the volumes and volume mounts are not added to the deployment yet, they will be added later + // in generate.go + dirname := filepath.Dir(volume.Source) + pathname := utils.PathToName(dirname) + var cm *ConfigMap + if v, ok := d.configMaps[pathname]; !ok { + cm = NewConfigMap(*d.service, appName) + cm.usage = FileMapUsageFiles + cm.path = dirname + cm.Name = utils.TplName(service.Name, appName) + "-" + pathname + d.configMaps[pathname] = &ConfigMapMount{ + configMap: cm, + mountPath: []mountPathConfig{{ + mountPath: volume.Target, + subPath: filepath.Base(volume.Source), + }}, + } + } else { + cm = v.configMap + mp := d.configMaps[pathname].mountPath + mp = append(mp, mountPathConfig{ + mountPath: volume.Target, + subPath: filepath.Base(volume.Source), + }) + d.configMaps[pathname].mountPath = mp + + } + cm.AppendFile(volume.Source) } func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) { diff --git a/generator/deployment_test.go b/generator/deployment_test.go index 4f3ce05..5c716c2 100644 --- a/generator/deployment_test.go +++ b/generator/deployment_test.go @@ -10,20 +10,22 @@ import ( "sigs.k8s.io/yaml" ) +const webTemplateOutput = `templates/web/deployment.yaml` + func TestGenerate(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 ` - tmpDir := setup(compose_file) + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := _compile_test(t, "-s", webTemplateOutput) // dt := DeploymentTest{} dt := v1.Deployment{} @@ -42,7 +44,7 @@ services: } func TestGenerateOneDeploymentWithSamePod(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 @@ -57,14 +59,15 @@ services: katenary.v3/same-pod: web ` - tmpDir := setup(compose_file) + outDir := "./chart" + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := _compile_test(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -76,8 +79,8 @@ services: // endsure that the fpm service is not created var err error - output, err = helmTemplate(ConvertOptions{ - OutputDir: "./chart", + _, err = helmTemplate(ConvertOptions{ + OutputDir: outDir, }, "-s", "templates/fpm/deployment.yaml") if err == nil { t.Errorf("Expected error, got nil") @@ -85,7 +88,7 @@ services: // ensure that the web service is created and has got 2 ports output, err = helmTemplate(ConvertOptions{ - OutputDir: "./chart", + OutputDir: outDir, }, "-s", "templates/web/service.yaml") if err != nil { t.Errorf("Error: %s", err) @@ -101,7 +104,7 @@ services: } func TestDependsOn(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 @@ -115,14 +118,14 @@ services: ports: - 3306:3306 ` - tmpDir := setup(compose_file) + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := _compile_test(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -138,7 +141,7 @@ services: } func TestHelmDependencies(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 @@ -156,15 +159,15 @@ services: version: 18.x.X ` - compose_file = fmt.Sprintf(compose_file, Prefix()) - tmpDir := setup(compose_file) + composeFile = fmt.Sprintf(composeFile, Prefix()) + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := _compile_test(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -198,7 +201,7 @@ services: } func TestLivenessProbesFromHealthCheck(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 @@ -210,14 +213,14 @@ services: timeout: 3s retries: 3 ` - tmpDir := setup(compose_file) + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := _compile_test(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -229,7 +232,7 @@ services: } func TestProbesFromLabels(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 @@ -246,15 +249,15 @@ services: path: /ready port: 80 ` - compose_file = fmt.Sprintf(compose_file, Prefix()) - tmpDir := setup(compose_file) + composeFile = fmt.Sprintf(composeFile, Prefix()) + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := _compile_test(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -280,7 +283,7 @@ services: } func TestSetValues(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 @@ -292,15 +295,15 @@ services: - FOO ` - compose_file = fmt.Sprintf(compose_file, Prefix()) - tmpDir := setup(compose_file) + composeFile = fmt.Sprintf(composeFile, Prefix()) + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := _compile_test(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) diff --git a/generator/extrafiles/readme.go b/generator/extrafiles/readme.go index 5d6c9db..01c54a2 100644 --- a/generator/extrafiles/readme.go +++ b/generator/extrafiles/readme.go @@ -2,13 +2,12 @@ package extrafiles import ( "bytes" + _ "embed" "fmt" "sort" "strings" "text/template" - _ "embed" - "gopkg.in/yaml.v3" ) diff --git a/generator/generator.go b/generator/generator.go index 185e0ef..d6c479c 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -1,7 +1,5 @@ package generator -// TODO: configmap from files 20% - import ( "bytes" "fmt" @@ -10,11 +8,11 @@ import ( "strconv" "strings" - "katenary/generator/labelStructs" - "katenary/utils" - "github.com/compose-spec/compose-go/types" corev1 "k8s.io/api/core/v1" + + "katenary/generator/labelStructs" + "katenary/utils" ) // Generate a chart from a compose project. @@ -388,7 +386,7 @@ func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map y, _ := pvc.Yaml() chart.Templates[pvc.Filename()] = &ChartTemplate{ Content: y, - Servicename: service.Name, // TODO, use name + Servicename: service.Name, } } } diff --git a/generator/ingress.go b/generator/ingress.go index 02f0aae..669593a 100644 --- a/generator/ingress.go +++ b/generator/ingress.go @@ -4,13 +4,13 @@ import ( "log" "strings" - "katenary/generator/labelStructs" - "katenary/utils" - "github.com/compose-spec/compose-go/types" networkv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" + + "katenary/generator/labelStructs" + "katenary/utils" ) var _ Yaml = (*Ingress)(nil) diff --git a/generator/katenaryLabels.go b/generator/katenaryLabels.go index d26d08d..c1f3448 100644 --- a/generator/katenaryLabels.go +++ b/generator/katenaryLabels.go @@ -10,9 +10,9 @@ import ( "text/tabwriter" "text/template" - "katenary/utils" - "sigs.k8s.io/yaml" + + "katenary/utils" ) var ( diff --git a/generator/katenaryLabels_test.go b/generator/katenaryLabels_test.go index 4cfbcc3..f6aa73c 100644 --- a/generator/katenaryLabels_test.go +++ b/generator/katenaryLabels_test.go @@ -8,6 +8,8 @@ import ( var testingKatenaryPrefix = Prefix() +const mainAppLabel = "main-app" + func TestPrefix(t *testing.T) { tests := []struct { name string @@ -27,7 +29,7 @@ func TestPrefix(t *testing.T) { } } -func Test_labelName(t *testing.T) { +func TestLabelName(t *testing.T) { type args struct { name string } @@ -39,9 +41,9 @@ func Test_labelName(t *testing.T) { { name: "Test_labelName", args: args{ - name: "main-app", + name: mainAppLabel, }, - want: testingKatenaryPrefix + "/main-app", + want: testingKatenaryPrefix + "/" + mainAppLabel, }, } for _, tt := range tests { @@ -65,7 +67,7 @@ func TestGetLabelHelp(t *testing.T) { } func TestGetLabelHelpFor(t *testing.T) { - help := GetLabelHelpFor("main-app", false) + help := GetLabelHelpFor(mainAppLabel, false) if help == "" { t.Errorf("GetLabelHelpFor() = %v, want %v", help, "Help") } diff --git a/generator/rbac.go b/generator/rbac.go index 8d0df76..f8295ab 100644 --- a/generator/rbac.go +++ b/generator/rbac.go @@ -1,13 +1,13 @@ package generator import ( - "katenary/utils" - "github.com/compose-spec/compose-go/types" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" + + "katenary/utils" ) var ( diff --git a/generator/secret.go b/generator/secret.go index 9a6122f..e26869b 100644 --- a/generator/secret.go +++ b/generator/secret.go @@ -5,12 +5,12 @@ import ( "fmt" "strings" - "katenary/utils" - "github.com/compose-spec/compose-go/types" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" + + "katenary/utils" ) var ( @@ -84,7 +84,7 @@ func (s *Secret) SetData(data map[string]string) { } // AddData adds a key value pair to the secret. -func (s *Secret) AddData(key string, value string) { +func (s *Secret) AddData(key, value string) { if value == "" { return } diff --git a/generator/service.go b/generator/service.go index a573f4d..1951a33 100644 --- a/generator/service.go +++ b/generator/service.go @@ -4,13 +4,13 @@ import ( "regexp" "strings" - "katenary/utils" - "github.com/compose-spec/compose-go/types" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/yaml" + + "katenary/utils" ) var _ Yaml = (*Service)(nil) diff --git a/generator/values.go b/generator/values.go index 8b26b39..59b4ca5 100644 --- a/generator/values.go +++ b/generator/values.go @@ -6,9 +6,6 @@ import ( "github.com/compose-spec/compose-go/types" ) -// Values is a map of all values for all services. Written to values.yaml. -// var Values = map[string]any{} - // RepositoryValue is a docker repository image and tag that will be saved in values.yaml. type RepositoryValue struct { Image string `yaml:"image"` @@ -17,19 +14,19 @@ type RepositoryValue struct { // PersistenceValue is a persistence configuration that will be saved in values.yaml. type PersistenceValue struct { - Enabled bool `yaml:"enabled"` StorageClass string `yaml:"storageClass"` Size string `yaml:"size"` AccessMode []string `yaml:"accessMode"` + Enabled bool `yaml:"enabled"` } // IngressValue is a ingress configuration that will be saved in values.yaml. type IngressValue struct { - Enabled bool `yaml:"enabled"` + Annotations map[string]string `yaml:"annotations"` Host string `yaml:"host"` Path string `yaml:"path"` Class string `yaml:"class"` - Annotations map[string]string `yaml:"annotations"` + Enabled bool `yaml:"enabled"` } // Value will be saved in values.yaml. It contains configuraiton for all deployment and services. @@ -37,13 +34,13 @@ type Value struct { Repository *RepositoryValue `yaml:"repository,omitempty"` Persistence map[string]*PersistenceValue `yaml:"persistence,omitempty"` Ingress *IngressValue `yaml:"ingress,omitempty"` - ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"` Environment map[string]any `yaml:"environment,omitempty"` Replicas *uint32 `yaml:"replicas,omitempty"` CronJob *CronJobValue `yaml:"cronjob,omitempty"` NodeSelector map[string]string `yaml:"nodeSelector"` - ServiceAccount string `yaml:"serviceAccount"` Resources map[string]any `yaml:"resources"` + ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"` + ServiceAccount string `yaml:"serviceAccount"` } // CronJobValue is a cronjob configuration that will be saved in values.yaml. diff --git a/generator/volume.go b/generator/volume.go index 133f32d..39a02eb 100644 --- a/generator/volume.go +++ b/generator/volume.go @@ -3,17 +3,19 @@ package generator import ( "strings" - "katenary/utils" - "github.com/compose-spec/compose-go/types" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" + + "katenary/utils" ) var _ Yaml = (*VolumeClaim)(nil) +const persistenceKey = "persistence" + // VolumeClaim is a kubernetes VolumeClaim. This is a PersistentVolumeClaim. type VolumeClaim struct { *v1.PersistentVolumeClaim @@ -41,7 +43,12 @@ func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *Vo AccessModes: []v1.PersistentVolumeAccessMode{ v1.ReadWriteOnce, }, - StorageClassName: utils.StrPtr(`{{ .Values.` + service.Name + `.persistence.` + volumeName + `.storageClass }}`), + StorageClassName: utils.StrPtr( + `{{ .Values.` + + service.Name + + "." + persistenceKey + + "." + volumeName + `.storageClass }}`, + ), Resources: v1.VolumeResourceRequirements{ Requests: v1.ResourceList{ v1.ResourceStorage: resource.MustParse("1Gi"), @@ -69,7 +76,7 @@ func (v *VolumeClaim) Yaml() ([]byte, error) { strings.Replace( string(out), "1Gi", - utils.TplValue(serviceName, "persistence."+volumeName+".size"), + utils.TplValue(serviceName, persistenceKey+"."+volumeName+".size"), 1, ), ) @@ -80,8 +87,8 @@ func (v *VolumeClaim) Yaml() ([]byte, error) { "- ReadWriteOnce", "{{- .Values."+ serviceName+ - ".persistence."+ - volumeName+ + "."+persistenceKey+ + "."+volumeName+ ".accessMode | toYaml | nindent __indent__ }}", 1, ), @@ -92,7 +99,10 @@ func (v *VolumeClaim) Yaml() ([]byte, error) { if strings.Contains(line, "storageClass") { lines[i] = utils.Wrap( line, - "{{- if ne .Values."+serviceName+".persistence."+volumeName+".storageClass \"-\" }}", + "{{- if ne .Values."+ + serviceName+ + "."+persistenceKey+ + "."+volumeName+".storageClass \"-\" }}", "{{- end }}", ) } @@ -103,8 +113,8 @@ func (v *VolumeClaim) Yaml() ([]byte, error) { out = []byte( "{{- if .Values." + serviceName + - ".persistence." + - volumeName + + "." + persistenceKey + + "." + volumeName + ".enabled }}\n" + string(out) + "\n{{- end }}", diff --git a/update/update_test.go b/update/update_test.go index 0627958..4ec18c5 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -7,7 +7,6 @@ import ( ) func TestDownloadLatestRelease(t *testing.T) { - // Reset the version to test the latest release Version = "0.0.0" @@ -17,15 +16,14 @@ func TestDownloadLatestRelease(t *testing.T) { // Now call the CheckLatestVersion function version, assets, err := CheckLatestVersion() - if err != nil { - t.Errorf("Error: %s", err) + t.Errorf("Error getting latest version: %s", err) } fmt.Println("Version found", version) // Touch exe binary - f, _ := os.OpenFile(exe, os.O_RDONLY|os.O_CREATE, 0755) + f, _ := os.OpenFile(exe, os.O_RDONLY|os.O_CREATE, 0o755) f.Write(nil) f.Close() @@ -48,5 +46,4 @@ func TestAlreadyUpToDate(t *testing.T) { } t.Log("Version is already the most recent", version) - } diff --git a/utils/utils.go b/utils/utils.go index 220d296..29081b7 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,6 +1,8 @@ package utils import ( + "bytes" + "fmt" "log" "path/filepath" "strings" @@ -114,8 +116,8 @@ func PathToName(path string) string { // EnvConfig is a struct to hold the description of an environment variable. type EnvConfig struct { - Description string Service types.ServiceConfig + Description string } // GetValuesFromLabel returns a map of values from a label. @@ -160,3 +162,27 @@ func MapKeys(m map[string]interface{}) []string { } return keys } + +// Confirm asks a question and returns true if the answer is y. +func Confirm(question string, icon ...Icon) bool { + if len(icon) > 0 { + fmt.Printf("%s %s [y/N] ", icon[0], question) + } else { + fmt.Print(question + " [y/N] ") + } + var response string + fmt.Scanln(&response) + return strings.ToLower(response) == "y" +} + +// EncodeBasicYaml encodes a basic yaml from an interface. +func EncodeBasicYaml(data any) ([]byte, error) { + buf := bytes.NewBuffer(nil) + enc := yaml.NewEncoder(buf) + enc.SetIndent(2) + err := enc.Encode(data) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} From adc44a5e8bcb63d3937ba29f952efa1c6c7a9c70 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 7 May 2024 13:18:00 +0200 Subject: [PATCH 85/97] Refactorization and ordering --- generator/chart.go | 213 +++++++++++++++++++++++++++++++ generator/generator.go | 279 ++--------------------------------------- generator/utils.go | 79 ++++++++++++ 3 files changed, 300 insertions(+), 271 deletions(-) create mode 100644 generator/utils.go diff --git a/generator/chart.go b/generator/chart.go index e9a13fa..8f3a89f 100644 --- a/generator/chart.go +++ b/generator/chart.go @@ -2,10 +2,13 @@ package generator import ( "fmt" + "log" "os" "path/filepath" "strings" + "github.com/compose-spec/compose-go/types" + "katenary/generator/labelStructs" "katenary/utils" ) @@ -114,3 +117,213 @@ func (chart *HelmChart) SaveTemplates(templateDir string) { f.Close() } } + +// generateConfigMapsAndSecrets creates the configmaps and secrets from the environment variables. +func (chart *HelmChart) generateConfigMapsAndSecrets(project *types.Project) error { + appName := chart.Name + for _, s := range project.Services { + if s.Environment == nil || len(s.Environment) == 0 { + continue + } + + originalEnv := types.MappingWithEquals{} + secretsVar := types.MappingWithEquals{} + + // copy env to originalEnv + for k, v := range s.Environment { + originalEnv[k] = v + } + + if v, ok := s.Labels[LabelSecrets]; ok { + list, err := labelStructs.SecretsFrom(v) + if err != nil { + log.Fatal("error unmarshaling secrets label:", err) + } + for _, secret := range list { + if secret == "" { + continue + } + if _, ok := s.Environment[secret]; !ok { + fmt.Printf("%s secret %s not found in environment", utils.IconWarning, secret) + continue + } + secretsVar[secret] = s.Environment[secret] + } + } + + if len(secretsVar) > 0 { + s.Environment = secretsVar + sec := NewSecret(s, appName) + y, _ := sec.Yaml() + name := sec.service.Name + chart.Templates[name+".secret.yaml"] = &ChartTemplate{ + Content: y, + Servicename: s.Name, + } + } + + // remove secrets from env + s.Environment = originalEnv // back to original + for k := range secretsVar { + delete(s.Environment, k) + } + if len(s.Environment) > 0 { + cm := NewConfigMap(s, appName) + y, _ := cm.Yaml() + name := cm.service.Name + chart.Templates[name+".configmap.yaml"] = &ChartTemplate{ + Content: y, + Servicename: s.Name, + } + } + } + return nil +} + +func (chart *HelmChart) generateDeployment(service types.ServiceConfig, deployments map[string]*Deployment, services map[string]*Service, podToMerge map[string]*types.ServiceConfig, appName string) error { + // check the "ports" label from container and add it to the service + if err := fixPorts(&service); err != nil { + return err + } + + // isgnored service + if isIgnored(service) { + fmt.Printf("%s Ignoring service %s\n", utils.IconInfo, service.Name) + return nil + } + + // helm dependency + if isHelmDependency, err := chart.setDependencies(service); err != nil { + return err + } else if isHelmDependency { + return nil + } + + // create all deployments + d := NewDeployment(service, chart) + deployments[service.Name] = d + + // generate the cronjob if needed + chart.setCronJob(service, appName) + + // 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[LabelSamePod]; ok && samePod != "" { + podToMerge[samePod] = &service + } + + // create the needed service for the container port + if len(service.Ports) > 0 { + s := NewService(service, appName) + services[service.Name] = s + } + + // create all ingresses + if ingress := d.AddIngress(service, appName); ingress != nil { + y, _ := ingress.Yaml() + chart.Templates[ingress.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + } + + return nil +} + +// setChartVersion sets the chart version from the service image tag. +func (chart *HelmChart) setChartVersion(service types.ServiceConfig) { + if chart.Version == "" { + image := service.Image + parts := strings.Split(image, ":") + if len(parts) > 1 { + chart.AppVersion = parts[1] + } else { + chart.AppVersion = "0.1.0" + } + } +} + +// setCronJob creates a cronjob from the service labels. +func (chart *HelmChart) setCronJob(service types.ServiceConfig, appName string) *CronJob { + if _, ok := service.Labels[LabelCronJob]; !ok { + return nil + } + cronjob, rbac := NewCronJob(service, chart, appName) + y, _ := cronjob.Yaml() + chart.Templates[cronjob.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + + if rbac != nil { + y, _ := rbac.RoleBinding.Yaml() + chart.Templates[rbac.RoleBinding.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + y, _ = rbac.Role.Yaml() + chart.Templates[rbac.Role.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + y, _ = rbac.ServiceAccount.Yaml() + chart.Templates[rbac.ServiceAccount.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + } + + return cronjob +} + +// setDependencies sets the dependencies from the service labels. +func (chart *HelmChart) setDependencies(service types.ServiceConfig) (bool, error) { + // helm dependency + if v, ok := service.Labels[LabelDependencies]; ok { + d, err := labelStructs.DependenciesFrom(v) + if err != nil { + return false, err + } + + for _, dep := range d { + fmt.Printf("%s Adding dependency to %s\n", utils.IconDependency, dep.Name) + chart.Dependencies = append(chart.Dependencies, dep) + name := dep.Name + if dep.Alias != "" { + name = dep.Alias + } + // add the dependency env vars to the values.yaml + chart.Values[name] = dep.Values + } + + return true, nil + } + return false, nil +} + +// setSharedConf sets the shared configmap to the service. +func (chart *HelmChart) setSharedConf(service types.ServiceConfig, deployments map[string]*Deployment) { + // if the service has the "shared-conf" label, we need to add the configmap + // to the chart and add the env vars to the service + if _, ok := service.Labels[LabelEnvFrom]; !ok { + return + } + fromservices, err := labelStructs.EnvFromFrom(service.Labels[LabelEnvFrom]) + if err != nil { + log.Fatal("error unmarshaling env-from label:", err) + } + // find the configmap in the chart templates + for _, fromservice := range fromservices { + if _, ok := chart.Templates[fromservice+".configmap.yaml"]; !ok { + log.Printf("configmap %s not found in chart templates", fromservice) + continue + } + // find the corresponding target deployment + target := findDeployment(service.Name, deployments) + if target == nil { + continue + } + // add the configmap to the service + addConfigMapToService(service.Name, fromservice, chart.Name, target) + } +} diff --git a/generator/generator.go b/generator/generator.go index d6c479c..5fd3c00 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -5,13 +5,11 @@ import ( "fmt" "log" "regexp" - "strconv" "strings" "github.com/compose-spec/compose-go/types" corev1 "k8s.io/api/core/v1" - "katenary/generator/labelStructs" "katenary/utils" ) @@ -53,7 +51,7 @@ func Generate(project *types.Project) (*HelmChart, error) { if mainCount > 1 { return nil, fmt.Errorf("found more than one main app") } - setChartVersion(chart, service) + chart.setChartVersion(service) } } if mainCount == 0 { @@ -62,51 +60,10 @@ func Generate(project *types.Project) (*HelmChart, error) { // first pass, create all deployments whatewer they are. for _, service := range project.Services { - // check the "ports" label from container and add it to the service - if err := fixPorts(&service); err != nil { + err := chart.generateDeployment(service, deployments, services, podToMerge, appName) + if err != nil { return nil, err } - - // isgnored service - if isIgnored(service) { - fmt.Printf("%s Ignoring service %s\n", utils.IconInfo, service.Name) - continue - } - - // helm dependency - if isHelmDependency, err := setDependencies(chart, service); err != nil { - return nil, err - } else if isHelmDependency { - continue - } - - // create all deployments - d := NewDeployment(service, chart) - deployments[service.Name] = d - - // generate the cronjob if needed - setCronJob(service, chart, appName) - - // 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[LabelSamePod]; ok && samePod != "" { - podToMerge[samePod] = &service - } - - // create the needed service for the container port - if len(service.Ports) > 0 { - s := NewService(service, appName) - services[service.Name] = s - } - - // create all ingresses - if ingress := d.AddIngress(service, appName); ingress != nil { - y, _ := ingress.Yaml() - chart.Templates[ingress.Filename()] = &ChartTemplate{ - Content: y, - Servicename: service.Name, - } - } } // now we have all deployments, we can create PVC if needed (it's separated from @@ -116,7 +73,8 @@ func Generate(project *types.Project) (*HelmChart, error) { addStaticVolumes(deployments, service) } for _, service := range project.Services { - if err := buildVolumes(service, chart, deployments); err != nil { + err := buildVolumes(service, chart, deployments) + if err != nil { return nil, err } } @@ -148,12 +106,12 @@ func Generate(project *types.Project) (*HelmChart, error) { } // generate configmaps with environment variables - generateConfigMapsAndSecrets(project, chart) + chart.generateConfigMapsAndSecrets(project) // 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 { - setSharedConf(s, chart, deployments) + chart.setSharedConf(s, deployments) } // generate yaml files @@ -255,114 +213,6 @@ func serviceIsMain(service types.ServiceConfig) bool { return false } -// setChartVersion sets the chart version from the service image tag. -func setChartVersion(chart *HelmChart, service types.ServiceConfig) { - if chart.Version == "" { - image := service.Image - parts := strings.Split(image, ":") - if len(parts) > 1 { - chart.AppVersion = parts[1] - } else { - chart.AppVersion = "0.1.0" - } - } -} - -// fixPorts checks the "ports" label from container and add it to the service. -func fixPorts(service *types.ServiceConfig) error { - // check the "ports" label from container and add it to the service - if portsLabel, ok := service.Labels[LabelPorts]; ok { - ports, err := labelStructs.PortsFrom(portsLabel) - if err != nil { - // maybe it's a string, comma separated - parts := strings.Split(portsLabel, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - port, err := strconv.ParseUint(part, 10, 32) - if err != nil { - return err - } - ports = append(ports, uint32(port)) - } - } - for _, port := range ports { - service.Ports = append(service.Ports, types.ServicePortConfig{ - Target: port, - }) - } - } - return nil -} - -// setCronJob creates a cronjob from the service labels. -func setCronJob(service types.ServiceConfig, chart *HelmChart, appName string) *CronJob { - if _, ok := service.Labels[LabelCronJob]; !ok { - return nil - } - cronjob, rbac := NewCronJob(service, chart, appName) - y, _ := cronjob.Yaml() - chart.Templates[cronjob.Filename()] = &ChartTemplate{ - Content: y, - Servicename: service.Name, - } - - if rbac != nil { - y, _ := rbac.RoleBinding.Yaml() - chart.Templates[rbac.RoleBinding.Filename()] = &ChartTemplate{ - Content: y, - Servicename: service.Name, - } - y, _ = rbac.Role.Yaml() - chart.Templates[rbac.Role.Filename()] = &ChartTemplate{ - Content: y, - Servicename: service.Name, - } - y, _ = rbac.ServiceAccount.Yaml() - chart.Templates[rbac.ServiceAccount.Filename()] = &ChartTemplate{ - Content: y, - Servicename: service.Name, - } - } - - return cronjob -} - -// setDependencies sets the dependencies from the service labels. -func setDependencies(chart *HelmChart, service types.ServiceConfig) (bool, error) { - // helm dependency - if v, ok := service.Labels[LabelDependencies]; ok { - d, err := labelStructs.DependenciesFrom(v) - if err != nil { - return false, err - } - - for _, dep := range d { - fmt.Printf("%s Adding dependency to %s\n", utils.IconDependency, dep.Name) - chart.Dependencies = append(chart.Dependencies, dep) - name := dep.Name - if dep.Alias != "" { - name = dep.Alias - } - // add the dependency env vars to the values.yaml - chart.Values[name] = dep.Values - } - - return true, nil - } - return false, nil -} - -// isIgnored returns true if the service is ignored. -func isIgnored(service types.ServiceConfig) bool { - if v, ok := service.Labels[LabelIgnore]; ok { - return v == "true" || v == "yes" || v == "1" - } - return false -} - // buildVolumes creates the volumes for the service. func buildVolumes(service types.ServiceConfig, chart *HelmChart, deployments map[string]*Deployment) error { appName := chart.Name @@ -442,68 +292,6 @@ func addStaticVolumes(deployments map[string]*Deployment, service types.ServiceC d.Spec.Template.Spec.Containers[index] = *container } -// generateConfigMapsAndSecrets creates the configmaps and secrets from the environment variables. -func generateConfigMapsAndSecrets(project *types.Project, chart *HelmChart) error { - appName := chart.Name - for _, s := range project.Services { - if s.Environment == nil || len(s.Environment) == 0 { - continue - } - - originalEnv := types.MappingWithEquals{} - secretsVar := types.MappingWithEquals{} - - // copy env to originalEnv - for k, v := range s.Environment { - originalEnv[k] = v - } - - if v, ok := s.Labels[LabelSecrets]; ok { - list, err := labelStructs.SecretsFrom(v) - if err != nil { - log.Fatal("error unmarshaling secrets label:", err) - } - for _, secret := range list { - if secret == "" { - continue - } - if _, ok := s.Environment[secret]; !ok { - fmt.Printf("%s secret %s not found in environment", utils.IconWarning, secret) - continue - } - secretsVar[secret] = s.Environment[secret] - } - } - - if len(secretsVar) > 0 { - s.Environment = secretsVar - sec := NewSecret(s, appName) - y, _ := sec.Yaml() - name := sec.service.Name - chart.Templates[name+".secret.yaml"] = &ChartTemplate{ - Content: y, - Servicename: s.Name, - } - } - - // remove secrets from env - s.Environment = originalEnv // back to original - for k := range secretsVar { - delete(s.Environment, k) - } - if len(s.Environment) > 0 { - cm := NewConfigMap(s, appName) - y, _ := cm.Yaml() - name := cm.service.Name - chart.Templates[name+".configmap.yaml"] = &ChartTemplate{ - Content: y, - Servicename: s.Name, - } - } - } - return nil -} - // samePodVolume returns true if the volume is already in the target deployment. func samePodVolume(service types.ServiceConfig, v types.ServiceVolumeConfig, deployments map[string]*Deployment) bool { // if the service has volumes, and it has "same-pod" label @@ -527,13 +315,7 @@ func samePodVolume(service types.ServiceConfig, v types.ServiceVolumeConfig, dep } // get the target deployment - var target *Deployment - for _, d := range deployments { - if d.service.Name == targetDeployment { - target = d - break - } - } + target := findDeployment(targetDeployment, deployments) if target == nil { return false } @@ -547,48 +329,3 @@ func samePodVolume(service types.ServiceConfig, v types.ServiceVolumeConfig, dep } return false } - -// setSharedConf sets the shared configmap to the service. -func setSharedConf(service types.ServiceConfig, chart *HelmChart, deployments map[string]*Deployment) { - // if the service has the "shared-conf" label, we need to add the configmap - // to the chart and add the env vars to the service - if _, ok := service.Labels[LabelEnvFrom]; !ok { - return - } - fromservices, err := labelStructs.EnvFromFrom(service.Labels[LabelEnvFrom]) - if err != nil { - log.Fatal("error unmarshaling env-from label:", err) - } - // find the configmap in the chart templates - for _, fromservice := range fromservices { - if _, ok := chart.Templates[fromservice+".configmap.yaml"]; !ok { - log.Printf("configmap %s not found in chart templates", fromservice) - continue - } - // find the corresponding target deployment - var target *Deployment - for _, d := range deployments { - if d.service.Name == service.Name { - target = d - break - } - } - if target == nil { - continue - } - // add the configmap to the service - for i, c := range target.Spec.Template.Spec.Containers { - if c.Name != service.Name { - continue - } - c.EnvFrom = append(c.EnvFrom, corev1.EnvFromSource{ - ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: utils.TplName(fromservice, chart.Name), - }, - }, - }) - target.Spec.Template.Spec.Containers[i] = c - } - } -} diff --git a/generator/utils.go b/generator/utils.go new file mode 100644 index 0000000..c278188 --- /dev/null +++ b/generator/utils.go @@ -0,0 +1,79 @@ +package generator + +import ( + "strconv" + "strings" + + "github.com/compose-spec/compose-go/types" + corev1 "k8s.io/api/core/v1" + + "katenary/generator/labelStructs" + "katenary/utils" +) + +// findDeployment finds the corresponding target deployment for a service. +func findDeployment(serviceName string, deployments map[string]*Deployment) *Deployment { + for _, d := range deployments { + if d.service.Name == serviceName { + return d + } + } + return nil +} + +// addConfigMapToService adds the configmap to the service. +func addConfigMapToService(serviceName, fromservice, chartName string, target *Deployment) { + for i, c := range target.Spec.Template.Spec.Containers { + if c.Name != serviceName { + continue + } + c.EnvFrom = append(c.EnvFrom, corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: utils.TplName(fromservice, chartName), + }, + }, + }) + target.Spec.Template.Spec.Containers[i] = c + } +} + +// fixPorts checks the "ports" label from container and add it to the service. +func fixPorts(service *types.ServiceConfig) error { + // check the "ports" label from container and add it to the service + portsLabel := "" + ok := false + if portsLabel, ok = service.Labels[LabelPorts]; !ok { + return nil + } + ports, err := labelStructs.PortsFrom(portsLabel) + if err != nil { + // maybe it's a string, comma separated + parts := strings.Split(portsLabel, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + port, err := strconv.ParseUint(part, 10, 32) + if err != nil { + return err + } + ports = append(ports, uint32(port)) + } + } + for _, port := range ports { + service.Ports = append(service.Ports, types.ServicePortConfig{ + Target: port, + }) + } + return nil +} + +// isIgnored returns true if the service is ignored. +func isIgnored(service types.ServiceConfig) bool { + if v, ok := service.Labels[LabelIgnore]; ok { + return v == "true" || v == "yes" || v == "1" + } + return false +} From 78dfb15cf564babbbdd254340ac2d7318eb128b2 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Tue, 7 May 2024 13:19:04 +0200 Subject: [PATCH 86/97] Add the command package --- doc/docs/packages/cmd/katenary.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 doc/docs/packages/cmd/katenary.md diff --git a/doc/docs/packages/cmd/katenary.md b/doc/docs/packages/cmd/katenary.md new file mode 100644 index 0000000..9261889 --- /dev/null +++ b/doc/docs/packages/cmd/katenary.md @@ -0,0 +1,12 @@ + + +# katenary + +```go +import "katenary/cmd/katenary" +``` + +Katenary CLI, main package. + +This package is not intended to be imported. It contains the main function that build the command line with \`cobra\` package. + From 918f1b845bdedb04aa9fd2723c5168e94f9fc754 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 17 Oct 2024 17:08:42 +0200 Subject: [PATCH 87/97] Fix problems and adding functionnalities Many fixes and enhancements: - Add icon option - Add env file managment - Ordering compose parsing options - Fix path with underscores - Fix image and tag discovery - Better documentation for labels --- .gitignore | 4 + Makefile | 11 +- README.md | 44 +- cmd/katenary/main.go | 83 ++- generator/chart.go | 32 +- generator/configMap.go | 55 +- generator/converter.go | 668 +++++++++++++------------ generator/deployment.go | 361 ++++++------- generator/extrafiles/readme.go | 46 +- generator/generator.go | 148 +++--- generator/ingress.go | 8 +- generator/katenaryLabels.go | 209 ++++---- generator/katenaryLabelsDoc.yaml | 51 +- generator/labelStructs/dependencies.go | 2 +- generator/labelStructs/ingress.go | 16 +- generator/rbac.go | 24 +- generator/secret.go | 24 +- generator/service.go | 10 +- generator/values.go | 43 +- generator/volume.go | 14 +- parser/main.go | 21 +- utils/utils.go | 10 +- 22 files changed, 991 insertions(+), 893 deletions(-) diff --git a/.gitignore b/.gitignore index 5d8f05a..618dbfd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ configs/ cover* .sq ./katenary +.aider* +.python_history +.bash_history +katenary diff --git a/Makefile b/Makefile index 5178f56..0ca9d0d 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,7 @@ BINARIES=dist/katenary-linux-amd64 dist/katenary-linux-arm64 dist/katenary.exe d ASC_BINARIES=$(patsubst %,%.asc,$(BINARIES)) # defaults +BROWSER=$(shell command -v epiphany || echo xdg-open) SHELL := bash # strict mode .SHELLFLAGS := -eu -o pipefail -c @@ -35,6 +36,7 @@ MAKEFLAGS += --no-builtin-rules all: build + help: @cat < 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. - -Today, it's partially developped in collaboration with [Klee Group](https://www.kleegroup.com). Note that Katenary is -and **will stay an opensource and free (as freedom) project**. We are convinced that the best way to make it better is to +Today, it's partially developed in collaboration with [Klee Group](https://www.kleegroup.com). Note that Katenary is +and **will stay an open source and free (as freedom) project**. We are convinced that the best way to make it better is to share it with the community. The main developer is [Patrice FERLET](https://github.com/metal3d). @@ -45,7 +44,7 @@ You can use this commands on Linux: sh <(curl -sSL https://raw.githubusercontent.com/metal3d/katenary/master/install.sh) ``` -# Else... Build yourself +# Or, build yourself If you've got `podman` or `docker`, you can build `katenary` by using: @@ -54,6 +53,7 @@ make build ``` You can then install it with: + ```bash make install ``` @@ -76,13 +76,12 @@ make build GO=local GOOS=linux GOARCH=arm64 Then place the `katenary` binary file inside your PATH. - # Tips -We strongly recommand to add the "completion" call to you SHELL using the common bashrc, or whatever the profile file +We strongly recommend adding 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 @@ -102,7 +101,7 @@ katenary completion fish | source # Usage -``` +```text Katenary is a tool to convert compose files to Helm Charts. Each [command] and subcommand has got an "help" and "--help" flag to show more information. @@ -134,22 +133,11 @@ Use "katenary [command] --help" for more information about a command. It creates a subdirectory inside `chart` that is named with the `appname` option (default is `MyApp`) - > To respect the ability to install the same application in the same namespace, Katenary will create "variable" names + > To respect the ability to install the same application in the same namespace, Katenary will create variable names > like `{{ .Release.Name }}-servicename`. So, you will need to use some labels inside your docker-compose file to help - > katenary to build a correct helm chart. + > Katenary to build a correct helm chart. - 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 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, see examples below - - Exemple of a possible `docker-compose.yaml` file: + Example of a possible `docker-compose.yaml` file: ```yaml services: @@ -196,9 +184,9 @@ services: # Labels -These labels could be found by `katenary help-labels`, and can be placed as "labels" inside your docker-compose file: +These labels could be found by `katenary help-labels`, and can be placed as labels inside your docker-compose file: -``` +```text To get more information about a label, use `katenary help-label e.g. katenary help-label dependencies @@ -218,11 +206,11 @@ katenary.v3/secrets: list of string Env vars to be set as secrets. katenary.v3/values: list of string or map Environment variables to be added to the values.yaml ``` -# What a name... +# What a name… Katenary is the stylized name of the project that comes from the "catenary" word. A catenary is a curve formed by a wire, rope, or chain hanging freely from two points that are not in the same vertical line. For example, the anchor chain between a boat and the anchor. -This "curved link" represents what we try to do, the project is a "streched link from docker-compose to helm chart". +This curved link represents what we try to do, the project is a stretched link from docker-compose to helm chart. diff --git a/cmd/katenary/main.go b/cmd/katenary/main.go index 3243a6f..1b67ca0 100644 --- a/cmd/katenary/main.go +++ b/cmd/katenary/main.go @@ -6,14 +6,13 @@ package main import ( "fmt" + "katenary/generator" + "katenary/utils" "os" "strings" "github.com/compose-spec/compose-go/cli" "github.com/spf13/cobra" - - "katenary/generator" - "katenary/utils" ) const longHelp = `Katenary is a tool to convert compose files to Helm Charts. @@ -133,6 +132,8 @@ func generateConvertCommand() *cobra.Command { var appVersion *string givenAppVersion := "" chartVersion := "0.1.0" + icon := "" + envFiles := []string{} convertCmd := &cobra.Command{ Use: "convert", @@ -148,17 +149,79 @@ func generateConvertCommand() *cobra.Command { HelmUpdate: helmdepUpdate, AppVersion: appVersion, ChartVersion: chartVersion, + Icon: icon, + EnvFiles: envFiles, }, dockerComposeFile...) }, } - convertCmd.Flags().BoolVarP(&force, "force", "f", force, "Force the overwrite of the chart directory") - convertCmd.Flags().BoolVarP(&helmdepUpdate, "helm-update", "u", helmdepUpdate, "Update helm dependencies if helm is installed") - convertCmd.Flags().StringSliceVarP(&profiles, "profile", "p", profiles, "Specify the profiles to use") - convertCmd.Flags().StringVarP(&outputDir, "output-dir", "o", outputDir, "Specify the output directory") - convertCmd.Flags().StringSliceVarP(&dockerComposeFile, "compose-file", "c", cli.DefaultFileNames, "Specify an alternate compose files - can be specified multiple times or use coma to separate them.\nNote that overides files are also used whatever the files you specify here.\nThe overides files are:\n"+strings.Join(cli.DefaultOverrideFileNames, ", \n")+"\n") - convertCmd.Flags().StringVarP(&givenAppVersion, "app-version", "a", "", "Specify the app version (in Chart.yaml)") - convertCmd.Flags().StringVarP(&chartVersion, "chart-version", "v", chartVersion, "Specify the chart version (in Chart.yaml)") + convertCmd.Flags().BoolVarP( + &force, + "force", + "f", + force, + "Force the overwrite of the chart directory", + ) + convertCmd.Flags().BoolVarP( + &helmdepUpdate, + "helm-update", + "u", + helmdepUpdate, + "Update helm dependencies if helm is installed", + ) + convertCmd.Flags().StringSliceVarP( + &profiles, + "profile", + "p", + profiles, + "Specify the profiles to use", + ) + convertCmd.Flags().StringVarP( + &outputDir, + "output-dir", + "o", + outputDir, + "Specify the output directory", + ) + convertCmd.Flags().StringSliceVarP( + &dockerComposeFile, + "compose-file", + "c", + cli.DefaultFileNames, + "Specify an alternate compose files - can be specified multiple times or use coma to separate them.\n"+ + "Note that overides files are also used whatever the files you specify here.\nThe overides files are:\n"+ + strings.Join(cli.DefaultOverrideFileNames, ", \n")+ + "\n", + ) + convertCmd.Flags().StringVarP( + &givenAppVersion, + "app-version", + "a", + "", + "Specify the app version (in Chart.yaml)", + ) + convertCmd.Flags().StringVarP( + &chartVersion, + "chart-version", + "v", + chartVersion, + "Specify the chart version (in Chart.yaml)", + ) + convertCmd.Flags().StringVarP( + &icon, + "icon", + "i", + "", + "Specify the icon (in Chart.yaml), use a valid URL, Helm does not support local files at this time.", + ) + convertCmd.Flags().StringSliceVarP( + &envFiles, + "env-file", + "e", + envFiles, + "Specify the env file to use additonnaly to the .env file. Can be specified multiple times.", + ) + return convertCmd } diff --git a/generator/chart.go b/generator/chart.go index 8f3a89f..8bd7580 100644 --- a/generator/chart.go +++ b/generator/chart.go @@ -2,27 +2,16 @@ package generator import ( "fmt" + "katenary/generator/labelStructs" + "katenary/utils" "log" "os" "path/filepath" "strings" "github.com/compose-spec/compose-go/types" - - "katenary/generator/labelStructs" - "katenary/utils" ) -// ConvertOptions are the options to convert a compose project to a helm chart. -type ConvertOptions struct { - AppVersion *string - OutputDir string - ChartVersion string - Profiles []string - Force bool - HelmUpdate bool -} - // ChartTemplate is a template of a chart. It contains the content of the template and the name of the service. // This is used internally to generate the templates. type ChartTemplate struct { @@ -30,6 +19,18 @@ type ChartTemplate struct { Content []byte } +// ConvertOptions are the options to convert a compose project to a helm chart. +type ConvertOptions struct { + AppVersion *string + OutputDir string + ChartVersion string + Icon string + Profiles []string + Force bool + HelmUpdate bool + EnvFiles []string +} + // HelmChart is a Helm Chart representation. It contains all the // tempaltes, values, versions, helpers... type HelmChart struct { @@ -38,6 +39,7 @@ type HelmChart struct { VolumeMounts map[string]any `yaml:"-"` composeHash *string `yaml:"-"` Name string `yaml:"name"` + Icon string `yaml:"icon,omitempty"` ApiVersion string `yaml:"apiVersion"` Version string `yaml:"version"` AppVersion string `yaml:"appVersion"` @@ -67,7 +69,7 @@ func (chart *HelmChart) SaveTemplates(templateDir string) { t := template.Content t = removeNewlinesInsideBrackets(t) t = removeUnwantedLines(t) - t = addModeline(t) + // t = addModeline(t) kind := utils.GetKind(name) var icon utils.Icon @@ -168,7 +170,7 @@ func (chart *HelmChart) generateConfigMapsAndSecrets(project *types.Project) err delete(s.Environment, k) } if len(s.Environment) > 0 { - cm := NewConfigMap(s, appName) + cm := NewConfigMap(s, appName, false) y, _ := cm.Yaml() name := cm.service.Name chart.Templates[name+".configmap.yaml"] = &ChartTemplate{ diff --git a/generator/configMap.go b/generator/configMap.go index 5b726d4..9c13bf8 100644 --- a/generator/configMap.go +++ b/generator/configMap.go @@ -1,6 +1,8 @@ package generator import ( + "katenary/generator/labelStructs" + "katenary/utils" "log" "os" "path/filepath" @@ -11,28 +13,8 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" - - "katenary/generator/labelStructs" - "katenary/utils" ) -// only used to check interface implementation -var ( - _ DataMap = (*ConfigMap)(nil) - _ Yaml = (*ConfigMap)(nil) -) - -// NewFileMap creates a new DataMap from a compose service. The appName is the name of the application taken from the project name. -func NewFileMap(service types.ServiceConfig, appName, kind string) DataMap { - switch kind { - case "configmap": - return NewConfigMap(service, appName) - default: - log.Fatalf("Unknown filemap kind: %s", kind) - } - return nil -} - // FileMapUsage is the usage of the filemap. type FileMapUsage uint8 @@ -42,6 +24,23 @@ const ( FileMapUsageFiles // files in a configmap. ) +// NewFileMap creates a new DataMap from a compose service. The appName is the name of the application taken from the project name. +func NewFileMap(service types.ServiceConfig, appName, kind string) DataMap { + switch kind { + case "configmap": + return NewConfigMap(service, appName, true) + default: + log.Fatalf("Unknown filemap kind: %s", kind) + } + return nil +} + +// only used to check interface implementation +var ( + _ DataMap = (*ConfigMap)(nil) + _ Yaml = (*ConfigMap)(nil) +) + // ConfigMap is a kubernetes ConfigMap. // Implements the DataMap interface. type ConfigMap struct { @@ -53,7 +52,7 @@ type ConfigMap struct { // NewConfigMap creates a new ConfigMap from a compose service. The appName is the name of the application taken from the project name. // The ConfigMap is filled by environment variables and labels "map-env". -func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { +func NewConfigMap(service types.ServiceConfig, appName string, forFile bool) *ConfigMap { done := map[string]bool{} drop := map[string]bool{} labelValues := []string{} @@ -99,6 +98,10 @@ func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap { service.Environment[value] = &val } + if forFile { + // do not bind env variables to the configmap + return cm + } // remove the variables that are already defined in the environment if l, ok := service.Labels[LabelMapEnv]; ok { envmap, err := labelStructs.MapEnvFrom(l) @@ -155,11 +158,6 @@ func NewConfigMapFromDirectory(service types.ServiceConfig, appName, path string return cm } -// SetData sets the data of the configmap. It replaces the entire data. -func (c *ConfigMap) SetData(data map[string]string) { - c.Data = data -} - // AddData adds a key value pair to the configmap. Append or overwrite the value if the key already exists. func (c *ConfigMap) AddData(key, value string) { c.Data[key] = value @@ -230,6 +228,11 @@ func (c *ConfigMap) Filename() string { } } +// SetData sets the data of the configmap. It replaces the entire data. +func (c *ConfigMap) SetData(data map[string]string) { + c.Data = data +} + // Yaml returns the yaml representation of the configmap func (c *ConfigMap) Yaml() ([]byte, error) { return yaml.Marshal(c) diff --git a/generator/converter.go b/generator/converter.go index 684301e..8490809 100644 --- a/generator/converter.go +++ b/generator/converter.go @@ -4,6 +4,10 @@ import ( "bytes" "errors" "fmt" + "katenary/generator/extrafiles" + "katenary/generator/labelStructs" + "katenary/parser" + "katenary/utils" "log" "os" "os/exec" @@ -13,13 +17,22 @@ import ( "time" "github.com/compose-spec/compose-go/types" - - "katenary/generator/extrafiles" - "katenary/generator/labelStructs" - "katenary/parser" - "katenary/utils" ) +const ingressClassHelp = `# Default value for ingress.class annotation +# class: "-" +# If the value is "-", controller will not set ingressClassName +# If the value is "", Ingress will be set to an empty string, so +# controller will use the default value for ingressClass +# If the value is specified, controller will set the named class e.g. "nginx" +` + +const storageClassHelp = `# Storage class to use for PVCs +# storageClass: "-" means use default +# storageClass: "" means do not specify +# storageClass: "foo" means use that storageClass +` + const headerHelp = `# This file is autogenerated by katenary # # DO NOT EDIT IT BY HAND UNLESS YOU KNOW WHAT YOU ARE DOING @@ -32,6 +45,47 @@ const headerHelp = `# This file is autogenerated by katenary ` +const imagePullSecretHelp = ` +# imagePullSecrets allows you to specify a name of an image pull secret. +# You must provide a list of object with the name field set to the name of the +# e.g. +# pullSecrets: +# - name: regcred +# You are, for now, repsonsible for creating the secret. +` + +const imagePullPolicyHelp = `# imagePullPolicy allows you to specify a policy to cache or always pull an image. +# You must provide a string value with one of the following values: +# - Always -> will always pull the image +# - Never -> will never pull the image, the image should be present on the node +# - IfNotPresent -> will pull the image only if it is not present on the node +` + +const resourceHelp = `# Resources allows you to specify the resource requests and limits for a service. +# Resources are used to specify the amount of CPU and memory that +# a container needs. +# +# e.g. +# resources: +# requests: +# memory: "64Mi" +# cpu: "250m" +# limits: +# memory: "128Mi" +# cpu: "500m" +` + +const mainTagAppDoc = `This is the version of the main application. +Leave it to blank to use the Chart "AppVersion" value.` + +var unwantedLines = []string{ + "creationTimestamp:", + "status:", +} + +// keyRegExp checks if the line starts by a # +var keyRegExp = regexp.MustCompile(`^\s*[^#]+:.*`) + // Convert a compose (docker, podman...) project to a helm chart. // It calls Generate() to generate the chart and then write it to the disk. func Convert(config ConvertOptions, dockerComposeFile ...string) { @@ -59,7 +113,7 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { } // parse the compose files - project, err := parser.Parse(config.Profiles, dockerComposeFile...) + project, err := parser.Parse(config.Profiles, config.EnvFiles, dockerComposeFile...) if err != nil { fmt.Println(err) os.Exit(1) @@ -109,6 +163,11 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { os.Exit(1) } + // add icon from the command line + if config.Icon != "" { + chart.Icon = config.Icon + } + // write the templates to the disk chart.SaveTemplates(templateDir) @@ -132,141 +191,6 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) { callHelmUpdate(config) } -const ingressClassHelp = `# Default value for ingress.class annotation -# class: "-" -# If the value is "-", controller will not set ingressClassName -# If the value is "", Ingress will be set to an empty string, so -# controller will use the default value for ingressClass -# If the value is specified, controller will set the named class e.g. "nginx" -` - -func addCommentsToValues(values []byte) []byte { - lines := strings.Split(string(values), "\n") - for i, line := range lines { - if strings.Contains(line, "ingress:") { - spaces := utils.CountStartingSpaces(line) - spacesString := strings.Repeat(" ", spaces) - // indent ingressClassHelper comment - ingressClassHelp := strings.ReplaceAll(ingressClassHelp, "\n", "\n"+spacesString) - ingressClassHelp = strings.TrimRight(ingressClassHelp, " ") - ingressClassHelp = spacesString + ingressClassHelp - lines[i] = ingressClassHelp + line - } - } - return []byte(strings.Join(lines, "\n")) -} - -const storageClassHelp = `# Storage class to use for PVCs -# storageClass: "-" means use default -# storageClass: "" means do not specify -# storageClass: "foo" means use that storageClass -` - -// addStorageClassHelp adds a comment to the values.yaml file to explain how to -// use the storageClass option. -func addStorageClassHelp(values []byte) []byte { - lines := strings.Split(string(values), "\n") - for i, line := range lines { - if strings.Contains(line, "storageClass:") { - spaces := utils.CountStartingSpaces(line) - spacesString := strings.Repeat(" ", spaces) - // indent ingressClassHelper comment - storageClassHelp := strings.ReplaceAll(storageClassHelp, "\n", "\n"+spacesString) - storageClassHelp = strings.TrimRight(storageClassHelp, " ") - storageClassHelp = spacesString + storageClassHelp - lines[i] = storageClassHelp + line - } - } - return []byte(strings.Join(lines, "\n")) -} - -// addModeline adds a modeline to the values.yaml file to make sure that vim -// will use the correct syntax highlighting. -func addModeline(values []byte) []byte { - modeline := "# vi" + "m: ft=helm.gotmpl.yaml" - - // if the values ends by `{{- end }}` we need to add the modeline before - lines := strings.Split(string(values), "\n") - - if lines[len(lines)-1] == "{{- end }}" || lines[len(lines)-1] == "{{- end -}}" { - lines = lines[:len(lines)-1] - lines = append(lines, modeline, "{{- end }}") - return []byte(strings.Join(lines, "\n")) - } - - return append(values, []byte(modeline)...) -} - -// addDescriptions adds the description from the label to the values.yaml file on top -// of the service definition. -func addDescriptions(values []byte, project types.Project) []byte { - for _, service := range project.Services { - if description, ok := service.Labels[LabelDescription]; ok { - // set it as comment - description = "\n# " + strings.ReplaceAll(description, "\n", "\n# ") - - values = regexp.MustCompile( - `(?m)^`+service.Name+`:$`, - ).ReplaceAll(values, []byte(description+"\n"+service.Name+":")) - } else { - // set it as comment - description = "\n# " + service.Name + " configuration" - - values = regexp.MustCompile( - `(?m)^`+service.Name+`:$`, - ).ReplaceAll( - values, - []byte(description+"\n"+service.Name+":"), - ) - } - } - return values -} - -func addDependencyDescription(values []byte, dependencies []labelStructs.Dependency) []byte { - for _, d := range dependencies { - name := d.Name - if d.Alias != "" { - name = d.Alias - } - - values = regexp.MustCompile( - `(?m)^`+name+`:$`, - ).ReplaceAll( - values, - []byte("\n# "+d.Name+" helm dependency configuration\n"+name+":"), - ) - } - return values -} - -const imagePullSecretHelp = ` -# imagePullSecrets allows you to specify a name of an image pull secret. -# You must provide a list of object with the name field set to the name of the -# e.g. -# pullSecrets: -# - name: regcred -# You are, for now, repsonsible for creating the secret. -` - -func addImagePullSecretsHelp(values []byte) []byte { - // add imagePullSecrets help - lines := strings.Split(string(values), "\n") - - for i, line := range lines { - if strings.Contains(line, "pullSecrets:") { - spaces := utils.CountStartingSpaces(line) - spacesString := strings.Repeat(" ", spaces) - // indent imagePullSecretHelp comment - imagePullSecretHelp := strings.ReplaceAll(imagePullSecretHelp, "\n", "\n"+spacesString) - imagePullSecretHelp = strings.TrimRight(imagePullSecretHelp, " ") - imagePullSecretHelp = spacesString + imagePullSecretHelp - lines[i] = imagePullSecretHelp + line - } - } - return []byte(strings.Join(lines, "\n")) -} - func addChartDoc(values []byte, project *types.Project) []byte { chartDoc := fmt.Sprintf(`# This is the main values.yaml file for the %s chart. # More information can be found in the chart's README.md file. @@ -303,67 +227,63 @@ func addChartDoc(values []byte, project *types.Project) []byte { return []byte(chartDoc + strings.Join(lines, "\n")) } -const imagePullPolicyHelp = `# imagePullPolicy allows you to specify a policy to cache or always pull an image. -# You must provide a string value with one of the following values: -# - Always -> will always pull the image -# - Never -> will never pull the image, the image should be present on the node -# - IfNotPresent -> will pull the image only if it is not present on the node -` - -func addImagePullPolicyHelp(values []byte) []byte { - // add imagePullPolicy help +func addCommentsToValues(values []byte) []byte { lines := strings.Split(string(values), "\n") for i, line := range lines { - if strings.Contains(line, "imagePullPolicy:") { + if strings.Contains(line, "ingress:") { spaces := utils.CountStartingSpaces(line) spacesString := strings.Repeat(" ", spaces) - // indent imagePullPolicyHelp comment - imagePullPolicyHelp := strings.ReplaceAll(imagePullPolicyHelp, "\n", "\n"+spacesString) - imagePullPolicyHelp = strings.TrimRight(imagePullPolicyHelp, " ") - imagePullPolicyHelp = spacesString + imagePullPolicyHelp - lines[i] = imagePullPolicyHelp + line + // indent ingressClassHelper comment + ingressClassHelp := strings.ReplaceAll(ingressClassHelp, "\n", "\n"+spacesString) + ingressClassHelp = strings.TrimRight(ingressClassHelp, " ") + ingressClassHelp = spacesString + ingressClassHelp + lines[i] = ingressClassHelp + line } } return []byte(strings.Join(lines, "\n")) } -const resourceHelp = `# Resources allows you to specify the resource requests and limits for a service. -# Resources are used to specify the amount of CPU and memory that -# a container needs. -# -# e.g. -# resources: -# requests: -# memory: "64Mi" -# cpu: "250m" -# limits: -# memory: "128Mi" -# cpu: "500m" -` - -func addResourceHelp(values []byte) []byte { - lines := strings.Split(string(values), "\n") - for i, line := range lines { - if strings.Contains(line, "resources:") { - spaces := utils.CountStartingSpaces(line) - spacesString := strings.Repeat(" ", spaces) - // indent resourceHelp comment - resourceHelp := strings.ReplaceAll(resourceHelp, "\n", "\n"+spacesString) - resourceHelp = strings.TrimRight(resourceHelp, " ") - resourceHelp = spacesString + resourceHelp - lines[i] = resourceHelp + line +func addDependencyDescription(values []byte, dependencies []labelStructs.Dependency) []byte { + for _, d := range dependencies { + name := d.Name + if d.Alias != "" { + name = d.Alias } + + values = regexp.MustCompile( + `(?m)^`+name+`:$`, + ).ReplaceAll( + values, + []byte("\n# "+d.Name+" helm dependency configuration\n"+name+":"), + ) } - return []byte(strings.Join(lines, "\n")) + return values } -func addVariablesDoc(values []byte, project *types.Project) []byte { - lines := strings.Split(string(values), "\n") - +// addDescriptions adds the description from the label to the values.yaml file on top +// of the service definition. +func addDescriptions(values []byte, project types.Project) []byte { for _, service := range project.Services { - lines = addDocToVariable(service, lines) + if description, ok := service.Labels[LabelDescription]; ok { + // set it as comment + description = "\n# " + strings.ReplaceAll(description, "\n", "\n# ") + + values = regexp.MustCompile( + `(?m)^`+service.Name+`:$`, + ).ReplaceAll(values, []byte(description+"\n"+service.Name+":")) + } else { + // set it as comment + description = "\n# " + service.Name + " configuration" + + values = regexp.MustCompile( + `(?m)^`+service.Name+`:$`, + ).ReplaceAll( + values, + []byte(description+"\n"+service.Name+":"), + ) + } } - return []byte(strings.Join(lines, "\n")) + return values } func addDocToVariable(service types.ServiceConfig, lines []string) []string { @@ -394,25 +314,38 @@ func addDocToVariable(service types.ServiceConfig, lines []string) []string { return lines } -const mainTagAppDoc = `This is the version of the main application. -Leave it to blank to use the Chart "AppVersion" value.` +func addImagePullPolicyHelp(values []byte) []byte { + // add imagePullPolicy help + lines := strings.Split(string(values), "\n") + for i, line := range lines { + if strings.Contains(line, "imagePullPolicy:") { + spaces := utils.CountStartingSpaces(line) + spacesString := strings.Repeat(" ", spaces) + // indent imagePullPolicyHelp comment + imagePullPolicyHelp := strings.ReplaceAll(imagePullPolicyHelp, "\n", "\n"+spacesString) + imagePullPolicyHelp = strings.TrimRight(imagePullPolicyHelp, " ") + imagePullPolicyHelp = spacesString + imagePullPolicyHelp + lines[i] = imagePullPolicyHelp + line + } + } + return []byte(strings.Join(lines, "\n")) +} -func addMainTagAppDoc(values []byte, project *types.Project) []byte { +func addImagePullSecretsHelp(values []byte) []byte { + // add imagePullSecrets help lines := strings.Split(string(values), "\n") - for _, service := range project.Services { - // read the label LabelMainApp - if v, ok := service.Labels[LabelMainApp]; !ok { - continue - } else if v == "false" || v == "no" || v == "0" { - continue - } else { - fmt.Printf("%s Adding main tag app doc %s\n", utils.IconConfig, service.Name) + for i, line := range lines { + if strings.Contains(line, "pullSecrets:") { + spaces := utils.CountStartingSpaces(line) + spacesString := strings.Repeat(" ", spaces) + // indent imagePullSecretHelp comment + imagePullSecretHelp := strings.ReplaceAll(imagePullSecretHelp, "\n", "\n"+spacesString) + imagePullSecretHelp = strings.TrimRight(imagePullSecretHelp, " ") + imagePullSecretHelp = spacesString + imagePullSecretHelp + lines[i] = imagePullSecretHelp + line } - - lines = addMainAppDoc(lines, service) } - return []byte(strings.Join(lines, "\n")) } @@ -440,107 +373,84 @@ func addMainAppDoc(lines []string, service types.ServiceConfig) []string { return lines } -func removeNewlinesInsideBrackets(values []byte) []byte { - re, err := regexp.Compile(`(?s)\{\{(.*?)\}\}`) - if err != nil { - log.Fatal(err) - } - return re.ReplaceAllFunc(values, func(b []byte) []byte { - // get the first match - matches := re.FindSubmatch(b) - replacement := bytes.ReplaceAll(matches[1], []byte("\n"), []byte(" ")) - // remove repeated spaces - replacement = regexp.MustCompile(`\s+`).ReplaceAll(replacement, []byte(" ")) - // remove newlines inside brackets - return bytes.ReplaceAll(b, matches[1], replacement) - }) -} - -var unwantedLines = []string{ - "creationTimestamp:", - "status:", -} - -func removeUnwantedLines(values []byte) []byte { +func addMainTagAppDoc(values []byte, project *types.Project) []byte { lines := strings.Split(string(values), "\n") - output := []string{} - for _, line := range lines { - next := false - for _, unwanted := range unwantedLines { - if strings.Contains(line, unwanted) { - next = true - } - } - if !next { - output = append(output, line) - } - } - return []byte(strings.Join(output, "\n")) -} -// check if the project makes use of older labels (kanetary.[^v3]) -func checkOldLabels(project *types.Project) error { - badServices := make([]string, 0) for _, service := range project.Services { - for label := range service.Labels { - if strings.Contains(label, "katenary.") && !strings.Contains(label, katenaryLabelPrefix) { - badServices = append(badServices, fmt.Sprintf("- %s: %s", service.Name, label)) - } + // read the label LabelMainApp + if v, ok := service.Labels[LabelMainApp]; !ok { + continue + } else if v == "false" || v == "no" || v == "0" { + continue + } else { + fmt.Printf("%s Adding main tag app doc %s\n", utils.IconConfig, service.Name) + } + + lines = addMainAppDoc(lines, service) + } + + return []byte(strings.Join(lines, "\n")) +} + +// addModeline adds a modeline to the values.yaml file to make sure that vim +// will use the correct syntax highlighting. +func addModeline(values []byte) []byte { + modeline := "# vi" + "m: ft=helm.gotmpl.yaml" + + // if the values ends by `{{- end }}` we need to add the modeline before + lines := strings.Split(string(values), "\n") + + if lines[len(lines)-1] == "{{- end }}" || lines[len(lines)-1] == "{{- end -}}" { + lines = lines[:len(lines)-1] + lines = append(lines, modeline, "{{- end }}") + return []byte(strings.Join(lines, "\n")) + } + + return append(values, []byte(modeline)...) +} + +func addResourceHelp(values []byte) []byte { + lines := strings.Split(string(values), "\n") + for i, line := range lines { + if strings.Contains(line, "resources:") { + spaces := utils.CountStartingSpaces(line) + spacesString := strings.Repeat(" ", spaces) + // indent resourceHelp comment + resourceHelp := strings.ReplaceAll(resourceHelp, "\n", "\n"+spacesString) + resourceHelp = strings.TrimRight(resourceHelp, " ") + resourceHelp = spacesString + resourceHelp + lines[i] = resourceHelp + line } } - if len(badServices) > 0 { - message := fmt.Sprintf(` Old labels detected in project "%s". - - The current version of katenary uses labels with the prefix "%s" which are not compatible with previous versions. - Your project is not compatible with this version. - - Please upgrade your labels to follow the current version - - Services to upgrade: -%s`, - project.Name, - katenaryLabelPrefix[0:len(katenaryLabelPrefix)-1], - strings.Join(badServices, "\n"), - ) - - return errors.New(utils.WordWrap(message, 80)) - - } - return nil + return []byte(strings.Join(lines, "\n")) } -// helmUpdate runs "helm dependency update" on the output directory. -func helmUpdate(config ConvertOptions) error { - // lookup for "helm" binary - fmt.Println(utils.IconInfo, "Updating helm dependencies...") - helm, err := exec.LookPath("helm") - if err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) +// addStorageClassHelp adds a comment to the values.yaml file to explain how to +// use the storageClass option. +func addStorageClassHelp(values []byte) []byte { + lines := strings.Split(string(values), "\n") + for i, line := range lines { + if strings.Contains(line, "storageClass:") { + spaces := utils.CountStartingSpaces(line) + spacesString := strings.Repeat(" ", spaces) + // indent ingressClassHelper comment + storageClassHelp := strings.ReplaceAll(storageClassHelp, "\n", "\n"+spacesString) + storageClassHelp = strings.TrimRight(storageClassHelp, " ") + storageClassHelp = spacesString + storageClassHelp + lines[i] = storageClassHelp + line + } } - // run "helm dependency update" - cmd := exec.Command(helm, "dependency", "update", config.OutputDir) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + return []byte(strings.Join(lines, "\n")) } -// helmLint runs "helm lint" on the output directory. -func helmLint(config ConvertOptions) error { - fmt.Println(utils.IconInfo, "Linting...") - helm, err := exec.LookPath("helm") - if err != nil { - fmt.Println(utils.IconFailure, err) - os.Exit(1) - } - cmd := exec.Command(helm, "lint", config.OutputDir) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} +func addVariablesDoc(values []byte, project *types.Project) []byte { + lines := strings.Split(string(values), "\n") -// keyRegExp checks if the line starts by a # -var keyRegExp = regexp.MustCompile(`^\s*[^#]+:.*`) + for _, service := range project.Services { + lines = addDocToVariable(service, lines) + } + return []byte(strings.Join(lines, "\n")) +} // addYAMLSelectorPath adds a selector path to the yaml file for each key // as comment. E.g. foo.ingress.host @@ -587,14 +497,40 @@ func addYAMLSelectorPath(values []byte) []byte { return []byte(strings.Join(toReturn, "\n")) } -func writeContent(path string, content []byte) { - f, err := os.Create(path) +func buildCharYamlFile(chart *HelmChart, project *types.Project, chartPath string) { + // calculate the sha1 hash of the services + yamlChart, err := utils.EncodeBasicYaml(chart) if err != nil { - fmt.Println(utils.IconFailure, err) + fmt.Println(err) os.Exit(1) } - defer f.Close() - f.Write(content) + // concat chart adding a comment with hash of services on top + yamlChart = append([]byte(fmt.Sprintf("# compose hash (sha1): %s\n", *chart.composeHash)), yamlChart...) + // add the list of compose files + files := []string{} + for _, file := range project.ComposeFiles { + base := filepath.Base(file) + files = append(files, base) + } + yamlChart = append([]byte(fmt.Sprintf("# compose files: %s\n", strings.Join(files, ", "))), yamlChart...) + // add generated date + yamlChart = append([]byte(fmt.Sprintf("# generated at: %s\n", time.Now().Format(time.RFC3339))), yamlChart...) + + // document Chart.yaml file + yamlChart = addChartDoc(yamlChart, project) + + writeContent(chartPath, yamlChart) +} + +func buildNotesFile(project *types.Project, notesPath string) { + // get the list of services to write in the notes + services := make([]string, 0) + for _, service := range project.Services { + services = append(services, service.Name) + } + // write the notes to the disk + notes := extrafiles.NotesFile(services) + writeContent(notesPath, []byte(notes)) } func buildValues(chart *HelmChart, project *types.Project, valuesPath string) { @@ -622,42 +558,6 @@ func buildValues(chart *HelmChart, project *types.Project, valuesPath string) { writeContent(valuesPath, values) } -func buildNotesFile(project *types.Project, notesPath string) { - // get the list of services to write in the notes - services := make([]string, 0) - for _, service := range project.Services { - services = append(services, service.Name) - } - // write the notes to the disk - notes := extrafiles.NotesFile(services) - writeContent(notesPath, []byte(notes)) -} - -func buildCharYamlFile(chart *HelmChart, project *types.Project, chartPath string) { - // calculate the sha1 hash of the services - yamlChart, err := utils.EncodeBasicYaml(chart) - if err != nil { - fmt.Println(err) - os.Exit(1) - } - // concat chart adding a comment with hash of services on top - yamlChart = append([]byte(fmt.Sprintf("# compose hash (sha1): %s\n", *chart.composeHash)), yamlChart...) - // add the list of compose files - files := []string{} - for _, file := range project.ComposeFiles { - base := filepath.Base(file) - files = append(files, base) - } - yamlChart = append([]byte(fmt.Sprintf("# compose files: %s\n", strings.Join(files, ", "))), yamlChart...) - // add generated date - yamlChart = append([]byte(fmt.Sprintf("# generated at: %s\n", time.Now().Format(time.RFC3339))), yamlChart...) - - // document Chart.yaml file - yamlChart = addChartDoc(yamlChart, project) - - writeContent(chartPath, yamlChart) -} - func callHelmUpdate(config ConvertOptions) { executeAndHandleError := func(fn func(ConvertOptions) error, config ConvertOptions, message string) { if err := fn(config); err != nil { @@ -672,3 +572,107 @@ func callHelmUpdate(config ConvertOptions) { fmt.Println(utils.IconSuccess, "Helm chart created successfully") } } + +func removeNewlinesInsideBrackets(values []byte) []byte { + re, err := regexp.Compile(`(?s)\{\{(.*?)\}\}`) + if err != nil { + log.Fatal(err) + } + return re.ReplaceAllFunc(values, func(b []byte) []byte { + // get the first match + matches := re.FindSubmatch(b) + replacement := bytes.ReplaceAll(matches[1], []byte("\n"), []byte(" ")) + // remove repeated spaces + replacement = regexp.MustCompile(`\s+`).ReplaceAll(replacement, []byte(" ")) + // remove newlines inside brackets + return bytes.ReplaceAll(b, matches[1], replacement) + }) +} + +func removeUnwantedLines(values []byte) []byte { + lines := strings.Split(string(values), "\n") + output := []string{} + for _, line := range lines { + next := false + for _, unwanted := range unwantedLines { + if strings.Contains(line, unwanted) { + next = true + } + } + if !next { + output = append(output, line) + } + } + return []byte(strings.Join(output, "\n")) +} + +func writeContent(path string, content []byte) { + f, err := os.Create(path) + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + defer f.Close() + f.Write(content) +} + +// helmLint runs "helm lint" on the output directory. +func helmLint(config ConvertOptions) error { + fmt.Println(utils.IconInfo, "Linting...") + helm, err := exec.LookPath("helm") + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + cmd := exec.Command(helm, "lint", config.OutputDir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// helmUpdate runs "helm dependency update" on the output directory. +func helmUpdate(config ConvertOptions) error { + // lookup for "helm" binary + fmt.Println(utils.IconInfo, "Updating helm dependencies...") + helm, err := exec.LookPath("helm") + if err != nil { + fmt.Println(utils.IconFailure, err) + os.Exit(1) + } + // run "helm dependency update" + cmd := exec.Command(helm, "dependency", "update", config.OutputDir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// check if the project makes use of older labels (kanetary.[^v3]) +func checkOldLabels(project *types.Project) error { + badServices := make([]string, 0) + for _, service := range project.Services { + for label := range service.Labels { + if strings.Contains(label, "katenary.") && !strings.Contains(label, katenaryLabelPrefix) { + badServices = append(badServices, fmt.Sprintf("- %s: %s", service.Name, label)) + } + } + } + if len(badServices) > 0 { + message := fmt.Sprintf(` Old labels detected in project "%s". + + The current version of katenary uses labels with the prefix "%s" which are not compatible with previous versions. + Your project is not compatible with this version. + + Please upgrade your labels to follow the current version + + Services to upgrade: +%s`, + project.Name, + katenaryLabelPrefix[0:len(katenaryLabelPrefix)-1], + strings.Join(badServices, "\n"), + ) + + return errors.New(utils.WordWrap(message, 80)) + + } + return nil +} diff --git a/generator/deployment.go b/generator/deployment.go index 5e2c637..bad1f6f 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -2,6 +2,8 @@ package generator import ( "fmt" + "katenary/generator/labelStructs" + "katenary/utils" "log" "os" "path/filepath" @@ -14,9 +16,6 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" - - "katenary/generator/labelStructs" - "katenary/utils" ) var _ Yaml = (*Deployment)(nil) @@ -106,32 +105,6 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { return dep } -// DependsOn adds a initContainer to the deployment that will wait for the service to be up. -func (d *Deployment) DependsOn(to *Deployment, servicename string) error { - // Add a initContainer with busybox:latest using netcat to check if the service is up - // it will wait until the service responds to all ports - for _, container := range to.Spec.Template.Spec.Containers { - commands := []string{} - if len(container.Ports) == 0 { - utils.Warn("No ports found for service ", servicename, ". You should declare a port in the service or use "+LabelPorts+" label.") - os.Exit(1) - } - for _, port := range container.Ports { - command := fmt.Sprintf("until nc -z %s %d; do\n sleep 1;\ndone", to.Name, port.ContainerPort) - commands = append(commands, command) - } - - command := []string{"/bin/sh", "-c", strings.Join(commands, "\n")} - d.Spec.Template.Spec.InitContainers = append(d.Spec.Template.Spec.InitContainers, corev1.Container{ - Name: "wait-for-" + to.service.Name, - Image: "busybox:latest", - Command: command, - }) - } - - return nil -} - // AddContainer adds a container to the deployment. func (d *Deployment) AddContainer(service types.ServiceConfig) { ports := []corev1.ContainerPort{} @@ -178,6 +151,34 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) { d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, container) } +func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) { + // get the label for healthcheck + if v, ok := service.Labels[LabelHealthCheck]; ok { + probes, err := labelStructs.ProbeFrom(v) + if err != nil { + log.Fatal(err) + } + container.LivenessProbe = probes.LivenessProbe + container.ReadinessProbe = probes.ReadinessProbe + return + } + + if service.HealthCheck != nil { + period := 30.0 + if service.HealthCheck.Interval != nil { + period = time.Duration(*service.HealthCheck.Interval).Seconds() + } + container.LivenessProbe = &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{ + Command: service.HealthCheck.Test[1:], + }, + }, + PeriodSeconds: int32(period), + } + } +} + // AddIngress adds an ingress to the deployment. It creates the ingress object. func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *Ingress { return NewIngress(service, d.chart) @@ -209,124 +210,6 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) { } } -func (d *Deployment) bindVolumes(volume types.ServiceVolumeConfig, isSamePod bool, tobind map[string]bool, service types.ServiceConfig, appName string) { - container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) - defer func(d *Deployment, container *corev1.Container, index int) { - d.Spec.Template.Spec.Containers[index] = *container - }(d, container, index) - if _, ok := tobind[volume.Source]; !isSamePod && volume.Type == "bind" && !ok { - utils.Warn( - "Bind volumes are not supported yet, " + - "excepting for those declared as " + - LabelConfigMapFiles + - ", skipping volume " + volume.Source + - " from service " + service.Name, - ) - return - } - - if container == nil { - utils.Warn("Container not found for volume", volume.Source) - return - } - - // ensure that the volume is not already present in the container - for _, vm := range container.VolumeMounts { - if vm.Name == volume.Source { - return - } - } - - switch volume.Type { - case "volume": - // Add volume to container - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: volume.Source, - MountPath: volume.Target, - }) - // Add volume to values.yaml only if it the service is not in the same pod that another service. - // If it is in the same pod, the volume will be added to the other service later - if _, ok := service.Labels[LabelSamePod]; !ok { - d.chart.Values[service.Name].(*Value).AddPersistence(volume.Source) - } - // Add volume to deployment - d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ - Name: volume.Source, - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: utils.TplName(service.Name, appName, volume.Source), - }, - }, - }) - case "bind": - // Add volume to container - stat, err := os.Stat(volume.Source) - if err != nil { - log.Fatal(err) - } - - if stat.IsDir() { - d.appendDirectoryToConfigMap(service, appName, volume) - } else { - d.appendFileToConfigMap(service, appName, volume) - } - } -} - -func (d *Deployment) appendDirectoryToConfigMap(service types.ServiceConfig, appName string, volume types.ServiceVolumeConfig) { - pathnme := utils.PathToName(volume.Source) - if _, ok := d.configMaps[pathnme]; !ok { - d.configMaps[pathnme] = &ConfigMapMount{ - mountPath: []mountPathConfig{}, - } - } - - // TODO: make it recursive to add all files in the directory and subdirectories - _, err := os.ReadDir(volume.Source) - if err != nil { - log.Fatal(err) - } - cm := NewConfigMapFromDirectory(service, appName, volume.Source) - d.configMaps[pathnme] = &ConfigMapMount{ - configMap: cm, - mountPath: append(d.configMaps[pathnme].mountPath, mountPathConfig{ - mountPath: volume.Target, - }), - } -} - -func (d *Deployment) appendFileToConfigMap(service types.ServiceConfig, appName string, volume types.ServiceVolumeConfig) { - // In case of a file, add it to the configmap and use "subPath" to mount it - // Note that the volumes and volume mounts are not added to the deployment yet, they will be added later - // in generate.go - dirname := filepath.Dir(volume.Source) - pathname := utils.PathToName(dirname) - var cm *ConfigMap - if v, ok := d.configMaps[pathname]; !ok { - cm = NewConfigMap(*d.service, appName) - cm.usage = FileMapUsageFiles - cm.path = dirname - cm.Name = utils.TplName(service.Name, appName) + "-" + pathname - d.configMaps[pathname] = &ConfigMapMount{ - configMap: cm, - mountPath: []mountPathConfig{{ - mountPath: volume.Target, - subPath: filepath.Base(volume.Source), - }}, - } - } else { - cm = v.configMap - mp := d.configMaps[pathname].mountPath - mp = append(mp, mountPathConfig{ - mountPath: volume.Target, - subPath: filepath.Base(volume.Source), - }) - d.configMaps[pathname].mountPath = mp - - } - cm.AppendFile(volume.Source) -} - func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) { // find the volume in the binded deployment for _, bindedVolume := range binded.Spec.Template.Spec.Volumes { @@ -354,6 +237,37 @@ func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) { } } +// DependsOn adds a initContainer to the deployment that will wait for the service to be up. +func (d *Deployment) DependsOn(to *Deployment, servicename string) error { + // Add a initContainer with busybox:latest using netcat to check if the service is up + // it will wait until the service responds to all ports + for _, container := range to.Spec.Template.Spec.Containers { + commands := []string{} + if len(container.Ports) == 0 { + utils.Warn("No ports found for service ", servicename, ". You should declare a port in the service or use "+LabelPorts+" label.") + os.Exit(1) + } + for _, port := range container.Ports { + command := fmt.Sprintf("until nc -z %s %d; do\n sleep 1;\ndone", to.Name, port.ContainerPort) + commands = append(commands, command) + } + + command := []string{"/bin/sh", "-c", strings.Join(commands, "\n")} + d.Spec.Template.Spec.InitContainers = append(d.Spec.Template.Spec.InitContainers, corev1.Container{ + Name: "wait-for-" + to.service.Name, + Image: "busybox:latest", + Command: command, + }) + } + + return nil +} + +// Filename returns the filename of the deployment. +func (d *Deployment) Filename() string { + return d.service.Name + ".deployment.yaml" +} + // SetEnvFrom sets the environment variables to a configmap. The configmap is created. func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { if len(service.Environment) == 0 { @@ -447,34 +361,6 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) { d.Spec.Template.Spec.Containers[index] = *container } -func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) { - // get the label for healthcheck - if v, ok := service.Labels[LabelHealthCheck]; ok { - probes, err := labelStructs.ProbeFrom(v) - if err != nil { - log.Fatal(err) - } - container.LivenessProbe = probes.LivenessProbe - container.ReadinessProbe = probes.ReadinessProbe - return - } - - if service.HealthCheck != nil { - period := 30.0 - if service.HealthCheck.Interval != nil { - period = time.Duration(*service.HealthCheck.Interval).Seconds() - } - container.LivenessProbe = &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - Exec: &corev1.ExecAction{ - Command: service.HealthCheck.Test[1:], - }, - }, - PeriodSeconds: int32(period), - } - } -} - // Yaml returns the yaml representation of the deployment. func (d *Deployment) Yaml() ([]byte, error) { serviceName := d.service.Name @@ -489,11 +375,13 @@ func (d *Deployment) Yaml() ([]byte, error) { spaces := "" volumeName := "" + nameDirective := "name: " + // this loop add condition for each volume mount for line, volume := range content { // find the volume name for i := line; i < len(content); i++ { - if strings.Contains(content[i], "name: ") { + if strings.Contains(content[i], nameDirective) { volumeName = strings.TrimSpace(strings.Replace(content[i], "name: ", "", 1)) break } @@ -511,7 +399,7 @@ func (d *Deployment) Yaml() ([]byte, error) { content[line] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + volumeName + `.enabled }}` + "\n" + volume changing = true } - if strings.Contains(volume, "name: ") && changing { + if strings.Contains(volume, nameDirective) && changing { content[line] = volume + "\n" + spaces + "{{- end }}" changing = false } @@ -624,7 +512,120 @@ func (d *Deployment) Yaml() ([]byte, error) { return []byte(strings.Join(content, "\n")), nil } -// Filename returns the filename of the deployment. -func (d *Deployment) Filename() string { - return d.service.Name + ".deployment.yaml" +func (d *Deployment) appendDirectoryToConfigMap(service types.ServiceConfig, appName string, volume types.ServiceVolumeConfig) { + pathnme := utils.PathToName(volume.Source) + if _, ok := d.configMaps[pathnme]; !ok { + d.configMaps[pathnme] = &ConfigMapMount{ + mountPath: []mountPathConfig{}, + } + } + + // TODO: make it recursive to add all files in the directory and subdirectories + _, err := os.ReadDir(volume.Source) + if err != nil { + log.Fatal(err) + } + cm := NewConfigMapFromDirectory(service, appName, volume.Source) + d.configMaps[pathnme] = &ConfigMapMount{ + configMap: cm, + mountPath: append(d.configMaps[pathnme].mountPath, mountPathConfig{ + mountPath: volume.Target, + }), + } +} + +func (d *Deployment) appendFileToConfigMap(service types.ServiceConfig, appName string, volume types.ServiceVolumeConfig) { + // In case of a file, add it to the configmap and use "subPath" to mount it + // Note that the volumes and volume mounts are not added to the deployment yet, they will be added later + // in generate.go + dirname := filepath.Dir(volume.Source) + pathname := utils.PathToName(dirname) + var cm *ConfigMap + if v, ok := d.configMaps[pathname]; !ok { + cm = NewConfigMap(*d.service, appName, true) + cm.usage = FileMapUsageFiles + cm.path = dirname + cm.Name = utils.TplName(service.Name, appName) + "-" + pathname + d.configMaps[pathname] = &ConfigMapMount{ + configMap: cm, + mountPath: []mountPathConfig{{ + mountPath: volume.Target, + subPath: filepath.Base(volume.Source), + }}, + } + } else { + cm = v.configMap + mp := d.configMaps[pathname].mountPath + mp = append(mp, mountPathConfig{ + mountPath: volume.Target, + subPath: filepath.Base(volume.Source), + }) + d.configMaps[pathname].mountPath = mp + + } + cm.AppendFile(volume.Source) +} + +func (d *Deployment) bindVolumes(volume types.ServiceVolumeConfig, isSamePod bool, tobind map[string]bool, service types.ServiceConfig, appName string) { + container, index := utils.GetContainerByName(service.Name, d.Spec.Template.Spec.Containers) + defer func(d *Deployment, container *corev1.Container, index int) { + d.Spec.Template.Spec.Containers[index] = *container + }(d, container, index) + if _, ok := tobind[volume.Source]; !isSamePod && volume.Type == "bind" && !ok { + utils.Warn( + "Bind volumes are not supported yet, " + + "excepting for those declared as " + + LabelConfigMapFiles + + ", skipping volume " + volume.Source + + " from service " + service.Name, + ) + return + } + + if container == nil { + utils.Warn("Container not found for volume", volume.Source) + return + } + + // ensure that the volume is not already present in the container + for _, vm := range container.VolumeMounts { + if vm.Name == volume.Source { + return + } + } + + switch volume.Type { + case "volume": + // Add volume to container + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: volume.Source, + MountPath: volume.Target, + }) + // Add volume to values.yaml only if it the service is not in the same pod that another service. + // If it is in the same pod, the volume will be added to the other service later + if _, ok := service.Labels[LabelSamePod]; !ok { + d.chart.Values[service.Name].(*Value).AddPersistence(volume.Source) + } + // Add volume to deployment + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volume.Source, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: utils.TplName(service.Name, appName, volume.Source), + }, + }, + }) + case "bind": + // Add volume to container + stat, err := os.Stat(volume.Source) + if err != nil { + log.Fatal(err) + } + + if stat.IsDir() { + d.appendDirectoryToConfigMap(service, appName, volume) + } else { + d.appendFileToConfigMap(service, appName, volume) + } + } } diff --git a/generator/extrafiles/readme.go b/generator/extrafiles/readme.go index 01c54a2..b3201fe 100644 --- a/generator/extrafiles/readme.go +++ b/generator/extrafiles/readme.go @@ -11,14 +11,35 @@ import ( "gopkg.in/yaml.v3" ) +//go:embed readme.tpl +var readmeTemplate string + type chart struct { Name string Description string Values []string } -//go:embed readme.tpl -var readmeTemplate string +func parseValues(prefix string, values map[string]interface{}, result map[string]string) { + for key, value := range values { + path := key + if prefix != "" { + path = prefix + "." + key + } + + switch v := value.(type) { + case []interface{}: + for i, u := range v { + parseValues(fmt.Sprintf("%s[%d]", path, i), map[string]interface{}{"value": u}, result) + } + case map[string]interface{}: + parseValues(path, v, result) + default: + strValue := fmt.Sprintf("`%v`", value) + result["`"+path+"`"] = strValue + } + } +} // ReadMeFile returns the content of the README.md file. func ReadMeFile(charname, description string, values map[string]any) string { @@ -74,24 +95,3 @@ func ReadMeFile(charname, description string, values map[string]any) string { return buf.String() } - -func parseValues(prefix string, values map[string]interface{}, result map[string]string) { - for key, value := range values { - path := key - if prefix != "" { - path = prefix + "." + key - } - - switch v := value.(type) { - case []interface{}: - for i, u := range v { - parseValues(fmt.Sprintf("%s[%d]", path, i), map[string]interface{}{"value": u}, result) - } - case map[string]interface{}: - parseValues(path, v, result) - default: - strValue := fmt.Sprintf("`%v`", value) - result["`"+path+"`"] = strValue - } - } -} diff --git a/generator/generator.go b/generator/generator.go index 5fd3c00..a9eaf72 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -162,49 +162,6 @@ func Generate(project *types.Project) (*HelmChart, error) { return chart, nil } -// 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), " ")) - line = bytes.ReplaceAll(line, []byte("__indent__"), []byte(fmt.Sprintf("%d", len(startSpaces)))) - 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 -} - // serviceIsMain returns true if the service is the main app. func serviceIsMain(service types.ServiceConfig) bool { if main, ok := service.Labels[LabelMainApp]; ok { @@ -213,37 +170,6 @@ func serviceIsMain(service types.ServiceConfig) bool { return false } -// buildVolumes creates the volumes for the service. -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": - pvc := NewVolumeClaim(service, v.Source, appName) - - // if the service is integrated in another deployment, we need to add the volume - // to the target deployment - if override, ok := service.Labels[LabelSamePod]; ok { - pvc.nameOverride = override - pvc.Spec.StorageClassName = utils.StrPtr(`{{ .Values.` + override + `.persistence.` + v.Source + `.storageClass }}`) - chart.Values[override].(*Value).AddPersistence(v.Source) - } - y, _ := pvc.Yaml() - chart.Templates[pvc.Filename()] = &ChartTemplate{ - Content: y, - Servicename: service.Name, - } - } - } - - return nil -} - func addStaticVolumes(deployments map[string]*Deployment, service types.ServiceConfig) { // add the bound configMaps files to the deployment containers var d *Deployment @@ -292,6 +218,80 @@ func addStaticVolumes(deployments map[string]*Deployment, service types.ServiceC d.Spec.Template.Spec.Containers[index] = *container } +// 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), " ")) + line = bytes.ReplaceAll(line, []byte("__indent__"), []byte(fmt.Sprintf("%d", len(startSpaces)))) + 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 +} + +// buildVolumes creates the volumes for the service. +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": + pvc := NewVolumeClaim(service, v.Source, appName) + + // if the service is integrated in another deployment, we need to add the volume + // to the target deployment + if override, ok := service.Labels[LabelSamePod]; ok { + pvc.nameOverride = override + pvc.Spec.StorageClassName = utils.StrPtr(`{{ .Values.` + override + `.persistence.` + v.Source + `.storageClass }}`) + chart.Values[override].(*Value).AddPersistence(v.Source) + } + y, _ := pvc.Yaml() + chart.Templates[pvc.Filename()] = &ChartTemplate{ + Content: y, + Servicename: service.Name, + } + } + } + + return nil +} + // samePodVolume returns true if the volume is already in the target deployment. func samePodVolume(service types.ServiceConfig, v types.ServiceVolumeConfig, deployments map[string]*Deployment) bool { // if the service has volumes, and it has "same-pod" label diff --git a/generator/ingress.go b/generator/ingress.go index 669593a..03559d2 100644 --- a/generator/ingress.go +++ b/generator/ingress.go @@ -119,6 +119,10 @@ func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress { return ing } +func (ingress *Ingress) Filename() string { + return ingress.service.Name + ".ingress.yaml" +} + func (ingress *Ingress) Yaml() ([]byte, error) { serviceName := ingress.service.Name ret, err := yaml.Marshal(ingress) @@ -159,7 +163,3 @@ func (ingress *Ingress) Yaml() ([]byte, error) { ret = []byte(strings.Join(out, "\n")) return ret, nil } - -func (ingress *Ingress) Filename() string { - return ingress.service.Name + ".ingress.yaml" -} diff --git a/generator/katenaryLabels.go b/generator/katenaryLabels.go index c1f3448..a373e7c 100644 --- a/generator/katenaryLabels.go +++ b/generator/katenaryLabels.go @@ -4,6 +4,7 @@ import ( "bytes" _ "embed" "fmt" + "katenary/utils" "regexp" "sort" "strings" @@ -11,37 +12,10 @@ import ( "text/template" "sigs.k8s.io/yaml" - - "katenary/utils" ) -var ( - // Set the documentation of labels here - // - //go:embed katenaryLabelsDoc.yaml - labelFullHelpYAML []byte - - // parsed yaml - labelFullHelp map[string]Help -) - -// Label is a katenary label to find in compose files. -type Label = string - -// Help is the documentation of a label. -type Help struct { - Short string `yaml:"short"` - Long string `yaml:"long"` - Example string `yaml:"example"` - Type string `yaml:"type"` -} - const katenaryLabelPrefix = "katenary.v3" -func Prefix() string { - return katenaryLabelPrefix -} - // Known labels. const ( LabelMainApp Label = katenaryLabelPrefix + "/main-app" @@ -60,16 +34,47 @@ const ( LabelEnvFrom Label = katenaryLabelPrefix + "/env-from" ) +var ( + // Set the documentation of labels here + // + //go:embed katenaryLabelsDoc.yaml + labelFullHelpYAML []byte + + // parsed yaml + labelFullHelp map[string]Help +) + +// Label is a katenary label to find in compose files. +type Label = string + +func labelName(name string) Label { + return Label(katenaryLabelPrefix + "/" + name) +} + +// Help is the documentation of a label. +type Help struct { + Short string `yaml:"short"` + Long string `yaml:"long"` + Example string `yaml:"example"` + Type string `yaml:"type"` +} + +// GetLabelNames returns a sorted list of all katenary label names. +func GetLabelNames() []string { + var names []string + for name := range labelFullHelp { + names = append(names, name) + } + sort.Strings(names) + return names +} + func init() { if err := yaml.Unmarshal(labelFullHelpYAML, &labelFullHelp); err != nil { panic(err) } } -func labelName(name string) Label { - return Label(katenaryLabelPrefix + "/" + name) -} - // Generate the help for the labels. func GetLabelHelp(asMarkdown bool) string { names := GetLabelNames() // sorted @@ -79,73 +84,6 @@ func GetLabelHelp(asMarkdown bool) string { return generateMarkdownHelp(names) } -func generatePlainHelp(names []string) string { - var builder strings.Builder - for _, name := range names { - help := labelFullHelp[name] - fmt.Fprintf(&builder, "%s:\t%s\t%s\n", labelName(name), help.Type, help.Short) - } - - // use tabwriter to align the help text - buf := new(strings.Builder) - w := tabwriter.NewWriter(buf, 0, 8, 0, '\t', tabwriter.AlignRight) - fmt.Fprintln(w, builder.String()) - w.Flush() - - head := "To get more information about a label, use `katenary help-label \ne.g. katenary help-label dependencies\n\n" - return head + buf.String() -} - -func generateMarkdownHelp(names []string) string { - var builder strings.Builder - var maxNameLength, maxDescriptionLength, maxTypeLength int - - max := func(a, b int) int { - if a > b { - return a - } - return b - } - for _, name := range names { - help := labelFullHelp[name] - maxNameLength = max(maxNameLength, len(name)+2+len(katenaryLabelPrefix)) - maxDescriptionLength = max(maxDescriptionLength, len(help.Short)) - maxTypeLength = max(maxTypeLength, len(help.Type)) - } - - fmt.Fprintf(&builder, "%s\n", generateTableHeader(maxNameLength, maxDescriptionLength, maxTypeLength)) - fmt.Fprintf(&builder, "%s\n", generateTableHeaderSeparator(maxNameLength, maxDescriptionLength, maxTypeLength)) - - for _, name := range names { - help := labelFullHelp[name] - fmt.Fprintf(&builder, "| %-*s | %-*s | %-*s |\n", - maxNameLength, "`"+labelName(name)+"`", // enclose in backticks - maxDescriptionLength, help.Short, - maxTypeLength, help.Type, - ) - } - - return builder.String() -} - -func generateTableHeader(maxNameLength, maxDescriptionLength, maxTypeLength int) string { - return fmt.Sprintf( - "| %-*s | %-*s | %-*s |", - maxNameLength, "Label name", - maxDescriptionLength, "Description", - maxTypeLength, "Type", - ) -} - -func generateTableHeaderSeparator(maxNameLength, maxDescriptionLength, maxTypeLength int) string { - return fmt.Sprintf( - "| %s | %s | %s |", - strings.Repeat("-", maxNameLength), - strings.Repeat("-", maxDescriptionLength), - strings.Repeat("-", maxTypeLength), - ) -} - // GetLabelHelpFor returns the help for a specific label. func GetLabelHelpFor(labelname string, asMarkdown bool) string { help, ok := labelFullHelp[labelname] @@ -202,14 +140,71 @@ func GetLabelHelpFor(labelname string, asMarkdown bool) string { return buf.String() } -// GetLabelNames returns a sorted list of all katenary label names. -func GetLabelNames() []string { - var names []string - for name := range labelFullHelp { - names = append(names, name) +func generateMarkdownHelp(names []string) string { + var builder strings.Builder + var maxNameLength, maxDescriptionLength, maxTypeLength int + + max := func(a, b int) int { + if a > b { + return a + } + return b } - sort.Strings(names) - return names + for _, name := range names { + help := labelFullHelp[name] + maxNameLength = max(maxNameLength, len(name)+2+len(katenaryLabelPrefix)) + maxDescriptionLength = max(maxDescriptionLength, len(help.Short)) + maxTypeLength = max(maxTypeLength, len(help.Type)) + } + + fmt.Fprintf(&builder, "%s\n", generateTableHeader(maxNameLength, maxDescriptionLength, maxTypeLength)) + fmt.Fprintf(&builder, "%s\n", generateTableHeaderSeparator(maxNameLength, maxDescriptionLength, maxTypeLength)) + + for _, name := range names { + help := labelFullHelp[name] + fmt.Fprintf(&builder, "| %-*s | %-*s | %-*s |\n", + maxNameLength, "`"+labelName(name)+"`", // enclose in backticks + maxDescriptionLength, help.Short, + maxTypeLength, help.Type, + ) + } + + return builder.String() +} + +func generatePlainHelp(names []string) string { + var builder strings.Builder + for _, name := range names { + help := labelFullHelp[name] + fmt.Fprintf(&builder, "%s:\t%s\t%s\n", labelName(name), help.Type, help.Short) + } + + // use tabwriter to align the help text + buf := new(strings.Builder) + w := tabwriter.NewWriter(buf, 0, 8, 0, '\t', tabwriter.AlignRight) + fmt.Fprintln(w, builder.String()) + w.Flush() + + head := "To get more information about a label, use `katenary help-label \ne.g. katenary help-label dependencies\n\n" + return head + buf.String() +} + +func generateTableHeader(maxNameLength, maxDescriptionLength, maxTypeLength int) string { + return fmt.Sprintf( + "| %-*s | %-*s | %-*s |", + maxNameLength, "Label name", + maxDescriptionLength, "Description", + maxTypeLength, "Type", + ) +} + +func generateTableHeaderSeparator(maxNameLength, maxDescriptionLength, maxTypeLength int) string { + return fmt.Sprintf( + "| %s | %s | %s |", + strings.Repeat("-", maxNameLength), + strings.Repeat("-", maxDescriptionLength), + strings.Repeat("-", maxTypeLength), + ) } func getHelpTemplate(asMarkdown bool) string { @@ -234,3 +229,7 @@ Example: {{ .Help.Example }} ` } + +func Prefix() string { + return katenaryLabelPrefix +} diff --git a/generator/katenaryLabelsDoc.yaml b/generator/katenaryLabelsDoc.yaml index 9553343..f4b555f 100644 --- a/generator/katenaryLabelsDoc.yaml +++ b/generator/katenaryLabelsDoc.yaml @@ -1,7 +1,7 @@ # Labels documentation. # # To create a label documentation: -# +# # "labelname": # type: the label type (bool, string, array, object...) # short: a short description @@ -13,23 +13,23 @@ # This file is embed in the Katenary binary and parsed in kanetaryLabels.go init() function. # # Note: -# - The short and long texts are parsed with text/template, so you can use template syntax. -# That means that if you want to display double brackets, you need to enclose them to -# prevent template to try to expand the content, for example : +# - The short and long texts are parsed with text/template, so you can use template syntax. +# That means that if you want to display double brackets, you need to enclose them to +# prevent template to try to expand the content, for example : # This is an {{ "{{ example }}" }}. # # This will display "This is an {{ exemple }}" in the output. # - Use {{ .KatenaryPrefix }} to let Katenary replace it with the label prefix (e.g. "katenary.v3") -"main-app": +"main-app": short: "Mark the service as the main app." long: |- This makes the service to be the main application. Its image tag is considered to be the - + Chart appVersion and to be the defaultvalue in Pod container image attribute. - + !!! Warning This label cannot be repeated in others services. If this label is set in more than one service as true, Katenary will return an error. @@ -43,17 +43,17 @@ {{ .KatenaryPrefix }}/main-app: true type: "bool" -"values": +"values": short: "Environment variables to be added to the values.yaml" long: |- By default, all environment variables in the "env" and environment files are added to configmaps with the static values set. This label allows adding environment variables to the values.yaml file. - + Note that the value inside the configmap is {{ "{{ tpl vaname . }}" }}, so you can set the value to a template that will be rendered with the values.yaml file. - + The value can be set with a documentation. This may help to understand the purpose of the variable. example: |- @@ -75,7 +75,7 @@ "secrets": short: "Env vars to be set as secrets." - long: |- + long: |- This label allows setting the environment variables as secrets. The variable is removed from the environment and added to a secret object. @@ -102,7 +102,7 @@ - 8081 type: "list of uint32" -"ingress": +"ingress": short: "Ingress rules to be added to the service." long: |- Declare an ingress rule for the service. The port should be exposed or @@ -114,7 +114,7 @@ hostname: mywebsite.com (optional) type: "object" -"map-env": +"map-env": short: "Map env vars from the service to the deployment." long: |- Because you may need to change the variable for Kubernetes, this label @@ -136,8 +136,8 @@ type: "object" "health-check": - short: "Health check to be added to the deployment." - long: "Health check to be added to the deployment." + short: "Health check to be added to the deployment." + long: "Health check to be added to the deployment." example: |- labels: {{ .KatenaryPrefix }}/health-check: |- @@ -146,12 +146,12 @@ port: 8080 type: "object" -"same-pod": +"same-pod": short: "Move the same-pod deployment to the target deployment." long: |- This will make the service to be included in another service pod. Some services must work together in the same pod, like a sidecar or a proxy or nginx + php-fpm. - + Note that volume and VolumeMount are copied from the source to the target deployment. example: |- @@ -169,7 +169,7 @@ long: |- This replaces the default comment in values.yaml file to the given description. It is useful to document the service and configuration. - + The value can be set with a documentation in multiline format. example: |- labels: @@ -179,12 +179,12 @@ type: "string" "ignore": - short: "Ignore the service" - long: "Ingoring a service to not be exported in helm chart." + short: "Ignore the service" + long: "Ingoring a service to not be exported in helm chart." example: "labels:\n {{ .KatenaryPrefix }}/ignore: \"true\"" - type: "bool" + type: "bool" -"dependencies": +"dependencies": short: "Add Helm dependencies to the service." long: |- Set the service to be, actually, a Helm dependency. This means that the @@ -232,7 +232,7 @@ service directory. If it is a directory, all files inside it are added to the ConfigMap. - + If the directory as subdirectories, so one configmap per subpath are created. !!! Warning @@ -248,11 +248,11 @@ - ./conf.d type: "list of strings" -"cronjob": +"cronjob": short: "Create a cronjob from the service." long: |- This adds a cronjob to the chart. - + The label value is a YAML object with the following attributes: - command: the command to be executed - schedule: the cron schedule (cron format or @every where "every" is a @@ -284,4 +284,5 @@ # defined inside this service too {{ .KatenaryPrefix }}/env-from: |- - myservice1 + # vim: ft=gotmpl.yaml diff --git a/generator/labelStructs/dependencies.go b/generator/labelStructs/dependencies.go index bc94b30..71dde8c 100644 --- a/generator/labelStructs/dependencies.go +++ b/generator/labelStructs/dependencies.go @@ -4,11 +4,11 @@ import "gopkg.in/yaml.v3" // Dependency is a dependency of a chart to other charts. type Dependency struct { + Values map[string]any `yaml:"-"` Name string `yaml:"name"` Version string `yaml:"version"` Repository string `yaml:"repository"` Alias string `yaml:"alias,omitempty"` - Values map[string]any `yaml:"-"` // do not export to Chart.yaml } // DependenciesFrom returns a slice of dependencies from the given string. diff --git a/generator/labelStructs/ingress.go b/generator/labelStructs/ingress.go index b01cd36..22c5b01 100644 --- a/generator/labelStructs/ingress.go +++ b/generator/labelStructs/ingress.go @@ -3,18 +3,12 @@ package labelStructs import "gopkg.in/yaml.v3" type Ingress struct { - // Hostname is the hostname to match against the request. It can contain wildcards. - Hostname string `yaml:"hostname"` - // Path is the path to match against the request. It can contain wildcards. - Path string `yaml:"path"` - // Enabled is a flag to enable or disable the ingress. - Enabled bool `yaml:"enabled"` - // Class is the ingress class to use. - Class string `yaml:"class"` - // Port is the port to use. - Port *int32 `yaml:"port,omitempty"` - // Annotations is a list of key-value pairs to add to the ingress. + Port *int32 `yaml:"port,omitempty"` Annotations map[string]string `yaml:"annotations,omitempty"` + Hostname string `yaml:"hostname"` + Path string `yaml:"path"` + Class string `yaml:"class"` + Enabled bool `yaml:"enabled"` } // IngressFrom creates a new Ingress from a compose service. diff --git a/generator/rbac.go b/generator/rbac.go index f8295ab..f314ab9 100644 --- a/generator/rbac.go +++ b/generator/rbac.go @@ -102,38 +102,38 @@ type RoleBinding struct { service *types.ServiceConfig } -func (r *RoleBinding) Yaml() ([]byte, error) { - return yaml.Marshal(r) -} - func (r *RoleBinding) Filename() string { return r.service.Name + ".rolebinding.yaml" } +func (r *RoleBinding) Yaml() ([]byte, error) { + return yaml.Marshal(r) +} + // Role is a kubernetes Role. type Role struct { *rbacv1.Role service *types.ServiceConfig } -func (r *Role) Yaml() ([]byte, error) { - return yaml.Marshal(r) -} - func (r *Role) Filename() string { return r.service.Name + ".role.yaml" } +func (r *Role) Yaml() ([]byte, error) { + return yaml.Marshal(r) +} + // ServiceAccount is a kubernetes ServiceAccount. type ServiceAccount struct { *corev1.ServiceAccount service *types.ServiceConfig } -func (r *ServiceAccount) Yaml() ([]byte, error) { - return yaml.Marshal(r) -} - func (r *ServiceAccount) Filename() string { return r.service.Name + ".serviceaccount.yaml" } + +func (r *ServiceAccount) Yaml() ([]byte, error) { + return yaml.Marshal(r) +} diff --git a/generator/secret.go b/generator/secret.go index e26869b..bf1c22f 100644 --- a/generator/secret.go +++ b/generator/secret.go @@ -76,13 +76,6 @@ func NewSecret(service types.ServiceConfig, appName string) *Secret { return secret } -// SetData sets the data of the secret. -func (s *Secret) SetData(data map[string]string) { - for key, value := range data { - s.AddData(key, value) - } -} - // AddData adds a key value pair to the secret. func (s *Secret) AddData(key, value string) { if value == "" { @@ -91,6 +84,18 @@ func (s *Secret) AddData(key, value string) { s.Data[key] = []byte(`{{ tpl ` + value + ` $ | b64enc }}`) } +// Filename returns the filename of the secret. +func (s *Secret) Filename() string { + return s.service.Name + ".secret.yaml" +} + +// SetData sets the data of the secret. +func (s *Secret) SetData(data map[string]string) { + for key, value := range data { + s.AddData(key, value) + } +} + // Yaml returns the yaml representation of the secret. func (s *Secret) Yaml() ([]byte, error) { y, err := yaml.Marshal(s) @@ -106,8 +111,3 @@ func (s *Secret) Yaml() ([]byte, error) { return y, nil } - -// Filename returns the filename of the secret. -func (s *Secret) Filename() string { - return s.service.Name + ".secret.yaml" -} diff --git a/generator/service.go b/generator/service.go index 1951a33..a0cc2b7 100644 --- a/generator/service.go +++ b/generator/service.go @@ -74,6 +74,11 @@ func (s *Service) AddPort(port types.ServicePortConfig, serviceName ...string) { }) } +// Filename returns the filename of the service. +func (s *Service) Filename() string { + return s.service.Name + ".service.yaml" +} + // Yaml returns the yaml representation of the service. func (s *Service) Yaml() ([]byte, error) { y, err := yaml.Marshal(s) @@ -88,8 +93,3 @@ func (s *Service) Yaml() ([]byte, error) { return y, err } - -// Filename returns the filename of the service. -func (s *Service) Filename() string { - return s.service.Name + ".service.yaml" -} diff --git a/generator/values.go b/generator/values.go index 59b4ca5..7e2b380 100644 --- a/generator/values.go +++ b/generator/values.go @@ -43,14 +43,6 @@ type Value struct { ServiceAccount string `yaml:"serviceAccount"` } -// CronJobValue is a cronjob configuration that will be saved in values.yaml. -type CronJobValue struct { - Repository *RepositoryValue `yaml:"repository,omitempty"` - Environment map[string]any `yaml:"environment,omitempty"` - ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"` - Schedule string `yaml:"schedule"` -} - // NewValue creates a new Value from a compose service. // The value contains the necessary information to deploy the service (image, tag, replicas, etc.). // @@ -64,15 +56,22 @@ func NewValue(service types.ServiceConfig, main ...bool) *Value { // find the image tag tag := "" + split := strings.Split(service.Image, ":") - v.Repository = &RepositoryValue{ - Image: split[0], + if len(split) == 1 { + v.Repository = &RepositoryValue{ + Image: service.Image, + } + } else { + v.Repository = &RepositoryValue{ + Image: strings.Join(split[:len(split)-1], ":"), + } } // for main service, the tag should the appVersion. So here we set it to empty. if len(main) > 0 && !main[0] { if len(split) > 1 { - tag = split[1] + tag = split[len(split)-1] } v.Repository.Tag = tag } else { @@ -82,6 +81,15 @@ func NewValue(service types.ServiceConfig, main ...bool) *Value { return v } +func (v *Value) AddIngress(host, path string) { + v.Ingress = &IngressValue{ + Enabled: true, + Host: host, + Path: path, + Class: "-", + } +} + // AddPersistence adds persistence configuration to the Value. func (v *Value) AddPersistence(volumeName string) { if v.Persistence == nil { @@ -95,11 +103,10 @@ func (v *Value) AddPersistence(volumeName string) { } } -func (v *Value) AddIngress(host, path string) { - v.Ingress = &IngressValue{ - Enabled: true, - Host: host, - Path: path, - Class: "-", - } +// CronJobValue is a cronjob configuration that will be saved in values.yaml. +type CronJobValue struct { + Repository *RepositoryValue `yaml:"repository,omitempty"` + Environment map[string]any `yaml:"environment,omitempty"` + ImagePullPolicy string `yaml:"imagePullPolicy,omitempty"` + Schedule string `yaml:"schedule"` } diff --git a/generator/volume.go b/generator/volume.go index 39a02eb..49f189b 100644 --- a/generator/volume.go +++ b/generator/volume.go @@ -12,10 +12,10 @@ import ( "katenary/utils" ) -var _ Yaml = (*VolumeClaim)(nil) - const persistenceKey = "persistence" +var _ Yaml = (*VolumeClaim)(nil) + // VolumeClaim is a kubernetes VolumeClaim. This is a PersistentVolumeClaim. type VolumeClaim struct { *v1.PersistentVolumeClaim @@ -59,6 +59,11 @@ func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *Vo } } +// Filename returns the suggested filename for a VolumeClaim. +func (v *VolumeClaim) Filename() string { + return v.service.Name + "." + v.volumeName + ".volumeclaim.yaml" +} + // Yaml marshals a VolumeClaim into yaml. func (v *VolumeClaim) Yaml() ([]byte, error) { serviceName := v.service.Name @@ -122,8 +127,3 @@ func (v *VolumeClaim) Yaml() ([]byte, error) { return out, nil } - -// Filename returns the suggested filename for a VolumeClaim. -func (v *VolumeClaim) Filename() string { - return v.service.Name + "." + v.volumeName + ".volumeclaim.yaml" -} diff --git a/parser/main.go b/parser/main.go index fe25920..aea58a9 100644 --- a/parser/main.go +++ b/parser/main.go @@ -2,6 +2,9 @@ package parser import ( + "log" + "path/filepath" + "github.com/compose-spec/compose-go/cli" "github.com/compose-spec/compose-go/types" ) @@ -12,6 +15,7 @@ func init() { "compose.katenary.yml", "compose.katenary.yaml", }, cli.DefaultOverrideFileNames...) + // add podman-compose files cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames, []string{ "podman-compose.katenary.yml", @@ -22,18 +26,31 @@ func init() { } // Parse compose files and return a project. The project is parsed with dotenv, osenv and profiles. -func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, error) { +func Parse(profiles []string, envFiles []string, dockerComposeFile ...string) (*types.Project, error) { if len(dockerComposeFile) == 0 { cli.DefaultOverrideFileNames = append(cli.DefaultOverrideFileNames, dockerComposeFile...) } + log.Println("Loading compose files: ", cli.DefaultOverrideFileNames) + + // resolve absolute paths of envFiles + for i := range envFiles { + var err error + envFiles[i], err = filepath.Abs(envFiles[i]) + if err != nil { + log.Fatal(err) + } + } + log.Println("Loading env files: ", envFiles) + options, err := cli.NewProjectOptions(nil, cli.WithProfiles(profiles), + cli.WithInterpolation(true), cli.WithDefaultConfigPath, + cli.WithEnvFiles(envFiles...), cli.WithOsEnv, cli.WithDotEnv, cli.WithNormalization(true), - cli.WithInterpolation(true), cli.WithResolvedPaths(false), ) if err != nil { diff --git a/utils/utils.go b/utils/utils.go index 29081b7..2ed7428 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -20,6 +20,11 @@ func TplName(serviceName, appname string, suffix ...string) string { if len(suffix) > 0 { suffix[0] = "-" + suffix[0] } + for i, s := range suffix { + // replae all "_" with "-" + suffix[i] = strings.ReplaceAll(s, "_", "-") + } + serviceName = strings.ReplaceAll(serviceName, "_", "-") return `{{ include "` + appname + `.fullname" . }}-` + serviceName + strings.Join(suffix, "-") } @@ -109,8 +114,9 @@ func PathToName(path string) string { if path[0] == '/' || path[0] == '.' { path = path[1:] } - path = strings.ReplaceAll(path, "/", "_") - path = strings.ReplaceAll(path, ".", "_") + path = strings.ReplaceAll(path, "_", "-") + path = strings.ReplaceAll(path, "/", "-") + path = strings.ReplaceAll(path, ".", "-") return path } From 4703aa7df5816c52ecae1360bc92bcac89810426 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 18 Oct 2024 09:34:56 +0200 Subject: [PATCH 88/97] MkDocs needs 4 spaces for lists --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index dd72225..fc3a4a6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,5 +7,5 @@ indent_size=4 [*.md] trim_trailing_whitespace = false indent_style = space -indent_size = 2 +indent_size = 4 From d2c8d08b7f9fd9aa57c8e583d7b81a2493c5a5f7 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 18 Oct 2024 09:34:57 +0200 Subject: [PATCH 89/97] Validate markdown Use markdownlint / marksman in your editor please --- .gitignore | 1 + .markdownlint.yaml | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 .markdownlint.yaml diff --git a/.gitignore b/.gitignore index 618dbfd..d623135 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/* chart/* *.yaml *.yml +!.markdownlint.yaml !generator/*.yaml doc/venv/* !doc/mkdocs.yaml diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..62408cb --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,18 @@ +# markdownlint configuration file +default: true + +MD013: # Line length + line_length: 240 + +MD010: # Hard tabs + code_blocks: false + +# no inline HTML +MD033: false + +# heading as first line element... +MD041: false + +# list indentation +MD007: + indent: 4 From 533e1422d0e8ed9165b3eebde15968e05628adc0 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 18 Oct 2024 09:34:57 +0200 Subject: [PATCH 90/97] Sonar complience Use valid names and factorize some constants checks --- generator/configMap_test.go | 2 +- generator/cronJob_test.go | 4 ++-- generator/deployment_test.go | 14 ++++++------- generator/ingress_test.go | 2 +- generator/secret_test.go | 2 +- generator/service_test.go | 2 +- generator/tools_test.go | 7 +++---- generator/volume_test.go | 40 +++++++++++++++++++----------------- 8 files changed, 37 insertions(+), 36 deletions(-) diff --git a/generator/configMap_test.go b/generator/configMap_test.go index 6a29373..5b0c192 100644 --- a/generator/configMap_test.go +++ b/generator/configMap_test.go @@ -24,7 +24,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/configmap.yaml") + output := internalCompileTest(t, "-s", "templates/web/configmap.yaml") configMap := v1.ConfigMap{} if err := yaml.Unmarshal([]byte(output), &configMap); err != nil { t.Errorf(unmarshalError, err) diff --git a/generator/cronJob_test.go b/generator/cronJob_test.go index b892726..dbe1bac 100644 --- a/generator/cronJob_test.go +++ b/generator/cronJob_test.go @@ -30,7 +30,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/cron/cronjob.yaml") + output := internalCompileTest(t, "-s", "templates/cron/cronjob.yaml") cronJob := batchv1.CronJob{} if err := yaml.Unmarshal([]byte(output), &cronJob); err != nil { t.Errorf(unmarshalError, err) @@ -83,7 +83,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/cron/cronjob.yaml") + output := internalCompileTest(t, "-s", "templates/cron/cronjob.yaml") cronJob := batchv1.CronJob{} if err := yaml.Unmarshal([]byte(output), &cronJob); err != nil { t.Errorf(unmarshalError, err) diff --git a/generator/deployment_test.go b/generator/deployment_test.go index 5c716c2..d20f187 100644 --- a/generator/deployment_test.go +++ b/generator/deployment_test.go @@ -25,7 +25,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", webTemplateOutput) + output := internalCompileTest(t, "-s", webTemplateOutput) // dt := DeploymentTest{} dt := v1.Deployment{} @@ -67,7 +67,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", webTemplateOutput) + output := internalCompileTest(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -125,7 +125,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", webTemplateOutput) + output := internalCompileTest(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -167,7 +167,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", webTemplateOutput) + output := internalCompileTest(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -220,7 +220,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", webTemplateOutput) + output := internalCompileTest(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -257,7 +257,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", webTemplateOutput) + output := internalCompileTest(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -303,7 +303,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", webTemplateOutput) + output := internalCompileTest(t, "-s", webTemplateOutput) dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) diff --git a/generator/ingress_test.go b/generator/ingress_test.go index 7a039e1..759145f 100644 --- a/generator/ingress_test.go +++ b/generator/ingress_test.go @@ -30,7 +30,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/ingress.yaml", "--set", "web.ingress.enabled=true") + output := internalCompileTest(t, "-s", "templates/web/ingress.yaml", "--set", "web.ingress.enabled=true") ingress := v1.Ingress{} if err := yaml.Unmarshal([]byte(output), &ingress); err != nil { t.Errorf(unmarshalError, err) diff --git a/generator/secret_test.go b/generator/secret_test.go index 93949b6..6f80fe7 100644 --- a/generator/secret_test.go +++ b/generator/secret_test.go @@ -29,7 +29,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/secret.yaml") + output := internalCompileTest(t, "-s", "templates/web/secret.yaml") secret := v1.Secret{} if err := yaml.Unmarshal([]byte(output), &secret); err != nil { t.Errorf(unmarshalError, err) diff --git a/generator/service_test.go b/generator/service_test.go index 1bc7a89..205c944 100644 --- a/generator/service_test.go +++ b/generator/service_test.go @@ -24,7 +24,7 @@ services: os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/service.yaml") + output := internalCompileTest(t, "-s", "templates/web/service.yaml") service := v1.Service{} if err := yaml.Unmarshal([]byte(output), &service); err != nil { t.Errorf(unmarshalError, err) diff --git a/generator/tools_test.go b/generator/tools_test.go index 6fbdd6b..9d1591e 100644 --- a/generator/tools_test.go +++ b/generator/tools_test.go @@ -1,12 +1,11 @@ package generator import ( + "katenary/parser" "log" "os" "os/exec" "testing" - - "katenary/parser" ) const unmarshalError = "Failed to unmarshal the output: %s" @@ -29,8 +28,8 @@ func teardown(tmpDir string) { } } -func _compile_test(t *testing.T, options ...string) string { - _, err := parser.Parse(nil, "compose.yml") +func internalCompileTest(t *testing.T, options ...string) string { + _, err := parser.Parse(nil, nil, "compose.yml") if err != nil { t.Fatalf("Failed to parse the project: %s", err) } diff --git a/generator/volume_test.go b/generator/volume_test.go index d2f49b9..2342535 100644 --- a/generator/volume_test.go +++ b/generator/volume_test.go @@ -10,8 +10,10 @@ import ( "sigs.k8s.io/yaml" ) +const htmlContent = "

Hello, World!

" + func TestGenerateWithBoundVolume(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 @@ -20,14 +22,14 @@ services: volumes: data: ` - tmpDir := setup(compose_file) + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := internalCompileTest(t, "-s", "templates/web/deployment.yaml") dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { @@ -40,7 +42,7 @@ volumes: } func TestWithStaticFiles(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 @@ -50,8 +52,8 @@ services: %s/configmap-files: |- - ./static ` - compose_file = fmt.Sprintf(compose_file, katenaryLabelPrefix) - tmpDir := setup(compose_file) + composeFile = fmt.Sprintf(composeFile, katenaryLabelPrefix) + tmpDir := setup(composeFile) defer teardown(tmpDir) // create a static directory with an index.html file @@ -61,14 +63,14 @@ services: if err != nil { t.Errorf("Failed to create index.html: %s", err) } - indexFile.WriteString("

Hello, World!

") + indexFile.WriteString(htmlContent) indexFile.Close() currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := internalCompileTest(t, "-s", "templates/web/deployment.yaml") dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -94,13 +96,13 @@ services: if len(data) != 1 { t.Errorf("Expected 1 data, got %d", len(data)) } - if data["index.html"] != "

Hello, World!

" { - t.Errorf("Expected index.html to be

Hello, World!

, got %s", data["index.html"]) + if data["index.html"] != htmlContent { + t.Errorf("Expected index.html to be "+htmlContent+", got %s", data["index.html"]) } } func TestWithFileMapping(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 @@ -110,8 +112,8 @@ services: %s/configmap-files: |- - ./static/index.html ` - compose_file = fmt.Sprintf(compose_file, katenaryLabelPrefix) - tmpDir := setup(compose_file) + composeFile = fmt.Sprintf(composeFile, katenaryLabelPrefix) + tmpDir := setup(composeFile) defer teardown(tmpDir) // create a static directory with an index.html file @@ -121,14 +123,14 @@ services: if err != nil { t.Errorf("Failed to create index.html: %s", err) } - indexFile.WriteString("

Hello, World!

") + indexFile.WriteString(htmlContent) indexFile.Close() currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := internalCompileTest(t, "-s", "templates/web/deployment.yaml") dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) @@ -147,7 +149,7 @@ services: } func TestBindFrom(t *testing.T) { - compose_file := ` + composeFile := ` services: web: image: nginx:1.29 @@ -167,15 +169,15 @@ volumes: data: ` - compose_file = fmt.Sprintf(compose_file, katenaryLabelPrefix) - tmpDir := setup(compose_file) + composeFile = fmt.Sprintf(composeFile, katenaryLabelPrefix) + tmpDir := setup(composeFile) defer teardown(tmpDir) currentDir, _ := os.Getwd() os.Chdir(tmpDir) defer os.Chdir(currentDir) - output := _compile_test(t, "-s", "templates/web/deployment.yaml") + output := internalCompileTest(t, "-s", "templates/web/deployment.yaml") dt := v1.Deployment{} if err := yaml.Unmarshal([]byte(output), &dt); err != nil { t.Errorf(unmarshalError, err) From 865473b41b7939bc8a2d7f60e337969ae44f44c1 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 18 Oct 2024 09:34:57 +0200 Subject: [PATCH 91/97] Enhance documentation - upgraded mkdocs and dependencise (+ add mermaid) - linted markdown - add more details --- doc/docs/coding.md | 33 +++++- doc/docs/dependencies.md | 12 +- doc/docs/faq.md | 78 ++++++++----- doc/docs/index.md | 30 +++-- doc/docs/packages/generator.md | 117 ++++++++++---------- doc/docs/packages/generator/extrafiles.md | 2 +- doc/docs/packages/generator/labelStructs.md | 22 ++-- doc/docs/packages/parser.md | 4 +- doc/docs/packages/utils.md | 32 +++--- doc/docs/usage.md | 60 +++++----- doc/mkdocs.yml | 6 +- doc/requirements.txt | 14 +-- 12 files changed, 227 insertions(+), 183 deletions(-) diff --git a/doc/docs/coding.md b/doc/docs/coding.md index d039527..25bc8cb 100644 --- a/doc/docs/coding.md +++ b/doc/docs/coding.md @@ -9,7 +9,7 @@ Katenary is developed in Go. The version currently supported is 1.20. For reason preferred to `interface{}`. Since version v3, Katenary uses, in addition to `go-compose`, the `k8s` library to generate objects that are guaranteed -to work before transformation. Katenary adds Helm syntax entries to add loops, transformations and conditions. +to work before transformation. Katenary adds Helm syntax entries to add loops, transformations, and conditions. We really try to follow best practices and code principles. But, Katenary needs a lot of workarounds and string manipulation during the process. There are, also, some drawbacks using standard k8s packages that makes a lot of type @@ -25,6 +25,35 @@ During conversion, the `generator` package is primarily responsible for creating one `Deployment` per `compose` service. If the container coming from "compose" exposes ports (explicitly), then a service is created. +```mermaid +flowchart TD + + D[Deployment]:::outputs@{shape: curv-trap} + C[Container List]@{shape: docs} + ConfigMap:::outputs@{shape: curv-trap} + Secrets:::outputs@{shape: curv-trap} + H[Helm Chart.yaml file]:::outputs@{shape: curv-trap} + Val[Values files]:::outputs@{shape: curv-trap} + PVC:::outputs@{shape: curv-trap} + S[Service]:::outputs@{shape: curv-trap} + + A[Compose file]:::inputs --> B[Compose parser] + B --> G[Generator] + G --> P[Ports exposed to services] ---> S + G ------> H + G --> C --> D + G ------> Val + G ....-> M[Merge Continainers if same-pod] + M ..-> C + G --> E[Environment variables] ----> Secrets & ConfigMap + G--> V[Bind volumes] -------> PVC + V -----> CF[ Create ConfigMap\nfor static files as\nconfigmap-files] --> ConfigMap + + Secrets & ConfigMap -- create envFrom --> D + V -- bind volumes --> D + +``` + If the declaration of a container is to be integrated into another pod (via the `same-pod` label), this `Deployment` and its associated service are still created. They are deleted last, once the merge has been completed. @@ -34,7 +63,6 @@ The `generator` package is where object struct are defined, and where the `Gener The generation is made by using a `HelmChart` object: -```golang ```golang for _, service := range project.Services { dep := NewDeployment(service) @@ -44,6 +72,7 @@ for _, service := range project.Services { Servicename: service.Name, } } +``` **A lot** of string manipulations are made by each `Yaml()` methods. This is where you find the complex and impacting operations. The `Yaml` methods **don't return a valid YAML content**. This is a Helm Chart Yaml content with template diff --git a/doc/docs/dependencies.md b/doc/docs/dependencies.md index 36f8975..29b73b7 100644 --- a/doc/docs/dependencies.md +++ b/doc/docs/dependencies.md @@ -2,15 +2,17 @@ Katenary uses `compose-go` and several kubernetes official packages. -- `github.com/compose-spec/compose-go`: to parse compose files. It ensures that: - - the project respects the "compose" specification - - katenary uses the "compose" struct exactly the same way that podman-compose or docker does +- `github.com/compose-spec/compose-go`: to parse compose files. It ensures : + - that the project respects the "compose" specification + - that Katenary uses the "compose" struct exactly the same way `podman compose` or `docker copose` does - `github.com/spf13/cobra`: to parse command line arguments, subcommands and flags. It also generates completion for - bash, zsh, fish and powershell. + bash, zsh, fish and PowerShell. - `github.com/thediveo/netdb`: to get the standard names of a service from its port number - `gopkg.in/yaml.v3`: - to generate `Chart.yaml` and `values.yaml` files (only) - to parse Katenary labels in the compose file - `k8s.io/api` and `k8s.io/apimachinery` to create Kubernetes objects -- `sigs.k8s.io/yaml`: to generate Katenary yaml files +- `sigs.k8s.io/yaml`: to generate Katenary YAML files in the format of Kubernetes objects +There are also some other packages used in the project, like `gopkg.in/yaml` to parse labels. I'm sorry to not list the +entire dependencies. You can check the `go.mod` file to see all the dependencies. diff --git a/doc/docs/faq.md b/doc/docs/faq.md index a54805b..902aaaa 100644 --- a/doc/docs/faq.md +++ b/doc/docs/faq.md @@ -2,86 +2,108 @@ ## Why Katenary? -The main author[^1] of Katenary is a big fan of Podman, Docker and makes a huge use of Compose. He uses it a lot in his daily work. When he started to work with Kubernetes, he wanted to have the same experience as with Docker Compose. He wanted to have a tool that could convert his `docker-compose` files to Kubernetes manifests, but also to Helm charts. +The main author[^1] of Katenary is a big fan of Podman, Docker and makes a huge use of Compose. He uses it a lot in his +daily work. When he started to work with Kubernetes, he wanted to have the same experience as with Docker Compose. +He wanted to have a tool that could convert his `docker-compose` files to Kubernetes manifests, but also to Helm charts. -Kompose was a good option. But the lacks of some options and configuration for the output Helm chart made him think about creating a new tool. He wanted to have a tool that could generate a complete Helm chart, with a lot of options and flexibility. +Kompose was a good option. But the lacks of some options and configuration for the output Helm chart made him think +about creating a new tool. He wanted to have a tool that could generate a complete Helm chart, with a lot of options +and flexibility. -[^1]: I'm talking about myself :sunglasses: - Patrice FERLET, aka metal3d, Tech Lead and DevOps Engineer at Klee Group. +[^1]: I'm talking about myself :sunglasses: - Patrice FERLET, aka Metal3d, Tech Lead and DevOps Engineer at Klee Group. ## What's the difference between Katenary and Kompose? -[Kompose](https://kompose.io/) is a very nice tool, made by the Kubernetes community. It's a tool to convert `docker-compose` files to Kubernetes manifests. It's a very good tool, and it's more mature than Katenary. +[Kompose](https://kompose.io/) is a very nice tool, made by the Kubernetes community. It's a tool to convert +`docker-compose` files to Kubernetes manifests. It's a very good tool, and it's more mature than Katenary. -Kompose is able to genererate Helm charts, but [it could be not the case in future releases](https://github.com/kubernetes/kompose/issues/1716) for several reasons[^2]. +Kompose is able to generate Helm charts, but [it could be not the case in future releases](https://github.com/kubernetes/kompose/issues/1716) for several reasons[^2]. -[^2]: The author of Kompose explains that they have no bandwidth to maintain the Helm chart generation. It's a complex task, and we can confirm. Katenary takes a lot of time to be developed and maintained. This issue mentions Katenary as an alternative to Helm chart generation :smile: +[^2]: The author of Kompose explains that they have no bandwidth to maintain the Helm chart generation. It's a complex +task, and we can confirm. Katenary takes a lot of time to be developed and maintained. This issue mentions Katenary as +an alternative to Helm chart generation :smile: -The project is focused on Kubernetes manifests and proposes to use "kusomize" to adapt the manifests. Helm seems to be not the priority. +The project is focused on Kubernetes manifests and proposes to use "kusomize" to adapt the manifests. Helm seems to be +not the priority. -Anyway, before this decision, the Helm chart generation was not what we expected. We wanted to have a more complete chart, with more options and more flexibility. +Anyway, before this decision, the Helm chart generation was not what we expected. We wanted to have a more complete +chart, with more options and more flexibility. > That's why we decided to create Katenary. -Kompose didn't manage to generate a values file, complexe volume binding, and many other things. It was also not able to manage dependencies between services. +Kompose didn't manage to generate a values file, complex volume binding, and many other things. It was also not able +to manage dependencies between services. > Be sure that we don't want to compete with Kompose. We just want to propose a different approach to the problem. -Kompose is an excellent tool, and we use it in some projects. It's a good choice if you want to convert your `docker-compose` files to Kubernetes manifests, but if you want to use Helm, Katenary is the tool you need. +Kompose is an excellent tool, and we use it in some projects. It's a good choice if you want to convert +your `docker-compose` files to Kubernetes manifests, but if you want to use Helm, Katenary is the tool you need. ## Why not using "one label" for all the configuration? -That was a dicsussion I had with my colleagues. The idea was to use a single label to store all the configuration. But, it's not a good idea. +That was a dicsussion I had with my colleagues. The idea was to use a single label to store all the configuration. +But, it's not a good idea. -Sometimes, you will have a long list of things to configure, like ports, ingress, dependecies, etc. It's better to have a clear and readable configuration. Segmented labels are easier to read and to maintain. It also avoids to have too many indentation levels in the YAML file. +Sometimes, you will have a long list of things to configure, like ports, ingress, dependencies, etc. It's better to have +a clear and readable configuration. Segmented labels are easier to read and to maintain. It also avoids having too +many indentation levels in the YAML file. It is also more flexible. You can add or remove labels without changing the others. ## Why not using a configuration file? -The idea was to keep the configuration at a same place, and using the go-compose library to read the labels. It's easier to have a single file to manage. +The idea was to keep the configuration at a same place, and using the go-compose library to read the labels. It's +easier to have a single file to manage. -By the way, Katenary auto accepts a `compose.katenary.yaml` file in the same directory. It's a way to separate the configuration from the compose file. It uses the [overrides mecanism](https://docs.docker.com/compose/multiple-compose-files/merge/) like "compose" does. +By the way, Katenary auto accepts a `compose.katenary.yaml` file in the same directory. It's a way to separate the +configuration from the compose file. It uses +the [overrides' mechanism](https://docs.docker.com/compose/multiple-compose-files/merge/) like "compose" does. - -## Why not developping with Rust? +## Why not developing with Rust? Seriously... OK, I will answer. -Rust is a good language. But, Podman, Docker, Kubernetes, Helm, and mostly all technologies around Kubernetes are written in Go. We have a large ecosystem in Go to manipulate, read, and write Kubernetes manifests as parsing Compose files. +Rust is a good language. But, Podman, Docker, Kubernetes, Helm, and mostly all technologies around Kubernetes are +written in Go. We have a large ecosystem in Go to manipulate, read, and write Kubernetes manifests as parsing +Compose files. -Go is better for this task. +> Go is better for this task. There is no reason to use Rust for this project. ## Any chance to have a GUI? -Yes, it's a possibility. But, it's not a priority. We have a lot of things to do before. We need to stabilize the project, to have a good documentation, to have a good test coverage, and to have a good community. +Yes, it's a possibility. But, it's not a priority. We have a lot of things to do before. We need to stabilize the +project, to have a good documentation, to have a good test coverage, and to have a good community. But, in a not so far future, we could have a GUI. The choice of [Fyne.io](https://fyne.io) is already made and we tested some concepts. - ## I'm rich (or not), I want to help you. How can I do? You can help us in many ways. -- The first things we really need, more than money, more than anything else, is to have feedback. If you use Katenary, if you have some issues, if you have some ideas, please open an issue on the [GitHub repository](https://github.com/metal3d/katenary). -- The second things is to help us to fix issues. If you're a Go developper, or if you want to fix the documentation, your help is greatly appreciated. +- The first things we really need, more than money, more than anything else, is to have feedback. If you use Katenary, +if you have some issues, if you have some ideas, please open an issue on the [GitHub repository](https://github.com/metal3d/katenary). +- The second things is to help us to fix issues. If you're a Go developper, or if you want to fix the documentation, +your help is greatly appreciated. - And then, of course, we need money, or sponsors. ### If you're a company -We will be happy to communicate your help by putting your logo on the website and in the documentaiton. You can sponsor us by giving us some money, or by giving us some time of your developers, or leaving us some time to work on the project. +We will be happy to communicate your help by putting your logo on the website and in the documentaiton. You can sponsor +us by giving us some money, or by giving us some time of your developers, or leaving us some time to work on the project. ### If you're an individual -All donators will be listed on the website and in the documentation. You can give us some money by using the [GitHub Sponsors]() +All donators will be listed on the website and in the documentation. You can give us some money by using +the [GitHub Sponsors]() -All main contributors[^3] will be listed on the website and in the documentation. +All main contributors[^3] will be listed on the website and in the documentation. > If you want to be anonymous, please tell us. - -[^3]: Main contributors are the people who have made a significant contribution to the project. It could be code, documentation, or any other help. There is no defined rules, at this time, to evaluate the contribution. It's a subjective decision. - +[^3]: Main contributors are the people who have made a significant contribution to the project. It could be code, +documentation, or any other help. There is no defined rules, at this time, to evaluate the contribution. +It's a subjective decision. diff --git a/doc/docs/index.md b/doc/docs/index.md index fc3c4c7..5c75dd3 100644 --- a/doc/docs/index.md +++ b/doc/docs/index.md @@ -6,10 +6,10 @@ 🚀 Unleash Productivity with Katenary! 🚀 -Tired of manual conversions? Katenary harnesses the labels from your "compose" file to craft complete Helm Charts +Tired of manual conversions? Katenary harnesses the labels from your "compose" file to craft complete Helm Charts effortlessly, saving you time and energy. -🛠️ Simple autmated CLI: Katenary handles the grunt work, generating everything needed for seamless service binding +🛠️ Simple autmated CLI: Katenary handles the grunt work, generating everything needed for seamless service binding and Helm Chart creation. 💡 Effortless Efficiency: You only need to add labels when it's necessary to precise things. Then call `katenary convert` @@ -19,13 +19,12 @@ and let the magic happen. ![](statics/workflow.svg) - # What is it? Katenary is a tool made to help you to transform "compose" files (`compose.yaml`, `docker-compose.yml`, `podman-compose.yml`...) to complete and production ready [Helm Chart](https://helm.sh). -You'll be able to deploy your project in [:material-kubernetes: Kubernetes](https://kubernetes.io) in a few seconds +You'll be able to deploy your project in [:material-kubernetes: Kubernetes](https://kubernetes.io) in a few seconds (of course, more if you need to tweak with labels). It uses your current file and optionnaly labels to configure the result. @@ -42,19 +41,19 @@ share it with the community. The main developer is [Patrice FERLET](https://github.com/metal3d). -The project source +The project source code is hosted on the [:fontawesome-brands-github: Katenary GitHub Repository](https://github.com/metal3d/katenary). ## Install Katenary -Katenary is developped using the :fontawesome-brands-golang:{ .gopher } [Go](https://go.dev) language. +Katenary is developped using the :fontawesome-brands-golang:{ .gopher } [Go](https://go.dev) language. The binary is statically linked, so you can simply download it from the [release page](https://github.com/metal3d/katenary/releases) of the project in GutHub. -You need to select the right binary for your operating system and architecture, and copy the binary in a directory +You need to select the right binary for your operating system and architecture, and copy the binary in a directory that is in your `PATH`. -If you are a Linux user, you can use the "one line installation command" which will download the binary in your +If you are a Linux user, you can use the "one line installation command" which will download the binary in your `$HOME/.local/bin` directory if it exists. ```bash @@ -66,10 +65,9 @@ sh <(curl -sSL https://raw.githubusercontent.com/metal3d/katenary/master/install Of course, you need to install Katenary once :smile: - !!! Note "You prefer to compile it, no need to install Go" - You can also build and install it yourself, the provided Makefile has got a `build` command that uses `podman` or - `docker` to build the binary. + You can also build and install it yourself, the provided Makefile has got a `build` command that uses `podman` or + `docker` to build the binary. So, you don't need to install Go compiler :+1:. @@ -85,7 +83,7 @@ make build make install ``` -`make install` copies `./katenary` binary to your user binary path (`~/.local/bin`) +`make install` copies `./katenary` binary to your user binary path (`~/.local/bin`) You can install it in other directory by changing the `PREFIX` variable. E.g.: @@ -109,8 +107,7 @@ source <(katenary completion bash) Add this line in you `~/.profile`, `~/.bash_aliases` or `~/.bashrc` file to have completion at startup. - -## What a name... +## What a name A catenary is the curve that a hanging chain or cable assumes under its own weight when supported only at its ends. I, the maintainer, decided to name "Katenary" this project because it's like a chain that links a boat to a dock. @@ -122,14 +119,13 @@ Anyway, it's too late to change the name now :smile: I spent time to find it :wink: -## Special thanks to... +## Special thanks to I really want to thank all the contributors, testers, and of course, the authors of the packages and tools that are used in this project. There is too many to list here. Katenary can works because of all these people. Open source is a great thing! :heart: - -!!! Edit "Special thanks" +!!! Edit "Special thanks" **Katenary is built with:**
diff --git a/doc/docs/packages/generator.md b/doc/docs/packages/generator.md index 924a942..5df58a5 100644 --- a/doc/docs/packages/generator.md +++ b/doc/docs/packages/generator.md @@ -35,7 +35,7 @@ var Version = "master" // changed at compile time ``` -## func [Convert]() +## func [Convert]() ```go func Convert(config ConvertOptions, dockerComposeFile ...string) @@ -44,7 +44,7 @@ func Convert(config ConvertOptions, dockerComposeFile ...string) Convert a compose \(docker, podman...\) project to a helm chart. It calls Generate\(\) to generate the chart and then write it to the disk. -## func [GetLabelHelp]() +## func [GetLabelHelp]() ```go func GetLabelHelp(asMarkdown bool) string @@ -53,7 +53,7 @@ func GetLabelHelp(asMarkdown bool) string Generate the help for the labels. -## func [GetLabelHelpFor]() +## func [GetLabelHelpFor]() ```go func GetLabelHelpFor(labelname string, asMarkdown bool) string @@ -62,7 +62,7 @@ func GetLabelHelpFor(labelname string, asMarkdown bool) string GetLabelHelpFor returns the help for a specific label. -## func [GetLabelNames]() +## func [GetLabelNames]() ```go func GetLabelNames() []string @@ -107,7 +107,7 @@ func NewCronJob(service types.ServiceConfig, chart *HelmChart, appName string) ( NewCronJob creates a new CronJob from a compose service. The appName is the name of the application taken from the project name. -## func [Prefix]() +## func [Prefix]() ```go func Prefix() string @@ -116,7 +116,7 @@ func Prefix() string -## type [ChartTemplate]() +## type [ChartTemplate]() ChartTemplate is a template of a chart. It contains the content of the template and the name of the service. This is used internally to generate the templates. @@ -128,7 +128,7 @@ type ChartTemplate struct { ``` -## type [ConfigMap]() +## type [ConfigMap]() ConfigMap is a kubernetes ConfigMap. Implements the DataMap interface. @@ -140,16 +140,16 @@ type ConfigMap struct { ``` -### func [NewConfigMap]() +### func [NewConfigMap]() ```go -func NewConfigMap(service types.ServiceConfig, appName string) *ConfigMap +func NewConfigMap(service types.ServiceConfig, appName string, forFile bool) *ConfigMap ``` NewConfigMap creates a new ConfigMap from a compose service. The appName is the name of the application taken from the project name. The ConfigMap is filled by environment variables and labels "map\-env". -### func [NewConfigMapFromDirectory]() +### func [NewConfigMapFromDirectory]() ```go func NewConfigMapFromDirectory(service types.ServiceConfig, appName, path string) *ConfigMap @@ -158,7 +158,7 @@ func NewConfigMapFromDirectory(service types.ServiceConfig, appName, path string NewConfigMapFromDirectory creates a new ConfigMap from a compose service. This path is the path to the file or directory. If the path is a directory, all files in the directory are added to the ConfigMap. Each subdirectory are ignored. Note that the Generate\(\) function will create the subdirectories ConfigMaps. -### func \(\*ConfigMap\) [AddData]() +### func \(\*ConfigMap\) [AddData]() ```go func (c *ConfigMap) AddData(key, value string) @@ -167,7 +167,7 @@ func (c *ConfigMap) AddData(key, value string) AddData adds a key value pair to the configmap. Append or overwrite the value if the key already exists. -### func \(\*ConfigMap\) [AppendDir]() +### func \(\*ConfigMap\) [AppendDir]() ```go func (c *ConfigMap) AppendDir(path string) @@ -176,7 +176,7 @@ func (c *ConfigMap) AppendDir(path string) AddFile adds files from given path to the configmap. It is not recursive, to add all files in a directory, you need to call this function for each subdirectory. -### func \(\*ConfigMap\) [AppendFile]() +### func \(\*ConfigMap\) [AppendFile]() ```go func (c *ConfigMap) AppendFile(path string) @@ -185,7 +185,7 @@ func (c *ConfigMap) AppendFile(path string) -### func \(\*ConfigMap\) [Filename]() +### func \(\*ConfigMap\) [Filename]() ```go func (c *ConfigMap) Filename() string @@ -194,7 +194,7 @@ func (c *ConfigMap) Filename() string Filename returns the filename of the configmap. If the configmap is used for files, the filename contains the path. -### func \(\*ConfigMap\) [SetData]() +### func \(\*ConfigMap\) [SetData]() ```go func (c *ConfigMap) SetData(data map[string]string) @@ -203,7 +203,7 @@ func (c *ConfigMap) SetData(data map[string]string) SetData sets the data of the configmap. It replaces the entire data. -### func \(\*ConfigMap\) [Yaml]() +### func \(\*ConfigMap\) [Yaml]() ```go func (c *ConfigMap) Yaml() ([]byte, error) @@ -212,7 +212,7 @@ func (c *ConfigMap) Yaml() ([]byte, error) Yaml returns the yaml representation of the configmap -## type [ConfigMapMount]() +## type [ConfigMapMount]() @@ -223,7 +223,7 @@ type ConfigMapMount struct { ``` -## type [ConvertOptions]() +## type [ConvertOptions]() ConvertOptions are the options to convert a compose project to a helm chart. @@ -232,9 +232,11 @@ type ConvertOptions struct { AppVersion *string OutputDir string ChartVersion string + Icon string Profiles []string Force bool HelmUpdate bool + EnvFiles []string } ``` @@ -273,7 +275,7 @@ Yaml returns the yaml representation of the cronjob. Implements the Yaml interface. -## type [CronJobValue]() +## type [CronJobValue]() CronJobValue is a cronjob configuration that will be saved in values.yaml. @@ -299,7 +301,7 @@ type DataMap interface { ``` -### func [NewFileMap]() +### func [NewFileMap]() ```go func NewFileMap(service types.ServiceConfig, appName, kind string) DataMap @@ -308,7 +310,7 @@ func NewFileMap(service types.ServiceConfig, appName, kind string) DataMap NewFileMap creates a new DataMap from a compose service. The appName is the name of the application taken from the project name. -## type [Deployment]() +## type [Deployment]() Deployment is a kubernetes Deployment. @@ -320,7 +322,7 @@ type Deployment struct { ``` -### func [NewDeployment]() +### func [NewDeployment]() ```go func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment @@ -329,7 +331,7 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name. It also creates the Values map that will be used to create the values.yaml file. -### func \(\*Deployment\) [AddContainer]() +### func \(\*Deployment\) [AddContainer]() ```go func (d *Deployment) AddContainer(service types.ServiceConfig) @@ -338,7 +340,7 @@ func (d *Deployment) AddContainer(service types.ServiceConfig) AddContainer adds a container to the deployment. -### func \(\*Deployment\) [AddHealthCheck]() +### func \(\*Deployment\) [AddHealthCheck]() ```go func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *corev1.Container) @@ -347,7 +349,7 @@ func (d *Deployment) AddHealthCheck(service types.ServiceConfig, container *core -### func \(\*Deployment\) [AddIngress]() +### func \(\*Deployment\) [AddIngress]() ```go func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *Ingress @@ -356,7 +358,7 @@ func (d *Deployment) AddIngress(service types.ServiceConfig, appName string) *In AddIngress adds an ingress to the deployment. It creates the ingress object. -### func \(\*Deployment\) [AddVolumes]() +### func \(\*Deployment\) [AddVolumes]() ```go func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) @@ -365,7 +367,7 @@ func (d *Deployment) AddVolumes(service types.ServiceConfig, appName string) AddVolumes adds a volume to the deployment. It does not create the PVC, it only adds the volumes to the deployment. If the volume is a bind volume it will warn the user that it is not supported yet. -### func \(\*Deployment\) [BindFrom]() +### func \(\*Deployment\) [BindFrom]() ```go func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) @@ -374,7 +376,7 @@ func (d *Deployment) BindFrom(service types.ServiceConfig, binded *Deployment) -### func \(\*Deployment\) [DependsOn]() +### func \(\*Deployment\) [DependsOn]() ```go func (d *Deployment) DependsOn(to *Deployment, servicename string) error @@ -383,7 +385,7 @@ func (d *Deployment) DependsOn(to *Deployment, servicename string) error DependsOn adds a initContainer to the deployment that will wait for the service to be up. -### func \(\*Deployment\) [Filename]() +### func \(\*Deployment\) [Filename]() ```go func (d *Deployment) Filename() string @@ -392,7 +394,7 @@ func (d *Deployment) Filename() string Filename returns the filename of the deployment. -### func \(\*Deployment\) [SetEnvFrom]() +### func \(\*Deployment\) [SetEnvFrom]() ```go func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) @@ -401,7 +403,7 @@ func (d *Deployment) SetEnvFrom(service types.ServiceConfig, appName string) SetEnvFrom sets the environment variables to a configmap. The configmap is created. -### func \(\*Deployment\) [Yaml]() +### func \(\*Deployment\) [Yaml]() ```go func (d *Deployment) Yaml() ([]byte, error) @@ -410,7 +412,7 @@ func (d *Deployment) Yaml() ([]byte, error) Yaml returns the yaml representation of the deployment. -## type [FileMapUsage]() +## type [FileMapUsage]() FileMapUsage is the usage of the filemap. @@ -428,7 +430,7 @@ const ( ``` -## type [HelmChart]() +## type [HelmChart]() HelmChart is a Helm Chart representation. It contains all the tempaltes, values, versions, helpers... @@ -439,6 +441,7 @@ type HelmChart struct { VolumeMounts map[string]any `yaml:"-"` Name string `yaml:"name"` + Icon string `yaml:"icon,omitempty"` ApiVersion string `yaml:"apiVersion"` Version string `yaml:"version"` AppVersion string `yaml:"appVersion"` @@ -450,7 +453,7 @@ type HelmChart struct { ``` -### func [Generate]() +### func [Generate]() ```go func Generate(project *types.Project) (*HelmChart, error) @@ -470,7 +473,7 @@ The Generate function will create the HelmChart object this way: - Merge the same\-pod services. -### func [NewChart]() +### func [NewChart]() ```go func NewChart(name string) *HelmChart @@ -479,7 +482,7 @@ func NewChart(name string) *HelmChart NewChart creates a new empty chart with the given name. -### func \(\*HelmChart\) [SaveTemplates]() +### func \(\*HelmChart\) [SaveTemplates]() ```go func (chart *HelmChart) SaveTemplates(templateDir string) @@ -488,7 +491,7 @@ func (chart *HelmChart) SaveTemplates(templateDir string) SaveTemplates the templates of the chart to the given directory. -## type [Help]() +## type [Help]() Help is the documentation of a label. @@ -523,7 +526,7 @@ func NewIngress(service types.ServiceConfig, Chart *HelmChart) *Ingress NewIngress creates a new Ingress from a compose service. -### func \(\*Ingress\) [Filename]() +### func \(\*Ingress\) [Filename]() ```go func (ingress *Ingress) Filename() string @@ -532,7 +535,7 @@ func (ingress *Ingress) Filename() string -### func \(\*Ingress\) [Yaml]() +### func \(\*Ingress\) [Yaml]() ```go func (ingress *Ingress) Yaml() ([]byte, error) @@ -556,7 +559,7 @@ type IngressValue struct { ``` -## type [Label]() +## type [Label]() Label is a katenary label to find in compose files. @@ -646,7 +649,7 @@ type Role struct { ``` -### func \(\*Role\) [Filename]() +### func \(\*Role\) [Filename]() ```go func (r *Role) Filename() string @@ -655,7 +658,7 @@ func (r *Role) Filename() string -### func \(\*Role\) [Yaml]() +### func \(\*Role\) [Yaml]() ```go func (r *Role) Yaml() ([]byte, error) @@ -676,7 +679,7 @@ type RoleBinding struct { ``` -### func \(\*RoleBinding\) [Filename]() +### func \(\*RoleBinding\) [Filename]() ```go func (r *RoleBinding) Filename() string @@ -685,7 +688,7 @@ func (r *RoleBinding) Filename() string -### func \(\*RoleBinding\) [Yaml]() +### func \(\*RoleBinding\) [Yaml]() ```go func (r *RoleBinding) Yaml() ([]byte, error) @@ -717,7 +720,7 @@ func NewSecret(service types.ServiceConfig, appName string) *Secret NewSecret creates a new Secret from a compose service -### func \(\*Secret\) [AddData]() +### func \(\*Secret\) [AddData]() ```go func (s *Secret) AddData(key, value string) @@ -726,7 +729,7 @@ func (s *Secret) AddData(key, value string) AddData adds a key value pair to the secret. -### func \(\*Secret\) [Filename]() +### func \(\*Secret\) [Filename]() ```go func (s *Secret) Filename() string @@ -735,7 +738,7 @@ func (s *Secret) Filename() string Filename returns the filename of the secret. -### func \(\*Secret\) [SetData]() +### func \(\*Secret\) [SetData]() ```go func (s *Secret) SetData(data map[string]string) @@ -744,7 +747,7 @@ func (s *Secret) SetData(data map[string]string) SetData sets the data of the secret. -### func \(\*Secret\) [Yaml]() +### func \(\*Secret\) [Yaml]() ```go func (s *Secret) Yaml() ([]byte, error) @@ -783,7 +786,7 @@ func (s *Service) AddPort(port types.ServicePortConfig, serviceName ...string) AddPort adds a port to the service. -### func \(\*Service\) [Filename]() +### func \(\*Service\) [Filename]() ```go func (s *Service) Filename() string @@ -792,7 +795,7 @@ func (s *Service) Filename() string Filename returns the filename of the service. -### func \(\*Service\) [Yaml]() +### func \(\*Service\) [Yaml]() ```go func (s *Service) Yaml() ([]byte, error) @@ -813,7 +816,7 @@ type ServiceAccount struct { ``` -### func \(\*ServiceAccount\) [Filename]() +### func \(\*ServiceAccount\) [Filename]() ```go func (r *ServiceAccount) Filename() string @@ -822,7 +825,7 @@ func (r *ServiceAccount) Filename() string -### func \(\*ServiceAccount\) [Yaml]() +### func \(\*ServiceAccount\) [Yaml]() ```go func (r *ServiceAccount) Yaml() ([]byte, error) @@ -851,7 +854,7 @@ type Value struct { ``` -### func [NewValue]() +### func [NewValue]() ```go func NewValue(service types.ServiceConfig, main ...bool) *Value @@ -862,7 +865,7 @@ NewValue creates a new Value from a compose service. The value contains the nece If \`main\` is true, the tag will be empty because it will be set in the helm chart appVersion. -### func \(\*Value\) [AddIngress]() +### func \(\*Value\) [AddIngress]() ```go func (v *Value) AddIngress(host, path string) @@ -871,7 +874,7 @@ func (v *Value) AddIngress(host, path string) -### func \(\*Value\) [AddPersistence]() +### func \(\*Value\) [AddPersistence]() ```go func (v *Value) AddPersistence(volumeName string) @@ -901,7 +904,7 @@ func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *Vo NewVolumeClaim creates a new VolumeClaim from a compose service. -### func \(\*VolumeClaim\) [Filename]() +### func \(\*VolumeClaim\) [Filename]() ```go func (v *VolumeClaim) Filename() string @@ -910,7 +913,7 @@ func (v *VolumeClaim) Filename() string Filename returns the suggested filename for a VolumeClaim. -### func \(\*VolumeClaim\) [Yaml]() +### func \(\*VolumeClaim\) [Yaml]() ```go func (v *VolumeClaim) Yaml() ([]byte, error) diff --git a/doc/docs/packages/generator/extrafiles.md b/doc/docs/packages/generator/extrafiles.md index 123cd95..cb56223 100644 --- a/doc/docs/packages/generator/extrafiles.md +++ b/doc/docs/packages/generator/extrafiles.md @@ -17,7 +17,7 @@ func NotesFile(services []string) string NotesFile returns the content of the note.txt file. -## func [ReadMeFile]() +## func [ReadMeFile]() ```go func ReadMeFile(charname, description string, values map[string]any) string diff --git a/doc/docs/packages/generator/labelStructs.md b/doc/docs/packages/generator/labelStructs.md index 4b86499..923c4ce 100644 --- a/doc/docs/packages/generator/labelStructs.md +++ b/doc/docs/packages/generator/labelStructs.md @@ -55,11 +55,11 @@ Dependency is a dependency of a chart to other charts. ```go type Dependency struct { + Values map[string]any `yaml:"-"` Name string `yaml:"name"` Version string `yaml:"version"` Repository string `yaml:"repository"` Alias string `yaml:"alias,omitempty"` - Values map[string]any `yaml:"-"` // do not export to Chart.yaml } ``` @@ -91,29 +91,23 @@ func EnvFromFrom(data string) (EnvFrom, error) EnvFromFrom returns a EnvFrom from the given string. -## type [Ingress]() +## type [Ingress]() ```go type Ingress struct { - // Hostname is the hostname to match against the request. It can contain wildcards. - Hostname string `yaml:"hostname"` - // Path is the path to match against the request. It can contain wildcards. - Path string `yaml:"path"` - // Enabled is a flag to enable or disable the ingress. - Enabled bool `yaml:"enabled"` - // Class is the ingress class to use. - Class string `yaml:"class"` - // Port is the port to use. - Port *int32 `yaml:"port,omitempty"` - // Annotations is a list of key-value pairs to add to the ingress. + Port *int32 `yaml:"port,omitempty"` Annotations map[string]string `yaml:"annotations,omitempty"` + Hostname string `yaml:"hostname"` + Path string `yaml:"path"` + Class string `yaml:"class"` + Enabled bool `yaml:"enabled"` } ``` -### func [IngressFrom]() +### func [IngressFrom]() ```go func IngressFrom(data string) (*Ingress, error) diff --git a/doc/docs/packages/parser.md b/doc/docs/packages/parser.md index 834b50a..8f310aa 100644 --- a/doc/docs/packages/parser.md +++ b/doc/docs/packages/parser.md @@ -8,10 +8,10 @@ import "katenary/parser" Parser package is a wrapper around compose\-go to parse compose files. -## func [Parse]() +## func [Parse]() ```go -func Parse(profiles []string, dockerComposeFile ...string) (*types.Project, error) +func Parse(profiles []string, envFiles []string, dockerComposeFile ...string) (*types.Project, error) ``` Parse compose files and return a project. The project is parsed with dotenv, osenv and profiles. diff --git a/doc/docs/packages/utils.md b/doc/docs/packages/utils.md index bb31f13..6fb81fb 100644 --- a/doc/docs/packages/utils.md +++ b/doc/docs/packages/utils.md @@ -8,7 +8,7 @@ import "katenary/utils" Utils package provides some utility functions used in katenary. It defines some constants and functions used in the whole project. -## func [Confirm]() +## func [Confirm]() ```go func Confirm(question string, icon ...Icon) bool @@ -17,7 +17,7 @@ func Confirm(question string, icon ...Icon) bool Confirm asks a question and returns true if the answer is y. -## func [CountStartingSpaces]() +## func [CountStartingSpaces]() ```go func CountStartingSpaces(line string) int @@ -26,7 +26,7 @@ func CountStartingSpaces(line string) int CountStartingSpaces counts the number of spaces at the beginning of a string. -## func [EncodeBasicYaml]() +## func [EncodeBasicYaml]() ```go func EncodeBasicYaml(data any) ([]byte, error) @@ -35,7 +35,7 @@ func EncodeBasicYaml(data any) ([]byte, error) EncodeBasicYaml encodes a basic yaml from an interface. -## func [GetContainerByName]() +## func [GetContainerByName]() ```go func GetContainerByName(name string, containers []corev1.Container) (*corev1.Container, int) @@ -44,7 +44,7 @@ func GetContainerByName(name string, containers []corev1.Container) (*corev1.Con GetContainerByName returns a container by name and its index in the array. It returns nil, \-1 if not found. -## func [GetKind]() +## func [GetKind]() ```go func GetKind(path string) (kind string) @@ -53,7 +53,7 @@ func GetKind(path string) (kind string) GetKind returns the kind of the resource from the file path. -## func [GetServiceNameByPort]() +## func [GetServiceNameByPort]() ```go func GetServiceNameByPort(port int) string @@ -62,7 +62,7 @@ func GetServiceNameByPort(port int) string GetServiceNameByPort returns the service name for a port. It the service name is not found, it returns an empty string. -## func [GetValuesFromLabel]() +## func [GetValuesFromLabel]() ```go func GetValuesFromLabel(service types.ServiceConfig, LabelValues string) map[string]*EnvConfig @@ -80,7 +80,7 @@ func HashComposefiles(files []string) (string, error) HashComposefiles returns a hash of the compose files. -## func [Int32Ptr]() +## func [Int32Ptr]() ```go func Int32Ptr(i int32) *int32 @@ -89,7 +89,7 @@ func Int32Ptr(i int32) *int32 Int32Ptr returns a pointer to an int32. -## func [MapKeys]() +## func [MapKeys]() ```go func MapKeys(m map[string]interface{}) []string @@ -98,7 +98,7 @@ func MapKeys(m map[string]interface{}) []string -## func [PathToName]() +## func [PathToName]() ```go func PathToName(path string) string @@ -107,7 +107,7 @@ func PathToName(path string) string PathToName converts a path to a kubernetes complient name. -## func [StrPtr]() +## func [StrPtr]() ```go func StrPtr(s string) *string @@ -125,7 +125,7 @@ func TplName(serviceName, appname string, suffix ...string) string TplName returns the name of the kubernetes resource as a template string. It is used in the templates and defined in \_helper.tpl file. -## func [TplValue]() +## func [TplValue]() ```go func TplValue(serviceName, variable string, pipes ...string) string @@ -143,7 +143,7 @@ func Warn(msg ...interface{}) Warn prints a warning message -## func [WordWrap]() +## func [WordWrap]() ```go func WordWrap(text string, lineWidth int) string @@ -152,7 +152,7 @@ func WordWrap(text string, lineWidth int) string WordWrap wraps a string to a given line width. Warning: it may break the string. You need to check the result. -## func [Wrap]() +## func [Wrap]() ```go func Wrap(src, above, below string) string @@ -161,7 +161,7 @@ func Wrap(src, above, below string) string Wrap wraps a string with a string above and below. It will respect the indentation of the src string. -## func [WrapBytes]() +## func [WrapBytes]() ```go func WrapBytes(src, above, below []byte) []byte @@ -170,7 +170,7 @@ func WrapBytes(src, above, below []byte) []byte WrapBytes wraps a byte array with a byte array above and below. It will respect the indentation of the src string. -## type [EnvConfig]() +## type [EnvConfig]() EnvConfig is a struct to hold the description of an environment variable. diff --git a/doc/docs/usage.md b/doc/docs/usage.md index dae8059..a8ccb48 100644 --- a/doc/docs/usage.md +++ b/doc/docs/usage.md @@ -4,9 +4,9 @@ Basically, you can use `katenary` to transpose a docker-compose file (or any com `podman-compose` and `docker-compose`) to a configurable Helm Chart. This resulting helm chart can be installed with `helm` command to your Kubernetes cluster. -!!! Warning "YAML in multiline label" - - Compose only accept text label. So, to put a complete YAML content in the target label, you need to use a pipe char (`|` or `|-`) +!!! Warning "YAML in multiline label" + + Compose only accept text label. So, to put a complete YAML content in the target label, you need to use a pipe char (`|` or `|-`) and to **indent** your content. For example : @@ -23,16 +23,15 @@ Basically, you can use `katenary` to transpose a docker-compose file (or any com - 1234 ``` - Katenary transforms compose services this way: - Takes the service and create a "Deployment" file -- if a port is declared, katenary creates a service (ClusterIP) -- if a port is exposed, katenary creates a service (NodePort) +- if a port is declared, Katenary creates a service (ClusterIP) +- if a port is exposed, Katenary creates a service (NodePort) - environment variables will be stored inside a configMap - image, tags, and ingresses configuration are also stored in `values.yaml` file -- if named volumes are declared, katenary create PersistentVolumeClaims - not enabled in values file -- `depends_on` needs that the pointed service declared a port. If not, you can use labels to inform katenary +- if named volumes are declared, Katenary create PersistentVolumeClaims - not enabled in values file +- `depends_on` needs that the pointed service declared a port. If not, you can use labels to inform Katenary For any other specific configuration, like binding local files as configMap, bind variables, add values with documentation, etc. You'll need to use labels. @@ -44,21 +43,21 @@ For more complete label usage, see [the labels page](labels.md). !!! Info "Overriding file" - It could be sometimes more convinient to separate the + It could be sometimes more convinient to separate the configuration related to Katenary inside a secondary file. Instead of adding labels inside the `compose.yaml` file, - you can create a file named `compose.katenary.yaml` and - declare your labels inside. Katenary will detect it by - default. + you can create a file named `compose.katenary.yaml` and + declare your labels inside. Katenary will detect it by + default. **No need to precise the file in the command line.** -## Make convertion +## Make conversion After having installed `katenary`, the standard usage is to call: -katenary convert + katenary convert It will search standard compose files in the current directory and try to create a helm chart in "chart" directory. @@ -66,10 +65,9 @@ It will search standard compose files in the current directory and try to create Katenary uses the compose-go library which respects the Docker and Docker-Compose specification. Keep in mind that it will find files exactly the same way as `docker-compose` and `podman-compose` do it. +Of course, you can provide others files than the default with (cumulative) `-c` options: -Of course, you can provide others files than the default with (cummulative) `-c` options: - -katenary convert -c file1.yaml -c file2.yaml + katenary convert -c file1.yaml -c file2.yaml ## Some common labels to use @@ -78,15 +76,14 @@ Katenary proposes a lot of labels to configure the helm chart generation, but so !!! Info For more complete label usage, see [the labels page](labels.md). - ### Work with Depends On? -Kubernetes does not propose service or pod starting detection from others pods. But katenary will create init containers +Kubernetes does not provide service or pod starting detection from others pods. But katenary will create init containers to make you able to wait for a service to respond. But you'll probably need to adapt a bit the compose file. See this compose file: -```yaml +```yaml version: "3" services: @@ -105,8 +102,7 @@ In this case, `webapp` needs to know the `database` port because the `depends_on (yet) solution to check the database startup. Katenary wants to create a `initContainer` to hit on the related service. So, instead of exposing the port in the compose definition, let's declare this to katenary with labels: - -```yaml +```yaml version: "3" services: @@ -126,12 +122,12 @@ services: ### Declare ingresses -It's very common to have an Ingress resource on web application to deploy on Kuberenetes. It allows to expose the -service to the outside of the cluster (you need to install an ingress controller). +It's very common to have an Ingress resource on web application to deploy on Kubernetes. It allows exposing the +service to the outside of the cluster (you need to install an ingress controller). Katenary can create this resource for you. You just need to declare the hostname and the port to bind. -```yaml +```yaml services: webapp: image: ... @@ -146,15 +142,14 @@ services: Note that the port to bind is the one used by the container, not the used locally. This is because Katenary create a service to bind the container itself. - ### Map environment to helm values -A lot of framework needs to receive service host or IP in an environment variable to configure the connexion. For +A lot of framework needs to receive service host or IP in an environment variable to configure the connection. For example, to connect a PHP application to a database. -With a compose file, there is no problem as Docker/Podman allows to resolve the name by container name: +With a compose file, there is no problem as Docker/Podman allows resolving the name by container name: -```yaml +```yaml services: webapp: image: php:7-apache @@ -168,7 +163,6 @@ services: Katenary prefixes the services with `{{ .Release.Name }}` (to make it possible to install the application several times in a namespace), so you need to "remap" the environment variable to the right one. - ```yaml services: webapp: @@ -180,7 +174,7 @@ services: DB_HOST: "{{ .Release.Name }}-database" database: - image: mariadb + image: mariadb ``` This label can be used to map others environment for any others reason. E.g. to change an informational environment @@ -189,7 +183,7 @@ variable. ```yaml services: webapp: - #... + #... environment: RUNNING: docker labels: @@ -198,4 +192,4 @@ services: ``` In the above example, `RUNNING` will be set to `kubernetes` when you'll deploy the application with helm, and it's -`docker` for "podman" and "docker" executions. +`docker` for "Podman" and "Docker" executions. diff --git a/doc/mkdocs.yml b/doc/mkdocs.yml index f165f88..342fe8d 100644 --- a/doc/mkdocs.yml +++ b/doc/mkdocs.yml @@ -28,7 +28,11 @@ markdown_extensions: - pymdownx.highlight: anchor_linenums: true use_pygments: false - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format extra_css: - statics/main.css extra_javascript: diff --git a/doc/requirements.txt b/doc/requirements.txt index b34c282..18a915f 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,7 +1,7 @@ -mkdocs>=1.5.3 -Jinja2>=3.1.3 -MarkupSafe>=2.1.5 -pymdown-extensions>=10.7.1 -mkdocs-material>=9.5.17 -mkdocs-material-extensions>=1.3.1 -mkdocs-plugin-inline-svg-mod>=0.0.1 +mkdocs==1.* +Jinja2==3.* +MarkupSafe==3.* +pymdown-extensions==10.* +mkdocs-material==9.* +mkdocs-material-extensions==1.* +mkdocs-plugin-inline-svg-mod From 164a617869811aafe95c5290d3895984c43f2d43 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 23 Oct 2024 16:17:01 +0200 Subject: [PATCH 92/97] fix(values): Remove tplString in environment directive We badly set a "tpl string" in values.yaml file for "values" labels set in compose.yaml file. --- generator/configMap.go | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/generator/configMap.go b/generator/configMap.go index 9c13bf8..3d81c2e 100644 --- a/generator/configMap.go +++ b/generator/configMap.go @@ -94,23 +94,20 @@ func NewConfigMap(service types.ServiceConfig, appName string, forFile bool) *Co done[value] = true continue } - val := utils.TplValue(service.Name, "environment."+value) - service.Environment[value] = &val } - if forFile { + if !forFile { // do not bind env variables to the configmap - return cm - } - // remove the variables that are already defined in the environment - if l, ok := service.Labels[LabelMapEnv]; ok { - envmap, err := labelStructs.MapEnvFrom(l) - if err != nil { - log.Fatal("Error parsing map-env", err) - } - for key, value := range envmap { - cm.AddData(key, strings.ReplaceAll(value, "__APP__", appName)) - done[key] = true + // remove the variables that are already defined in the environment + if l, ok := service.Labels[LabelMapEnv]; ok { + envmap, err := labelStructs.MapEnvFrom(l) + if err != nil { + log.Fatal("Error parsing map-env", err) + } + for key, value := range envmap { + cm.AddData(key, strings.ReplaceAll(value, "__APP__", appName)) + done[key] = true + } } } for key, env := range service.Environment { From 63c6d5d0ef5da630170c558fd7cc2a5945c694ad Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 23 Oct 2024 16:20:29 +0200 Subject: [PATCH 93/97] chore(format): moved import Formatter changed the import order --- generator/generator.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index a9eaf72..3cf9899 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -3,14 +3,13 @@ package generator import ( "bytes" "fmt" + "katenary/utils" "log" "regexp" "strings" "github.com/compose-spec/compose-go/types" corev1 "k8s.io/api/core/v1" - - "katenary/utils" ) // Generate a chart from a compose project. From d31993953b970ab7b51500cc0a853b833ee245b7 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Wed, 23 Oct 2024 16:32:50 +0200 Subject: [PATCH 94/97] fix(doc): livenessProbe and readinessProbe must be set In the health-check label, we now need to specify the kind of check to do - in the expected form from Kubernetes specification. --- generator/katenaryLabelsDoc.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/generator/katenaryLabelsDoc.yaml b/generator/katenaryLabelsDoc.yaml index f4b555f..d9a6d37 100644 --- a/generator/katenaryLabelsDoc.yaml +++ b/generator/katenaryLabelsDoc.yaml @@ -141,9 +141,10 @@ example: |- labels: {{ .KatenaryPrefix }}/health-check: |- - httpGet: - path: /health - port: 8080 + livenessProbe: + httpGet: + path: /health + port: 8080 type: "object" "same-pod": From db168c91c9a7d47bb43491f7b63616678380d9eb Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 24 Oct 2024 17:21:04 +0200 Subject: [PATCH 95/97] fix(generation): use tpl in note for hostnames The value in hostname in values file can be a templated string. So, we should execute the content. --- generator/extrafiles/notes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generator/extrafiles/notes.go b/generator/extrafiles/notes.go index c2662e5..2fd6e12 100644 --- a/generator/extrafiles/notes.go +++ b/generator/extrafiles/notes.go @@ -15,7 +15,7 @@ func NotesFile(services []string) string { ingresses := make([]string, len(services)) for i, service := range services { condition := fmt.Sprintf(`{{- if and .Values.%[1]s.ingress .Values.%[1]s.ingress.enabled }}`, service) - line := fmt.Sprintf(`{{- $count = add1 $count -}}{{- $listOfURL = printf "%%s\n- http://%%s" $listOfURL .Values.%s.ingress.host -}}`, service) + line := fmt.Sprintf(`{{- $count = add1 $count -}}{{- $listOfURL = printf "%%s\n- http://%%s" $listOfURL (tpl .Values.%s.ingress.host .) -}}`, service) ingresses[i] = fmt.Sprintf("%s\n%s\n{{- end }}", condition, line) } From d72f371c59857f1ccaa1c1c9e2a246642043d83f Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 24 Oct 2024 17:23:26 +0200 Subject: [PATCH 96/97] fix(generation): fix the volume var/path name Underscores are forbidden by Kubernetes (should be a valid URL string), we replace "_" by "-" in names, and we leave the values file using the original name. So a volume named "foo_bar" in compose file, is registered as "foo_bar" in the values file, but "foo-bar" is used as volume name in deployments, volume claims, ... --- generator/deployment.go | 14 ++++++++++---- generator/volume.go | 6 +++--- utils/utils.go | 6 ++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/generator/deployment.go b/generator/deployment.go index bad1f6f..26aad53 100644 --- a/generator/deployment.go +++ b/generator/deployment.go @@ -35,6 +35,7 @@ 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:"-"` @@ -90,6 +91,7 @@ func NewDeployment(service types.ServiceConfig, chart *HelmChart) *Deployment { }, }, configMaps: make(map[string]*ConfigMapMount), + volumeMap: make(map[string]string), } // add containers @@ -396,7 +398,8 @@ func (d *Deployment) Yaml() ([]byte, error) { if strings.Contains(volume, "mountPath: ") { spaces = strings.Repeat(" ", utils.CountStartingSpaces(volume)) - content[line] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + volumeName + `.enabled }}` + "\n" + volume + varName := d.volumeMap[volumeName] + content[line] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + varName + `.enabled }}` + "\n" + volume changing = true } if strings.Contains(volume, nameDirective) && changing { @@ -438,7 +441,8 @@ func (d *Deployment) Yaml() ([]byte, error) { if strings.Contains(line, "- name: ") && inVolumes { spaces = strings.Repeat(" ", utils.CountStartingSpaces(line)) - content[i] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + volumeName + `.enabled }}` + "\n" + line + varName := d.volumeMap[volumeName] + content[i] = spaces + `{{- if .Values.` + serviceName + `.persistence.` + varName + `.enabled }}` + "\n" + line changing = true } if strings.Contains(line, "claimName: ") && changing { @@ -597,8 +601,10 @@ func (d *Deployment) bindVolumes(volume types.ServiceVolumeConfig, isSamePod boo switch volume.Type { case "volume": // Add volume to container + fixedName := utils.FixedResourceName(volume.Source) + d.volumeMap[fixedName] = volume.Source container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: volume.Source, + Name: fixedName, MountPath: volume.Target, }) // Add volume to values.yaml only if it the service is not in the same pod that another service. @@ -608,7 +614,7 @@ func (d *Deployment) bindVolumes(volume types.ServiceVolumeConfig, isSamePod boo } // Add volume to deployment d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ - Name: volume.Source, + Name: fixedName, VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ ClaimName: utils.TplName(service.Name, appName, volume.Source), diff --git a/generator/volume.go b/generator/volume.go index 49f189b..2a232d9 100644 --- a/generator/volume.go +++ b/generator/volume.go @@ -1,6 +1,7 @@ package generator import ( + "katenary/utils" "strings" "github.com/compose-spec/compose-go/types" @@ -8,8 +9,6 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" - - "katenary/utils" ) const persistenceKey = "persistence" @@ -26,6 +25,7 @@ type VolumeClaim struct { // NewVolumeClaim creates a new VolumeClaim from a compose service. func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *VolumeClaim { + fixedName := utils.FixedResourceName(volumeName) return &VolumeClaim{ volumeName: volumeName, service: &service, @@ -35,7 +35,7 @@ func NewVolumeClaim(service types.ServiceConfig, volumeName, appName string) *Vo APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ - Name: utils.TplName(service.Name, appName) + "-" + volumeName, + Name: utils.TplName(service.Name, appName) + "-" + fixedName, Labels: GetLabels(service.Name, appName), Annotations: Annotations, }, diff --git a/utils/utils.go b/utils/utils.go index 2ed7428..0e2e196 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -117,6 +117,7 @@ func PathToName(path string) string { path = strings.ReplaceAll(path, "_", "-") path = strings.ReplaceAll(path, "/", "-") path = strings.ReplaceAll(path, ".", "-") + path = strings.ToLower(path) return path } @@ -192,3 +193,8 @@ func EncodeBasicYaml(data any) ([]byte, error) { } return buf.Bytes(), nil } + +// FixedResourceName returns a resource name without underscores to respect the kubernetes naming convention. +func FixedResourceName(name string) string { + return strings.ReplaceAll(name, "_", "-") +} From 2d33367422764ab6e6eab93aa548587772ee10c7 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Thu, 24 Oct 2024 17:24:36 +0200 Subject: [PATCH 97/97] chore(optim): manage space in struct Warning given by sonarlint, saves a few bytes. --- generator/chart.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generator/chart.go b/generator/chart.go index 8bd7580..da5fa7d 100644 --- a/generator/chart.go +++ b/generator/chart.go @@ -26,9 +26,9 @@ type ConvertOptions struct { ChartVersion string Icon string Profiles []string + EnvFiles []string Force bool HelmUpdate bool - EnvFiles []string } // HelmChart is a Helm Chart representation. It contains all the