diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..0ef7ac8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,22 @@ +--- +name: Bug Report +about: Report a bug encountered while using this plugin +labels: kind/bug + +--- + + + +**What happened**: + +**What you expected to happen**: + +**How to reproduce it (as minimally and precisely as possible)**: + +**Anything else we need to know?**: + +**Environment**: +- Plugin version (use `kubectl confirm version` or `kubectl-confirm version`): +- OS (e.g: `cat /etc/os-release`): + diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md new file mode 100644 index 0000000..ab45caa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.md @@ -0,0 +1,11 @@ +--- +name: Enhancement Request +about: Suggest an enhancement to this plugin +labels: kind/feature + +--- + + +**What would you like to be added**: + +**Why is this needed**: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c9c5337 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +#### What type of PR is this? + + + +#### What this PR does / why we need it: + +#### Which issue(s) this PR fixes: + +#### Unit Tests: +Please check the following box to confirm you have added unit tests: +- [ ] I added unit tests that cover any new or changed code diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..6476d03 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,51 @@ +name: Build + +on: + workflow_call: + pull_request: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Go + uses: actions/setup-go@v2 + with: + go-version: ^1.18 + + - name: Install staticcheck + run: go install honnef.co/go/tools/cmd/staticcheck@latest + + - name: Install golint + run: go install golang.org/x/lint/golint@latest + + - name: StaticCheck + run: make staticcheck + + - name: Lint + run: make lint + + - name: Vet + run: make vet + + - name: Verify + run: make verify + + - name: Test + run: make test + + - name: Build + run: make release + + - name: Upload Artifacts + uses: actions/upload-artifact@v2 + if: github.event_name != 'pull_request' + with: + name: build-output + path: _output/ + if-no-files-found: error diff --git a/.github/workflows/prerelease.yaml b/.github/workflows/prerelease.yaml new file mode 100644 index 0000000..2b036f4 --- /dev/null +++ b/.github/workflows/prerelease.yaml @@ -0,0 +1,27 @@ +name: Prerelease + +on: + push: + branches: + - "master" + +jobs: + build: + name: Build + uses: ./.github/workflows/build.yaml + + prerelease: + name: Prerelease + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v2 + with: + name: build-output + + - uses: marvinpinto/action-automatic-releases@latest + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + automatic_release_tag: prerelease + prerelease: true + files: "*" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..11cd570 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,26 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build: + name: Build + uses: ./.github/workflows/build.yaml + + release: + name: Release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v2 + with: + name: build-output + + - uses: marvinpinto/action-automatic-releases@latest + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + prerelease: false + files: "*" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..061c12b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +_output/ \ No newline at end of file diff --git a/.krew.yaml b/.krew.yaml new file mode 100644 index 0000000..349225e --- /dev/null +++ b/.krew.yaml @@ -0,0 +1,43 @@ +apiVersion: krew.googlecontainertools.github.com/v1alpha2 +kind: Plugin +metadata: + name: confirm +spec: + version: {{ .TagName }} + homepage: https://github.com/brianpursley/kubectl-confirm + shortDescription: Show information and prompt for confirmation before running a command + description: | + Shows configuration, dry-run (if available), and diff (if available), + and then prompts you to confirm by typing 'yes' before proceeding to + execute the kubectl command. + platforms: + - bin: kubectl-confirm + {{addURIAndSha "https://github.com/brianpursley/kubectl-confirm/releases/download/{{ .TagName }}/kubectl-confirm-{{ .TagName }}-darwin-amd64.tar.gz" .TagName }} + selector: + matchLabels: + os: darwin + arch: amd64 + - bin: kubectl-confirm + {{addURIAndSha "https://github.com/brianpursley/kubectl-confirm/releases/download/{{ .TagName }}/kubectl-confirm-{{ .TagName }}-darwin-arm64.tar.gz" .TagName }} + selector: + matchLabels: + os: darwin + arch: arm64 + - bin: kubectl-confirm + {{addURIAndSha "https://github.com/brianpursley/kubectl-confirm/releases/download/{{ .TagName }}/kubectl-confirm-{{ .TagName }}-linux-amd64.tar.gz" .TagName }} + selector: + matchLabels: + os: linux + arch: amd64 + - bin: kubectl-confirm + {{addURIAndSha "https://github.com/brianpursley/kubectl-confirm/releases/download/{{ .TagName }}/kubectl-confirm-{{ .TagName }}-linux-arm64.tar.gz" .TagName }} + selector: + matchLabels: + os: linux + arch: arm64 + - bin: kubectl-confirm.exe + {{addURIAndSha "https://github.com/brianpursley/kubectl-confirm/releases/download/{{ .TagName }}/kubectl-confirm-{{ .TagName }}-windows-amd64.tar.gz" .TagName }} + selector: + matchLabels: + os: windows + arch: amd64 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c84bb1e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +Contributions are welcome in the form of issue creation and/or pull requests. + +If you open a pull request, make sure your code follows standard golang conventions and styling rules and that unit tests have been added to cover any new or changed functionality. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6bb6f85 --- /dev/null +++ b/Makefile @@ -0,0 +1,118 @@ +# Copyright 2022 Brian Pursley. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +VERSION=`git tag --sort=committerdate | grep -E 'v[0-9].*' | tail -1 | cut -b 2-` +GIT_COMMIT=`git rev-parse HEAD` +LDFLAGS=-ldflags="-X 'github.com/brianpursley/kubectl-confirm/internal/version.Version=${VERSION}' -X 'github.com/brianpursley/kubectl-confirm/internal/version.GitCommit=${GIT_COMMIT}'" + +.PHONY: build +build: checks + @echo "Building" + @mkdir -p _output && cd _output && go build $(LDFLAGS) ../cmd/kubectl-confirm.go + +checks: staticcheck lint vet verify test + +.PHONY: install +install: build + @echo "Installing" + @mv _output/kubectl-confirm ~/go/bin/kubectl-confirm + +.PHONY: clean +clean: + @echo "Deleting _output" + @rm -rf _output + +.PHONY: test +test: + @echo "Running go test" + @go test $(LDFLAGS) ./... + +.PHONY: staticcheck +staticcheck: +ifeq (, $(shell which staticcheck)) + $(error staticcheck not found (go install honnef.co/go/tools/cmd/staticcheck@latest)) +endif + @echo "Running staticcheck" + @staticcheck ./... + +.PHONY: lint +lint: +ifeq (, $(shell which golint)) + $(error golint not found (go install golang.org/x/lint/golint@latest)) +endif + @echo "Running golint" + @golint -set_exit_status ./... + +.PHONY: vet +vet: + @echo "Running go vet" + @go vet ./... + +.PHONY: verify +verify: + @echo "Running go verify" + @go mod verify + +.PHONY: release +release: clean darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 windows-amd64 sha256 + @cat _output/checksum.txt + +.PHONY: darwin-amd64 +darwin-amd64: + @echo "Building darwin amd64" + @mkdir -p _output && cd _output && \ + GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) ../cmd/kubectl-confirm.go && \ + cp ../LICENSE . && \ + tar -czf kubectl-confirm-darwin-amd64.tar.gz kubectl-confirm LICENSE && \ + rm kubectl-confirm LICENSE + +.PHONY: darwin-arm64 +darwin-arm64: + @echo "Building darwin arm64" + @mkdir -p _output && cd _output && \ + GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) ../cmd/kubectl-confirm.go && \ + cp ../LICENSE . && \ + tar -czf kubectl-confirm-darwin-arm64.tar.gz kubectl-confirm LICENSE && \ + rm kubectl-confirm LICENSE + +.PHONY: linux-amd64 +linux-amd64: + @echo "Building linux amd64" + @mkdir -p _output && cd _output && \ + GOOS=linux GOARCH=amd64 go build $(LDFLAGS) ../cmd/kubectl-confirm.go && \ + cp ../LICENSE . && \ + tar -czf kubectl-confirm-linux-amd64.tar.gz kubectl-confirm LICENSE && \ + rm kubectl-confirm LICENSE + +.PHONY: linux-arm64 +linux-arm64: + @echo "Building linux arm64" + @mkdir -p _output && cd _output && \ + GOOS=linux GOARCH=arm64 go build $(LDFLAGS) ../cmd/kubectl-confirm.go && \ + cp ../LICENSE . && \ + tar -czf kubectl-confirm-linux-arm64.tar.gz kubectl-confirm LICENSE && \ + rm kubectl-confirm LICENSE + +.PHONY: windows-amd64 +windows-amd64: + @echo "Building windows amd64" + @mkdir -p _output && cd _output && \ + GOOS=windows GOARCH=amd64 go build $(LDFLAGS) ../cmd/kubectl-confirm.go && \ + cp ../LICENSE . && \ + tar -czf kubectl-confirm-windows-amd64.tar.gz kubectl-confirm.exe LICENSE && \ + rm kubectl-confirm.exe LICENSE + +sha256: + @echo "Generating checksum.txt" + @cd _output && sha256sum *.gz > checksum.txt \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6581bcf --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Kubectl Confirm Plugin + +Kubectl Confirm is a plugin for Kubectl that displays information and asked for confirmation before executing a command. + +The following information is displayed: +* Configuration: Context name, Cluster, User, and Namespace +* Dry Run Output (if the executed command supports the `--dry-run` flag) +* Diff Output (`apply` command only) + +## Example Output +``` +$ kubectl confirm apply -f ~/changed.yaml +========== Config =========== +Context: kind-kind +Cluster: kind-kind +User: kind-kind +Namespace: default + +========== Dry Run ========== +deployment.apps/foo configured (server dry run) + +========== Diff ============= +diff -u -N /tmp/LIVE-2275701238/apps.v1.Deployment.default.foo /tmp/MERGED-1055657464/apps.v1.Deployment.default.foo +--- /tmp/LIVE-2275701238/apps.v1.Deployment.default.foo 2022-07-28 10:27:25.690604172 -0400 ++++ /tmp/MERGED-1055657464/apps.v1.Deployment.default.foo 2022-07-28 10:27:25.694604038 -0400 +@@ -6,7 +6,7 @@ + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"apps/v1","kind":"Deployment","metadata":{"annotations":{},"name":"foo","namespace":"default"},"spec":{"replicas":1,"revisionHistoryLimit":10,"selector":{"matchLabels":{"app":"foo"}},"template":{"metadata":{"labels":{"app":"foo"}},"spec":{"containers":[{"command":["sh","-c","echo Container bar is running! \u0026\u0026 sleep 9999999"],"image":"busybox:1.30","name":"bar"}]}}}} + creationTimestamp: "2022-07-28T11:43:58Z" +- generation: 1 ++ generation: 2 + managedFields: + - apiVersion: apps/v1 + fieldsType: FieldsV1 +@@ -90,7 +90,7 @@ + spec: + progressDeadlineSeconds: 600 + replicas: 1 +- revisionHistoryLimit: 10 ++ revisionHistoryLimit: 11 + selector: + matchLabels: + app: foo + +========== Confirm ========== +The following command will be executed: +kubectl apply -f /home/bpursley/changed.yaml + +Enter 'yes' to continue: +``` + +If you enter `yes` (exactly) then it will proceed: +``` +Enter 'yes' to continue: yes + +deployment.apps/foo configured +``` + +If you enter anything other than `yes` (exactly), then it will stop: +``` +Enter 'yes' to continue: no + +Command aborted. +``` + +## Known Limitations + +* Command line completion does not work + diff --git a/cmd/kubectl-confirm.go b/cmd/kubectl-confirm.go new file mode 100644 index 0000000..3c644df --- /dev/null +++ b/cmd/kubectl-confirm.go @@ -0,0 +1,31 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + + "github.com/brianpursley/kubectl-confirm/pkg/cmd" +) + +func main() { + cmd := cmd.NewConfirmCommand() + err := cmd.Execute() + if err != nil { + _ = fmt.Errorf("confirm failed: %s", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..27e5a33 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/brianpursley/kubectl-confirm + +go 1.18 + +require github.com/spf13/cobra v1.5.0 + +require ( + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0d85248 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= +github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/util/test_util.go b/internal/util/test_util.go new file mode 100644 index 0000000..ea536d1 --- /dev/null +++ b/internal/util/test_util.go @@ -0,0 +1,104 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "bytes" + "fmt" + "io" + + "github.com/spf13/cobra" +) + +// NewTestCommand creates a new cobra command for testing, with the specified stdin, stdout, and stderr +func NewTestCommand() (*cobra.Command, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { + cmd := &cobra.Command{} + stdin := &bytes.Buffer{} + cmd.SetIn(stdin) + stdout := &bytes.Buffer{} + cmd.SetOut(stdout) + stderr := &bytes.Buffer{} + cmd.SetErr(stderr) + return cmd, stdin, stdout, stderr +} + +// FakeExecRunner is used to mock external program execution +type FakeExecRunner struct { + fakeExecRuns []fakeExecRun + RunNames []string + RunArgs [][]string +} + +type fakeExecRun struct { + Stdout string + Stderr string + Error error +} + +// NewFakeExecRunner creates a new instance of FakeExecRunner +func NewFakeExecRunner() *FakeExecRunner { + f := &FakeExecRunner{} + ExecRun = f.execRun + return f +} + +// SetupRun enqueues a new mocked run of an executable, including stdout, stderr, and an error +func (f *FakeExecRunner) SetupRun(stdout, stderr string, err error) { + f.fakeExecRuns = append(f.fakeExecRuns, fakeExecRun{ + Stdout: stdout, + Stderr: stderr, + Error: err, + }) +} + +// LastRunName returns the name of the last execRun +func (f *FakeExecRunner) LastRunName() string { + return f.RunNames[len(f.RunNames)-1] +} + +// LastRunArgs returns the args of the last execRun +func (f *FakeExecRunner) LastRunArgs() []string { + return f.RunArgs[len(f.RunArgs)-1] +} + +// RunCount returns the number of times execRun was called +func (f *FakeExecRunner) RunCount() int { + return len(f.RunNames) +} + +func (f *FakeExecRunner) execRun(name string, args []string, _ io.Reader, stdout, stderr io.Writer) error { + f.RunNames = append(f.RunNames, name) + f.RunArgs = append(f.RunArgs, args) + + if len(f.fakeExecRuns) == 0 { + return fmt.Errorf("there are no more fake exec runs") + } + thisRun := f.fakeExecRuns[0] + f.fakeExecRuns = f.fakeExecRuns[1:] + + if thisRun.Stdout != "" { + if _, err := stdout.Write(bytes.NewBufferString(thisRun.Stdout).Bytes()); err != nil { + return err + } + } + if thisRun.Stderr != "" { + if _, err := stderr.Write(bytes.NewBufferString(thisRun.Stderr).Bytes()); err != nil { + return err + } + } + return thisRun.Error +} diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..ce28a33 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,70 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "io" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" +) + +// PrintSectionTitle prints a title with formatting +func PrintSectionTitle(cmd *cobra.Command, title string) { + cmd.Printf("========== %s %s\n", title, strings.Repeat("=", 17-len(title))) +} + +// GetKubectlPath returns the path that should be used to execute kubectl. You can set the +// KUBECTL_PATH environment variable to override the path. +func GetKubectlPath() string { + if kubectlPath, found := os.LookupEnv("KUBECTL_PATH"); found { + return kubectlPath + } + return "kubectl" +} + +// HasOutputFlag returns try if osArgs contains -o or --output +func HasOutputFlag() bool { + for _, a := range os.Args { + if strings.HasPrefix(a, "-o") || strings.HasPrefix(a, "--output") { + return true + } + } + return false +} + +// ExecRun runs the specified executable with args and the specified stdin, stdout, and stderr +var ExecRun = func(name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + cmd := exec.Command(name, args...) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + +// IsNonRegularFile returns true if the file is not a regular file +var IsNonRegularFile = func(name string) bool { + fi, err := os.Stat(name) + return err == nil && !fi.IsDir() && !fi.Mode().IsRegular() +} + +// Exit is a wrapper around os.Exit to help with mocking +var Exit = func(code int) { + os.Exit(code) +} diff --git a/internal/util/util_test.go b/internal/util/util_test.go new file mode 100644 index 0000000..0ba1e88 --- /dev/null +++ b/internal/util/util_test.go @@ -0,0 +1,34 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "os" + "testing" +) + +func TestGetKubectlPath(t *testing.T) { + if path := GetKubectlPath(); path != "kubectl" { + t.Fatalf("expected getKubectlPath to return \"kubectl\", but it was %q", path) + } + + _ = os.Setenv("KUBECTL_PATH", "foo") + defer os.Unsetenv("KUBECTL_PATH") + if path := GetKubectlPath(); path != "foo" { + t.Fatalf("expected getKubectlPath to return \"foo\", but it was %q", path) + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..6b2e62f --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,22 @@ +package version + +import "fmt" + +// Version is the version number of Kubectl-Confirm +var Version = "" + +// GitCommit is the git commit hash of Kubectl-Confirm +var GitCommit = "" + +// String returns a formatted version string +func String() string { + version := Version + if version == "" { + version = "UNKNOWN" + } + gitCommit := GitCommit + if gitCommit == "" { + gitCommit = "UNKNOWN" + } + return fmt.Sprintf("%s (git commit %s)", version, gitCommit) +} diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go new file mode 100644 index 0000000..8953653 --- /dev/null +++ b/pkg/cmd/config.go @@ -0,0 +1,84 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "encoding/json" + + "github.com/spf13/cobra" + + "github.com/brianpursley/kubectl-confirm/internal/util" +) + +func (o *confirmOptions) printConfig(cmd *cobra.Command) error { + util.PrintSectionTitle(cmd, "Config") + defer cmd.Println() + + // Get the effective config + stdout := bytes.Buffer{} + err := util.ExecRun(util.GetKubectlPath(), []string{"config", "view", "-o=json"}, cmd.InOrStdin(), &stdout, cmd.ErrOrStderr()) + if err != nil { + return err + } + var config map[string]interface{} + err = json.Unmarshal(stdout.Bytes(), &config) + if err != nil { + return err + } + + context := o.context + if len(context) == 0 { + context = config["current-context"].(string) + } + cmd.Printf("%-11s %s\n", "Context:", context) + + contextConfig := findContextInConfig(config, context) + + cluster := o.cluster + if len(cluster) == 0 && contextConfig != nil && contextConfig["cluster"] != nil { + cluster = contextConfig["cluster"].(string) + } + cmd.Printf("%-11s %s\n", "Cluster:", cluster) + + user := o.user + if len(user) == 0 && contextConfig != nil && contextConfig["user"] != nil { + user = contextConfig["user"].(string) + } + cmd.Printf("%-11s %s\n", "User:", user) + + namespace := o.namespace + if len(namespace) == 0 && contextConfig != nil && contextConfig["namespace"] != nil { + namespace = contextConfig["namespace"].(string) + } + if len(namespace) == 0 { + namespace = "default" + } + cmd.Printf("%-11s %s\n", "Namespace:", namespace) + + return nil +} + +func findContextInConfig(config map[string]interface{}, context string) map[string]interface{} { + for _, c := range config["contexts"].([]interface{}) { + cc := c.(map[string]interface{}) + if cc["name"] == context { + return cc["context"].(map[string]interface{}) + } + } + return nil +} diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go new file mode 100644 index 0000000..d88b874 --- /dev/null +++ b/pkg/cmd/config_test.go @@ -0,0 +1,143 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "reflect" + "testing" + + "github.com/brianpursley/kubectl-confirm/internal/util" +) + +func TestPrintConfig(t *testing.T) { + fakeStdout := ` +{ + "current-context": "foo", + "contexts": [ + { + "name": "foo", + "context": { + "cluster": "foo-cluster", + "user": "foo-user", + "namespace": "foo-namespace" + } + }, + { + "name": "bar", + "context": { + "cluster": "bar-cluster", + "user": "bar-user", + "namespace": "bar-namespace" + } + }, + { + "name": "baz", + "context": { + "cluster": "baz-cluster", + "user": "baz-user" + } + } + ] +} +` + testCases := []struct { + name string + options confirmOptions + expectedStdout string + }{ + { + name: "no options set should use current context from config", + options: confirmOptions{}, + expectedStdout: `========== Config =========== +Context: foo +Cluster: foo-cluster +User: foo-user +Namespace: foo-namespace + +`, + }, + { + name: "context set in options", + options: confirmOptions{context: "bar"}, + expectedStdout: `========== Config =========== +Context: bar +Cluster: bar-cluster +User: bar-user +Namespace: bar-namespace + +`, + }, + { + name: "default namespace", + options: confirmOptions{context: "baz"}, + expectedStdout: `========== Config =========== +Context: baz +Cluster: baz-cluster +User: baz-user +Namespace: default + +`, + }, + { + name: "override cluster, namespace, and user using options", + options: confirmOptions{ + context: "bar", + cluster: "override-cluster", + namespace: "override-namespace", + user: "override-user", + }, + expectedStdout: `========== Config =========== +Context: bar +Cluster: override-cluster +User: override-user +Namespace: override-namespace + +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fakeExecRunner := util.NewFakeExecRunner() + fakeExecRunner.SetupRun(fakeStdout, "", nil) + + cmd, _, stdout, stderr := util.NewTestCommand() + + err := tc.options.printConfig(cmd) + if err != nil { + t.Fatalf("printConfig failed: %v", err) + } + + if fakeExecRunner.LastRunName() != "kubectl" { + t.Fatalf("expected kubectl to be run, but it was not") + } + + expectedKubectlArgs := []string{"config", "view", "-o=json"} + if !reflect.DeepEqual(fakeExecRunner.LastRunArgs(), expectedKubectlArgs) { + t.Fatalf("wrong kubectl args.\nexpected: %v\ngot: %v\n", expectedKubectlArgs, fakeExecRunner.LastRunArgs()) + } + + if stdout.String() != tc.expectedStdout { + t.Fatalf("wrong stdout\nexpected:\n%s\ngot:\n%s\n", tc.expectedStdout, stdout.String()) + } + + if stderr.Len() > 0 { + t.Fatalf("unexpected stderr:\n%s", stderr.String()) + } + }) + } +} diff --git a/pkg/cmd/confirm.go b/pkg/cmd/confirm.go new file mode 100644 index 0000000..03073cd --- /dev/null +++ b/pkg/cmd/confirm.go @@ -0,0 +1,211 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/brianpursley/kubectl-confirm/internal/util" + "github.com/brianpursley/kubectl-confirm/internal/version" +) + +// Commands that have --dry-run flag +var dryRunCommands = map[string]bool{ + "annotate": true, + "apply": true, + "autoscale": true, + "cordon": true, + "create": true, + "delete": true, + "drain": true, + "expose": true, + "label": true, + "patch": true, + "replace": true, + "run": true, + "scale": true, + "set": true, + "taint": true, + "uncordon": true, +} + +// Commands that have both --dry-run and --output flags +var diffCommands = map[string]bool{ + "annotate": true, + "apply": true, + "autoscale": true, + "create": true, + "expose": true, + "label": true, + "patch": true, + "replace": true, + "run": true, + "scale": true, + "set": true, + "taint": true, +} + +type confirmOptions struct { + cluster string + context string + namespace string + user string + + filenames []string + kustomize string + + hasAnyNonRegularFiles bool +} + +const shortHelpText string = ` +The Kubectl Confirm plugin prints information and prompts you to confirm whether to continue before running a Kubectl command. +` + +const longHelpText = shortHelpText + ` +The plugin will show the following information: + + * Configuration (context, cluster, user, and namespace) + * Dry run output (if available for the kubectl command) + * Diff output (if available for the kubectl command) + +After the information is displayed, you will be asked to confirm whether to proceed. + +Upon confirmation, the Kubectl command will be executed. + +All arguments and flags will be passed through to Kubectl. +` + +// NewConfirmCommand returns a cobra command for the confirm command +func NewConfirmCommand() *cobra.Command { + options := confirmOptions{} + + var executableName = filepath.Base(os.Args[0]) + if strings.HasPrefix(executableName, "kubectl-") { + executableName = "kubectl" + } + usage := fmt.Sprintf("%s confirm [command] [flags] [options]", executableName) + + cmd := cobra.Command{ + SilenceUsage: true, + Short: shortHelpText, + Long: longHelpText, + Use: usage, + FParseErrWhitelist: cobra.FParseErrWhitelist{ + UnknownFlags: true, + }, + RunE: func(cmd *cobra.Command, args []string) error { + return options.run(cmd, args) + }, + } + + cmd.Flags().StringVar(&options.cluster, "cluster", "", "") + _ = cmd.Flags().MarkHidden("cluster") + cmd.Flags().StringVar(&options.context, "context", "", "") + _ = cmd.Flags().MarkHidden("context") + cmd.Flags().StringVarP(&options.namespace, "namespace", "n", "", "") + _ = cmd.Flags().MarkHidden("namespace") + cmd.Flags().StringVar(&options.user, "user", "", "") + _ = cmd.Flags().MarkHidden("user") + + cmd.Flags().StringArrayVarP(&options.filenames, "filename", "f", []string{}, "") + _ = cmd.Flags().MarkHidden("filename") + cmd.Flags().StringVarP(&options.kustomize, "kustomize", "k", "", "") + _ = cmd.Flags().MarkHidden("kustomize") + + return &cmd +} + +func (o *confirmOptions) run(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("expected at least one argument") + } + + commandName := args[0] + + // Help + if commandName == "help" { + return cmd.Help() + } + + // Version + if commandName == "version" { + if !util.HasOutputFlag() { + cmd.Printf("Kubectl Confirm Plugin Version: %s\n\n", version.String()) + } + return util.ExecRun(util.GetKubectlPath(), os.Args[1:], cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr()) + } + + // Check for non-regular files (ie. process substitution). In this case, dry run and diff cannot be performed, or + // else they will consume the file stream and the real execution will fail. This check sets a flag on the options + // indicating that one or more non-regular files were detected. + o.checkForNonRegularFiles() + + // Config + if err := o.printConfig(cmd); err != nil { + return err + } + + // Dry Run + if dryRunCommands[commandName] { + err := o.dryRun(cmd) + if err != nil { + return err + } + } + + // Diff + if diffCommands[commandName] { + if err := o.diff(cmd); err != nil { + return err + } + } + + // Prompt + util.PrintSectionTitle(cmd, "Confirm") + cmd.Printf("The following command will be executed:\n%s %s\n\n", util.GetKubectlPath(), strings.Join(os.Args[1:], " ")) + cmd.Printf("Enter 'yes' to continue: ") + var response string + _, _ = fmt.Fscanln(cmd.InOrStdin(), &response) + cmd.Println() + if response != "yes" { + cmd.PrintErr("Command aborted.\n") + util.Exit(1) + return nil + } + + // Execute the real command + return util.ExecRun(util.GetKubectlPath(), os.Args[1:], cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr()) +} + +func (o *confirmOptions) checkForNonRegularFiles() { + o.hasAnyNonRegularFiles = false + if len(o.kustomize) > 0 && util.IsNonRegularFile(o.kustomize) { + o.hasAnyNonRegularFiles = true + return + } + for _, f := range o.filenames { + if util.IsNonRegularFile(f) { + o.hasAnyNonRegularFiles = true + return + } + } +} diff --git a/pkg/cmd/confirm_test.go b/pkg/cmd/confirm_test.go new file mode 100644 index 0000000..6c237e7 --- /dev/null +++ b/pkg/cmd/confirm_test.go @@ -0,0 +1,257 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "os" + "reflect" + "strings" + "testing" + + "github.com/brianpursley/kubectl-confirm/internal/util" + "github.com/brianpursley/kubectl-confirm/internal/version" +) + +func TestCheckForNonRegularFiles(t *testing.T) { + util.IsNonRegularFile = func(name string) bool { + return name == "63" + } + testCases := []struct { + name string + options confirmOptions + expectedHasAnyNonRegularFiles bool + }{ + { + name: "no non regular files", + options: confirmOptions{ + kustomize: "example/", + filenames: []string{"foo.yaml", "bar.yaml"}, + }, + }, + { + name: "kustomize is non regular file", + options: confirmOptions{ + kustomize: "63", + filenames: []string{"foo.yaml", "bar.yaml"}, + }, + expectedHasAnyNonRegularFiles: true, + }, + { + name: "filename is non regular file", + options: confirmOptions{ + kustomize: "example/", + filenames: []string{"foo.yaml", "63"}, + }, + expectedHasAnyNonRegularFiles: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.options.checkForNonRegularFiles() + + if tc.options.hasAnyNonRegularFiles != tc.expectedHasAnyNonRegularFiles { + t.Fatalf("wrong hasAnyNonRegularFiles. expected: %v, got: %v", tc.expectedHasAnyNonRegularFiles, tc.options.hasAnyNonRegularFiles) + } + }) + } +} + +func TestRun(t *testing.T) { + testCases := []struct { + name string + options confirmOptions + kubectlPath string + fakeOsArgs []string + fakeArgs []string + response string + expectedStdout string + unexpectedStdout string + expectedStderr string + expectKubectl bool + expectedKubectlArgs []string + expectedExitCode int + }{ + { + name: "help should show help and exit", + options: confirmOptions{}, + fakeArgs: []string{"help"}, + fakeOsArgs: []string{"confirm", "help"}, + expectKubectl: false, + expectedExitCode: 0, + }, + { + name: "version should show version", + options: confirmOptions{}, + fakeArgs: []string{"version"}, + fakeOsArgs: []string{"confirm", "version"}, + expectKubectl: true, + expectedStdout: version.String(), + expectedKubectlArgs: []string{"version"}, + expectedExitCode: 0, + }, + { + name: "version should not show plugin version if output flag is specified", + options: confirmOptions{}, + fakeArgs: []string{"version"}, + fakeOsArgs: []string{"confirm", "version", "-o", "yaml"}, + expectKubectl: true, + unexpectedStdout: "Kubectl Confirm Plugin Version: ", + expectedKubectlArgs: []string{"version", "-o", "yaml"}, + expectedExitCode: 0, + }, + // TODO: Should show dry run when command is dry-runnable + // TODO: Should skip dry run when command is not dry-runnable + { + name: "should show diff when command is diff-able", + options: confirmOptions{}, + fakeArgs: []string{"apply"}, + fakeOsArgs: []string{"confirm", "apply", "-f", "foo.yaml"}, + response: "yes\n", + expectKubectl: true, + expectedStdout: `========== Confirm ========== +The following command will be executed: +kubectl apply -f foo.yaml + +Enter 'yes' to continue: `, + expectedKubectlArgs: []string{"apply", "-f", "foo.yaml"}, + expectedExitCode: 0, + }, + { + name: "should use KUBECTL_PATH environment variable", + options: confirmOptions{}, + kubectlPath: "override-kubectl-path", + fakeArgs: []string{"apply"}, + fakeOsArgs: []string{"confirm", "apply", "-f", "foo.yaml"}, + response: "yes\n", + expectKubectl: true, + expectedStdout: `========== Confirm ========== +The following command will be executed: +override-kubectl-path apply -f foo.yaml + +Enter 'yes' to continue: `, + expectedKubectlArgs: []string{"apply", "-f", "foo.yaml"}, + expectedExitCode: 0, + }, + { + name: "should skip diff when command is not not diff-able", + options: confirmOptions{}, + fakeArgs: []string{"delete"}, + fakeOsArgs: []string{"confirm", "delete", "-f", "foo.yaml"}, + response: "yes\n", + expectKubectl: true, + expectedStdout: `========== Confirm ========== +The following command will be executed: +kubectl delete -f foo.yaml + +Enter 'yes' to continue: `, + unexpectedStdout: "========== Diff =============", + expectedKubectlArgs: []string{"delete", "-f", "foo.yaml"}, + expectedExitCode: 0, + }, + { + name: "should abort if response is not yes", + options: confirmOptions{}, + fakeArgs: []string{"delete"}, + fakeOsArgs: []string{"confirm", "delete", "-f", "foo.yaml"}, + response: "no\n", + expectKubectl: true, + expectedStdout: `========== Confirm ========== +The following command will be executed: +kubectl delete -f foo.yaml + +Enter 'yes' to continue: `, + expectedStderr: "Command aborted.", + expectedKubectlArgs: []string{"delete", "-f", "foo.yaml", "--dry-run=server"}, + expectedExitCode: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd, stdin, stdout, stderr := util.NewTestCommand() + stdin.Write(bytes.NewBufferString(tc.response).Bytes()) + + var actualExitCode int + util.Exit = func(code int) { + actualExitCode = code + } + + commandName := tc.fakeArgs[0] + + fakeExecRunner := util.NewFakeExecRunner() + fakeExecRunner.SetupRun(`{"current-context": "foo", "contexts": [{"name": "foo", "context": {}}]}`, "", nil) + if dryRunCommands[commandName] { + fakeExecRunner.SetupRun("fake dry run output", "", nil) + } + if diffCommands[commandName] { + fakeExecRunner.SetupRun("fake diff dry run output", "", nil) + fakeExecRunner.SetupRun("fake diff output", "", nil) + } + if tc.response == "yes\n" { + fakeExecRunner.SetupRun("fake real command output", "", nil) + } + + os.Args = tc.fakeOsArgs + + if len(tc.kubectlPath) > 0 { + _ = os.Setenv("KUBECTL_PATH", tc.kubectlPath) + defer os.Unsetenv("KUBECTL_PATH") + } + + err := tc.options.run(cmd, tc.fakeArgs) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if tc.expectKubectl { + expectedLastRunName := "kubectl" + if len(tc.kubectlPath) > 0 { + expectedLastRunName = tc.kubectlPath + } + if fakeExecRunner.LastRunName() != expectedLastRunName { + t.Fatalf("expected %q to be run, but it was %q", expectedLastRunName, fakeExecRunner.LastRunName()) + } + + if !reflect.DeepEqual(fakeExecRunner.LastRunArgs(), tc.expectedKubectlArgs) { + t.Fatalf("wrong kubectl args.\nexpected: %v\ngot: %v\n", tc.expectedKubectlArgs, fakeExecRunner.LastRunArgs()) + } + } else { + if fakeExecRunner.RunCount() > 0 { + t.Fatalf("unexpected run %q with args = %v", fakeExecRunner.LastRunName(), fakeExecRunner.LastRunArgs()) + } + } + + if !strings.Contains(stdout.String(), tc.expectedStdout) { + t.Fatalf("expected stdout to contain %q, but it did not", tc.expectedStdout) + } + + if tc.unexpectedStdout != "" && strings.Contains(stdout.String(), tc.unexpectedStdout) { + t.Fatalf("expected stdout not to contain %q, but it did", tc.unexpectedStdout) + } + + if !strings.Contains(stderr.String(), tc.expectedStderr) { + t.Fatalf("expected stderr to contain %q, but it did not", tc.expectedStderr) + } + + if actualExitCode != tc.expectedExitCode { + t.Fatalf("wrong exit code. expected: %d, got %d", tc.expectedExitCode, actualExitCode) + } + }) + } +} diff --git a/pkg/cmd/diff.go b/pkg/cmd/diff.go new file mode 100644 index 0000000..afe85ee --- /dev/null +++ b/pkg/cmd/diff.go @@ -0,0 +1,61 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/brianpursley/kubectl-confirm/internal/util" +) + +func (o *confirmOptions) diff(cmd *cobra.Command) error { + util.PrintSectionTitle(cmd, "Diff") + defer cmd.Println() + + if o.hasAnyNonRegularFiles { + cmd.Println("*** Skipped because one or more non-regular files were specified ***") + return nil + } + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + err := util.ExecRun(util.GetKubectlPath(), append(os.Args[1:], "--dry-run=server", "--output=yaml"), cmd.InOrStdin(), &stdout, &stderr) + if err != nil { + return fmt.Errorf("%s", stderr.String()) + } + + f, err := os.CreateTemp("", "kubectl-confirm-") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err := f.Write(stdout.Bytes()); err != nil { + return err + } + + // Run kubectl diff + err = util.ExecRun(util.GetKubectlPath(), []string{"diff", "--filename", f.Name()}, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr()) + if err == nil { + cmd.Println("no changes detected") + } + return nil +} diff --git a/pkg/cmd/diff_test.go b/pkg/cmd/diff_test.go new file mode 100644 index 0000000..ee4a166 --- /dev/null +++ b/pkg/cmd/diff_test.go @@ -0,0 +1,150 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "os" + "reflect" + "testing" + + "github.com/brianpursley/kubectl-confirm/internal/util" +) + +func TestDiff(t *testing.T) { + testCases := []struct { + name string + options confirmOptions + osArgs []string + expectKubectlRun bool + expectedKubectlDryRunArgs []string + fakeKubectlDiffStdout string + expectedStdout string + }{ + { + name: "non regular files", + options: confirmOptions{ + hasAnyNonRegularFiles: true, + }, + expectKubectlRun: false, + expectedStdout: `========== Diff ============= +*** Skipped because one or more non-regular files were specified *** + +`, + }, + { + name: "single file no changes", + osArgs: []string{"kubectl-confirm", "apply", "--filename", "foo.yaml"}, + expectKubectlRun: true, + expectedKubectlDryRunArgs: []string{"apply", "--filename", "foo.yaml", "--dry-run=server", "--output=yaml"}, + fakeKubectlDiffStdout: "", + expectedStdout: `========== Diff ============= +no changes detected + +`, + }, + { + name: "single file with changes", + osArgs: []string{"kubectl-confirm", "apply", "--filename", "foo.yaml"}, + expectKubectlRun: true, + expectedKubectlDryRunArgs: []string{"apply", "--filename", "foo.yaml", "--dry-run=server", "--output=yaml"}, + fakeKubectlDiffStdout: "fake diff output\n", + expectedStdout: `========== Diff ============= +fake diff output + +`, + }, + { + name: "multiple files", + osArgs: []string{"kubectl-confirm", "apply", "--filename", "foo.yaml", "--filename", "bar.yaml"}, + expectKubectlRun: true, + expectedKubectlDryRunArgs: []string{"apply", "--filename", "foo.yaml", "--filename", "bar.yaml", "--dry-run=server", "--output=yaml"}, + fakeKubectlDiffStdout: "fake diff output\n", + expectedStdout: `========== Diff ============= +fake diff output + +`, + }, + { + name: "recursive", + osArgs: []string{"kubectl-confirm", "apply", "--filename", "foo/", "--recursive"}, + expectKubectlRun: true, + expectedKubectlDryRunArgs: []string{"apply", "--filename", "foo/", "--recursive", "--dry-run=server", "--output=yaml"}, + fakeKubectlDiffStdout: "fake diff output\n", + expectedStdout: `========== Diff ============= +fake diff output + +`, + }, + { + name: "kustomize", + osArgs: []string{"kubectl-confirm", "apply", "--kustomize", "foo/"}, + expectKubectlRun: true, + expectedKubectlDryRunArgs: []string{"apply", "--kustomize", "foo/", "--dry-run=server", "--output=yaml"}, + fakeKubectlDiffStdout: "fake diff output\n", + expectedStdout: `========== Diff ============= +fake diff output + +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var fakeError error + if tc.fakeKubectlDiffStdout != "" { + fakeError = fmt.Errorf("exit status 1") + } + fakeExecRunner := util.NewFakeExecRunner() + fakeExecRunner.SetupRun("fake dry run output", "", nil) // Dry run + fakeExecRunner.SetupRun(tc.fakeKubectlDiffStdout, "", fakeError) // Diff + + cmd, _, stdout, stderr := util.NewTestCommand() + + os.Args = tc.osArgs + + err := tc.options.diff(cmd) + if err != nil { + t.Fatalf("diff failed: %v", err) + } + + if tc.expectKubectlRun { + if fakeExecRunner.RunNames[0] != "kubectl" { + t.Fatalf("expected kubectl to be run, but it was not") + } + if !reflect.DeepEqual(fakeExecRunner.RunArgs[0], tc.expectedKubectlDryRunArgs) { + t.Fatalf("wrong kubectl args.\nexpected: %v\ngot: %v\n", tc.expectedKubectlDryRunArgs, fakeExecRunner.RunArgs[0]) + } + + if fakeExecRunner.RunNames[1] != "kubectl" { + t.Fatalf("expected kubectl to be run, but it was not") + } + if fakeExecRunner.RunArgs[1][0] != "diff" { + t.Fatalf("expected kubectl diff to be called, but it was not") + } + } + + if stdout.String() != tc.expectedStdout { + t.Fatalf("wrong stdout\nexpected:\n%s\ngot:\n%s\n", tc.expectedStdout, stdout.String()) + } + + if stderr.Len() > 0 { + t.Fatalf("unexpected stderr:\n%s", stderr.String()) + } + }) + } +} diff --git a/pkg/cmd/dryrun.go b/pkg/cmd/dryrun.go new file mode 100644 index 0000000..0201a56 --- /dev/null +++ b/pkg/cmd/dryrun.go @@ -0,0 +1,47 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "fmt" + "github.com/spf13/cobra" + "os" + + "github.com/brianpursley/kubectl-confirm/internal/util" +) + +func (o *confirmOptions) dryRun(cmd *cobra.Command) error { + util.PrintSectionTitle(cmd, "Dry Run") + defer cmd.Println() + + if o.hasAnyNonRegularFiles { + cmd.Println("*** Skipped because one or more non-regular files were specified ***") + return nil + } + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + err := util.ExecRun(util.GetKubectlPath(), append(os.Args[1:], "--dry-run=server"), cmd.InOrStdin(), &stdout, &stderr) + if err != nil { + return fmt.Errorf("%s", stderr.String()) + } + + cmd.Print(stdout.String()) + return nil +} diff --git a/pkg/cmd/dryrun_test.go b/pkg/cmd/dryrun_test.go new file mode 100644 index 0000000..7650dbe --- /dev/null +++ b/pkg/cmd/dryrun_test.go @@ -0,0 +1,120 @@ +/* +Copyright 2022 Brian Pursley. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "os" + "reflect" + "testing" + + "github.com/brianpursley/kubectl-confirm/internal/util" +) + +func TestDryRun(t *testing.T) { + testCases := []struct { + name string + options confirmOptions + expectKubectl bool + fakeOsArgs []string + expectedKubectlArgs []string + fakeKubectlStdout string + fakeKubectlStderr string + fakeKubectlError error + expectedStdout string + expectedError error + }{ + { + name: "non regular files", + options: confirmOptions{ + hasAnyNonRegularFiles: true, + }, + fakeOsArgs: []string{"confirm", "foo", "bar", "--baz"}, + expectKubectl: false, + expectedStdout: `========== Dry Run ========== +*** Skipped because one or more non-regular files were specified *** + +`, + }, + { + name: "successful dry run", + options: confirmOptions{}, + expectKubectl: true, + fakeOsArgs: []string{"confirm", "foo", "bar", "--baz"}, + expectedKubectlArgs: []string{"foo", "bar", "--baz", "--dry-run=server"}, + fakeKubectlStdout: "fake dry run output\n", + expectedStdout: `========== Dry Run ========== +fake dry run output + +`, + }, + { + name: "error", + options: confirmOptions{}, + expectKubectl: true, + fakeOsArgs: []string{"confirm", "foo", "bar", "--baz"}, + expectedKubectlArgs: []string{"foo", "bar", "--baz", "--dry-run=server"}, + fakeKubectlError: fmt.Errorf("exit status 1"), + fakeKubectlStderr: "unknown flag: --dry-run", + expectedStdout: `========== Dry Run ========== + +`, + expectedError: fmt.Errorf("unknown flag: --dry-run"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fakeExecRunner := util.NewFakeExecRunner() + fakeExecRunner.SetupRun(tc.fakeKubectlStdout, tc.fakeKubectlStderr, tc.fakeKubectlError) + + os.Args = tc.fakeOsArgs + cmd, _, stdout, stderr := util.NewTestCommand() + + err := tc.options.dryRun(cmd) + if tc.expectedError == nil { + if err != nil { + t.Fatalf("dryRun failed: %v", err) + } + } else { + if err == nil { + t.Fatalf("expected an error, but no error was returned.\nExpected: %v\n", tc.expectedError) + } else if err.Error() != tc.expectedError.Error() { + t.Fatalf("wrong error returned.\nExpected: %v\nGot: %v\n", tc.expectedError, err.Error()) + } + } + + if tc.expectKubectl { + if fakeExecRunner.LastRunName() != "kubectl" { + t.Fatalf("expected kubectl to be run, but it was not") + } + + if !reflect.DeepEqual(fakeExecRunner.LastRunArgs(), tc.expectedKubectlArgs) { + t.Fatalf("wrong kubectl args.\nexpected: %v\ngot: %v\n", tc.expectedKubectlArgs, fakeExecRunner.LastRunArgs()) + } + } + + if stdout.String() != tc.expectedStdout { + t.Fatalf("wrong stdout\nexpected:\n%s\ngot:\n%s\n", tc.expectedStdout, stdout.String()) + } + + if stderr.Len() > 0 { + t.Fatalf("unexpected stderr:\n%s", stderr.String()) + } + }) + } +}