From a3c1c00a4526317463e25a3f072142a4bdca1ff5 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Thu, 26 Feb 2026 20:13:49 +0100 Subject: [PATCH 01/28] feat: Add kubernetes targets This will add kubernetes targets. This assuming you are having helm charts. It will detect those and do validation and diffing magic on them. Signed-off-by: Atze de Vries --- internal/core/core.go | 39 +++ internal/devtool/dyff.go | 162 ++++++++++++ internal/devtool/helm.go | 122 +++++++++ internal/devtool/kubescore.go | 123 +++++++++ internal/devtool/tools.Dockerfile | 2 + internal/git/git.go | 30 +++ internal/kubernetes/kubernetes.go | 245 ++++++++++++++++++ internal/kubernetes/kubernetes_test.go | 153 +++++++++++ .../charta/templates/deployment.yaml | 44 ++++ .../charta/templates/service.yaml | 22 ++ .../kubernetes/helm/charts/charta/.helmignore | 23 ++ .../kubernetes/helm/charts/charta/Chart.yaml | 24 ++ .../helm/charts/charta/templates/NOTES.txt | 22 ++ .../helm/charts/charta/templates/_helpers.tpl | 62 +++++ .../charts/charta/templates/deployment.yaml | 78 ++++++ .../helm/charts/charta/templates/hpa.yaml | 32 +++ .../helm/charts/charta/templates/ingress.yaml | 43 +++ .../helm/charts/charta/templates/service.yaml | 15 ++ .../charta/templates/serviceaccount.yaml | 13 + .../charts/charta/values-production-fail.yaml | 1 + .../helm/charts/charta/values-production.yaml | 1 + .../helm/charts/charta/values-staging.yaml | 1 + .../kubernetes/helm/charts/charta/values.yaml | 123 +++++++++ .../kubernetes/helm/charts/chartb/.helmignore | 23 ++ .../kubernetes/helm/charts/chartb/Chart.yaml | 24 ++ .../helm/charts/chartb/templates/NOTES.txt | 22 ++ .../helm/charts/chartb/templates/_helpers.tpl | 62 +++++ .../charts/chartb/templates/deployment.yaml | 78 ++++++ .../helm/charts/chartb/templates/hpa.yaml | 32 +++ .../helm/charts/chartb/templates/ingress.yaml | 43 +++ .../helm/charts/chartb/templates/service.yaml | 15 ++ .../chartb/templates/serviceaccount.yaml | 13 + .../helm/charts/chartb/values-dev.yaml | 1 + .../helm/charts/chartb/values-this-dev.yaml | 1 + .../kubernetes/helm/charts/chartb/values.yaml | 123 +++++++++ .../kubernetes/helm/charts/chartc/.helmignore | 23 ++ .../kubernetes/helm/charts/chartc/Chart.yaml | 24 ++ .../helm/charts/chartc/inject-fail.yaml | 1 + .../helm/charts/chartc/templates/service.yaml | 8 + .../kubernetes/helm/charts/chartc/values.yaml | 123 +++++++++ internal/targets/kubernetes/kubernetes.go | 61 +++++ targets/goapp/kubernetes.go | 29 +++ 42 files changed, 2086 insertions(+) create mode 100644 internal/devtool/dyff.go create mode 100644 internal/devtool/helm.go create mode 100644 internal/devtool/kubescore.go create mode 100644 internal/kubernetes/kubernetes.go create mode 100644 internal/kubernetes/kubernetes_test.go create mode 100644 internal/kubernetes/testdata/ref-data/chart-a-staging/charta/templates/deployment.yaml create mode 100644 internal/kubernetes/testdata/ref-data/chart-a-staging/charta/templates/service.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/.helmignore create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/Chart.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/NOTES.txt create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/_helpers.tpl create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/deployment.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/hpa.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/ingress.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/service.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/serviceaccount.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-production-fail.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-production.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-staging.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/.helmignore create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/Chart.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/NOTES.txt create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/_helpers.tpl create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/deployment.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/hpa.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/ingress.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/service.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/serviceaccount.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values-dev.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values-this-dev.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/.helmignore create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/Chart.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/inject-fail.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/templates/service.yaml create mode 100644 internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/values.yaml create mode 100644 internal/targets/kubernetes/kubernetes.go create mode 100644 targets/goapp/kubernetes.go diff --git a/internal/core/core.go b/internal/core/core.go index 1d7594a4..937a638c 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -173,3 +173,42 @@ func GetRepoRoot() (string, error) { } return cwd, nil } + +// ListRescursiveFiles recursively finds all files in the root directory that match the given pattern. +func ListRescursiveFiles(root, pattern string) ([]string, error) { + var matches []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + // If an error occurs (e.g., permission denied on a directory), + // the function can decide how to handle it. Returning nil skips the error + // for this specific path and continues the traversal. + return nil + } + + // Check if it's a file and if its name matches the pattern. + if !d.IsDir() { + // filepath.Match checks a filename against a glob pattern. + if matched, _ := filepath.Match(pattern, d.Name()); matched { + matches = append(matches, path) + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("error walking directory: %w", err) + } + + return matches, nil +} + +// DirExists returns true if a dir exists +func DirExists(path string) bool { + _, err := os.Stat(path) + if err == nil { + return true + } + if errors.Is(err, fs.ErrNotExist) { + return false + } + return false +} diff --git a/internal/devtool/dyff.go b/internal/devtool/dyff.go new file mode 100644 index 00000000..21d97249 --- /dev/null +++ b/internal/devtool/dyff.go @@ -0,0 +1,162 @@ +package devtool + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/coopnorge/mage/internal/core" + "github.com/hashicorp/go-version" + "github.com/magefile/mage/sh" +) + +// Dyff holds the devtool for policy-bot +type Dyff struct{} + +// DyffConfigCheckDocker the content of policy-bot.Dockerfile +// +//go:embed policy-bot/policy-bot.Dockerfile +var DyffDocker string + +const dyffVersion = "v1.10.5" + +// Run runs the policy-bot devtool +func (dyff Dyff) Run(env map[string]string, args ...string) error { + if !isCommandAvailable("dyff") { + fmt.Println("Dyff binary not found. Use 'brew install dyff' to install. Falling back to running the docker version") + return dyff.runInDocker(env, args...) + } + + err := dyff.versionOK() + if err != nil { + fmt.Printf("Go does not meet version constraints. Falling back to docker verion\n error: %s\n", err) + return dyff.runInDocker(env, args...) + } + + fmt.Println("Using native go") + return dyff.runNative(env, args...) + // for now only support running in Docker +} + +func (dyff Dyff) versionOK() error { + // example v3.17.1+g980d8ac + out, err := sh.Output("dyff", "version") + if err != nil { + return err + } + current, err := version.NewVersion(strings.Split(out, " ")[2]) + if err != nil { + return err + } + devtool, err := version.NewVersion(dyffVersion) + if err != nil { + return err + } + // set constraint that minor minus 5 version should be minimum + constraintString := fmt.Sprintf(">= %s.%s", strconv.Itoa(devtool.Segments()[0]), strconv.Itoa(devtool.Segments()[1]-2)) + constraint, err := version.NewConstraint(constraintString) + if err != nil { + return err + } + if !constraint.Check(current) { + return fmt.Errorf("version found %s does not match constraint %s", current.Original(), constraint.String()) + } + return nil +} + +func (dyff Dyff) runNative(env map[string]string, args ...string) error { + // check if env var with output filename exist then also write content to + // file name + out, err := sh.OutputWith(env, "dyff", args...) + if err != nil { + return err + } + fmt.Println(out) + filename, found := env["OUTPUT_FILE"] + if found { + err = os.WriteFile(filename, []byte(out), 0x644) + } + if err != nil { + return err + } + return err +} + +func (dyff Dyff) runInDocker(env map[string]string, args ...string) error { + image, err := dyff.buildImage() + if err != nil { + return err + } + + path, err := os.Getwd() + if err != nil { + return err + } + + // workdir is dependant on the version of dependabot + origWorkDir, err := sh.Output( + "docker", "inspect", + "--format={{.Config.WorkingDir}}", + image, + ) + if err != nil { + return err + } + + // the binary is in the original working directory + entryPoint := filepath.Join(origWorkDir, fmt.Sprintf("bin/linux-%s/policy-bot", runtime.GOARCH)) + + dockerArgs := []string{ + "--volume", fmt.Sprintf("%s:/app", path), // Mount the source code + "--workdir", "/app", // set workdir to where we want to run + "--entrypoint", entryPoint, + } + + if env == nil { + env = map[string]string{} + } + + for k, v := range env { + dockerArgs = append(dockerArgs, "--env", fmt.Sprintf("%s=%s", k, v)) + } + + runArgs := []string{ + "run", + "--rm", + } + runArgs = append(runArgs, dockerArgs...) + runArgs = append(runArgs, image) + runArgs = append(runArgs, args...) + + if core.Verbose() { + return sh.RunWith(env, "docker", runArgs...) + } + out, err := sh.OutputWith(env, "docker", runArgs...) + if err != nil { + fmt.Println(out) + return err + } + return err +} + +func (dyff Dyff) buildImage() (string, error) { + imagename := fmt.Sprintf("%s:%s", "dyff", dyffVersion) + + _, cleanup, err := core.WriteTempFile(core.OutputDir, fmt.Sprintf("%s.dockerfile", "dyff"), DyffDocker) + if err != nil { + return "", err + } + defer cleanup() + + _, cleanup, err = core.MkdirTemp() + if err != nil { + return "", nil + } + defer cleanup() + + return imagename, nil +} diff --git a/internal/devtool/helm.go b/internal/devtool/helm.go new file mode 100644 index 00000000..18db1e41 --- /dev/null +++ b/internal/devtool/helm.go @@ -0,0 +1,122 @@ +package devtool + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/coopnorge/mage/internal/core" + "github.com/hashicorp/go-version" + "github.com/magefile/mage/sh" +) + +// Helm holds the devtool for helm +type Helm struct{} + +// Run runs the helm devtool +func (helm Helm) Run(env map[string]string, args ...string) error { + if !isCommandAvailable("helm") { + fmt.Println("helm binary not found. Use 'brew install helm' to install. Falling back to running the docker version") + return helm.runInDocker(env, args...) + } + + err := helm.versionOK() + if err != nil { + fmt.Printf("helm does not meet version constraints. Falling back to docker verion\n error: %s\n", err) + return helm.runInDocker(env, args...) + } + + fmt.Println("Using native helm") + return helm.runNative(env, args...) +} + +func (helm Helm) versionOK() error { + devtoolData, err := getTool(ToolsDockerfile, "helm") + if err != nil { + return err + } + // example v3.17.1+g980d8ac + out, err := sh.Output("helm", "version", "--short") + if err != nil { + return err + } + current, err := version.NewVersion(strings.Split(out, "+")[0]) + if err != nil { + return err + } + devtool, err := version.NewVersion(devtoolData.version) + if err != nil { + return err + } + // set constraint that minor minus 5 version should be minimum + constraintString := fmt.Sprintf(">= %s.%s", strconv.Itoa(devtool.Segments()[0]), strconv.Itoa(devtool.Segments()[1]-5)) + constraint, err := version.NewConstraint(constraintString) + if err != nil { + return err + } + if !constraint.Check(current) { + return fmt.Errorf("version found %s does not match constraint %s", current.Original(), constraint.String()) + } + return nil +} + +func (helm Helm) runNative(env map[string]string, args ...string) error { + if core.Verbose() { + return sh.RunWith(env, "helm", helm.addDefautsArgs(args...)...) + } + out, err := sh.OutputWith(env, "helm", helm.addDefautsArgs(args...)...) + if err != nil { + fmt.Println(out) + return err + } + return err +} + +func (helm Helm) runInDocker(env map[string]string, args ...string) error { + devtool, err := getTool(ToolsDockerfile, "helm") + if err != nil { + return err + } + + path, err := os.Getwd() + if err != nil { + return err + } + + // helm --strict -verbose -schema-location "https://raw.githubusercontent.com/coopnorge/kubernetes-schemas/main/pallets/{{ .ResourceKind }}{{ .KindSuffix }}.json" .pallet/gitconfig.yaml + + dockerArgs := []string{ + "--volume", fmt.Sprintf("%s:/app", path), // Mount the source code + "--workdir", "/app", // set workdir to where we want to run + } + + if env == nil { + env = map[string]string{} + } + for k, v := range env { + dockerArgs = append(dockerArgs, "--env", fmt.Sprintf("%s=%s", k, v)) + } + + runArgs := []string{ + "run", + "--rm", + } + runArgs = append(runArgs, dockerArgs...) + runArgs = append(runArgs, devtool.image) + runArgs = append(runArgs, helm.addDefautsArgs(args...)...) + + if core.Verbose() { + return sh.RunWith(env, "docker", runArgs...) + } + out, err := sh.OutputWith(env, "docker", runArgs...) + if err != nil { + fmt.Println(out) + return err + } + return err +} + +func (helm Helm) addDefautsArgs(args ...string) []string { + return args +} diff --git a/internal/devtool/kubescore.go b/internal/devtool/kubescore.go new file mode 100644 index 00000000..66615e6d --- /dev/null +++ b/internal/devtool/kubescore.go @@ -0,0 +1,123 @@ +package devtool + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/coopnorge/mage/internal/core" + "github.com/hashicorp/go-version" + "github.com/magefile/mage/sh" +) + +// KubeScore holds the devtool for kubescore +type KubeScore struct{} + +// Run runs the kubescore devtool +func (kubescore KubeScore) Run(env map[string]string, args ...string) error { + if !isCommandAvailable("kube-score") { + fmt.Println("kube-score binary not found. Use 'brew install kube-score' to install. Falling back to running the docker version") + return kubescore.runInDocker(env, args...) + } + + err := kubescore.versionOK() + if err != nil { + fmt.Printf("kube-score does not meet version constraints. Falling back to docker verion\n error: %s\n", err) + return kubescore.runInDocker(env, args...) + } + + fmt.Println("Using native kube-score") + return kubescore.runNative(env, args...) +} + +func (kubescore KubeScore) versionOK() error { + devtoolData, err := getTool(ToolsDockerfile, "kube-score") + if err != nil { + return err + } + // example v3.17.1+g980d8ac + out, err := sh.Output("kube-score", "version") + if err != nil { + return err + } + // kube-score version: 1.18.0, commit: 0fb5f668e153c22696aa75ec769b080c41b5dd3d, built: 2024-02-05T14:08:35Z + + versionString := strings.Split(strings.Split(out, ",")[0], ":")[1] + current, err := version.NewVersion(strings.TrimSpace(versionString)) + if err != nil { + return err + } + devtool, err := version.NewVersion(devtoolData.version) + if err != nil { + return err + } + // set constraint that minor minus 5 version should be minimum + constraintString := fmt.Sprintf(">= %s.%s", strconv.Itoa(devtool.Segments()[0]), strconv.Itoa(devtool.Segments()[1]-2)) + constraint, err := version.NewConstraint(constraintString) + if err != nil { + return err + } + if !constraint.Check(current) { + return fmt.Errorf("version found %s does not match constraint %s", current.Original(), constraint.String()) + } + return nil +} + +func (kubescore KubeScore) runNative(env map[string]string, args ...string) error { + if core.Verbose() { + return sh.RunWith(env, "kube-score", kubescore.addDefautsArgs(args...)...) + } + out, err := sh.OutputWith(env, "kube-score", kubescore.addDefautsArgs(args...)...) + if err != nil { + fmt.Println(out) + return err + } + return err +} + +func (kubescore KubeScore) runInDocker(env map[string]string, args ...string) error { + devtool, err := getTool(ToolsDockerfile, "kube-score") + if err != nil { + return err + } + + path, err := os.Getwd() + if err != nil { + return err + } + + dockerArgs := []string{ + "--volume", fmt.Sprintf("%s:/app", path), // Mount the source code + "--workdir", "/app", // set workdir to where we want to run + } + + if env == nil { + env = map[string]string{} + } + for k, v := range env { + dockerArgs = append(dockerArgs, "--env", fmt.Sprintf("%s=%s", k, v)) + } + + runArgs := []string{ + "run", + "--rm", + } + runArgs = append(runArgs, dockerArgs...) + runArgs = append(runArgs, devtool.image) + runArgs = append(runArgs, kubescore.addDefautsArgs(args...)...) + + if core.Verbose() { + return sh.RunWith(env, "docker", runArgs...) + } + out, err := sh.OutputWith(env, "docker", runArgs...) + if err != nil { + fmt.Println(out) + return err + } + return err +} + +func (kubescore KubeScore) addDefautsArgs(args ...string) []string { + return args +} diff --git a/internal/devtool/tools.Dockerfile b/internal/devtool/tools.Dockerfile index 901415e5..26d44bed 100644 --- a/internal/devtool/tools.Dockerfile +++ b/internal/devtool/tools.Dockerfile @@ -6,3 +6,5 @@ FROM docker.io/hashicorp/terraform:1.5.7@sha256:9fc0d70fb0f858b0af1fadfcf8b7510b FROM ghcr.io/terraform-linters/tflint:v0.60.0@sha256:cef181224b4a9cea521d8f785d50957ea3215b449e2d97e7793f222e2808d188 AS tflint FROM docker.io/aquasec/trivy:0.68.2@sha256:05d0126976bdedcd0782a0336f77832dbea1c81b9cc5e4b3a5ea5d2ec863aca7 AS trivy FROM quay.io/terraform-docs/terraform-docs:0.20.0@sha256:37329e2dc2518e7f719a986a3954b10771c3fe000f50f83fd4d98d489df2eae2 AS terraform-docs +FROM docker.io/alpine/helm:3.20.0@sha256:2240b3c3e917a156c4af570c7f8bdf951072196de69f2a0d06e2cd2fc0ba40a8 as helm +FROM docker.io/zegl/kube-score:v1.20.0@sha256:ac4c43ad560af905d66f6bf57b0937c591332e6dbf2167c31193a13b4695ab97 as kube-score diff --git a/internal/git/git.go b/internal/git/git.go index 680870b9..40239f61 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "strings" "github.com/magefile/mage/sh" @@ -88,3 +89,32 @@ func checkBranch(branch string) error { func IsTracked(path string) bool { return sh.Run("git", "ls-files", "--error-unmatch", path) == nil } + +// CurrentBranch returns the current branch +func CurrentBranch() (string, error) { + return sh.Output("git", "rev-parse", "--abbrev-ref", "HEAD") +} + +// Worktree creates a new worktree for the given branch. +// It returns the absolute path to the worktree and an error if the operation fails. +func Worktree(branch string) (string, func(), error) { + // Define target location (e.g., in a 'worktrees' directory outside the current repo). + // Placing worktrees outside prevents recursive issues with tools scanning the main repo. + targetDir := filepath.Join("worktrees", branch) + + // Execute 'git worktree add ' using mage/sh. + // sh.Run prints output to stdout/stderr and returns an error if the command fails. + err := sh.Run("git", "worktree", "add", targetDir, branch) + if err != nil { + return "", nil, fmt.Errorf("failed to create worktree for branch %s: %w", branch, err) + } + + // 5. Define the cleanup function. + // We use git worktree remove which cleans up the admin files and the directory. + cleanup := func() { + // We use sh.Run so the cleanup output is visible in mage -v + _ = sh.Run("git", "worktree", "remove", targetDir) + } + + return targetDir, cleanup, nil +} diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go new file mode 100644 index 00000000..e4894bcb --- /dev/null +++ b/internal/kubernetes/kubernetes.go @@ -0,0 +1,245 @@ +// Package kubernetes has the concern of validating pallets +package kubernetes + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/coopnorge/mage/internal/core" + "github.com/coopnorge/mage/internal/devtool" + "github.com/coopnorge/mage/internal/git" +) + +var ( + helm devtool.Helm + kubeconform devtool.KubeConform + kubescore devtool.KubeScore + dyff devtool.Dyff +) + +type HelmChart struct { + path string + env string + valueFiles []string +} + +func isHelmChart(p string, d fs.DirEntry) bool { + if !d.IsDir() { + return false + } + return core.FileExists(filepath.Join(p, "Chart.yaml")) +} + +// RenderTemplates renders the templates of a specific helm chart. It will +// return a function for cleanup +// When second argument is set to true it will try to render even if some +// files are not there. This is used when rendering a template which is in +// unkown state +func RenderTemplates(chart HelmChart, try bool) (string, func(), error) { + outdir, cleanup, err := core.MkdirTemp() + if err != nil { + return outdir, nil, err + } + if try { + // if the chart does not exist it will just return an empty dir, which + // we can diff against + if !core.FileExists(filepath.Join(chart.path, "Chart.yaml")) { + return outdir, cleanup, nil + } + } + + valueFilesFlags := []string{} + for _, file := range chart.valueFiles { + fp := filepath.Join(chart.path, file) + if try { + // when in try, continue if file does not exist + if !core.FileExists(fp) { + continue + } + } + valueFilesFlags = append(valueFilesFlags, "--values") + valueFilesFlags = append(valueFilesFlags, fp) + } + args := []string{} + args = append(args, "template") + args = append(args, chart.path) + args = append(args, "--output-dir") + args = append(args, outdir) + args = append(args, valueFilesFlags...) + + return outdir, cleanup, helm.Run(nil, args...) +} + +func DiffTemplates(chart HelmChart) error { + // dyff between a/helloworld/charts/app/templates/ b/helloworld/charts/app/templates/ -o github + currentBranch, err := git.CurrentBranch() + if err != nil { + return err + } + + branchTemplates, branchDirCleanup, err := RenderTemplates(chart, false) + defer branchDirCleanup() + if err != nil { + return err + } + + mainWorktree, worktreeCleanup, err := git.Worktree("main") + defer worktreeCleanup() + if err != nil { + return err + } + // create a chart object for the chart in the main branch + mainChart := HelmChart{ + path: filepath.Join(mainWorktree, chart.path), + env: chart.env, + valueFiles: chart.valueFiles, + } + mainTemplates, mainBranchCleanup, err := RenderTemplates(mainChart, true) + defer mainBranchCleanup() + if err != nil { + return err + } + + args := []string{"between"} + env := make(map[string]string) + // simply assumming that if CI is set, we are in github actions + if _, found := os.LookupEnv("CI"); found { + args = append(args, "--output", "github") + env["OUTPUT_FILE"] = fmt.Sprintf("%s-%s-%s.diff", filepath.Base(chart.path), chart.env, currentBranch) + } + args = append(args, mainTemplates, branchTemplates) + return dyff.Run(env, args...) +} + +// FindHelmCharts will search through the base directory to find the +// all helm charts +func FindHelmCharts(base string) ([]HelmChart, error) { + directories := []string{} + charts := []HelmChart{} + envs := []string{"dev", "test", "staging", "production"} + + err := filepath.WalkDir(base, func(workDir string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if core.IsDotDirectory(workDir, d) { + return filepath.SkipDir + } + if !isHelmChart(workDir, d) { + return nil + } + directories = append(directories, workDir) + return nil + }) + if err != nil { + return nil, err + } + for _, dir := range directories { + for _, env := range envs { + valueFiles, err := findHelmValues(dir, env) + if err != nil { + return nil, err + } + // skip if we find no env specific values + if len(valueFiles) == 0 { + continue + } + slices.Reverse(valueFiles) + charts = append(charts, HelmChart{ + path: dir, + env: env, + valueFiles: valueFiles, + }) + } + } + return charts, nil +} + +func ListHelmCharts(charts []HelmChart) { + for _, chart := range charts { + fmt.Sprintf("---\n") + fmt.Sprintf("path: %s\n", chart.path) + fmt.Sprintf("environment: %s\n", chart.env) + fmt.Sprintf("valueFiles: [%s]\n", strings.Join(chart.valueFiles, "\", \"")) + } +} + +func ValidateWithKubeConform(chart HelmChart) error { + dir, cleanup, err := RenderTemplates(chart, false) + defer cleanup() + if err != nil { + return err + } + args := []string{ + "-schema-location", "default", + "--schema-location", "https://raw.githubusercontent.com/coopnorge/kubernetes-schemas/main/api-platform/{{ .ResourceKind }}{{ .KindSuffix }}.json", + } + files, err := core.ListRescursiveFiles(dir, "*.yaml") + if err != nil { + return err + } + args = append(args, files...) + return kubeconform.Run(nil, args...) +} + +func ValidateWithKubeScore(chart HelmChart) error { + dir, cleanup, err := RenderTemplates(chart, false) + defer cleanup() + if err != nil { + return err + } + args := []string{ + "score", + } + files, err := core.ListRescursiveFiles(dir, "*.yaml") + if err != nil { + return err + } + if len(files) == 0 { + return nil + } + args = append(args, files...) + return kubescore.Run(nil, args...) +} + +func findHelmValues(dir string, env string) ([]string, error) { + // order of finding value files is + // case only env files + // values.yaml, values-.yaml + // case with extra name + // values.yaml, values-.yaml, values--.yaml + // We are finding in reverse because if no env values are found we assume + // no env + files := []string{} + pattern := fmt.Sprintf("%s/values-*-%s.yaml", dir, env) + envValues, err := filepath.Glob(pattern) + if err != nil { + return []string{}, err + } + // specific named value files exists + if len(envValues) > 0 { + for _, envval := range envValues { + files = append(files, filepath.Base(envval)) + } + if core.FileExists(filepath.Join(dir, "values.yaml")) { + files = append(files, "values.yaml") + } + return files, nil + } + + if core.FileExists(filepath.Join(dir, fmt.Sprintf("values-%s.yaml", env))) { + files = append(files, fmt.Sprintf("values-%s.yaml", env)) + } + // no env files are found, returning a chart without value files + if len(files) == 0 { + return files, nil + } + if core.FileExists(filepath.Join(dir, "values.yaml")) { + files = append(files, "values.yaml") + } + return files, nil +} diff --git a/internal/kubernetes/kubernetes_test.go b/internal/kubernetes/kubernetes_test.go new file mode 100644 index 00000000..02707246 --- /dev/null +++ b/internal/kubernetes/kubernetes_test.go @@ -0,0 +1,153 @@ +package kubernetes + +import ( + _ "embed" + "testing" + + "github.com/magefile/mage/sh" + "github.com/stretchr/testify/assert" +) + +func TestFindHelmCharts(t *testing.T) { + tests := []struct { + name string // description of this test case + // Named input parameters for target function. + workdir string + want []HelmChart + wantErr bool + }{ + { + name: "Should find all relevant charts with envs", + workdir: "testdata/repo", + want: []HelmChart{ + { + path: "infrastructure/kubernetes/helm/charts/charta", + env: "production", + valueFiles: []string{"values.yaml", "values-production.yaml"}, + }, + { + path: "infrastructure/kubernetes/helm/charts/charta", + env: "staging", + valueFiles: []string{"values.yaml", "values-staging.yaml"}, + }, + { + path: "infrastructure/kubernetes/helm/charts/chartb", + env: "dev", + valueFiles: []string{"values.yaml", "values-this-dev.yaml"}, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Chdir(tt.workdir) + got, gotErr := FindHelmCharts(".") + assert.NoError(t, gotErr) + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestRenderHelmChart(t *testing.T) { + tests := []struct { + name string + chart HelmChart + }{ + { + name: "simple chart should render", + chart: HelmChart{ + env: "staging", + path: "testdata/repo/infrastructure/kubernetes/helm/charts/charta", + valueFiles: []string{"values.yaml", "values-staging.yaml"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir, cleanup, err := RenderTemplates(tt.chart, false) + assert.NoError(t, err) + assert.NoError(t, sh.RunV("git", "--no-pager", "diff", "--no-index", dir, "testdata/ref-data/chart-a-staging/")) + t.Cleanup(cleanup) + }) + } +} + +func TestKubeConform(t *testing.T) { + tests := []struct { + name string + chart HelmChart + wantErr bool + }{ + { + name: "KubeConform should pass", + chart: HelmChart{ + env: "staging", + path: "testdata/repo/infrastructure/kubernetes/helm/charts/charta", + valueFiles: []string{"values.yaml", "values-staging.yaml"}, + }, + wantErr: false, + }, + { + name: "KubeConform should fail", + chart: HelmChart{ + env: "production", + path: "testdata/repo/infrastructure/kubernetes/helm/charts/charta", + valueFiles: []string{"values.yaml", "values-production-fail.yaml"}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateWithKubeConform(tt.chart) + if tt.wantErr { + assert.Error(t, err, tt.name) + } else { + assert.NoError(t, err, tt.name) + } + }) + } +} + +func TestKubeScore(t *testing.T) { + tests := []struct { + name string + chart HelmChart + wantErr bool + }{ + { + name: "KubeScore should pass", + chart: HelmChart{ + env: "staging", + path: "testdata/repo/infrastructure/kubernetes/helm/charts/chartc", + valueFiles: []string{"values.yaml"}, + }, + wantErr: false, + }, + { + name: "KubeScore should fail", + chart: HelmChart{ + env: "production", + path: "testdata/repo/infrastructure/kubernetes/helm/charts/chartc", + valueFiles: []string{"values.yaml", "inject-fail.yaml"}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateWithKubeScore(tt.chart) + if tt.wantErr { + if assert.Error(t, err, tt.name) { //&& tt.errMsg != "" { + // assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + assert.NoError(t, err, tt.name) + } + }) + } +} diff --git a/internal/kubernetes/testdata/ref-data/chart-a-staging/charta/templates/deployment.yaml b/internal/kubernetes/testdata/ref-data/chart-a-staging/charta/templates/deployment.yaml new file mode 100644 index 00000000..050057aa --- /dev/null +++ b/internal/kubernetes/testdata/ref-data/chart-a-staging/charta/templates/deployment.yaml @@ -0,0 +1,44 @@ +--- +# Source: charta/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: release-name-charta + labels: + helm.sh/chart: charta-0.1.0 + app.kubernetes.io/name: charta + app.kubernetes.io/instance: release-name + app.kubernetes.io/version: "1.16.0" + app.kubernetes.io/managed-by: Helm +spec: + replicas: 4 + selector: + matchLabels: + app.kubernetes.io/name: charta + app.kubernetes.io/instance: release-name + template: + metadata: + labels: + helm.sh/chart: charta-0.1.0 + app.kubernetes.io/name: charta + app.kubernetes.io/instance: release-name + app.kubernetes.io/version: "1.16.0" + app.kubernetes.io/managed-by: Helm + spec: + serviceAccountName: default + containers: + - name: charta + image: "nginx:1.16.0" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http diff --git a/internal/kubernetes/testdata/ref-data/chart-a-staging/charta/templates/service.yaml b/internal/kubernetes/testdata/ref-data/chart-a-staging/charta/templates/service.yaml new file mode 100644 index 00000000..7e19d82c --- /dev/null +++ b/internal/kubernetes/testdata/ref-data/chart-a-staging/charta/templates/service.yaml @@ -0,0 +1,22 @@ +--- +# Source: charta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: release-name-charta + labels: + helm.sh/chart: charta-0.1.0 + app.kubernetes.io/name: charta + app.kubernetes.io/instance: release-name + app.kubernetes.io/version: "1.16.0" + app.kubernetes.io/managed-by: Helm +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: charta + app.kubernetes.io/instance: release-name diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/.helmignore b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/Chart.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/Chart.yaml new file mode 100644 index 00000000..bb84210e --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: charta +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/NOTES.txt b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/NOTES.txt new file mode 100644 index 00000000..7bfaccac --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "charta.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "charta.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "charta.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "charta.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/_helpers.tpl b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/_helpers.tpl new file mode 100644 index 00000000..ee891e23 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "charta.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "charta.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 }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "charta.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "charta.labels" -}} +helm.sh/chart: {{ include "charta.chart" . }} +{{ include "charta.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "charta.selectorLabels" -}} +app.kubernetes.io/name: {{ include "charta.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "charta.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "charta.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/deployment.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/deployment.yaml new file mode 100644 index 00000000..ae8c698b --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: {{ .Values.deploymentKindName | default "Deployment"}} +metadata: + name: {{ include "charta.fullname" . }} + labels: + {{- include "charta.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "charta.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "charta.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "charta.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/hpa.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/hpa.yaml new file mode 100644 index 00000000..c700f4fb --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "charta.fullname" . }} + labels: + {{- include "charta.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "charta.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/ingress.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/ingress.yaml new file mode 100644 index 00000000..af511df8 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "charta.fullname" . }} + labels: + {{- include "charta.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "charta.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/service.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/service.yaml new file mode 100644 index 00000000..111b0b6c --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "charta.fullname" . }} + labels: + {{- include "charta.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "charta.selectorLabels" . | nindent 4 }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/serviceaccount.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/serviceaccount.yaml new file mode 100644 index 00000000..2c965386 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "charta.serviceAccountName" . }} + labels: + {{- include "charta.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-production-fail.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-production-fail.yaml new file mode 100644 index 00000000..3f2b6563 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-production-fail.yaml @@ -0,0 +1 @@ +deploymentKindName: Car diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-production.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-production.yaml new file mode 100644 index 00000000..62324447 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-production.yaml @@ -0,0 +1 @@ +g: c diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-staging.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-staging.yaml new file mode 100644 index 00000000..181e8e61 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values-staging.yaml @@ -0,0 +1 @@ +replicaCount: 4 diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values.yaml new file mode 100644 index 00000000..2bf17253 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta/values.yaml @@ -0,0 +1,123 @@ +# Default values for charta. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 2 + +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +image: + repository: nginx + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +imagePullSecrets: [] +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: false + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ +service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 80 + +# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +livenessProbe: + httpGet: + path: / + port: http +readinessProbe: + httpGet: + path: / + port: http + +# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/.helmignore b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/Chart.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/Chart.yaml new file mode 100644 index 00000000..afda0965 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: chartb +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/NOTES.txt b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/NOTES.txt new file mode 100644 index 00000000..46bb8335 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "chartb.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "chartb.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "chartb.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "chartb.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/_helpers.tpl b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/_helpers.tpl new file mode 100644 index 00000000..7abb6041 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "chartb.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "chartb.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 }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "chartb.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "chartb.labels" -}} +helm.sh/chart: {{ include "chartb.chart" . }} +{{ include "chartb.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "chartb.selectorLabels" -}} +app.kubernetes.io/name: {{ include "chartb.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "chartb.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "chartb.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/deployment.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/deployment.yaml new file mode 100644 index 00000000..c707d68e --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "chartb.fullname" . }} + labels: + {{- include "chartb.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "chartb.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "chartb.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "chartb.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/hpa.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/hpa.yaml new file mode 100644 index 00000000..0fd52376 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "chartb.fullname" . }} + labels: + {{- include "chartb.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "chartb.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/ingress.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/ingress.yaml new file mode 100644 index 00000000..b1a3f206 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "chartb.fullname" . }} + labels: + {{- include "chartb.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "chartb.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/service.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/service.yaml new file mode 100644 index 00000000..c32a6fe7 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "chartb.fullname" . }} + labels: + {{- include "chartb.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "chartb.selectorLabels" . | nindent 4 }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/serviceaccount.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/serviceaccount.yaml new file mode 100644 index 00000000..d8bd871e --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "chartb.serviceAccountName" . }} + labels: + {{- include "chartb.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values-dev.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values-dev.yaml new file mode 100644 index 00000000..8af2375c --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values-dev.yaml @@ -0,0 +1 @@ +c: d diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values-this-dev.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values-this-dev.yaml new file mode 100644 index 00000000..26a745dd --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values-this-dev.yaml @@ -0,0 +1 @@ +a: b diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values.yaml new file mode 100644 index 00000000..80cae49b --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartb/values.yaml @@ -0,0 +1,123 @@ +# Default values for chartb. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 1 + +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +image: + repository: nginx + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +imagePullSecrets: [] +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ +service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 80 + +# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +livenessProbe: + httpGet: + path: / + port: http +readinessProbe: + httpGet: + path: / + port: http + +# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/.helmignore b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/Chart.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/Chart.yaml new file mode 100644 index 00000000..94a044b3 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: chartc +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/inject-fail.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/inject-fail.yaml new file mode 100644 index 00000000..4fffa848 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/inject-fail.yaml @@ -0,0 +1 @@ +kind: Service diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/templates/service.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/templates/service.yaml new file mode 100644 index 00000000..11bf5b50 --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/templates/service.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: {{ .Values.kind | default "ConfigMap"}} +metadata: + name: kube-score + labels: + origin: kube-score +data: + hi: hi diff --git a/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/values.yaml b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/values.yaml new file mode 100644 index 00000000..7ae9219f --- /dev/null +++ b/internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc/values.yaml @@ -0,0 +1,123 @@ +# Default values for chartc. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 1 + +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +image: + repository: nginx + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +imagePullSecrets: [] +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ +service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 80 + +# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +livenessProbe: + httpGet: + path: / + port: http +readinessProbe: + httpGet: + path: / + port: http + +# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/internal/targets/kubernetes/kubernetes.go b/internal/targets/kubernetes/kubernetes.go new file mode 100644 index 00000000..bf393e8e --- /dev/null +++ b/internal/targets/kubernetes/kubernetes.go @@ -0,0 +1,61 @@ +package terraform + +import ( + "context" + _ "embed" + + "github.com/coopnorge/mage/internal/kubernetes" + "github.com/magefile/mage/mg" +) + +// Validate runs kubeconform, kube-score and render templates +func Validate(ctx context.Context) error { + charts, err := kubernetes.FindHelmCharts(".") + if err != nil { + return err + } + + var renders []any + var kubeconforms []any + var kubescores []any + for _, chart := range charts { + renders = append(renders, mg.F(render, chart)) + kubeconforms = append(kubeconforms, mg.F(kubernetes.ValidateWithKubeConform, chart)) + kubescores = append(kubescores, mg.F(kubernetes.ValidateWithKubeScore, chart)) + } + + mg.CtxDeps(ctx, renders...) + mg.CtxDeps(ctx, append(kubeconforms, kubescores)...) + return nil +} + +func render(_ context.Context, chart kubernetes.HelmChart) error { + _, cleanup, err := kubernetes.RenderTemplates(chart, false) + defer cleanup() + return err +} + +// Diff runs a diff for all the helm charts compared to the manin brdnch +func Diff(ctx context.Context) error { + charts, err := kubernetes.FindHelmCharts(".") + if err != nil { + return err + } + var diffs []any + for _, chart := range charts { + diffs = append(diffs, mg.F(kubernetes.DiffTemplates, chart)) + } + + mg.SerialCtxDeps(ctx, diffs...) + return nil +} + +// List lists the found helm charts +func List(ctx context.Context) error { + charts, err := kubernetes.FindHelmCharts(".") + if err != nil { + return err + } + kubernetes.ListHelmCharts(charts) + return nil +} diff --git a/targets/goapp/kubernetes.go b/targets/goapp/kubernetes.go new file mode 100644 index 00000000..89cabc66 --- /dev/null +++ b/targets/goapp/kubernetes.go @@ -0,0 +1,29 @@ +package goapp + +import ( + "context" + + kubernetesTargets "github.com/coopnorge/mage/internal/targets/kubernetes" + "github.com/magefile/mage/mg" +) + +// K8s is the magefile namespace to group Kubernetes commands +type K8s mg.Namespace + +// Validate validates all helm charts +func (K8s) Validate(ctx context.Context) error { + mg.CtxDeps(ctx, mg.F(kubernetesTargets.Validate)) + return nil +} + +// Diff returns the string true or false depending on the fact that +// the current branch contains changes compared to the main branch. +func (K8s) Diff(ctx context.Context) error { + mg.CtxDeps(ctx, kubernetesTargets.Diff) + return nil +} + +func (K8s) List(ctx context.Context) error { + mg.CtxDeps(ctx, kubernetesTargets.List) + return nil +} From 2d6fc76825c6938773dd0d99ea9079f055243904 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 14:50:06 +0100 Subject: [PATCH 02/28] add function/struct to setup routing to buf/std(in/out) Signed-off-by: Atze de Vries --- internal/devtool/devtool.go | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/devtool/devtool.go b/internal/devtool/devtool.go index 672f9b02..03df0cfa 100644 --- a/internal/devtool/devtool.go +++ b/internal/devtool/devtool.go @@ -1,8 +1,10 @@ package devtool import ( + "bytes" _ "embed" "fmt" + "io" "os" "os/exec" "runtime" @@ -11,6 +13,7 @@ import ( "strings" "github.com/coopnorge/mage/internal/core" + "github.com/magefile/mage/mg" "github.com/magefile/mage/sh" ) @@ -160,3 +163,41 @@ func getTool(dockerfile string, tool string) (*dockerDevTool, error) { } return &devtool, fmt.Errorf("unable to find devtool %s", tool) } + +// devtoolOutput represents the output of a devtool to stdout and stderr +type devtoolOutput struct { + // StdOut is the stdout stream to the console + StdOut io.Writer + // StdErr is the stderr stream to the console + StdErr io.Writer + // BufOut is the stdout to a buffer + BufOut *bytes.Buffer + // Buferr is the stdout to a buffer + BufErr *bytes.Buffer +} + +// setupStdOutErr setups up multi iowriters and resturns one for +// stdout and one for stderr. The multi writer for std out will +// write to a buffer and if mage is run verbose will output to stdout. If non +// verbose, stdout will be redirected to io.Discard +// Stderr will always outoput to stderr and to a buffer. +func setupStdOutErr(forceStdOut bool) devtoolOutput { + bufOut := &bytes.Buffer{} + bufErr := &bytes.Buffer{} + var stdOutDevice io.Writer + if mg.Verbose() || forceStdOut { + stdOutDevice = os.Stdout + } else { + stdOutDevice = io.Discard + } + + stdout := io.MultiWriter(bufOut, stdOutDevice) + stderr := io.MultiWriter(bufErr, os.Stderr) + + return devtoolOutput{ + StdOut: stdout, + StdErr: stderr, + BufOut: bufOut, + BufErr: bufErr, + } +} From 97cb2b2a5c34e21d3467c1ecb92c8b51df571c07 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 14:50:16 +0100 Subject: [PATCH 03/28] enable dyff devtool Signed-off-by: Atze de Vries --- internal/devtool/dyff.go | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/internal/devtool/dyff.go b/internal/devtool/dyff.go index 21d97249..934fb16d 100644 --- a/internal/devtool/dyff.go +++ b/internal/devtool/dyff.go @@ -17,7 +17,7 @@ import ( // Dyff holds the devtool for policy-bot type Dyff struct{} -// DyffConfigCheckDocker the content of policy-bot.Dockerfile +// DyffDocker the content of dyff.Dockerfile // //go:embed policy-bot/policy-bot.Dockerfile var DyffDocker string @@ -25,19 +25,19 @@ var DyffDocker string const dyffVersion = "v1.10.5" // Run runs the policy-bot devtool -func (dyff Dyff) Run(env map[string]string, args ...string) error { +func (dyff Dyff) Run(env map[string]string, args ...string) (string, string, error) { if !isCommandAvailable("dyff") { fmt.Println("Dyff binary not found. Use 'brew install dyff' to install. Falling back to running the docker version") - return dyff.runInDocker(env, args...) + return "", "", dyff.runInDocker(env, args...) } err := dyff.versionOK() if err != nil { - fmt.Printf("Go does not meet version constraints. Falling back to docker verion\n error: %s\n", err) - return dyff.runInDocker(env, args...) + fmt.Printf("Dyff does not meet version constraints. Falling back to docker verion\n error: %s\n", err) + return "", "", dyff.runInDocker(env, args...) } - fmt.Println("Using native go") + fmt.Println("Using native dyff") return dyff.runNative(env, args...) // for now only support running in Docker } @@ -68,22 +68,11 @@ func (dyff Dyff) versionOK() error { return nil } -func (dyff Dyff) runNative(env map[string]string, args ...string) error { - // check if env var with output filename exist then also write content to - // file name - out, err := sh.OutputWith(env, "dyff", args...) - if err != nil { - return err - } - fmt.Println(out) - filename, found := env["OUTPUT_FILE"] - if found { - err = os.WriteFile(filename, []byte(out), 0x644) - } - if err != nil { - return err - } - return err +func (dyff Dyff) runNative(env map[string]string, args ...string) (string, string, error) { + outs := setupStdOutErr(true) + _, err := sh.Exec(env, outs.StdOut, outs.StdErr, "dyff", args...) + + return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err } func (dyff Dyff) runInDocker(env map[string]string, args ...string) error { From bf1334a353a20a1052b763894dbf6bfd6ff19321 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 14:50:23 +0100 Subject: [PATCH 04/28] enable helm devtool Signed-off-by: Atze de Vries --- internal/devtool/helm.go | 45 ++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/internal/devtool/helm.go b/internal/devtool/helm.go index 18db1e41..4d25e569 100644 --- a/internal/devtool/helm.go +++ b/internal/devtool/helm.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" - "github.com/coopnorge/mage/internal/core" "github.com/hashicorp/go-version" "github.com/magefile/mage/sh" ) @@ -14,8 +13,12 @@ import ( // Helm holds the devtool for helm type Helm struct{} -// Run runs the helm devtool -func (helm Helm) Run(env map[string]string, args ...string) error { +// Run runs the helm devtool. It returns stdout, stderr and error. If verbose +// is enable on mage it will also stream stdout to the console +func (helm Helm) Run(env map[string]string, args ...string) (string, string, error) { + if val, found := os.LookupEnv("HELM_IN_DOCKER"); found && val == "1" { + return helm.runInDocker(env, args...) + } if !isCommandAvailable("helm") { fmt.Println("helm binary not found. Use 'brew install helm' to install. Falling back to running the docker version") return helm.runInDocker(env, args...) @@ -61,31 +64,24 @@ func (helm Helm) versionOK() error { return nil } -func (helm Helm) runNative(env map[string]string, args ...string) error { - if core.Verbose() { - return sh.RunWith(env, "helm", helm.addDefautsArgs(args...)...) - } - out, err := sh.OutputWith(env, "helm", helm.addDefautsArgs(args...)...) - if err != nil { - fmt.Println(out) - return err - } - return err +func (helm Helm) runNative(env map[string]string, args ...string) (string, string, error) { + outs := setupStdOutErr(false) + _, err := sh.Exec(env, outs.StdOut, outs.StdErr, "helm", helm.addDefautsArgs(args...)...) + + return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err } -func (helm Helm) runInDocker(env map[string]string, args ...string) error { +func (helm Helm) runInDocker(env map[string]string, args ...string) (string, string, error) { devtool, err := getTool(ToolsDockerfile, "helm") if err != nil { - return err + return "", "", err } path, err := os.Getwd() if err != nil { - return err + return "", "", err } - // helm --strict -verbose -schema-location "https://raw.githubusercontent.com/coopnorge/kubernetes-schemas/main/pallets/{{ .ResourceKind }}{{ .KindSuffix }}.json" .pallet/gitconfig.yaml - dockerArgs := []string{ "--volume", fmt.Sprintf("%s:/app", path), // Mount the source code "--workdir", "/app", // set workdir to where we want to run @@ -106,15 +102,10 @@ func (helm Helm) runInDocker(env map[string]string, args ...string) error { runArgs = append(runArgs, devtool.image) runArgs = append(runArgs, helm.addDefautsArgs(args...)...) - if core.Verbose() { - return sh.RunWith(env, "docker", runArgs...) - } - out, err := sh.OutputWith(env, "docker", runArgs...) - if err != nil { - fmt.Println(out) - return err - } - return err + outs := setupStdOutErr(false) + _, err = sh.Exec(env, outs.StdOut, outs.StdErr, "docker", runArgs...) + + return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err } func (helm Helm) addDefautsArgs(args ...string) []string { From ef13eb9f30c62638ceb677cbfad5eca98189b947 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 14:50:34 +0100 Subject: [PATCH 05/28] add worktree func to git Signed-off-by: Atze de Vries --- internal/git/git.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index 40239f61..a7b0008d 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -4,9 +4,9 @@ import ( "fmt" "net/url" "os" - "path/filepath" "strings" + "github.com/coopnorge/mage/internal/core" "github.com/magefile/mage/sh" ) @@ -100,20 +100,20 @@ func CurrentBranch() (string, error) { func Worktree(branch string) (string, func(), error) { // Define target location (e.g., in a 'worktrees' directory outside the current repo). // Placing worktrees outside prevents recursive issues with tools scanning the main repo. - targetDir := filepath.Join("worktrees", branch) - - // Execute 'git worktree add ' using mage/sh. - // sh.Run prints output to stdout/stderr and returns an error if the command fails. - err := sh.Run("git", "worktree", "add", targetDir, branch) + targetDir, cleanupDir, err := core.MkdirTemp() + if err != nil { + return targetDir, cleanupDir, err + } + // Execute 'git worktree add ' + err = sh.Run("git", "worktree", "add", targetDir, branch) if err != nil { return "", nil, fmt.Errorf("failed to create worktree for branch %s: %w", branch, err) } - // 5. Define the cleanup function. // We use git worktree remove which cleans up the admin files and the directory. cleanup := func() { - // We use sh.Run so the cleanup output is visible in mage -v _ = sh.Run("git", "worktree", "remove", targetDir) + cleanupDir() } return targetDir, cleanup, nil From 0dd98e738030111bca78fa0b349d9e7f0fef2c6e Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 14:50:55 +0100 Subject: [PATCH 06/28] update kubernetes targets Signed-off-by: Atze de Vries --- internal/kubernetes/kubernetes.go | 83 ++++++++++++++++++----- internal/targets/kubernetes/kubernetes.go | 42 ++++++++---- targets/goapp/kubernetes.go | 2 +- 3 files changed, 94 insertions(+), 33 deletions(-) diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index e4894bcb..1602a21e 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -40,6 +40,19 @@ func isHelmChart(p string, d fs.DirEntry) bool { // files are not there. This is used when rendering a template which is in // unkown state func RenderTemplates(chart HelmChart, try bool) (string, func(), error) { + return renderTemplatesToOutput(chart, false, try) +} + +// RenderTemplatesSingleFile renders the templates of a specific helm chart. It will +// return a function for cleanup +// When second argument is set to true it will try to render even if some +// files are not there. This is used when rendering a template which is in +// unkown state +func RenderTemplatesSingleFile(chart HelmChart, try bool) (string, func(), error) { + return renderTemplatesToOutput(chart, true, try) +} + +func renderTemplatesToOutput(chart HelmChart, singleFile bool, try bool) (string, func(), error) { outdir, cleanup, err := core.MkdirTemp() if err != nil { return outdir, nil, err @@ -67,11 +80,22 @@ func RenderTemplates(chart HelmChart, try bool) (string, func(), error) { args := []string{} args = append(args, "template") args = append(args, chart.path) - args = append(args, "--output-dir") - args = append(args, outdir) args = append(args, valueFilesFlags...) + if !singleFile { + args = append(args, "--output-dir") + args = append(args, outdir) + } - return outdir, cleanup, helm.Run(nil, args...) + _, _, err = helm.Run(nil, "dep", "up", chart.path) + if err != nil { + return outdir, cleanup, err + } + out, _, err := helm.Run(nil, args...) + if singleFile { + outdir = filepath.Join(outdir, "templates.yaml") + err = os.WriteFile(outdir, []byte(out), 0o644) + } + return outdir, cleanup, err } func DiffTemplates(chart HelmChart) error { @@ -81,38 +105,63 @@ func DiffTemplates(chart HelmChart) error { return err } - branchTemplates, branchDirCleanup, err := RenderTemplates(chart, false) - defer branchDirCleanup() + branchTemplates, branchTemplatesCleanup, err := RenderTemplatesSingleFile(chart, false) if err != nil { return err } + defer branchTemplatesCleanup() mainWorktree, worktreeCleanup, err := git.Worktree("main") - defer worktreeCleanup() if err != nil { return err } + defer worktreeCleanup() // create a chart object for the chart in the main branch mainChart := HelmChart{ path: filepath.Join(mainWorktree, chart.path), env: chart.env, valueFiles: chart.valueFiles, } - mainTemplates, mainBranchCleanup, err := RenderTemplates(mainChart, true) - defer mainBranchCleanup() + mainTemplates, mainTemplatesCleanup, err := RenderTemplatesSingleFile(mainChart, true) if err != nil { return err } + defer mainTemplatesCleanup() - args := []string{"between"} - env := make(map[string]string) + args := []string{ + "--color", "on", + "--truecolor", "on", + "between", + } // simply assumming that if CI is set, we are in github actions - if _, found := os.LookupEnv("CI"); found { + _, inCI := os.LookupEnv("CI") + if inCI { args = append(args, "--output", "github") - env["OUTPUT_FILE"] = fmt.Sprintf("%s-%s-%s.diff", filepath.Base(chart.path), chart.env, currentBranch) + // env["OUTPUT_FILE"] = fmt.Sprintf("%s-%s-%s.diff", filepath.Base(chart.path), chart.env, currentBranch) + } + if !core.FileExists(mainTemplates) { + return fmt.Errorf("%s does not exist", mainTemplates) } args = append(args, mainTemplates, branchTemplates) - return dyff.Run(env, args...) + + fmt.Printf("Diff compared to main of chart: %s env: %s\n", filepath.Base(chart.path), chart.env) + out, _, err := dyff.Run(nil, args...) + + if inCI { + path := filepath.Join("var", "kubernetes", "diff", fmt.Sprintf("%s-%s-%s.diff", currentBranch, filepath.Base(chart.path), chart.env)) + err := os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + return err + } + if out == "" { + out = fmt.Sprintf("# no diff for %s %s", filepath.Base(chart.path), chart.env) + } + err = os.WriteFile(path, []byte(out), 0o644) + if err != nil { + return err + } + } + return err } // FindHelmCharts will search through the base directory to find the @@ -161,10 +210,10 @@ func FindHelmCharts(base string) ([]HelmChart, error) { func ListHelmCharts(charts []HelmChart) { for _, chart := range charts { - fmt.Sprintf("---\n") - fmt.Sprintf("path: %s\n", chart.path) - fmt.Sprintf("environment: %s\n", chart.env) - fmt.Sprintf("valueFiles: [%s]\n", strings.Join(chart.valueFiles, "\", \"")) + fmt.Printf("---\n") + fmt.Printf("path: %s\n", chart.path) + fmt.Printf("environment: %s\n", chart.env) + fmt.Printf("valueFiles: [\"%s\"]\n", strings.Join(chart.valueFiles, "\", \"")) } } diff --git a/internal/targets/kubernetes/kubernetes.go b/internal/targets/kubernetes/kubernetes.go index bf393e8e..72418d4f 100644 --- a/internal/targets/kubernetes/kubernetes.go +++ b/internal/targets/kubernetes/kubernetes.go @@ -5,7 +5,6 @@ import ( _ "embed" "github.com/coopnorge/mage/internal/kubernetes" - "github.com/magefile/mage/mg" ) // Validate runs kubeconform, kube-score and render templates @@ -14,18 +13,23 @@ func Validate(ctx context.Context) error { if err != nil { return err } - - var renders []any - var kubeconforms []any - var kubescores []any + // we are not using mg.(Serial)CtxDeps here because the input of the + // functions are not strings, int, bools or time duration. + // Ref: https://github.com/magefile/mage/blob/master/mg/fn.go#L174-L192 for _, chart := range charts { - renders = append(renders, mg.F(render, chart)) - kubeconforms = append(kubeconforms, mg.F(kubernetes.ValidateWithKubeConform, chart)) - kubescores = append(kubescores, mg.F(kubernetes.ValidateWithKubeScore, chart)) + err = render(ctx, chart) + if err != nil { + return err + } + err = kubeconform(ctx, chart) + if err != nil { + return err + } + err = kubescore(ctx, chart) + if err != nil { + return err + } } - - mg.CtxDeps(ctx, renders...) - mg.CtxDeps(ctx, append(kubeconforms, kubescores)...) return nil } @@ -35,18 +39,26 @@ func render(_ context.Context, chart kubernetes.HelmChart) error { return err } +func kubeconform(_ context.Context, chart kubernetes.HelmChart) error { + return kubernetes.ValidateWithKubeConform(chart) +} + +func kubescore(_ context.Context, chart kubernetes.HelmChart) error { + return kubernetes.ValidateWithKubeScore(chart) +} + // Diff runs a diff for all the helm charts compared to the manin brdnch func Diff(ctx context.Context) error { charts, err := kubernetes.FindHelmCharts(".") if err != nil { return err } - var diffs []any for _, chart := range charts { - diffs = append(diffs, mg.F(kubernetes.DiffTemplates, chart)) + err = kubernetes.DiffTemplates(chart) + if err != nil { + return err + } } - - mg.SerialCtxDeps(ctx, diffs...) return nil } diff --git a/targets/goapp/kubernetes.go b/targets/goapp/kubernetes.go index 89cabc66..e7d37ce8 100644 --- a/targets/goapp/kubernetes.go +++ b/targets/goapp/kubernetes.go @@ -12,7 +12,7 @@ type K8s mg.Namespace // Validate validates all helm charts func (K8s) Validate(ctx context.Context) error { - mg.CtxDeps(ctx, mg.F(kubernetesTargets.Validate)) + mg.CtxDeps(ctx, kubernetesTargets.Validate) return nil } From 7b2a138e900fc74f71dad041c6f5d52802bc4f16 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 15:05:30 +0100 Subject: [PATCH 07/28] sync cmd lib with mage pr Signed-off-by: Atze de Vries --- internal/core/cmd.go | 132 ++++++++++++++++++++++++++++---------- internal/core/cmd_test.go | 8 +-- 2 files changed, 103 insertions(+), 37 deletions(-) diff --git a/internal/core/cmd.go b/internal/core/cmd.go index bcd283fc..1f7dd50e 100644 --- a/internal/core/cmd.go +++ b/internal/core/cmd.go @@ -1,12 +1,5 @@ package core -// Copyright 2026 Coop Norge SA -// Copyright 2017 Nate Finch (Original Mage Authors) -// -// Licensed under the Apache License, Version 2.0; -// this file contains modifications from the original source. -// Original source: https://github.com/magefile/mage/blob/master/sh/cmd.go - import ( "bytes" "fmt" @@ -19,80 +12,155 @@ import ( "github.com/magefile/mage/mg" ) -// This is moslty just copied from the mage/sh library. Added because -// we need to introduce running commands in a different directory +// RunCmd returns a function that will call Run with the given command. This is +// useful for creating command aliases to make your scripts easier to read, like +// this: +// +// // in a helper file somewhere +// var g0 = sh.RunCmd("go") // go is a keyword :( +// +// // somewhere in your main code +// if err := g0("install", "github.com/gohugo/hugo"); err != nil { +// return err +// } +// +// Args passed to command get baked in as args to the command when you run it. +// Any args passed in when you run the returned function will be appended to the +// original args. For example, this is equivalent to the above: +// +// var goInstall = sh.RunCmd("go", "install") goInstall("github.com/gohugo/hugo") +// +// RunCmd uses Exec underneath, so see those docs for more details. +func RunCmd(cmd string, args ...string) func(args ...string) error { + return func(args2 ...string) error { + return Run(cmd, append(args, args2...)...) + } +} + +// RunAtCmd returns a function that will call Run with the given command at a given path. +// This is useful for creating command aliases to make your scripts easier to read, like +// this: +// +// // in a helper file somewhere +// var g0 = sh.RunAtCmd("go") // go is a keyword :( +// +// // somewhere in your main code +// if err := g0("/tmp", "install", "github.com/gohugo/hugo"); err != nil { +// return err +// } +// +// Args passed to command get baked in as args to the command when you run it. +// Any args passed in when you run the returned function will be appended to the +// original args. For example, this is equivalent to the above: +// +// var goInstall = sh.RunAtCmd("go", "install") goInstall("tmp", "github.com/gohugo/hugo") +// +// RunAtCmd uses Exec underneath, so see those docs for more details. +func RunAtCmd(cmd string, args ...string) func(pwd string, args ...string) error { + return func(pwd string, args2 ...string) error { + return RunAt(pwd, cmd, append(args, args2...)...) + } +} + +// OutCmd is like RunCmd except the command returns the output of the +// command. +func OutCmd(cmd string, args ...string) func(args ...string) (string, error) { + return func(args2 ...string) (string, error) { + return Output(cmd, append(args, args2...)...) + } +} + +// OutAtCmd is like RunAtCmd except the command returns the output of the +// command. +func OutAtCmd(cmd string, args ...string) func(pwd string, args ...string) (string, error) { + return func(pwd string, args2 ...string) (string, error) { + return OutputAt(pwd, cmd, append(args, args2...)...) + } +} // Run is like RunWith, but doesn't specify any environment variables. func Run(cmd string, args ...string) error { - return RunAtWith(nil, "", cmd, args...) + return RunWith(nil, cmd, args...) } // RunAt is like RunAtWith, but doesn't specify any environment variables. -func RunAt(pwd string, cmd string, args ...string) error { +func RunAt(pwd, cmd string, args ...string) error { return RunAtWith(nil, pwd, cmd, args...) } -// RunV is like RunAtV, but doesn't specify any environment variables. +// RunV is like Run, but always sends the command's stdout to os.Stdout. func RunV(cmd string, args ...string) error { return RunAtV("", cmd, args...) } // RunAtV is like RunAt, but always sends the command's stdout to os.Stdout. func RunAtV(pwd string, cmd string, args ...string) error { - _, err := Exec(nil, os.Stdout, os.Stderr, pwd, cmd, args...) + _, err := ExecAt(nil, os.Stdout, os.Stderr, pwd, cmd, args...) return err } -// RunAtWith runs the given command at a specific path, directing stderr to -// this program's stderr and printing stdout to stdout if mage was run with -v. -// It adds adds env to the environment variables for the command being run. Environment +// RunWith runs the given command, directing stderr to this program's stderr and +// printing stdout to stdout if mage was run with -v. It adds adds env to the +// environment variables for the command being run. Environment variables should +// be in the format name=value. +func RunWith(env map[string]string, cmd string, args ...string) error { + return RunAtWith(env, "", cmd, args...) +} + +// RunAtWith runs the given command at a certain path, directing stderr to this +// program's stderr and printing stdout to stdout if mage was run with -v. It adds +// adds env to the environment variables for the command being run. Environment // variables should be in the format name=value. func RunAtWith(env map[string]string, pwd string, cmd string, args ...string) error { var output io.Writer if mg.Verbose() { output = os.Stdout } - _, err := Exec(env, output, os.Stderr, pwd, cmd, args...) + _, err := ExecAt(env, output, os.Stderr, pwd, cmd, args...) return err } // RunWithV is like RunWith, but always sends the command's stdout to os.Stdout. func RunWithV(env map[string]string, cmd string, args ...string) error { - _, err := Exec(env, os.Stdout, os.Stderr, "", cmd, args...) - return err + return RunAtWithV(env, "", cmd, args...) } // RunAtWithV is like RunAtWith, but always sends the command's stdout to os.Stdout. func RunAtWithV(env map[string]string, pwd string, cmd string, args ...string) error { - _, err := Exec(env, os.Stdout, os.Stderr, pwd, cmd, args...) + _, err := ExecAt(env, os.Stdout, os.Stderr, pwd, cmd, args...) return err } -// Output is like OuttAt but run at the current working directry. +// Output runs the command and returns the text from stdout. func Output(cmd string, args ...string) (string, error) { return OutputAt("", cmd, args...) } -// OutputAt runs the command and returns the text from stdout. +// OutputAt runs the command at a certain path and returns the text from stdout. func OutputAt(pwd string, cmd string, args ...string) (string, error) { buf := &bytes.Buffer{} - _, err := Exec(nil, buf, os.Stderr, pwd, cmd, args...) + _, err := ExecAt(nil, buf, os.Stderr, pwd, cmd, args...) return strings.TrimSuffix(buf.String(), "\n"), err } -// OutputWith is like OutputAtWith but run at the current working directry. +// OutputWith is like RunWith, but returns what is written to stdout. func OutputWith(env map[string]string, cmd string, args ...string) (string, error) { return OutputAtWith(env, "", cmd, args...) } -// OutputAtWith is like RunWith, but returns what is written to stdout. -func OutputAtWith(env map[string]string, pwd, cmd string, args ...string) (string, error) { +// OutputAt With is like RunAtWith, but returns what is written to stdout. +func OutputAtWith(env map[string]string, pwd string, cmd string, args ...string) (string, error) { buf := &bytes.Buffer{} - _, err := Exec(env, buf, os.Stderr, pwd, cmd, args...) + _, err := ExecAt(env, buf, os.Stderr, pwd, cmd, args...) return strings.TrimSuffix(buf.String(), "\n"), err } -// Exec executes the command, piping its stdout and stderr to the given +// Exec is like execAt but always runs in the current workdir. +func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, err error) { + return ExecAt(env, stdout, stderr, "", cmd, args...) +} + +// ExecAt executes the command, piping its stdout and stderr to the given // writers. If the command fails, it will return an error that, if returned // from a target or mg.Deps call, will cause mage to exit with the same code as // the command failed with. Env is a list of environment variables to set when @@ -104,7 +172,8 @@ func OutputAtWith(env map[string]string, pwd, cmd string, args ...string) (strin // Ran reports if the command ran (rather than was not found or not executable). // Code reports the exit code the command returned if it ran. If err == nil, ran // is always true and code is always 0. -func Exec(env map[string]string, stdout, stderr io.Writer, pwd string, cmd string, args ...string) (ran bool, err error) { + +func ExecAt(env map[string]string, stdout, stderr io.Writer, pwd string, cmd string, args ...string) (ran bool, err error) { expand := func(s string) string { s2, ok := env[s] if ok { @@ -132,14 +201,11 @@ func run(env map[string]string, stdout, stderr io.Writer, pwd string, cmd string for k, v := range env { c.Env = append(c.Env, k+"="+v) } + c.Dir = pwd c.Stderr = stderr c.Stdout = stdout c.Stdin = os.Stdin - if pwd != "" { - c.Dir = pwd - } - var quoted []string for i := range args { quoted = append(quoted, fmt.Sprintf("%q", args[i])) diff --git a/internal/core/cmd_test.go b/internal/core/cmd_test.go index a570c61e..e534bf51 100644 --- a/internal/core/cmd_test.go +++ b/internal/core/cmd_test.go @@ -8,7 +8,7 @@ import ( ) func TestExitCode(t *testing.T) { - ran, err := Exec(nil, nil, nil, "", "sh", "-c", "exit 99") + ran, err := Exec(nil, nil, nil, "sh", "-c", "exit 99") if err == nil { t.Fatal("unexpected nil error from run") } @@ -24,7 +24,7 @@ func TestExitCode(t *testing.T) { func TestSettingPwd(t *testing.T) { pwd := "/" out := &bytes.Buffer{} - ran, err := Exec(nil, out, nil, pwd, "pwd") + ran, err := ExecAt(nil, out, nil, pwd, "pwd") if err != nil { t.Fatalf("unexpected error from runner: %#v", err) } @@ -42,7 +42,7 @@ func TestSettingNoPwd(t *testing.T) { t.Errorf("Failed getting current working directory") } out := &bytes.Buffer{} - ran, err := Exec(nil, out, nil, "", "pwd") + ran, err := ExecAt(nil, out, nil, "", "pwd") if err != nil { t.Fatalf("unexpected error from runner: %#v", err) } @@ -66,7 +66,7 @@ func TestSettingInvalidPwd(t *testing.T) { func TestEnv(t *testing.T) { env := "SOME_REALLY_LONG_MAGEFILE_SPECIFIC_THING" out := &bytes.Buffer{} - ran, err := Exec(map[string]string{env: "foobar"}, out, nil, "", "echo", fmt.Sprintf("$%s", env)) + ran, err := Exec(map[string]string{env: "foobar"}, out, nil, "echo", fmt.Sprintf("$%s", env)) if err != nil { t.Fatalf("unexpected error from runner: %#v", err) } From 5e8bcaa5f3f4d11efa6abe3db5d59e69ef544958 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 17:04:01 +0100 Subject: [PATCH 08/28] make file files in sub dir always return relative paths Signed-off-by: Atze de Vries --- internal/core/core.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/core/core.go b/internal/core/core.go index f5a3e0b8..f7f6df22 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -189,7 +189,12 @@ func ListRescursiveFiles(root, pattern string) ([]string, error) { if !d.IsDir() { // filepath.Match checks a filename against a glob pattern. if matched, _ := filepath.Match(pattern, d.Name()); matched { - matches = append(matches, path) + // make relateive + relPath, err := filepath.Rel(root, path) + if err != nil { + return err + } + matches = append(matches, relPath) } } return nil From cdcc5cdc67a073c8db0663d1843a2e3f77444aff Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 17:04:13 +0100 Subject: [PATCH 09/28] use better var name Signed-off-by: Atze de Vries --- internal/devtool/devtool.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/devtool/devtool.go b/internal/devtool/devtool.go index 03df0cfa..77e79d63 100644 --- a/internal/devtool/devtool.go +++ b/internal/devtool/devtool.go @@ -181,11 +181,11 @@ type devtoolOutput struct { // write to a buffer and if mage is run verbose will output to stdout. If non // verbose, stdout will be redirected to io.Discard // Stderr will always outoput to stderr and to a buffer. -func setupStdOutErr(forceStdOut bool) devtoolOutput { +func setupStdOutErr(alwaysStdOut bool) devtoolOutput { bufOut := &bytes.Buffer{} bufErr := &bytes.Buffer{} var stdOutDevice io.Writer - if mg.Verbose() || forceStdOut { + if mg.Verbose() || alwaysStdOut { stdOutDevice = os.Stdout } else { stdOutDevice = io.Discard From 8616b7c4a6b0186aed701dc677f7572f57a797b4 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 17:04:44 +0100 Subject: [PATCH 10/28] make kubercorm, helm annd kubescore actually work in docker Signed-off-by: Atze de Vries --- internal/devtool/helm.go | 24 ++++++--------- internal/devtool/kubeconform.go | 51 ++++++++++++------------------- internal/devtool/kubescore.go | 51 ++++++++++++------------------- internal/kubernetes/kubernetes.go | 34 ++++++++++++++++----- internal/pallets/pallets.go | 3 +- 5 files changed, 78 insertions(+), 85 deletions(-) diff --git a/internal/devtool/helm.go b/internal/devtool/helm.go index 4d25e569..a565b12c 100644 --- a/internal/devtool/helm.go +++ b/internal/devtool/helm.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/coopnorge/mage/internal/core" "github.com/hashicorp/go-version" "github.com/magefile/mage/sh" ) @@ -15,23 +16,23 @@ type Helm struct{} // Run runs the helm devtool. It returns stdout, stderr and error. If verbose // is enable on mage it will also stream stdout to the console -func (helm Helm) Run(env map[string]string, args ...string) (string, string, error) { +func (helm Helm) Run(env map[string]string, workdir string, args ...string) (string, string, error) { if val, found := os.LookupEnv("HELM_IN_DOCKER"); found && val == "1" { - return helm.runInDocker(env, args...) + return helm.runInDocker(env, workdir, args...) } if !isCommandAvailable("helm") { fmt.Println("helm binary not found. Use 'brew install helm' to install. Falling back to running the docker version") - return helm.runInDocker(env, args...) + return helm.runInDocker(env, workdir, args...) } err := helm.versionOK() if err != nil { fmt.Printf("helm does not meet version constraints. Falling back to docker verion\n error: %s\n", err) - return helm.runInDocker(env, args...) + return helm.runInDocker(env, workdir, args...) } fmt.Println("Using native helm") - return helm.runNative(env, args...) + return helm.runNative(env, workdir, args...) } func (helm Helm) versionOK() error { @@ -64,26 +65,21 @@ func (helm Helm) versionOK() error { return nil } -func (helm Helm) runNative(env map[string]string, args ...string) (string, string, error) { +func (helm Helm) runNative(env map[string]string, workdir string, args ...string) (string, string, error) { outs := setupStdOutErr(false) - _, err := sh.Exec(env, outs.StdOut, outs.StdErr, "helm", helm.addDefautsArgs(args...)...) + _, err := core.ExecAt(env, outs.StdOut, outs.StdErr, workdir, "helm", helm.addDefautsArgs(args...)...) return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err } -func (helm Helm) runInDocker(env map[string]string, args ...string) (string, string, error) { +func (helm Helm) runInDocker(env map[string]string, workdir string, args ...string) (string, string, error) { devtool, err := getTool(ToolsDockerfile, "helm") if err != nil { return "", "", err } - path, err := os.Getwd() - if err != nil { - return "", "", err - } - dockerArgs := []string{ - "--volume", fmt.Sprintf("%s:/app", path), // Mount the source code + "--volume", fmt.Sprintf("%s:/app", workdir), // Mount the source code "--workdir", "/app", // set workdir to where we want to run } diff --git a/internal/devtool/kubeconform.go b/internal/devtool/kubeconform.go index abd71549..b4f42060 100644 --- a/internal/devtool/kubeconform.go +++ b/internal/devtool/kubeconform.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" "github.com/coopnorge/mage/internal/core" "github.com/hashicorp/go-version" @@ -14,20 +15,23 @@ import ( type KubeConform struct{} // Run runs the kubeconform devtool -func (kf KubeConform) Run(env map[string]string, args ...string) error { +func (kf KubeConform) Run(env map[string]string, workdir string, args ...string) (string, string, error) { + if val, found := os.LookupEnv("KUBECONFORM_IN_DOCKER"); found && val == "1" { + return kf.runInDocker(env, workdir, args...) + } if !isCommandAvailable("kubeconform") { fmt.Println("kubeconform binary not found. Use 'brew install kubeconform' to install. Falling back to running the docker version") - return kf.runInDocker(env, args...) + return kf.runInDocker(env, workdir, args...) } err := kf.versionOK() if err != nil { fmt.Printf("kubeconform does not meet version constraints. Falling back to docker version\n error: %s\n", err) - return kf.runInDocker(env, args...) + return kf.runInDocker(env, workdir, args...) } fmt.Println("Using native kubeconform") - return kf.runNative(env, args...) + return kf.runNative(env, workdir, args...) } func (kf KubeConform) versionOK() error { @@ -59,34 +63,24 @@ func (kf KubeConform) versionOK() error { return nil } -func (kf KubeConform) runNative(env map[string]string, args ...string) error { - if core.Verbose() { - return sh.RunWith(env, "kubeconform", kf.addDefautsArgs(args...)...) - } - out, err := sh.OutputWith(env, "kubeconform", kf.addDefautsArgs(args...)...) - if err != nil { - fmt.Println(out) - return err - } - return err +func (kf KubeConform) runNative(env map[string]string, workdir string, args ...string) (string, string, error) { + outs := setupStdOutErr(true) + _, err := core.ExecAt(env, outs.StdOut, outs.StdErr, workdir, "kubeconform", kf.addDefautsArgs(args...)...) + + return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err } // DevtoolGo runs the devtool for Go -func (kf KubeConform) runInDocker(env map[string]string, args ...string) error { +func (kf KubeConform) runInDocker(env map[string]string, workdir string, args ...string) (string, string, error) { devtool, err := getTool(ToolsDockerfile, "kubeconform") if err != nil { - return err - } - - path, err := os.Getwd() - if err != nil { - return err + return "", "", err } // kubeconform --strict -verbose -schema-location "https://raw.githubusercontent.com/coopnorge/kubernetes-schemas/main/pallets/{{ .ResourceKind }}{{ .KindSuffix }}.json" .pallet/gitconfig.yaml dockerArgs := []string{ - "--volume", fmt.Sprintf("%s:/app", path), // Mount the source code + "--volume", fmt.Sprintf("%s:/app", workdir), // Mount the source code "--workdir", "/app", // set workdir to where we want to run } @@ -105,15 +99,10 @@ func (kf KubeConform) runInDocker(env map[string]string, args ...string) error { runArgs = append(runArgs, devtool.image) runArgs = append(runArgs, kf.addDefautsArgs(args...)...) - if core.Verbose() { - return sh.RunWith(env, "docker", runArgs...) - } - out, err := sh.OutputWith(env, "docker", runArgs...) - if err != nil { - fmt.Println(out) - return err - } - return err + outs := setupStdOutErr(true) + _, err = core.Exec(env, outs.StdOut, outs.StdErr, "docker", runArgs...) + + return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err } func (kf KubeConform) addDefautsArgs(args ...string) []string { diff --git a/internal/devtool/kubescore.go b/internal/devtool/kubescore.go index 66615e6d..7588e20c 100644 --- a/internal/devtool/kubescore.go +++ b/internal/devtool/kubescore.go @@ -15,20 +15,23 @@ import ( type KubeScore struct{} // Run runs the kubescore devtool -func (kubescore KubeScore) Run(env map[string]string, args ...string) error { +func (kubescore KubeScore) Run(env map[string]string, workdir string, args ...string) (string, string, error) { + if val, found := os.LookupEnv("KUBESCORE_IN_DOCKER"); found && val == "1" { + return kubescore.runInDocker(env, workdir, args...) + } if !isCommandAvailable("kube-score") { fmt.Println("kube-score binary not found. Use 'brew install kube-score' to install. Falling back to running the docker version") - return kubescore.runInDocker(env, args...) + return kubescore.runInDocker(env, workdir, args...) } err := kubescore.versionOK() if err != nil { fmt.Printf("kube-score does not meet version constraints. Falling back to docker verion\n error: %s\n", err) - return kubescore.runInDocker(env, args...) + return kubescore.runInDocker(env, workdir, args...) } fmt.Println("Using native kube-score") - return kubescore.runNative(env, args...) + return kubescore.runNative(env, workdir, args...) } func (kubescore KubeScore) versionOK() error { @@ -36,7 +39,6 @@ func (kubescore KubeScore) versionOK() error { if err != nil { return err } - // example v3.17.1+g980d8ac out, err := sh.Output("kube-score", "version") if err != nil { return err @@ -64,31 +66,21 @@ func (kubescore KubeScore) versionOK() error { return nil } -func (kubescore KubeScore) runNative(env map[string]string, args ...string) error { - if core.Verbose() { - return sh.RunWith(env, "kube-score", kubescore.addDefautsArgs(args...)...) - } - out, err := sh.OutputWith(env, "kube-score", kubescore.addDefautsArgs(args...)...) - if err != nil { - fmt.Println(out) - return err - } - return err +func (kubescore KubeScore) runNative(env map[string]string, workdir string, args ...string) (string, string, error) { + outs := setupStdOutErr(true) + _, err := core.ExecAt(env, outs.StdOut, outs.StdErr, workdir, "kube-score", kubescore.addDefautsArgs(args...)...) + + return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err } -func (kubescore KubeScore) runInDocker(env map[string]string, args ...string) error { +func (kubescore KubeScore) runInDocker(env map[string]string, workdir string, args ...string) (string, string, error) { devtool, err := getTool(ToolsDockerfile, "kube-score") if err != nil { - return err - } - - path, err := os.Getwd() - if err != nil { - return err + return "", "", err } dockerArgs := []string{ - "--volume", fmt.Sprintf("%s:/app", path), // Mount the source code + "--volume", fmt.Sprintf("%s:/app", workdir), // Mount the source code "--workdir", "/app", // set workdir to where we want to run } @@ -107,15 +99,10 @@ func (kubescore KubeScore) runInDocker(env map[string]string, args ...string) er runArgs = append(runArgs, devtool.image) runArgs = append(runArgs, kubescore.addDefautsArgs(args...)...) - if core.Verbose() { - return sh.RunWith(env, "docker", runArgs...) - } - out, err := sh.OutputWith(env, "docker", runArgs...) - if err != nil { - fmt.Println(out) - return err - } - return err + outs := setupStdOutErr(true) + _, err = core.Exec(env, outs.StdOut, outs.StdErr, "docker", kubescore.addDefautsArgs(runArgs...)...) + + return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err } func (kubescore KubeScore) addDefautsArgs(args ...string) []string { diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index 1602a21e..51421e16 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -75,22 +75,32 @@ func renderTemplatesToOutput(chart HelmChart, singleFile bool, try bool) (string } } valueFilesFlags = append(valueFilesFlags, "--values") - valueFilesFlags = append(valueFilesFlags, fp) + valueFilesFlags = append(valueFilesFlags, file) } args := []string{} args = append(args, "template") - args = append(args, chart.path) args = append(args, valueFilesFlags...) if !singleFile { args = append(args, "--output-dir") args = append(args, outdir) } + args = append(args, ".") - _, _, err = helm.Run(nil, "dep", "up", chart.path) + // make path abs when it is not, required for running in docker + path := chart.path + if filepath.IsLocal(chart.path) { + base, err := core.GetRepoRoot() + if err != nil { + return outdir, cleanup, err + } + path = filepath.Join(base, chart.path) + } + // make sure dependencies are there + _, _, err = helm.Run(nil, path, "dep", "up", ".") if err != nil { return outdir, cleanup, err } - out, _, err := helm.Run(nil, args...) + out, _, err := helm.Run(nil, path, args...) if singleFile { outdir = filepath.Join(outdir, "templates.yaml") err = os.WriteFile(outdir, []byte(out), 0o644) @@ -144,7 +154,7 @@ func DiffTemplates(chart HelmChart) error { } args = append(args, mainTemplates, branchTemplates) - fmt.Printf("Diff compared to main of chart: %s env: %s\n", filepath.Base(chart.path), chart.env) + fmt.Printf("---\nDiff compared to main of \nchart: %s\nenv: %s\n---\n", chart.path, chart.env) out, _, err := dyff.Run(nil, args...) if inCI { @@ -232,7 +242,8 @@ func ValidateWithKubeConform(chart HelmChart) error { return err } args = append(args, files...) - return kubeconform.Run(nil, args...) + _, _, err = kubeconform.Run(nil, dir, args...) + return err } func ValidateWithKubeScore(chart HelmChart) error { @@ -244,6 +255,7 @@ func ValidateWithKubeScore(chart HelmChart) error { args := []string{ "score", } + files, err := core.ListRescursiveFiles(dir, "*.yaml") if err != nil { return err @@ -252,7 +264,15 @@ func ValidateWithKubeScore(chart HelmChart) error { return nil } args = append(args, files...) - return kubescore.Run(nil, args...) + // if filepath.IsLocal(dir) { + // root, err := core.GetRepoRoot() + // if err != nil { + // return err + // } + // dir = filepath.Join(root, dir) + // } + _, _, err = kubescore.Run(nil, dir, args...) + return err } func findHelmValues(dir string, env string) ([]string, error) { diff --git a/internal/pallets/pallets.go b/internal/pallets/pallets.go index 8ef893cd..4b9eee02 100644 --- a/internal/pallets/pallets.go +++ b/internal/pallets/pallets.go @@ -35,7 +35,8 @@ func Validate() error { "https://raw.githubusercontent.com/coopnorge/kubernetes-schemas/main/pallets/{{ .ResourceKind }}{{ .KindSuffix }}.json", } args = append(args, palletList...) - return kubeconform.Run(nil, args...) + _, _, err = kubeconform.Run(nil, ".", args...) + return err // return devtool.Run("kubeconform", dockerArgs, cmd, args...) } From 5b60779b5851caf627d742034661cce5bb12c6b3 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 17:56:53 +0100 Subject: [PATCH 11/28] make dyff also work in docker Signed-off-by: Atze de Vries --- internal/core/core.go | 24 ++++++++ internal/devtool/dyff.go | 84 ++++++++++++--------------- internal/devtool/dyff/dyff.Dockerfile | 18 ++++++ internal/kubernetes/kubernetes.go | 28 ++++++--- 4 files changed, 100 insertions(+), 54 deletions(-) create mode 100644 internal/devtool/dyff/dyff.Dockerfile diff --git a/internal/core/core.go b/internal/core/core.go index f7f6df22..25e93886 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -3,6 +3,7 @@ package core import ( "errors" "fmt" + "io" "io/fs" "os" "path" @@ -231,3 +232,26 @@ func GetAbsWorkDir(workdir string) string { } return filepath.Join(cwd, workdir) } + +// CopyFiles copies a file from src to dst. +func CopyFile(src, dst string) error { + source, err := os.Open(src) + if err != nil { + return err + } + defer source.Close() // Reading close is usually safe to defer + + destination, err := os.Create(dst) + if err != nil { + return err + } + + // io.Copy does the heavy lifting + if _, err := io.Copy(destination, source); err != nil { + destination.Close() // Close but prioritize returning the Copy error + return err + } + + // Manually close to catch write/flush errors + return destination.Close() +} diff --git a/internal/devtool/dyff.go b/internal/devtool/dyff.go index 934fb16d..29957c59 100644 --- a/internal/devtool/dyff.go +++ b/internal/devtool/dyff.go @@ -4,7 +4,6 @@ import ( _ "embed" "fmt" "os" - "path/filepath" "runtime" "strconv" "strings" @@ -17,28 +16,32 @@ import ( // Dyff holds the devtool for policy-bot type Dyff struct{} -// DyffDocker the content of dyff.Dockerfile +// DyffDockerfile the content of dyff.Dockerfile // -//go:embed policy-bot/policy-bot.Dockerfile -var DyffDocker string +//go:embed dyff/dyff.Dockerfile +var DyffDockerfile string -const dyffVersion = "v1.10.5" +const dyffVersion = "1.10.5" // Run runs the policy-bot devtool -func (dyff Dyff) Run(env map[string]string, args ...string) (string, string, error) { +func (dyff Dyff) Run(env map[string]string, workdir string, args ...string) (string, string, error) { + if val, found := os.LookupEnv("DYFF_IN_DOCKER"); found && val == "1" { + return dyff.runInDocker(env, workdir, args...) + } + if !isCommandAvailable("dyff") { fmt.Println("Dyff binary not found. Use 'brew install dyff' to install. Falling back to running the docker version") - return "", "", dyff.runInDocker(env, args...) + return dyff.runInDocker(env, workdir, args...) } err := dyff.versionOK() if err != nil { fmt.Printf("Dyff does not meet version constraints. Falling back to docker verion\n error: %s\n", err) - return "", "", dyff.runInDocker(env, args...) + return dyff.runInDocker(env, workdir, args...) } fmt.Println("Using native dyff") - return dyff.runNative(env, args...) + return dyff.runNative(env, workdir, args...) // for now only support running in Docker } @@ -68,41 +71,21 @@ func (dyff Dyff) versionOK() error { return nil } -func (dyff Dyff) runNative(env map[string]string, args ...string) (string, string, error) { +func (dyff Dyff) runNative(env map[string]string, workdir string, args ...string) (string, string, error) { outs := setupStdOutErr(true) - _, err := sh.Exec(env, outs.StdOut, outs.StdErr, "dyff", args...) + _, err := core.ExecAt(env, outs.StdOut, outs.StdErr, workdir, "dyff", args...) return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err } -func (dyff Dyff) runInDocker(env map[string]string, args ...string) error { +func (dyff Dyff) runInDocker(env map[string]string, workdir string, args ...string) (string, string, error) { image, err := dyff.buildImage() if err != nil { - return err - } - - path, err := os.Getwd() - if err != nil { - return err - } - - // workdir is dependant on the version of dependabot - origWorkDir, err := sh.Output( - "docker", "inspect", - "--format={{.Config.WorkingDir}}", - image, - ) - if err != nil { - return err + return "", "", err } - - // the binary is in the original working directory - entryPoint := filepath.Join(origWorkDir, fmt.Sprintf("bin/linux-%s/policy-bot", runtime.GOARCH)) - dockerArgs := []string{ - "--volume", fmt.Sprintf("%s:/app", path), // Mount the source code + "--volume", fmt.Sprintf("%s:/app", workdir), // Mount the source code "--workdir", "/app", // set workdir to where we want to run - "--entrypoint", entryPoint, } if env == nil { @@ -121,31 +104,38 @@ func (dyff Dyff) runInDocker(env map[string]string, args ...string) error { runArgs = append(runArgs, image) runArgs = append(runArgs, args...) - if core.Verbose() { - return sh.RunWith(env, "docker", runArgs...) - } - out, err := sh.OutputWith(env, "docker", runArgs...) - if err != nil { - fmt.Println(out) - return err - } - return err + outs := setupStdOutErr(true) + _, err = core.Exec(env, outs.StdOut, outs.StdErr, "docker", runArgs...) + + return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err } func (dyff Dyff) buildImage() (string, error) { - imagename := fmt.Sprintf("%s:%s", "dyff", dyffVersion) + // Entity valiator does not really seem to be maintained. We should look + // into alternatives in the future. + // + imageName := fmt.Sprintf("%s:%s", "dyff", dyffVersion) - _, cleanup, err := core.WriteTempFile(core.OutputDir, fmt.Sprintf("%s.dockerfile", "dyff"), DyffDocker) + file, cleanup, err := core.WriteTempFile(core.OutputDir, fmt.Sprintf("%s.Dockerfile", "dyff"), DyffDockerfile) if err != nil { return "", err } defer cleanup() - _, cleanup, err = core.MkdirTemp() + path, cleanup, err := core.MkdirTemp() if err != nil { return "", nil } defer cleanup() - return imagename, nil + return imageName, sh.Run( + "docker", "buildx", "build", + "--platform", fmt.Sprintf("linux/%s", runtime.GOARCH), + "-f", file, + "-t", imageName, + "--load", + "--build-arg", fmt.Sprintf("%s=%s", "DYFF_VERSION", dyffVersion), + "--build-arg", fmt.Sprintf("%s=%s", "TARGETARG", runtime.GOARCH), + path, + ) } diff --git a/internal/devtool/dyff/dyff.Dockerfile b/internal/devtool/dyff/dyff.Dockerfile new file mode 100644 index 00000000..75f57356 --- /dev/null +++ b/internal/devtool/dyff/dyff.Dockerfile @@ -0,0 +1,18 @@ +FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 AS downloader + +RUN apk add --no-cache curl tar + +ARG DYFF_VERSION +ARG TARGETARCH +ARG RELEASE_URL="https://github.com/homeport/dyff/releases/download/v${DYFF_VERSION}/dyff_${DYFF_VERSION}_linux_${TARGETARCH}.tar.gz" + +WORKDIR /tmp +RUN curl -L ${RELEASE_URL} | tar -xz + +FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 +COPY --from=downloader /tmp/dyff /usr/local/bin/dyff +ENTRYPOINT ["dyff"] + + + + diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index 51421e16..3464ea76 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -110,10 +110,6 @@ func renderTemplatesToOutput(chart HelmChart, singleFile bool, try bool) (string func DiffTemplates(chart HelmChart) error { // dyff between a/helloworld/charts/app/templates/ b/helloworld/charts/app/templates/ -o github - currentBranch, err := git.CurrentBranch() - if err != nil { - return err - } branchTemplates, branchTemplatesCleanup, err := RenderTemplatesSingleFile(chart, false) if err != nil { @@ -152,13 +148,31 @@ func DiffTemplates(chart HelmChart) error { if !core.FileExists(mainTemplates) { return fmt.Errorf("%s does not exist", mainTemplates) } - args = append(args, mainTemplates, branchTemplates) + + diffDir, cleanupDiffDir, err := core.MkdirTemp() + defer cleanupDiffDir() + if err != nil { + return err + } + + branchFilename := fmt.Sprintf("branch-%s-%s.yaml", filepath.Base(chart.path), chart.env) + mainFilename := fmt.Sprintf("main-%s-%s.yaml", filepath.Base(chart.path), chart.env) + err = core.CopyFile(branchTemplates, filepath.Join(diffDir, branchFilename)) + if err != nil { + return err + } + + err = core.CopyFile(mainTemplates, filepath.Join(diffDir, mainFilename)) + if err != nil { + return err + } + args = append(args, branchFilename, mainFilename) fmt.Printf("---\nDiff compared to main of \nchart: %s\nenv: %s\n---\n", chart.path, chart.env) - out, _, err := dyff.Run(nil, args...) + out, _, err := dyff.Run(nil, diffDir, args...) if inCI { - path := filepath.Join("var", "kubernetes", "diff", fmt.Sprintf("%s-%s-%s.diff", currentBranch, filepath.Base(chart.path), chart.env)) + path := filepath.Join("var", "kubernetes", "diff", fmt.Sprintf("%s-%s.diff", filepath.Base(chart.path), chart.env)) err := os.MkdirAll(filepath.Dir(path), 0o755) if err != nil { return err From 87352b8f1ca571269003880727b532062fbcc6dc Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Mon, 2 Mar 2026 19:46:00 +0100 Subject: [PATCH 12/28] rewrite the render template again after some learning Signed-off-by: Atze de Vries --- internal/kubernetes/kubernetes.go | 102 +++++++++------------- internal/targets/kubernetes/kubernetes.go | 10 ++- 2 files changed, 47 insertions(+), 65 deletions(-) diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index 3464ea76..e2a5525e 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -34,34 +34,18 @@ func isHelmChart(p string, d fs.DirEntry) bool { return core.FileExists(filepath.Join(p, "Chart.yaml")) } -// RenderTemplates renders the templates of a specific helm chart. It will -// return a function for cleanup -// When second argument is set to true it will try to render even if some +// RenderTemplates renders the templates of a specific helm chart. It required +// a destination. If the dest is a folder it will render the files separate. If +// it is a file, then it will render all in 1 temiplate. +// When third argument is set to true it will try to render even if some // files are not there. This is used when rendering a template which is in // unkown state -func RenderTemplates(chart HelmChart, try bool) (string, func(), error) { - return renderTemplatesToOutput(chart, false, try) -} - -// RenderTemplatesSingleFile renders the templates of a specific helm chart. It will -// return a function for cleanup -// When second argument is set to true it will try to render even if some -// files are not there. This is used when rendering a template which is in -// unkown state -func RenderTemplatesSingleFile(chart HelmChart, try bool) (string, func(), error) { - return renderTemplatesToOutput(chart, true, try) -} - -func renderTemplatesToOutput(chart HelmChart, singleFile bool, try bool) (string, func(), error) { - outdir, cleanup, err := core.MkdirTemp() - if err != nil { - return outdir, nil, err - } +func RenderTemplates(chart HelmChart, dest string, try bool) error { if try { // if the chart does not exist it will just return an empty dir, which // we can diff against if !core.FileExists(filepath.Join(chart.path, "Chart.yaml")) { - return outdir, cleanup, nil + return nil } } @@ -80,9 +64,9 @@ func renderTemplatesToOutput(chart HelmChart, singleFile bool, try bool) (string args := []string{} args = append(args, "template") args = append(args, valueFilesFlags...) - if !singleFile { + if filepath.Ext(dest) == "" { args = append(args, "--output-dir") - args = append(args, outdir) + args = append(args, dest) } args = append(args, ".") @@ -91,31 +75,38 @@ func renderTemplatesToOutput(chart HelmChart, singleFile bool, try bool) (string if filepath.IsLocal(chart.path) { base, err := core.GetRepoRoot() if err != nil { - return outdir, cleanup, err + return err } path = filepath.Join(base, chart.path) } // make sure dependencies are there - _, _, err = helm.Run(nil, path, "dep", "up", ".") + _, _, err := helm.Run(nil, path, "dep", "up", ".") if err != nil { - return outdir, cleanup, err + return err } out, _, err := helm.Run(nil, path, args...) - if singleFile { - outdir = filepath.Join(outdir, "templates.yaml") - err = os.WriteFile(outdir, []byte(out), 0o644) + if filepath.Ext(dest) != "" { + fmt.Printf("write to file %s\n", dest) + return os.WriteFile(dest, []byte(out), 0o644) } - return outdir, cleanup, err + return err } func DiffTemplates(chart HelmChart) error { // dyff between a/helloworld/charts/app/templates/ b/helloworld/charts/app/templates/ -o github - branchTemplates, branchTemplatesCleanup, err := RenderTemplatesSingleFile(chart, false) + diffDir, cleanupDiffDir, err := core.MkdirTemp() + defer cleanupDiffDir() + if err != nil { + return err + } + branchFilename := fmt.Sprintf("branch-%s-%s.yaml", filepath.Base(chart.path), chart.env) + mainFilename := fmt.Sprintf("main-%s-%s.yaml", filepath.Base(chart.path), chart.env) + + err = RenderTemplates(chart, filepath.Join(diffDir, branchFilename), false) if err != nil { return err } - defer branchTemplatesCleanup() mainWorktree, worktreeCleanup, err := git.Worktree("main") if err != nil { @@ -128,11 +119,11 @@ func DiffTemplates(chart HelmChart) error { env: chart.env, valueFiles: chart.valueFiles, } - mainTemplates, mainTemplatesCleanup, err := RenderTemplatesSingleFile(mainChart, true) + + err = RenderTemplates(mainChart, filepath.Join(diffDir, mainFilename), true) if err != nil { return err } - defer mainTemplatesCleanup() args := []string{ "--color", "on", @@ -143,29 +134,8 @@ func DiffTemplates(chart HelmChart) error { _, inCI := os.LookupEnv("CI") if inCI { args = append(args, "--output", "github") - // env["OUTPUT_FILE"] = fmt.Sprintf("%s-%s-%s.diff", filepath.Base(chart.path), chart.env, currentBranch) - } - if !core.FileExists(mainTemplates) { - return fmt.Errorf("%s does not exist", mainTemplates) } - diffDir, cleanupDiffDir, err := core.MkdirTemp() - defer cleanupDiffDir() - if err != nil { - return err - } - - branchFilename := fmt.Sprintf("branch-%s-%s.yaml", filepath.Base(chart.path), chart.env) - mainFilename := fmt.Sprintf("main-%s-%s.yaml", filepath.Base(chart.path), chart.env) - err = core.CopyFile(branchTemplates, filepath.Join(diffDir, branchFilename)) - if err != nil { - return err - } - - err = core.CopyFile(mainTemplates, filepath.Join(diffDir, mainFilename)) - if err != nil { - return err - } args = append(args, branchFilename, mainFilename) fmt.Printf("---\nDiff compared to main of \nchart: %s\nenv: %s\n---\n", chart.path, chart.env) @@ -242,35 +212,43 @@ func ListHelmCharts(charts []HelmChart) { } func ValidateWithKubeConform(chart HelmChart) error { - dir, cleanup, err := RenderTemplates(chart, false) + dest, cleanup, err := core.MkdirTemp() defer cleanup() if err != nil { return err } + err = RenderTemplates(chart, dest, false) + if err != nil { + return err + } args := []string{ "-schema-location", "default", "--schema-location", "https://raw.githubusercontent.com/coopnorge/kubernetes-schemas/main/api-platform/{{ .ResourceKind }}{{ .KindSuffix }}.json", } - files, err := core.ListRescursiveFiles(dir, "*.yaml") + files, err := core.ListRescursiveFiles(dest, "*.yaml") if err != nil { return err } args = append(args, files...) - _, _, err = kubeconform.Run(nil, dir, args...) + _, _, err = kubeconform.Run(nil, dest, args...) return err } func ValidateWithKubeScore(chart HelmChart) error { - dir, cleanup, err := RenderTemplates(chart, false) + dest, cleanup, err := core.MkdirTemp() defer cleanup() if err != nil { return err } + err = RenderTemplates(chart, dest, false) + if err != nil { + return err + } args := []string{ "score", } - files, err := core.ListRescursiveFiles(dir, "*.yaml") + files, err := core.ListRescursiveFiles(dest, "*.yaml") if err != nil { return err } @@ -285,7 +263,7 @@ func ValidateWithKubeScore(chart HelmChart) error { // } // dir = filepath.Join(root, dir) // } - _, _, err = kubescore.Run(nil, dir, args...) + _, _, err = kubescore.Run(nil, dest, args...) return err } diff --git a/internal/targets/kubernetes/kubernetes.go b/internal/targets/kubernetes/kubernetes.go index 72418d4f..186d79c9 100644 --- a/internal/targets/kubernetes/kubernetes.go +++ b/internal/targets/kubernetes/kubernetes.go @@ -1,9 +1,10 @@ -package terraform +package kubernetes import ( "context" _ "embed" + "github.com/coopnorge/mage/internal/core" "github.com/coopnorge/mage/internal/kubernetes" ) @@ -34,9 +35,12 @@ func Validate(ctx context.Context) error { } func render(_ context.Context, chart kubernetes.HelmChart) error { - _, cleanup, err := kubernetes.RenderTemplates(chart, false) + dest, cleanup, err := core.MkdirTemp() defer cleanup() - return err + if err != nil { + return err + } + return kubernetes.RenderTemplates(chart, dest, false) } func kubeconform(_ context.Context, chart kubernetes.HelmChart) error { From f68483bbf8eb96e7b08cc7029c18e5de8c5d86d4 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Wed, 4 Mar 2026 09:40:19 +0100 Subject: [PATCH 13/28] removed unused function Signed-off-by: Atze de Vries --- internal/core/core.go | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/internal/core/core.go b/internal/core/core.go index 25e93886..f7f6df22 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -3,7 +3,6 @@ package core import ( "errors" "fmt" - "io" "io/fs" "os" "path" @@ -232,26 +231,3 @@ func GetAbsWorkDir(workdir string) string { } return filepath.Join(cwd, workdir) } - -// CopyFiles copies a file from src to dst. -func CopyFile(src, dst string) error { - source, err := os.Open(src) - if err != nil { - return err - } - defer source.Close() // Reading close is usually safe to defer - - destination, err := os.Create(dst) - if err != nil { - return err - } - - // io.Copy does the heavy lifting - if _, err := io.Copy(destination, source); err != nil { - destination.Close() // Close but prioritize returning the Copy error - return err - } - - // Manually close to catch write/flush errors - return destination.Close() -} From 8d0b265fb517cdeac7ba00dcce5339b8f02eb08a Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Wed, 4 Mar 2026 14:41:56 +0100 Subject: [PATCH 14/28] add github stuff Signed-off-by: Atze de Vries --- .github/workflows/goapp.yaml | 10 ++ .github/workflows/reusable-kubernetes.yaml | 39 ++++++ internal/github/github.go | 126 +++++++++++++++++++ internal/kubernetes/kubernetes.go | 138 +++++++++++++++++---- internal/kubernetes/kubernetes_test.go | 60 +++++++-- 5 files changed, 341 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/reusable-kubernetes.yaml create mode 100644 internal/github/github.go diff --git a/.github/workflows/goapp.yaml b/.github/workflows/goapp.yaml index 8fdd23ae..0cd58335 100644 --- a/.github/workflows/goapp.yaml +++ b/.github/workflows/goapp.yaml @@ -149,3 +149,13 @@ jobs: contents: read pull-requests: read secrets: inherit + + kubernetes: + name: Kubernetes + needs: ["detect-changes"] + # if: ${{ needs.detect-changes.outputs.catalog-info == 'true' }} + uses: ./.github/workflows/reusable-kubernetes.yaml + permissions: + contents: read + pull-requests: read + issues: write diff --git a/.github/workflows/reusable-kubernetes.yaml b/.github/workflows/reusable-kubernetes.yaml new file mode 100644 index 00000000..a5d68b66 --- /dev/null +++ b/.github/workflows/reusable-kubernetes.yaml @@ -0,0 +1,39 @@ +concurrency: + group: reusable-kubernetes-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true +on: + workflow_call: + inputs: {} + secrets: {} + +jobs: + validate-kubernetes: + name: Validate Kuberenetes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "stable" + cache-dependency-path: "**/go.sum" + - name: Install go Tools + run: go install tool + + - name: Kubernetes List Detected charts + id: list + run: go tool mage k8s:list + + - name: Kubernetes validation + id: validate + run: go tool mage k8s:validate + + - name: Kubernetes validation + id: diff + run: go tool mage k8s:diff diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 00000000..405fde45 --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,126 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "os" + "os/exec" + "slices" + "strings" + "unicode/utf8" + + "github.com/magefile/mage/sh" +) + +type ghIssueComment struct { + ID string `json:"id"` + Body string `json:"body"` +} + +type ghIssueComments struct { + Comments []ghIssueComment `json:"comments"` +} + +// FindCommentInPR searches the current PR for a string in a comment. +// It will return true if found and the comment ID. If muiltiple comments are +// found it will return the most recent. If no comment found it will return +// false +func FindCommentInPR(searchString string) (bool, string, error) { + // jq := fmt.Sprintf(".comments[] | select(.body | contains(\\\"%s\\\")) | .id\, searchString) + out, err := sh.Output("gh", "pr", "view", "--json", "comments") + if err != nil { + return false, "", err + } + var comments ghIssueComments + err = json.Unmarshal([]byte(out), &comments) + if err != nil { + return false, "", err + } + for _, comment := range slices.Backward(comments.Comments) { + if strings.Contains(comment.Body, searchString) { + return true, comment.ID, nil + } + } + // nothing found + return false, "", nil +} + +// HideComment hides a comment +func HideComment(id string) error { + // gh api graphql -F id='COMMENT_NODE_ID' -f query=' + // mutation($id: ID!) { minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {minimizedComment {isMinimized}}}' + + query := "query=mutation($id:ID!){ minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}" + idArg := fmt.Sprintf("id=%s", id) + + // not using mage sh library because it will remove $ + // https://github.com/magefile/mage/pull/505 + // return sh.Run("gh", "api", "graphql", "-F", idArg, "-f", query) + cmd := exec.Command("gh", "api", "graphql", "-F", idArg, "-f", query) + var stderr bytes.Buffer + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return fmt.Errorf("failed to run command. Error %s", stderr.String()) + } + return nil +} + +// ReplaceCommentInPR replaces a comment with the id id and the body sources from +// the supplied filename. It +// will return an error if the body is to big or the command fails +func ReplaceCommentInPR(id string, filename string) error { + // gh api -X PATCH repos/{owner}/{repo}/issues/comments/{comment_id} -f body=@path/to/your/comment.md + + err := validateCommentBody(filename) + if err != nil { + return err + } + pathArg := fmt.Sprintf("repos/{owner}/{repo}/issues/comments/%s", id) + bodyArg := fmt.Sprintf("body=@%s", filename) + return sh.Run("gh", "api", "-X", "PATCH", pathArg, "-f", bodyArg) +} + +// CreateCommentInPR creates a comment with the id id and the body sources from +// the supplied filename. It +// will return an error if the body is to big or the command fails +func CreateCommentInPR(filename string) error { + err := validateCommentBody(filename) + if err != nil { + return err + } + return sh.Run("gh", "pr", "comment", "--body-file", filename) +} + +// PrintActionMessage prints a action message in github action using the +// :: format. It makes sure the encoding is correct. The first input the level, the +// second is the is the title and the third the message +// level can be debug, notice, warning, error. It will return a error if the +// level is not allowed. +func PrintActionMessage(level, title, message string) error { + allowedLevels := []string{"debug", "notice", "warning", "error"} + if !slices.Contains(allowedLevels, level) { + return fmt.Errorf("supplied level %s is not in the list %s", level, strings.Join(allowedLevels, ",")) + } + fmt.Printf("::%s title=%s::%s", level, url.QueryEscape(title), url.QueryEscape(message)) + return nil +} + +// InCI returns a true if you are runing in Github Actions +func InCI() bool { + _, found := os.LookupEnv("CI") + return found +} + +func validateCommentBody(filename string) error { + body, err := os.ReadFile(filename) + if err != nil { + return err + } + if utf8.RuneCountInString(string(body)) > 65536 { + return fmt.Errorf("body is %d characters which is more than the max of 65536", utf8.RuneCountInString(string(body))) + } + return nil +} diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index e2a5525e..f7d059cc 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -2,16 +2,21 @@ package kubernetes import ( + "bytes" "fmt" "io/fs" "os" "path/filepath" + "regexp" "slices" "strings" + "text/template" + "unicode/utf8" "github.com/coopnorge/mage/internal/core" "github.com/coopnorge/mage/internal/devtool" "github.com/coopnorge/mage/internal/git" + "github.com/coopnorge/mage/internal/github" ) var ( @@ -80,9 +85,15 @@ func RenderTemplates(chart HelmChart, dest string, try bool) error { path = filepath.Join(base, chart.path) } // make sure dependencies are there - _, _, err := helm.Run(nil, path, "dep", "up", ".") + depstatus, _, err := helm.Run(nil, path, "dep", "list", ".") if err != nil { - return err + return fmt.Errorf("failed to check dependencies. Please remove all contents %s/charts. Error: %s", chart.path, err) + } + if strings.Contains(depstatus, "missing") { + _, _, err := helm.Run(nil, path, "dep", "up", ".") + if err != nil { + return err + } } out, _, err := helm.Run(nil, path, args...) if filepath.Ext(dest) != "" { @@ -92,6 +103,8 @@ func RenderTemplates(chart HelmChart, dest string, try bool) error { return err } +// DiffTemplates will create a diff of the rendered templates of a helmchart +// compared to the main branch func DiffTemplates(chart HelmChart) error { // dyff between a/helloworld/charts/app/templates/ b/helloworld/charts/app/templates/ -o github @@ -130,34 +143,98 @@ func DiffTemplates(chart HelmChart) error { "--truecolor", "on", "between", } - // simply assumming that if CI is set, we are in github actions - _, inCI := os.LookupEnv("CI") - if inCI { + if github.InCI() { args = append(args, "--output", "github") } - args = append(args, branchFilename, mainFilename) + args = append(args, mainFilename, branchFilename) fmt.Printf("---\nDiff compared to main of \nchart: %s\nenv: %s\n---\n", chart.path, chart.env) out, _, err := dyff.Run(nil, diffDir, args...) - if inCI { - path := filepath.Join("var", "kubernetes", "diff", fmt.Sprintf("%s-%s.diff", filepath.Base(chart.path), chart.env)) + if github.InCI() { + path := filepath.Join("var", "kubernetes", "diff", fmt.Sprintf("%s-%s.md", filepath.Base(chart.path), chart.env)) err := os.MkdirAll(filepath.Dir(path), 0o755) if err != nil { return err } - if out == "" { - out = fmt.Sprintf("# no diff for %s %s", filepath.Base(chart.path), chart.env) + + title := fmt.Sprintf("%s %s", filepath.Base(chart.path), chart.env) + changes := strings.Count(out, "!") + summary := fmt.Sprintf("found %d change(s)", changes) + if changes > 0 { + summary = fmt.Sprintf("found **%d** change(s)", changes) } - err = os.WriteFile(path, []byte(out), 0o644) + md, err := diffMarkdownTemplate(title, summary, out, 64000) if err != nil { return err } + err = os.WriteFile(path, []byte(md), 0o644) + if err != nil { + return err + } + + searchString := fmt.Sprintf("### Kubernetes templates for %s", title) + + found, id, err := github.FindCommentInPR(searchString) + if err != nil { + return err + } + if found { + + // err := github.ReplaceCommentInPR(id, path) + err := github.HideComment(id) + if err != nil { + return err + } + } + return github.CreateCommentInPR(path) } return err } +func diffMarkdownTemplate(title, summary, diff string, limit int) (string, error) { + // make sure template are not to long + diffNote := "" + if utf8.RuneCountInString(diff) > limit { + diff = diff[:limit] + diffNote = fmt.Sprintf("# !!NOTE diff has been cut of because it is longer than %d. Full diff is in action log.", limit) + } + + // cleanup colorcoding + const ansi = "[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]" + re := regexp.MustCompile(ansi) + diff = re.ReplaceAllString(diff, "") + data := map[string]string{ + "Title": title, + "Summary": summary, + "Diff": diff, + "DiffNote": diffNote, + } + + funcMap := template.FuncMap{ + "tripplebacktick": func() string { return "```" }, + } + + const mdTemplate = `### Kubernetes templates for {{.Title}} + +
{{ .Summary }} +{{.DiffNote}} +{{tripplebacktick}}diff +{{ .Diff }} +{{tripplebacktick}} +
+` + tmpl, err := template.New("md").Funcs(funcMap).Parse(mdTemplate) + if err != nil { + return "", err + } + + var buf bytes.Buffer + err = tmpl.Execute(&buf, data) + return buf.String(), err +} + // FindHelmCharts will search through the base directory to find the // all helm charts func FindHelmCharts(base string) ([]HelmChart, error) { @@ -202,6 +279,7 @@ func FindHelmCharts(base string) ([]HelmChart, error) { return charts, nil } +// ListHelmCharts list the found helm charts in this repository func ListHelmCharts(charts []HelmChart) { for _, chart := range charts { fmt.Printf("---\n") @@ -211,6 +289,8 @@ func ListHelmCharts(charts []HelmChart) { } } +// ValidateWithKubeConform will run kubeconform validation on a supplied +// HelmChart func ValidateWithKubeConform(chart HelmChart) error { dest, cleanup, err := core.MkdirTemp() defer cleanup() @@ -230,10 +310,17 @@ func ValidateWithKubeConform(chart HelmChart) error { return err } args = append(args, files...) - _, _, err = kubeconform.Run(nil, dest, args...) + out, _, err := kubeconform.Run(nil, dest, args...) + if github.InCI() && err != nil { + err := github.PrintActionMessage("error", fmt.Sprintf("kubeconform failed for %s %s", filepath.Base(chart.path), chart.env), out) + if err != nil { + return err + } + } return err } +// ValidateWithKubeScore will run kube-score validation on a supplied HelmChart func ValidateWithKubeScore(chart HelmChart) error { dest, cleanup, err := core.MkdirTemp() defer cleanup() @@ -256,23 +343,24 @@ func ValidateWithKubeScore(chart HelmChart) error { return nil } args = append(args, files...) - // if filepath.IsLocal(dir) { - // root, err := core.GetRepoRoot() - // if err != nil { - // return err - // } - // dir = filepath.Join(root, dir) - // } - _, _, err = kubescore.Run(nil, dest, args...) + out, _, err := kubescore.Run(nil, dest, args...) + if github.InCI() && err != nil { + err := github.PrintActionMessage("error", fmt.Sprintf("kubecore failed for %s %s", filepath.Base(chart.path), chart.env), out) + if err != nil { + return err + } + } return err } +// findHelmValues will find value yaml files for a specific environment. It +// will return them in the correct rendering order. +// order of finding value files is +// case only env files +// values.yaml, values-.yaml +// case with extra name +// values.yaml, values-.yaml, values--.yaml func findHelmValues(dir string, env string) ([]string, error) { - // order of finding value files is - // case only env files - // values.yaml, values-.yaml - // case with extra name - // values.yaml, values-.yaml, values--.yaml // We are finding in reverse because if no env values are found we assume // no env files := []string{} diff --git a/internal/kubernetes/kubernetes_test.go b/internal/kubernetes/kubernetes_test.go index 02707246..3fb6dd30 100644 --- a/internal/kubernetes/kubernetes_test.go +++ b/internal/kubernetes/kubernetes_test.go @@ -4,6 +4,7 @@ import ( _ "embed" "testing" + "github.com/coopnorge/mage/internal/core" "github.com/magefile/mage/sh" "github.com/stretchr/testify/assert" ) @@ -58,7 +59,7 @@ func TestRenderHelmChart(t *testing.T) { name: "simple chart should render", chart: HelmChart{ env: "staging", - path: "testdata/repo/infrastructure/kubernetes/helm/charts/charta", + path: "internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta", valueFiles: []string{"values.yaml", "values-staging.yaml"}, }, }, @@ -66,8 +67,9 @@ func TestRenderHelmChart(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - dir, cleanup, err := RenderTemplates(tt.chart, false) - assert.NoError(t, err) + dir, cleanup, err := core.MkdirTemp() + assert.NoError(t, err, "failed to create temp dir %s", err) + assert.NoError(t, RenderTemplates(tt.chart, dir, false), "failed to render template") assert.NoError(t, sh.RunV("git", "--no-pager", "diff", "--no-index", dir, "testdata/ref-data/chart-a-staging/")) t.Cleanup(cleanup) }) @@ -84,7 +86,7 @@ func TestKubeConform(t *testing.T) { name: "KubeConform should pass", chart: HelmChart{ env: "staging", - path: "testdata/repo/infrastructure/kubernetes/helm/charts/charta", + path: "internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta", valueFiles: []string{"values.yaml", "values-staging.yaml"}, }, wantErr: false, @@ -93,7 +95,7 @@ func TestKubeConform(t *testing.T) { name: "KubeConform should fail", chart: HelmChart{ env: "production", - path: "testdata/repo/infrastructure/kubernetes/helm/charts/charta", + path: "internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/charta", valueFiles: []string{"values.yaml", "values-production-fail.yaml"}, }, wantErr: true, @@ -122,7 +124,7 @@ func TestKubeScore(t *testing.T) { name: "KubeScore should pass", chart: HelmChart{ env: "staging", - path: "testdata/repo/infrastructure/kubernetes/helm/charts/chartc", + path: "internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc", valueFiles: []string{"values.yaml"}, }, wantErr: false, @@ -131,7 +133,7 @@ func TestKubeScore(t *testing.T) { name: "KubeScore should fail", chart: HelmChart{ env: "production", - path: "testdata/repo/infrastructure/kubernetes/helm/charts/chartc", + path: "internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc", valueFiles: []string{"values.yaml", "inject-fail.yaml"}, }, wantErr: true, @@ -151,3 +153,47 @@ func TestKubeScore(t *testing.T) { }) } } + +func TestTemplateRender(t *testing.T) { + tests := []struct { + name string + title string + summary string + diff string + limit int + want string + }{ + { + name: "Template should render", + title: "Diff for testing", + summary: "Some stuff changed", + diff: `@@ spec.hosts.0 @@ +# networking.istio.io/v1beta1/ServiceEntry/coop +! ± value change +- api.staging.coopa ++ api.staging.coop`, + limit: 64000, + want: "### Kubernetes templates for Diff for testing\n\n
Some stuff changed\n\n```diff\n@@ spec.hosts.0 @@\n# networking.istio.io/v1beta1/ServiceEntry/coop\n! ± value change\n- api.staging.coopa\n+ api.staging.coop\n```\n
\n", + }, + { + name: "Template should cutoff", + title: "Diff for testing", + summary: "Some stuff changed", + diff: `@@ spec.hosts.0 @@ +# networking.istio.io/v1beta1/ServiceEntry/coop +! ± value change +- api.staging.coopa ++ api.staging.coop`, + limit: 60, + want: "### Kubernetes templates for Diff for testing\n\n
Some stuff changed\n# !!NOTE diff has been cut of because it is longer than 60. Full diff is in action log.\n```diff\n@@ spec.hosts.0 @@\n# networking.istio.io/v1beta1/ServiceEntr\n```\n
\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff, err := diffMarkdownTemplate(tt.title, tt.summary, tt.diff, tt.limit) + assert.NoError(t, err, tt.name) + assert.Equal(t, tt.want, diff) + }) + } +} From 5cce67fa6a58115245e153626c1284ca177f846e Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Wed, 4 Mar 2026 15:16:12 +0100 Subject: [PATCH 15/28] add changes detection Signed-off-by: Atze de Vries --- .github/workflows/goapp.yaml | 8 +++++++- internal/kubernetes/kubernetes.go | 20 ++++++++++++++++++++ internal/targets/kubernetes/kubernetes.go | 17 +++++++++++++++++ targets/goapp/kubernetes.go | 7 +++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/.github/workflows/goapp.yaml b/.github/workflows/goapp.yaml index 0cd58335..6c826257 100644 --- a/.github/workflows/goapp.yaml +++ b/.github/workflows/goapp.yaml @@ -50,6 +50,7 @@ jobs: policy-bot: ${{ steps.policy-bot.outputs.policy_bot == 'true' && github.event_name == 'pull_request' }} pallets: ${{ steps.pallets.outputs.pallets == 'true' && github.event_name == 'pull_request' }} catalog-info: ${{ steps.catalog-info.outputs.catalog_info == 'true' && github.event_name == 'pull_request' }} + kubernetes: ${{ steps.kubernetes.outputs.kubernetes == 'true' && github.event_name == 'pull_request' }} steps: - uses: actions/checkout@v6 with: @@ -98,6 +99,11 @@ jobs: run: echo "catalog_info=$(go tool mage catalogInfo:changes)" >> $GITHUB_OUTPUT env: CHANGED_FILES: ${{ steps.filter.outputs.changed_files }} + - name: Check for Kubernetes changes + id: kubernetes + run: echo "kubernetes=$(go tool mage k8s:changes)" >> $GITHUB_OUTPUT + env: + CHANGED_FILES: ${{ steps.filter.outputs.changed_files }} goapp: name: Go application @@ -153,7 +159,7 @@ jobs: kubernetes: name: Kubernetes needs: ["detect-changes"] - # if: ${{ needs.detect-changes.outputs.catalog-info == 'true' }} + if: ${{ needs.detect-changes.outputs.kubernetes == 'true' }} uses: ./.github/workflows/reusable-kubernetes.yaml permissions: contents: read diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index f7d059cc..d912ad14 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -353,6 +353,26 @@ func ValidateWithKubeScore(chart HelmChart) error { return err } +// HasChanges checks if the current branch has helmchart changes +// from the main branch +func HasChanges() (bool, error) { + changedFiles, err := git.DiffToMain() + if err != nil { + return false, err + } + charts, err := FindHelmCharts(".") + if err != nil { + return false, err + } + paths := []string{} + for _, chart := range charts { + paths = append(paths, chart.path) + } + // always trigger on go.mod/sum and workflows because of changes in ci. + additionalGlobs := []string{"go.mod", "go.sum", ".github/workflows/*"} + return core.CompareChangesToPaths(changedFiles, paths, additionalGlobs) +} + // findHelmValues will find value yaml files for a specific environment. It // will return them in the correct rendering order. // order of finding value files is diff --git a/internal/targets/kubernetes/kubernetes.go b/internal/targets/kubernetes/kubernetes.go index 186d79c9..43892fcf 100644 --- a/internal/targets/kubernetes/kubernetes.go +++ b/internal/targets/kubernetes/kubernetes.go @@ -3,6 +3,7 @@ package kubernetes import ( "context" _ "embed" + "fmt" "github.com/coopnorge/mage/internal/core" "github.com/coopnorge/mage/internal/kubernetes" @@ -75,3 +76,19 @@ func List(ctx context.Context) error { kubernetes.ListHelmCharts(charts) return nil } + +// Changes implements a target that check if the current branch has changes +// related to main branch +func Changes(_ context.Context) error { + changes, err := kubernetes.HasChanges() + if err != nil { + return err + } + + if changes { + fmt.Println("true") + return nil + } + fmt.Println("false") + return nil +} diff --git a/targets/goapp/kubernetes.go b/targets/goapp/kubernetes.go index e7d37ce8..24467b77 100644 --- a/targets/goapp/kubernetes.go +++ b/targets/goapp/kubernetes.go @@ -27,3 +27,10 @@ func (K8s) List(ctx context.Context) error { mg.CtxDeps(ctx, kubernetesTargets.List) return nil } + +// Changes returns the string true or false depending on the fact that +// the current branch contains changes compared to the main branch. +func (K8s) Changes(ctx context.Context) error { + mg.CtxDeps(ctx, kubernetesTargets.Changes) + return nil +} From 9c34703696494ae003f0d2c6f25cfe42400ee4a1 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Wed, 4 Mar 2026 15:16:23 +0100 Subject: [PATCH 16/28] use custom escape Signed-off-by: Atze de Vries --- internal/github/github.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/github/github.go b/internal/github/github.go index 405fde45..bd9b213e 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "net/url" "os" "os/exec" "slices" @@ -104,10 +103,19 @@ func PrintActionMessage(level, title, message string) error { if !slices.Contains(allowedLevels, level) { return fmt.Errorf("supplied level %s is not in the list %s", level, strings.Join(allowedLevels, ",")) } - fmt.Printf("::%s title=%s::%s", level, url.QueryEscape(title), url.QueryEscape(message)) + fmt.Printf("::%s title=%s::%s", level, gitHubActionsEscape(title), gitHubActionsEscape(message)) return nil } +func gitHubActionsEscape(s string) string { + r := strings.NewReplacer( + "%", "%25", + "\n", "%0A", + "\r", "%0D", + ) + return r.Replace(s) +} + // InCI returns a true if you are runing in Github Actions func InCI() bool { _, found := os.LookupEnv("CI") From a6ed219ccc6bfe0227ab97ec0975022fc91c23d1 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Wed, 4 Mar 2026 15:33:33 +0100 Subject: [PATCH 17/28] add log groups Signed-off-by: Atze de Vries --- internal/github/github.go | 14 ++++++++++++++ internal/kubernetes/kubernetes.go | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/internal/github/github.go b/internal/github/github.go index bd9b213e..3aee1a49 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -116,6 +116,20 @@ func gitHubActionsEscape(s string) string { return r.Replace(s) } +// StartLogGroup starts a log group if running in github actions +func StartLogGroup(name string) { + if InCI() { + fmt.Printf("::group::%s\n", gitHubActionsEscape(name)) + } +} + +// EndLogGroup ends a log group if running in github actions +func EndLogGroup() { + if InCI() { + fmt.Println("::endgroup::") + } +} + // InCI returns a true if you are runing in Github Actions func InCI() bool { _, found := os.LookupEnv("CI") diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index d912ad14..e0c24da5 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -310,7 +310,9 @@ func ValidateWithKubeConform(chart HelmChart) error { return err } args = append(args, files...) + github.StartLogGroup("kubeconform") out, _, err := kubeconform.Run(nil, dest, args...) + github.EndLogGroup() if github.InCI() && err != nil { err := github.PrintActionMessage("error", fmt.Sprintf("kubeconform failed for %s %s", filepath.Base(chart.path), chart.env), out) if err != nil { @@ -343,7 +345,9 @@ func ValidateWithKubeScore(chart HelmChart) error { return nil } args = append(args, files...) + github.StartLogGroup("kube-score") out, _, err := kubescore.Run(nil, dest, args...) + github.EndLogGroup() if github.InCI() && err != nil { err := github.PrintActionMessage("error", fmt.Sprintf("kubecore failed for %s %s", filepath.Base(chart.path), chart.env), out) if err != nil { From 1179430b5603f8239c78fbe13ae572af67dd6e95 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Wed, 4 Mar 2026 16:03:53 +0100 Subject: [PATCH 18/28] fix a bunch of linting issues Signed-off-by: Atze de Vries --- internal/core/cmd.go | 3 +-- internal/core/core.go | 5 ++++- internal/git/git.go | 2 +- internal/github/github.go | 2 +- internal/kubernetes/kubernetes.go | 5 ++--- internal/kubernetes/kubernetes_test.go | 5 +---- internal/targets/kubernetes/kubernetes.go | 5 ++--- targets/goapp/kubernetes.go | 1 + 8 files changed, 13 insertions(+), 15 deletions(-) diff --git a/internal/core/cmd.go b/internal/core/cmd.go index 1f7dd50e..8f3f7514 100644 --- a/internal/core/cmd.go +++ b/internal/core/cmd.go @@ -148,7 +148,7 @@ func OutputWith(env map[string]string, cmd string, args ...string) (string, erro return OutputAtWith(env, "", cmd, args...) } -// OutputAt With is like RunAtWith, but returns what is written to stdout. +// OutputAtWith is like RunAtWith, but returns what is written to stdout. func OutputAtWith(env map[string]string, pwd string, cmd string, args ...string) (string, error) { buf := &bytes.Buffer{} _, err := ExecAt(env, buf, os.Stderr, pwd, cmd, args...) @@ -172,7 +172,6 @@ func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...s // Ran reports if the command ran (rather than was not found or not executable). // Code reports the exit code the command returned if it ran. If err == nil, ran // is always true and code is always 0. - func ExecAt(env map[string]string, stdout, stderr io.Writer, pwd string, cmd string, args ...string) (ran bool, err error) { expand := func(s string) string { s2, ok := env[s] diff --git a/internal/core/core.go b/internal/core/core.go index f7f6df22..e471e588 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -188,7 +188,10 @@ func ListRescursiveFiles(root, pattern string) ([]string, error) { // Check if it's a file and if its name matches the pattern. if !d.IsDir() { // filepath.Match checks a filename against a glob pattern. - if matched, _ := filepath.Match(pattern, d.Name()); matched { + if matched, err := filepath.Match(pattern, d.Name()); matched { + if err != nil { + return err + } // make relateive relPath, err := filepath.Rel(root, path) if err != nil { diff --git a/internal/git/git.go b/internal/git/git.go index a7b0008d..3146fdea 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -112,7 +112,7 @@ func Worktree(branch string) (string, func(), error) { // We use git worktree remove which cleans up the admin files and the directory. cleanup := func() { - _ = sh.Run("git", "worktree", "remove", targetDir) + _ = sh.Run("git", "worktree", "remove", targetDir) //nolint:errcheck cleanupDir() } diff --git a/internal/github/github.go b/internal/github/github.go index 3aee1a49..96e679fa 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -130,7 +130,7 @@ func EndLogGroup() { } } -// InCI returns a true if you are runing in Github Actions +// InCI returns a true if you are running in Github Actions func InCI() bool { _, found := os.LookupEnv("CI") return found diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index e0c24da5..4aa96056 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -26,6 +26,7 @@ var ( dyff devtool.Dyff ) +// HelmChart represents a helmchart with the path env and valuefiles type HelmChart struct { path string env string @@ -44,7 +45,7 @@ func isHelmChart(p string, d fs.DirEntry) bool { // it is a file, then it will render all in 1 temiplate. // When third argument is set to true it will try to render even if some // files are not there. This is used when rendering a template which is in -// unkown state +// unknown state func RenderTemplates(chart HelmChart, dest string, try bool) error { if try { // if the chart does not exist it will just return an empty dir, which @@ -181,8 +182,6 @@ func DiffTemplates(chart HelmChart) error { return err } if found { - - // err := github.ReplaceCommentInPR(id, path) err := github.HideComment(id) if err != nil { return err diff --git a/internal/kubernetes/kubernetes_test.go b/internal/kubernetes/kubernetes_test.go index 3fb6dd30..0b18da34 100644 --- a/internal/kubernetes/kubernetes_test.go +++ b/internal/kubernetes/kubernetes_test.go @@ -1,7 +1,6 @@ package kubernetes import ( - _ "embed" "testing" "github.com/coopnorge/mage/internal/core" @@ -144,9 +143,7 @@ func TestKubeScore(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := ValidateWithKubeScore(tt.chart) if tt.wantErr { - if assert.Error(t, err, tt.name) { //&& tt.errMsg != "" { - // assert.Contains(t, err.Error(), tt.errMsg) - } + assert.Error(t, err, tt.name) } else { assert.NoError(t, err, tt.name) } diff --git a/internal/targets/kubernetes/kubernetes.go b/internal/targets/kubernetes/kubernetes.go index 43892fcf..473ed5f8 100644 --- a/internal/targets/kubernetes/kubernetes.go +++ b/internal/targets/kubernetes/kubernetes.go @@ -2,7 +2,6 @@ package kubernetes import ( "context" - _ "embed" "fmt" "github.com/coopnorge/mage/internal/core" @@ -53,7 +52,7 @@ func kubescore(_ context.Context, chart kubernetes.HelmChart) error { } // Diff runs a diff for all the helm charts compared to the manin brdnch -func Diff(ctx context.Context) error { +func Diff(_ context.Context) error { charts, err := kubernetes.FindHelmCharts(".") if err != nil { return err @@ -68,7 +67,7 @@ func Diff(ctx context.Context) error { } // List lists the found helm charts -func List(ctx context.Context) error { +func List(_ context.Context) error { charts, err := kubernetes.FindHelmCharts(".") if err != nil { return err diff --git a/targets/goapp/kubernetes.go b/targets/goapp/kubernetes.go index 24467b77..d764e690 100644 --- a/targets/goapp/kubernetes.go +++ b/targets/goapp/kubernetes.go @@ -23,6 +23,7 @@ func (K8s) Diff(ctx context.Context) error { return nil } +// List returns a list of found helm charts with their envs func (K8s) List(ctx context.Context) error { mg.CtxDeps(ctx, kubernetesTargets.List) return nil From 7fc417ed6c6afc36a0a4b859f822446cc13a4852 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Mar 2026 10:31:45 +0100 Subject: [PATCH 19/28] fix typos in devtool.dyff and add renovate snippet for dyff updates Signed-off-by: Atze de Vries --- .github/renovate.json5 | 11 +++++++++++ internal/devtool/dyff.go | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 18579a13..252be6b8 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -22,7 +22,18 @@ "datasourceTemplate": "npm", "depNameTemplate": "@roadiehq/backstage-entity-validator", "versioningTemplate": "semver", + }, + { + "customType": "regex", + "managerFilePatterns": ["/^internal/devtool/dyff.go$/"], + "matchStrings": [ + "const\\s+dyffVersion\\s*=\\s*(?[^\"]+)" + ], + "datasourceTemplate": "gitub-releases", + "depNameTemplate": "homeport/dyff", + "versioningTemplate": "semver", } + ], "packageRules": [ { diff --git a/internal/devtool/dyff.go b/internal/devtool/dyff.go index 29957c59..0c1b7669 100644 --- a/internal/devtool/dyff.go +++ b/internal/devtool/dyff.go @@ -13,7 +13,7 @@ import ( "github.com/magefile/mage/sh" ) -// Dyff holds the devtool for policy-bot +// Dyff holds the devtool for dyff type Dyff struct{} // DyffDockerfile the content of dyff.Dockerfile @@ -23,7 +23,7 @@ var DyffDockerfile string const dyffVersion = "1.10.5" -// Run runs the policy-bot devtool +// Run runs the dyff devtool func (dyff Dyff) Run(env map[string]string, workdir string, args ...string) (string, string, error) { if val, found := os.LookupEnv("DYFF_IN_DOCKER"); found && val == "1" { return dyff.runInDocker(env, workdir, args...) @@ -60,7 +60,7 @@ func (dyff Dyff) versionOK() error { return err } // set constraint that minor minus 5 version should be minimum - constraintString := fmt.Sprintf(">= %s.%s", strconv.Itoa(devtool.Segments()[0]), strconv.Itoa(devtool.Segments()[1]-2)) + constraintString := fmt.Sprintf(">= %s.%s", strconv.Itoa(devtool.Segments()[0]), strconv.Itoa(devtool.Segments()[1]-5)) constraint, err := version.NewConstraint(constraintString) if err != nil { return err From f5e2c3b8763a5a0365e64702430d4868fc0fd2ef Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Mar 2026 10:41:23 +0100 Subject: [PATCH 20/28] add test for listrecursivefiles Signed-off-by: Atze de Vries --- internal/core/core_test.go | 26 +++++++++++++++++++++++++- internal/core/testdata/folder1/a/a.txt | 0 internal/core/testdata/folder1/a/b.txt | 0 internal/core/testdata/folder1/a/g.md | 0 internal/core/testdata/folder1/b/c.txt | 0 internal/core/testdata/folder1/b/f.md | 0 6 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 internal/core/testdata/folder1/a/a.txt create mode 100644 internal/core/testdata/folder1/a/b.txt create mode 100644 internal/core/testdata/folder1/a/g.md create mode 100644 internal/core/testdata/folder1/b/c.txt create mode 100644 internal/core/testdata/folder1/b/f.md diff --git a/internal/core/core_test.go b/internal/core/core_test.go index ab06aaf2..862245e3 100644 --- a/internal/core/core_test.go +++ b/internal/core/core_test.go @@ -82,7 +82,7 @@ func TestMkdirTemp(t *testing.T) { got, cleanup, gotErr := core.MkdirTemp() assert.NoError(t, gotErr) assert.DirExists(t, got) - //assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("%s/.+", os.TempDir())), got) + // assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("%s/.+", os.TempDir())), got) assert.Regexp(t, regexp.MustCompile(filepath.Join(os.TempDir(), "/.+")), got) cleanup() }) @@ -148,3 +148,27 @@ func TestCompareChangesToPaths(t *testing.T) { }) } } + +func TestListRecursiveFiles(t *testing.T) { + tests := []struct { + name string // description of this test case + // Named input parameters for target function. + directory string + pattern string + want []string + }{ + { + name: "List files recursevely", + pattern: "*.txt", + directory: "testdata/folder1", + want: []string{"a/a.txt", "a/b.txt", "b/c.txt"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, gotErr := core.ListRescursiveFiles(tt.directory, tt.pattern) + assert.NoError(t, gotErr) + assert.ElementsMatch(t, tt.want, out) + }) + } +} diff --git a/internal/core/testdata/folder1/a/a.txt b/internal/core/testdata/folder1/a/a.txt new file mode 100644 index 00000000..e69de29b diff --git a/internal/core/testdata/folder1/a/b.txt b/internal/core/testdata/folder1/a/b.txt new file mode 100644 index 00000000..e69de29b diff --git a/internal/core/testdata/folder1/a/g.md b/internal/core/testdata/folder1/a/g.md new file mode 100644 index 00000000..e69de29b diff --git a/internal/core/testdata/folder1/b/c.txt b/internal/core/testdata/folder1/b/c.txt new file mode 100644 index 00000000..e69de29b diff --git a/internal/core/testdata/folder1/b/f.md b/internal/core/testdata/folder1/b/f.md new file mode 100644 index 00000000..e69de29b From ffb48040188b0be1351d4e09ea6090bac879dd04 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Mar 2026 11:02:04 +0100 Subject: [PATCH 21/28] make function outof printing buffers to strings Signed-off-by: Atze de Vries --- internal/devtool/devtool.go | 10 ++++++++++ internal/devtool/dyff.go | 6 +++--- internal/devtool/helm.go | 4 ++-- internal/devtool/kubeconform.go | 5 ++--- internal/devtool/kubescore.go | 4 ++-- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/internal/devtool/devtool.go b/internal/devtool/devtool.go index 77e79d63..7d2d149e 100644 --- a/internal/devtool/devtool.go +++ b/internal/devtool/devtool.go @@ -201,3 +201,13 @@ func setupStdOutErr(alwaysStdOut bool) devtoolOutput { BufErr: bufErr, } } + +// printOut returns the bufOut in strings with new lines. +func (o devtoolOutput) printOut() string { + return strings.TrimSuffix((o.BufOut).String(), "\n") +} + +// printErr returns the bufErr in stringswith new lines. +func (o devtoolOutput) printErr() string { + return strings.TrimSuffix((o.BufErr).String(), "\n") +} diff --git a/internal/devtool/dyff.go b/internal/devtool/dyff.go index 0c1b7669..e1bbbb19 100644 --- a/internal/devtool/dyff.go +++ b/internal/devtool/dyff.go @@ -60,7 +60,7 @@ func (dyff Dyff) versionOK() error { return err } // set constraint that minor minus 5 version should be minimum - constraintString := fmt.Sprintf(">= %s.%s", strconv.Itoa(devtool.Segments()[0]), strconv.Itoa(devtool.Segments()[1]-5)) + constraintString := fmt.Sprintf(">= %s.%s", strconv.Itoa(devtool.Segments()[0]), strconv.Itoa(devtool.Segments()[1]-2)) constraint, err := version.NewConstraint(constraintString) if err != nil { return err @@ -75,7 +75,7 @@ func (dyff Dyff) runNative(env map[string]string, workdir string, args ...string outs := setupStdOutErr(true) _, err := core.ExecAt(env, outs.StdOut, outs.StdErr, workdir, "dyff", args...) - return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err + return outs.printOut(), outs.printErr(), err } func (dyff Dyff) runInDocker(env map[string]string, workdir string, args ...string) (string, string, error) { @@ -107,7 +107,7 @@ func (dyff Dyff) runInDocker(env map[string]string, workdir string, args ...stri outs := setupStdOutErr(true) _, err = core.Exec(env, outs.StdOut, outs.StdErr, "docker", runArgs...) - return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err + return outs.printOut(), outs.printErr(), err } func (dyff Dyff) buildImage() (string, error) { diff --git a/internal/devtool/helm.go b/internal/devtool/helm.go index a565b12c..71e09ef7 100644 --- a/internal/devtool/helm.go +++ b/internal/devtool/helm.go @@ -69,7 +69,7 @@ func (helm Helm) runNative(env map[string]string, workdir string, args ...string outs := setupStdOutErr(false) _, err := core.ExecAt(env, outs.StdOut, outs.StdErr, workdir, "helm", helm.addDefautsArgs(args...)...) - return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err + return outs.printOut(), outs.printErr(), err } func (helm Helm) runInDocker(env map[string]string, workdir string, args ...string) (string, string, error) { @@ -101,7 +101,7 @@ func (helm Helm) runInDocker(env map[string]string, workdir string, args ...stri outs := setupStdOutErr(false) _, err = sh.Exec(env, outs.StdOut, outs.StdErr, "docker", runArgs...) - return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err + return outs.printOut(), outs.printErr(), err } func (helm Helm) addDefautsArgs(args ...string) []string { diff --git a/internal/devtool/kubeconform.go b/internal/devtool/kubeconform.go index b4f42060..28269454 100644 --- a/internal/devtool/kubeconform.go +++ b/internal/devtool/kubeconform.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "strconv" - "strings" "github.com/coopnorge/mage/internal/core" "github.com/hashicorp/go-version" @@ -67,7 +66,7 @@ func (kf KubeConform) runNative(env map[string]string, workdir string, args ...s outs := setupStdOutErr(true) _, err := core.ExecAt(env, outs.StdOut, outs.StdErr, workdir, "kubeconform", kf.addDefautsArgs(args...)...) - return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err + return outs.printOut(), outs.printErr(), err } // DevtoolGo runs the devtool for Go @@ -102,7 +101,7 @@ func (kf KubeConform) runInDocker(env map[string]string, workdir string, args .. outs := setupStdOutErr(true) _, err = core.Exec(env, outs.StdOut, outs.StdErr, "docker", runArgs...) - return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err + return outs.printOut(), outs.printErr(), err } func (kf KubeConform) addDefautsArgs(args ...string) []string { diff --git a/internal/devtool/kubescore.go b/internal/devtool/kubescore.go index 7588e20c..000fc234 100644 --- a/internal/devtool/kubescore.go +++ b/internal/devtool/kubescore.go @@ -70,7 +70,7 @@ func (kubescore KubeScore) runNative(env map[string]string, workdir string, args outs := setupStdOutErr(true) _, err := core.ExecAt(env, outs.StdOut, outs.StdErr, workdir, "kube-score", kubescore.addDefautsArgs(args...)...) - return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err + return outs.printOut(), outs.printErr(), err } func (kubescore KubeScore) runInDocker(env map[string]string, workdir string, args ...string) (string, string, error) { @@ -102,7 +102,7 @@ func (kubescore KubeScore) runInDocker(env map[string]string, workdir string, ar outs := setupStdOutErr(true) _, err = core.Exec(env, outs.StdOut, outs.StdErr, "docker", kubescore.addDefautsArgs(runArgs...)...) - return strings.TrimSuffix((outs.BufOut).String(), "\n"), strings.TrimSuffix((outs.BufErr).String(), "\n"), err + return outs.printOut(), outs.printErr(), err } func (kubescore KubeScore) addDefautsArgs(args ...string) []string { From 3919a639d7d7bda024ec48e1b463bea4297088da Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Mar 2026 11:02:17 +0100 Subject: [PATCH 22/28] return removal error as string in worktree Signed-off-by: Atze de Vries --- internal/git/git.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/git/git.go b/internal/git/git.go index 3146fdea..a69976ab 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -112,7 +112,10 @@ func Worktree(branch string) (string, func(), error) { // We use git worktree remove which cleans up the admin files and the directory. cleanup := func() { - _ = sh.Run("git", "worktree", "remove", targetDir) //nolint:errcheck + err = sh.Run("git", "worktree", "remove", targetDir) + if err != nil { + fmt.Printf("Failed to delete %s, error %s", targetDir, err) + } cleanupDir() } From 6230409e163c104ab3b39a74db62526fe7631309 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Mar 2026 11:02:30 +0100 Subject: [PATCH 23/28] add kubernets validation as default target Signed-off-by: Atze de Vries --- targets/goapp/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/targets/goapp/main.go b/targets/goapp/main.go index f4606907..d1df7df6 100644 --- a/targets/goapp/main.go +++ b/targets/goapp/main.go @@ -81,7 +81,7 @@ func Build(ctx context.Context) error { // // For details see [Go.Validate], [Terraform.Validate] and [Docker.Validate]. func Validate(ctx context.Context) error { - mg.CtxDeps(ctx, Go.Validate, Docker.Validate, Terraform.Validate, CatalogInfo.Validate, PolicyBotConfig.Validate, Pallets.Validate) + mg.CtxDeps(ctx, Go.Validate, Docker.Validate, Terraform.Validate, CatalogInfo.Validate, PolicyBotConfig.Validate, Pallets.Validate, K8s.Validate) return nil } From 4cd42ef61593759a9995a1fdcb0689db9f6d7977 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Mar 2026 11:27:49 +0100 Subject: [PATCH 24/28] fix dyff version diff Signed-off-by: Atze de Vries --- internal/devtool/dyff.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/devtool/dyff.go b/internal/devtool/dyff.go index e1bbbb19..e7982ca7 100644 --- a/internal/devtool/dyff.go +++ b/internal/devtool/dyff.go @@ -60,7 +60,7 @@ func (dyff Dyff) versionOK() error { return err } // set constraint that minor minus 5 version should be minimum - constraintString := fmt.Sprintf(">= %s.%s", strconv.Itoa(devtool.Segments()[0]), strconv.Itoa(devtool.Segments()[1]-2)) + constraintString := fmt.Sprintf(">= %s.%s", strconv.Itoa(devtool.Segments()[0]), strconv.Itoa(devtool.Segments()[1]-5)) constraint, err := version.NewConstraint(constraintString) if err != nil { return err From 5b47d37f96f11acf5fd2ada5564a64d8de15a2c8 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Mar 2026 11:40:41 +0100 Subject: [PATCH 25/28] fix version comment in kubescore Signed-off-by: Atze de Vries --- internal/devtool/kubescore.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/devtool/kubescore.go b/internal/devtool/kubescore.go index 000fc234..3fe612d3 100644 --- a/internal/devtool/kubescore.go +++ b/internal/devtool/kubescore.go @@ -54,7 +54,7 @@ func (kubescore KubeScore) versionOK() error { if err != nil { return err } - // set constraint that minor minus 5 version should be minimum + // set constraint that minor minus 2 version should be minimum constraintString := fmt.Sprintf(">= %s.%s", strconv.Itoa(devtool.Segments()[0]), strconv.Itoa(devtool.Segments()[1]-2)) constraint, err := version.NewConstraint(constraintString) if err != nil { From 8222c8bf86252fab5b6d12597c1fa06e70fc50b1 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Mar 2026 12:02:26 +0100 Subject: [PATCH 26/28] rename ListRecursiveFiles to ListFilesRecursively Signed-off-by: Atze de Vries --- internal/core/core.go | 4 ++-- internal/core/core_test.go | 4 ++-- internal/kubernetes/kubernetes.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/core/core.go b/internal/core/core.go index e471e588..3ac8a453 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -174,8 +174,8 @@ func GetRepoRoot() (string, error) { return cwd, nil } -// ListRescursiveFiles recursively finds all files in the root directory that match the given pattern. -func ListRescursiveFiles(root, pattern string) ([]string, error) { +// ListFilesRecursively recursively finds all files in the root directory that match the given pattern. +func ListFilesRecursively(root, pattern string) ([]string, error) { var matches []string err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { diff --git a/internal/core/core_test.go b/internal/core/core_test.go index 862245e3..bd32ea73 100644 --- a/internal/core/core_test.go +++ b/internal/core/core_test.go @@ -149,7 +149,7 @@ func TestCompareChangesToPaths(t *testing.T) { } } -func TestListRecursiveFiles(t *testing.T) { +func TestListFilesRecursively(t *testing.T) { tests := []struct { name string // description of this test case // Named input parameters for target function. @@ -166,7 +166,7 @@ func TestListRecursiveFiles(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - out, gotErr := core.ListRescursiveFiles(tt.directory, tt.pattern) + out, gotErr := core.ListFilesRecursively(tt.directory, tt.pattern) assert.NoError(t, gotErr) assert.ElementsMatch(t, tt.want, out) }) diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index 4aa96056..aba53a42 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -304,7 +304,7 @@ func ValidateWithKubeConform(chart HelmChart) error { "-schema-location", "default", "--schema-location", "https://raw.githubusercontent.com/coopnorge/kubernetes-schemas/main/api-platform/{{ .ResourceKind }}{{ .KindSuffix }}.json", } - files, err := core.ListRescursiveFiles(dest, "*.yaml") + files, err := core.ListFilesRecursively(dest, "*.yaml") if err != nil { return err } @@ -336,7 +336,7 @@ func ValidateWithKubeScore(chart HelmChart) error { "score", } - files, err := core.ListRescursiveFiles(dest, "*.yaml") + files, err := core.ListFilesRecursively(dest, "*.yaml") if err != nil { return err } From 6014e58a3781ba746e3f5c40038a56948cf7c083 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Mar 2026 14:43:08 +0100 Subject: [PATCH 27/28] make finding of environment and related value values independed of a predefined environment list Signed-off-by: Atze de Vries --- internal/kubernetes/kubernetes.go | 27 +++++++++++++++++++++++++- internal/kubernetes/kubernetes_test.go | 5 +++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index aba53a42..64460be4 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -239,7 +239,6 @@ func diffMarkdownTemplate(title, summary, diff string, limit int) (string, error func FindHelmCharts(base string) ([]HelmChart, error) { directories := []string{} charts := []HelmChart{} - envs := []string{"dev", "test", "staging", "production"} err := filepath.WalkDir(base, func(workDir string, d fs.DirEntry, err error) error { if err != nil { @@ -258,6 +257,10 @@ func FindHelmCharts(base string) ([]HelmChart, error) { return nil, err } for _, dir := range directories { + envs, err := detectHelmEnvironments(dir) + if err != nil { + return charts, err + } for _, env := range envs { valueFiles, err := findHelmValues(dir, env) if err != nil { @@ -415,3 +418,25 @@ func findHelmValues(dir string, env string) ([]string, error) { } return files, nil } + +// detectHelmEnvironments will try to detect all environments for helm values +func detectHelmEnvironments(dir string) ([]string, error) { + // Try to detect environments + environments := []string{} + allEnvironmentFiles, err := filepath.Glob(fmt.Sprintf("%s/values-*.yaml", dir)) + if err != nil { + return environments, err + } + if err != nil { + return []string{}, err + } + for _, environmentFile := range allEnvironmentFiles { + environmentFileSlice := strings.Split(environmentFile, "-") + environment := strings.Split(environmentFileSlice[len(environmentFileSlice)-1], ".")[0] + if slices.Contains(environments, environment) { + continue + } + environments = append(environments, environment) + } + return environments, nil +} diff --git a/internal/kubernetes/kubernetes_test.go b/internal/kubernetes/kubernetes_test.go index 0b18da34..3f7b52d9 100644 --- a/internal/kubernetes/kubernetes_test.go +++ b/internal/kubernetes/kubernetes_test.go @@ -35,6 +35,11 @@ func TestFindHelmCharts(t *testing.T) { env: "dev", valueFiles: []string{"values.yaml", "values-this-dev.yaml"}, }, + { + path: "infrastructure/kubernetes/helm/charts/charta", + env: "fail", + valueFiles: []string{"values.yaml", "values-production-fail.yaml"}, + }, }, wantErr: false, }, From cb04608c8f826dee2e0200ae19666dc655d27c10 Mon Sep 17 00:00:00 2001 From: Atze de Vries Date: Fri, 6 Mar 2026 14:55:01 +0100 Subject: [PATCH 28/28] fix double err check Signed-off-by: Atze de Vries --- internal/kubernetes/kubernetes.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index 64460be4..137e8f0f 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -427,9 +427,6 @@ func detectHelmEnvironments(dir string) ([]string, error) { if err != nil { return environments, err } - if err != nil { - return []string{}, err - } for _, environmentFile := range allEnvironmentFiles { environmentFileSlice := strings.Split(environmentFile, "-") environment := strings.Split(environmentFileSlice[len(environmentFileSlice)-1], ".")[0]