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/.github/workflows/goapp.yaml b/.github/workflows/goapp.yaml index 8fdd23ae..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 @@ -149,3 +155,13 @@ jobs: contents: read pull-requests: read secrets: inherit + + kubernetes: + name: Kubernetes + needs: ["detect-changes"] + if: ${{ needs.detect-changes.outputs.kubernetes == '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/core/cmd.go b/internal/core/cmd.go index bcd283fc..8f3f7514 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) { +// 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 := 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,7 @@ 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 +200,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) } diff --git a/internal/core/core.go b/internal/core/core.go index a2917b26..3ac8a453 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -174,6 +174,53 @@ func GetRepoRoot() (string, error) { return cwd, nil } +// 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 { + // 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, err := filepath.Match(pattern, d.Name()); matched { + if err != nil { + return err + } + // make relateive + relPath, err := filepath.Rel(root, path) + if err != nil { + return err + } + matches = append(matches, relPath) + } + } + 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 +} + // GetAbsWorkDir accepts a directory as string and joins it with the current workdir // directory to return a absolute directory. If the supplied directory is // already absolute it will just return the input. diff --git a/internal/core/core_test.go b/internal/core/core_test.go index ab06aaf2..bd32ea73 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 TestListFilesRecursively(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.ListFilesRecursively(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 diff --git a/internal/devtool/devtool.go b/internal/devtool/devtool.go index 672f9b02..7d2d149e 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,51 @@ 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(alwaysStdOut bool) devtoolOutput { + bufOut := &bytes.Buffer{} + bufErr := &bytes.Buffer{} + var stdOutDevice io.Writer + if mg.Verbose() || alwaysStdOut { + 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, + } +} + +// 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 new file mode 100644 index 00000000..e7982ca7 --- /dev/null +++ b/internal/devtool/dyff.go @@ -0,0 +1,141 @@ +package devtool + +import ( + _ "embed" + "fmt" + "os" + "runtime" + "strconv" + "strings" + + "github.com/coopnorge/mage/internal/core" + "github.com/hashicorp/go-version" + "github.com/magefile/mage/sh" +) + +// Dyff holds the devtool for dyff +type Dyff struct{} + +// DyffDockerfile the content of dyff.Dockerfile +// +//go:embed dyff/dyff.Dockerfile +var DyffDockerfile string + +const dyffVersion = "1.10.5" + +// 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...) + } + + 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, 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, workdir, args...) + } + + fmt.Println("Using native dyff") + return dyff.runNative(env, workdir, 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]-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 (dyff Dyff) runNative(env map[string]string, workdir string, args ...string) (string, string, error) { + outs := setupStdOutErr(true) + _, err := core.ExecAt(env, outs.StdOut, outs.StdErr, workdir, "dyff", args...) + + return outs.printOut(), outs.printErr(), err +} + +func (dyff Dyff) runInDocker(env map[string]string, workdir string, args ...string) (string, string, error) { + image, err := dyff.buildImage() + if err != nil { + return "", "", err + } + dockerArgs := []string{ + "--volume", fmt.Sprintf("%s:/app", workdir), // 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, image) + runArgs = append(runArgs, args...) + + outs := setupStdOutErr(true) + _, err = core.Exec(env, outs.StdOut, outs.StdErr, "docker", runArgs...) + + return outs.printOut(), outs.printErr(), err +} + +func (dyff Dyff) buildImage() (string, error) { + // Entity valiator does not really seem to be maintained. We should look + // into alternatives in the future. + // + imageName := fmt.Sprintf("%s:%s", "dyff", dyffVersion) + + file, cleanup, err := core.WriteTempFile(core.OutputDir, fmt.Sprintf("%s.Dockerfile", "dyff"), DyffDockerfile) + if err != nil { + return "", err + } + defer cleanup() + + path, cleanup, err := core.MkdirTemp() + if err != nil { + return "", nil + } + defer cleanup() + + 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/devtool/helm.go b/internal/devtool/helm.go new file mode 100644 index 00000000..71e09ef7 --- /dev/null +++ b/internal/devtool/helm.go @@ -0,0 +1,109 @@ +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. 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, workdir string, args ...string) (string, string, error) { + if val, found := os.LookupEnv("HELM_IN_DOCKER"); found && val == "1" { + 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, 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, workdir, args...) + } + + fmt.Println("Using native helm") + return helm.runNative(env, workdir, 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, workdir string, args ...string) (string, string, error) { + outs := setupStdOutErr(false) + _, err := core.ExecAt(env, outs.StdOut, outs.StdErr, workdir, "helm", helm.addDefautsArgs(args...)...) + + return outs.printOut(), outs.printErr(), err +} + +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 + } + + dockerArgs := []string{ + "--volume", fmt.Sprintf("%s:/app", workdir), // 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...)...) + + outs := setupStdOutErr(false) + _, err = sh.Exec(env, outs.StdOut, outs.StdErr, "docker", runArgs...) + + return outs.printOut(), outs.printErr(), err +} + +func (helm Helm) addDefautsArgs(args ...string) []string { + return args +} diff --git a/internal/devtool/kubeconform.go b/internal/devtool/kubeconform.go index abd71549..28269454 100644 --- a/internal/devtool/kubeconform.go +++ b/internal/devtool/kubeconform.go @@ -14,20 +14,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 +62,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 outs.printOut(), outs.printErr(), 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 +98,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 outs.printOut(), outs.printErr(), err } func (kf KubeConform) addDefautsArgs(args ...string) []string { diff --git a/internal/devtool/kubescore.go b/internal/devtool/kubescore.go new file mode 100644 index 00000000..3fe612d3 --- /dev/null +++ b/internal/devtool/kubescore.go @@ -0,0 +1,110 @@ +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, 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, 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, workdir, args...) + } + + fmt.Println("Using native kube-score") + return kubescore.runNative(env, workdir, args...) +} + +func (kubescore KubeScore) versionOK() error { + devtoolData, err := getTool(ToolsDockerfile, "kube-score") + if err != nil { + return err + } + 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 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 { + 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, 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 outs.printOut(), outs.printErr(), err +} + +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 + } + + dockerArgs := []string{ + "--volume", fmt.Sprintf("%s:/app", workdir), // 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...)...) + + outs := setupStdOutErr(true) + _, err = core.Exec(env, outs.StdOut, outs.StdErr, "docker", kubescore.addDefautsArgs(runArgs...)...) + + return outs.printOut(), outs.printErr(), err +} + +func (kubescore KubeScore) addDefautsArgs(args ...string) []string { + return args +} diff --git a/internal/devtool/tools.Dockerfile b/internal/devtool/tools.Dockerfile index f63a08c7..d6bc3d5f 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.61.0@sha256:b835d64d66abfdbc146694b918eb3cd733ec772465ad511464d4e8bebbdd6732 AS tflint FROM docker.io/aquasec/trivy:0.69.3@sha256:bcc376de8d77cfe086a917230e818dc9f8528e3c852f7b1aff648949b6258d1c 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..a69976ab 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -6,6 +6,7 @@ import ( "os" "strings" + "github.com/coopnorge/mage/internal/core" "github.com/magefile/mage/sh" ) @@ -88,3 +89,35 @@ 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, 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) + } + + // We use git worktree remove which cleans up the admin files and the directory. + cleanup := func() { + err = sh.Run("git", "worktree", "remove", targetDir) + if err != nil { + fmt.Printf("Failed to delete %s, error %s", targetDir, err) + } + cleanupDir() + } + + return targetDir, cleanup, nil +} diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 00000000..96e679fa --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,148 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "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, gitHubActionsEscape(title), gitHubActionsEscape(message)) + return nil +} + +func gitHubActionsEscape(s string) string { + r := strings.NewReplacer( + "%", "%25", + "\n", "%0A", + "\r", "%0D", + ) + 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 running 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 new file mode 100644 index 00000000..137e8f0f --- /dev/null +++ b/internal/kubernetes/kubernetes.go @@ -0,0 +1,439 @@ +// Package kubernetes has the concern of validating pallets +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 ( + helm devtool.Helm + kubeconform devtool.KubeConform + kubescore devtool.KubeScore + dyff devtool.Dyff +) + +// HelmChart represents a helmchart with the path env and valuefiles +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 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 +// 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 + // we can diff against + if !core.FileExists(filepath.Join(chart.path, "Chart.yaml")) { + return 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, file) + } + args := []string{} + args = append(args, "template") + args = append(args, valueFilesFlags...) + if filepath.Ext(dest) == "" { + args = append(args, "--output-dir") + args = append(args, dest) + } + args = append(args, ".") + + // 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 err + } + path = filepath.Join(base, chart.path) + } + // make sure dependencies are there + depstatus, _, err := helm.Run(nil, path, "dep", "list", ".") + if err != nil { + 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) != "" { + fmt.Printf("write to file %s\n", dest) + return os.WriteFile(dest, []byte(out), 0o644) + } + 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 + + 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 + } + + mainWorktree, worktreeCleanup, err := git.Worktree("main") + 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, + } + + err = RenderTemplates(mainChart, filepath.Join(diffDir, mainFilename), true) + if err != nil { + return err + } + + args := []string{ + "--color", "on", + "--truecolor", "on", + "between", + } + if github.InCI() { + args = append(args, "--output", "github") + } + + 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 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 + } + + 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) + } + 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.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) { + directories := []string{} + charts := []HelmChart{} + + 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 { + envs, err := detectHelmEnvironments(dir) + if err != nil { + return charts, err + } + 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 +} + +// ListHelmCharts list the found helm charts in this repository +func ListHelmCharts(charts []HelmChart) { + for _, chart := range charts { + 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, "\", \"")) + } +} + +// ValidateWithKubeConform will run kubeconform validation on a supplied +// HelmChart +func ValidateWithKubeConform(chart HelmChart) error { + 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.ListFilesRecursively(dest, "*.yaml") + if err != nil { + 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 { + 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() + if err != nil { + return err + } + err = RenderTemplates(chart, dest, false) + if err != nil { + return err + } + args := []string{ + "score", + } + + files, err := core.ListFilesRecursively(dest, "*.yaml") + if err != nil { + return err + } + if len(files) == 0 { + 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 { + return err + } + } + 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 +// 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) { + // 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 +} + +// 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 + } + 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 new file mode 100644 index 00000000..3f7b52d9 --- /dev/null +++ b/internal/kubernetes/kubernetes_test.go @@ -0,0 +1,201 @@ +package kubernetes + +import ( + "testing" + + "github.com/coopnorge/mage/internal/core" + "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"}, + }, + { + path: "infrastructure/kubernetes/helm/charts/charta", + env: "fail", + valueFiles: []string{"values.yaml", "values-production-fail.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: "internal/kubernetes/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 := 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) + }) + } +} + +func TestKubeConform(t *testing.T) { + tests := []struct { + name string + chart HelmChart + wantErr bool + }{ + { + name: "KubeConform should pass", + chart: HelmChart{ + env: "staging", + path: "internal/kubernetes/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: "internal/kubernetes/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: "internal/kubernetes/testdata/repo/infrastructure/kubernetes/helm/charts/chartc", + valueFiles: []string{"values.yaml"}, + }, + wantErr: false, + }, + { + name: "KubeScore should fail", + chart: HelmChart{ + env: "production", + path: "internal/kubernetes/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 { + assert.Error(t, err, tt.name) + } else { + assert.NoError(t, err, tt.name) + } + }) + } +} + +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) + }) + } +} 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/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...) } diff --git a/internal/targets/kubernetes/kubernetes.go b/internal/targets/kubernetes/kubernetes.go new file mode 100644 index 00000000..473ed5f8 --- /dev/null +++ b/internal/targets/kubernetes/kubernetes.go @@ -0,0 +1,93 @@ +package kubernetes + +import ( + "context" + "fmt" + + "github.com/coopnorge/mage/internal/core" + "github.com/coopnorge/mage/internal/kubernetes" +) + +// Validate runs kubeconform, kube-score and render templates +func Validate(ctx context.Context) error { + charts, err := kubernetes.FindHelmCharts(".") + if err != nil { + return err + } + // 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 { + 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 + } + } + return nil +} + +func render(_ context.Context, chart kubernetes.HelmChart) error { + dest, cleanup, err := core.MkdirTemp() + defer cleanup() + if err != nil { + return err + } + return kubernetes.RenderTemplates(chart, dest, false) +} + +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(_ context.Context) error { + charts, err := kubernetes.FindHelmCharts(".") + if err != nil { + return err + } + for _, chart := range charts { + err = kubernetes.DiffTemplates(chart) + if err != nil { + return err + } + } + return nil +} + +// List lists the found helm charts +func List(_ context.Context) error { + charts, err := kubernetes.FindHelmCharts(".") + if err != nil { + return err + } + 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 new file mode 100644 index 00000000..d764e690 --- /dev/null +++ b/targets/goapp/kubernetes.go @@ -0,0 +1,37 @@ +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, 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 +} + +// 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 +} + +// 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 +} 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 }