40 Commits

Author SHA1 Message Date
8dfca953dc Fix push-release 2022-03-31 14:14:54 +02:00
7b774e84d8 Develop (#3)
* Update command added
* Ensure that the upgraded version is really greater
* Update command should not update prerelease
* Minor presentation changes
* Fix command generation in containers from docker-compose file
- Refactored service creation
* Place the full command to the "cmd" package
* Update cobra to v1.4.0
* Updated build and release creation
* Created an install script
* Add more doc
* Add some tests...
* Add a test directive in Makefile
2022-03-31 14:12:20 +02:00
d0576d4b81 Add name explanation 2022-02-17 15:12:15 +01:00
c1fc388b26 Fix Smile link 2022-02-17 15:07:25 +01:00
86ca723aa8 Fixup logos 2022-02-17 15:04:13 +01:00
35ecb0d4d9 Add temporary logo 2022-02-17 12:22:52 +01:00
89adc17857 Add doc for "same-pod" example 2022-02-17 11:54:09 +01:00
16fddbc6aa Fixed ignore list 2022-02-17 11:53:15 +01:00
8543bc5232 Added some generated examples 2022-02-17 11:48:33 +01:00
3d45401649 Fixup doc 2022-02-17 11:38:48 +01:00
95c24be14a Make it possible to mount one file from configMap 2022-02-17 11:38:23 +01:00
bf44d442e5 Fix "/" in configMap names
Fix #2
2022-02-17 11:04:04 +01:00
5d574015ce Fix the missed volume mount for empty dirs
Fix #1
2022-02-17 10:43:07 +01:00
3619cc4b20 Update README.md
Add Releases information to install katenary
2022-02-17 10:06:50 +01:00
5a49c4f869 Forgotten logo file... 2022-02-17 09:58:44 +01:00
88fb12a3bf Add Smile :) 2022-02-17 09:58:09 +01:00
d387d9aec0 Add MIT License 2022-02-17 09:42:32 +01:00
1343f99e39 Better build command + Doc 2022-02-16 18:32:51 +01:00
90d04346d5 Add completion doc 2022-02-16 17:57:14 +01:00
950a77aade Fix documentation 2022-02-16 17:48:57 +01:00
6ef4f7ac42 Fix template 2022-02-16 17:44:25 +01:00
513039e3c9 Add ability to link containers in one pod 2022-02-16 17:40:11 +01:00
a60ab484d2 And one more time, fix flag strings 2022-02-16 11:11:30 +01:00
d9fcf5a1b9 Fix doc 2022-02-16 11:09:08 +01:00
0668b718ea Doc 2022-02-16 11:02:56 +01:00
0d1a6f8c82 Fix some indentation behavio + add indent flag 2022-02-16 10:56:21 +01:00
a4834a0661 Now manage kubernetes version in ingress
The backend and ingressClassName are now under condition
2022-02-16 10:37:46 +01:00
722c7424d0 Fix version flag 2022-02-14 17:15:11 +01:00
b602aa5e39 Fix the use of version flag 2022-02-14 17:02:29 +01:00
d965e1d19b Use "cobra" to propose better command/flags 2022-02-14 17:00:32 +01:00
5a4d9e396d Add healtcheck + some fixes
- better docs
- add healtcheck based on docker-compoe commands or labels
- fix some problems on secret names
- better dependency check
2022-02-14 14:37:09 +01:00
8164603b47 Add a clean directive 2022-02-14 14:36:43 +01:00
9aec646ab2 Fix the compose detection 2022-02-02 10:30:32 +01:00
8d4ea90a9a Fix name generation with "." and "/" 2022-02-02 10:15:14 +01:00
4320519a2a Fix the volume name with minus sign 2022-02-02 10:09:42 +01:00
b9e91d56aa Allow different docker-compose extensions 2022-02-02 10:02:35 +01:00
93a06b52fb Give a default value for GO var 2022-01-26 09:44:23 +01:00
cb88f2879d Give better information at build time 2022-01-26 09:43:01 +01:00
1e79e954c5 Fix the source change detection 2022-01-26 09:21:36 +01:00
69982e4514 Add information about the compiled version 2022-01-26 09:18:32 +01:00
51 changed files with 2192 additions and 320 deletions

5
.gitignore vendored
View File

@@ -1,5 +1,10 @@
dist/*
.cache/*
chart/*
docker-compose.yaml
katenary
*.env
docker-compose*
!examples/**/docker-compose*
.credentials
release.id

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 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
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

123
Makefile
View File

@@ -4,6 +4,16 @@ 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
GO=container
OUT=katenary
BLD_CMD=go build -ldflags="-X 'main.Version=$(VERSION)'" -o $(OUT) ./cmd/*.go
GOOS=linux
GOARCH=amd64
BUILD_IMAGE=docker.io/golang:1.18-alpine
.PHONY: help clean build
.ONESHELL:
help:
@cat <<EOF
@@ -19,19 +29,88 @@ help:
$$ sudo make install PREFIX=/usr/local
Katenary is statically built (in Go), so there is no library to install.
To build for others OS:
$$ make build GOOS=linux GOARCH=amd64
This will build the binary for linux amd64.
$$ make build GOOS=linux GOARCH=arm
This will build the binary for linux arm.
$$ make build GOOS=windows GOARCH=amd64
This will build the binary for windows amd64.
$$ make build GOOS=darwin GOARCH=amd64
This will build the binary for darwin amd64.
Or you can build all versions:
$$ make build-all
EOF
build: pull katenary
build: katenary
build-all: pull dist dist/katenary-linux-amd64 dist/katenary-linux-arm64 dist/katenary.exe dist/katenary-darwin-amd64 dist/katenary-freebsd-amd64 dist/katenary-freebsd-arm64
katenary: *.go generator/*.go compose/*.go helm/*.go
@echo Build using $(CTN)
ifeq ($(CTN),podman)
@podman run --rm -v $(PWD):/go/src/katenary:z -w /go/src/katenary --userns keep-id -it golang go build -o katenary -ldflags="-X 'main.Version=$(VERSION)'" .
else
@docker run --rm -v $(PWD):/go/src/katenary:z -w /go/src/katenary --user $(shell id -u):$(shell id -g) -e HOME=/tmp -it golang go build -o katenary -ldflags="-X 'main.Version=$(VERSION)'" .
pull:
ifneq ($(GO),local)
@echo -e "\033[1;32mPulling $(BUILD_IMAGE) docker image\033[0m"
@$(CTN) pull $(BUILD_IMAGE)
endif
dist:
mkdir -p dist
dist/katenary-linux-amd64:
@echo
@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"
$(MAKE) katenary GOOS=linux GOARCH=arm64 OUT=$@
dist/katenary.exe:
@echo
@echo -e "\033[1;32mBuilding katenary $(VERSION) for windows...\033[0m"
$(MAKE) katenary GOOS=windows GOARCH=amd64 OUT=$@
dist/katenary-darwin-amd64:
@echo
@echo -e "\033[1;32mBuilding katenary $(VERSION) for darwin...\033[0m"
$(MAKE) katenary GOOS=darwin GOARCH=amd64 OUT=$@
dist/katenary-freebsd-amd64:
@echo
@echo -e "\033[1;32mBuilding katenary $(VERSION) for freebsd...\033[0m"
$(MAKE) katenary GOOS=freebsd GOARCH=amd64 OUT=$@
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 docker.io/golang $(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 docker.io/golang $(BLD_CMD)
endif
echo "=> Stripping if possible"
strip $(OUT) 2>/dev/null || echo "=> No strip available"
install: build
cp katenary $(PREFIX)/bin/katenary
@@ -40,6 +119,34 @@ uninstall:
rm -f $(PREFIX)/bin/katenary
clean:
rm -f katenary
rm -rf katenary dist/* release.id
tests: test
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
TOKEN=$(shell cat .credentials)
# create a new release based on current tag and get the release id
@curl -sSL -X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: token $$TOKEN" \
-d "{\"tag_name\": \"$(VERSION)\", \"target_commitish\": \"\", \"name\": \"$(VERSION)\", \"draft\": true, \"prerelease\": true}" \
https://api.github.com/repos/metal3d/katenary/releases | jq -r '.id' > release.id
@echo "Release id: $$(cat release.id) created"
@echo "Uploading assets..."
# push all dist binary as assets to the release
@for i in $$(find dist -type f -name "katenary*"); do
curl -sSL -H "Authorization: token $$TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/octet-stream" \
--data-binary @$$i \
https://uploads.github.com/repos/metal3d/katenary/releases/$(shell cat release.id)/assets?name=$$(basename $$i)
done
@rm -f release.id

134
README.md
View File

@@ -1,10 +1,30 @@
<div style="text-align:center">
<img src="./misc/logo.png" alt="Katenary Logo" />
</div>
Katenary is a tool to help transforming `docker-compose` files to a working Helm Chart for Kubernetes.
> **Important Note** Katenary is a tool to help building Helm Chart from a docker-compose file, but docker-compose doesn't propose as many features as what can do Kubernetes. So, we strongly recommend to use Katenary as a "bootstrap" tool and then to manually enhance the generated helm chart.
> **Important Note:** Katenary is a tool to help building Helm Chart from a docker-compose file, but docker-compose doesn't propose as many features as what can do Kubernetes. So, we strongly recommend to use Katenary as a "bootstrap" tool and then to manually enhance the generated helm chart.
This project is partially made at [Smile](https://www.smile.eu)
<div style="text-align:center">
<a href="https://www.smile.eu"><img src="./misc/Logo_Smile.png" alt="Smile Logo" width="250" /></a>
</div>
# 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 use this commands on Linux:
```bash
sh <(curl -sSL https://raw.githubusercontent.com/metal3d/katenary/master/install.sh)
```
# Else... Build yourself
If you've got `podman` or `docker`, you can build `katenary` by using:
```bash
@@ -22,26 +42,75 @@ It will use the default PREFIX (`~/.local/`) to install the binary in the `bin`
sudo make install PREFIX=/usr/local
```
If that goes wrong, you can use your local Go compiler:
```bash
make build GO=local
# To force OS or architecture
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 you use.
E.g. :
```bash
# bash in ~/.bashrc file
source <(katenary completion bash)
# if the documentation breaks a bit your completion:
source <(katenary completion bash --no-description)
# zsh in ~/.zshrc
source <(helm completion zsh)
# fish in ~/.config/fish/config.fish
katenary completion fish | source
# powershell (as we don't provide any support on Windows yet, please avoid this...)
```
# Usage
```bash
Usage of katenary:
-appname string
sive the helm chart app name (default "MyApp")
-appversion string
set the chart appVersion (default "0.0.1")
-chart-dir string
set the chart directory (default "chart")
-compose string
set the compose file to parse (default "docker-compose.yaml")
-force
force the removal of the chart-dir
-version
Show version and exit
```
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 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 <command> --help
or
"katenary help <command>"
Usage:
katenary [command]
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
Flags:
-h, --help help for katenary
Use "katenary [command] --help" for more information about a command.
```
Katenary will try to find a `docker-compose.yaml` 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`)
@@ -56,7 +125,7 @@ What can be interpreted by Katenary:
- `env_file` list will create a configMap object per environemnt file (⚠ todo: the "to-service" label doesn't work with configMap for now)
- some labels can help to bind values, for example:
- `katenary.io/ingress: 80` will expose the port 80 in a ingress
- `katenary.io/to-service: VARNAME` will convert the value to a variable `{{ .Release.Name }}-VARNAME` - it's usefull when you want to pass the name of a service as a variable (think about the service name for mysql to pass to a container that wants to connect to this)
- `katenary.io/env-to-service: VARNAME` will convert the value to a variable `{{ .Release.Name }}-VARNAME` - it's usefull when you want to pass the name of a service as a variable (think about the service name for mysql to pass to a container that wants to connect to this)
Exemple of a possible `docker-compose.yaml` file:
@@ -94,8 +163,29 @@ services:
# Labels
- `katenary.io/env-to-service` binds the given (coma separated) variables names to {{ .Release.Name }}-value
- `katenary.io/ingress`: create an ingress and bind it to the given port
- `katenary.io/secret-envfiles`: force the creation of a secret for the given coma separated list of "env_file"
- `katenary.io/ports` is a coma separated list of ports if you want to avoid the "ports" section in your docker-compose for any reason
- `katenary.io/configma-volumes` is a coma separated list of directory (should be declared as volumes also) to transform to a configMap object
These labels could be found by `katenary show-labels`, and can be placed as "labels" inside your docker-compose file:
```
katenary.io/secret-envfiles : set the given file names as a secret instead of configmap
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/env-to-service : specifies that the environment variable points on a service name (coma separated)
katenary.io/configmap-volumes : specifies that the volumes points on a configmap (coma separated)
katenary.io/same-pod : specifies that the pod should be deployed in the same pod than the given service name
katenary.io/empty-dirs : specifies that the given volume names should be "emptyDir" instead of persistentVolumeClaim (coma separated)
katenary.io/healthcheck : specifies that the container should be monitored by a healthcheck, **it overrides the docker-compose healthcheck**.
You can use these form of label values:
- "http://[not used address][:port][/path]" to specify an http healthcheck
- "tcp://[not used address]:port" to specify a tcp healthcheck
- other string is condidered as a "command" healthcheck
```
# 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 bot and the anchor.
This "curved link" represents what we try to do, the project is a "streched link from docker-compose to helm chart".

151
cmd/main.go Normal file
View File

@@ -0,0 +1,151 @@
package main
import (
"fmt"
"katenary/generator/writers"
"katenary/helm"
"katenary/update"
"strconv"
"github.com/spf13/cobra"
)
var Version = "master" // changed at compile time
var longHelp = `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 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 <command> --help
or
"katenary help <command>"
`
func init() {
// apply the version to the "update" package
update.Version = Version
}
func main() {
// The base command
rootCmd := &cobra.Command{
Use: "katenary",
Long: longHelp,
Short: "Katenary is a tool to convert docker-compose files to Helm Charts",
}
// to display the version
versionCmd := &cobra.Command{
Use: "version",
Short: "Display version",
Run: func(c *cobra.Command, args []string) { c.Println(Version) },
}
// convert command, need some flags
convertCmd := &cobra.Command{
Use: "convert",
Short: "Convert docker-compose to helm chart",
Long: "Convert docker-compose to helm chart. The resulting helm chart will be in the current directory/" +
ChartsDir + "/" + AppName +
".\nThe appversion will be generated that way:\n" +
"- if it's in a git project, it takes git version or tag\n" +
"- if it's not defined, so the version will be get from the --app-version flag \n" +
"- if it's not defined, so the 0.0.1 version is used",
Run: func(c *cobra.Command, args []string) {
force := c.Flag("force").Changed
// TODO: is there a way to get typed values from cobra?
appversion := c.Flag("app-version").Value.String()
composeFile := c.Flag("compose-file").Value.String()
appName := c.Flag("app-name").Value.String()
chartDir := c.Flag("output-dir").Value.String()
indentation, err := strconv.Atoi(c.Flag("indent-size").Value.String())
if err != nil {
writers.IndentSize = indentation
}
Convert(composeFile, appversion, appName, chartDir, force)
},
}
convertCmd.Flags().BoolP(
"force", "f", false, "force overwrite of existing output files")
convertCmd.Flags().StringP(
"app-version", "a", AppVersion, "app version")
convertCmd.Flags().StringP(
"compose-file", "c", ComposeFile, "docker compose file")
convertCmd.Flags().StringP(
"app-name", "n", AppName, "application name")
convertCmd.Flags().StringP(
"output-dir", "o", ChartsDir, "chart directory")
convertCmd.Flags().IntP(
"indent-size", "i", 2, "set the indent size of the YAML files")
// show possible labels to set in docker-compose file
showLabelsCmd := &cobra.Command{
Use: "show-labels",
Short: "Show labels of a resource",
Run: func(c *cobra.Command, args []string) {
c.Println(helm.GetLabelsDocumentation())
},
}
// Update the binary to the latest version
updateCmd := &cobra.Command{
Use: "upgrade",
Short: "Upgrade katenary to the latest version if available",
Run: func(c *cobra.Command, args []string) {
version, assets, err := update.CheckLatestVersion()
if err != nil {
c.Println(err)
return
}
c.Println("Updating to version: " + version)
err = update.DownloadLatestVersion(assets)
if err != nil {
c.Println(err)
return
}
c.Println("Update completed")
},
}
rootCmd.AddCommand(
versionCmd,
convertCmd,
showLabelsCmd,
updateCmd,
)
// in parallel, check if the current katenary version is the latest
ch := make(chan string)
go func() {
version, _, err := update.CheckLatestVersion()
if err != nil {
ch <- ""
return
}
if Version != version {
ch <- fmt.Sprintf("\x1b[33mNew version available: " +
version +
" - to auto upgrade katenary, you can execute: katenary upgrade\x1b[0m\n")
}
}()
// Execute the command
finalize := make(chan error)
go func() {
finalize <- rootCmd.Execute()
}()
// Wait for both goroutines to finish
if err := <-finalize; err != nil {
fmt.Println(err)
}
fmt.Print(<-ch)
}

View File

@@ -2,22 +2,58 @@ package main
import (
"errors"
"flag"
"fmt"
"katenary/compose"
"katenary/generator"
"katenary/helm"
"os"
"os/exec"
"path/filepath"
"strings"
)
var ComposeFile = "docker-compose.yaml"
var AppName = "MyApp"
var Version = "master" // set at build time to the git version/tag
var ChartsDir = "chart"
var (
composeFiles = []string{"docker-compose.yaml", "docker-compose.yml"}
ComposeFile = ""
AppName = "MyApp"
ChartsDir = "chart"
AppVersion = "0.0.1"
)
func init() {
FindComposeFile()
SetAppName()
SetAppVersion()
}
func FindComposeFile() bool {
for _, file := range composeFiles {
if _, err := os.Stat(file); err == nil {
ComposeFile = file
return true
}
}
return false
}
// SetAppName sets the application name from the current directory name.
func SetAppName() {
wd, err := os.Getwd()
if err != nil {
return
}
AppName = filepath.Base(wd)
if AppName == "" {
AppName = "MyApp"
}
}
// SetAppVersion set the AppVersion variable to the git version/tag
func SetAppVersion() {
AppVersion, _ = detectGitVersion()
}
// Try to detect the git version/tag.
func detectGitVersion() (string, error) {
defaulVersion := "0.0.1"
// Check if .git directory exists
@@ -56,35 +92,16 @@ func detectGitVersion() (string, error) {
return defaulVersion, errors.New("git log failed")
}
func main() {
appVersion := "0.0.1"
helpMessageForAppversion := "The version of the application. " +
"Default is 0.0.1. If you are using git, it will be the git version. " +
"Otherwise, it will be the branch name and the commit hash."
if v, err := detectGitVersion(); err == nil {
appVersion = v
helpMessageForAppversion = "The version of the application. " +
"If not set, the version will be detected from git."
func Convert(composeFile, appVersion, appName, chartDir string, force bool) {
composeFound := FindComposeFile()
_, err := os.Stat(ComposeFile)
if !composeFound && err != nil {
fmt.Println("No compose file found")
os.Exit(1)
}
// flags
flag.StringVar(&ChartsDir, "chart-dir", ChartsDir, "set the chart directory")
flag.StringVar(&ComposeFile, "compose", ComposeFile, "set the compose file to parse")
flag.StringVar(&AppName, "appname", helm.GetProjectName(), "set the helm chart app name")
flag.StringVar(&appVersion, "appversion", appVersion, helpMessageForAppversion)
version := flag.Bool("version", false, "show version and exit")
force := flag.Bool("force", false, "force the removal of the chart-dir")
flag.Parse()
// Only display the version
if *version {
fmt.Println(Version)
os.Exit(0)
}
dirname := filepath.Join(ChartsDir, AppName)
if _, err := os.Stat(dirname); err == nil && !*force {
dirname := filepath.Join(chartDir, appName)
if _, err := os.Stat(dirname); err == nil && !force {
response := ""
for response != "y" && response != "n" {
response = "n"
@@ -115,9 +132,9 @@ func main() {
// Parse the compose file now
p := compose.NewParser(ComposeFile)
p.Parse(AppName)
p.Parse(appName)
// start generator
generator.Generate(p, Version, AppName, appVersion, ComposeFile, dirname)
generator.Generate(p, Version, appName, appVersion, ComposeFile, dirname)
}

View File

@@ -21,22 +21,40 @@ type Parser struct {
var Appname = ""
// NewParser create a Parser and parse the file given in filename.
func NewParser(filename string) *Parser {
// NewParser create a Parser and parse the file given in filename. If filename is empty, we try to parse the content[0] argument that should be a valid YAML content.
func NewParser(filename string, content ...string) *Parser {
c := NewCompose()
if filename != "" {
f, err := os.Open(filename)
if err != nil {
log.Fatal(err)
}
c := NewCompose()
dec := yaml.NewDecoder(f)
dec.Decode(c)
err = dec.Decode(c)
if err != nil {
log.Fatal(err)
}
} else {
dec := yaml.NewDecoder(strings.NewReader(content[0]))
err := dec.Decode(c)
if err != nil {
log.Fatal(err)
}
}
p := &Parser{Data: c}
return p
}
func (p *Parser) Parse(appname string) {
Appname = appname
services := make(map[string][]string)
// get the service list, to be sure that everything is ok
c := p.Data
for name, s := range c.Services {
if portlabel, ok := s.Labels[helm.LABEL_PORT]; ok {
services := strings.Split(portlabel, ",")
@@ -87,10 +105,4 @@ func NewParser(filename string) *Parser {
" for the \"" + name + "\" service \x1b[0m")
}
return p
}
func (p *Parser) Parse(appname string) {
Appname = appname
}

89
compose/parser_test.go Normal file
View File

@@ -0,0 +1,89 @@
package compose
import "testing"
const DOCKER_COMPOSE_YML1 = `
version: "3"
services:
# first service, very basic
web:
image: nginx
ports:
- "80:80"
environment:
FOO: bar
BAZ: qux
networks:
- frontend
database:
image: postgres
networks:
- frontend
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: mysecretpassword
POSTGRES_DB: mydb
labels:
katenary.io/ports: "5432"
`
func TestParser(t *testing.T) {
p := NewParser("", DOCKER_COMPOSE_YML1)
p.Parse("test")
// check if the "web" and "database" service is parsed correctly
// by checking if the "ports" and "environment"
for name, service := range p.Data.Services {
if name == "web" {
if len(service.Ports) != 1 {
t.Errorf("Expected 1 port, got %d", len(service.Ports))
}
if service.Ports[0] != "80:80" {
t.Errorf("Expected port 80:80, got %s", service.Ports[0])
}
if len(service.Environment) != 2 {
t.Errorf("Expected 2 environment variables, got %d", len(service.Environment))
}
if service.Environment["FOO"] != "bar" {
t.Errorf("Expected FOO=bar, got %s", service.Environment["FOO"])
}
if service.Environment["BAZ"] != "qux" {
t.Errorf("Expected BAZ=qux, got %s", service.Environment["BAZ"])
}
}
// same for the "database" service
if name == "database" {
if len(service.Ports) != 1 {
t.Errorf("Expected 1 port, got %d", len(service.Ports))
}
if service.Ports[0] != "5432" {
t.Errorf("Expected port 5432, got %s", service.Ports[0])
}
if len(service.Environment) != 3 {
t.Errorf("Expected 3 environment variables, got %d", len(service.Environment))
}
if service.Environment["POSTGRES_USER"] != "postgres" {
t.Errorf("Expected POSTGRES_USER=postgres, got %s", service.Environment["POSTGRES_USER"])
}
if service.Environment["POSTGRES_PASSWORD"] != "mysecretpassword" {
t.Errorf("Expected POSTGRES_PASSWORD=mysecretpassword, got %s", service.Environment["POSTGRES_PASSWORD"])
}
if service.Environment["POSTGRES_DB"] != "mydb" {
t.Errorf("Expected POSTGRES_DB=mydb, got %s", service.Environment["POSTGRES_DB"])
}
// check labels
if len(service.Labels) != 1 {
t.Errorf("Expected 1 label, got %d", len(service.Labels))
}
// is label katenary.io/ports correct?
if service.Labels["katenary.io/ports"] != "5432" {
t.Errorf("Expected katenary.io/ports=5432, got %s", service.Labels["katenary.io/ports"])
}
}
}
}

View File

@@ -15,6 +15,15 @@ func NewCompose() *Compose {
return c
}
// HealthCheck manage generic type to handle TCP, HTTP and TCP health check.
type HealthCheck struct {
Test []string `yaml:"test"`
Interval string `yaml:"interval"`
Timeout string `yaml:"timeout"`
Retries int `yaml:"retries"`
StartPeriod string `yaml:"start_period"`
}
// Service represent a "service" in a docker-compose file.
type Service struct {
Image string `yaml:"image"`
@@ -26,4 +35,6 @@ type Service struct {
Expose []int `yaml:"expose"`
EnvFiles []string `yaml:"env_file"`
RawBuild interface{} `yaml:"build"`
HealthCheck *HealthCheck `yaml:"healthcheck"`
Command []string `yaml:"command"`
}

10
examples/basic/README.md Normal file
View File

@@ -0,0 +1,10 @@
# 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
Take a look on [chart/basic](chart/basic) directory to see what `katenary convert` command has generated.

View File

@@ -0,0 +1,8 @@
# 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

View File

@@ -0,0 +1,8 @@
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 }}

View File

@@ -0,0 +1,39 @@
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

View File

@@ -0,0 +1,19 @@
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

View File

@@ -0,0 +1,48 @@
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'

View File

@@ -0,0 +1,34 @@
{{- 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 -}}

View File

@@ -0,0 +1,19 @@
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

View File

@@ -0,0 +1,8 @@
database:
image: mariadb:10
webapp:
image: php:7-apache
ingress:
class: nginx
enabled: false
host: webapp.basic.tld

View File

@@ -0,0 +1,29 @@
version: "3"
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/env-to-service: DB_HOST
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

View File

@@ -0,0 +1,13 @@
# 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.

View File

@@ -0,0 +1,8 @@
# 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

View File

@@ -0,0 +1,8 @@
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 }}

View File

@@ -0,0 +1,23 @@
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;
}
}

View File

@@ -0,0 +1,30 @@
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

View File

@@ -0,0 +1,52 @@
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

View File

@@ -0,0 +1,34 @@
{{- 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 -}}

View File

@@ -0,0 +1,19 @@
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

View File

@@ -0,0 +1,8 @@
http:
image: nginx:alpine
ingress:
class: nginx
enabled: false
host: http.same-pod.tld
php:
image: php:fpm

View File

@@ -0,0 +1,10 @@
upstream _php {
server unix:/sock/fpm.sock;
}
server {
listen 80;
location ~ ^/index\.php(/|$) {
fastcgi_pass _php;
include fastcgi_params;
}
}

View File

@@ -0,0 +1,17 @@
[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

View File

@@ -0,0 +1,38 @@
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:

View File

@@ -6,6 +6,7 @@ import (
"katenary/compose"
"katenary/helm"
"log"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -14,6 +15,8 @@ import (
"time"
"errors"
"github.com/google/shlex"
)
var servicesMap = make(map[string]int)
@@ -36,241 +39,85 @@ const (
// Values is kept in memory to create a values.yaml file.
var Values = make(map[string]map[string]interface{})
var VolumeValues = make(map[string]map[string]map[string]interface{})
var EmptyDirs = []string{}
var dependScript = `
OK=0
echo "Checking __service__ port"
while [ $OK != 1 ]; do
echo -n "."
nc -z ` + RELEASE_NAME + `-__service__ __port__ && OK=1
sleep 1
nc -z ` + RELEASE_NAME + `-__service__ __port__ 2>&1 >/dev/null && OK=1 || sleep 1
done
echo
echo "Done"
`
var madeDeployments = make(map[string]helm.Deployment, 0)
// Create a Deployment for a given compose.Service. It returns a list of objects: a Deployment and a possible Service (kubernetes represnetation as maps).
func CreateReplicaObject(name string, s *compose.Service) chan interface{} {
func CreateReplicaObject(name string, s *compose.Service, linked map[string]*compose.Service) chan interface{} {
ret := make(chan interface{}, len(s.Ports)+len(s.Expose)+1)
go parseService(name, s, ret)
go parseService(name, s, linked, ret)
return ret
}
// This function will try to yied deployment and services based on a service from the compose file structure.
func parseService(name string, s *compose.Service, ret chan interface{}) {
func parseService(name string, s *compose.Service, linked map[string]*compose.Service, ret chan interface{}) {
Magenta(ICON_PACKAGE+" Generating deployment for ", name)
o := helm.NewDeployment(name)
container := helm.NewContainer(name, s.Image, s.Environment, s.Labels)
prepareContainer(container, s, name)
// prepare secrets
secretsFiles := make([]string, 0)
if v, ok := s.Labels[helm.LABEL_ENV_SECRET]; ok {
secretsFiles = strings.Split(v, ",")
}
// manage environment files (env_file in compose)
for _, envfile := range s.EnvFiles {
f := strings.ReplaceAll(envfile, "_", "-")
f = strings.ReplaceAll(f, ".env", "")
f = strings.ReplaceAll(f, ".", "-")
cf := f + "-" + name
isSecret := false
for _, s := range secretsFiles {
if s == envfile {
isSecret = true
}
}
var store helm.InlineConfig
if !isSecret {
Bluef(ICON_CONF+" Generating configMap %s\n", cf)
store = helm.NewConfigMap(cf)
} else {
Bluef(ICON_SECRET+" Generating secret %s\n", cf)
store = helm.NewSecret(cf)
}
if err := store.AddEnvFile(envfile); err != nil {
ActivateColors = true
Red(err.Error())
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,
},
})
ret <- store
}
// check the image, and make it "variable" in values.yaml
container.Image = "{{ .Values." + name + ".image }}"
Values[name] = map[string]interface{}{
"image": s.Image,
}
// manage ports
exists := make(map[int]string)
for _, port := range s.Ports {
_p := strings.Split(port, ":")
port = _p[0]
if len(_p) > 1 {
port = _p[1]
}
portNumber, _ := strconv.Atoi(port)
portName := name
for _, n := range exists {
if name == n {
portName = fmt.Sprintf("%s-%d", name, portNumber)
}
}
container.Ports = append(container.Ports, &helm.ContainerPort{
Name: portName,
ContainerPort: portNumber,
})
exists[portNumber] = name
}
// manage the "expose" section to be a NodePort in Kubernetes
for _, port := range s.Expose {
if _, exist := exists[port]; exist {
continue
}
container.Ports = append(container.Ports, &helm.ContainerPort{
Name: name,
ContainerPort: port,
})
}
// Prepare volumes
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 _, volume := range s.Volumes {
parts := strings.Split(volume, ":")
volname := parts[0]
volepath := parts[1]
isCM := false
for _, cmVol := range configMapsVolumes {
cmVol = strings.TrimSpace(cmVol)
if volname == cmVol {
isCM = true
break
}
}
if !isCM && (strings.HasPrefix(volname, ".") || strings.HasPrefix(volname, "/")) {
// local volume cannt be mounted
ActivateColors = true
Redf("You cannot, at this time, have local volume in %s deployment\n", name)
ActivateColors = false
continue
}
if isCM {
// the volume is a path and it's explicitally asked to be a configmap in labels
cm := buildCMFromPath(volname)
volname = strings.Replace(volname, "./", "", 1)
volname = strings.ReplaceAll(volname, ".", "-")
cm.K8sBase.Metadata.Name = RELEASE_NAME + "-" + volname + "-" + name
// build a configmap from the volume path
volumes = append(volumes, map[string]interface{}{
"name": volname,
"configMap": map[string]string{
"name": cm.K8sBase.Metadata.Name,
},
})
mountPoints = append(mountPoints, map[string]interface{}{
"name": volname,
"mountPath": volepath,
})
ret <- cm
} else {
pvc := helm.NewPVC(name, volname)
volumes = append(volumes, map[string]interface{}{
"name": volname,
"persistentVolumeClaim": map[string]string{
"claimName": RELEASE_NAME + "-" + volname,
},
})
mountPoints = append(mountPoints, map[string]interface{}{
"name": volname,
"mountPath": volepath,
})
Yellow(ICON_STORE+" Generate volume values for ", volname, " in deployment ", name)
locker.Lock()
if _, ok := VolumeValues[name]; !ok {
VolumeValues[name] = make(map[string]map[string]interface{})
}
VolumeValues[name][volname] = map[string]interface{}{
"enabled": false,
"capacity": "1Gi",
}
locker.Unlock()
ret <- pvc
}
}
container.VolumeMounts = mountPoints
o.Spec.Template.Spec.Volumes = volumes
// Set the container to the deployment
o.Spec.Template.Spec.Containers = []*helm.Container{container}
// Add some labels
// Prepare volumes
madePVC := make(map[string]bool)
o.Spec.Template.Spec.Volumes = prepareVolumes(name, name, s, container, madePVC, ret)
// Now, for "depends_on" section, it's a bit tricky to get dependencies, see the function below.
o.Spec.Template.Spec.InitContainers = prepareInitContainers(name, s, container)
// Add selectors
selectors := buildSelector(name, s)
o.Spec.Selector = map[string]interface{}{
"matchLabels": buildSelector(name, s),
"matchLabels": selectors,
}
o.Spec.Template.Metadata.Labels = buildSelector(name, s)
o.Spec.Template.Metadata.Labels = selectors
// Now, for "depends_on" section, it's a bit tricky...
// 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)
// Now, the linked services
for lname, link := range linked {
container := helm.NewContainer(lname, link.Image, link.Environment, link.Labels)
prepareContainer(container, link, lname)
o.Spec.Template.Spec.Containers = append(o.Spec.Template.Spec.Containers, container)
o.Spec.Template.Spec.Volumes = append(o.Spec.Template.Spec.Volumes, prepareVolumes(name, lname, link, container, madePVC, ret)...)
o.Spec.Template.Spec.InitContainers = append(o.Spec.Template.Spec.InitContainers, prepareInitContainers(lname, link, container)...)
//append ports and expose ports to the deployment, to be able to generate them in the Service file
if len(link.Ports) > 0 || len(link.Expose) > 0 {
s.Ports = append(s.Ports, link.Ports...)
s.Expose = append(s.Expose, link.Expose...)
}
}
foundPort := -1
if defaultPort, err := getPort(dp); err != nil {
// BUG: Sometimes the chan remains opened
foundPort = <-waitPort(dp)
// Remove duplicates in volumes
volumes := make([]map[string]interface{}, 0)
done := make(map[string]bool)
for _, vol := range o.Spec.Template.Spec.Volumes {
name := vol["name"].(string)
if _, ok := done[name]; ok {
continue
} else {
foundPort = defaultPort
done[name] = true
volumes = append(volumes, vol)
}
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))
o.Spec.Template.Spec.Volumes = volumes
c.Command = []string{
"sh",
"-c",
command,
}
initContainers = append(initContainers, c)
}
o.Spec.Template.Spec.InitContainers = initContainers
// Then, create services for "ports" and "expose" section
// 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 createService(name, s) {
for _, s := range generateServicesAndIngresses(name, s) {
ret <- s
}
}
@@ -279,7 +126,7 @@ func parseService(name string, s *compose.Service, ret chan interface{}) {
// But... some other deployment can wait for it, so we alert that this deployment hasn't got any
// associated service.
if len(s.Ports) == 0 {
// alert any current or **futur** waiters that this service is not exposed
// alert any current or **future** waiters that this service is not exposed
go func() {
for {
select {
@@ -309,10 +156,21 @@ func parseService(name string, s *compose.Service, ret chan interface{}) {
ret <- nil
}
// Create a service (k8s).
func createService(name string, s *compose.Service) []interface{} {
// prepareContainer assigns image, command, env, and labels to a container.
func prepareContainer(container *helm.Container, service *compose.Service, servicename string) {
container.Image = "{{ .Values." + servicename + ".image }}"
container.Command = service.Command
Values[servicename] = map[string]interface{}{
"image": service.Image,
}
prepareProbes(servicename, service, container)
generateContainerPorts(service, servicename, container)
}
ret := make([]interface{}, 0)
// Create a service (k8s).
func generateServicesAndIngresses(name string, s *compose.Service) []interface{} {
ret := make([]interface{}, 0) // can handle helm.Service or helm.Ingress
Magenta(ICON_SERVICE+" Generating service for ", name)
ks := helm.NewService(name)
@@ -370,7 +228,7 @@ func createIngress(name string, port int, s *compose.Service) *helm.Ingress {
Paths: []helm.IngressPath{{
Path: "/",
PathType: "Prefix",
Backend: helm.IngressBackend{
Backend: &helm.IngressBackend{
Service: helm.IngressService{
Name: RELEASE_NAME + "-" + name,
Port: map[string]interface{}{
@@ -430,6 +288,7 @@ func waitPort(name string) chan int {
return c
}
// Build the selector for the service.
func buildSelector(name string, s *compose.Service) map[string]string {
return map[string]string{
"katenary.io/component": name,
@@ -437,6 +296,7 @@ func buildSelector(name string, s *compose.Service) map[string]string {
}
}
// buildCMFromPath generates a ConfigMap from a path.
func buildCMFromPath(path string) *helm.ConfigMap {
stat, err := os.Stat(path)
if err != nil {
@@ -468,3 +328,321 @@ func buildCMFromPath(path string) *helm.ConfigMap {
cm.Data = files
return cm
}
// generateContainerPorts add the container ports of a service.
func generateContainerPorts(s *compose.Service, name string, container *helm.Container) {
exists := make(map[int]string)
for _, port := range s.Ports {
_p := strings.Split(port, ":")
port = _p[0]
if len(_p) > 1 {
port = _p[1]
}
portNumber, _ := strconv.Atoi(port)
portName := name
for _, n := range exists {
if name == n {
portName = fmt.Sprintf("%s-%d", name, portNumber)
}
}
container.Ports = append(container.Ports, &helm.ContainerPort{
Name: portName,
ContainerPort: portNumber,
})
exists[portNumber] = name
}
// manage the "expose" section to be a NodePort in Kubernetes
for _, port := range s.Expose {
if _, exist := exists[port]; exist {
continue
}
container.Ports = append(container.Ports, &helm.ContainerPort{
Name: name,
ContainerPort: port,
})
}
}
// prepareVolumes add the volumes of a service.
func prepareVolumes(deployment, name string, s *compose.Service, container *helm.Container, madePVC map[string]bool, ret chan interface{}) []map[string]interface{} {
volumes := make([]map[string]interface{}, 0)
mountPoints := make([]interface{}, 0)
configMapsVolumes := make([]string, 0)
if v, ok := s.Labels[helm.LABEL_VOL_CM]; ok {
configMapsVolumes = strings.Split(v, ",")
}
for _, volume := range s.Volumes {
parts := strings.Split(volume, ":")
volname := parts[0]
volepath := parts[1]
isCM := false
for _, cmVol := range configMapsVolumes {
cmVol = strings.TrimSpace(cmVol)
if volname == cmVol {
isCM = true
break
}
}
if !isCM && (strings.HasPrefix(volname, ".") || strings.HasPrefix(volname, "/")) {
// local volume cannt be mounted
ActivateColors = true
Redf("You cannot, at this time, have local volume in %s deployment\n", name)
ActivateColors = false
continue
}
if isCM {
// check if the volname path points on a file, if so, we need to add subvolume to the interface
stat, _ := os.Stat(volname)
pointToFile := ""
if !stat.IsDir() {
pointToFile = filepath.Base(volname)
volname = filepath.Dir(volname)
}
// the volume is a path and it's explicitally asked to be a configmap in labels
cm := buildCMFromPath(volname)
volname = strings.Replace(volname, "./", "", 1)
volname = strings.ReplaceAll(volname, "/", "-")
volname = strings.ReplaceAll(volname, ".", "-")
cm.K8sBase.Metadata.Name = RELEASE_NAME + "-" + volname + "-" + name
// build a configmap from the volume path
volumes = append(volumes, map[string]interface{}{
"name": volname,
"configMap": map[string]string{
"name": cm.K8sBase.Metadata.Name,
},
})
if len(pointToFile) > 0 {
mountPoints = append(mountPoints, map[string]interface{}{
"name": volname,
"mountPath": volepath,
"subPath": pointToFile,
})
} else {
mountPoints = append(mountPoints, map[string]interface{}{
"name": volname,
"mountPath": volepath,
})
}
ret <- cm
} else {
// rmove minus sign from volume name
volname = strings.ReplaceAll(volname, "-", "")
isEmptyDir := false
for _, v := range EmptyDirs {
v = strings.ReplaceAll(v, "-", "")
if v == volname {
volumes = append(volumes, map[string]interface{}{
"name": volname,
"emptyDir": map[string]string{},
})
mountPoints = append(mountPoints, map[string]interface{}{
"name": volname,
"mountPath": volepath,
})
container.VolumeMounts = mountPoints
isEmptyDir = true
break
}
}
if isEmptyDir {
continue
}
volumes = append(volumes, map[string]interface{}{
"name": volname,
"persistentVolumeClaim": map[string]string{
"claimName": RELEASE_NAME + "-" + volname,
},
})
mountPoints = append(mountPoints, map[string]interface{}{
"name": volname,
"mountPath": volepath,
})
Yellow(ICON_STORE+" Generate volume values", volname, "for container named", name, "in deployment", deployment)
locker.Lock()
if _, ok := VolumeValues[deployment]; !ok {
VolumeValues[deployment] = make(map[string]map[string]interface{})
}
VolumeValues[deployment][volname] = map[string]interface{}{
"enabled": false,
"capacity": "1Gi",
}
locker.Unlock()
if _, ok := madePVC[deployment+volname]; !ok {
madePVC[deployment+volname] = true
pvc := helm.NewPVC(deployment, volname)
ret <- pvc
}
}
}
container.VolumeMounts = mountPoints
return volumes
}
// prepareInitContainers add the init containers of a service.
func prepareInitContainers(name string, s *compose.Service, 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
if defaultPort, err := getPort(dp); err != nil {
// BUG: Sometimes the chan remains opened
foundPort = <-waitPort(dp)
} else {
foundPort = defaultPort
}
if foundPort == -1 {
log.Fatalf(
"ERROR, the %s service is waiting for %s port number, "+
"but it is never discovered. You must declare at least one port in "+
"the \"ports\" section of the service in the docker-compose file",
name,
dp,
)
}
command = strings.ReplaceAll(command, "__port__", strconv.Itoa(foundPort))
c.Command = []string{
"sh",
"-c",
command,
}
initContainers = append(initContainers, c)
}
return initContainers
}
// prepareProbes generate http/tcp/command probes for a service.
func prepareProbes(name string, s *compose.Service, container *helm.Container) {
// manage the healthcheck property, if any
if s.HealthCheck != nil {
if s.HealthCheck.Interval == "" {
s.HealthCheck.Interval = "10s"
}
interval, err := time.ParseDuration(s.HealthCheck.Interval)
if err != nil {
log.Fatal(err)
}
if s.HealthCheck.StartPeriod == "" {
s.HealthCheck.StartPeriod = "0s"
}
initialDelaySeconds, err := time.ParseDuration(s.HealthCheck.StartPeriod)
if err != nil {
log.Fatal(err)
}
probe := helm.NewProbe(int(interval.Seconds()), int(initialDelaySeconds.Seconds()), 1, s.HealthCheck.Retries)
healthCheckLabel := s.Labels[helm.LABEL_HEALTHCHECK]
if healthCheckLabel != "" {
path := "/"
port := 80
u, err := url.Parse(healthCheckLabel)
if err == nil {
path = u.Path
port, _ = strconv.Atoi(u.Port())
} else {
path = "/"
port = 80
}
if strings.HasPrefix(healthCheckLabel, "http://") {
probe.HttpGet = &helm.HttpGet{
Path: path,
Port: port,
}
} else if strings.HasPrefix(healthCheckLabel, "tcp://") {
if err != nil {
log.Fatal(err)
}
probe.TCP = &helm.TCP{
Port: port,
}
} else {
c, _ := shlex.Split(healthCheckLabel)
probe.Exec = &helm.Exec{
Command: c,
}
}
} else if s.HealthCheck.Test[0] == "CMD" {
probe.Exec = &helm.Exec{
Command: s.HealthCheck.Test[1:],
}
}
container.LivenessProbe = probe
}
}
// prepareEnvFromFiles generate configMap or secrets from environment files.
func prepareEnvFromFiles(name string, s *compose.Service, container *helm.Container, ret chan interface{}) {
// prepare secrets
secretsFiles := make([]string, 0)
if v, ok := s.Labels[helm.LABEL_ENV_SECRET]; ok {
secretsFiles = strings.Split(v, ",")
}
// manage environment files (env_file in compose)
for _, envfile := range s.EnvFiles {
f := strings.ReplaceAll(envfile, "_", "-")
f = strings.ReplaceAll(f, ".env", "")
f = strings.ReplaceAll(f, ".", "")
f = strings.ReplaceAll(f, "/", "")
cf := f + "-" + name
isSecret := false
for _, s := range secretsFiles {
if s == envfile {
isSecret = true
}
}
var store helm.InlineConfig
if !isSecret {
Bluef(ICON_CONF+" Generating configMap %s\n", cf)
store = helm.NewConfigMap(cf)
} else {
Bluef(ICON_SECRET+" Generating secret %s\n", cf)
store = helm.NewSecret(cf)
}
if err := store.AddEnvFile(envfile); err != nil {
ActivateColors = true
Red(err.Error())
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,
},
})
ret <- store
}
}

265
generator/main_test.go Normal file
View File

@@ -0,0 +1,265 @@
package generator
import (
"io/ioutil"
"katenary/compose"
"katenary/helm"
"os"
"path/filepath"
"strings"
"testing"
)
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/env-to-service: DB_HOST
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
volumes:
data:
driver: local
`
func setUp(t *testing.T) (string, *compose.Parser) {
p := compose.NewParser("", DOCKER_COMPOSE_YML)
p.Parse("testapp")
// create a temporary directory
tmp, err := os.MkdirTemp(os.TempDir(), "katenary-test")
t.Log("Generated ", tmp, "directory")
if err != nil {
t.Fatal(err)
}
Generate(p, "test-0", "testapp", "1.2.3", DOCKER_COMPOSE_YML, tmp)
return tmp, p
}
// Check if the web2 service has got a command.
func TestCommand(t *testing.T) {
tmp, p := setUp(t)
defer os.RemoveAll(tmp)
for name := range p.Data.Services {
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 os.RemoveAll(tmp)
for name := range p.Data.Services {
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 strings.Contains(line, "DB_HOST") {
next = true
continue
}
if next {
matched = true
if !strings.Contains(line, helm.RELEASE_NAME+"-database") {
t.Error("DB_HOST variable should be set to " + helm.RELEASE_NAME + "-database")
}
break
}
}
if !matched {
t.Error("DB_HOST variable not found in ", path)
}
}
}
}
// Check if the same pod is not deployed twice.
func TestSamePod(t *testing.T) {
tmp, p := setUp(t)
defer os.RemoveAll(tmp)
for name, service := range p.Data.Services {
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 os.RemoveAll(tmp)
for name, service := range p.Data.Services {
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.Fatal(err)
}
}
}
}
// Check if the volumes are correctly set.
func TestPVC(t *testing.T) {
tmp, p := setUp(t)
defer os.RemoveAll(tmp)
for name := range p.Data.Services {
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 {
t.Fatal(err)
}
}
}
}
//Check if web service has got a ingress.
func TestIngress(t *testing.T) {
tmp, p := setUp(t)
defer os.RemoveAll(tmp)
for name := range p.Data.Services {
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)
}
}
}
}

View File

@@ -21,10 +21,48 @@ func Generate(p *compose.Parser, katernayVersion, appName, appVersion, composeFi
helm.Appname = appName
helm.Version = katernayVersion
templatesDir := filepath.Join(dirName, "templates")
// try to create the directory
err := os.MkdirAll(templatesDir, 0755)
if err != nil {
panic(err)
}
files := make(map[string]chan interface{})
// list avoided services
avoids := make(map[string]bool)
for n, service := range p.Data.Services {
if _, ok := service.Labels[helm.LABEL_SAMEPOD]; ok {
avoids[n] = true
}
}
for name, s := range p.Data.Services {
files[name] = CreateReplicaObject(name, s)
// Manage emptyDir volumes
if empty, ok := s.Labels[helm.LABEL_EMPTYDIRS]; ok {
//split empty list by coma
emptyDirs := strings.Split(empty, ",")
//append them in EmptyDirs
EmptyDirs = append(EmptyDirs, emptyDirs...)
}
// fetch corresponding service in "links"
linked := make(map[string]*compose.Service, 0)
// find service linked to this one
for n, service := range p.Data.Services {
if _, ok := service.Labels[helm.LABEL_SAMEPOD]; ok {
if service.Labels[helm.LABEL_SAMEPOD] == name {
linked[n] = service
}
}
}
if _, found := avoids[name]; found {
continue
}
files[name] = CreateReplicaObject(name, s, linked)
}
// to generate notes, we need to keep an Ingresses list
@@ -92,7 +130,7 @@ func Generate(p *compose.Parser, katernayVersion, appName, appVersion, composeFi
fp.WriteString(`# Create on ` + time.Now().Format(time.RFC3339) + "\n")
fp.WriteString(`# Katenary command line: ` + strings.Join(os.Args, " ") + "\n")
enc = yaml.NewEncoder(fp)
enc.SetIndent(2)
enc.SetIndent(writers.IndentSize)
enc.Encode(map[string]interface{}{
"apiVersion": "v2",
"name": appName,

View File

@@ -11,7 +11,7 @@ func BuildConfigMap(c interface{}, kind, servicename, name, templatesDir string)
fname := filepath.Join(templatesDir, servicename+"."+name+"."+kind+".yaml")
fp, _ := os.Create(fname)
enc := yaml.NewEncoder(fp)
enc.SetIndent(2)
enc.SetIndent(IndentSize)
enc.Encode(c)
fp.Close()
}

View File

@@ -16,22 +16,25 @@ func BuildDeployment(deployment *helm.Deployment, name, templatesDir string) {
fp, _ := os.Create(fname)
buffer := bytes.NewBuffer(nil)
enc := yaml.NewEncoder(buffer)
enc.SetIndent(2)
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") {
line = " {{- if .Values." + component + ".persistence." + dataname + ".enabled }}\n" + line
n = CountSpaces(line)
line = strings.Repeat(" ", n) + "{{- if .Values." + component + ".persistence." + dataname + ".enabled }}\n" + line
} else if strings.Contains(line, "claimName") {
line += "\n {{ else }}"
line += "\n emptyDir: {}"
line += "\n {{- end }}"
spaces := strings.Repeat(" ", n)
line += "\n" + spaces + "{{ else }}"
line += "\n" + spaces + "emptyDir: {}"
line += "\n" + spaces + "{{- end }}"
}
fp.WriteString(line + "\n")
}

View File

@@ -10,12 +10,24 @@ import (
"gopkg.in/yaml.v3"
)
const classAndVersionCondition = `{{- if and .Values.__name__.ingress.class (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}` + "\n"
const versionCondition = `{{- if semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion }}` + "\n"
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(2)
enc.SetIndent(IndentSize)
buffer.WriteString("{{- if .Values." + name + ".ingress.enabled -}}\n")
enc.Encode(ingress)
buffer.WriteString("{{- end -}}")
@@ -23,18 +35,32 @@ func BuildIngress(ingress *helm.Ingress, name, templatesDir string) {
fp, _ := os.Create(fname)
content := string(buffer.Bytes())
lines := strings.Split(content, "\n")
backendHit := false
for _, l := range lines {
if strings.Contains(l, "ingressClassName") {
p := strings.Split(l, ":")
condition := p[1]
condition = strings.ReplaceAll(condition, "'", "")
condition = strings.ReplaceAll(condition, "{{", "")
condition = strings.ReplaceAll(condition, "}}", "")
condition = strings.TrimSpace(condition)
condition = "{{- if " + condition + " }}"
l = " " + condition + "\n" + l + "\n {{- end }}"
// 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.18-0 version or higher
if strings.Contains(l, "service:") {
n := CountSpaces(l)
l = strings.Repeat(" ", n) + versionCondition + 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")
}
fp.Close()
}

View File

@@ -17,7 +17,7 @@ func BuildService(service *helm.Service, name, templatesDir string) {
fname := filepath.Join(templatesDir, name+suffix+"."+kind+".yaml")
fp, _ := os.Create(fname)
enc := yaml.NewEncoder(fp)
enc.SetIndent(2)
enc.SetIndent(IndentSize)
enc.Encode(service)
fp.Close()
}

View File

@@ -10,12 +10,15 @@ import (
func BuildStorage(storage *helm.Storage, name, templatesDir string) {
kind := "pvc"
fname := filepath.Join(templatesDir, name+"."+kind+".yaml")
name = storage.Metadata.Labels[helm.K+"/component"]
pvcname := storage.Metadata.Labels[helm.K+"/pvc-name"]
fname := filepath.Join(templatesDir, name+"-"+pvcname+"."+kind+".yaml")
fp, _ := os.Create(fname)
volname := storage.K8sBase.Metadata.Labels[helm.K+"/pvc-name"]
fp.WriteString("{{ if .Values." + name + ".persistence." + volname + ".enabled }}\n")
enc := yaml.NewEncoder(fp)
enc.SetIndent(2)
enc.SetIndent(IndentSize)
enc.Encode(storage)
fp.WriteString("{{- end -}}")
}

View File

@@ -0,0 +1,17 @@
package writers
// IndentSize set the indentation size for yaml output.
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
}

9
go.mod
View File

@@ -2,4 +2,11 @@ module katenary
go 1.16
require gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/kr/pretty v0.2.0 // indirect
github.com/spf13/cobra v1.4.0
golang.org/x/mod v0.5.1
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
)

32
go.sum
View File

@@ -1,4 +1,34 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q=
github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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/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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -47,6 +47,39 @@ type Container struct {
EnvFrom []map[string]map[string]string `yaml:"envFrom,omitempty"`
Command []string `yaml:"command,omitempty"`
VolumeMounts []interface{} `yaml:"volumeMounts,omitempty"`
LivenessProbe *Probe `yaml:"livenessProbe,omitempty"`
}
type HttpGet struct {
Path string `yaml:"path"`
Port int `yaml:"port"`
}
type Exec struct {
Command []string `yaml:"command"`
}
type TCP struct {
Port int `yaml:"port"`
}
type Probe struct {
HttpGet *HttpGet `yaml:"httpGet,omitempty"`
Exec *Exec `yaml:"exec,omitempty"`
TCP *TCP `yaml:"tcp,omitempty"`
Period int `yaml:"periodSeconds"`
Success int `yaml:"successThreshold"`
Failure int `yaml:"failureThreshold"`
InitialDelay int `yaml:"initialDelaySeconds"`
}
func NewProbe(period, initialDelaySeconds, success, failure int) *Probe {
return &Probe{
Period: period,
Success: success,
Failure: failure,
InitialDelay: initialDelaySeconds,
}
}
func NewContainer(name, image string, environment, labels map[string]string) *Container {

View File

@@ -38,14 +38,16 @@ type IngressHttp struct {
type IngressPath struct {
Path string
PathType string `yaml:"pathType"`
Backend IngressBackend
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
Port map[string]interface{}
Name string `yaml:"name"`
Port map[string]interface{} `yaml:"port"`
}

View File

@@ -1,11 +1,13 @@
package helm
import (
"bytes"
"crypto/sha1"
"fmt"
"io/ioutil"
"os"
"strings"
"text/template"
)
const K = "katenary.io"
@@ -16,8 +18,41 @@ const (
LABEL_INGRESS = K + "/ingress"
LABEL_ENV_SERVICE = K + "/env-to-service"
LABEL_VOL_CM = K + "/configmap-volumes"
LABEL_HEALTHCHECK = K + "/healthcheck"
LABEL_SAMEPOD = K + "/same-pod"
LABEL_EMPTYDIRS = K + "/empty-dirs"
)
func GetLabelsDocumentation() string {
t, _ := template.New("labels").Parse(`
# Labels
{{.LABEL_ENV_SECRET | printf "%-33s"}}: set the given file names as a secret instead of configmap
{{.LABEL_PORT | printf "%-33s"}}: set the ports to expose as a service (coma separated)
{{.LABEL_INGRESS | printf "%-33s"}}: set the port to expose in an ingress (coma separated)
{{.LABEL_ENV_SERVICE | printf "%-33s"}}: specifies that the environment variable points on a service name (coma separated)
{{.LABEL_VOL_CM | printf "%-33s"}}: specifies that the volumes points on a configmap (coma separated)
{{.LABEL_SAMEPOD | printf "%-33s"}}: specifies that the pod should be deployed in the same pod than the given service name
{{.LABEL_EMPTYDIRS | printf "%-33s"}}: specifies that the given volume names should be "emptyDir" instead of persistentVolumeClaim (coma separated)
{{.LABEL_HEALTHCHECK | printf "%-33s"}}: specifies that the container should be monitored by a healthcheck, **it overrides the docker-compose healthcheck**.
{{ printf "%-34s" ""}} You can use these form of label values:
{{ printf "%-35s" ""}}- "http://[not used address][:port][/path]" to specify an http healthcheck
{{ printf "%-35s" ""}}- "tcp://[not used address]:port" to specify a tcp healthcheck
{{ printf "%-35s" ""}}- other string is condidered as a "command" healthcheck
`)
buff := bytes.NewBuffer(nil)
t.Execute(buff, map[string]string{
"LABEL_ENV_SECRET": LABEL_ENV_SECRET,
"LABEL_ENV_SERVICE": LABEL_ENV_SERVICE,
"LABEL_PORT": LABEL_PORT,
"LABEL_INGRESS": LABEL_INGRESS,
"LABEL_VOL_CM": LABEL_VOL_CM,
"LABEL_HEALTHCHECK": LABEL_HEALTHCHECK,
"LABEL_SAMEPOD": LABEL_SAMEPOD,
"LABEL_EMPTYDIRS": LABEL_EMPTYDIRS,
})
return buff.String()
}
var (
Appname = ""
Version = "1.0" // should be set from main.Version

58
install.sh Normal file
View File

@@ -0,0 +1,58 @@
#!/bin/sh
# Install the latest version of the Katenary detecting the right OS and architecture.
# Can be launched with the following command:
# sh <(curl -sSL https://raw.githubusercontent.com/metal3d/katenary/master/install.sh)
set -e
# Detect the OS and architecture
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")
# Where to download the binary
BASE="https://github.com/metal3d/katenary/releases/latest/download/"
if [ $ARCH = "x86_64" ]; then
ARCH="amd64"
fi
BIN_URL="$BASE/katenary-$OS-$ARCH"
INSTALL_TYPE="global"
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)
$USE_SUDO mv $T $BIN_PATH/katenary
$USE_SUDO chmod +x $BIN_PATH/katenary
echo
echo "Installed to $BIN_PATH/katenary"
echo "Installation complete! Run 'katenary --help' to get started."

BIN
misc/LogoMakr-1TEtSp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
misc/Logo_Smile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
misc/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

140
update/main.go Normal file
View File

@@ -0,0 +1,140 @@
package update
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"runtime"
"time"
"golang.org/x/mod/semver"
)
var exe, _ = os.Executable()
var Version = "master" // reset by cmd/main.go
// Asset is a github asset from release url.
type Asset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}
// 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{
Timeout: time.Second * 1,
}
// Create a request
req, err := http.NewRequest("GET", githuburl, nil)
if err != nil {
return "", nil, err
}
// Send the request via a client
resp, err := client.Do(req)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
// Get tag_name from the json response
var release = struct {
TagName string `json:"tag_name"`
Assets []Asset `json:"assets"`
PreRelease bool `json:"prerelease"`
}{}
err = json.NewDecoder(resp.Body).Decode(&release)
if err != nil {
return "", nil, err
}
// if it's a prerelease, don't update
if release.PreRelease {
return "", nil, errors.New("Prerelease detected, not updating")
}
// no tag, don't update
if release.TagName == "" {
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 release.TagName, release.Assets, nil
}
// DownloadLatestVersion will download the latest version of katenary.
func DownloadLatestVersion(assets []Asset) error {
// Download the latest version
fmt.Println("Downloading the latest version...")
// ok, replace this from the current version to the latest version
err := os.Rename(exe, exe+".old")
if err != nil {
return err
}
// Download the latest version for the current OS
for _, asset := range assets {
switch runtime.GOOS {
case "windows":
if asset.Name == "katenary.exe" {
err = DownloadFile(asset.URL, exe)
}
case "linux":
switch runtime.GOARCH {
case "amd64":
if asset.Name == "katenary-linux-amd64" {
err = DownloadFile(asset.URL, exe)
}
case "arm64":
if asset.Name == "katenary-linux-arm64" {
err = DownloadFile(asset.URL, exe)
}
}
case "darwin":
if asset.Name == "katenary-darwin" {
err = DownloadFile(asset.URL, exe)
}
default:
fmt.Println("Unsupported OS")
err = errors.New("Unsupported OS")
}
}
if err == nil {
// remove the old version
os.Remove(exe + ".old")
} else {
// restore the old version
os.Rename(exe+".old", exe)
}
return err
}
// DownloadFile will download a url to a local file. It also ensure that the file is executable.
func DownloadFile(url, exe string) error {
// Download the url binary to exe path
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
fp, err := os.OpenFile(exe, os.O_WRONLY|os.O_CREATE, 0755)
if err != nil {
return err
}
defer fp.Close()
_, err = io.Copy(fp, resp.Body)
return err
}

52
update/update_test.go Normal file
View File

@@ -0,0 +1,52 @@
package update
import (
"fmt"
"os"
"testing"
)
func TestDownloadLatestRelease(t *testing.T) {
// Reset the version to test the latest release
Version = "0.0.0"
// change "exe" to /tmp/test-katenary
exe = "/tmp/test-katenary"
defer os.Remove(exe)
// Now call the CheckLatestVersion function
version, assets, err := CheckLatestVersion()
if err != nil {
t.Errorf("Error: %s", err)
}
fmt.Println("Version found", version)
// Touch exe binary
f, _ := os.OpenFile(exe, os.O_RDONLY|os.O_CREATE, 0755)
f.Write(nil)
f.Close()
err = DownloadLatestVersion(assets)
if err != nil {
t.Errorf("Error: %s", err)
}
}
func TestAlreadyUpToDate(t *testing.T) {
Version = "99999.999.99"
exe = "/tmp/test-katenary"
defer os.Remove(exe)
// Call the version check
version, _, err := CheckLatestVersion()
if err == nil {
t.Errorf("Error: %s", err)
}
t.Log("Version is already the most recent", version)
}