├── .gitignore ├── CODEOWNERS ├── slice ├── testdata │ ├── non-kubernetes-skip │ │ ├── stdout │ │ └── stderr │ ├── non-kubernetes │ │ ├── stderr │ │ └── stdout.yaml │ ├── ingress-namespace │ │ ├── stderr │ │ └── stdout.yaml │ ├── simple-no-k8s.yaml │ ├── simple-no-k8s-crlf.yaml │ ├── simple-no-k8s │ │ └── example.yaml │ ├── non-kubernetes.yaml │ ├── non-kubernetes-skip.yaml │ ├── ingress-namespace.yaml │ ├── full │ │ ├── service-hello-docker-svc.yaml │ │ ├── -.yaml │ │ ├── deployment-hello-docker.yaml │ │ └── ingress-hello-docker-ing.yaml │ └── full.yaml ├── plural.go ├── output.go ├── errors_test.go ├── template_test.go ├── template.go ├── errors.go ├── validate_test.go ├── kube.go ├── split.go ├── utils.go ├── split_test.go ├── validate.go ├── process.go ├── template │ ├── funcs.go │ └── funcs_test.go ├── execute.go ├── execute_test.go └── process_test.go ├── main.go ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE └── workflows │ ├── testing.yml │ ├── stale.yml │ └── releasing.yaml ├── LICENSE ├── go.mod ├── Dockerfile ├── main_test.go ├── .krew.yaml ├── docs ├── including-excluding-items.md ├── why.md ├── configuring-cli.md ├── template_functions.md ├── faq.md └── examples.md ├── .goreleaser.yml ├── go.sum ├── README.md └── app.go /.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @patrickdappollonio 2 | -------------------------------------------------------------------------------- /slice/testdata/non-kubernetes-skip/stdout: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /slice/testdata/non-kubernetes/stderr: -------------------------------------------------------------------------------- 1 | 2 files parsed to stdout. 2 | -------------------------------------------------------------------------------- /slice/testdata/ingress-namespace/stderr: -------------------------------------------------------------------------------- 1 | 2 files parsed to stdout. 2 | -------------------------------------------------------------------------------- /slice/testdata/non-kubernetes-skip/stderr: -------------------------------------------------------------------------------- 1 | 0 files parsed to stdout. 2 | -------------------------------------------------------------------------------- /slice/testdata/simple-no-k8s.yaml: -------------------------------------------------------------------------------- 1 | foo: bar 2 | --- 3 | foo: baz 4 | --- 5 | bar: baz 6 | -------------------------------------------------------------------------------- /slice/testdata/simple-no-k8s-crlf.yaml: -------------------------------------------------------------------------------- 1 | foo: bar 2 | --- 3 | foo: baz 4 | --- 5 | bar: baz 6 | -------------------------------------------------------------------------------- /slice/testdata/simple-no-k8s/example.yaml: -------------------------------------------------------------------------------- 1 | foo: bar 2 | --- 3 | foo: baz 4 | --- 5 | bar: baz 6 | -------------------------------------------------------------------------------- /slice/testdata/non-kubernetes.yaml: -------------------------------------------------------------------------------- 1 | kind: foo 2 | name: bar 3 | age: baz 4 | --- 5 | another: file 6 | -------------------------------------------------------------------------------- /slice/testdata/non-kubernetes-skip.yaml: -------------------------------------------------------------------------------- 1 | kind: foo 2 | name: bar 3 | age: baz 4 | --- 5 | another: file 6 | -------------------------------------------------------------------------------- /slice/plural.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | func pluralize(s string, n int) string { 4 | if n == 1 { 5 | return s 6 | } 7 | return s + "s" 8 | } 9 | -------------------------------------------------------------------------------- /slice/testdata/non-kubernetes/stdout.yaml: -------------------------------------------------------------------------------- 1 | # File: foo-.yaml (28 bytes) 2 | kind: foo 3 | name: bar 4 | age: baz 5 | --- 6 | # File: -.yaml (13 bytes) 7 | another: file 8 | -------------------------------------------------------------------------------- /slice/testdata/ingress-namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: nginx-ingress 5 | --- 6 | apiVersion: v1 7 | kind: Namespace 8 | metadata: 9 | name: production 10 | -------------------------------------------------------------------------------- /slice/testdata/full/service-hello-docker-svc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: hello-docker-svc 5 | spec: 6 | selector: 7 | app: hello-docker-app 8 | ports: 9 | - port: 8000 10 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | if err := root().Execute(); err != nil { 10 | fmt.Fprintln(os.Stderr, "error:", err.Error()) 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /slice/testdata/full/-.yaml: -------------------------------------------------------------------------------- 1 | # foo 2 | --- 3 | # apiVersion: v1 4 | # kind: Pod 5 | # metadata: 6 | # name: hello-docker 7 | # labels: 8 | # app: hello-docker-app 9 | # spec: 10 | # containers: 11 | # - name: hello-docker-container 12 | # image: patrickdappollonio/hello-docker 13 | -------------------------------------------------------------------------------- /slice/testdata/ingress-namespace/stdout.yaml: -------------------------------------------------------------------------------- 1 | # File: pod-nginx-ingress.yaml (56 bytes) 2 | apiVersion: v1 3 | kind: Pod 4 | metadata: 5 | name: nginx-ingress 6 | --- 7 | # File: namespace-production.yaml (59 bytes) 8 | apiVersion: v1 9 | kind: Namespace 10 | metadata: 11 | name: production 12 | -------------------------------------------------------------------------------- /slice/output.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import "fmt" 4 | 5 | func (s *Split) WriteStderr(format string, args ...interface{}) { 6 | if s.opts.Quiet { 7 | return 8 | } 9 | 10 | fmt.Fprintf(s.opts.Stderr, format+"\n", args...) 11 | } 12 | 13 | func (s *Split) WriteStdout(format string, args ...interface{}) { 14 | fmt.Fprintf(s.opts.Stdout, format+"\n", args...) 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### What did you do? 4 | 5 | 6 | 7 | ### What did you expect to see? 8 | 9 | 10 | 11 | ### What did you see instead? 12 | 13 | 14 | -------------------------------------------------------------------------------- /slice/testdata/full/deployment-hello-docker.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: hello-docker 5 | spec: 6 | replicas: 3 7 | selector: 8 | matchLabels: 9 | app: hello-docker-app 10 | template: 11 | metadata: 12 | labels: 13 | app: hello-docker-app 14 | spec: 15 | containers: 16 | - name: hello-docker-container 17 | image: patrickdappollonio/hello-docker 18 | -------------------------------------------------------------------------------- /slice/testdata/full/ingress-hello-docker-ing.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: hello-docker-ing 5 | spec: 6 | ingressClassName: nginx 7 | rules: 8 | - host: foo.bar 9 | http: 10 | paths: 11 | - path: / 12 | pathType: Prefix 13 | backend: 14 | service: 15 | name: hello-docker-svc 16 | port: 17 | number: 8000 18 | -------------------------------------------------------------------------------- /slice/errors_test.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestErrorsInterface(t *testing.T) { 10 | require.Implementsf(t, (*error)(nil), &strictModeSkipErr{}, "strictModeSkipErr should implement error") 11 | require.Implementsf(t, (*error)(nil), &skipErr{}, "skipErr should implement error") 12 | require.Implementsf(t, (*error)(nil), &cantFindFieldErr{}, "cantFindFieldErr should implement error") 13 | } 14 | 15 | func requireErrorIf(t *testing.T, wantErr bool, err error) { 16 | if wantErr { 17 | require.Error(t, err) 18 | } else { 19 | require.NoError(t, err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | test-app: 8 | name: Test Application 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Clone repository 12 | uses: actions/checkout@v5 13 | - name: Set up Go 14 | uses: actions/setup-go@v6 15 | with: 16 | go-version-file: "go.mod" 17 | - name: Run golangci-lint 18 | uses: golangci/golangci-lint-action@v6 19 | with: 20 | version: latest 21 | - name: Test application 22 | run: go test -v ./... 23 | - name: Compile application 24 | run: go build 25 | -------------------------------------------------------------------------------- /slice/template_test.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTemplate_compileTemplate(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | opts Options 11 | wantErr bool 12 | }{ 13 | { 14 | name: "compile template generic", 15 | opts: Options{ 16 | GoTemplate: "{{.}}", 17 | }, 18 | }, 19 | { 20 | name: "non existent function", 21 | opts: Options{ 22 | GoTemplate: "{{. | foobarbaz}}", 23 | }, 24 | wantErr: true, 25 | }, 26 | { 27 | name: "existent function", 28 | opts: Options{ 29 | GoTemplate: "{{. | lower}}", 30 | }, 31 | }, 32 | } 33 | 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | s := &Split{opts: tt.opts, log: nolog} 37 | 38 | err := s.compileTemplate() 39 | requireErrorIf(t, tt.wantErr, err) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write # only for delete-branch option 11 | issues: write 12 | pull-requests: write 13 | steps: 14 | - uses: actions/stale@v10 15 | with: 16 | days-before-issue-stale: 30 17 | days-before-issue-close: 14 18 | stale-issue-label: "stale" 19 | exempt-issue-labels: "community-feedback,automated" 20 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 21 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 22 | days-before-pr-stale: -1 23 | days-before-pr-close: -1 24 | repo-token: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Patrick D'appollonio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/patrickdappollonio/kubectl-slice 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/mb0/glob v0.0.0-20160210091149-1eb79d2de6c4 7 | github.com/spf13/cobra v1.10.1 8 | github.com/spf13/pflag v1.0.10 9 | github.com/spf13/viper v1.21.0 10 | github.com/stretchr/testify v1.11.1 11 | golang.org/x/text v0.30.0 12 | gopkg.in/yaml.v3 v3.0.1 13 | ) 14 | 15 | require ( 16 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 17 | github.com/fsnotify/fsnotify v1.9.0 // indirect 18 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 19 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 20 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 21 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 22 | github.com/sagikazarmark/locafero v0.11.0 // indirect 23 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 24 | github.com/spf13/afero v1.15.0 // indirect 25 | github.com/spf13/cast v1.10.0 // indirect 26 | github.com/subosito/gotenv v1.6.0 // indirect 27 | go.yaml.in/yaml/v3 v3.0.4 // indirect 28 | golang.org/x/sys v0.29.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /slice/template.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "text/template" 8 | 9 | local "github.com/patrickdappollonio/kubectl-slice/slice/template" 10 | ) 11 | 12 | func (s *Split) compileTemplate() error { 13 | s.log.Printf("About to compile template: %q", s.opts.GoTemplate) 14 | t, err := template.New("split").Funcs(local.Functions).Parse(s.opts.GoTemplate) 15 | if err != nil { 16 | return fmt.Errorf("file name template parse failed: %w", improveExecError(err)) 17 | } 18 | 19 | s.template = t 20 | return nil 21 | } 22 | 23 | func improveExecError(err error) error { 24 | // Before you start screaming because I'm handling an error using strings, 25 | // consider that there's a longstanding open TODO to improve template.ExecError 26 | // to be more meaningful: 27 | // https://github.com/golang/go/blob/go1.17/src/text/template/exec.go#L107-L109 28 | 29 | if _, ok := err.(template.ExecError); !ok { 30 | if !strings.HasPrefix(err.Error(), "template:") { 31 | return err 32 | } 33 | } 34 | 35 | s := err.Error() 36 | 37 | if pos := strings.LastIndex(s, ":"); pos >= 0 { 38 | return template.ExecError{ 39 | Name: "", 40 | Err: errors.New(strings.TrimSpace(s[pos+1:])), 41 | } 42 | } 43 | 44 | return err 45 | } 46 | -------------------------------------------------------------------------------- /slice/errors.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type strictModeSkipErr struct { 9 | fieldName string 10 | } 11 | 12 | func (s *strictModeSkipErr) Error() string { 13 | return fmt.Sprintf( 14 | "resource does not have a Kubernetes %q field or the field is invalid or empty", s.fieldName, 15 | ) 16 | } 17 | 18 | type skipErr struct { 19 | name string 20 | kind string 21 | } 22 | 23 | func (e *skipErr) Error() string { 24 | return fmt.Sprintf("resource %s %q is configured to be skipped", e.kind, e.name) 25 | } 26 | 27 | const nonK8sHelper = `the file has no Kubernetes metadata: it is most likely a non-Kubernetes YAML file, you can skip it with --skip-non-k8s` 28 | 29 | type cantFindFieldErr struct { 30 | fieldName string 31 | fileCount int 32 | meta kubeObjectMeta 33 | } 34 | 35 | func (e *cantFindFieldErr) Error() string { 36 | var sb strings.Builder 37 | 38 | sb.WriteString(fmt.Sprintf( 39 | "unable to find Kubernetes %q field in file %d", 40 | e.fieldName, e.fileCount, 41 | )) 42 | 43 | if e.meta.empty() { 44 | sb.WriteString(": " + nonK8sHelper) 45 | } else { 46 | sb.WriteString(fmt.Sprintf( 47 | ": processed details: kind %q, name %q, apiVersion %q", 48 | e.meta.Kind, e.meta.Name, e.meta.APIVersion, 49 | )) 50 | } 51 | 52 | return sb.String() 53 | } 54 | -------------------------------------------------------------------------------- /slice/testdata/full.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # foo 3 | --- 4 | 5 | --- 6 | # apiVersion: v1 7 | # kind: Pod 8 | # metadata: 9 | # name: hello-docker 10 | # labels: 11 | # app: hello-docker-app 12 | # spec: 13 | # containers: 14 | # - name: hello-docker-container 15 | # image: patrickdappollonio/hello-docker 16 | 17 | --- 18 | apiVersion: apps/v1 19 | kind: Deployment 20 | metadata: 21 | name: hello-docker 22 | spec: 23 | replicas: 3 24 | selector: 25 | matchLabels: 26 | app: hello-docker-app 27 | template: 28 | metadata: 29 | labels: 30 | app: hello-docker-app 31 | spec: 32 | containers: 33 | - name: hello-docker-container 34 | image: patrickdappollonio/hello-docker 35 | 36 | --- 37 | apiVersion: v1 38 | kind: Service 39 | metadata: 40 | name: hello-docker-svc 41 | spec: 42 | selector: 43 | app: hello-docker-app 44 | ports: 45 | - port: 8000 46 | 47 | --- 48 | apiVersion: networking.k8s.io/v1 49 | kind: Ingress 50 | metadata: 51 | name: hello-docker-ing 52 | spec: 53 | ingressClassName: nginx 54 | rules: 55 | - host: foo.bar 56 | http: 57 | paths: 58 | - path: / 59 | pathType: Prefix 60 | backend: 61 | service: 62 | name: hello-docker-svc 63 | port: 64 | number: 8000 65 | -------------------------------------------------------------------------------- /.github/workflows/releasing.yaml: -------------------------------------------------------------------------------- 1 | name: Releasing 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | 7 | permissions: 8 | packages: write 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | name: Release Application 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v5 18 | with: 19 | fetch-depth: 0 20 | - name: Set up Go 21 | uses: actions/setup-go@v6 22 | with: 23 | go-version-file: "go.mod" 24 | - name: Run golangci-lint 25 | uses: golangci/golangci-lint-action@v6 26 | with: 27 | version: latest 28 | - name: Test application 29 | run: go test -v ./... 30 | - name: Docker login 31 | uses: docker/login-action@v3 32 | with: 33 | registry: "ghcr.io" 34 | username: ${{ github.repository_owner}} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | - name: Set up QEMU 37 | uses: docker/setup-qemu-action@v3 38 | - name: Release application to Github 39 | uses: goreleaser/goreleaser-action@v6 40 | with: 41 | distribution: goreleaser 42 | version: latest 43 | args: release --clean 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | - name: Update new version in krew-index 47 | uses: rajatjindal/krew-release-bot@v0.0.47 48 | 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG KUBECTL_VERSION=1.31.1 2 | ARG YQ_VERSION=v4.44.3 3 | 4 | # Stage 1: Download binaries 5 | FROM debian:12-slim as download_binary 6 | 7 | ARG KUBECTL_VERSION 8 | ARG YQ_VERSION 9 | 10 | # Install curl and certificates, and clean up in one layer to reduce image size 11 | RUN apt-get update && apt-get install -y --no-install-recommends \ 12 | curl \ 13 | ca-certificates \ 14 | && rm -rf /var/lib/apt/lists* 15 | 16 | # Download kubectl binary 17 | RUN curl -sSL -o /kubectl "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \ 18 | && chmod +x /kubectl 19 | 20 | # Download yq binary 21 | RUN curl -sSL -o /yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \ 22 | && chmod +x /yq 23 | 24 | # Stage 2 25 | FROM debian:12-slim 26 | 27 | ARG DEBIAN_FRONTEND=noninteractive 28 | 29 | RUN apt-get update && apt-get install -y --no-install-recommends \ 30 | sudo \ 31 | && useradd -m -s /bin/bash slice \ 32 | && echo 'slice ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/slice \ 33 | && chmod 0440 /etc/sudoers.d/slice \ 34 | && rm -rf /var/lib/apt/lists/* 35 | 36 | # Copy binaries from the download_binary stage 37 | COPY --from=download_binary /kubectl /usr/local/bin/kubectl 38 | COPY --from=download_binary /yq /usr/local/bin/yq 39 | COPY --from=download_binary /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 40 | 41 | # Copy kubectl-slice from local filesystem 42 | COPY kubectl-slice /usr/local/bin/kubectl-slice 43 | 44 | USER slice 45 | WORKDIR /home/slice 46 | -------------------------------------------------------------------------------- /slice/validate_test.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSplit_validateFilters(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | opts Options 11 | wantErr bool 12 | }{ 13 | { 14 | name: "prevent using allow skipping kind while using included kinds", 15 | opts: Options{ 16 | AllowEmptyKinds: true, 17 | IncludedKinds: []string{"foo"}, 18 | }, 19 | wantErr: true, 20 | }, 21 | { 22 | name: "prevent using allow skipping kind while using excluded kinds", 23 | opts: Options{ 24 | AllowEmptyKinds: true, 25 | ExcludedKinds: []string{"foo"}, 26 | }, 27 | wantErr: true, 28 | }, 29 | { 30 | name: "prevent using allow skipping name while using included names", 31 | opts: Options{ 32 | AllowEmptyNames: true, 33 | IncludedNames: []string{"foo"}, 34 | }, 35 | wantErr: true, 36 | }, 37 | { 38 | name: "prevent using allow skipping name while using excluded names", 39 | opts: Options{ 40 | AllowEmptyNames: true, 41 | ExcludedNames: []string{"foo"}, 42 | }, 43 | wantErr: true, 44 | }, 45 | { 46 | name: "cannot specify included and excluded kinds", 47 | opts: Options{ 48 | IncludedKinds: []string{"foo"}, 49 | ExcludedKinds: []string{"bar"}, 50 | }, 51 | wantErr: true, 52 | }, 53 | } 54 | for _, tt := range tests { 55 | t.Run(tt.name, func(t *testing.T) { 56 | s := &Split{opts: tt.opts} 57 | if err := s.validateFilters(); (err != nil) != tt.wantErr { 58 | t.Errorf("Split.validateFilters() error = %v, wantErr %v", err, tt.wantErr) 59 | } 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestMainApp(t *testing.T) { 12 | // A quick and dirty test run to make sure the 13 | // application run without errors and no regressions 14 | // are generated. 15 | // We also test for skipping non Kubernetes resources 16 | // in fake files. 17 | 18 | cases := []struct { 19 | name string 20 | file string 21 | flags []string 22 | stdout string 23 | stderr string 24 | }{ 25 | { 26 | name: "readme sample", 27 | file: "slice/testdata/ingress-namespace.yaml", 28 | stdout: "slice/testdata/ingress-namespace/stdout.yaml", 29 | stderr: "slice/testdata/ingress-namespace/stderr", 30 | }, 31 | { 32 | name: "non-kubernetes file", 33 | file: "slice/testdata/non-kubernetes.yaml", 34 | stdout: "slice/testdata/non-kubernetes/stdout.yaml", 35 | stderr: "slice/testdata/non-kubernetes/stderr", 36 | }, 37 | { 38 | name: "non-kubernetes file with skip non k8s enabled", 39 | file: "slice/testdata/non-kubernetes-skip.yaml", 40 | flags: []string{"--skip-non-k8s"}, 41 | stdout: "slice/testdata/non-kubernetes-skip/stdout", 42 | stderr: "slice/testdata/non-kubernetes-skip/stderr", 43 | }, 44 | } 45 | 46 | for _, c := range cases { 47 | t.Run(c.name, func(tt *testing.T) { 48 | var stdout, stderr bytes.Buffer 49 | 50 | baseArgs := []string{"--input-file=" + c.file, "--stdout"} 51 | args := append(baseArgs, c.flags...) 52 | 53 | cmd := root() 54 | cmd.SetOut(&stdout) 55 | cmd.SetErr(&stderr) 56 | cmd.SetArgs(args) 57 | require.NoError(tt, cmd.Execute()) 58 | 59 | appout, err := os.ReadFile(c.stdout) 60 | require.NoError(tt, err) 61 | apperr, err := os.ReadFile(c.stderr) 62 | require.NoError(tt, err) 63 | 64 | require.EqualValues(tt, string(appout), stdout.String()) 65 | require.EqualValues(tt, string(apperr), stderr.String()) 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.krew.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: krew.googlecontainertools.github.com/v1alpha2 2 | kind: Plugin 3 | metadata: 4 | name: slice 5 | spec: 6 | version: {{ .TagName }} 7 | homepage: https://github.com/patrickdappollonio/kubectl-slice 8 | shortDescription: Split a multi-YAML file into individual files. 9 | description: | 10 | This is a tool that can split a multi-YAML Kubernetes manifest into multiple subfiles using 11 | a naming convention you choose. This is done by parsing the YAML code and giving you the option 12 | to access any key from the YAML object by loading it into a Go Templates. 13 | platforms: 14 | - selector: 15 | matchLabels: 16 | os: darwin 17 | arch: arm64 18 | {{addURIAndSha "https://github.com/patrickdappollonio/kubectl-slice/releases/download/v{{ slice .TagName 1 }}/kubectl-slice_darwin_arm64.tar.gz" .TagName | indent 6 }} 19 | bin: kubectl-slice 20 | - selector: 21 | matchLabels: 22 | os: darwin 23 | arch: amd64 24 | {{addURIAndSha "https://github.com/patrickdappollonio/kubectl-slice/releases/download/v{{ slice .TagName 1 }}/kubectl-slice_darwin_x86_64.tar.gz" .TagName | indent 6 }} 25 | bin: kubectl-slice 26 | - selector: 27 | matchLabels: 28 | os: linux 29 | arch: arm64 30 | {{addURIAndSha "https://github.com/patrickdappollonio/kubectl-slice/releases/download/v{{ slice .TagName 1 }}/kubectl-slice_linux_arm64.tar.gz" .TagName | indent 6 }} 31 | bin: kubectl-slice 32 | - selector: 33 | matchLabels: 34 | os: linux 35 | arch: arm 36 | {{addURIAndSha "https://github.com/patrickdappollonio/kubectl-slice/releases/download/v{{ slice .TagName 1 }}/kubectl-slice_linux_arm.tar.gz" .TagName | indent 6 }} 37 | bin: kubectl-slice 38 | - selector: 39 | matchLabels: 40 | os: linux 41 | arch: amd64 42 | {{addURIAndSha "https://github.com/patrickdappollonio/kubectl-slice/releases/download/v{{ slice .TagName 1 }}/kubectl-slice_linux_x86_64.tar.gz" .TagName | indent 6 }} 43 | bin: kubectl-slice 44 | - selector: 45 | matchLabels: 46 | os: windows 47 | arch: amd64 48 | {{addURIAndSha "https://github.com/patrickdappollonio/kubectl-slice/releases/download/v{{ slice .TagName 1 }}/kubectl-slice_windows_x86_64.tar.gz" .TagName | indent 6 }} 49 | bin: kubectl-slice.exe 50 | -------------------------------------------------------------------------------- /docs/including-excluding-items.md: -------------------------------------------------------------------------------- 1 | # Including and excluding items 2 | 3 | - [Including and excluding items](#including-and-excluding-items) 4 | - [Including items](#including-items) 5 | - [Excluding items](#excluding-items) 6 | - [Excluding non Kubernetes manifests](#excluding-non-kubernetes-manifests) 7 | 8 | `kubectl-slice` supports including and excluding items from the list of resources to be processed. You can achieve this by using the `--include` and `--exclude` flags and their extensions. 9 | 10 | ## Including items 11 | 12 | The following flags will allow you to include items by name or kind or both: 13 | 14 | * `--include-name`: include items by name. For example, on a pod named `foo`, you can use `--include-name=foo` to include it. 15 | * `--include-kind`: include items by kind. For example, on a pod, you can use `--include-kind=Pod` to include it. 16 | * `--include`: include items by kind and name, using the format `/`. For example, on a pod named `foo`, you can use `--include=Pod/foo` to include it. 17 | 18 | Globs are supported on all of the above flags so that you can use `--include-name=foo*` to include all resources with names starting with `foo`. For the `--include` flag, globs are supported on both the `` and `` parts so that you can use `--include=Pod/foo*` to include all pods with names starting with `foo`. 19 | 20 | ## Excluding items 21 | 22 | The following flags will allow you to exclude items by name or kind, or both: 23 | 24 | * `--exclude-name`: exclude items by name. For example, on a pod named `foo`, you can use `--exclude-name=foo` to exclude it. 25 | * `--exclude-kind`: exclude items by kind. For example, on a pod, you can use `--exclude-kind=Pod` to exclude it. 26 | * `--exclude`: exclude items by kind and name, using the format `/`. For example, on a pod named `foo`, you can use `--exclude=Pod/foo` to exclude it. 27 | 28 | ## Excluding non Kubernetes manifests 29 | 30 | In some cases, you might provide to `kubectl-slice` a list of YAML files that might not actually be Kubernetes manifests. The flag `--skip-non-k8s` can be used to skip these files that do not have an `apiVersion`, `kind` and `metadata.name`. 31 | 32 | Be aware there are no attempts to validate whether these fields are correct. If you're expecting to exclude a Kubernetes manifest with a nonexistent API version with this, it won't work. This flag is only meant to skip files that do not have the fields mentioned before, and it will perform no API calls to your Kubernetes cluster to check if the `apiVersion` and `kind` fields are valid objects or CRDs in your Kubernetes cluster. 33 | -------------------------------------------------------------------------------- /slice/kube.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | type yamlFile struct { 9 | filename string 10 | meta kubeObjectMeta 11 | data []byte 12 | } 13 | 14 | type kubeObjectMeta struct { 15 | APIVersion string 16 | Kind string 17 | Name string 18 | Namespace string 19 | Group string 20 | } 21 | 22 | func (objectMeta *kubeObjectMeta) GetGroupFromAPIVersion() string { 23 | fields := strings.Split(objectMeta.APIVersion, "/") 24 | if len(fields) == 2 { 25 | return strings.ToLower(fields[0]) 26 | } 27 | 28 | return "" 29 | } 30 | 31 | func (k kubeObjectMeta) empty() bool { 32 | return k.APIVersion == "" && k.Kind == "" && k.Name == "" && k.Namespace == "" 33 | } 34 | 35 | // from: https://github.com/helm/helm/blob/v3.11.1/pkg/releaseutil/kind_sorter.go#LL31-L67C2 36 | var helmInstallOrder = []string{ 37 | "Namespace", 38 | "NetworkPolicy", 39 | "ResourceQuota", 40 | "LimitRange", 41 | "PodSecurityPolicy", 42 | "PodDisruptionBudget", 43 | "ServiceAccount", 44 | "Secret", 45 | "SecretList", 46 | "ConfigMap", 47 | "StorageClass", 48 | "PersistentVolume", 49 | "PersistentVolumeClaim", 50 | "CustomResourceDefinition", 51 | "ClusterRole", 52 | "ClusterRoleList", 53 | "ClusterRoleBinding", 54 | "ClusterRoleBindingList", 55 | "Role", 56 | "RoleList", 57 | "RoleBinding", 58 | "RoleBindingList", 59 | "Service", 60 | "DaemonSet", 61 | "Pod", 62 | "ReplicationController", 63 | "ReplicaSet", 64 | "Deployment", 65 | "HorizontalPodAutoscaler", 66 | "StatefulSet", 67 | "Job", 68 | "CronJob", 69 | "IngressClass", 70 | "Ingress", 71 | "APIService", 72 | } 73 | 74 | // from: https://github.com/helm/helm/blob/v3.11.1/pkg/releaseutil/kind_sorter.go#L113-L119 75 | func sortYAMLsByKind(manifests []yamlFile) []yamlFile { 76 | sort.SliceStable(manifests, func(i, j int) bool { 77 | return lessByKind(manifests[i], manifests[j], manifests[i].meta.Kind, manifests[j].meta.Kind, helmInstallOrder) 78 | }) 79 | 80 | return manifests 81 | } 82 | 83 | // from: https://github.com/helm/helm/blob/v3.11.1/pkg/releaseutil/kind_sorter.go#L133-L158 84 | func lessByKind(_ interface{}, _ interface{}, kindA string, kindB string, o []string) bool { 85 | ordering := make(map[string]int, len(o)) 86 | for v, k := range o { 87 | ordering[k] = v 88 | } 89 | 90 | first, aok := ordering[kindA] 91 | second, bok := ordering[kindB] 92 | 93 | if !aok && !bok { 94 | // if both are unknown then sort alphabetically by kind, keep original order if same kind 95 | if kindA != kindB { 96 | return kindA < kindB 97 | } 98 | return first < second 99 | } 100 | // unknown kind is last 101 | if !aok { 102 | return false 103 | } 104 | if !bok { 105 | return true 106 | } 107 | // sort different kinds, keep original order if same priority 108 | return first < second 109 | } 110 | -------------------------------------------------------------------------------- /slice/split.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "log" 7 | "os" 8 | "text/template" 9 | ) 10 | 11 | const DefaultTemplateName = "{{.kind | lower}}-{{.metadata.name}}.yaml" 12 | 13 | // Logger is the interface used by Split to log debug messages 14 | // and it's satisfied by Go's log.Logger 15 | type Logger interface { 16 | Printf(format string, v ...interface{}) 17 | SetOutput(w io.Writer) 18 | Println(v ...interface{}) 19 | } 20 | 21 | // Split is a Kubernetes Split instance. Each instance has its own template 22 | // used to generate the resource names when saving to disk. Because of this, 23 | // avoid reusing the same instance of Split 24 | type Split struct { 25 | opts Options 26 | log Logger 27 | template *template.Template 28 | data *bytes.Buffer 29 | 30 | filesFound []yamlFile 31 | fileCount int 32 | } 33 | 34 | // New creates a new Split instance with the options set 35 | func New(opts Options) (*Split, error) { 36 | s := &Split{ 37 | log: log.New(io.Discard, "[debug] ", log.Lshortfile), 38 | } 39 | 40 | if opts.Stdout == nil { 41 | opts.Stdout = os.Stdout 42 | } 43 | 44 | if opts.Stderr == nil { 45 | opts.Stderr = os.Stderr 46 | } 47 | 48 | if opts.DebugMode { 49 | s.log.SetOutput(opts.Stderr) 50 | } 51 | 52 | s.opts = opts 53 | 54 | if err := s.init(); err != nil { 55 | return nil, err 56 | } 57 | 58 | return s, nil 59 | } 60 | 61 | // Options holds the Split options used when splitting Kubernetes resources 62 | type Options struct { 63 | Stdout io.Writer 64 | Stderr io.Writer 65 | 66 | InputFile string // the name of the input file to be read 67 | InputFolder string // the name of the input folder to be read 68 | InputFolderExt []string // the extensions of the files to be read 69 | Recurse bool // if true, the input folder will be read recursively 70 | OutputDirectory string // the path to the directory where the files will be stored 71 | PruneOutputDir bool // if true, the output directory will be pruned before writing the files 72 | OutputToStdout bool // if true, the output will be written to stdout instead of a file 73 | GoTemplate string // the go template code to render the file names 74 | DryRun bool // if true, no files are created 75 | DebugMode bool // enables debug mode 76 | Quiet bool // disables all writing to stdout/stderr 77 | IncludeTripleDash bool // include the "---" separator on resources sliced 78 | 79 | IncludedKinds []string 80 | ExcludedKinds []string 81 | IncludedNames []string 82 | ExcludedNames []string 83 | Included []string 84 | Excluded []string 85 | StrictKubernetes bool // if true, any YAMLs that don't contain at least an "apiVersion", "kind" and "metadata.name" will be excluded 86 | 87 | SortByKind bool // if true, it will sort the resources by kind 88 | RemoveFileComments bool // if true, it will remove comments generated by the app from the generated files 89 | 90 | AllowEmptyNames bool 91 | AllowEmptyKinds bool 92 | 93 | IncludedGroups []string 94 | ExcludedGroups []string 95 | } 96 | -------------------------------------------------------------------------------- /slice/utils.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | func inarray[T comparable](needle T, haystack []T) bool { 13 | for _, v := range haystack { 14 | if v == needle { 15 | return true 16 | } 17 | } 18 | 19 | return false 20 | } 21 | 22 | // loadfolder reads the folder contents recursively for `.yaml` and `.yml` files 23 | // and returns a buffer with the contents of all files found; returns the buffer 24 | // with all the files separated by `---` and the number of files found 25 | func loadfolder(extensions []string, folderPath string, recurse bool) (*bytes.Buffer, int, error) { 26 | var buffer bytes.Buffer 27 | var count int 28 | 29 | err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { 30 | if err != nil { 31 | return err 32 | } 33 | 34 | if info.IsDir() { 35 | if path != folderPath && !recurse { 36 | return filepath.SkipDir 37 | } 38 | return nil 39 | } 40 | 41 | ext := strings.ToLower(filepath.Ext(path)) 42 | if inarray(ext, extensions) { 43 | count++ 44 | 45 | data, err := os.ReadFile(path) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | if buffer.Len() > 0 { 51 | buffer.WriteString("\n---\n") 52 | } 53 | 54 | buffer.Write(data) 55 | } 56 | 57 | return nil 58 | }) 59 | if err != nil { 60 | return nil, 0, err 61 | } 62 | 63 | if buffer.Len() == 0 { 64 | return nil, 0, fmt.Errorf("no files found in %q with extensions: %s", folderPath, strings.Join(extensions, ", ")) 65 | } 66 | 67 | return &buffer, count, nil 68 | } 69 | 70 | func loadfile(fp string) (*bytes.Buffer, error) { 71 | f, err := openFile(fp) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | defer f.Close() 77 | 78 | var buf bytes.Buffer 79 | if _, err := io.Copy(&buf, f); err != nil { 80 | return nil, fmt.Errorf("unable to read file %q: %s", fp, err.Error()) 81 | } 82 | 83 | return &buf, nil 84 | } 85 | 86 | func openFile(fp string) (*os.File, error) { 87 | if fp == os.Stdin.Name() { 88 | // On Windows, the name in Go for stdin is `/dev/stdin` which doesn't 89 | // exist. It must use the syscall to point to the file and open it 90 | return os.Stdin, nil 91 | } 92 | 93 | // Any other file that's not stdin can be opened normally 94 | f, err := os.Open(fp) 95 | if err != nil { 96 | return nil, fmt.Errorf("unable to open file %q: %s", fp, err.Error()) 97 | } 98 | 99 | return f, nil 100 | } 101 | 102 | func deleteFolderContents(location string) error { 103 | f, err := os.Open(location) 104 | if err != nil { 105 | return fmt.Errorf("unable to open folder %q: %s", location, err.Error()) 106 | } 107 | defer f.Close() 108 | 109 | names, err := f.Readdirnames(-1) 110 | if err != nil { 111 | return fmt.Errorf("unable to read folder %q: %s", location, err.Error()) 112 | } 113 | 114 | for _, name := range names { 115 | if err := os.RemoveAll(location + "/" + name); err != nil { 116 | return fmt.Errorf("unable to remove %q: %s", name, err.Error()) 117 | } 118 | } 119 | 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /slice/split_test.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | type noopLogger struct{} 14 | 15 | func (noopLogger) Println(...interface{}) {} 16 | func (noopLogger) SetOutput(_ io.Writer) {} 17 | func (noopLogger) Printf(_ string, _ ...interface{}) {} 18 | 19 | var nolog = &noopLogger{} 20 | 21 | func TestEndToEnd(t *testing.T) { 22 | cases := []struct { 23 | name string 24 | inputFile string 25 | template string 26 | expectedFiles []string 27 | }{ 28 | { 29 | name: "end to end", 30 | inputFile: "full.yaml", 31 | template: DefaultTemplateName, 32 | expectedFiles: []string{ 33 | "full/-.yaml", 34 | "full/deployment-hello-docker.yaml", 35 | "full/ingress-hello-docker-ing.yaml", 36 | "full/service-hello-docker-svc.yaml", 37 | }, 38 | }, 39 | { 40 | name: "basic file, non-k8s", 41 | inputFile: "simple-no-k8s.yaml", 42 | template: "example.yaml", 43 | expectedFiles: []string{"simple-no-k8s/example.yaml"}, 44 | }, 45 | { 46 | name: "basic file, non-k8s, CRLF line endings", 47 | inputFile: "simple-no-k8s-crlf.yaml", 48 | template: "example.yaml", 49 | expectedFiles: []string{"simple-no-k8s/example.yaml"}, 50 | }, 51 | } 52 | 53 | for _, v := range cases { 54 | t.Run(v.name, func(tt *testing.T) { 55 | dir := t.TempDir() 56 | 57 | opts := Options{ 58 | InputFile: filepath.Join("testdata", v.inputFile), 59 | OutputDirectory: dir, 60 | GoTemplate: v.template, 61 | Stdout: io.Discard, 62 | Stderr: io.Discard, 63 | } 64 | 65 | slice, err := New(opts) 66 | require.NoError(tt, err, "not expecting an error") 67 | require.NoError(tt, slice.Execute(), "not expecting an error on Execute()") 68 | 69 | slice.log = nolog 70 | 71 | files, err := os.ReadDir(dir) 72 | require.NoError(tt, err, "not expecting an error on ReadDir()") 73 | 74 | converted := make(map[string]string) 75 | for _, v := range files { 76 | if v.IsDir() { 77 | continue 78 | } 79 | 80 | if !strings.HasSuffix(v.Name(), ".yaml") { 81 | continue 82 | } 83 | 84 | f, err := os.ReadFile(dir + "/" + v.Name()) 85 | if err != nil { 86 | tt.Fatalf("unable to read file %q: %s", v.Name(), err.Error()) 87 | } 88 | 89 | converted[v.Name()] = string(f) 90 | } 91 | 92 | require.Equalf(tt, len(v.expectedFiles), len(converted), "required vs converted file length mismatch") 93 | 94 | for _, originalName := range v.expectedFiles { 95 | originalValue, err := os.ReadFile(filepath.Join("testdata", originalName)) 96 | require.NoError(tt, err, "not expecting an error while reading testdata file") 97 | 98 | found := false 99 | currentFile := "" 100 | 101 | for receivedName, receivedValue := range converted { 102 | if filepath.Base(originalName) == receivedName { 103 | found = true 104 | currentFile = receivedValue 105 | break 106 | } 107 | } 108 | 109 | if !found { 110 | tt.Fatalf("expecting to find file %q but it was not found", originalName) 111 | } 112 | 113 | require.Equalf(tt, string(originalValue), currentFile, "on file %q", originalName) 114 | } 115 | }) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - env: 4 | - CGO_ENABLED=0 5 | goos: 6 | - linux 7 | - windows 8 | - darwin 9 | goarch: 10 | - amd64 11 | - arm 12 | - arm64 13 | tags: 14 | - netgo 15 | flags: 16 | - -trimpath 17 | ldflags: 18 | - -s -w -X main.version={{.Version}} -extldflags "-static" 19 | 20 | dockers: 21 | - image_templates: 22 | - "ghcr.io/patrickdappollonio/kubectl-slice:{{ .Tag }}-amd64" 23 | goos: linux 24 | goarch: amd64 25 | use: buildx 26 | build_flag_templates: 27 | - "--platform=linux/amd64" 28 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 29 | - "--label=org.opencontainers.image.description={{ .ProjectName }} version {{ .Version }}. See release notes at https://github.com/patrickdappollonio/{{ .ProjectName }}/releases/tag/v{{ .RawVersion }}" 30 | - "--label=org.opencontainers.image.url=https://github.com/patrickdappollonio/{{ .ProjectName }}" 31 | - "--label=org.opencontainers.image.source=https://github.com/patrickdappollonio/{{ .ProjectName }}" 32 | - "--label=org.opencontainers.image.version={{ .Version }}" 33 | - '--label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}' 34 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 35 | 36 | - image_templates: 37 | - "ghcr.io/patrickdappollonio/kubectl-slice:{{ .Tag }}-arm64" 38 | goos: linux 39 | goarch: arm64 40 | use: buildx 41 | build_flag_templates: 42 | - "--platform=linux/arm64" 43 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 44 | - "--label=org.opencontainers.image.description={{ .ProjectName }} version {{ .Version }}. See release notes at https://github.com/patrickdappollonio/{{ .ProjectName }}/releases/tag/v{{ .RawVersion }}" 45 | - "--label=org.opencontainers.image.url=https://github.com/patrickdappollonio/{{ .ProjectName }}" 46 | - "--label=org.opencontainers.image.source=https://github.com/patrickdappollonio/{{ .ProjectName }}" 47 | - "--label=org.opencontainers.image.version={{ .Version }}" 48 | - '--label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}' 49 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 50 | 51 | docker_manifests: 52 | - name_template: "ghcr.io/patrickdappollonio/kubectl-slice:v{{ .RawVersion }}" 53 | image_templates: 54 | - "ghcr.io/patrickdappollonio/kubectl-slice:{{ .Tag }}-amd64" 55 | - "ghcr.io/patrickdappollonio/kubectl-slice:{{ .Tag }}-arm64" 56 | - name_template: "ghcr.io/patrickdappollonio/kubectl-slice:v{{ .Major }}" 57 | image_templates: 58 | - "ghcr.io/patrickdappollonio/kubectl-slice:{{ .Tag }}-amd64" 59 | - "ghcr.io/patrickdappollonio/kubectl-slice:{{ .Tag }}-arm64" 60 | - name_template: "ghcr.io/patrickdappollonio/kubectl-slice:latest" 61 | image_templates: 62 | - "ghcr.io/patrickdappollonio/kubectl-slice:{{ .Tag }}-amd64" 63 | - "ghcr.io/patrickdappollonio/kubectl-slice:{{ .Tag }}-arm64" 64 | 65 | archives: 66 | - name_template: >- 67 | {{ .ProjectName }}_ 68 | {{- tolower .Os }}_ 69 | {{- if eq .Arch "amd64" }}x86_64 70 | {{- else if eq .Arch "386" }}i386 71 | {{- else }}{{ .Arch }}{{ end }} 72 | checksum: 73 | name_template: "checksums.txt" 74 | snapshot: 75 | version_template: "{{ incpatch .Version }}-next" 76 | changelog: 77 | sort: asc 78 | filters: 79 | exclude: 80 | - "^docs:" 81 | - "^test:" 82 | -------------------------------------------------------------------------------- /docs/why.md: -------------------------------------------------------------------------------- 1 | # Why `kubectl-slice`? 2 | 3 | - [Why `kubectl-slice`?](#why-kubectl-slice) 4 | - [Differences with other tools](#differences-with-other-tools) 5 | - [Losing the original file and its format](#losing-the-original-file-and-its-format) 6 | - [Naming format and access to data within YAML](#naming-format-and-access-to-data-within-yaml) 7 | 8 | Multiple services and applications to do GitOps require you to provide a folder similar to this: 9 | 10 | ```text 11 | . 12 | ├── cluster/ 13 | │ ├── foo-cluster-role-binding.yaml 14 | │ ├── foo-cluster-role.yaml 15 | │ └── ... 16 | └── namespaces/ 17 | ├── kube-system/ 18 | │ └── ... 19 | ├── prometheus-monitoring/ 20 | │ └── ... 21 | └── production/ 22 | ├── foo-role-binding.yaml 23 | ├── foo-service-account.yaml 24 | └── foo-deployment.yaml 25 | ``` 26 | 27 | Where resources that are globally scoped live in the `cluster/` folder -- or the folder designated by the service or application -- and namespace-specific resources live inside `namespaces/$NAME/`. 28 | 29 | Performing this task on big installations such as applications coming from Helm is a bit daunting, and a manual task. `kubectl-slice` can help by allowing you to read a single YAML file which holds multiple YAML manifests, parse each one of them, allow you to use their fields as parameters to generate custom names, then rendering those into individual files in a specific folder. 30 | 31 | ### Differences with other tools 32 | 33 | #### Losing the original file and its format 34 | 35 | There are other plugins and apps out there that can split your YAML into multiple sub-YAML files like `kubectl-slice`, however, they do it by decoding the YAML, processing it, then re-encode it again, which will lose its original definition. That means that some array pieces, for example, might be encoded to a different output -- while still keeping them as arrays; comments are also lost -- since the decoding to Go, then re-encoding back to YAML will ignore YAML Comments. 36 | 37 | `kubectl-slice` will keep the original file, and even when it will still parse it into Go to give you the ability to use any of the fields as part of the template for the name, the original file contents are still preserved with no changes, so your comments and the preference on how you render arrays, for example, will remain exactly the same as the original file. 38 | 39 | #### Naming format and access to data within YAML 40 | 41 | One of the things you can do too with `kubectl-slice` that you might not be able to do with other tools is the fact that with `kubectl-slice` you can literally access any field from the YAML file. Now, granted, if for example you decide to use an annotation in your YAML as part of the name template, that annotation may exist in _some_ of the YAMLs but perhaps not in all of them, so you have to account for that by providing a [`default`](template_functions.md#default) or using Go Template's `if else` blocks. 42 | 43 | Other apps might not allow you to read into the entire YAML, and even more so, they might enforce a convention on some of the fields you are able to access. Resource names, for example, [should follow a Kubernetes standard](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names) which some apps might edit preemptively since they don't make for good or "nice" file names, and as such, replace all dots for underscores. `kubectl-slice` will let you provide a template that might render an invalid file name, that's true, but you have [a plethora of functions](template_functions.md#replace) to modify its behavior yourself to something that fits your design better. Perhaps you prefer dashes rather than underscores, and you can do that. 44 | 45 | Upcoming versions will improve this even more by allowing annotation access using positions rather than names, for example. 46 | -------------------------------------------------------------------------------- /docs/configuring-cli.md: -------------------------------------------------------------------------------- 1 | # Configuring the CLI 2 | 3 | - [Configuring the CLI](#configuring-the-cli) 4 | - [Using a configuration file](#using-a-configuration-file) 5 | - [Using environment variables](#using-environment-variables) 6 | - [Using command-line flags](#using-command-line-flags) 7 | 8 | There are three ways to configure the CLI. Providing configuration to it has the following processing order: 9 | 10 | 1. Configuration file 11 | 2. Environment variables 12 | 3. Command-line flags 13 | 14 | The order of precedence dictates how `kubectl-slice` will handle being provided with configuration from multiple sources. 15 | 16 | The following example can illustrate this by providing `kubectl-slice` with an input file to process via three different ways: 17 | 18 | ```bash 19 | KUBECTL_SLICE_INPUT_FILE=2.yaml kubectl-slice -f 3.yaml --config $(echo "input_file: 1.yaml">>config.yaml && echo "config.yaml") 20 | ``` 21 | 22 | You'll notice the error message you get is that the file `3.yaml` doesn't exist. From the configuration file (first precedence), to the environment variable (second precedence), to the command-line flag (third precedence), `kubectl-slice` used `3.yaml`. 23 | 24 | Removing now the `-f 3.yaml` you'll see that `kubectl-slice` will use `2.yaml` as the input file, coming from the environment variable. Deleting the environment variable will load the setting from the configuration file. 25 | 26 | The order of precedence is useful if you want to provide a default configuration file and then override some of the settings using environment variables or command-line flags. 27 | 28 | ## Using a configuration file 29 | 30 | The configuration file is a YAML file that contains the settings for `kubectl-slice`. The configuration file uses the same format expected by the CLI flags, with the names of the flags being the keys of the YAML file and dashes replaced with underscores. 31 | 32 | For example, the `--input-file` flag becomes `input_file:` in the configuration file. 33 | 34 | The following is an example of a configuration file with the types defined: 35 | 36 | ```yaml 37 | input_file: string 38 | input_dir: string 39 | extensions: [string] 40 | recurse: boolean 41 | output_dir: string 42 | template: string 43 | dry_run: boolean 44 | debug: boolean 45 | quiet: boolean 46 | include_kind: [string] 47 | exclude_kind: [string] 48 | include_name: [string] 49 | exclude_name: [string] 50 | include: [string] 51 | exclude: [string] 52 | skip_non_k8s: bool 53 | sort_by_kind: bool 54 | stdout: bool 55 | ``` 56 | 57 | You can use this file to provide more complex templates by using multiline strings without having to escape special characters, for example: 58 | 59 | ```yaml 60 | template: > 61 | {{ .kind | lower }}/{{ .metadata.name | dottodash | replace ":" "-" }}.yaml 62 | ``` 63 | 64 | ## Using environment variables 65 | 66 | Similarly to what happens with YAML configuration files, we use the same format for environment variables, with the names of the flags being the keys of the environment variable and dashes replaced with underscores. The environment variable's name is also prefixed with `KUBECTL_SLICE`, and the entire key is uppercased. 67 | 68 | Here are a few examples of environment variables and their corresponding flags: 69 | 70 | | Environment variable | Flag | 71 | | -------------------------- | -------------- | 72 | | `KUBECTL_SLICE_INPUT_FILE` | `--input-file` | 73 | | `KUBECTL_SLICE_OUTPUT_DIR` | `--output-dir` | 74 | | `KUBECTL_SLICE_TEMPLATE` | `--template` | 75 | | `KUBECTL_SLICE_DRY_RUN` | `--dry-run` | 76 | | `KUBECTL_SLICE_DEBUG` | `--debug` | 77 | 78 | The same values as the YAML counterpart apply. In the case of booleans, `true` or `false` are valid values. In the case of arrays, the values are comma-separated. 79 | 80 | ## Using command-line flags 81 | 82 | The command-line flags are the most straightforward way to configure `kubectl-slice`. You can get an up-to-date list of the available flags by running `kubectl-slice --help`. 83 | -------------------------------------------------------------------------------- /slice/validate.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "path/filepath" 7 | "regexp" 8 | ) 9 | 10 | var ( 11 | regKN = regexp.MustCompile(`^[^/]+/[^/]+$`) 12 | extensions = []string{".yaml", ".yml"} 13 | ) 14 | 15 | func (s *Split) init() error { 16 | s.log.Printf("Initializing with settings: %#v", s.opts) 17 | 18 | if s.opts.InputFile != "" && s.opts.InputFolder != "" { 19 | return fmt.Errorf("cannot specify both input file and input folder") 20 | } 21 | 22 | if s.opts.InputFile == "" && s.opts.InputFolder == "" { 23 | return fmt.Errorf("input file or input folder is required") 24 | } 25 | 26 | var buf *bytes.Buffer 27 | 28 | if s.opts.InputFile != "" { 29 | s.log.Printf("Loading file %s", s.opts.InputFile) 30 | var err error 31 | buf, err = loadfile(s.opts.InputFile) 32 | if err != nil { 33 | return err 34 | } 35 | } 36 | 37 | if s.opts.InputFolder != "" { 38 | exts := extensions 39 | s.opts.InputFolder = filepath.Clean(s.opts.InputFolder) 40 | 41 | if len(s.opts.InputFolderExt) > 0 { 42 | exts = s.opts.InputFolderExt 43 | } 44 | 45 | s.log.Printf("Loading folder %q", s.opts.InputFolder) 46 | var err error 47 | var count int 48 | buf, count, err = loadfolder(exts, s.opts.InputFolder, s.opts.Recurse) 49 | if err != nil { 50 | return err 51 | } 52 | s.log.Printf("Found %d files in folder %q", count, s.opts.InputFolder) 53 | } 54 | 55 | if buf == nil || buf.Len() == 0 { 56 | return fmt.Errorf("no data found in input file or folder") 57 | } 58 | 59 | s.data = buf 60 | 61 | if s.opts.OutputToStdout { 62 | if s.opts.OutputDirectory != "" { 63 | return fmt.Errorf("cannot specify both output to stdout and output to file: output directory flag is set") 64 | } 65 | } else { 66 | if s.opts.OutputDirectory == "" { 67 | return fmt.Errorf("output directory flag is empty or not set") 68 | } 69 | } 70 | 71 | if err := s.compileTemplate(); err != nil { 72 | return err 73 | } 74 | 75 | return s.validateFilters() 76 | } 77 | 78 | func (s *Split) validateFilters() error { 79 | if len(s.opts.IncludedKinds) > 0 && s.opts.AllowEmptyKinds { 80 | return fmt.Errorf("cannot specify both included kinds and allow empty kinds") 81 | } 82 | 83 | if len(s.opts.ExcludedKinds) > 0 && s.opts.AllowEmptyKinds { 84 | return fmt.Errorf("cannot specify both excluded kinds and allow empty kinds") 85 | } 86 | 87 | if len(s.opts.IncludedNames) > 0 && s.opts.AllowEmptyNames { 88 | return fmt.Errorf("cannot specify both included names and allow empty names") 89 | } 90 | 91 | if len(s.opts.ExcludedNames) > 0 && s.opts.AllowEmptyNames { 92 | return fmt.Errorf("cannot specify both excluded names and allow empty names") 93 | } 94 | 95 | if len(s.opts.IncludedKinds) > 0 && len(s.opts.ExcludedKinds) > 0 { 96 | return fmt.Errorf("cannot specify both included and excluded kinds") 97 | } 98 | 99 | if len(s.opts.IncludedNames) > 0 && len(s.opts.ExcludedNames) > 0 { 100 | return fmt.Errorf("cannot specify both included and excluded names") 101 | } 102 | 103 | if len(s.opts.Included) > 0 && len(s.opts.Excluded) > 0 { 104 | return fmt.Errorf("cannot specify both included and excluded") 105 | } 106 | 107 | if len(s.opts.ExcludedGroups) > 0 && len(s.opts.IncludedGroups) > 0 { 108 | return fmt.Errorf("cannot specify both included and excluded groups") 109 | } 110 | 111 | // Merge all filters into excluded and included. 112 | for _, v := range s.opts.IncludedKinds { 113 | s.opts.Included = append(s.opts.Included, fmt.Sprintf("%s/*", v)) 114 | } 115 | 116 | for _, v := range s.opts.ExcludedKinds { 117 | s.opts.Excluded = append(s.opts.Excluded, fmt.Sprintf("%s/*", v)) 118 | } 119 | 120 | for _, v := range s.opts.IncludedNames { 121 | s.opts.Included = append(s.opts.Included, fmt.Sprintf("*/%s", v)) 122 | } 123 | 124 | for _, v := range s.opts.ExcludedNames { 125 | s.opts.Excluded = append(s.opts.Excluded, fmt.Sprintf("*/%s", v)) 126 | } 127 | 128 | // Validate included and excluded filters. 129 | for _, included := range s.opts.Included { 130 | if !regKN.MatchString(included) { 131 | return fmt.Errorf("invalid included pattern %q should be /", included) 132 | } 133 | } 134 | 135 | for _, excluded := range s.opts.Excluded { 136 | if !regKN.MatchString(excluded) { 137 | return fmt.Errorf("invalid excluded pattern %q should be /", excluded) 138 | } 139 | } 140 | 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 3 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 5 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 6 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 7 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 8 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 9 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 10 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 11 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 13 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 14 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 15 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 16 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 17 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 18 | github.com/mb0/glob v0.0.0-20160210091149-1eb79d2de6c4 h1:NK3O7S5FRD/wj7ORQ5C3Mx1STpyEMuFe+/F0Lakd1Nk= 19 | github.com/mb0/glob v0.0.0-20160210091149-1eb79d2de6c4/go.mod h1:FqD3ES5hx6zpzDainDaHgkTIqrPaI9uX4CVWqYZoQjY= 20 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 21 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 22 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 23 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 25 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 26 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 27 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 28 | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 29 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 30 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 31 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 32 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 33 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 34 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 35 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 36 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 37 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 38 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 39 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 40 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 41 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 42 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 43 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 44 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 45 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 46 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 47 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 48 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 49 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 50 | golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= 51 | golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 53 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 54 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 55 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 56 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 57 | -------------------------------------------------------------------------------- /slice/process.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/mb0/glob" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | // parseYAMLManifest parses a single YAML file as received by contents. It also renders the 14 | // template needed to generate its name 15 | func (s *Split) parseYAMLManifest(contents []byte) (yamlFile, error) { 16 | // All resources we'll handle are Kubernetes manifest, and even those who are lists, 17 | // they're still Kubernetes Objects of type List, so we can use a map 18 | manifest := make(map[string]interface{}) 19 | 20 | s.log.Println("Parsing YAML from buffer up to this point") 21 | if err := yaml.Unmarshal(contents, &manifest); err != nil { 22 | return yamlFile{}, fmt.Errorf("unable to parse YAML file number %d: %w", s.fileCount, err) 23 | } 24 | 25 | // Render the name to a buffer using the Go Template 26 | s.log.Println("Rendering filename template from Go Template") 27 | var buf bytes.Buffer 28 | if err := s.template.Execute(&buf, manifest); err != nil { 29 | return yamlFile{}, fmt.Errorf("unable to render file name for YAML file number %d: %w", s.fileCount, improveExecError(err)) 30 | } 31 | 32 | // Check if file contains the required Kubernetes metadata 33 | k8smeta := checkKubernetesBasics(manifest) 34 | 35 | // Check if at least the three fields are not empty 36 | if s.opts.StrictKubernetes { 37 | if k8smeta.APIVersion == "" { 38 | return yamlFile{}, &strictModeSkipErr{fieldName: "apiVersion"} 39 | } 40 | 41 | if k8smeta.Kind == "" { 42 | return yamlFile{}, &strictModeSkipErr{fieldName: "kind"} 43 | } 44 | 45 | if k8smeta.Name == "" { 46 | return yamlFile{}, &strictModeSkipErr{fieldName: "metadata.name"} 47 | } 48 | } 49 | 50 | // Check before handling if we're about to filter resources 51 | var ( 52 | hasIncluded = len(s.opts.Included) > 0 53 | hasExcluded = len(s.opts.Excluded) > 0 54 | ) 55 | 56 | s.log.Printf("Applying filters -> Included: %v; Excluded: %v", s.opts.Included, s.opts.Excluded) 57 | s.log.Printf("Kubernetes metadata found -> %#v", k8smeta) 58 | 59 | // Check if we have a Kubernetes kind and we're requesting inclusion or exclusion 60 | if k8smeta.Kind == "" && !s.opts.AllowEmptyKinds && (hasIncluded || hasExcluded) { 61 | return yamlFile{}, &cantFindFieldErr{fieldName: "kind", fileCount: s.fileCount, meta: k8smeta} 62 | } 63 | 64 | // Check if we have a Kubernetes name and we're requesting inclusion or exclusion 65 | if k8smeta.Name == "" && !s.opts.AllowEmptyNames && (hasIncluded || hasExcluded) { 66 | return yamlFile{}, &cantFindFieldErr{fieldName: "metadata.name", fileCount: s.fileCount, meta: k8smeta} 67 | } 68 | 69 | // We need to check if the file should be skipped 70 | if hasExcluded || hasIncluded { 71 | // If we're working with including only specific resources, then filter by them 72 | if hasIncluded && !inSliceIgnoreCaseGlob(s.opts.Included, fmt.Sprintf("%s/%s", k8smeta.Kind, k8smeta.Name)) { 73 | return yamlFile{}, &skipErr{kind: "kind/name", name: fmt.Sprintf("%s/%s", k8smeta.Kind, k8smeta.Name)} 74 | } 75 | 76 | // Otherwise exclude resources based on the parameter received 77 | if hasExcluded && inSliceIgnoreCaseGlob(s.opts.Excluded, fmt.Sprintf("%s/%s", k8smeta.Kind, k8smeta.Name)) { 78 | return yamlFile{}, &skipErr{kind: "kind/name", name: fmt.Sprintf("%s/%s", k8smeta.Kind, k8smeta.Name)} 79 | } 80 | } 81 | 82 | if len(s.opts.IncludedGroups) > 0 || len(s.opts.ExcludedGroups) > 0 { 83 | if k8smeta.APIVersion == "" { 84 | return yamlFile{}, &cantFindFieldErr{fieldName: "apiVersion", fileCount: s.fileCount, meta: k8smeta} 85 | } 86 | 87 | var groups []string 88 | if len(s.opts.IncludedGroups) > 0 { 89 | groups = s.opts.IncludedGroups 90 | } else if len(s.opts.ExcludedGroups) > 0 { 91 | groups = s.opts.ExcludedGroups 92 | } 93 | 94 | if err := checkGroup(k8smeta, groups, len(s.opts.IncludedGroups) > 0); err != nil { 95 | return yamlFile{}, &skipErr{} 96 | } 97 | } 98 | 99 | // Trim the file name 100 | name := strings.TrimSpace(buf.String()) 101 | 102 | // Fix for text/template Go issue #24963, as well as removing any linebreaks 103 | name = strings.NewReplacer("", "", "\n", "").Replace(name) 104 | 105 | if str := strings.TrimSuffix(name, filepath.Ext(name)); str == "" { 106 | return yamlFile{}, fmt.Errorf("file name rendered will yield no file name for YAML file number %d (original name: %q, metadata: %v)", s.fileCount, name, k8smeta) 107 | } 108 | 109 | return yamlFile{filename: name, meta: k8smeta}, nil 110 | } 111 | 112 | // inSliceIgnoreCase checks if a string is in a slice, ignoring case 113 | func inSliceIgnoreCase(slice []string, expected string) bool { 114 | expected = strings.ToLower(expected) 115 | 116 | for _, a := range slice { 117 | if strings.ToLower(a) == expected { 118 | return true 119 | } 120 | } 121 | 122 | return false 123 | } 124 | 125 | // inSliceIgnoreCaseGlob checks if a string is in a slice, ignoring case and 126 | // allowing the use of a glob pattern 127 | func inSliceIgnoreCaseGlob(slice []string, expected string) bool { 128 | expected = strings.ToLower(expected) 129 | 130 | for _, pattern := range slice { 131 | pattern = strings.ToLower(pattern) 132 | 133 | if match, _ := glob.Match(pattern, expected); match { 134 | return true 135 | } 136 | } 137 | 138 | return false 139 | } 140 | 141 | // checkStringInMap checks if a string is in a map, and if not, returns an error 142 | func checkStringInMap(local map[string]interface{}, key string) string { 143 | iface, found := local[key] 144 | 145 | if !found { 146 | return "" 147 | } 148 | 149 | str, ok := iface.(string) 150 | if !ok { 151 | return "" 152 | } 153 | 154 | return str 155 | } 156 | 157 | // checkKubernetesBasics check if the minimum required keys are there for a Kubernetes Object 158 | func checkKubernetesBasics(manifest map[string]interface{}) kubeObjectMeta { 159 | var metadata kubeObjectMeta 160 | 161 | metadata.APIVersion = checkStringInMap(manifest, "apiVersion") 162 | metadata.Kind = checkStringInMap(manifest, "kind") 163 | 164 | if md, found := manifest["metadata"]; found { 165 | metadata.Name = checkStringInMap(md.(map[string]interface{}), "name") 166 | metadata.Namespace = checkStringInMap(md.(map[string]interface{}), "namespace") 167 | } 168 | 169 | return metadata 170 | } 171 | 172 | func checkGroup(objmeta kubeObjectMeta, groupName []string, included bool) error { 173 | 174 | for _, group := range groupName { 175 | if included { 176 | if objmeta.GetGroupFromAPIVersion() == strings.ToLower(group) { 177 | return nil 178 | } 179 | } else { 180 | if objmeta.GetGroupFromAPIVersion() == strings.ToLower(group) { 181 | return &skipErr{} 182 | } 183 | } 184 | } 185 | 186 | if included { 187 | return &skipErr{} 188 | } else { 189 | return nil 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /slice/template/funcs.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha1" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "fmt" 9 | "html/template" 10 | "os" 11 | "regexp" 12 | "strings" 13 | 14 | "golang.org/x/text/cases" 15 | "golang.org/x/text/language" 16 | "gopkg.in/yaml.v3" 17 | ) 18 | 19 | var Functions = template.FuncMap{ 20 | "lower": jsonLower, 21 | "lowercase": jsonLower, 22 | "uppercase": jsonUpper, 23 | "upper": jsonUpper, 24 | "title": jsonTitle, 25 | "sprintf": fmt.Sprintf, 26 | "printf": fmt.Sprintf, 27 | "trim": jsonTrimSpace, 28 | "trimPrefix": jsonTrimPrefix, 29 | "trimSuffix": jsonTrimSuffix, 30 | "default": fnDefault, 31 | "sha1sum": sha1sum, 32 | "sha256sum": sha256sum, 33 | "str": strJSON, 34 | "required": jsonRequired, 35 | "env": env, 36 | "replace": jsonReplace, 37 | "alphanumify": jsonAlphanumify, 38 | "alphanumdash": jsonAlphanumdash, 39 | "dottodash": jsonDotToDash, 40 | "dottounder": jsonDotToUnder, 41 | "index": mapValueByIndex, 42 | "indexOrEmpty": mapValueByIndexOrEmpty, 43 | } 44 | 45 | // mapValueByIndexOrEmpty retrieves a value from a map without returning an error if the key is not found. 46 | func mapValueByIndexOrEmpty(index string, m map[string]interface{}) interface{} { 47 | if m == nil { 48 | return "" 49 | } 50 | 51 | if index == "" { 52 | return "" 53 | } 54 | 55 | v, ok := m[index] 56 | if !ok { 57 | return "" 58 | } 59 | 60 | return v 61 | } 62 | 63 | // mapValueByIndex returns the value of the map at the given index 64 | func mapValueByIndex(index string, m map[string]interface{}) (interface{}, error) { 65 | if m == nil { 66 | return nil, fmt.Errorf("map is nil") 67 | } 68 | 69 | if index == "" { 70 | return nil, fmt.Errorf("index is empty") 71 | } 72 | 73 | v, ok := m[index] 74 | if !ok { 75 | return nil, fmt.Errorf("map does not contain index %q", index) 76 | } 77 | 78 | return v, nil 79 | } 80 | 81 | // strJSON converts a value received from JSON/YAML to string. Since not all data 82 | // types are supported for JSON, we can limit to just the primitives that are 83 | // not arrays, objects or null; see: 84 | // https://pkg.go.dev/encoding/json#Unmarshal 85 | func strJSON(val interface{}) (string, error) { 86 | if val == nil { 87 | return "", nil 88 | } 89 | 90 | switch a := val.(type) { 91 | case string: 92 | return a, nil 93 | 94 | case bool: 95 | return fmt.Sprintf("%v", a), nil 96 | 97 | case float64: 98 | return fmt.Sprintf("%v", a), nil 99 | 100 | default: 101 | return "", fmt.Errorf("unexpected data type %T -- can't convert to string", val) 102 | } 103 | } 104 | 105 | var ( 106 | reAlphaNum = regexp.MustCompile(`[^a-zA-Z0-9]+`) 107 | reSlugify = regexp.MustCompile(`[^a-zA-Z0-9-]+`) 108 | ) 109 | 110 | func jsonAlphanumify(val interface{}) (string, error) { 111 | s, err := strJSON(val) 112 | if err != nil { 113 | return "", err 114 | } 115 | 116 | return reAlphaNum.ReplaceAllString(s, ""), nil 117 | } 118 | 119 | func jsonAlphanumdash(val interface{}) (string, error) { 120 | s, err := strJSON(val) 121 | if err != nil { 122 | return "", err 123 | } 124 | 125 | return reSlugify.ReplaceAllString(s, ""), nil 126 | } 127 | 128 | func jsonDotToDash(val interface{}) (string, error) { 129 | s, err := strJSON(val) 130 | if err != nil { 131 | return "", err 132 | } 133 | 134 | return strings.ReplaceAll(s, ".", "-"), nil 135 | } 136 | 137 | func jsonDotToUnder(val interface{}) (string, error) { 138 | s, err := strJSON(val) 139 | if err != nil { 140 | return "", err 141 | } 142 | 143 | return strings.ReplaceAll(s, ".", "_"), nil 144 | } 145 | 146 | func jsonReplace(search, replace string, val interface{}) (string, error) { 147 | s, err := strJSON(val) 148 | if err != nil { 149 | return "", err 150 | } 151 | 152 | return strings.NewReplacer(search, replace).Replace(s), nil 153 | } 154 | 155 | func env(key string) string { 156 | return os.Getenv(strings.ToUpper(key)) 157 | } 158 | 159 | func jsonRequired(val interface{}) (interface{}, error) { 160 | if val == nil { 161 | return nil, fmt.Errorf("argument is marked as required, but it renders to empty") 162 | } 163 | 164 | s, err := strJSON(val) 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | if s == "" { 170 | return nil, fmt.Errorf("argument is marked as required, but it renders to empty or it's an object or an unsupported type") 171 | } 172 | 173 | return val, nil 174 | } 175 | 176 | func jsonLower(val interface{}) (string, error) { 177 | s, err := strJSON(val) 178 | if err != nil { 179 | return "", err 180 | } 181 | 182 | return strings.ToLower(s), nil 183 | } 184 | 185 | func jsonUpper(val interface{}) (string, error) { 186 | s, err := strJSON(val) 187 | if err != nil { 188 | return "", err 189 | } 190 | 191 | return strings.ToUpper(s), nil 192 | } 193 | 194 | func jsonTitle(val interface{}) (string, error) { 195 | s, err := strJSON(val) 196 | if err != nil { 197 | return "", err 198 | } 199 | 200 | return cases.Title(language.Und).String(s), nil 201 | } 202 | 203 | func jsonTrimSpace(val interface{}) (string, error) { 204 | s, err := strJSON(val) 205 | if err != nil { 206 | return "", err 207 | } 208 | 209 | return strings.TrimSpace(s), nil 210 | } 211 | 212 | func jsonTrimPrefix(prefix string, val interface{}) (string, error) { 213 | s, err := strJSON(val) 214 | if err != nil { 215 | return "", err 216 | } 217 | 218 | return strings.TrimPrefix(s, prefix), nil 219 | } 220 | 221 | func jsonTrimSuffix(suffix string, val interface{}) (string, error) { 222 | s, err := strJSON(val) 223 | if err != nil { 224 | return "", err 225 | } 226 | 227 | return strings.TrimSuffix(s, suffix), nil 228 | } 229 | 230 | func fnDefault(defval, val interface{}) (string, error) { 231 | v, err := strJSON(val) 232 | if err != nil { 233 | return "", err 234 | } 235 | 236 | dv, err := strJSON(defval) 237 | if err != nil { 238 | return "", err 239 | } 240 | 241 | if v != "" { 242 | return v, nil 243 | } 244 | 245 | return dv, nil 246 | } 247 | 248 | func altStrJSON(val interface{}) (string, error) { 249 | var buf bytes.Buffer 250 | if err := yaml.NewEncoder(&buf).Encode(val); err != nil { 251 | return "", fmt.Errorf("unable to encode object to YAML: %w", err) 252 | } 253 | 254 | return buf.String(), nil 255 | } 256 | 257 | func sha256sum(input interface{}) (string, error) { 258 | s, err := altStrJSON(input) 259 | if err != nil { 260 | return "", err 261 | } 262 | 263 | hash := sha256.Sum256([]byte(s)) 264 | return hex.EncodeToString(hash[:]), nil 265 | } 266 | 267 | func sha1sum(input interface{}) (string, error) { 268 | s, err := altStrJSON(input) 269 | if err != nil { 270 | return "", err 271 | } 272 | 273 | hash := sha1.Sum([]byte(s)) 274 | return hex.EncodeToString(hash[:]), nil 275 | } 276 | -------------------------------------------------------------------------------- /docs/template_functions.md: -------------------------------------------------------------------------------- 1 | # Template Functions 2 | 3 | - [Template Functions](#template-functions) 4 | - [`lower`, `lowercase`](#lower-lowercase) 5 | - [`upper`, `uppercase`](#upper-uppercase) 6 | - [`title`](#title) 7 | - [`sprintf`, `printf`](#sprintf-printf) 8 | - [`trim`](#trim) 9 | - [`trimPrefix`, `trimSuffix`](#trimprefix-trimsuffix) 10 | - [`default`](#default) 11 | - [`required`](#required) 12 | - [`env`](#env) 13 | - [`sha1sum`, `sha256sum`](#sha1sum-sha256sum) 14 | - [`str`](#str) 15 | - [`replace`](#replace) 16 | - [`alphanumify`, `alphanumdash`](#alphanumify-alphanumdash) 17 | - [`dottodash`, `dottounder`](#dottodash-dottounder) 18 | - [`index`, `indexOrEmpty`](#index-indexorempty) 19 | 20 | The following template functions are available, with some functions having aliases for convenience: 21 | 22 | ## `lower`, `lowercase` 23 | 24 | Converts the value to string as stated in [String conversion](docs/faq.md#string-conversion), then lowercases it. 25 | 26 | ```handlebars 27 | {{ "Namespace" | lower }} 28 | namespace 29 | ``` 30 | 31 | ## `upper`, `uppercase` 32 | 33 | Converts the value to string as stated in [String conversion](docs/faq.md#string-conversion), then uppercases it. 34 | 35 | ```handlebars 36 | {{ "Namespace" | upper }} 37 | NAMESPACE 38 | ``` 39 | 40 | ## `title` 41 | 42 | Converts the value to string as stated in [String conversion](docs/faq.md#string-conversion), then capitalize the first character of each word. 43 | 44 | ```handlebars 45 | {{ "hello world" | title }} 46 | Hello World 47 | ``` 48 | 49 | While available, it's use is discouraged for file names. 50 | 51 | ## `sprintf`, `printf` 52 | 53 | Alias of Go's `fmt.Sprintf`. 54 | 55 | ```handlebars 56 | {{ printf "number-%d" 20 }} 57 | number-20 58 | ``` 59 | 60 | ## `trim` 61 | 62 | Converts the value to string as stated in [String conversion](docs/faq.md#string-conversion), then removes any whitespace at the beginning or end of the string. 63 | 64 | ```handlebars 65 | {{ " hello world " | trim }} 66 | hello world 67 | ``` 68 | 69 | ## `trimPrefix`, `trimSuffix` 70 | 71 | Converts the value to string as stated in [String conversion](docs/faq.md#string-conversion), then removes either the prefix or the suffix. 72 | 73 | Do note that the parameters are flipped from Go's `strings.TrimPrefix` and `strings.TrimSuffix`: here, the first parameter is the prefix, rather than being the last parameter. This is to allow piping one output to another: 74 | 75 | ```handlebars 76 | {{ " foo" | trimPrefix " " }} 77 | foo 78 | ``` 79 | 80 | ## `default` 81 | 82 | If the value is set, return it, otherwise, a default value is used. 83 | 84 | ```handlebars 85 | {{ "" | default "bar" }} 86 | bar 87 | ``` 88 | 89 | ## `required` 90 | 91 | If the argument renders to an empty string, the application fails and exits with non-zero status code. 92 | 93 | ```handlebars 94 | {{ "" | required }} 95 | 96 | ``` 97 | 98 | ## `env` 99 | 100 | Fetch an environment variable to be printed. If the environment variable is mandatory, consider using `required`. If the environment variable might be empty, consider using `default`. 101 | 102 | `env` allows the key to be case-insensitive: it will be uppercased internally. 103 | 104 | ```handlebars 105 | {{ env "user" }} 106 | patrick 107 | ``` 108 | 109 | ## `sha1sum`, `sha256sum` 110 | 111 | Renders a `sha1sum` or `sha256sum` of a given value. The value is converted first to their YAML representation, with comments removed, then the `sum` is performed. This is to ensure that the "behavior" can stay the same, even when the file might have multiple comments that might change. 112 | 113 | Primitives such as `string`, `bool` and `float64` are converted as-is. 114 | 115 | While not recommended, you can use this to always generate a new name if the YAML declaration drifts. The following snippet uses `.`, which represents the entire YAML file -- on a multi-YAML file, each `.` represents a single file: 116 | 117 | ```handlebars 118 | {{ . | sha1sum }} 119 | f502bbf15d0988a9b28b73f8450de47f75179f5c 120 | ``` 121 | 122 | ## `str` 123 | 124 | Converts any primitive as stated in [String conversion](docs/faq.md#string-conversion), to string: 125 | 126 | ```handlebars 127 | {{ false | str }} 128 | false 129 | ``` 130 | 131 | ## `replace` 132 | 133 | Converts the value to a string as stated in [String conversion](docs/faq.md#string-conversion), then replaces all ocurrences of a string with another: 134 | 135 | ```handlebars 136 | {{ "hello.dev" | replace "." "_" }} 137 | hello_dev 138 | ``` 139 | 140 | ## `alphanumify`, `alphanumdash` 141 | 142 | Converts the value to a string as stated in [String conversion](docs/faq.md#string-conversion), and keeps from the original string only alphanumeric characters -- for `alphanumify` -- or alphanumeric plus dashes and underscores -- like URLs, for `alphanumdash`: 143 | 144 | ```handlebars 145 | {{ "secret-foo.dev" | alphanumify }} 146 | secretsfoodev 147 | ``` 148 | 149 | ```handlebars 150 | {{ "secret-foo.dev" | alphanumdash }} 151 | secrets-foodev 152 | ``` 153 | 154 | ## `dottodash`, `dottounder` 155 | 156 | Converts the value to a string as stated in [String conversion](docs/faq.md#string-conversion), and replaces all dots to either dashes or underscores: 157 | 158 | ```handlebars 159 | {{ "secret-foo.dev" | dottodash }} 160 | secrets-foo-dev 161 | ``` 162 | 163 | ```handlebars 164 | {{ "secret-foo.dev" | dottounder }} 165 | secrets-foo_dev 166 | ``` 167 | 168 | Particularly useful for Kubernetes FQDNs needed to be used as filenames. 169 | 170 | ## `index`, `indexOrEmpty` 171 | 172 | For certain resources where YAML indexes are not alphanumeric, but contain special characters such as labels or annotations, `index` allows you to retrieve those resources. Consider the following YAML: 173 | 174 | ```yaml 175 | apiVersion: apps/v1 176 | kind: Deployment 177 | metadata: 178 | name: my-deployment 179 | labels: 180 | app.kubernetes.io/name: patrickdap-deployment 181 | ``` 182 | 183 | It's not possible to access the value `patrickdap-deployment` using dot notation like this: `{{ .metadata.labels.app.kubernetes.io/name }}`: the Go Template engine will throw an error. Instead, you can use `index`: 184 | 185 | ```handlebars 186 | {{ index "app.kubernetes.io/name" .metadata.labels }} 187 | patrickdap-deployment 188 | ``` 189 | 190 | The reason the parameters are flipped is to allow piping one output to another: 191 | 192 | ```handlebars 193 | {{ .metadata.labels | index "app.kubernetes.io/name" }} 194 | patrickdap-deployment 195 | ``` 196 | 197 | > [!NOTE] 198 | > The `index` function will raise an error if the key is not found. If you want to avoid this, use `indexOrEmpty`. 199 | 200 | `indexOrEmpty` works the same as the `index` function but does not raise errors; instead, it returns an empty string, which can be used in `if` statements, piped to the `default` function, etc. For example: 201 | 202 | ``` 203 | {{ $component := indexOrEmpty "k8s.config/component" .metadata.labels | default "unlabeled" }} 204 | {{ printf "%s-%s-%s.yaml" $component (lower .kind) .metadata.name }} 205 | ``` 206 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | - [Frequently Asked Questions](#frequently-asked-questions) 4 | - [I want to exclude or include certain Kubernetes resource types, how do I do it?](#i-want-to-exclude-or-include-certain-kubernetes-resource-types-how-do-i-do-it) 5 | - [Some of the code in my YAML is an entire YAML file commented out, how do I skip it?](#some-of-the-code-in-my-yaml-is-an-entire-yaml-file-commented-out-how-do-i-skip-it) 6 | - [How to add namespaces to YAML resources with no namespace?](#how-to-add-namespaces-to-yaml-resources-with-no-namespace) 7 | - [How do I access YAML fields by name?](#how-do-i-access-yaml-fields-by-name) 8 | - [Two files will generate the same file name, what do I do?](#two-files-will-generate-the-same-file-name-what-do-i-do) 9 | - [This app doesn't seem to work with Windows `CRLF`](#this-app-doesnt-seem-to-work-with-windows-crlf) 10 | - [How are string conversions handled?](#how-are-string-conversions-handled) 11 | - [I keep getting `file name template parse failed: bad character`, how do I fix it?](#i-keep-getting-file-name-template-parse-failed-bad-character-how-do-i-fix-it) 12 | 13 | ## I want to exclude or include certain Kubernetes resource types, how do I do it? 14 | 15 | `kubectl-slice` has two available flags: `--exclude-kind` and `--include-kind`. They can be used to exclude or include specific resources. For example, to exclude all `Deployment` resources, you can use `--exclude-kind=Deployment`: 16 | 17 | ```bash 18 | kubectl-slice -f manifest.yaml --exclude-kind=Deployment 19 | ``` 20 | 21 | Both arguments can be used comma-separated or by calling them multiple times. As such, these two invocations are the same: 22 | 23 | ```bash 24 | kubectl-slice --exclude-kind=Deployment,ReplicaSet,DaemonSet 25 | kubectl-slice --exclude-kind=Deployment --exclude-kind=ReplicaSet --exclude-kind=DaemonSet 26 | ``` 27 | 28 | Additionally, you can use `--include-name` and `--exclude-name` to include or exclude resources by Kubernetes name. 29 | 30 | ```bash 31 | kubectl-slice --exclude-name=my-deployment 32 | ``` 33 | 34 | Globs are also supported, so you can use `--include-name=foo*` to include all resources with names starting with `foo`. 35 | 36 | If a YAML resource can't be parsed to detect its name or kind, it will be included by default on any `exclude` flag. 37 | 38 | All flags are also case insensitive -- they will be converted to lowercase before applying the glob check. 39 | 40 | ## Some of the code in my YAML is an entire YAML file commented out, how do I skip it? 41 | 42 | By default, `kubectl-slice` will also slice out commented YAML file sections. If you would rather want to ensure only Kubernetes resources are sliced from the original YAML file, then there's two options: 43 | 44 | * Use `--include-kind` or `--include-name` to only include Kubernetes resources by kind or name; or 45 | * Use `--skip-non-k8s` to skip any non-Kubernetes resources 46 | 47 | `--include-kind` can be used so you control your entire output by specifying only the resources you want. For example, if you want to only slice out `Deployment` resources, you can use `--include-kind=Deployment`. 48 | 49 | `--skip-non-k8s`, on the other hand, works by ensuring that your YAML contains the following fields: `apiVersion`, `kind`, and `metadata.name`. Keep in mind that it won't check if these fields are empty, it will just ensure that those fields exist within each one of the YAML processed. If one of them does not contain these fields, it will be skipped. 50 | 51 | ## How to add namespaces to YAML resources with no namespace? 52 | 53 | It's very common that Helm charts or even plain YAMLs found online might not contain the namespace, and because of that, the field isn't available in the YAML. Since this tool was created to fit a specific criteria [as seen above](../README.md#why-kubectl-slice), there's no need to implement this here. However, you can use `kustomize` to quickly add the namespace to your manifest, then run it through `kubectl-slice`. 54 | 55 | First, create a `kustomization.yaml` file: 56 | 57 | ```yaml 58 | apiVersion: kustomize.config.k8s.io/v1beta1 59 | kind: Kustomization 60 | namespace: my-namespace 61 | resources: 62 | - my-file.yaml 63 | ``` 64 | 65 | Replace `my-namespace` for the namespace you want to set, and `my-file.yaml` for the name of the actual file that has no namespaces declared. Then run: 66 | 67 | ``` 68 | kustomize build 69 | ``` 70 | 71 | This will render your new file, namespaces included, to `stdout`. You can pipe this as-is to `kubectl-slice`: 72 | 73 | ``` 74 | kustomize build | kubectl-slice 75 | ``` 76 | 77 | Keep in mind that is recommended to **not add namespaces** to your YAML resources, to allow users to install in any destination they choose. For example, a namespaceless file called `foo.yaml` can be installed to the namespace `bar` by using: 78 | 79 | ``` 80 | kubectl apply -n bar -f foo.yaml 81 | ``` 82 | 83 | ## How do I access YAML fields by name? 84 | 85 | Any field from the YAML file can be used, however, non-existent fields will render an empty string. This is very common for situations such as rendering a Helm template [where the namespace shouldn't be defined](#how-to-add-namespaces-to-yaml-resources-with-no-namespace). 86 | 87 | If you would rather fail executing `kubectl-slice` if a field was not found, consider using the `required` Go Template function. The following template will make `kubectl-slice` fail with a non-zero exit code if the namespace of any of the resources is not defined. 88 | 89 | ```handlebars 90 | {{.metadata.namespace | required}}.yaml 91 | ``` 92 | 93 | If you would rather provide a default for those resources without, say, a namespace, you can use the `default` function: 94 | 95 | ```handlebars 96 | {{.metadata.namespace | default "global"}}.yaml 97 | ``` 98 | 99 | This will render any resource without a namespace with the name `global.yaml`. 100 | 101 | ## Two files will generate the same file name, what do I do? 102 | 103 | Since it's possible to provide a Go Template for a file name that might be the same for multiple resources, `kubectl-slice` will append any YAML that matches by file name to the given file using the `---` separator. 104 | 105 | For example, considering the following file name: 106 | 107 | ```handlebars 108 | {{.metadata.namespace | default "global"}}.yaml 109 | ``` 110 | 111 | Any cluster-scoped resource will be appended into `global.yaml`, while any resource in the namespace `production` will be appended to `production.yaml`. 112 | 113 | ## This app doesn't seem to work with Windows `CRLF` 114 | 115 | `kubectl-slice` does not support Windows line breaks with `CRLF` -- also known as `\r\n`. Consider using Unix line breaks `\n`. 116 | 117 | ## How are string conversions handled? 118 | 119 | Since `kubectl-slice` is built in Go, there's only a handful of primitives that can be read from the YAML manifest. All of these have been hand-picked to be stringified automatically -- in fact, multiple template functions will accept them, and yes, that means you can `lowercase` a number 😅 120 | 121 | The decision is intentional and it's due to the fact that's impossible to map any potential Kubernetes resource, given the fact that you can teach Kubernetes new objects using Custom Resource Definitions. Because of that, resources are read as untyped and converted to strings when possible. 122 | 123 | The following YAML untyped values are handled, [in accordance with the `json.Unmarshal` documentation](https://pkg.go.dev/encoding/json#Unmarshal): 124 | 125 | * `bool`, for JSON booleans 126 | * `float64`, for JSON numbers 127 | * `string`, for JSON strings 128 | 129 | ## I keep getting `file name template parse failed: bad character`, how do I fix it? 130 | 131 | If you're receiving this error, chances are you're attempting to access a field from the YAML whose name is not limited to alphanumeric characters, such as annotations or labels, like `app.kubernetes.io/name`. 132 | 133 | To fix it, use the [`index` function](functions.md#index) to access the field by index. For example, if you want to access the `app.kubernetes.io/name` field, you can use the following template: 134 | 135 | ```handlebars 136 | {{ index "app.kubernetes.io/name" .metadata.labels }} 137 | ``` 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `kubectl-slice`: split Kubernetes YAMLs into files 2 | 3 | [![Downloads](https://img.shields.io/github/downloads/patrickdappollonio/kubectl-slice/total?color=blue&logo=github&style=flat-square)](https://github.com/patrickdappollonio/kubectl-slice/releases) 4 | 5 | - [`kubectl-slice`: split Kubernetes YAMLs into files](#kubectl-slice-split-kubernetes-yamls-into-files) 6 | - [Installation](#installation) 7 | - [Using `krew`](#using-krew) 8 | - [Using Homebrew](#using-homebrew) 9 | - [Download and install manually](#download-and-install-manually) 10 | - [Usage](#usage) 11 | - [Why `kubectl-slice`?](#why-kubectl-slice) 12 | - [Passing configuration options to `kubectl-slice`](#passing-configuration-options-to-kubectl-slice) 13 | - [Including and excluding manifests from the output](#including-and-excluding-manifests-from-the-output) 14 | - [Examples](#examples) 15 | - [Contributing \& Roadmap](#contributing--roadmap) 16 | 17 | `kubectl-slice` is a tool that allows you to split a single multi-YAML Kubernetes manifest (with `--input-file` or `-f`), or a folder containing multiple manifests files (with `--input-folder` or `-d`, optionally with `--recursive`), into multiple subfiles using a naming convention you choose. This is done by parsing the YAML code and allowing you to access any key from the YAML object [using Go Templates](https://pkg.go.dev/text/template). 18 | 19 | By default, `kubectl-slice` will split your files into multiple subfiles following this naming convention that you can configure to your liking: 20 | 21 | ```handlebars 22 | {{.kind | lower}}-{{.metadata.name}}.yaml 23 | ``` 24 | 25 | That is, the Kubernetes kind -- in this case, the value `Namespace` -- lowercased, followed by a dash, followed by the resource name -- in this case, the value `production`: 26 | 27 | ```text 28 | namespace-production.yaml 29 | ``` 30 | 31 | If your YAML includes multiple files, for example: 32 | 33 | ```yaml 34 | apiVersion: v1 35 | kind: Pod 36 | metadata: 37 | name: nginx-ingress 38 | --- 39 | apiVersion: v1 40 | kind: Namespace 41 | metadata: 42 | name: production 43 | ``` 44 | 45 | Then the following files will be created: 46 | 47 | ```text 48 | $ kubectl-slice --input-file=input.yaml --output-dir=. 49 | Wrote pod-nginx-ingress.yaml -- 58 bytes. 50 | Wrote namespace-production.yaml -- 61 bytes. 51 | 2 files generated. 52 | ``` 53 | 54 | You can customize the file name to your liking, by using the `--template` flag. 55 | 56 | ## Installation 57 | 58 | `kubectl-slice` can be used as a standalone tool or through `kubectl`, as a plugin. 59 | 60 | ### Using `krew` 61 | 62 | `kubectl-slice` is available as a [krew plugin](https://krew.sigs.k8s.io/docs/user-guide/installing-plugins/). 63 | 64 | To install, use the following command: 65 | 66 | ```bash 67 | kubectl krew install slice 68 | ``` 69 | 70 | ### Using Homebrew 71 | 72 | `kubectl-slice` is available as a Homebrew tap for both macOS and Linux: 73 | 74 | ```bash 75 | brew install patrickdappollonio/tap/kubectl-slice 76 | ``` 77 | 78 | ### Download and install manually 79 | 80 | Download the latest release for your platform from the [Releases page](https://github.com/patrickdappollonio/kubectl-slice/releases), then extract and move the `kubectl-slice` binary to any place in your `$PATH`. If you have `kubectl` installed, you can use both `kubectl-slice` and `kubectl slice` (note in the later the absence of the `-`). 81 | 82 | ## Usage 83 | 84 | ```text 85 | kubectl-slice allows you to split a YAML into multiple subfiles using a pattern. 86 | For documentation, available functions, and more, visit: https://github.com/patrickdappollonio/kubectl-slice. 87 | 88 | Usage: 89 | kubectl-slice [flags] 90 | 91 | Examples: 92 | kubectl-slice -f foo.yaml -o ./ --include-kind Pod,Namespace 93 | kubectl-slice -f foo.yaml -o ./ --exclude-kind Pod 94 | kubectl-slice -f foo.yaml -o ./ --exclude-name *-svc 95 | kubectl-slice -f foo.yaml --exclude-name *-svc --stdout 96 | kubectl-slice -f foo.yaml --include Pod/* --stdout 97 | kubectl-slice -f foo.yaml --exclude deployment/kube* --stdout 98 | kubectl-slice -d ./ --recurse -o ./ --include-kind Pod,Namespace 99 | kubectl-slice -d ./ --recurse --stdout --include Pod/* 100 | kubectl-slice --config config.yaml 101 | 102 | Flags: 103 | --allow-empty-kinds if enabled, resources with empty kinds don't produce an error when filtering 104 | --allow-empty-names if enabled, resources with empty names don't produce an error when filtering 105 | -c, --config string path to the config file 106 | --dry-run if true, no files are created, but the potentially generated files will be printed as the command output 107 | --exclude strings resource name to exclude in the output (format /, case insensitive, glob supported) 108 | --exclude-kind strings resource kind to exclude in the output (singular, case insensitive, glob supported) 109 | --exclude-name strings resource name to exclude in the output (singular, case insensitive, glob supported) 110 | --extensions strings the extensions to look for in the input folder (default [.yaml,.yml]) 111 | -h, --help help for kubectl-slice 112 | --include strings resource name to include in the output (format /, case insensitive, glob supported) 113 | --include-kind strings resource kind to include in the output (singular, case insensitive, glob supported) 114 | --include-name strings resource name to include in the output (singular, case insensitive, glob supported) 115 | --include-triple-dash if enabled, the typical "---" YAML separator is included at the beginning of resources sliced 116 | -f, --input-file string the input file used to read the initial macro YAML file; if empty or "-", stdin is used (exclusive with --input-folder) 117 | -d, --input-folder string the input folder used to read the initial macro YAML files (exclusive with --input-file) 118 | -o, --output-dir string the output directory used to output the splitted files 119 | --prune if enabled, the output directory will be pruned before writing the files 120 | -q, --quiet if true, no output is written to stdout/err 121 | -r, --recurse if true, the input folder will be read recursively (has no effect unless used with --input-folder) 122 | -s, --skip-non-k8s if enabled, any YAMLs that don't contain at least an "apiVersion", "kind" and "metadata.name" will be excluded from the split 123 | --sort-by-kind if enabled, resources are sorted by Kind, a la Helm, before saving them to disk 124 | --stdout if enabled, no resource is written to disk and all resources are printed to stdout instead 125 | -t, --template string go template used to generate the file name when creating the resource files in the output directory (default "{{.kind | lower}}-{{.metadata.name}}.yaml") 126 | -v, --version version for kubectl-slice 127 | ``` 128 | 129 | ## Why `kubectl-slice`? 130 | 131 | See [why `kubectl-slice`?](docs/why.md) for more information. 132 | 133 | ## Passing configuration options to `kubectl-slice` 134 | 135 | Besides command-line flags, you can also use environment variables and a YAML configuration file to pass options to `kubectl-slice`. See [the documentation for configuration options](docs/configuring-cli.md) for details about both, including precedence. 136 | 137 | ## Including and excluding manifests from the output 138 | 139 | Including or excluding manifests from the output via `metadata.name` or `kind` is possible. Globs are supported in both cases. See [the documentation for including and excluding items](docs/including-excluding-items.md) for more information. 140 | 141 | ## Examples 142 | 143 | See [examples](docs/examples.md) for more information. 144 | 145 | ## Contributing & Roadmap 146 | 147 | Pull requests are welcomed! So far, looking for help with the following items, which are also part of the roadmap: 148 | 149 | * Adding unit tests 150 | * Improving the YAML file-by-file parser, right now it works by buffering line by line 151 | * Adding support to install through `brew` 152 | * [Adding new features marked as `enhancement`](//github.com/patrickdappollonio/kubectl-slice/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement) 153 | -------------------------------------------------------------------------------- /slice/execute.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | const ( 13 | folderChmod = 0o775 14 | defaultChmod = 0o664 15 | ) 16 | 17 | func (s *Split) processSingleFile(file []byte) error { 18 | s.log.Printf("Found a new YAML file in buffer, number %d", s.fileCount) 19 | 20 | // If there's no data in the buffer, return without doing anything 21 | // but count the file 22 | file = bytes.TrimSpace(file) 23 | 24 | if len(file) == 0 { 25 | // If it is the first file, it means the original file started 26 | // with "---", which is valid YAML, but we don't count it 27 | // as a file. 28 | if s.fileCount == 1 { 29 | s.log.Println("Got empty file. Skipping.") 30 | return nil 31 | } 32 | 33 | return nil 34 | } 35 | 36 | // Send it for processing 37 | meta, err := s.parseYAMLManifest(file) 38 | if err != nil { 39 | switch err.(type) { 40 | case *skipErr: 41 | s.log.Printf("Skipping file %d: %s", s.fileCount, err.Error()) 42 | return nil 43 | 44 | case *strictModeSkipErr: 45 | s.log.Printf("Skipping file %d: %s", s.fileCount, err.Error()) 46 | return nil 47 | 48 | default: 49 | return err 50 | } 51 | } 52 | 53 | existentData, position := []byte(nil), -1 54 | for pos := 0; pos < len(s.filesFound); pos++ { 55 | if s.filesFound[pos].filename == meta.filename { 56 | existentData = s.filesFound[pos].data 57 | position = pos 58 | break 59 | } 60 | } 61 | 62 | if position == -1 { 63 | s.log.Printf("Got nonexistent file. Adding it to the list: %s", meta.filename) 64 | s.filesFound = append(s.filesFound, yamlFile{ 65 | filename: meta.filename, 66 | meta: meta.meta, 67 | data: file, 68 | }) 69 | } else { 70 | s.log.Printf("Got existent file. Appending to original buffer: %s", meta.filename) 71 | existentData = append(existentData, []byte("\n---\n")...) 72 | existentData = append(existentData, file...) 73 | s.filesFound[position] = yamlFile{ 74 | filename: meta.filename, 75 | meta: meta.meta, 76 | data: existentData, 77 | } 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func (s *Split) scan() error { 84 | // Since we'll be iterating over files that potentially might end up being 85 | // duplicated files, we need to store them somewhere to, later, save them 86 | // to files 87 | s.fileCount = 0 88 | s.filesFound = make([]yamlFile, 0) 89 | 90 | // We can totally create a single decoder then decode using that, however, 91 | // we want to maintain 1:1 exactly the same declaration as the YAML originally 92 | // fed by the user, so we split and save copies of these resources locally. 93 | // If we re-marshal the YAML, it might lose the format originally provided 94 | // by the user. 95 | scanner := bufio.NewReader(s.data) 96 | 97 | // Create a local buffer to read files line by line 98 | local := bytes.Buffer{} 99 | 100 | // Parse a single file 101 | parseFile := func() error { 102 | contents := local.Bytes() 103 | local = bytes.Buffer{} 104 | return s.processSingleFile(contents) 105 | } 106 | 107 | // Iterate over the entire buffer 108 | for { 109 | // Grab a single line 110 | line, err := scanner.ReadString('\n') 111 | // Find if there's an error 112 | if err != nil { 113 | // If we reached the end of file, handle up to this point 114 | if err == io.EOF { 115 | s.log.Println("Reached end of file while parsing. Sending remaining buffer to process.") 116 | local.WriteString(line) 117 | 118 | if err := parseFile(); err != nil { 119 | return err 120 | } 121 | 122 | s.fileCount++ 123 | break 124 | } 125 | 126 | // Otherwise handle the unexpected error 127 | return fmt.Errorf("unable to read YAML file number %d: %w", s.fileCount, err) 128 | } 129 | 130 | // Check if we're at the end of the file 131 | if line == "---\n" || line == "---\r\n" { 132 | s.log.Println("Found the end of a file. Sending buffer to process.") 133 | if err := parseFile(); err != nil { 134 | return err 135 | } 136 | s.fileCount++ 137 | continue 138 | } 139 | 140 | fmt.Fprint(&local, line) 141 | } 142 | 143 | s.log.Printf( 144 | "Finished processing buffer. Generated %d individual files, and processed %d files in the original YAML.", 145 | len(s.filesFound), s.fileCount, 146 | ) 147 | 148 | return nil 149 | } 150 | 151 | func (s *Split) store() error { 152 | // Handle output directory being empty 153 | if s.opts.OutputDirectory == "" { 154 | s.opts.OutputDirectory = "." 155 | } 156 | 157 | // If the user wants to prune the output directory, do it 158 | if s.opts.PruneOutputDir && !s.opts.OutputToStdout && !s.opts.DryRun { 159 | // Check if the directory exists and if it does, prune it 160 | if _, err := os.Stat(s.opts.OutputDirectory); !os.IsNotExist(err) { 161 | s.log.Printf("Pruning output directory %q", s.opts.OutputDirectory) 162 | if err := deleteFolderContents(s.opts.OutputDirectory); err != nil { 163 | return fmt.Errorf("unable to prune output directory %q: %w", s.opts.OutputDirectory, err) 164 | } 165 | s.log.Printf("Output directory %q pruned", s.opts.OutputDirectory) 166 | } 167 | } 168 | 169 | // Now save those files to disk (or if dry-run is on, print what it would 170 | // save). Files will be overwritten. 171 | s.fileCount = 0 172 | for _, v := range s.filesFound { 173 | s.fileCount++ 174 | 175 | fullpath := filepath.Join(s.opts.OutputDirectory, v.filename) 176 | fileLength := len(v.data) 177 | 178 | s.log.Printf("Handling file %q: %d bytes long.", fullpath, fileLength) 179 | 180 | switch { 181 | case s.opts.DryRun: 182 | s.WriteStderr("Would write %s -- %d bytes.", fullpath, fileLength) 183 | continue 184 | 185 | case s.opts.OutputToStdout: 186 | if s.fileCount != 1 { 187 | s.WriteStdout("---") 188 | } 189 | 190 | if !s.opts.RemoveFileComments { 191 | s.WriteStdout("# File: %s (%d bytes)", fullpath, fileLength) 192 | } 193 | 194 | s.WriteStdout("%s", v.data) 195 | continue 196 | 197 | default: 198 | local := make([]byte, 0, len(v.data)+4) 199 | 200 | // If the user wants to include the triple dash, add it 201 | // at the beginning of the file 202 | if s.opts.IncludeTripleDash && !bytes.Equal(v.data, []byte("---")) { 203 | local = append([]byte("---\n"), v.data...) 204 | } else { 205 | local = append(local, v.data...) 206 | } 207 | 208 | // do nothing, handling below 209 | if err := s.writeToFile(fullpath, local); err != nil { 210 | return err 211 | } 212 | 213 | s.WriteStderr("Wrote %s -- %d bytes.", fullpath, len(local)) 214 | continue 215 | } 216 | } 217 | 218 | switch { 219 | case s.opts.DryRun: 220 | s.WriteStderr("%d %s generated (dry-run)", s.fileCount, pluralize("file", s.fileCount)) 221 | 222 | case s.opts.OutputToStdout: 223 | s.WriteStderr("%d %s parsed to stdout.", s.fileCount, pluralize("file", s.fileCount)) 224 | 225 | default: 226 | s.WriteStderr("%d %s generated.", s.fileCount, pluralize("file", s.fileCount)) 227 | } 228 | 229 | return nil 230 | } 231 | 232 | func (s *Split) sort() { 233 | if s.opts.SortByKind { 234 | s.filesFound = sortYAMLsByKind(s.filesFound) 235 | } 236 | } 237 | 238 | // Execute runs the process according to the split.Options provided. This will 239 | // generate the files in the given directory. 240 | func (s *Split) Execute() error { 241 | if err := s.scan(); err != nil { 242 | return err 243 | } 244 | 245 | s.sort() 246 | 247 | return s.store() 248 | } 249 | 250 | func (s *Split) writeToFile(path string, data []byte) error { 251 | // Since a single Go Template File Name might render different folder prefixes, 252 | // we need to ensure they're all created. 253 | if err := os.MkdirAll(filepath.Dir(path), folderChmod); err != nil { 254 | return fmt.Errorf("unable to create output folder for file %q: %w", path, err) 255 | } 256 | 257 | // Open the file as read/write, create the file if it doesn't exist, and if 258 | // it does, truncate it. 259 | s.log.Printf("Opening file path %q for writing", path) 260 | f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, defaultChmod) 261 | if err != nil { 262 | return fmt.Errorf("unable to create/open file %q: %w", path, err) 263 | } 264 | 265 | defer f.Close() 266 | 267 | // Check if the last character is a newline, and if not, add one 268 | if !bytes.HasSuffix(data, []byte{'\n'}) { 269 | s.log.Printf("Adding new line to end of contents (content did not end on a line break)") 270 | data = append(data, '\n') 271 | } 272 | 273 | // Write the entire file buffer back to the file in disk 274 | if _, err := f.Write(data); err != nil { 275 | return fmt.Errorf("unable to write file contents for file %q: %w", path, err) 276 | } 277 | 278 | // Attempt to close the file cleanly 279 | if err := f.Close(); err != nil { 280 | return fmt.Errorf("unable to close file after write for file %q: %w", path, err) 281 | } 282 | 283 | return nil 284 | } 285 | -------------------------------------------------------------------------------- /slice/execute_test.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | "text/template" 9 | 10 | local "github.com/patrickdappollonio/kubectl-slice/slice/template" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestExecute_processSingleFile(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | fields Options 18 | fileInput string 19 | wantErr bool 20 | wantFilterErr bool 21 | fileOutput *yamlFile 22 | }{ 23 | { 24 | name: "basic pod", 25 | fields: Options{}, 26 | fileInput: ` 27 | apiVersion: v1 28 | kind: Pod 29 | metadata: 30 | name: nginx-ingress 31 | `, 32 | fileOutput: &yamlFile{ 33 | filename: "pod-nginx-ingress.yaml", 34 | meta: kubeObjectMeta{ 35 | APIVersion: "v1", 36 | Kind: "Pod", 37 | Name: "nginx-ingress", 38 | }, 39 | }, 40 | }, 41 | // ---------------------------------------------------------------- 42 | { 43 | name: "empty file", 44 | fields: Options{}, 45 | fileInput: `---`, 46 | fileOutput: &yamlFile{ 47 | filename: "-.yaml", 48 | }, 49 | }, 50 | // ---------------------------------------------------------------- 51 | { 52 | name: "include kind", 53 | fields: Options{ 54 | IncludedKinds: []string{"Pod"}, 55 | }, 56 | fileInput: ` 57 | apiVersion: v1 58 | kind: Pod 59 | metadata: 60 | name: nginx-ingress 61 | `, 62 | fileOutput: &yamlFile{ 63 | filename: "pod-nginx-ingress.yaml", 64 | meta: kubeObjectMeta{ 65 | APIVersion: "v1", 66 | Kind: "Pod", 67 | Name: "nginx-ingress", 68 | }, 69 | }, 70 | }, 71 | { 72 | name: "include Pod using include option", 73 | fields: Options{ 74 | Included: []string{"Pod/*"}, 75 | }, 76 | fileInput: ` 77 | apiVersion: v1 78 | kind: Pod 79 | metadata: 80 | name: nginx-ingress 81 | `, 82 | fileOutput: &yamlFile{ 83 | filename: "pod-nginx-ingress.yaml", 84 | meta: kubeObjectMeta{ 85 | APIVersion: "v1", 86 | Kind: "Pod", 87 | Name: "nginx-ingress", 88 | }, 89 | }, 90 | }, 91 | // ---------------------------------------------------------------- 92 | { 93 | name: "non kubernetes files skipped using strict kubernetes", 94 | fields: Options{ 95 | StrictKubernetes: true, 96 | }, 97 | fileInput: ` 98 | # 99 | # This is a comment 100 | # 101 | `, 102 | }, 103 | // ---------------------------------------------------------------- 104 | { 105 | name: "non kubernetes file", 106 | fields: Options{}, 107 | fileInput: ` 108 | # 109 | # This is a comment 110 | # 111 | `, 112 | fileOutput: &yamlFile{ 113 | filename: "-.yaml", 114 | }, 115 | }, 116 | // ---------------------------------------------------------------- 117 | { 118 | name: "file with only spaces", 119 | fields: Options{}, 120 | fileInput: ` 121 | `, 122 | }, 123 | // ---------------------------------------------------------------- 124 | { 125 | name: "skipping kind", 126 | fields: Options{ 127 | IncludedKinds: []string{"Pod"}, 128 | }, 129 | fileInput: ` 130 | apiVersion: v1 131 | kind: Namespace 132 | metadata: 133 | name: foobar 134 | `, 135 | }, 136 | // ---------------------------------------------------------------- 137 | { 138 | name: "skipping name", 139 | fields: Options{ 140 | IncludedNames: []string{"foofoo"}, 141 | }, 142 | fileInput: ` 143 | apiVersion: v1 144 | kind: Namespace 145 | metadata: 146 | name: foobar 147 | `, 148 | }, 149 | // ---------------------------------------------------------------- 150 | { 151 | name: "invalid YAML", 152 | fields: Options{}, 153 | fileInput: `kind: "Namespace`, 154 | wantErr: true, 155 | }, 156 | // ---------------------------------------------------------------- 157 | { 158 | name: "invalid YAML", 159 | fields: Options{}, 160 | fileInput: ` 161 | kind: "Namespace 162 | `, 163 | wantErr: true, 164 | }, 165 | { 166 | name: "invalid excluded", 167 | fields: Options{ 168 | Excluded: []string{"Pod/Namespace/*"}, 169 | }, 170 | wantFilterErr: true, 171 | }, 172 | { 173 | name: "invalid included", 174 | fields: Options{ 175 | Included: []string{"Pod"}, 176 | }, 177 | wantFilterErr: true, 178 | }, 179 | } 180 | 181 | for _, tt := range tests { 182 | t.Run(tt.name, func(t *testing.T) { 183 | t.Parallel() 184 | 185 | s := &Split{ 186 | opts: tt.fields, 187 | log: nolog, 188 | template: template.Must(template.New("split").Funcs(local.Functions).Parse(DefaultTemplateName)), 189 | fileCount: 1, 190 | } 191 | 192 | requireErrorIf(t, tt.wantFilterErr, s.validateFilters()) 193 | requireErrorIf(t, tt.wantErr, s.processSingleFile([]byte(tt.fileInput))) 194 | 195 | if tt.fileOutput != nil { 196 | require.Lenf(t, s.filesFound, 1, "expected 1 file from list, got %d", len(s.filesFound)) 197 | 198 | current := s.filesFound[0] 199 | require.Equal(t, tt.fileOutput.filename, current.filename) 200 | require.Equal(t, tt.fileOutput.meta.APIVersion, current.meta.APIVersion) 201 | require.Equal(t, tt.fileOutput.meta.Kind, current.meta.Kind) 202 | require.Equal(t, tt.fileOutput.meta.Name, current.meta.Name) 203 | require.Equal(t, tt.fileOutput.meta.Namespace, current.meta.Namespace) 204 | } else { 205 | require.Lenf(t, s.filesFound, 0, "expected 0 files from list, got %d", len(s.filesFound)) 206 | } 207 | }) 208 | } 209 | } 210 | 211 | func TestExecute_writeToFileCases(t *testing.T) { 212 | tempdir := t.TempDir() 213 | s := &Split{log: nolog} 214 | 215 | t.Run("write new file", func(tt *testing.T) { 216 | t.Parallel() 217 | require.NoError(tt, s.writeToFile(filepath.Join(tempdir, "test.txt"), []byte("test"))) 218 | content, err := os.ReadFile(filepath.Join(tempdir, "test.txt")) 219 | require.NoError(tt, err) 220 | require.Equal(tt, "test\n", string(content)) 221 | }) 222 | 223 | t.Run("truncate existent file", func(tt *testing.T) { 224 | preexistent := filepath.Join(tempdir, "test_no_newline.txt") 225 | 226 | require.NoError(tt, os.WriteFile(preexistent, []byte("foobarbaz"), 0o644)) 227 | require.NoError(tt, s.writeToFile(preexistent, []byte("test"))) 228 | 229 | content, err := os.ReadFile(preexistent) 230 | require.NoError(tt, err) 231 | require.Equal(tt, "test\n", string(content)) 232 | }) 233 | 234 | t.Run("attempt writing to a read only directory", func(tt *testing.T) { 235 | require.NoError(tt, os.MkdirAll(filepath.Join(tempdir, "readonly"), 0o444)) 236 | require.Error(tt, s.writeToFile(filepath.Join(tempdir, "readonly", "test.txt"), []byte("test"))) 237 | }) 238 | 239 | t.Run("attempt writing to a read only sub-directory", func(tt *testing.T) { 240 | require.NoError(tt, os.MkdirAll(filepath.Join(tempdir, "readonly_sub"), 0o444)) 241 | require.Error(tt, s.writeToFile(filepath.Join(tempdir, "readonly_sub", "readonly", "test.txt"), []byte("test"))) 242 | }) 243 | } 244 | 245 | func TestAddingTripleDashes(t *testing.T) { 246 | cases := []struct { 247 | name string 248 | input string 249 | includeDashes bool 250 | output map[string]string 251 | }{ 252 | { 253 | name: "empty file", 254 | input: `---`, 255 | output: map[string]string{"-.yaml": "---\n"}, 256 | }, 257 | { 258 | name: "simple no dashes", 259 | input: `apiVersion: v1 260 | kind: Pod 261 | metadata: 262 | name: nginx-ingress 263 | --- 264 | apiVersion: v1 265 | kind: Namespace 266 | metadata: 267 | name: production`, 268 | output: map[string]string{ 269 | "pod-nginx-ingress.yaml": "apiVersion: v1\nkind: Pod\nmetadata:\n name: nginx-ingress\n", 270 | "namespace-production.yaml": "apiVersion: v1\nkind: Namespace\nmetadata:\n name: production\n", 271 | }, 272 | }, 273 | { 274 | name: "simple with dashes", 275 | includeDashes: true, 276 | input: `apiVersion: v1 277 | kind: Pod 278 | metadata: 279 | name: nginx-ingress 280 | --- 281 | apiVersion: v1 282 | kind: Namespace 283 | metadata: 284 | name: production`, 285 | output: map[string]string{ 286 | "pod-nginx-ingress.yaml": "---\napiVersion: v1\nkind: Pod\nmetadata:\n name: nginx-ingress\n", 287 | "namespace-production.yaml": "---\napiVersion: v1\nkind: Namespace\nmetadata:\n name: production\n", 288 | }, 289 | }, 290 | { 291 | name: "simple with dashes - adding empty intermediate files", 292 | includeDashes: true, 293 | input: `apiVersion: v1 294 | kind: Pod 295 | metadata: 296 | name: nginx-ingress 297 | --- 298 | --- 299 | --- 300 | --- 301 | apiVersion: v1 302 | kind: Namespace 303 | metadata: 304 | name: production`, 305 | output: map[string]string{ 306 | "pod-nginx-ingress.yaml": "---\napiVersion: v1\nkind: Pod\nmetadata:\n name: nginx-ingress\n", 307 | "namespace-production.yaml": "---\napiVersion: v1\nkind: Namespace\nmetadata:\n name: production\n", 308 | }, 309 | }, 310 | } 311 | 312 | for _, tt := range cases { 313 | t.Run(tt.name, func(t *testing.T) { 314 | t.Parallel() 315 | 316 | tdinput := t.TempDir() 317 | tdoutput := t.TempDir() 318 | require.NotEqual(t, tdinput, tdoutput, "input and output directories should be different") 319 | 320 | err := os.WriteFile(filepath.Join(tdinput, "input.yaml"), []byte(tt.input), 0o644) 321 | require.NoError(t, err, "error found while writing input file") 322 | 323 | s, err := New(Options{ 324 | GoTemplate: DefaultTemplateName, 325 | IncludeTripleDash: tt.includeDashes, 326 | InputFile: filepath.Join(tdinput, "input.yaml"), 327 | OutputDirectory: tdoutput, 328 | Stderr: os.Stderr, 329 | Stdout: io.Discard, 330 | }) 331 | require.NoError(t, err, "error found while creating new Split instance") 332 | require.NoError(t, s.Execute(), "error found while executing slice") 333 | 334 | files, err := os.ReadDir(tdoutput) 335 | require.NoError(t, err, "error found while reading output directory") 336 | 337 | for _, file := range files { 338 | content, err := os.ReadFile(filepath.Join(tdoutput, file.Name())) 339 | require.NoError(t, err, "error found while reading file %q", file.Name()) 340 | 341 | expected, found := tt.output[file.Name()] 342 | require.True(t, found, "expected file %q to be found in the output map", file.Name()) 343 | require.Equal(t, expected, string(content), "expected content to be equal for file %q", file.Name()) 344 | } 345 | }) 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/patrickdappollonio/kubectl-slice/slice" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/pflag" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | var version = "development" 16 | 17 | const ( 18 | helpShort = "kubectl-slice allows you to split a YAML into multiple subfiles using a pattern." 19 | 20 | helpLong = `kubectl-slice allows you to split a YAML into multiple subfiles using a pattern. 21 | For documentation, available functions, and more, visit: https://github.com/patrickdappollonio/kubectl-slice.` 22 | ) 23 | 24 | var examples = []string{ 25 | "kubectl-slice -f foo.yaml -o ./ --include-kind Pod,Namespace", 26 | "kubectl-slice -f foo.yaml -o ./ --exclude-kind Pod", 27 | "kubectl-slice -f foo.yaml -o ./ --exclude-name *-svc", 28 | "kubectl-slice -f foo.yaml --exclude-name *-svc --stdout", 29 | "kubectl-slice -f foo.yaml --include Pod/* --stdout", 30 | "kubectl-slice -f foo.yaml --exclude deployment/kube* --stdout", 31 | "kubectl-slice -d ./ --recurse -o ./ --include-kind Pod,Namespace", 32 | "kubectl-slice -d ./ --recurse --stdout --include Pod/*", 33 | "kubectl-slice --config config.yaml", 34 | } 35 | 36 | func generateExamples([]string) string { 37 | var s bytes.Buffer 38 | for pos, v := range examples { 39 | s.WriteString(fmt.Sprintf(" %s", v)) 40 | 41 | if pos != len(examples)-1 { 42 | s.WriteString("\n") 43 | } 44 | } 45 | 46 | return s.String() 47 | } 48 | 49 | func root() *cobra.Command { 50 | opts := slice.Options{} 51 | var configFile string 52 | 53 | rootCommand := &cobra.Command{ 54 | Use: "kubectl-slice", 55 | Short: helpShort, 56 | Long: helpLong, 57 | Version: version, 58 | SilenceUsage: true, 59 | SilenceErrors: true, 60 | Example: generateExamples(examples), 61 | 62 | PreRunE: func(cmd *cobra.Command, args []string) error { 63 | return bindCobraAndViper(cmd, configFile) 64 | }, 65 | 66 | RunE: func(cmd *cobra.Command, args []string) error { 67 | // Bind to the appropriate stdout/stderr 68 | opts.Stdout = cmd.OutOrStdout() 69 | opts.Stderr = cmd.ErrOrStderr() 70 | 71 | // If no input file has been provided or it's "-", then 72 | // point the app to stdin 73 | if (opts.InputFile == "" || opts.InputFile == "-") && opts.InputFolder == "" { 74 | opts.InputFile = os.Stdin.Name() 75 | 76 | // Check if we're receiving data from the terminal 77 | // or from piped content. Users from piped content 78 | // won't see this message. Users that might have forgotten 79 | // setting the flags correctly will see this message. 80 | if !opts.Quiet { 81 | if fi, err := os.Stdin.Stat(); err == nil && fi.Mode()&os.ModeNamedPipe == 0 { 82 | fmt.Fprintln(opts.Stderr, "Receiving data from the terminal. Press CTRL+D when you're done typing or CTRL+C") 83 | fmt.Fprintln(opts.Stderr, "to exit without processing the content. If you're seeing this by mistake, make") 84 | fmt.Fprintln(opts.Stderr, "sure the command line flags, environment variables or config file are correct.") 85 | } 86 | } 87 | } 88 | 89 | // Create a new instance. This will also perform a basic validation. 90 | instance, err := slice.New(opts) 91 | if err != nil { 92 | return fmt.Errorf("validation failed: %w", err) 93 | } 94 | 95 | return instance.Execute() 96 | }, 97 | } 98 | 99 | rootCommand.Flags().StringVarP(&opts.InputFile, "input-file", "f", "", "the input file used to read the initial macro YAML file; if empty or \"-\", stdin is used (exclusive with --input-folder)") 100 | rootCommand.Flags().StringVarP(&opts.InputFolder, "input-folder", "d", "", "the input folder used to read the initial macro YAML files (exclusive with --input-file)") 101 | rootCommand.Flags().StringSliceVar(&opts.InputFolderExt, "extensions", []string{".yaml", ".yml"}, "the extensions to look for in the input folder") 102 | rootCommand.Flags().BoolVarP(&opts.Recurse, "recurse", "r", false, "if true, the input folder will be read recursively (has no effect unless used with --input-folder)") 103 | rootCommand.Flags().StringVarP(&opts.OutputDirectory, "output-dir", "o", "", "the output directory used to output the splitted files") 104 | rootCommand.Flags().StringVarP(&opts.GoTemplate, "template", "t", slice.DefaultTemplateName, "go template used to generate the file name when creating the resource files in the output directory") 105 | rootCommand.Flags().BoolVar(&opts.DryRun, "dry-run", false, "if true, no files are created, but the potentially generated files will be printed as the command output") 106 | rootCommand.Flags().BoolVar(&opts.DebugMode, "debug", false, "enable debug mode") 107 | rootCommand.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "if true, no output is written to stdout/err") 108 | rootCommand.Flags().StringSliceVar(&opts.IncludedKinds, "include-kind", nil, "resource kind to include in the output (singular, case insensitive, glob supported)") 109 | rootCommand.Flags().StringSliceVar(&opts.ExcludedKinds, "exclude-kind", nil, "resource kind to exclude in the output (singular, case insensitive, glob supported)") 110 | rootCommand.Flags().StringSliceVar(&opts.IncludedNames, "include-name", nil, "resource name to include in the output (singular, case insensitive, glob supported)") 111 | rootCommand.Flags().StringSliceVar(&opts.ExcludedNames, "exclude-name", nil, "resource name to exclude in the output (singular, case insensitive, glob supported)") 112 | rootCommand.Flags().StringSliceVar(&opts.Included, "include", nil, "resource name to include in the output (format /, case insensitive, glob supported)") 113 | rootCommand.Flags().StringSliceVar(&opts.Excluded, "exclude", nil, "resource name to exclude in the output (format /, case insensitive, glob supported)") 114 | rootCommand.Flags().BoolVarP(&opts.StrictKubernetes, "skip-non-k8s", "s", false, "if enabled, any YAMLs that don't contain at least an \"apiVersion\", \"kind\" and \"metadata.name\" will be excluded from the split") 115 | rootCommand.Flags().BoolVar(&opts.SortByKind, "sort-by-kind", false, "if enabled, resources are sorted by Kind, a la Helm, before saving them to disk") 116 | rootCommand.Flags().BoolVar(&opts.OutputToStdout, "stdout", false, "if enabled, no resource is written to disk and all resources are printed to stdout instead") 117 | rootCommand.Flags().StringVarP(&configFile, "config", "c", "", "path to the config file") 118 | rootCommand.Flags().BoolVar(&opts.AllowEmptyKinds, "allow-empty-kinds", false, "if enabled, resources with empty kinds don't produce an error when filtering") 119 | rootCommand.Flags().BoolVar(&opts.AllowEmptyNames, "allow-empty-names", false, "if enabled, resources with empty names don't produce an error when filtering") 120 | rootCommand.Flags().BoolVar(&opts.IncludeTripleDash, "include-triple-dash", false, "if enabled, the typical \"---\" YAML separator is included at the beginning of resources sliced") 121 | rootCommand.Flags().BoolVar(&opts.PruneOutputDir, "prune", false, "if enabled, the output directory will be pruned before writing the files") 122 | rootCommand.Flags().BoolVar(&opts.RemoveFileComments, "remove-comments", false, "if enabled, comments generated by the app are removed from the sliced files (but keep comments from the original file)") 123 | rootCommand.Flags().StringSliceVar(&opts.IncludedGroups, "include-group", nil, "resource kind to include in the output (singular, case insensitive, glob supported)") 124 | rootCommand.Flags().StringSliceVar(&opts.ExcludedGroups, "exclude-group", nil, "resource kind to exclude in the output (singular, case insensitive, glob supported)") 125 | _ = rootCommand.Flags().MarkHidden("debug") 126 | return rootCommand 127 | } 128 | 129 | // envVarPrefix is the prefix used for environment variables. 130 | // Using underscores to ensure compatibility with the shell. 131 | const envVarPrefix = "KUBECTL_SLICE" 132 | 133 | // skippedFlags is a list of flags that are not bound through 134 | // Viper. These include things like "help", "version", and of 135 | // course, "config", since it doesn't make sense to say where 136 | // the config file is located in the config file itself. 137 | var skippedFlags = [...]string{ 138 | "help", 139 | "version", 140 | "config", 141 | } 142 | 143 | // bindCobraAndViper binds the settings loaded by Viper 144 | // to the flags defined in Cobra. 145 | func bindCobraAndViper(cmd *cobra.Command, configFileLocation string) error { 146 | v := viper.New() 147 | 148 | // If a configuration file has been passed... 149 | if cmd.Flags().Lookup("config").Changed { 150 | // ... then set it as the configuration file 151 | v.SetConfigFile(configFileLocation) 152 | 153 | // then read the configuration file 154 | if err := v.ReadInConfig(); err != nil { 155 | return fmt.Errorf("failed to read configuration file: %w", err) 156 | } 157 | } 158 | 159 | // Handler for potential error 160 | var err error 161 | 162 | // Recurse through all the variables 163 | cmd.Flags().VisitAll(func(flag *pflag.Flag) { 164 | // Skip the flags that are not bound through Viper 165 | for _, v := range skippedFlags { 166 | if v == flag.Name { 167 | return 168 | } 169 | } 170 | 171 | // Normalize key names with underscores instead of dashes 172 | nameUnderscored := strings.ReplaceAll(flag.Name, "-", "_") 173 | envVarName := strings.ToUpper(fmt.Sprintf("%s_%s", envVarPrefix, nameUnderscored)) 174 | 175 | // Bind the flag to the environment variable 176 | if val, found := os.LookupEnv(envVarName); found { 177 | v.Set(nameUnderscored, val) 178 | } 179 | 180 | // If the CLI flag hasn't been changed, but the value is set in 181 | // the configuration file, then set the CLI flag to the value 182 | // from the configuration file 183 | if !flag.Changed && v.IsSet(nameUnderscored) { 184 | // Type check for all the supported types 185 | switch val := v.Get(nameUnderscored).(type) { 186 | 187 | case string: 188 | _ = cmd.Flags().Set(flag.Name, val) 189 | 190 | case []interface{}: 191 | var stringified []string 192 | for _, v := range val { 193 | stringified = append(stringified, fmt.Sprintf("%v", v)) 194 | } 195 | _ = cmd.Flags().Set(flag.Name, strings.Join(stringified, ",")) 196 | 197 | case bool: 198 | _ = cmd.Flags().Set(flag.Name, fmt.Sprintf("%t", val)) 199 | 200 | case int: 201 | _ = cmd.Flags().Set(flag.Name, fmt.Sprintf("%d", val)) 202 | 203 | default: 204 | err = fmt.Errorf("unsupported type %T for flag %q", val, nameUnderscored) 205 | return 206 | } 207 | } 208 | }) 209 | 210 | // If an error occurred, return it 211 | return err 212 | } 213 | -------------------------------------------------------------------------------- /slice/process_test.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "testing" 5 | "text/template" 6 | 7 | local "github.com/patrickdappollonio/kubectl-slice/slice/template" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func Test_inSliceIgnoreCase(t *testing.T) { 12 | type args struct { 13 | slice []string 14 | expected string 15 | } 16 | 17 | tests := []struct { 18 | name string 19 | args args 20 | want bool 21 | }{ 22 | { 23 | name: "in slice", 24 | args: args{ 25 | slice: []string{"foo", "bar"}, 26 | expected: "foo", 27 | }, 28 | want: true, 29 | }, 30 | { 31 | name: "not in slice", 32 | args: args{ 33 | slice: []string{"foo", "bar"}, 34 | expected: "baz", 35 | }, 36 | want: false, 37 | }, 38 | { 39 | name: "pattern fo-star without glob support", 40 | args: args{ 41 | slice: []string{"fo*", "bar"}, 42 | expected: "foo", 43 | }, 44 | want: false, 45 | }, 46 | { 47 | name: "pattern anything without glob support", 48 | args: args{ 49 | slice: []string{"*"}, 50 | expected: "foo", 51 | }, 52 | want: false, 53 | }, 54 | } 55 | 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | t.Parallel() 59 | 60 | if got := inSliceIgnoreCase(tt.args.slice, tt.args.expected); got != tt.want { 61 | t.Errorf("inSliceIgnoreCase() = %v, want %v", got, tt.want) 62 | } 63 | }) 64 | } 65 | } 66 | 67 | func Test_inSliceIgnoreCaseGlob(t *testing.T) { 68 | type args struct { 69 | slice []string 70 | expected string 71 | } 72 | 73 | tests := []struct { 74 | name string 75 | args args 76 | want bool 77 | }{ 78 | { 79 | name: "in slice", 80 | args: args{ 81 | slice: []string{"foo", "bar"}, 82 | expected: "foo", 83 | }, 84 | want: true, 85 | }, 86 | { 87 | name: "not in slice", 88 | args: args{ 89 | slice: []string{"foo", "bar"}, 90 | expected: "baz", 91 | }, 92 | want: false, 93 | }, 94 | { 95 | name: "pattern fo-star", 96 | args: args{ 97 | slice: []string{"fo*", "bar"}, 98 | expected: "foo", 99 | }, 100 | want: true, 101 | }, 102 | { 103 | name: "pattern anything", 104 | args: args{ 105 | slice: []string{"*"}, 106 | expected: "foo", 107 | }, 108 | want: true, 109 | }, 110 | { 111 | name: "kubernetes annotation", 112 | args: args{ 113 | slice: []string{"kubernetes.io/*"}, 114 | expected: "kubernetes.io/ingress.class", 115 | }, 116 | want: true, 117 | }, 118 | } 119 | 120 | for _, tt := range tests { 121 | t.Run(tt.name, func(t *testing.T) { 122 | t.Parallel() 123 | 124 | require.Equal(t, tt.want, inSliceIgnoreCaseGlob(tt.args.slice, tt.args.expected)) 125 | }) 126 | } 127 | } 128 | 129 | func Test_checkStringInMap(t *testing.T) { 130 | type args struct { 131 | local map[string]interface{} 132 | key string 133 | } 134 | 135 | tests := []struct { 136 | name string 137 | args args 138 | want string 139 | }{ 140 | { 141 | name: "key found in map", 142 | args: args{ 143 | local: map[string]interface{}{ 144 | "foo": "bar", 145 | }, 146 | key: "foo", 147 | }, 148 | want: "bar", 149 | }, 150 | { 151 | name: "key not found in map", 152 | args: args{ 153 | local: map[string]interface{}{ 154 | "foo": "bar", 155 | }, 156 | key: "baz", 157 | }, 158 | want: "", 159 | }, 160 | { 161 | name: "key with non string value", 162 | args: args{ 163 | local: map[string]interface{}{ 164 | "foo": map[string]string{ 165 | "bar": "baz", 166 | }, 167 | }, 168 | key: "foo", 169 | }, 170 | want: "", 171 | }, 172 | } 173 | 174 | for _, tt := range tests { 175 | t.Run(tt.name, func(t *testing.T) { 176 | t.Parallel() 177 | require.Equal(t, tt.want, checkStringInMap(tt.args.local, tt.args.key)) 178 | }) 179 | } 180 | } 181 | 182 | func Test_checkKubernetesBasics(t *testing.T) { 183 | type args struct { 184 | manifest map[string]interface{} 185 | } 186 | 187 | tests := []struct { 188 | name string 189 | args args 190 | want kubeObjectMeta 191 | }{ 192 | { 193 | name: "all fields found", 194 | args: args{ 195 | manifest: map[string]interface{}{ 196 | "kind": "Deployment", 197 | "apiVersion": "apps/v1", 198 | "metadata": map[string]interface{}{ 199 | "name": "foo", 200 | }, 201 | }, 202 | }, 203 | want: kubeObjectMeta{ 204 | Kind: "Deployment", 205 | APIVersion: "apps/v1", 206 | Name: "foo", 207 | }, 208 | }, 209 | { 210 | name: "no fields found", 211 | args: args{ 212 | manifest: map[string]interface{}{}, 213 | }, 214 | want: kubeObjectMeta{}, 215 | }, 216 | { 217 | name: "missing metadata fields", 218 | args: args{ 219 | manifest: map[string]interface{}{ 220 | "kind": "Deployment", 221 | "apiVersion": "apps/v1", 222 | }, 223 | }, 224 | want: kubeObjectMeta{ 225 | Kind: "Deployment", 226 | APIVersion: "apps/v1", 227 | }, 228 | }, 229 | } 230 | 231 | for _, tt := range tests { 232 | t.Run(tt.name, func(t *testing.T) { 233 | t.Parallel() 234 | require.Equal(t, tt.want, checkKubernetesBasics(tt.args.manifest)) 235 | }) 236 | } 237 | } 238 | 239 | func TestSplit_parseYAMLManifest(t *testing.T) { 240 | tests := []struct { 241 | name string 242 | contents []byte 243 | strictKube bool 244 | want yamlFile 245 | wantErr bool 246 | }{ 247 | { 248 | name: "valid yaml with namespace", 249 | contents: []byte(`--- 250 | apiVersion: v1 251 | kind: Service 252 | metadata: 253 | name: foo 254 | namespace: bar 255 | `), 256 | want: yamlFile{ 257 | filename: "service-foo.yaml", 258 | meta: kubeObjectMeta{ 259 | APIVersion: "v1", 260 | Kind: "Service", 261 | Name: "foo", 262 | Namespace: "bar", 263 | }, 264 | }, 265 | }, 266 | { 267 | name: "valid yaml without namespace", 268 | contents: []byte(`--- 269 | apiVersion: v1 270 | kind: Service 271 | metadata: 272 | name: foo 273 | `), 274 | want: yamlFile{ 275 | filename: "service-foo.yaml", 276 | meta: kubeObjectMeta{ 277 | APIVersion: "v1", 278 | Kind: "Service", 279 | Name: "foo", 280 | Namespace: "", 281 | }, 282 | }, 283 | }, 284 | { 285 | name: "valid yaml with namespace, strict kubernetes", 286 | contents: []byte(`--- 287 | apiVersion: v1 288 | kind: Service 289 | metadata: 290 | name: foo 291 | namespace: bar 292 | `), 293 | strictKube: true, 294 | want: yamlFile{ 295 | filename: "service-foo.yaml", 296 | meta: kubeObjectMeta{ 297 | APIVersion: "v1", 298 | Kind: "Service", 299 | Name: "foo", 300 | Namespace: "bar", 301 | }, 302 | }, 303 | }, 304 | { 305 | name: "valid yaml without namespace, strict kubernetes", 306 | contents: []byte(`--- 307 | apiVersion: v1 308 | kind: Service 309 | metadata: 310 | name: foo 311 | `), 312 | strictKube: true, 313 | want: yamlFile{ 314 | filename: "service-foo.yaml", 315 | meta: kubeObjectMeta{ 316 | APIVersion: "v1", 317 | Kind: "Service", 318 | Name: "foo", 319 | Namespace: "", 320 | }, 321 | }, 322 | }, 323 | { 324 | name: "apiVersion only, strict kubernetes", 325 | contents: []byte(`--- 326 | apiVersion: v1 327 | `), 328 | strictKube: true, 329 | wantErr: true, 330 | }, 331 | { 332 | name: "apiVersion + kind, strict kubernetes", 333 | contents: []byte(`--- 334 | apiVersion: v1 335 | kind: Foo 336 | `), 337 | strictKube: true, 338 | wantErr: true, 339 | }, 340 | } 341 | for _, tt := range tests { 342 | t.Run(tt.name, func(t *testing.T) { 343 | t.Parallel() 344 | 345 | s := &Split{ 346 | log: nolog, 347 | template: template.Must(template.New(DefaultTemplateName).Funcs(local.Functions).Parse(DefaultTemplateName)), 348 | } 349 | s.opts.StrictKubernetes = tt.strictKube 350 | 351 | got, err := s.parseYAMLManifest(tt.contents) 352 | requireErrorIf(t, tt.wantErr, err) 353 | require.Equal(t, tt.want, got) 354 | }) 355 | } 356 | } 357 | 358 | func TestSplit_parseYamlManifestAllowingEmpties(t *testing.T) { 359 | tests := []struct { 360 | name string 361 | contents []byte 362 | skipEmptyName bool 363 | skipEmptyKind bool 364 | includeKind string 365 | includeName string 366 | want yamlFile 367 | wantErr bool 368 | }{ 369 | { 370 | name: "include name and kind", 371 | contents: []byte(`--- 372 | apiVersion: v1 373 | kind: Foo 374 | metadata: 375 | name: bar 376 | `), 377 | want: yamlFile{ 378 | filename: "foo-bar.yaml", 379 | meta: kubeObjectMeta{APIVersion: "v1", Kind: "Foo", Name: "bar"}, 380 | }, 381 | includeKind: "Foo", 382 | skipEmptyName: false, 383 | skipEmptyKind: false, 384 | }, 385 | { 386 | name: "allow empty kind", 387 | contents: []byte(`--- 388 | apiVersion: v1 389 | kind: "" 390 | metadata: 391 | name: bar 392 | `), 393 | want: yamlFile{ 394 | filename: "-bar.yaml", 395 | meta: kubeObjectMeta{APIVersion: "v1", Kind: "", Name: "bar"}, 396 | }, 397 | includeName: "bar", 398 | skipEmptyName: false, 399 | skipEmptyKind: true, 400 | }, 401 | { 402 | name: "dont allow empty kind", 403 | contents: []byte(`--- 404 | apiVersion: v1 405 | metadata: 406 | name: bar 407 | `), 408 | wantErr: true, 409 | includeName: "bar", 410 | skipEmptyName: false, 411 | skipEmptyKind: false, 412 | }, 413 | { 414 | name: "allow empty name", 415 | contents: []byte(`--- 416 | apiVersion: v1 417 | kind: Foo 418 | metadata: 419 | name: "" 420 | `), 421 | want: yamlFile{ 422 | filename: "foo-.yaml", 423 | meta: kubeObjectMeta{APIVersion: "v1", Kind: "Foo", Name: ""}, 424 | }, 425 | includeKind: "Foo", 426 | skipEmptyName: true, 427 | skipEmptyKind: false, 428 | }, 429 | { 430 | name: "dont allow empty name", 431 | contents: []byte(`--- 432 | apiVersion: v1 433 | kind: Foo 434 | `), 435 | wantErr: true, 436 | includeKind: "Foo", 437 | skipEmptyName: false, 438 | skipEmptyKind: false, 439 | }, 440 | } 441 | 442 | for _, tt := range tests { 443 | t.Run(tt.name, func(t *testing.T) { 444 | t.Parallel() 445 | 446 | s := &Split{ 447 | log: nolog, 448 | template: template.Must(template.New(DefaultTemplateName).Funcs(local.Functions).Parse(DefaultTemplateName)), 449 | } 450 | 451 | if len(tt.includeKind) > 0 { 452 | s.opts.IncludedKinds = []string{tt.includeKind} 453 | } 454 | 455 | if len(tt.includeName) > 0 { 456 | s.opts.IncludedNames = []string{tt.includeName} 457 | } 458 | 459 | s.opts.AllowEmptyKinds = tt.skipEmptyKind 460 | s.opts.AllowEmptyNames = tt.skipEmptyName 461 | 462 | if err := s.validateFilters(); err != nil { 463 | t.Fatalf("not expecting error validating filters, got: %s", err) 464 | } 465 | 466 | got, err := s.parseYAMLManifest(tt.contents) 467 | requireErrorIf(t, tt.wantErr, err) 468 | t.Logf("got: %#v", got) 469 | require.Equal(t, tt.want, got) 470 | }) 471 | } 472 | } 473 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | - [Examples](#examples) 4 | - [Slicing the Tekton manifest](#slicing-the-tekton-manifest) 5 | - [Finding all Kubernetes resources of a given kind in multiple YAML files in a folder](#finding-all-kubernetes-resources-of-a-given-kind-in-multiple-yaml-files-in-a-folder) 6 | 7 | The following examples demonstrate the capabilities of `kubectl-slice`. 8 | 9 | ## Slicing the Tekton manifest 10 | 11 | [Tekton Pipelines](https://tekton.dev/) is a powerful tool that's available through a Helm Chart from the [cd.foundation](https://cd.foundation). We can grab it from their Helm repository and render it locally, then use `kubectl-slice` to split it into multiple files. 12 | 13 | We'll use the following filename template so there's one folder for each Kubernetes resource `kind`, so all `Secrets` for example are in the same folder, then we will use the resource name as defined in `metadata.name`. We'll also modify the name, since some of the Tekton resources have an FQDN for a name, like `tekton.pipelines.dev`, with the `dottodash` template function: 14 | 15 | ```handlebars 16 | {{.kind|lower}}/{{.metadata.name|dottodash}}.yaml 17 | ``` 18 | 19 | We will render the Helm Chart locally to `stdout` with: 20 | 21 | ```bash 22 | helm repo add cdf https://cdfoundation.github.io/tekton-helm-chart/ 23 | helm template tekton cdf/tekton-pipeline 24 | ``` 25 | 26 | Then we can pipe that output directly to `kubectl-slice`: 27 | 28 | ```bash 29 | helm template tekton cdf/tekton-pipeline | kubectl-slice --template '{{.kind|lower}}/{{.metadata.name|dottodash}}.yaml' --output-dir . 30 | ``` 31 | 32 | Which will render the following output: 33 | 34 | ```text 35 | Wrote rolebinding/tekton-pipelines-info.yaml -- 590 bytes. 36 | Wrote service/tekton-pipelines-controller.yaml -- 1007 bytes. 37 | Wrote podsecuritypolicy/tekton-pipelines.yaml -- 1262 bytes. 38 | Wrote configmap/config-registry-cert.yaml -- 906 bytes. 39 | Wrote configmap/feature-flags.yaml -- 646 bytes. 40 | Wrote clusterrole/tekton-pipelines-controller-tenant-access.yaml -- 1035 bytes. 41 | Wrote clusterrolebinding/tekton-pipelines-webhook-cluster-access.yaml -- 565 bytes. 42 | Wrote role/tekton-pipelines-info.yaml -- 592 bytes. 43 | Wrote service/tekton-pipelines-webhook.yaml -- 1182 bytes. 44 | Wrote deployment/tekton-pipelines-webhook.yaml -- 3645 bytes. 45 | Wrote serviceaccount/tekton-bot.yaml -- 883 bytes. 46 | Wrote configmap/config-defaults.yaml -- 2424 bytes. 47 | Wrote configmap/config-logging.yaml -- 1596 bytes. 48 | Wrote customresourcedefinition/runs-tekton-dev.yaml -- 2308 bytes. 49 | Wrote role/tekton-pipelines-leader-election.yaml -- 495 bytes. 50 | Wrote rolebinding/tekton-pipelines-webhook.yaml -- 535 bytes. 51 | Wrote customresourcedefinition/clustertasks-tekton-dev.yaml -- 2849 bytes. 52 | Wrote customresourcedefinition/pipelineresources-tekton-dev.yaml -- 1874 bytes. 53 | Wrote clusterrole/tekton-aggregate-view.yaml -- 1133 bytes. 54 | Wrote role/tekton-pipelines-webhook.yaml -- 1152 bytes. 55 | Wrote rolebinding/tekton-pipelines-webhook-leaderelection.yaml -- 573 bytes. 56 | Wrote validatingwebhookconfiguration/validation-webhook-pipeline-tekton-dev.yaml -- 663 bytes. 57 | Wrote serviceaccount/tekton-pipelines-webhook.yaml -- 317 bytes. 58 | Wrote configmap/config-leader-election.yaml -- 985 bytes. 59 | Wrote configmap/pipelines-info.yaml -- 1137 bytes. 60 | Wrote clusterrolebinding/tekton-pipelines-controller-cluster-access.yaml -- 1163 bytes. 61 | Wrote role/tekton-pipelines-controller.yaml -- 1488 bytes. 62 | Wrote deployment/tekton-pipelines-controller.yaml -- 5203 bytes. 63 | Wrote configmap/config-observability.yaml -- 2429 bytes. 64 | Wrote customresourcedefinition/tasks-tekton-dev.yaml -- 2824 bytes. 65 | Wrote mutatingwebhookconfiguration/webhook-pipeline-tekton-dev.yaml -- 628 bytes. 66 | Wrote validatingwebhookconfiguration/config-webhook-pipeline-tekton-dev.yaml -- 742 bytes. 67 | Wrote namespace/tekton-pipelines.yaml -- 808 bytes. 68 | Wrote secret/webhook-certs.yaml -- 959 bytes. 69 | Wrote customresourcedefinition/pipelineruns-tekton-dev.yaml -- 3801 bytes. 70 | Wrote serviceaccount/tekton-pipelines-controller.yaml -- 908 bytes. 71 | Wrote configmap/config-artifact-pvc.yaml -- 977 bytes. 72 | Wrote customresourcedefinition/conditions-tekton-dev.yaml -- 1846 bytes. 73 | Wrote clusterrolebinding/tekton-pipelines-controller-tenant-access.yaml -- 816 bytes. 74 | Wrote rolebinding/tekton-pipelines-controller.yaml -- 1133 bytes. 75 | Wrote rolebinding/tekton-pipelines-controller-leaderelection.yaml -- 585 bytes. 76 | Wrote horizontalpodautoscaler/tekton-pipelines-webhook.yaml -- 1518 bytes. 77 | Wrote configmap/config-artifact-bucket.yaml -- 1408 bytes. 78 | Wrote customresourcedefinition/pipelines-tekton-dev.yaml -- 2840 bytes. 79 | Wrote customresourcedefinition/taskruns-tekton-dev.yaml -- 3785 bytes. 80 | Wrote clusterrole/tekton-aggregate-edit.yaml -- 1274 bytes. 81 | Wrote clusterrole/tekton-pipelines-controller-cluster-access.yaml -- 1886 bytes. 82 | Wrote clusterrole/tekton-pipelines-webhook-cluster-access.yaml -- 2480 bytes. 83 | 48 files generated. 84 | ``` 85 | 86 | We can navigate the folders: 87 | 88 | ```bash 89 | $ tree -d 90 | . 91 | ├── clusterrole 92 | ├── clusterrolebinding 93 | ├── configmap 94 | ├── customresourcedefinition 95 | ├── deployment 96 | ├── horizontalpodautoscaler 97 | ├── mutatingwebhookconfiguration 98 | ├── namespace 99 | ├── podsecuritypolicy 100 | ├── role 101 | ├── rolebinding 102 | ├── secret 103 | ├── service 104 | ├── serviceaccount 105 | └── validatingwebhookconfiguration 106 | 107 | 15 directories 108 | ``` 109 | 110 | And poking into a single directory, for example, `configmap`: 111 | 112 | ```bash 113 | $ tree configmap 114 | configmap 115 | ├── config-artifact-bucket.yaml 116 | ├── config-artifact-pvc.yaml 117 | ├── config-defaults.yaml 118 | ├── config-leader-election.yaml 119 | ├── config-logging.yaml 120 | ├── config-observability.yaml 121 | ├── config-registry-cert.yaml 122 | ├── feature-flags.yaml 123 | └── pipelines-info.yaml 124 | 125 | 0 directories, 9 files 126 | ``` 127 | 128 | ## Finding all Kubernetes resources of a given kind in multiple YAML files in a folder 129 | 130 | Imagine you have a folder with several YAML files. Each file may contain one to many Kubernetes resources. You want to find all resources of a given kind, for example, all `Secret` resources. 131 | 132 | As an example, let's clone the ArgoCD repository, which has a nifty `manifests/` folder. Say we want to find all the secrets-type files from the `base` folder in `manifests/base/`, looking at all the YAML files in that folder, we have: 133 | 134 | ```bash 135 | $ find ./manifests/base -type f -name "*.yaml" 136 | ./manifests/base/application-controller-roles/argocd-application-controller-role.yaml 137 | ./manifests/base/application-controller-roles/kustomization.yaml 138 | ./manifests/base/application-controller-roles/argocd-application-controller-rolebinding.yaml 139 | ./manifests/base/application-controller-roles/argocd-application-controller-sa.yaml 140 | ./manifests/base/application-controller/kustomization.yaml 141 | ./manifests/base/application-controller/argocd-application-controller-statefulset.yaml 142 | ./manifests/base/application-controller/argocd-metrics.yaml 143 | ./manifests/base/application-controller/argocd-application-controller-network-policy.yaml 144 | ./manifests/base/application-controller-deployment/argocd-application-controller-deployment.yaml 145 | ./manifests/base/application-controller-deployment/argocd-application-controller-service.yaml 146 | ./manifests/base/application-controller-deployment/kustomization.yaml 147 | ./manifests/base/application-controller-deployment/argocd-application-controller-statefulset.yaml 148 | ./manifests/base/config/argocd-cm.yaml 149 | ./manifests/base/config/kustomization.yaml 150 | ./manifests/base/config/argocd-cmd-params-cm.yaml 151 | ./manifests/base/config/argocd-gpg-keys-cm.yaml 152 | ./manifests/base/config/argocd-tls-certs-cm.yaml 153 | ./manifests/base/config/argocd-ssh-known-hosts-cm.yaml 154 | ./manifests/base/config/argocd-rbac-cm.yaml 155 | ./manifests/base/config/argocd-secret.yaml 156 | ./manifests/base/redis/argocd-redis-service.yaml 157 | ./manifests/base/redis/kustomization.yaml 158 | ./manifests/base/redis/argocd-redis-role.yaml 159 | ./manifests/base/redis/argocd-redis-deployment.yaml 160 | ./manifests/base/redis/argocd-redis-rolebinding.yaml 161 | ./manifests/base/redis/argocd-redis-sa.yaml 162 | ./manifests/base/redis/argocd-redis-network-policy.yaml 163 | ./manifests/base/notification/argocd-notifications-controller-network-policy.yaml 164 | ./manifests/base/notification/kustomization.yaml 165 | ./manifests/base/notification/argocd-notifications-controller-rolebinding.yaml 166 | ./manifests/base/notification/argocd-notifications-controller-sa.yaml 167 | ./manifests/base/notification/argocd-notifications-controller-metrics-service.yaml 168 | ./manifests/base/notification/argocd-notifications-cm.yaml 169 | ./manifests/base/notification/argocd-notifications-controller-deployment.yaml 170 | ./manifests/base/notification/argocd-notifications-secret.yaml 171 | ./manifests/base/notification/argocd-notifications-controller-role.yaml 172 | ./manifests/base/repo-server/argocd-repo-server-network-policy.yaml 173 | ./manifests/base/repo-server/argocd-repo-server-service.yaml 174 | ./manifests/base/repo-server/argocd-repo-server-deployment.yaml 175 | ./manifests/base/repo-server/kustomization.yaml 176 | ./manifests/base/repo-server/argocd-repo-server-sa.yaml 177 | ./manifests/base/kustomization.yaml 178 | ./manifests/base/server/argocd-server-rolebinding.yaml 179 | ./manifests/base/server/kustomization.yaml 180 | ./manifests/base/server/argocd-server-network-policy.yaml 181 | ./manifests/base/server/argocd-server-role.yaml 182 | ./manifests/base/server/argocd-server-deployment.yaml 183 | ./manifests/base/server/argocd-server-metrics.yaml 184 | ./manifests/base/server/argocd-server-sa.yaml 185 | ./manifests/base/server/argocd-server-service.yaml 186 | ./manifests/base/dex/argocd-dex-server-rolebinding.yaml 187 | ./manifests/base/dex/argocd-dex-server-service.yaml 188 | ./manifests/base/dex/kustomization.yaml 189 | ./manifests/base/dex/argocd-dex-server-network-policy.yaml 190 | ./manifests/base/dex/argocd-dex-server-sa.yaml 191 | ./manifests/base/dex/argocd-dex-server-deployment.yaml 192 | ./manifests/base/dex/argocd-dex-server-role.yaml 193 | ./manifests/base/applicationset-controller/argocd-applicationset-controller-role.yaml 194 | ./manifests/base/applicationset-controller/argocd-applicationset-controller-rolebinding.yaml 195 | ./manifests/base/applicationset-controller/argocd-applicationset-controller-deployment.yaml 196 | ./manifests/base/applicationset-controller/kustomization.yaml 197 | ./manifests/base/applicationset-controller/argocd-applicationset-controller-service.yaml 198 | ./manifests/base/applicationset-controller/argocd-applicationset-controller-network-policy.yaml 199 | ./manifests/base/applicationset-controller/argocd-applicationset-controller-sa.yaml 200 | ``` 201 | 202 | It would be time-consuming to try to process them manually. 203 | 204 | Let's ask `kubectl-slice` to get us only the `Secrets`. Since there are some `kustomize` files in there, we'll exclude those, which fit the criteria for `--skip-non-k8s` since they don't have a `metadata.name` field. Let's print those to `stdout` as well: 205 | 206 | ```bash 207 | $ kubectl-slice -d ./manifests/base --recurse --include-kind Secret --skip-non-k8s --stdout 208 | # File: secret-argocd-secret.yaml (162 bytes) 209 | apiVersion: v1 210 | kind: Secret 211 | metadata: 212 | name: argocd-secret 213 | labels: 214 | app.kubernetes.io/name: argocd-secret 215 | app.kubernetes.io/part-of: argocd 216 | type: Opaque 217 | --- 218 | # File: secret-argocd-notifications-secret.yaml (252 bytes) 219 | apiVersion: v1 220 | kind: Secret 221 | metadata: 222 | labels: 223 | app.kubernetes.io/component: notifications-controller 224 | app.kubernetes.io/name: argocd-notifications-controller 225 | app.kubernetes.io/part-of: argocd 226 | name: argocd-notifications-secret 227 | type: Opaque 228 | 2 files parsed to stdout. 229 | ``` 230 | -------------------------------------------------------------------------------- /slice/template/funcs_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "math/rand" 5 | "os" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func Test_mapValueByIndexEmpty(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | index string 17 | m map[string]interface{} 18 | want interface{} 19 | }{ 20 | { 21 | name: "nil map", 22 | index: "foo", 23 | m: nil, 24 | want: "", 25 | }, 26 | { 27 | name: "empty index", 28 | index: "", 29 | m: map[string]interface{}{}, 30 | want: "", 31 | }, 32 | { 33 | name: "fetch existent field", 34 | index: "foo", 35 | m: map[string]interface{}{ 36 | "foo": "bar", 37 | }, 38 | want: "bar", 39 | }, 40 | { 41 | name: "fetch nonexistent field", 42 | index: "baz", 43 | m: map[string]interface{}{ 44 | "foo": "bar", 45 | }, 46 | want: "", 47 | }, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | got := mapValueByIndexOrEmpty(tt.index, tt.m) 53 | require.Equal(t, tt.want, got) 54 | }) 55 | } 56 | } 57 | 58 | func Test_mapValueByIndex(t *testing.T) { 59 | tests := []struct { 60 | name string 61 | index string 62 | m map[string]interface{} 63 | want interface{} 64 | wantErr bool 65 | }{ 66 | { 67 | name: "nil map", 68 | index: "foo", 69 | m: nil, 70 | wantErr: true, 71 | }, 72 | { 73 | name: "empty index", 74 | index: "", 75 | m: map[string]interface{}{}, 76 | wantErr: true, 77 | }, 78 | { 79 | name: "fetch existent field", 80 | index: "foo", 81 | m: map[string]interface{}{ 82 | "foo": "bar", 83 | }, 84 | want: "bar", 85 | }, 86 | { 87 | name: "fetch nonexistent field", 88 | index: "baz", 89 | m: map[string]interface{}{ 90 | "foo": "bar", 91 | }, 92 | wantErr: true, 93 | }, 94 | } 95 | 96 | for _, tt := range tests { 97 | t.Run(tt.name, func(t *testing.T) { 98 | got, err := mapValueByIndex(tt.index, tt.m) 99 | requireErrorIf(t, tt.wantErr, err) 100 | require.Equal(t, tt.want, got) 101 | }) 102 | } 103 | } 104 | 105 | func Test_strJSON(t *testing.T) { 106 | tests := []struct { 107 | name string 108 | val interface{} 109 | want string 110 | wantErr bool 111 | }{ 112 | { 113 | name: "string conversion", 114 | val: "foo", 115 | want: "foo", 116 | }, 117 | { 118 | name: "bool true conversion", 119 | val: true, 120 | want: "true", 121 | }, 122 | { 123 | name: "bool false conversion", 124 | val: false, 125 | want: "false", 126 | }, 127 | { 128 | name: "float64 conversion", 129 | val: 3.141592654, 130 | want: "3.141592654", 131 | }, 132 | { 133 | name: "incorrect data type conversion", 134 | val: []string{}, 135 | wantErr: true, 136 | }, 137 | } 138 | 139 | for _, tt := range tests { 140 | t.Run(tt.name, func(t *testing.T) { 141 | got, err := strJSON(tt.val) 142 | requireErrorIf(t, tt.wantErr, err) 143 | require.Equal(t, tt.want, got) 144 | }) 145 | } 146 | } 147 | 148 | func Test_jsonAlphanumify(t *testing.T) { 149 | tests := []struct { 150 | name string 151 | val interface{} 152 | want string 153 | wantErr bool 154 | }{ 155 | { 156 | name: "remove dots", 157 | val: "foo.bar", 158 | want: "foobar", 159 | }, 160 | { 161 | name: "remove dots and slashes", 162 | val: "foo.bar/baz", 163 | want: "foobarbaz", 164 | }, 165 | { 166 | name: "remove all special characters", 167 | val: "foo.bar/baz!@#$%^&*()_+-=[]{}\\|;:'\",<.>/?", 168 | want: "foobarbaz", 169 | }, 170 | } 171 | 172 | for _, tt := range tests { 173 | t.Run(tt.name, func(t *testing.T) { 174 | got, err := jsonAlphanumify(tt.val) 175 | requireErrorIf(t, tt.wantErr, err) 176 | require.Equal(t, tt.want, got) 177 | }) 178 | } 179 | } 180 | 181 | func Test_jsonAlphanumdash(t *testing.T) { 182 | tests := []struct { 183 | name string 184 | val interface{} 185 | want string 186 | wantErr bool 187 | }{ 188 | { 189 | name: "remove dots", 190 | val: "foo.bar-baz", 191 | want: "foobar-baz", 192 | }, 193 | { 194 | name: "remove dots and slashes", 195 | val: "foo.bar/baz-daz", 196 | want: "foobarbaz-daz", 197 | }, 198 | { 199 | name: "remove all special characters", 200 | val: "foo.bar/baz!@#$%^&*()_+=[]{}\\|;:'\",<.>/?-daz", 201 | want: "foobarbaz-daz", 202 | }, 203 | } 204 | 205 | for _, tt := range tests { 206 | t.Run(tt.name, func(t *testing.T) { 207 | got, err := jsonAlphanumdash(tt.val) 208 | requireErrorIf(t, tt.wantErr, err) 209 | require.Equal(t, tt.want, got) 210 | }) 211 | } 212 | } 213 | 214 | func Test_jsonDotToDash(t *testing.T) { 215 | tests := []struct { 216 | name string 217 | val interface{} 218 | want string 219 | wantErr bool 220 | }{ 221 | { 222 | name: "single dot", 223 | val: "foo.bar", 224 | want: "foo-bar", 225 | }, 226 | { 227 | name: "multi dot", 228 | val: "foo...bar", 229 | want: "foo---bar", 230 | }, 231 | { 232 | name: "no dot", 233 | val: "foobar", 234 | want: "foobar", 235 | }, 236 | } 237 | 238 | for _, tt := range tests { 239 | t.Run(tt.name, func(t *testing.T) { 240 | got, err := jsonDotToDash(tt.val) 241 | requireErrorIf(t, tt.wantErr, err) 242 | require.Equal(t, tt.want, got) 243 | }) 244 | } 245 | } 246 | 247 | func Test_jsonDotToUnder(t *testing.T) { 248 | tests := []struct { 249 | name string 250 | val interface{} 251 | want string 252 | wantErr bool 253 | }{ 254 | { 255 | name: "single dot", 256 | val: "foo.bar", 257 | want: "foo_bar", 258 | }, 259 | { 260 | name: "multi dot", 261 | val: "foo...bar", 262 | want: "foo___bar", 263 | }, 264 | { 265 | name: "no dot", 266 | val: "foobar", 267 | want: "foobar", 268 | }, 269 | } 270 | 271 | for _, tt := range tests { 272 | t.Run(tt.name, func(t *testing.T) { 273 | got, err := jsonDotToUnder(tt.val) 274 | requireErrorIf(t, tt.wantErr, err) 275 | require.Equal(t, tt.want, got) 276 | }) 277 | } 278 | } 279 | 280 | func Test_jsonReplace(t *testing.T) { 281 | type args struct { 282 | search string 283 | replace string 284 | val interface{} 285 | } 286 | 287 | tests := []struct { 288 | name string 289 | args args 290 | want string 291 | wantErr bool 292 | }{ 293 | { 294 | name: "basic replace", 295 | args: args{ 296 | search: "foo", 297 | replace: "bar", 298 | val: "foobar", 299 | }, 300 | want: "barbar", 301 | }, 302 | { 303 | name: "non existent replacement", 304 | args: args{ 305 | search: "foo", 306 | replace: "bar", 307 | val: "barbar", 308 | }, 309 | want: "barbar", 310 | }, 311 | } 312 | 313 | for _, tt := range tests { 314 | t.Run(tt.name, func(t *testing.T) { 315 | got, err := jsonReplace(tt.args.search, tt.args.replace, tt.args.val) 316 | requireErrorIf(t, tt.wantErr, err) 317 | require.Equal(t, tt.want, got) 318 | }) 319 | } 320 | } 321 | 322 | func Test_env(t *testing.T) { 323 | letters := []rune("abcdefghijklmnopqrstuvwxyz") 324 | 325 | randSeq := func(n int) string { 326 | rnd := rand.New(rand.NewSource(time.Now().UnixNano())) 327 | b := make([]rune, n) 328 | for i := range b { 329 | b[i] = letters[rnd.Intn(len(letters))] 330 | } 331 | return string(b) 332 | } 333 | 334 | type args struct { 335 | key string 336 | env map[string]string 337 | } 338 | 339 | tests := []struct { 340 | name string 341 | args args 342 | want string 343 | }{ 344 | { 345 | name: "generic", 346 | args: args{ 347 | key: "foo", 348 | env: map[string]string{ 349 | "foo": "bar", 350 | }, 351 | }, 352 | want: "bar", 353 | }, 354 | { 355 | name: "non-existent", 356 | args: args{ 357 | key: "fooofooo", 358 | env: map[string]string{ 359 | "foo": "bar", 360 | }, 361 | }, 362 | want: "", 363 | }, 364 | { 365 | name: "case insensitive key", 366 | args: args{ 367 | key: "FOO", 368 | env: map[string]string{ 369 | "foo": "bar", 370 | }, 371 | }, 372 | want: "bar", 373 | }, 374 | } 375 | for _, tt := range tests { 376 | t.Run(tt.name, func(t *testing.T) { 377 | prefix := randSeq(10) + "_" 378 | 379 | for k, v := range tt.args.env { 380 | key := strings.ToUpper(prefix + k) 381 | os.Setenv(key, v) 382 | defer os.Unsetenv(key) 383 | } 384 | 385 | require.Equal(t, tt.want, env(prefix+tt.args.key)) 386 | }) 387 | } 388 | } 389 | 390 | func Test_jsonRequired(t *testing.T) { 391 | tests := []struct { 392 | name string 393 | val interface{} 394 | want interface{} 395 | wantErr bool 396 | }{ 397 | { 398 | name: "no error", 399 | val: true, // any non empty value will do 400 | want: true, 401 | }, 402 | { 403 | name: "empty item", 404 | val: nil, 405 | wantErr: true, 406 | }, 407 | { 408 | name: "unsupported item", 409 | val: struct{ name string }{name: "foo"}, 410 | wantErr: true, 411 | }, 412 | } 413 | 414 | for _, tt := range tests { 415 | t.Run(tt.name, func(t *testing.T) { 416 | got, err := jsonRequired(tt.val) 417 | requireErrorIf(t, tt.wantErr, err) 418 | require.Equal(t, tt.want, got) 419 | }) 420 | } 421 | } 422 | 423 | func Test_jsonLowerAndUpper(t *testing.T) { 424 | type args struct { 425 | val interface{} 426 | prefix string 427 | suffix string 428 | } 429 | tests := []struct { 430 | name string 431 | args args 432 | lower string 433 | upper string 434 | title string 435 | trimmed string 436 | noprefix string 437 | nosuffix string 438 | wantErr bool 439 | }{ 440 | { 441 | name: "generic first test ", 442 | args: args{ 443 | val: "foo bar baz ", 444 | prefix: "foo ", 445 | suffix: " baz ", 446 | }, 447 | lower: "foo bar baz ", 448 | upper: "FOO BAR BAZ ", 449 | title: "Foo Bar Baz ", 450 | trimmed: "foo bar baz", 451 | noprefix: "bar baz ", 452 | nosuffix: "foo bar", 453 | }, 454 | { 455 | name: "invalid value type", 456 | args: args{ 457 | val: struct{}{}, 458 | }, 459 | wantErr: true, 460 | }, 461 | } 462 | 463 | for _, tt := range tests { 464 | t.Run(tt.name, func(t *testing.T) { 465 | lowered, err := jsonLower(tt.args.val) 466 | requireErrorIf(t, tt.wantErr, err) 467 | 468 | uppered, err := jsonUpper(tt.args.val) 469 | requireErrorIf(t, tt.wantErr, err) 470 | 471 | titled, err := jsonTitle(tt.args.val) 472 | requireErrorIf(t, tt.wantErr, err) 473 | 474 | trimspaced, err := jsonTrimSpace(tt.args.val) 475 | requireErrorIf(t, tt.wantErr, err) 476 | 477 | prefixed, err := jsonTrimPrefix(tt.args.prefix, tt.args.val) 478 | requireErrorIf(t, tt.wantErr, err) 479 | 480 | suffixed, err := jsonTrimSuffix(tt.args.suffix, tt.args.val) 481 | requireErrorIf(t, tt.wantErr, err) 482 | 483 | require.Equal(t, tt.lower, lowered) 484 | require.Equal(t, tt.upper, uppered) 485 | require.Equal(t, tt.title, titled) 486 | require.Equal(t, tt.trimmed, trimspaced) 487 | require.Equal(t, tt.noprefix, prefixed) 488 | require.Equal(t, tt.nosuffix, suffixed) 489 | }) 490 | } 491 | } 492 | 493 | func Test_fnDefault(t *testing.T) { 494 | type args struct { 495 | defval interface{} 496 | val interface{} 497 | } 498 | 499 | tests := []struct { 500 | name string 501 | args args 502 | want string 503 | wantErr bool 504 | }{ 505 | { 506 | name: "non value use default", 507 | args: args{ 508 | defval: "foo", 509 | val: nil, 510 | }, 511 | want: "foo", 512 | }, 513 | { 514 | name: "existent value skip default", 515 | args: args{ 516 | defval: "foo", 517 | val: "bar", 518 | }, 519 | want: "bar", 520 | }, 521 | { 522 | name: "inconvertible value type use default", 523 | args: args{ 524 | val: []struct{}{}, 525 | }, 526 | wantErr: true, 527 | }, 528 | } 529 | 530 | for _, tt := range tests { 531 | t.Run(tt.name, func(t *testing.T) { 532 | got, err := fnDefault(tt.args.defval, tt.args.val) 533 | requireErrorIf(t, tt.wantErr, err) 534 | require.Equal(t, tt.want, got) 535 | }) 536 | } 537 | } 538 | 539 | func Test_altStrJSON(t *testing.T) { 540 | tests := []struct { 541 | name string 542 | val interface{} 543 | want string 544 | wantErr bool 545 | }{ 546 | { 547 | name: "default", 548 | val: "foo", 549 | want: "foo\n", 550 | }, 551 | { 552 | name: "convert to object", 553 | val: map[string]interface{}{ 554 | "foo": "bar", 555 | }, 556 | want: "foo: bar\n", 557 | }, 558 | { 559 | name: "convert to array", 560 | val: []interface{}{ 561 | "foo", 562 | "bar", 563 | }, 564 | want: "- foo\n- bar\n", 565 | }, 566 | } 567 | 568 | for _, tt := range tests { 569 | t.Run(tt.name, func(t *testing.T) { 570 | got, err := altStrJSON(tt.val) 571 | requireErrorIf(t, tt.wantErr, err) 572 | require.Equal(t, tt.want, got) 573 | }) 574 | } 575 | } 576 | 577 | func Test_sha256sum(t *testing.T) { 578 | tests := []struct { 579 | name string 580 | input interface{} 581 | want string 582 | wantErr bool 583 | }{ 584 | { 585 | name: "generic string", 586 | input: "foo", 587 | want: "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", 588 | }, 589 | { 590 | name: "generic array", 591 | input: []interface{}{ 592 | "foo", 593 | "bar", 594 | }, 595 | want: "d50869a9dcda5fe0b6413eb366dec11d0eb7226c5569f7de8dad1fcd917e5480", 596 | }, 597 | { 598 | name: "generic object", 599 | input: map[string]interface{}{ 600 | "foo": "bar", 601 | }, 602 | want: "1dabc4e3cbbd6a0818bd460f3a6c9855bfe95d506c74726bc0f2edb0aecb1f4e", 603 | }, 604 | } 605 | 606 | for _, tt := range tests { 607 | t.Run(tt.name, func(t *testing.T) { 608 | got, err := sha256sum(tt.input) 609 | requireErrorIf(t, tt.wantErr, err) 610 | require.Equal(t, tt.want, got) 611 | }) 612 | } 613 | } 614 | 615 | func Test_sha1sum(t *testing.T) { 616 | tests := []struct { 617 | name string 618 | input interface{} 619 | want string 620 | wantErr bool 621 | }{ 622 | { 623 | name: "generic string", 624 | input: "foo", 625 | want: "f1d2d2f924e986ac86fdf7b36c94bcdf32beec15", 626 | }, 627 | { 628 | name: "generic array", 629 | input: []interface{}{ 630 | "foo", 631 | "bar", 632 | }, 633 | want: "c11e6a294774caece9f882726f0f85c72691bb19", 634 | }, 635 | { 636 | name: "generic object", 637 | input: map[string]interface{}{ 638 | "foo": "bar", 639 | }, 640 | want: "7e109797e472ae8cbd20d7a4d7e231a96324377c", 641 | }, 642 | } 643 | 644 | for _, tt := range tests { 645 | t.Run(tt.name, func(t *testing.T) { 646 | got, err := sha1sum(tt.input) 647 | requireErrorIf(t, tt.wantErr, err) 648 | require.Equal(t, tt.want, got) 649 | }) 650 | } 651 | } 652 | 653 | func requireErrorIf(t *testing.T, wantErr bool, err error) { 654 | if wantErr { 655 | require.Error(t, err) 656 | } else { 657 | require.NoError(t, err) 658 | } 659 | } 660 | --------------------------------------------------------------------------------