├── .github ├── dependabot.yml └── workflows │ └── go.yml ├── .gitignore ├── .golangci.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile.exporter ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── aws ├── disk.go ├── disk_test.go ├── provider.go └── provider_test.go ├── azure ├── disk.go ├── disk_test.go ├── provider.go └── provider_test.go ├── cmd ├── internal │ ├── age.go │ ├── age_test.go │ ├── providers.go │ ├── providers_test.go │ ├── string_slice_flag.go │ └── string_slice_flag_test.go ├── unused-exporter │ ├── config.go │ ├── exporter.go │ ├── exporter_test.go │ ├── main.go │ └── server.go └── unused │ ├── internal │ └── ui │ │ ├── group_table.go │ │ ├── interactive.go │ │ ├── interactive │ │ ├── delete_view.go │ │ ├── keys.go │ │ ├── model.go │ │ ├── provider_list.go │ │ └── provider_view.go │ │ ├── table.go │ │ └── ui.go │ └── main.go ├── disk.go ├── disks.go ├── disks_test.go ├── doc.go ├── gcp ├── disk.go ├── disk_test.go ├── provider.go └── provider_test.go ├── go.mod ├── go.sum ├── meta.go ├── meta_test.go ├── provider.go └── unusedtest ├── disk.go ├── disk_test.go ├── meta.go ├── meta_test.go ├── provider.go └── provider_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | reviewers: 8 | - "inkel" 9 | groups: 10 | bubbletea: 11 | patterns: 12 | - github.com/charmbracelet/bubbles 13 | - github.com/charmbracelet/bubbletea 14 | - github.com/charmbracelet/lipgloss 15 | - github.com/evertras/bubble-table 16 | aws-sdk-go: 17 | patterns: 18 | - github.com/aws/aws-sdk-go-v2 19 | - github.com/aws/aws-sdk-go-v2/* 20 | azure: 21 | patterns: 22 | - github.com/Azure/azure-sdk-for-go 23 | - github.com/Azure/go-autorest/autorest 24 | - github.com/Azure/go-autorest/autorest 25 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 18 | with: 19 | persist-credentials: false 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@be3c94b385c4f180051c996d336f57a34c397495 23 | with: 24 | go-version: 1.24 25 | 26 | - name: Checks 27 | run: make checks 28 | 29 | - name: Test 30 | run: make test 31 | 32 | - name: Benchmark 33 | run: make benchmark 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .air.toml 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | 2 | # Adjust as appropriate 3 | linters-settings: 4 | govet: 5 | check-shadowing: false 6 | golint: 7 | min-confidence: 0 8 | gocyclo: 9 | min-complexity: 20 10 | maligned: 11 | suggest-new: true 12 | dupl: 13 | threshold: 100 14 | goconst: 15 | min-len: 6 16 | min-occurrences: 8 17 | lll: 18 | line-length: 240 19 | nakedret: 20 | max-func-lines: 0 21 | 22 | issues: 23 | exclude-use-default: false 24 | 25 | exclude-rules: 26 | # Duplicated errcheck checks 27 | - linters: [gosec] 28 | text: G104 29 | # Ignore aliasing in tests 30 | - linters: [gosec] 31 | text: G601 32 | path: _test\.go 33 | # Non-secure URLs are okay in tests 34 | - linters: [gosec] 35 | text: G107 36 | path: _test\.go 37 | # Nil pointers will fail tests anyway 38 | - linters: [staticcheck] 39 | text: SA5011 40 | path: _test\.go 41 | # Duplicated errcheck checks 42 | - linters: [staticcheck] 43 | text: SA5001 44 | # Duplicated function naming check 45 | - linters: [stylecheck] 46 | text: ST1003 47 | # We don't require comments on everything 48 | - linters: [stylecheck] 49 | text: should have( a package)? comment 50 | # Use of math/rand instead of crypto/rand 51 | - linters: [gosec] 52 | text: G404 53 | 54 | linters: 55 | disable-all: true 56 | enable: 57 | - bodyclose 58 | - errcheck 59 | - goconst 60 | - gocyclo 61 | - gofmt 62 | - goimports 63 | - gosec 64 | - gosimple 65 | - govet 66 | - ineffassign 67 | - lll 68 | - misspell 69 | - prealloc 70 | - staticcheck 71 | - stylecheck 72 | - typecheck 73 | - unconvert 74 | - unparam 75 | - unused 76 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@grafana.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to Grafana unused project! We welcome all people who want to contribute in a healthy and constructive manner within our community. To help us create a safe and positive community experience for all, we require all participants to adhere to the [Code of Conduct](CODE_OF_CONDUCT.md). 4 | 5 | This document is a guide to help you through the process of contributing to Grafana unused. 6 | 7 | ## Become a contributor 8 | 9 | You can contribute to Grafana Plugin SDK for Go in several ways. Here are some examples: 10 | 11 | - Contribute to the Grafana unused codebase. 12 | - Report bugs and enhancements. 13 | 14 | For more ways to contribute, check out the [Open Source Guides](https://opensource.guide/how-to-contribute/). 15 | 16 | ### Report bugs 17 | 18 | Report a bug by submitting a [bug report](https://github.com/grafana/unused/issues/new?labels=bug&template=1-bug_report.md). Make sure that you provide as much information as possible on how to reproduce the bug. 19 | 20 | Before submitting a new issue, try to make sure someone hasn't already reported the problem. Look through the [existing issues](https://github.com/grafana/unused/issues) for similar issues. 21 | 22 | #### Security issues 23 | 24 | If you believe you've found a security vulnerability, please read our [security policy](https://github.com/grafana/unused/security/policy) for more details. 25 | 26 | ### Suggest enhancements 27 | 28 | If you have an idea of how to improve Grafana Plugin SDK for Go, submit an [enhancement request](https://github.com/grafana/unused/issues/new?labels=enhancement&template=2-enhancement_request.md). 29 | -------------------------------------------------------------------------------- /Dockerfile.exporter: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 AS builder 2 | 3 | ENV GO11MODULE=on 4 | 5 | WORKDIR /app 6 | 7 | COPY ["go.mod", "go.sum", "./"] 8 | 9 | RUN ["go", "mod", "download"] 10 | 11 | COPY . . 12 | 13 | ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 14 | 15 | RUN ["go", "build", "-v", "-o", "unused-exporter", "./cmd/unused-exporter"] 16 | 17 | FROM alpine:3.16.2 18 | 19 | COPY --from=builder /app/unused-exporter /app/ 20 | 21 | EXPOSE 8080 22 | 23 | ENTRYPOINT ["/app/unused-exporter"] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PKGS = ./... 2 | TESTFLAGS = -race -vet all -mod readonly 3 | BUILDFLAGS = -v 4 | BENCH = . 5 | BENCHFLAGS = -benchmem -bench=${BENCH} 6 | 7 | IMAGE_PREFIX ?= us.gcr.io/kubernetes-dev 8 | GIT_VERSION ?= $(shell git describe --tags --always --dirty) 9 | 10 | build: 11 | go build ${BUILDFLAGS} ${PKGS} 12 | 13 | test: 14 | go test ${TESTFLAGS} ${PKGS} 15 | 16 | benchmark: 17 | go test ${TESTFLAGS} ${BENCHFLAGS} ${PKGS} 18 | 19 | checks: 20 | go vet ${PKGS} 21 | go run honnef.co/go/tools/cmd/staticcheck@latest ${PKGS} 22 | make lint 23 | 24 | lint: 25 | go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -c .golangci.yml 26 | 27 | docker: 28 | docker build --build-arg=GIT_VERSION=$(GIT_VERSION) -t $(IMAGE_PREFIX)/unused -f Dockerfile.exporter . --load 29 | docker tag $(IMAGE_PREFIX)/unused $(IMAGE_PREFIX)/unused:$(GIT_VERSION) 30 | 31 | push: docker 32 | docker push $(IMAGE_PREFIX)/unused:$(GIT_VERSION) 33 | docker push $(IMAGE_PREFIX)/unused:latest 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CLI tool, Prometheus exporter, and Go module to list your unused disks in all cloud providers 2 | This repository contains a Go library to list your unused persistent disks in different cloud providers, and binaries for displaying them at the CLI or exporting Prometheus metrics. 3 | 4 | At Grafana Labs we host our workloads in multiple cloud providers. 5 | Our workloads orchestration is managed by Kubernetes, and we've found that due to some misconfiguration in the backend storage, we used to have lots of unused resources, specially persistent disks. 6 | These leaked resources cost money, and because these are resources that are not in use anymore, it translates into wasted money. 7 | This library and its companion tools should help you out to identify these resources and clean them up. 8 | 9 | ## Go module `github.com/grafana/unused` 10 | This module exports some interfaces and implementations to easily list all your unsed persistent disks in GCP, AWS, and Azure. 11 | You can find the API in the [Go package documentation](https://pkg.go.dev/github.com/grafana/unused). 12 | 13 | ## Binaries 14 | This repository also provides two binaries ready to use to interactively view unused disks (`cmd/unused`) or expose unused disk metrics (`cmd/unused-exporter`) to [Prometheus](https://prometheus.io). 15 | Both programs can authenticate against the following providers using the listed CLI flags: 16 | 17 | | Provider | Flag | Description | 18 | |-|-|-| 19 | | GCP | `-gcp.project` | ID of the GCP project | 20 | | AWS | `-aws.profile` | AWS configuration profile name | 21 | | Azure | `-azure.sub` | Azure subscription ID | 22 | 23 | These flags can be specified more than once, allowing to have different configurations for each provider. 24 | 25 | #### Notes on authentication 26 | Both binaries are opinionated on how to authenticate against each Cloud Service Provider (CSP). 27 | 28 | | Provider | Notes | 29 | |-|-| 30 | | GCP | Depends on [default credentials](https://cloud.google.com/docs/authentication/application-default-credentials) | 31 | | AWS | Uses profile names from your [credentials file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) or `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION` env variables | 32 | | Azure | Either specify an `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, and `AZURE_TENANT_ID`, or requires [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/) installed on the host and [signed in](https://learn.microsoft.com/en-us/cli/azure/authenticate-azure-cli) | 33 | 34 | ### `unused` binary 35 | TUI tool to query all given providers and list them as a neat table. 36 | It also supports an interactive mode which allows to select and delete disks in an easy way. 37 | 38 | ``` 39 | go install github.com/grafana/unused/cmd/unused@latest 40 | ``` 41 | 42 | ### `unused-exporter` Prometheus exporter 43 | Web server exposing Prometheus metrics about each providers count of unused disks. 44 | It exposes the following metrics: 45 | 46 | | Metric | Description | 47 | |-|-| 48 | | `unused_disks_count` | How many unused disks are in this provider | 49 | | `unused_disks_total_size_bytes` | Total size of unused disks in this provider in bytes | 50 | | `unused_disk_size_bytes` | Size of each disk in bytes | 51 | | `unused_disks_last_used_timestamp_seconds` | Last timestamp (unix seconds) when this disk was used. GCP only! | 52 | | `unused_provider_duration_seconds` | How long in seconds took to fetch this provider information | 53 | | `unused_provider_info` | CSP information | 54 | | `unused_provider_success` | Static metric indicating if collecting the metrics succeeded or not | 55 | 56 | All metrics have the `provider` and `provider_id` labels to identify to which provider instance they belong. 57 | The `unused_disks_count`, `unused_disk_size_bytes`, and `unused_disks_total_size_bytes` metrics have an additional `k8s_namespace` metric mapped to the `kubernetes.io/created-for/pvc/namespace` annotation assigned to persistent disks created by Kubernetes. 58 | 59 | Information about each unused disk is currently logged to stdout given that it contains more changing information that could lead to cardinality explosion. 60 | 61 | ``` 62 | go install github.com/grafana/unused/cmd/unused-exporter@latest 63 | ``` 64 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting security issues 2 | 3 | If you think you have found a security vulnerability, please send a report to [security@grafana.com](mailto:security@grafana.com). This address can be used for all of Grafana Labs's open source and commercial products (including but not limited to Grafana, Grafana Cloud, Grafana Enterprise, and grafana.com). We can accept only vulnerability reports at this address. 4 | 5 | Please encrypt your message to us; please use our PGP key. The key fingerprint is: 6 | 7 | F988 7BEA 027A 049F AE8E 5CAA D125 8932 BE24 C5CA 8 | 9 | The key is available from [pgp.mit.edu](https://pgp.mit.edu/pks/lookup?op=get&search=0xF9887BEA027A049FAE8E5CAAD1258932BE24C5CA) by searching for [grafana](https://pgp.mit.edu/pks/lookup?search=grafana&op=index). 10 | 11 | Grafana Labs will send you a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. 12 | 13 | **Important:** We ask you to not disclose the vulnerability before it have been fixed and announced, unless you received a response from the Grafana Labs security team that you can do so. 14 | 15 | ## Security announcements 16 | 17 | We maintain a category on the community site called [Security Announcements](https://community.grafana.com/c/security-announcements), 18 | where we will post a summary, remediation, and mitigation details for any patch containing security fixes. 19 | 20 | You can also subscribe to email updates to this category if you have a grafana.com account and sign on to the community site or track updates via an [RSS feed](https://community.grafana.com/c/security-announcements.rss). 21 | -------------------------------------------------------------------------------- /aws/disk.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/aws/aws-sdk-go-v2/service/ec2/types" 7 | "github.com/grafana/unused" 8 | ) 9 | 10 | var _ unused.Disk = &Disk{} 11 | 12 | // Disk holds information about an AWS EC2 volume. 13 | type Disk struct { 14 | types.Volume 15 | provider *Provider 16 | meta unused.Meta 17 | } 18 | 19 | // ID returns the volume ID of this AWS EC2 volume. 20 | func (d *Disk) ID() string { return *d.Volume.VolumeId } 21 | 22 | // Provider returns a reference to the provider used to instantiate 23 | // this disk. 24 | func (d *Disk) Provider() unused.Provider { return d.provider } 25 | 26 | // Name returns the name of this AWS EC2 volume. 27 | // 28 | // AWS EC2 volumes do not have a name property, instead they store the 29 | // name in tags. This method will try to find the Name or 30 | // CSIVolumeName, otherwise it will return empty. 31 | func (d *Disk) Name() string { 32 | for _, t := range d.Volume.Tags { 33 | if *t.Key == "Name" || *t.Key == "CSIVolumeName" { 34 | return *t.Value 35 | } 36 | } 37 | return "" 38 | } 39 | 40 | // CreatedAt returns the time when the AWS EC2 volume was created. 41 | func (d *Disk) CreatedAt() time.Time { return *d.Volume.CreateTime } 42 | 43 | // Meta returns the disk metadata. 44 | func (d *Disk) Meta() unused.Meta { return d.meta } 45 | 46 | // SizeGB returns the size of this AWS EC2 volume in GiB. 47 | func (d *Disk) SizeGB() int { return int(*d.Volume.Size) } 48 | 49 | // SizeBytes returns the size of this AWS EC2 volume in bytes. 50 | func (d *Disk) SizeBytes() float64 { return float64(*d.Volume.Size) * unused.GiBbytes } 51 | 52 | // LastUsedAt returns a zero [time.Time] value, as AWS does not 53 | // provide this information. 54 | func (d *Disk) LastUsedAt() time.Time { return time.Time{} } 55 | 56 | // DiskType Type returns the type of this AWS EC2 volume. 57 | func (d *Disk) DiskType() unused.DiskType { 58 | switch d.Volume.VolumeType { 59 | case types.VolumeTypeGp2, types.VolumeTypeGp3, types.VolumeTypeIo1, types.VolumeTypeIo2: 60 | return unused.SSD 61 | case types.VolumeTypeSt1, types.VolumeTypeSc1, types.VolumeTypeStandard: 62 | return unused.HDD 63 | default: 64 | return unused.Unknown 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /aws/disk_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/aws/aws-sdk-go-v2/aws" 8 | "github.com/aws/aws-sdk-go-v2/service/ec2/types" 9 | "github.com/grafana/unused" 10 | ) 11 | 12 | func TestDisk(t *testing.T) { 13 | createdAt := time.Date(2021, 7, 16, 5, 55, 00, 0, time.UTC) 14 | size := int32(10) 15 | 16 | for _, keyName := range []string{"Name", "CSIVolumeName"} { 17 | t.Run(keyName, func(t *testing.T) { 18 | var d unused.Disk = &Disk{ 19 | types.Volume{ 20 | VolumeId: aws.String("my-disk-id"), 21 | CreateTime: &createdAt, 22 | Tags: []types.Tag{ 23 | { 24 | Key: aws.String(keyName), 25 | Value: aws.String("my-disk"), 26 | }, 27 | }, 28 | Size: &size, 29 | }, 30 | nil, 31 | nil, 32 | } 33 | 34 | if exp, got := "my-disk-id", d.ID(); exp != got { 35 | t.Errorf("expecting ID() %q, got %q", exp, got) 36 | } 37 | 38 | if exp, got := "AWS", d.Provider().Name(); exp != got { 39 | t.Errorf("expecting Provider() %q, got %q", exp, got) 40 | } 41 | 42 | if exp, got := "my-disk", d.Name(); exp != got { 43 | t.Errorf("expecting Name() %q, got %q", exp, got) 44 | } 45 | 46 | if !createdAt.Equal(d.CreatedAt()) { 47 | t.Errorf("expecting CreatedAt() %v, got %v", createdAt, d.CreatedAt()) 48 | } 49 | 50 | if exp, got := int(size), d.SizeGB(); exp != got { 51 | t.Errorf("expecting SizeGB() %d, got %d", exp, got) 52 | } 53 | 54 | if exp, got := float64(size)*unused.GiBbytes, d.SizeBytes(); exp != got { 55 | t.Errorf("expecting SizeBytes() %f, got %f", exp, got) 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /aws/provider.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/aws/aws-sdk-go-v2/aws" 9 | "github.com/aws/aws-sdk-go-v2/service/ec2" 10 | "github.com/aws/aws-sdk-go-v2/service/ec2/types" 11 | "github.com/grafana/unused" 12 | ) 13 | 14 | var _ unused.Provider = &Provider{} 15 | 16 | var ProviderName = "AWS" 17 | 18 | // Provider implements [unused.Provider] for AWS. 19 | type Provider struct { 20 | client *ec2.Client 21 | meta unused.Meta 22 | logger *slog.Logger 23 | } 24 | 25 | // Name returns AWS. 26 | func (p *Provider) Name() string { return ProviderName } 27 | 28 | // Meta returns the provider metadata. 29 | func (p *Provider) Meta() unused.Meta { return p.meta } 30 | 31 | // ID returns the profile of this provider. 32 | func (p *Provider) ID() string { return p.meta["profile"] } 33 | 34 | // NewProvider creates a new AWS [unused.Provider]. 35 | // 36 | // A valid EC2 client must be supplied in order to list the unused 37 | // resources. The metadata passed will be used to identify the 38 | // provider. 39 | func NewProvider(logger *slog.Logger, client *ec2.Client, meta unused.Meta) (*Provider, error) { 40 | if meta == nil { 41 | meta = make(unused.Meta) 42 | } 43 | 44 | return &Provider{ 45 | client: client, 46 | meta: meta, 47 | logger: logger, 48 | }, nil 49 | } 50 | 51 | // ListUnusedDisks returns all the AWS EC2 volumes that are available, 52 | // ie. not used by any other resource. 53 | func (p *Provider) ListUnusedDisks(ctx context.Context) (unused.Disks, error) { 54 | params := &ec2.DescribeVolumesInput{ 55 | Filters: []types.Filter{ 56 | // only show available (i.e. not "in-use") volumes 57 | { 58 | Name: aws.String("status"), 59 | Values: []string{string(types.VolumeStateAvailable)}, 60 | }, 61 | // exclude snapshots 62 | { 63 | Name: aws.String("snapshot-id"), 64 | Values: []string{""}, 65 | }, 66 | }, 67 | } 68 | 69 | pager := ec2.NewDescribeVolumesPaginator(p.client, params) 70 | 71 | var upds unused.Disks 72 | 73 | for pager.HasMorePages() { 74 | res, err := pager.NextPage(ctx) 75 | if err != nil { 76 | return nil, fmt.Errorf("cannot list AWS unused disks: %w", err) 77 | } 78 | 79 | for _, v := range res.Volumes { 80 | m := unused.Meta{ 81 | "zone": *v.AvailabilityZone, 82 | } 83 | for _, t := range v.Tags { 84 | k := *t.Key 85 | if k == "Name" || k == "CSIVolumeName" { 86 | // already returned in Name() 87 | continue 88 | } 89 | m[k] = *t.Value 90 | } 91 | 92 | upds = append(upds, &Disk{v, p, m}) 93 | } 94 | } 95 | 96 | return upds, nil 97 | } 98 | 99 | // Delete deletes the given disk from AWS. 100 | func (p *Provider) Delete(ctx context.Context, disk unused.Disk) error { 101 | _, err := p.client.DeleteVolume(ctx, &ec2.DeleteVolumeInput{ 102 | VolumeId: aws.String(disk.ID()), 103 | }) 104 | if err != nil { 105 | return fmt.Errorf("cannot delete AWS disk: %w", err) 106 | } 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /aws/provider_test.go: -------------------------------------------------------------------------------- 1 | package aws_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | 10 | awsutil "github.com/aws/aws-sdk-go-v2/aws" 11 | "github.com/aws/aws-sdk-go-v2/config" 12 | "github.com/aws/aws-sdk-go-v2/credentials" 13 | "github.com/aws/aws-sdk-go-v2/service/ec2" 14 | endpoints "github.com/aws/smithy-go/endpoints" 15 | "github.com/grafana/unused" 16 | "github.com/grafana/unused/aws" 17 | "github.com/grafana/unused/unusedtest" 18 | ) 19 | 20 | func TestNewProvider(t *testing.T) { 21 | ctx := context.Background() 22 | cfg, err := config.LoadDefaultConfig(ctx) 23 | if err != nil { 24 | t.Fatalf("cannot load AWS config: %v", err) 25 | } 26 | 27 | p, err := aws.NewProvider(nil, ec2.NewFromConfig(cfg), map[string]string{"profile": "my-profile"}) 28 | if err != nil { 29 | t.Fatalf("unexpected error: %v", err) 30 | } 31 | 32 | if p == nil { 33 | t.Fatal("expecting Provider, got nil") 34 | } 35 | 36 | if exp, got := "my-profile", p.ID(); exp != got { 37 | t.Fatalf("provider id was incorrect, exp: %v, got: %v", exp, got) 38 | } 39 | } 40 | 41 | func TestProviderMeta(t *testing.T) { 42 | err := unusedtest.TestProviderMeta(func(meta unused.Meta) (unused.Provider, error) { 43 | ctx := context.Background() 44 | cfg, err := config.LoadDefaultConfig(ctx) 45 | if err != nil { 46 | t.Fatalf("cannot load AWS config: %v", err) 47 | } 48 | 49 | return aws.NewProvider(nil, ec2.NewFromConfig(cfg), meta) 50 | }) 51 | if err != nil { 52 | t.Fatalf("unexpected error: %v", err) 53 | } 54 | } 55 | 56 | type mockEndpointResolver url.URL 57 | 58 | func (er mockEndpointResolver) ResolveEndpoint(ctx context.Context, params ec2.EndpointParameters) (endpoints.Endpoint, error) { 59 | return endpoints.Endpoint{ 60 | URI: url.URL(er), 61 | }, nil 62 | } 63 | 64 | func TestListUnusedDisks(t *testing.T) { 65 | ctx := context.Background() 66 | 67 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 68 | // How cannot love you, AWS 69 | _, err := w.Write([]byte(` 70 | 59dbff89-35bd-4eac-99ed-be587EXAMPLE 71 | 72 | 73 | vol-1234567890abcdef0 74 | 80 75 | 76 | us-east-1a 77 | available 78 | 2022-03-12T17:25:21.000Z 79 | standard 80 | true 81 | false 82 | 83 | 84 | vol-abcdef01234567890 85 | 120 86 | 87 | us-west-2b 88 | available 89 | 2022-02-12T17:25:21.000Z 90 | standard 91 | true 92 | false 93 | 94 | 95 | CSIVolumeName 96 | prometheus-1 97 | 98 | 99 | ebs.csi.aws.com/cluster 100 | true 101 | 102 | 103 | kubernetes.io-created-for-pv-name 104 | pvc-prometheus-1 105 | 106 | 107 | kubernetes.io-created-for-pvc-name 108 | prometheus-1 109 | 110 | 111 | kubernetes.io-created-for-pvc-namespace 112 | monitoring 113 | 114 | 115 | 116 | 117 | `)) 118 | if err != nil { 119 | t.Fatalf("unexpected error writing response: %v", err) 120 | } 121 | })) 122 | defer ts.Close() 123 | 124 | tsURL, _ := url.Parse(ts.URL) 125 | er := mockEndpointResolver(*tsURL) 126 | 127 | cfg, err := config.LoadDefaultConfig(ctx, 128 | config.WithCredentialsProvider(credentials.StaticCredentialsProvider{ 129 | Value: awsutil.Credentials{ 130 | AccessKeyID: "AKID", 131 | SecretAccessKey: "SECRET", 132 | SessionToken: "SESSION", 133 | Source: "example hard coded credentials", 134 | }, 135 | })) 136 | if err != nil { 137 | t.Fatalf("cannot load AWS config: %v", err) 138 | } 139 | 140 | p, err := aws.NewProvider(nil, ec2.NewFromConfig(cfg, ec2.WithEndpointResolverV2(ec2.EndpointResolverV2(er))), nil) 141 | if err != nil { 142 | t.Fatalf("unexpected error: %v", err) 143 | } 144 | 145 | disks, err := p.ListUnusedDisks(ctx) 146 | if err != nil { 147 | t.Fatal("unexpected error listing unused disks:", err) 148 | } 149 | 150 | if exp, got := 2, len(disks); exp != got { 151 | t.Errorf("expecting %d disks, got %d", exp, got) 152 | } 153 | 154 | err = unusedtest.AssertEqualMeta(unused.Meta{"zone": "us-east-1a"}, disks[0].Meta()) 155 | if err != nil { 156 | t.Fatalf("metadata doesn't match: %v", err) 157 | } 158 | 159 | err = unusedtest.AssertEqualMeta(unused.Meta{ 160 | "zone": "us-west-2b", 161 | "ebs.csi.aws.com/cluster": "true", 162 | "kubernetes.io-created-for-pv-name": "pvc-prometheus-1", 163 | "kubernetes.io-created-for-pvc-name": "prometheus-1", 164 | "kubernetes.io-created-for-pvc-namespace": "monitoring", 165 | }, disks[1].Meta()) 166 | if err != nil { 167 | t.Fatalf("metadata doesn't match: %v", err) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /azure/disk.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | import ( 4 | "time" 5 | 6 | compute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" 7 | "github.com/grafana/unused" 8 | ) 9 | 10 | var _ unused.Disk = &Disk{} 11 | 12 | // Disk holds information about an Azure compute disk. 13 | type Disk struct { 14 | *compute.Disk 15 | provider *Provider 16 | meta unused.Meta 17 | } 18 | 19 | // ID returns the Azure compute disk ID. 20 | func (d *Disk) ID() string { return *d.Disk.ID } 21 | 22 | // Provider returns a reference to the provider used to instantiate 23 | // this disk. 24 | func (d *Disk) Provider() unused.Provider { return d.provider } 25 | 26 | // Name returns the name of this Azure compute disk. 27 | func (d *Disk) Name() string { return *d.Disk.Name } 28 | 29 | // SizeGB returns the size of this Azure compute disk in GB. 30 | // Note that Azure uses binary GB, aka, GiB 31 | // https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.management.compute.models.datadisk.disksizegb?view=azure-dotnet-legacy 32 | func (d *Disk) SizeGB() int { return int(*d.Disk.Properties.DiskSizeGB) } 33 | 34 | // SizeBytes returns the size of this Azure compute disk in bytes. 35 | func (d *Disk) SizeBytes() float64 { return float64(*d.Disk.Properties.DiskSizeBytes) } 36 | 37 | // CreatedAt returns the time when this Azure compute disk was 38 | // created. 39 | func (d *Disk) CreatedAt() time.Time { return *d.Disk.Properties.TimeCreated } 40 | 41 | // Meta returns the disk metadata. 42 | func (d *Disk) Meta() unused.Meta { return d.meta } 43 | 44 | // LastUsedAt returns a zero [time.Time] value, as Azure does not 45 | // provide this information. 46 | func (d *Disk) LastUsedAt() time.Time { return time.Time{} } 47 | 48 | // DiskType Type returns the type of this Azure compute disk. 49 | func (d *Disk) DiskType() unused.DiskType { 50 | switch *d.Disk.SKU.Name { 51 | case compute.DiskStorageAccountTypesStandardLRS: 52 | return unused.HDD 53 | case compute.DiskStorageAccountTypesPremiumLRS, 54 | compute.DiskStorageAccountTypesStandardSSDLRS, 55 | compute.DiskStorageAccountTypesUltraSSDLRS: 56 | return unused.SSD 57 | default: 58 | return unused.Unknown 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /azure/disk_test.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | compute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" 8 | "github.com/grafana/unused" 9 | ) 10 | 11 | func TestDisk(t *testing.T) { 12 | createdAt := time.Date(2021, 7, 16, 5, 55, 00, 0, time.UTC) 13 | name := "my-disk" 14 | id := "my-disk-id" 15 | sizeGB := int32(10) 16 | sizeBytes := int64(10_737_418_240) 17 | sku := compute.DiskStorageAccountTypesStandardSSDLRS 18 | 19 | var d unused.Disk = &Disk{ 20 | &compute.Disk{ 21 | ID: &id, 22 | Name: &name, 23 | SKU: &compute.DiskSKU{ 24 | Name: &sku, 25 | }, 26 | Properties: &compute.DiskProperties{ 27 | TimeCreated: &createdAt, 28 | DiskSizeGB: &sizeGB, 29 | DiskSizeBytes: &sizeBytes, 30 | }, 31 | }, 32 | nil, 33 | nil, 34 | } 35 | 36 | if exp, got := "my-disk-id", d.ID(); exp != got { 37 | t.Errorf("expecting ID() %q, got %q", exp, got) 38 | } 39 | 40 | if exp, got := "Azure", d.Provider().Name(); exp != got { 41 | t.Errorf("expecting Provider() %q, got %q", exp, got) 42 | } 43 | 44 | if exp, got := "my-disk", d.Name(); exp != got { 45 | t.Errorf("expecting Name() %q, got %q", exp, got) 46 | } 47 | 48 | if exp, got := unused.SSD, d.DiskType(); exp != got { 49 | t.Errorf("expecting DiskType() %q, got %q", exp, got) 50 | } 51 | 52 | if !createdAt.Equal(d.CreatedAt()) { 53 | t.Errorf("expecting CreatedAt() %v, got %v", createdAt, d.CreatedAt()) 54 | } 55 | 56 | if exp, got := int(sizeGB), d.SizeGB(); exp != got { 57 | t.Errorf("expecting SizeGB() %d, got %d", exp, got) 58 | } 59 | 60 | if exp, got := float64(sizeBytes), d.SizeBytes(); exp != got { 61 | t.Errorf("expecting SizeBytes() %f, got %f", exp, got) 62 | } 63 | 64 | if !d.LastUsedAt().IsZero() { 65 | t.Errorf("Azure doesn't provide a last usage timestamp for disks, got %v", d.LastUsedAt()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /azure/provider.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | compute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" 10 | "github.com/grafana/unused" 11 | ) 12 | 13 | var ProviderName = "Azure" 14 | 15 | var _ unused.Provider = &Provider{} 16 | 17 | const ResourceGroupMetaKey = "resource-group" 18 | 19 | // Provider implements [unused.Provider] for Azure. 20 | type Provider struct { 21 | client *compute.DisksClient 22 | meta unused.Meta 23 | } 24 | 25 | // Name returns Azure. 26 | func (p *Provider) Name() string { return ProviderName } 27 | 28 | // Meta returns the provider metadata. 29 | func (p *Provider) Meta() unused.Meta { return p.meta } 30 | 31 | // ID returns the subscription for this provider. 32 | func (p *Provider) ID() string { return p.meta["SubscriptionID"] } 33 | 34 | var ErrInvalidSubscriptionID = errors.New("invalid subscription ID in metadata") 35 | 36 | // NewProvider creates a new Azure [unused.Provider]. 37 | // 38 | // A valid Azure compute disks client must be supplied in order to 39 | // list the unused resources. 40 | func NewProvider(client *compute.DisksClient, meta unused.Meta) (*Provider, error) { 41 | if meta == nil { 42 | meta = make(unused.Meta) 43 | } 44 | if sid, ok := meta["SubscriptionID"]; !ok || sid == "" { 45 | return nil, ErrInvalidSubscriptionID 46 | } 47 | 48 | return &Provider{client: client, meta: meta}, nil 49 | } 50 | 51 | // ListUnusedDisks returns all the Azure compute disks that are not 52 | // managed by other resources. 53 | func (p *Provider) ListUnusedDisks(ctx context.Context) (unused.Disks, error) { 54 | var upds unused.Disks 55 | 56 | pages := p.client.NewListPager(&compute.DisksClientListOptions{}) 57 | 58 | prefix := fmt.Sprintf("/subscriptions/%s/resourceGroups/", p.meta["SubscriptionID"]) 59 | 60 | for pages.More() { 61 | page, err := pages.NextPage(ctx) 62 | if err != nil { 63 | return nil, fmt.Errorf("listing Azure disks: %w", err) 64 | } 65 | for _, d := range page.Value { 66 | if d.ManagedBy != nil { 67 | continue 68 | } 69 | 70 | m := make(unused.Meta, len(d.Tags)+1) 71 | m["location"] = *d.Location 72 | for k, v := range d.Tags { 73 | m[k] = *v 74 | } 75 | 76 | // Azure doesn't return the resource group directly 77 | // "/subscriptions/$subscription-id/resourceGroups/$resource-group-name/providers/Microsoft.Compute/disks/$disk-name" 78 | rg := strings.TrimPrefix(*d.ID, prefix) 79 | m[ResourceGroupMetaKey] = rg[:strings.IndexRune(rg, '/')] 80 | 81 | upds = append(upds, &Disk{d, p, m}) 82 | } 83 | } 84 | 85 | return upds, nil 86 | } 87 | 88 | // Delete deletes the given disk from Azure. 89 | func (p *Provider) Delete(ctx context.Context, disk unused.Disk) error { 90 | poller, err := p.client.BeginDelete(ctx, disk.Meta()[ResourceGroupMetaKey], disk.Name(), nil) 91 | if err != nil { 92 | return fmt.Errorf("cannot delete Azure disk: failed to finish request: %w", err) 93 | } 94 | 95 | if _, err := poller.PollUntilDone(ctx, nil); err != nil { 96 | return fmt.Errorf("cannot delete Azure disk: %w", err) 97 | } 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /azure/provider_test.go: -------------------------------------------------------------------------------- 1 | package azure_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | compute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" 10 | "github.com/google/uuid" 11 | "github.com/grafana/unused" 12 | "github.com/grafana/unused/azure" 13 | "github.com/grafana/unused/unusedtest" 14 | ) 15 | 16 | func TestNewProvider(t *testing.T) { 17 | c, err := compute.NewDisksClient("my-subscription", nil, nil) 18 | if err != nil { 19 | t.Fatalf("cannot create disks client: %v", err) 20 | } 21 | p, err := azure.NewProvider(c, unused.Meta{"SubscriptionID": "my-subscription"}) 22 | if err != nil { 23 | t.Fatalf("unexpected error: %v", err) 24 | } 25 | 26 | if p == nil { 27 | t.Fatal("expecting provider") 28 | } 29 | 30 | if exp, got := "my-subscription", p.ID(); exp != got { 31 | t.Fatalf("provider id was incorrect, exp: %v, got: %v", exp, got) 32 | } 33 | } 34 | 35 | func TestProviderMeta(t *testing.T) { 36 | t.Skip("skip this test while we figure out the right way to test this provider for metadata") 37 | err := unusedtest.TestProviderMeta(func(meta unused.Meta) (unused.Provider, error) { 38 | c, err := compute.NewDisksClient("my-subscription", nil, nil) 39 | if err != nil { 40 | t.Fatalf("cannot create disks client: %v", err) 41 | } 42 | return azure.NewProvider(c, meta) 43 | }) 44 | if err != nil { 45 | t.Fatalf("unexpected error: %v", err) 46 | } 47 | } 48 | 49 | func TestListUnusedDisks(t *testing.T) { 50 | t.Skip("Azure now checks if the subscription ID exists so it fails to authenticate") 51 | // Azure is really strange when it comes to marhsaling JSON, so, 52 | // yeah, this is an awful hack. 53 | mock := func(w http.ResponseWriter, req *http.Request) { 54 | _, err := w.Write([]byte(`{ 55 | "value": [ 56 | {"name":"disk-1","managedBy":"grafana"}, 57 | {"name":"disk-2","location":"germanywestcentral","tags": { 58 | "created-by": "kubernetes-azure-dd", 59 | "kubernetes.io-created-for-pv-name": "pvc-prometheus-1", 60 | "kubernetes.io-created-for-pvc-name": "prometheus-1", 61 | "kubernetes.io-created-for-pvc-namespace": "monitoring" 62 | },"id":"/subscriptions/my-subscription/resourceGroups/RGNAME/providers/Microsoft.Compute/disks/disk-2"}, 63 | {"name":"disk-3","managedBy":"grafana"} 64 | ] 65 | }`)) 66 | if err != nil { 67 | t.Fatalf("unexpected error writing response: %v", err) 68 | } 69 | } 70 | 71 | var ( 72 | ctx = context.Background() 73 | subID = uuid.New().String() 74 | ts = httptest.NewServer(http.HandlerFunc(mock)) 75 | ) 76 | defer ts.Close() 77 | 78 | c, err := compute.NewDisksClient(subID, nil, nil) 79 | if err != nil { 80 | t.Fatalf("cannot create disks client: %v", err) 81 | } 82 | //c.BaseURI = ts.URL 83 | 84 | p, err := azure.NewProvider(c, unused.Meta{"SubscriptionID": subID}) 85 | if err != nil { 86 | t.Fatalf("unexpected error creating provider: %v", err) 87 | } 88 | 89 | disks, err := p.ListUnusedDisks(ctx) 90 | if err != nil { 91 | t.Fatalf("unexpected error: %v", err) 92 | } 93 | 94 | if exp, got := 1, len(disks); exp != got { 95 | t.Errorf("expecting %d disks, got %d", exp, got) 96 | } 97 | 98 | err = unusedtest.AssertEqualMeta(unused.Meta{ 99 | "location": "germanywestcentral", 100 | "created-by": "kubernetes-azure-dd", 101 | "kubernetes.io-created-for-pv-name": "pvc-prometheus-1", 102 | "kubernetes.io-created-for-pvc-name": "prometheus-1", 103 | "kubernetes.io-created-for-pvc-namespace": "monitoring", 104 | azure.ResourceGroupMetaKey: "RGNAME", 105 | }, disks[0].Meta()) 106 | if err != nil { 107 | t.Fatalf("metadata doesn't match: %v", err) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /cmd/internal/age.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func Age(date time.Time) string { 9 | if date.IsZero() { 10 | return "n/a" 11 | } 12 | 13 | d := time.Since(date) 14 | 15 | if d <= time.Minute { 16 | return "1m" 17 | } else if d < time.Hour { 18 | return fmt.Sprintf("%dm", d/time.Minute) 19 | } else if d < 24*time.Hour { 20 | return fmt.Sprintf("%dh", d/time.Hour) 21 | } else if d < 365*24*time.Hour { 22 | return fmt.Sprintf("%dd", d/(24*time.Hour)) 23 | } else { 24 | return fmt.Sprintf("%dy", d/(365*24*time.Hour)) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /cmd/internal/age_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/grafana/unused/cmd/internal" 8 | ) 9 | 10 | func TestAge(t *testing.T) { 11 | now := time.Now() 12 | 13 | tests := []struct { 14 | in time.Time 15 | exp string 16 | }{ 17 | {now.Add(-30 * time.Second), "1m"}, 18 | {now.Add(-30 * time.Minute), "30m"}, 19 | {now.Add(-5 * time.Hour), "5h"}, 20 | {now.Add(-7 * 24 * time.Hour), "7d"}, 21 | {now.Add(-364 * 24 * time.Hour), "364d"}, 22 | {now.Add(-600 * 24 * time.Hour), "1y"}, 23 | {now.Add(-740 * 24 * time.Hour), "2y"}, 24 | {time.Time{}, "n/a"}, 25 | } 26 | 27 | for _, tt := range tests { 28 | t.Run(tt.exp, func(t *testing.T) { 29 | if got := internal.Age(tt.in); tt.exp != got { 30 | t.Errorf("expecting Age(%s) = %s, got %s", tt.in.Format(time.RFC3339), tt.exp, got) 31 | } 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cmd/internal/providers.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "log/slog" 9 | 10 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 11 | azcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" 12 | "github.com/aws/aws-sdk-go-v2/config" 13 | "github.com/aws/aws-sdk-go-v2/service/ec2" 14 | "github.com/grafana/unused" 15 | "github.com/grafana/unused/aws" 16 | "github.com/grafana/unused/azure" 17 | "github.com/grafana/unused/gcp" 18 | compute "google.golang.org/api/compute/v1" 19 | ) 20 | 21 | var ErrNoProviders = errors.New("please select at least one provider") 22 | 23 | func CreateProviders(ctx context.Context, logger *slog.Logger, gcpProjects, awsProfiles, azureSubs []string) ([]unused.Provider, error) { 24 | providers := make([]unused.Provider, 0, len(gcpProjects)+len(awsProfiles)+len(azureSubs)) 25 | 26 | for _, projectID := range gcpProjects { 27 | svc, err := compute.NewService(ctx) 28 | if err != nil { 29 | return nil, fmt.Errorf("cannot create GCP compute service: %w", err) 30 | } 31 | p, err := gcp.NewProvider(logger, svc, projectID, map[string]string{"project": projectID}) 32 | if err != nil { 33 | return nil, fmt.Errorf("creating GCP provider for project %s: %w", projectID, err) 34 | } 35 | providers = append(providers, p) 36 | } 37 | 38 | for _, profile := range awsProfiles { 39 | cfg, err := config.LoadDefaultConfig(ctx, config.WithSharedConfigProfile(profile)) 40 | if err != nil { 41 | return nil, fmt.Errorf("cannot load AWS config for profile %s: %w", profile, err) 42 | } 43 | 44 | p, err := aws.NewProvider(logger, ec2.NewFromConfig(cfg), map[string]string{"profile": profile}) 45 | if err != nil { 46 | return nil, fmt.Errorf("creating AWS provider for profile %s: %w", profile, err) 47 | } 48 | providers = append(providers, p) 49 | } 50 | 51 | if len(azureSubs) > 0 { 52 | tc, err := azidentity.NewDefaultAzureCredential(nil) 53 | if err != nil { 54 | return nil, fmt.Errorf("fetching default Azure credential: %w", err) 55 | } 56 | 57 | for _, sub := range azureSubs { 58 | c, err := azcompute.NewDisksClient(sub, tc, nil) 59 | if err != nil { 60 | return nil, fmt.Errorf("creating Azure disks client: %w", err) 61 | } 62 | 63 | p, err := azure.NewProvider(c, map[string]string{"SubscriptionID": sub}) 64 | if err != nil { 65 | return nil, fmt.Errorf("creating Azure provider for subscription %s: %w", sub, err) 66 | } 67 | providers = append(providers, p) 68 | } 69 | } 70 | 71 | if len(providers) == 0 { 72 | return nil, ErrNoProviders 73 | } 74 | 75 | return providers, nil 76 | } 77 | 78 | // ProviderFlags adds the provider configuration flags to the given 79 | // flag set. 80 | func ProviderFlags(fs *flag.FlagSet, gcpProject, awsProfile, azureSub *StringSliceFlag) { 81 | fs.Var(gcpProject, "gcp.project", "GCP project ID (can be specified multiple times)") 82 | fs.Var(awsProfile, "aws.profile", "AWS profile (can be specified multiple times)") 83 | fs.Var(azureSub, "azure.sub", "Azure subscription (can be specified multiple times)") 84 | fs.StringVar(&gcp.ProviderName, "gcp.providername", gcp.ProviderName, `GCP provider name to use, default: "GCP" (e.g. "GKE")`) 85 | fs.StringVar(&aws.ProviderName, "aws.providername", aws.ProviderName, `AWS provider name to use, default: "AWS" (e.g. "EKS")`) 86 | fs.StringVar(&azure.ProviderName, "azure.providername", azure.ProviderName, `Azure provider name to use, default: "Azure" (e.g. "AKS")`) 87 | } 88 | -------------------------------------------------------------------------------- /cmd/internal/providers_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "io" 8 | "log/slog" 9 | "os" 10 | "testing" 11 | 12 | "github.com/grafana/unused/aws" 13 | "github.com/grafana/unused/azure" 14 | "github.com/grafana/unused/cmd/internal" 15 | "github.com/grafana/unused/gcp" 16 | ) 17 | 18 | func TestCreateProviders(t *testing.T) { 19 | l := slog.New(slog.NewTextHandler(io.Discard, nil)) 20 | 21 | t.Run("fail when no provider is given", func(t *testing.T) { 22 | ps, err := internal.CreateProviders(context.Background(), l, nil, nil, nil) 23 | 24 | if !errors.Is(err, internal.ErrNoProviders) { 25 | t.Fatalf("expecting error %v, got %v", internal.ErrNoProviders, err) 26 | } 27 | if ps != nil { 28 | t.Fatalf("expecting nil providers, got %v", ps) 29 | } 30 | }) 31 | 32 | if os.Getenv("CI") == "true" { 33 | t.Skip("the following tests need authentication") // TODO 34 | } 35 | 36 | t.Run("GCP", func(t *testing.T) { 37 | ps, err := internal.CreateProviders(context.Background(), l, []string{"foo", "bar"}, nil, nil) 38 | if err != nil { 39 | t.Fatalf("unexpected error: %v", err) 40 | } 41 | if len(ps) != 2 { 42 | t.Fatalf("expecting 2 providers, got %d", len(ps)) 43 | } 44 | 45 | for _, p := range ps { 46 | if _, ok := p.(*gcp.Provider); !ok { 47 | t.Fatalf("expecting *gcp.Provider, got %T", p) 48 | } 49 | } 50 | }) 51 | 52 | t.Run("AWS", func(t *testing.T) { 53 | t.Skip("AWS now fails when it cannot find the profile in the configuration") 54 | ps, err := internal.CreateProviders(context.Background(), l, nil, []string{"foo", "bar"}, nil) 55 | if err != nil { 56 | t.Fatalf("unexpected error: %v", err) 57 | } 58 | if len(ps) != 2 { 59 | t.Fatalf("expecting 2 providers, got %d", len(ps)) 60 | } 61 | 62 | for _, p := range ps { 63 | if _, ok := p.(*aws.Provider); !ok { 64 | t.Fatalf("expecting *aws.Provider, got %T", p) 65 | } 66 | } 67 | }) 68 | 69 | t.Run("Azure", func(t *testing.T) { 70 | ps, err := internal.CreateProviders(context.Background(), l, nil, nil, []string{"foo", "bar"}) 71 | if err != nil { 72 | t.Fatalf("unexpected error: %v", err) 73 | } 74 | if len(ps) != 2 { 75 | t.Fatalf("expecting 2 providers, got %d", len(ps)) 76 | } 77 | 78 | for _, p := range ps { 79 | if _, ok := p.(*azure.Provider); !ok { 80 | t.Fatalf("expecting *azure.Provider, got %T", p) 81 | } 82 | } 83 | }) 84 | } 85 | 86 | func TestProviderFlags(t *testing.T) { 87 | var gcpProject, awsProfile, azureSub internal.StringSliceFlag 88 | 89 | fs := flag.NewFlagSet("test", flag.ContinueOnError) 90 | fs.SetOutput(io.Discard) 91 | fs.Usage = func() {} 92 | 93 | internal.ProviderFlags(fs, &gcpProject, &awsProfile, &azureSub) 94 | 95 | args := []string{ 96 | "-gcp.project=my-project", 97 | "-azure.sub=my-subscription", 98 | "-aws.profile=my-profile", 99 | "-gcp.providername=GKE", 100 | "-azure.providername=AKS", 101 | "-aws.providername=EKS", 102 | } 103 | 104 | if err := fs.Parse(args); err != nil { 105 | t.Fatalf("unexpected error: %v", err) 106 | } 107 | 108 | testSlices := map[*internal.StringSliceFlag]string{ 109 | &gcpProject: "my-project", 110 | &awsProfile: "my-profile", 111 | &azureSub: "my-subscription", 112 | } 113 | testStrings := map[*string]string{ 114 | &gcp.ProviderName: "GKE", 115 | &aws.ProviderName: "EKS", 116 | &azure.ProviderName: "AKS", 117 | } 118 | 119 | for v, exp := range testSlices { 120 | if len(*v) != 1 || (*v)[0] != exp { 121 | t.Errorf("expecting one value (%q), got %v", exp, v) 122 | } 123 | } 124 | for v, exp := range testStrings { 125 | if *v != exp { 126 | t.Errorf("expecting %q, got %v", exp, v) 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /cmd/internal/string_slice_flag.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "strings" 4 | 5 | type StringSliceFlag []string 6 | 7 | func (s *StringSliceFlag) String() string { return strings.Join(*s, ",") } 8 | 9 | func (s *StringSliceFlag) Set(v string) error { 10 | *s = append(*s, v) 11 | return nil 12 | } 13 | -------------------------------------------------------------------------------- /cmd/internal/string_slice_flag_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "flag" 5 | "testing" 6 | ) 7 | 8 | func TestStringSliceFlag(t *testing.T) { 9 | var vs StringSliceFlag 10 | 11 | fs := flag.NewFlagSet("test", flag.ContinueOnError) 12 | fs.Var(&vs, "ss", "test") 13 | 14 | if err := fs.Parse([]string{"-ss", "foo", "-ss=bar"}); err != nil { 15 | t.Fatalf("unexpected error: %v", err) 16 | } 17 | 18 | if exp, got := 2, len(vs); exp != got { 19 | t.Errorf("expecting %d values, got %d", exp, got) 20 | t.Errorf("%q", vs) 21 | } 22 | } 23 | 24 | func TestStringSliceSet(t *testing.T) { 25 | ss := &StringSliceFlag{} 26 | 27 | for _, v := range []string{"foo", "bar"} { 28 | if err := ss.Set(v); err != nil { 29 | t.Fatalf("unexpected error setting %s: %v", v, err) 30 | } 31 | } 32 | 33 | if exp, got := "foo,bar", ss.String(); exp != got { 34 | t.Errorf("expecting %q, got %q", exp, got) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /cmd/unused-exporter/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "time" 6 | 7 | "github.com/grafana/unused/cmd/internal" 8 | ) 9 | 10 | type config struct { 11 | Providers struct { 12 | GCP internal.StringSliceFlag 13 | AWS internal.StringSliceFlag 14 | Azure internal.StringSliceFlag 15 | } 16 | 17 | Web struct { 18 | Address string 19 | Path string 20 | Timeout time.Duration 21 | } 22 | 23 | Collector struct { 24 | Timeout time.Duration 25 | PollInterval time.Duration 26 | } 27 | 28 | Logger *slog.Logger 29 | VerboseLogging bool 30 | } 31 | -------------------------------------------------------------------------------- /cmd/unused-exporter/exporter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/grafana/unused" 12 | "github.com/grafana/unused/aws" 13 | "github.com/grafana/unused/azure" 14 | "github.com/grafana/unused/gcp" 15 | "github.com/prometheus/client_golang/prometheus" 16 | ) 17 | 18 | const namespace = "unused" 19 | 20 | type metric struct { 21 | desc *prometheus.Desc 22 | value float64 23 | labels []string 24 | } 25 | 26 | type exporter struct { 27 | ctx context.Context 28 | logger *slog.Logger 29 | verbose bool 30 | 31 | timeout time.Duration 32 | pollInterval time.Duration 33 | 34 | providers []unused.Provider 35 | 36 | info *prometheus.Desc 37 | count *prometheus.Desc 38 | ds *prometheus.Desc 39 | size *prometheus.Desc 40 | dur *prometheus.Desc 41 | suc *prometheus.Desc 42 | dlu *prometheus.Desc 43 | 44 | mu sync.RWMutex 45 | cache map[unused.Provider][]metric 46 | } 47 | 48 | func registerExporter(ctx context.Context, providers []unused.Provider, cfg config) error { 49 | labels := []string{"provider", "provider_id"} 50 | 51 | e := &exporter{ 52 | ctx: ctx, 53 | logger: cfg.Logger, 54 | verbose: cfg.VerboseLogging, 55 | providers: providers, 56 | timeout: cfg.Collector.Timeout, 57 | pollInterval: cfg.Collector.PollInterval, 58 | 59 | info: prometheus.NewDesc( 60 | prometheus.BuildFQName(namespace, "provider", "info"), 61 | "CSP information", 62 | labels, 63 | nil), 64 | 65 | count: prometheus.NewDesc( 66 | prometheus.BuildFQName(namespace, "disks", "count"), 67 | "How many unused disks are in this provider", 68 | append(labels, "k8s_namespace"), 69 | nil), 70 | ds: prometheus.NewDesc( 71 | prometheus.BuildFQName(namespace, "disk", "size_bytes"), 72 | "Disk size in bytes", 73 | append(labels, []string{"disk", "created_for_pv", "k8s_namespace", "type", "region", "zone"}...), 74 | nil), 75 | 76 | size: prometheus.NewDesc( 77 | prometheus.BuildFQName(namespace, "disks", "total_size_bytes"), 78 | "Total size of unused disks in this provider in bytes", 79 | append(labels, "k8s_namespace", "type"), 80 | nil), 81 | 82 | dur: prometheus.NewDesc( 83 | prometheus.BuildFQName(namespace, "provider", "duration_seconds"), 84 | "How long in seconds took to fetch this provider information", 85 | labels, 86 | nil), 87 | 88 | suc: prometheus.NewDesc( 89 | prometheus.BuildFQName(namespace, "provider", "success"), 90 | "Static metric indicating if collecting the metrics succeeded or not", 91 | labels, 92 | nil), 93 | 94 | dlu: prometheus.NewDesc( 95 | prometheus.BuildFQName(namespace, "disks", "last_used_timestamp_seconds"), 96 | "Kubernetes metadata associated with each unused disk, with the value as the last time the disk was used (if available)", 97 | append(labels, []string{"disk", "created_for_pv", "created_for_pvc", "zone"}...), 98 | nil), 99 | 100 | cache: make(map[unused.Provider][]metric, len(providers)), 101 | } 102 | 103 | e.logger.Info("start background polling of providers", 104 | slog.Int("providers", len(e.providers)), 105 | slog.Duration("interval", e.pollInterval), 106 | slog.Duration("timeout", e.timeout), 107 | ) 108 | 109 | for _, p := range providers { 110 | p := p 111 | go e.pollProvider(p) 112 | } 113 | 114 | return prometheus.Register(e) 115 | } 116 | 117 | func (e *exporter) Describe(ch chan<- *prometheus.Desc) { 118 | ch <- e.info 119 | ch <- e.count 120 | ch <- e.size 121 | ch <- e.dur 122 | ch <- e.dlu 123 | } 124 | 125 | type namespaceInfo struct { 126 | Count int 127 | SizeByType map[unused.DiskType]float64 128 | } 129 | 130 | func (e *exporter) pollProvider(p unused.Provider) { 131 | tick := time.NewTicker(e.pollInterval) 132 | defer tick.Stop() 133 | 134 | for { 135 | select { 136 | case <-e.ctx.Done(): // parent context was cancelled 137 | return 138 | 139 | default: 140 | // we don't wait for tick.C here as we want to start 141 | // polling immediately; we wait at the end. 142 | 143 | var ( 144 | success int64 = 1 145 | 146 | logger = e.logger.With( 147 | slog.String("provider", strings.ToLower(p.Name())), 148 | slog.String("provider_id", p.ID()), 149 | ) 150 | ) 151 | 152 | logger.Info("collecting metrics") 153 | ctx, cancel := context.WithTimeout(e.ctx, e.timeout) 154 | start := time.Now() 155 | disks, err := p.ListUnusedDisks(ctx) 156 | cancel() // release resources early 157 | dur := time.Since(start) 158 | if err != nil { 159 | logger.Error("failed to collect metrics", slog.String("error", err.Error())) 160 | success = 0 161 | } 162 | 163 | diskInfoByNamespace := make(map[string]*namespaceInfo) 164 | var ms []metric 165 | 166 | for _, d := range disks { 167 | diskLabels := getDiskLabels(d, e.verbose) 168 | e.logger.Info("unused disk found", diskLabels...) 169 | 170 | ns := getNamespace(d, p) 171 | di := diskInfoByNamespace[ns] 172 | if di == nil { 173 | di = &namespaceInfo{ 174 | SizeByType: make(map[unused.DiskType]float64), 175 | } 176 | diskInfoByNamespace[ns] = di 177 | } 178 | di.Count += 1 179 | di.SizeByType[d.DiskType()] += float64(d.SizeBytes()) 180 | 181 | e.logger.Info(fmt.Sprintf("Disk %s last used at %v", d.Name(), d.LastUsedAt())) 182 | 183 | m := d.Meta() 184 | if m.CreatedForPV() == "" { 185 | continue 186 | } 187 | 188 | addMetric(&ms, p, e.dlu, lastUsedTS(d), d.ID(), m.CreatedForPV(), m.CreatedForPVC(), m.Zone()) 189 | addMetric(&ms, p, e.ds, d.SizeBytes(), d.ID(), m.CreatedForPV(), ns, string(d.DiskType()), getRegionFromZone(p, m.Zone()), m.Zone()) 190 | } 191 | 192 | addMetric(&ms, p, e.info, 1) 193 | addMetric(&ms, p, e.dur, float64(dur.Seconds())) 194 | addMetric(&ms, p, e.suc, float64(success)) 195 | 196 | for ns, di := range diskInfoByNamespace { 197 | addMetric(&ms, p, e.count, float64(di.Count), ns) 198 | for diskType, diskSize := range di.SizeByType { 199 | addMetric(&ms, p, e.size, diskSize, ns, string(diskType)) 200 | } 201 | } 202 | 203 | e.mu.Lock() 204 | e.cache[p] = ms 205 | e.mu.Unlock() 206 | 207 | logger.Info("metrics collected", 208 | slog.Int("metrics", len(ms)), 209 | slog.Bool("success", success == 1), 210 | slog.Duration("dur", dur), 211 | ) 212 | 213 | <-tick.C 214 | } 215 | 216 | } 217 | } 218 | 219 | func (e *exporter) Collect(ch chan<- prometheus.Metric) { 220 | e.mu.RLock() 221 | defer e.mu.RUnlock() 222 | 223 | for p, ms := range e.cache { 224 | labels := []any{ 225 | slog.String("provider", p.Name()), 226 | slog.String("provider_id", p.ID()), 227 | slog.Int("metrics", len(ms)), 228 | } 229 | 230 | if e.verbose { 231 | providerMeta := p.Meta() 232 | providerMetaLabels := make([]any, 0, len(providerMeta)) 233 | for _, k := range providerMeta.Keys() { 234 | providerMetaLabels = append(providerMetaLabels, slog.String(k, providerMeta[k])) 235 | } 236 | labels = append(labels, providerMetaLabels...) 237 | } 238 | 239 | e.logger.Info("reading provider cache", labels...) 240 | 241 | for _, m := range ms { 242 | ch <- prometheus.MustNewConstMetric(m.desc, prometheus.GaugeValue, m.value, m.labels...) 243 | } 244 | } 245 | } 246 | 247 | func getDiskLabels(d unused.Disk, v bool) []any { 248 | diskLabels := []any{ 249 | slog.String("name", d.Name()), 250 | slog.Int("size_gb", d.SizeGB()), 251 | slog.Time("created", d.CreatedAt()), 252 | } 253 | 254 | if v { 255 | meta := d.Meta() 256 | diskMetaLabels := make([]any, 0, len(meta)) 257 | for _, k := range meta.Keys() { 258 | diskMetaLabels = append(diskMetaLabels, slog.String(k, meta[k])) 259 | } 260 | diskLabels = append(diskLabels, diskMetaLabels...) 261 | } 262 | 263 | return diskLabels 264 | } 265 | 266 | func getNamespace(d unused.Disk, p unused.Provider) string { 267 | switch p.Name() { 268 | case gcp.ProviderName: 269 | return d.Meta()["kubernetes.io/created-for/pvc/namespace"] 270 | case aws.ProviderName: 271 | return d.Meta()["kubernetes.io/created-for/pvc/namespace"] 272 | case azure.ProviderName: 273 | return d.Meta()["kubernetes.io-created-for-pvc-namespace"] 274 | default: 275 | panic("getNamespace(): unrecognized provider name:" + p.Name()) 276 | } 277 | } 278 | 279 | func addMetric(ms *[]metric, p unused.Provider, d *prometheus.Desc, v float64, lbls ...string) { 280 | *ms = append(*ms, metric{ 281 | desc: d, 282 | value: v, 283 | labels: append([]string{strings.ToLower(p.Name()), p.ID()}, lbls...), 284 | }) 285 | } 286 | 287 | func lastUsedTS(d unused.Disk) float64 { 288 | lastUsed := d.LastUsedAt() 289 | if lastUsed.IsZero() { 290 | return 0 291 | } 292 | 293 | return float64(lastUsed.Unix()) 294 | } 295 | 296 | func getRegionFromZone(p unused.Provider, z string) string { 297 | switch p.Name() { 298 | case gcp.ProviderName: 299 | return z[:strings.LastIndex(z, "-")] 300 | case aws.ProviderName: 301 | return z[:len(z)-1] 302 | case azure.ProviderName: 303 | return z 304 | default: 305 | panic("getRegionFromZone(): unrecognized provider name:" + p.Name()) 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /cmd/unused-exporter/exporter_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/grafana/unused" 10 | "github.com/grafana/unused/aws" 11 | "github.com/grafana/unused/azure" 12 | "github.com/grafana/unused/gcp" 13 | ) 14 | 15 | type MockProvider struct { 16 | unused.Provider 17 | 18 | name string 19 | } 20 | 21 | func (m MockProvider) Name() string { return m.name } 22 | 23 | type MockDisk struct { 24 | unused.Disk 25 | name string 26 | sizeGB int 27 | createdAt time.Time 28 | meta map[string]string 29 | } 30 | 31 | func (d *MockDisk) Name() string { 32 | return d.name 33 | } 34 | 35 | func (d *MockDisk) Meta() unused.Meta { 36 | return d.meta 37 | } 38 | 39 | func (d *MockDisk) CreatedAt() time.Time { 40 | return d.createdAt 41 | } 42 | 43 | func (d *MockDisk) SizeGB() int { 44 | return d.sizeGB 45 | } 46 | 47 | func TestGetRegionFromZone(t *testing.T) { 48 | type testCase struct { 49 | provider string 50 | zone string 51 | expected string 52 | } 53 | 54 | testCases := map[string]testCase{ 55 | "Azure": {azure.ProviderName, "eastus1", "eastus1"}, 56 | "GCP": {gcp.ProviderName, "us-central1-a", "us-central1"}, 57 | "AWS": {aws.ProviderName, "us-west-2a", "us-west-2"}, 58 | } 59 | 60 | for n, tc := range testCases { 61 | t.Run(n, func(t *testing.T) { 62 | p := &MockProvider{name: tc.provider} 63 | result := getRegionFromZone(p, tc.zone) 64 | if result != tc.expected { 65 | t.Errorf("getRegionFromZone(%s, %s) = %s, expected %s", tc.provider, tc.zone, result, tc.expected) 66 | } 67 | }) 68 | } 69 | } 70 | 71 | func TestGetNamespace(t *testing.T) { 72 | type testCase struct { 73 | provider string 74 | diskMeta map[string]string 75 | expected string 76 | } 77 | 78 | testCases := map[string]testCase{ 79 | "Azure": { 80 | provider: azure.ProviderName, 81 | diskMeta: map[string]string{ 82 | "kubernetes.io-created-for-pvc-namespace": "azure-namespace", 83 | }, 84 | expected: "azure-namespace", 85 | }, 86 | "GCP": { 87 | provider: gcp.ProviderName, 88 | diskMeta: map[string]string{ 89 | "kubernetes.io/created-for/pvc/namespace": "gcp-namespace", 90 | }, 91 | expected: "gcp-namespace", 92 | }, 93 | "AWS": { 94 | provider: aws.ProviderName, 95 | diskMeta: map[string]string{ 96 | "kubernetes.io/created-for/pvc/namespace": "aws-namespace", 97 | }, 98 | expected: "aws-namespace", 99 | }, 100 | } 101 | 102 | for n, tc := range testCases { 103 | t.Run(n, func(t *testing.T) { 104 | p := &MockProvider{name: tc.provider} 105 | d := &MockDisk{meta: tc.diskMeta} 106 | result := getNamespace(d, p) 107 | if result != tc.expected { 108 | t.Errorf("getNamespace(%v, %v) = %s, expected %s", d, p, result, tc.expected) 109 | } 110 | }) 111 | } 112 | } 113 | 114 | func TestGetDiskLabels(t *testing.T) { 115 | type testCase struct { 116 | verbose bool 117 | disk *MockDisk 118 | expected []any 119 | } 120 | 121 | createdAt := time.Now() 122 | 123 | testCases := map[string]testCase{ 124 | "Basic Disk Labels": { 125 | verbose: false, 126 | disk: &MockDisk{ 127 | name: "test-disk", 128 | sizeGB: 100, 129 | createdAt: createdAt, 130 | meta: map[string]string{}, 131 | }, 132 | expected: []any{ 133 | slog.String("name", "test-disk"), 134 | slog.Int("size_gb", 100), 135 | slog.Time("created", createdAt), 136 | }, 137 | }, 138 | "Verbose Disk Labels": { 139 | verbose: true, 140 | disk: &MockDisk{ 141 | name: "test-disk", 142 | sizeGB: 100, 143 | createdAt: createdAt, 144 | meta: map[string]string{ 145 | "key1": "value1", 146 | "key2": "value2", 147 | }, 148 | }, 149 | expected: []any{ 150 | slog.String("name", "test-disk"), 151 | slog.Int("size_gb", 100), 152 | slog.Time("created", createdAt), 153 | slog.String("key1", "value1"), 154 | slog.String("key2", "value2"), 155 | }, 156 | }, 157 | } 158 | 159 | for n, tc := range testCases { 160 | t.Run(n, func(t *testing.T) { 161 | actual := getDiskLabels(tc.disk, tc.verbose) 162 | if !reflect.DeepEqual(actual, tc.expected) { 163 | t.Errorf("getDiskLabels(%v, %v) = %v, expected %v", tc.disk, tc.verbose, actual, tc.expected) 164 | } 165 | }) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /cmd/unused-exporter/main.go: -------------------------------------------------------------------------------- 1 | // unused-exporter is a Prometheus exporter with a web interface to 2 | // expose unused disks as metrics. 3 | // 4 | // Provider selection is opinionated, currently accepting the 5 | // following authentication method for each provider: 6 | // - GCP: pass gcp.project with a valid GCP project ID. 7 | // - AWS: pass aws.profile with a valid AWS shared profile. 8 | // - Azure: pass azure.sub with a valid Azure subscription ID. 9 | package main 10 | 11 | import ( 12 | "context" 13 | "flag" 14 | "fmt" 15 | "log/slog" 16 | "os" 17 | "os/signal" 18 | "time" 19 | 20 | "github.com/grafana/unused/cmd/internal" 21 | ) 22 | 23 | func main() { 24 | cfg := config{ 25 | Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), 26 | } 27 | 28 | internal.ProviderFlags(flag.CommandLine, &cfg.Providers.GCP, &cfg.Providers.AWS, &cfg.Providers.Azure) 29 | 30 | flag.BoolVar(&cfg.VerboseLogging, "verbose", false, "add verbose logging information") 31 | flag.DurationVar(&cfg.Collector.Timeout, "collect.timeout", 30*time.Second, "timeout for collecting metrics from each provider") 32 | flag.StringVar(&cfg.Web.Path, "web.path", "/metrics", "path on which to expose metrics") 33 | flag.StringVar(&cfg.Web.Address, "web.address", ":8080", "address to expose metrics and web interface") 34 | flag.DurationVar(&cfg.Web.Timeout, "web.timeout", 5*time.Second, "timeout for shutting down the server") 35 | flag.DurationVar(&cfg.Collector.PollInterval, "collect.interval", 5*time.Minute, "interval to poll the cloud provider API for unused disks") 36 | 37 | flag.Parse() 38 | 39 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 40 | defer cancel() 41 | 42 | if err := realMain(ctx, cfg); err != nil { 43 | cancel() // cleanup resources 44 | fmt.Fprintln(os.Stderr, err) 45 | os.Exit(1) 46 | } 47 | } 48 | 49 | func realMain(ctx context.Context, cfg config) error { 50 | providers, err := internal.CreateProviders(ctx, cfg.Logger, cfg.Providers.GCP, cfg.Providers.AWS, cfg.Providers.Azure) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if err := registerExporter(ctx, providers, cfg); err != nil { 56 | return fmt.Errorf("registering exporter: %w", err) 57 | } 58 | 59 | if err := runWebServer(ctx, cfg); err != nil { 60 | return fmt.Errorf("running web server: %w", err) 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /cmd/unused-exporter/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/prometheus/client_golang/prometheus/promhttp" 12 | ) 13 | 14 | func runWebServer(ctx context.Context, cfg config) error { 15 | mux := http.NewServeMux() 16 | promHandler := promhttp.Handler() 17 | mux.HandleFunc(cfg.Web.Path, func(w http.ResponseWriter, req *http.Request) { 18 | start := time.Now() 19 | promHandler.ServeHTTP(w, req) 20 | cfg.Logger.Info("Prometheus query", 21 | slog.String("path", cfg.Web.Path), 22 | slog.Duration("dur", time.Since(start)), 23 | ) 24 | }) 25 | mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { 26 | if req.URL.Path == "/" { 27 | fmt.Fprintf(w, indexTemplate, cfg.Web.Path) // nolint:errcheck 28 | } else { 29 | http.NotFound(w, req) 30 | } 31 | }) 32 | 33 | srv := &http.Server{ 34 | ReadTimeout: 1 * time.Second, 35 | Addr: cfg.Web.Address, 36 | Handler: mux, 37 | } 38 | 39 | listenErr := make(chan error) 40 | 41 | go func() { 42 | cfg.Logger.Info("starting server", 43 | slog.String("addr", cfg.Web.Address), 44 | slog.String("metricspath", cfg.Web.Path), 45 | ) 46 | listenErr <- srv.ListenAndServe() 47 | }() 48 | 49 | select { 50 | case <-ctx.Done(): 51 | cfg.Logger.Info("shutting down server") 52 | ctx, cancel := context.WithTimeout(context.Background(), cfg.Web.Timeout) 53 | defer cancel() 54 | 55 | if err := srv.Shutdown(ctx); err != nil { 56 | return fmt.Errorf("shutting down server: %w", err) 57 | } 58 | 59 | case err := <-listenErr: 60 | if !errors.Is(err, http.ErrServerClosed) { 61 | return fmt.Errorf("running server: %w", err) 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | 68 | const indexTemplate = ` 69 | 70 | Unused Disks Exporter 71 | 72 |

Unused Disks Exporter

73 |

Metrics

74 | 75 | ` 76 | -------------------------------------------------------------------------------- /cmd/unused/internal/ui/group_table.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "sort" 8 | "strconv" 9 | "strings" 10 | "text/tabwriter" 11 | 12 | "github.com/grafana/unused" 13 | ) 14 | 15 | // Disks are aggregated by the key composed by these 3 strings: 16 | // 1. Provider 17 | // 2. Value of the tag from disk metadata 18 | // Name of key passed in the options.Group 19 | // If requested key is absent in disk metadata, use value "NONE" 20 | // 3. Disk type: "hdd", "ssd" or "unknown" 21 | type groupKey [3]string 22 | 23 | func GroupTable(ctx context.Context, options Options) error { 24 | disks, err := listUnusedDisks(ctx, options.Providers) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | if options.Filter.Key != "" { 30 | filtered := make(unused.Disks, 0, len(disks)) 31 | for _, d := range disks { 32 | if d.Meta().Matches(options.Filter.Key, options.Filter.Value) { 33 | filtered = append(filtered, d) 34 | } 35 | } 36 | disks = filtered 37 | } 38 | 39 | if len(disks) == 0 { 40 | fmt.Println("No disks found") 41 | return nil 42 | } 43 | 44 | w := tabwriter.NewWriter(os.Stdout, 8, 4, 2, ' ', 0) 45 | 46 | headers := []string{"PROVIDER", options.Group, "TYPE", "DISKS_COUNT", "TOTAL_SIZE_GB"} 47 | totalSize := make(map[groupKey]int) 48 | totalCount := make(map[groupKey]int) 49 | 50 | fmt.Fprintln(w, strings.Join(headers, "\t")) // nolint:errcheck 51 | 52 | var aggrValue string 53 | for _, d := range disks { 54 | p := d.Provider() 55 | if value, ok := d.Meta()[options.Group]; ok { 56 | aggrValue = value 57 | } else { 58 | aggrValue = "NONE" 59 | } 60 | aggrKey := groupKey{p.Name(), aggrValue, string(d.DiskType())} 61 | totalSize[aggrKey] += d.SizeGB() 62 | totalCount[aggrKey] += 1 63 | } 64 | 65 | keys := make([]groupKey, 0, len(totalSize)) 66 | for k := range totalSize { 67 | keys = append(keys, k) 68 | } 69 | 70 | sort.Slice(keys, func(i, j int) bool { 71 | for k := 0; k < len(keys[i]); k++ { 72 | if keys[i][k] != keys[j][k] { 73 | return keys[i][k] < keys[j][k] 74 | } 75 | } 76 | return true 77 | }) 78 | 79 | for _, aggrKey := range keys { 80 | row := aggrKey[:] 81 | row = append(row, strconv.Itoa(totalCount[aggrKey]), strconv.Itoa(totalSize[aggrKey])) 82 | fmt.Fprintln(w, strings.Join(row, "\t")) // nolint:errcheck 83 | } 84 | 85 | if err := w.Flush(); err != nil { 86 | return fmt.Errorf("flushing table contents: %w", err) 87 | } 88 | 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /cmd/unused/internal/ui/interactive.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/grafana/unused/cmd/unused/internal/ui/interactive" 9 | ) 10 | 11 | func Interactive(ctx context.Context, options Options) error { 12 | m := interactive.New(options.Providers, options.ExtraColumns, options.Filter.Key, options.Filter.Value, options.DryRun) 13 | 14 | if _, err := tea.NewProgram(m).Run(); err != nil { 15 | return fmt.Errorf("cannot start interactive UI: %w", err) 16 | } 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /cmd/unused/internal/ui/interactive/delete_view.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/key" 10 | "github.com/charmbracelet/bubbles/spinner" 11 | "github.com/charmbracelet/bubbles/viewport" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | "github.com/grafana/unused" 15 | ) 16 | 17 | type deleteViewModel struct { 18 | output viewport.Model 19 | help help.Model 20 | w, h int 21 | confirm key.Binding 22 | toggle key.Binding 23 | provider unused.Provider 24 | spinner spinner.Model 25 | delete bool 26 | disks unused.Disks 27 | cur int 28 | status []*deleteStatus 29 | dryRun bool 30 | } 31 | 32 | func newDeleteViewModel(dryRun bool) deleteViewModel { 33 | return deleteViewModel{ 34 | help: newHelp(), 35 | confirm: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "confirm delete")), 36 | toggle: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "toggle dry-run")), 37 | spinner: spinner.New(), 38 | dryRun: dryRun, 39 | } 40 | } 41 | 42 | func (m deleteViewModel) WithDisks(provider unused.Provider, disks unused.Disks) deleteViewModel { 43 | m.provider = provider 44 | m.disks = disks 45 | m.status = make([]*deleteStatus, len(disks)) 46 | m.cur = 0 47 | return m 48 | } 49 | 50 | type deleteNextMsg struct{} 51 | 52 | func (m deleteViewModel) Update(msg tea.Msg) (deleteViewModel, tea.Cmd) { 53 | var cmd tea.Cmd 54 | 55 | switch msg := msg.(type) { 56 | case tea.KeyMsg: 57 | switch { 58 | case key.Matches(msg, m.confirm): 59 | m.delete = true 60 | cmd = tea.Batch(m.spinner.Tick, sendMsg(deleteNextMsg{})) 61 | 62 | case key.Matches(msg, m.toggle): 63 | m.dryRun = !m.dryRun 64 | } 65 | 66 | case deleteNextMsg: 67 | if m.cur == len(m.disks) { 68 | m.delete = false 69 | return m, nil 70 | } 71 | 72 | ds := m.status[m.cur] 73 | if ds == nil { 74 | ds = &deleteStatus{} 75 | m.status[m.cur] = ds 76 | 77 | return m, deleteDisk(m.disks[m.cur], ds, m.dryRun) 78 | } else if ds.done { 79 | m.cur++ 80 | } 81 | 82 | cmd = tea.Batch(m.spinner.Tick, sendMsg(deleteNextMsg{})) 83 | 84 | case spinner.TickMsg: 85 | m.spinner, cmd = m.spinner.Update(msg) 86 | } 87 | 88 | return m, cmd 89 | } 90 | 91 | type deleteStatus struct { 92 | done bool 93 | err error 94 | } 95 | 96 | func deleteDisk(d unused.Disk, s *deleteStatus, dryRun bool) tea.Cmd { 97 | return func() tea.Msg { 98 | if !dryRun { 99 | s.err = d.Provider().Delete(context.TODO(), d) 100 | } 101 | 102 | s.done = true 103 | 104 | return deleteNextMsg{} 105 | } 106 | } 107 | 108 | var bold = lipgloss.NewStyle().Bold(true) 109 | 110 | func (m deleteViewModel) View() string { 111 | sb := &strings.Builder{} 112 | 113 | if m.delete { 114 | fmt.Fprintf(sb, "Deleting %d/%d disks from %s %s\n\n", m.cur+1, len(m.disks), m.provider.Name(), m.provider.Meta().String()) 115 | } else if m.cur == len(m.disks) { 116 | fmt.Fprintf(sb, "Deleted %d disks from %s %s\n\n", len(m.disks), m.provider.Name(), m.provider.Meta().String()) 117 | } else { 118 | fmt.Fprintf(sb, "You're about to delete %d disks from %s %s\n\n", len(m.disks), m.provider.Name(), m.provider.Meta()) 119 | 120 | if m.dryRun { 121 | fmt.Fprintln(sb, bold.Render("DISKS WON'T BE DELETED BECAUSE DRY-RUN MODE IS ENABLED")) 122 | } else { 123 | fmt.Fprintln(sb, bold.Render("Press `x` to start deleting the following disks:")) 124 | } 125 | 126 | fmt.Fprintf(sb, "\n") 127 | } 128 | 129 | for i, d := range m.disks { 130 | s := m.status[i] 131 | 132 | switch { 133 | case s == nil: 134 | fmt.Fprintf(sb, " %s\n", d.Name()) 135 | 136 | case m.cur == i: 137 | fmt.Fprintf(sb, "➤ %s %s\n", d.Name(), m.spinner.View()) 138 | 139 | case !s.done: 140 | 141 | case s.err != nil: 142 | fmt.Fprintf(sb, "𐄂 %s\n %s\n", d.Name(), errorStyle.Render(s.err.Error())) 143 | 144 | default: 145 | fmt.Fprintf(sb, "✓ %s\n", d.Name()) 146 | } 147 | } 148 | 149 | m.output.SetContent(sb.String()) 150 | 151 | return lipgloss.JoinVertical(lipgloss.Left, m.output.View(), m.help.View(m)) 152 | } 153 | 154 | func (m deleteViewModel) ShortHelp() []key.Binding { 155 | return []key.Binding{navKeys.Quit, m.confirm, m.toggle, navKeys.Back} 156 | } 157 | 158 | func (m deleteViewModel) FullHelp() [][]key.Binding { 159 | return [][]key.Binding{m.ShortHelp()} 160 | } 161 | 162 | func (m *deleteViewModel) resetSize() { 163 | hh := lipgloss.Height(m.help.View(m)) 164 | m.output.Width, m.output.Height = m.w, m.h-hh 165 | m.help.Width = m.w 166 | } 167 | 168 | func (m *deleteViewModel) SetSize(w, h int) { 169 | m.w, m.h = w, h 170 | m.resetSize() 171 | } 172 | -------------------------------------------------------------------------------- /cmd/unused/internal/ui/interactive/keys.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | var navKeys = struct { 6 | Quit, Up, Down, PageUp, PageDown, Home, End, Back key.Binding 7 | }{ 8 | Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), 9 | Up: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), 10 | Down: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), 11 | PageUp: key.NewBinding(key.WithKeys("pgup", "right"), key.WithHelp("→", "page up")), 12 | PageDown: key.NewBinding(key.WithKeys("pgdown", "left"), key.WithHelp("←", "page down")), 13 | Home: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "first")), 14 | End: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "last")), 15 | Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("", "back")), 16 | } 17 | -------------------------------------------------------------------------------- /cmd/unused/internal/ui/interactive/model.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/charmbracelet/bubbles/help" 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/spinner" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/grafana/unused" 13 | ) 14 | 15 | type state int 16 | 17 | const ( 18 | stateProviderList state = iota 19 | stateProviderView 20 | stateFetchingDisks 21 | stateDeletingDisks 22 | ) 23 | 24 | var _ tea.Model = Model{} 25 | 26 | type Model struct { 27 | providerList providerListModel 28 | providerView providerViewModel 29 | deleteView deleteViewModel 30 | provider unused.Provider 31 | spinner spinner.Model 32 | disks map[unused.Provider]unused.Disks 33 | state state 34 | extraCols []string 35 | key, value string 36 | help help.Model 37 | err error 38 | } 39 | 40 | func New(providers []unused.Provider, extraColumns []string, key, value string, dryRun bool) Model { 41 | m := Model{ 42 | providerList: newProviderListModel(providers), 43 | providerView: newProviderViewModel(extraColumns), 44 | deleteView: newDeleteViewModel(dryRun), 45 | disks: make(map[unused.Provider]unused.Disks), 46 | state: stateProviderList, 47 | spinner: spinner.New(), 48 | extraCols: extraColumns, 49 | key: key, 50 | value: value, 51 | help: newHelp(), 52 | } 53 | 54 | if len(providers) == 1 { 55 | m.provider = providers[0] 56 | } 57 | 58 | return m 59 | } 60 | 61 | func (m Model) Init() tea.Cmd { 62 | cmds := []tea.Cmd{tea.EnterAltScreen} 63 | if m.provider != nil { // No need to show the providers list if there's only one provider 64 | cmds = append(cmds, sendMsg(m.provider)) 65 | } 66 | return tea.Batch(cmds...) 67 | } 68 | 69 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 70 | switch msg := msg.(type) { 71 | case tea.KeyMsg: 72 | switch { 73 | case key.Matches(msg, navKeys.Quit): 74 | return m, tea.Quit 75 | 76 | case key.Matches(msg, navKeys.Back): 77 | switch m.state { 78 | case stateProviderView: 79 | m.state = stateProviderList 80 | return m, nil 81 | 82 | case stateDeletingDisks: 83 | delete(m.disks, m.provider) 84 | m.state = stateFetchingDisks 85 | return m, tea.Batch(m.spinner.Tick, loadDisks(m.provider, m.disks, m.key, m.value)) 86 | } 87 | 88 | return m, nil 89 | } 90 | 91 | case unused.Provider: 92 | if m.state == stateProviderList { 93 | m.provider = msg 94 | m.providerView = m.providerView.Empty() 95 | m.state = stateFetchingDisks 96 | 97 | return m, tea.Batch(m.spinner.Tick, loadDisks(m.provider, m.disks, m.key, m.value)) 98 | } 99 | 100 | case unused.Disks: 101 | switch m.state { 102 | case stateFetchingDisks: 103 | m.providerView = m.providerView.WithDisks(msg) 104 | m.state = stateProviderView 105 | 106 | case stateProviderView: 107 | m.deleteView = m.deleteView.WithDisks(m.provider, msg) 108 | m.state = stateDeletingDisks 109 | } 110 | 111 | case spinner.TickMsg: 112 | if m.state == stateFetchingDisks { 113 | var cmd tea.Cmd 114 | m.spinner, cmd = m.spinner.Update(msg) 115 | return m, cmd 116 | } 117 | 118 | case tea.WindowSizeMsg: 119 | m.providerList.SetSize(msg.Width, msg.Height) 120 | m.providerView.SetSize(msg.Width, msg.Height) 121 | m.deleteView.SetSize(msg.Width, msg.Height) 122 | 123 | case error: 124 | m.err = msg 125 | return m, nil 126 | } 127 | 128 | var cmd tea.Cmd 129 | 130 | switch m.state { 131 | case stateProviderList: 132 | m.providerList, cmd = m.providerList.Update(msg) 133 | 134 | case stateProviderView: 135 | m.providerView, cmd = m.providerView.Update(msg) 136 | 137 | case stateDeletingDisks: 138 | m.deleteView, cmd = m.deleteView.Update(msg) 139 | } 140 | 141 | return m, cmd 142 | } 143 | 144 | var errorStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#cb4b16", Dark: "#d87979"}) 145 | 146 | func (m Model) View() string { 147 | if m.err != nil { 148 | return errorStyle.Render(m.err.Error()) 149 | } 150 | 151 | switch m.state { 152 | case stateProviderList: 153 | return m.providerList.View() 154 | 155 | case stateProviderView: 156 | return m.providerView.View() 157 | 158 | case stateFetchingDisks: 159 | return fmt.Sprintf("Fetching disks for %s %s %s\n", m.provider.Name(), m.provider.Meta().String(), m.spinner.View()) 160 | 161 | case stateDeletingDisks: 162 | return m.deleteView.View() 163 | 164 | default: 165 | return "WHAT" 166 | } 167 | } 168 | 169 | func loadDisks(provider unused.Provider, cache map[unused.Provider]unused.Disks, key, value string) tea.Cmd { 170 | return func() tea.Msg { 171 | if disks, ok := cache[provider]; ok { 172 | return disks 173 | } 174 | 175 | disks, err := provider.ListUnusedDisks(context.TODO()) 176 | if err != nil { 177 | return err 178 | } 179 | 180 | if key != "" { 181 | filtered := make(unused.Disks, 0, len(disks)) 182 | for _, d := range disks { 183 | if d.Meta().Matches(key, value) { 184 | filtered = append(filtered, d) 185 | } 186 | } 187 | disks = filtered 188 | } 189 | 190 | cache[provider] = disks 191 | 192 | return disks 193 | } 194 | } 195 | 196 | // sendMsg is a tea.Cmd that will send whatever is passed as an 197 | // argument as a tea.Msg. 198 | func sendMsg(msg tea.Msg) tea.Cmd { return func() tea.Msg { return msg } } 199 | 200 | func newHelp() help.Model { 201 | keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ 202 | Light: "#909090", 203 | Dark: "#FFFF00", 204 | }) 205 | 206 | descStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ 207 | Light: "#B2B2B2", 208 | Dark: "#999999", 209 | }) 210 | 211 | sepStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ 212 | Light: "#DDDADA", 213 | Dark: "#3C3C3C", 214 | }) 215 | 216 | m := help.New() 217 | m.Styles = help.Styles{ 218 | ShortKey: keyStyle, 219 | ShortDesc: descStyle, 220 | ShortSeparator: sepStyle, 221 | Ellipsis: sepStyle, 222 | FullKey: keyStyle, 223 | FullDesc: descStyle, 224 | FullSeparator: sepStyle, 225 | } 226 | 227 | return m 228 | } 229 | -------------------------------------------------------------------------------- /cmd/unused/internal/ui/interactive/provider_list.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/key" 6 | "github.com/charmbracelet/bubbles/list" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/grafana/unused" 10 | ) 11 | 12 | type providerItem struct { 13 | unused.Provider 14 | } 15 | 16 | func (i providerItem) FilterValue() string { 17 | return i.Provider.Name() + " " + i.Provider.Meta().String() 18 | } 19 | 20 | func (i providerItem) Title() string { 21 | return i.Provider.Name() 22 | } 23 | 24 | func (i providerItem) Description() string { 25 | return i.Provider.Meta().String() 26 | } 27 | 28 | func newProviderList(providers []unused.Provider) list.Model { 29 | items := make([]list.Item, len(providers)) 30 | for i, p := range providers { 31 | items[i] = providerItem{p} 32 | } 33 | 34 | m := list.New(items, list.NewDefaultDelegate(), 0, 0) 35 | m.Title = "Please select which provider to use for checking unused disks" 36 | m.AdditionalFullHelpKeys = func() []key.Binding { 37 | return []key.Binding{key.NewBinding( 38 | key.WithKeys("enter"), 39 | key.WithHelp("enter", "select provider"), 40 | )} 41 | } 42 | m.AdditionalShortHelpKeys = m.AdditionalFullHelpKeys 43 | m.SetFilteringEnabled(false) 44 | m.SetShowHelp(false) 45 | m.DisableQuitKeybindings() 46 | 47 | return m 48 | } 49 | 50 | type providerListModel struct { 51 | list list.Model 52 | help help.Model 53 | sel key.Binding 54 | w, h int 55 | } 56 | 57 | func newProviderListModel(providers []unused.Provider) providerListModel { 58 | return providerListModel{ 59 | list: newProviderList(providers), 60 | help: newHelp(), 61 | sel: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select provider")), 62 | } 63 | } 64 | 65 | func (m providerListModel) Update(msg tea.Msg) (providerListModel, tea.Cmd) { 66 | switch msg := msg.(type) { 67 | case tea.KeyMsg: 68 | switch { 69 | case key.Matches(msg, m.sel): 70 | return m, sendMsg(m.list.SelectedItem().(providerItem).Provider) 71 | 72 | case msg.String() == "?": 73 | m.help.ShowAll = !m.help.ShowAll 74 | m.resetSize() 75 | return m, nil 76 | 77 | case key.Matches(msg, navKeys.Quit): 78 | return m, tea.Quit 79 | 80 | default: 81 | var cmd tea.Cmd 82 | m.list, cmd = m.list.Update(msg) 83 | return m, cmd 84 | } 85 | } 86 | 87 | return m, nil 88 | } 89 | 90 | func (m providerListModel) View() string { 91 | return lipgloss.JoinVertical(lipgloss.Left, m.list.View(), m.help.View(m)) 92 | } 93 | 94 | func (m *providerListModel) resetSize() { 95 | hh := lipgloss.Height(m.help.View(m)) 96 | m.list.SetSize(m.w, m.h-hh) 97 | m.help.Width = m.w 98 | } 99 | 100 | func (m *providerListModel) SetSize(w, h int) { 101 | m.w, m.h = w, h 102 | m.resetSize() 103 | } 104 | 105 | func (m providerListModel) ShortHelp() []key.Binding { 106 | return []key.Binding{navKeys.Quit, m.sel, navKeys.Up, navKeys.Down} 107 | } 108 | 109 | func (m providerListModel) FullHelp() [][]key.Binding { 110 | return [][]key.Binding{ 111 | m.ShortHelp(), 112 | {navKeys.PageUp, navKeys.PageDown, navKeys.Home, navKeys.End}, 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /cmd/unused/internal/ui/interactive/provider_view.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/key" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/evertras/bubble-table/table" 9 | "github.com/grafana/unused" 10 | "github.com/grafana/unused/cmd/internal" 11 | ) 12 | 13 | const ( 14 | columnDisk = "disk" 15 | columnName = "name" 16 | columnAge = "age" 17 | columnUnused = "ageUnused" 18 | columnSize = "size" 19 | columnType = "type" 20 | ) 21 | 22 | // Custom Kubernetes columns. 23 | // These constants are copied from the ui package. 24 | const ( 25 | KubernetesNS = "__k8s:ns__" 26 | KubernetesPV = "__k8s:pv__" 27 | KubernetesPVC = "__k8s:pvc__" 28 | ) 29 | 30 | var k8sHeaders = map[string]string{ 31 | KubernetesNS: "Namespace", 32 | KubernetesPVC: "PVC", 33 | KubernetesPV: "PV", 34 | } 35 | 36 | var ( 37 | headerStyle = lipgloss.NewStyle().Align(lipgloss.Center).Bold(true) 38 | nameStyle = lipgloss.NewStyle().Align(lipgloss.Left) 39 | ageStyle = lipgloss.NewStyle().Align(lipgloss.Right) 40 | ) 41 | 42 | type providerViewModel struct { 43 | table table.Model 44 | help help.Model 45 | toggle key.Binding 46 | delete key.Binding 47 | w, h int 48 | 49 | extraCols []string 50 | } 51 | 52 | func newProviderViewModel(extraColumns []string) providerViewModel { 53 | cols := []table.Column{ 54 | table.NewFlexColumn(columnName, "Name", 2).WithStyle(nameStyle), 55 | table.NewColumn(columnAge, "Age", 6).WithStyle(ageStyle), 56 | table.NewColumn(columnUnused, "Unused", 6).WithStyle(ageStyle), 57 | table.NewColumn(columnType, "Type", 6).WithStyle(ageStyle), 58 | table.NewColumn(columnSize, "Size (GB)", 10).WithStyle(ageStyle), 59 | } 60 | 61 | for _, c := range extraColumns { 62 | h, ok := k8sHeaders[c] 63 | if !ok { 64 | h = c 65 | } 66 | cols = append(cols, table.NewFlexColumn(c, h, 1).WithStyle(nameStyle)) 67 | } 68 | 69 | table := table.New(cols). 70 | HeaderStyle(headerStyle). 71 | Focused(true). 72 | WithSelectedText(" ", "✔"). 73 | WithFooterVisibility(false). 74 | SelectableRows(true) 75 | 76 | return providerViewModel{ 77 | table: table, 78 | help: newHelp(), 79 | toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle mark")), 80 | delete: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "delete marked")), 81 | 82 | extraCols: extraColumns, 83 | } 84 | } 85 | 86 | func (m providerViewModel) Update(msg tea.Msg) (providerViewModel, tea.Cmd) { 87 | switch msg := msg.(type) { 88 | case tea.KeyMsg: 89 | switch { 90 | case key.Matches(msg, m.delete): 91 | if rows := m.table.SelectedRows(); len(rows) > 0 { 92 | disks := make(unused.Disks, len(rows)) 93 | for i, r := range rows { 94 | disks[i] = r.Data[columnDisk].(unused.Disk) 95 | } 96 | return m, sendMsg(disks) 97 | } 98 | 99 | case msg.String() == "?": 100 | m.help.ShowAll = !m.help.ShowAll 101 | m.resetSize() 102 | return m, nil 103 | 104 | case key.Matches(msg, navKeys.Quit): 105 | return m, tea.Quit 106 | 107 | default: 108 | var cmd tea.Cmd 109 | m.table, cmd = m.table.Update(msg) 110 | return m, cmd 111 | } 112 | } 113 | 114 | return m, nil 115 | } 116 | 117 | func (m providerViewModel) View() string { 118 | return lipgloss.JoinVertical(lipgloss.Left, m.table.View(), m.help.View(m)) 119 | } 120 | 121 | func (m providerViewModel) ShortHelp() []key.Binding { 122 | return []key.Binding{navKeys.Quit, navKeys.Back, m.toggle, m.delete, navKeys.Up, navKeys.Down} 123 | } 124 | 125 | func (m providerViewModel) FullHelp() [][]key.Binding { 126 | return [][]key.Binding{ 127 | m.ShortHelp(), 128 | {navKeys.PageUp, navKeys.PageDown, navKeys.Home, navKeys.End}, 129 | } 130 | } 131 | 132 | func (m *providerViewModel) resetSize() { 133 | hh := lipgloss.Height(m.help.View(m)) 134 | m.table = m.table.WithTargetWidth(m.w).WithPageSize(m.h - 4 - hh) 135 | m.help.Width = m.w 136 | } 137 | 138 | func (m *providerViewModel) SetSize(w, h int) { 139 | m.w, m.h = w, h 140 | m.resetSize() 141 | } 142 | 143 | func (m providerViewModel) Empty() providerViewModel { 144 | m.table = m.table.WithRows(nil) 145 | return m 146 | } 147 | 148 | func (m providerViewModel) WithDisks(disks unused.Disks) providerViewModel { 149 | rows := make([]table.Row, len(disks)) 150 | 151 | for i, d := range disks { 152 | row := table.RowData{ 153 | columnDisk: d, 154 | columnName: d.Name(), 155 | columnAge: internal.Age(d.CreatedAt()), 156 | columnUnused: internal.Age(d.LastUsedAt()), 157 | columnType: d.DiskType(), 158 | columnSize: d.SizeGB(), 159 | } 160 | 161 | meta := d.Meta() 162 | for _, c := range m.extraCols { 163 | var v string 164 | switch c { 165 | case KubernetesNS: 166 | v = meta.CreatedForNamespace() 167 | case KubernetesPV: 168 | v = meta.CreatedForPV() 169 | case KubernetesPVC: 170 | v = meta.CreatedForPVC() 171 | default: 172 | v = meta[c] 173 | } 174 | row[c] = v 175 | } 176 | 177 | rows[i] = table.NewRow(row) 178 | } 179 | 180 | m.table = m.table.WithRows(rows) 181 | return m 182 | } 183 | -------------------------------------------------------------------------------- /cmd/unused/internal/ui/table.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "sync" 9 | "text/tabwriter" 10 | 11 | "github.com/grafana/unused" 12 | "github.com/grafana/unused/cmd/internal" 13 | ) 14 | 15 | var k8sHeaders = map[string]string{ 16 | KubernetesNS: "K8S:NS", 17 | KubernetesPVC: "K8S:PVC", 18 | KubernetesPV: "K8S:PV", 19 | } 20 | 21 | func Table(ctx context.Context, options Options) error { 22 | disks, err := listUnusedDisks(ctx, options.Providers) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | if options.Filter.Key != "" { 28 | filtered := make(unused.Disks, 0, len(disks)) 29 | for _, d := range disks { 30 | if d.Meta().Matches(options.Filter.Key, options.Filter.Value) { 31 | filtered = append(filtered, d) 32 | } 33 | } 34 | disks = filtered 35 | } 36 | 37 | if len(disks) == 0 { 38 | fmt.Println("No disks found") 39 | return nil 40 | } 41 | 42 | w := tabwriter.NewWriter(os.Stdout, 8, 4, 2, ' ', 0) 43 | 44 | headers := []string{"PROVIDER", "DISK", "AGE", "UNUSED", "TYPE", "SIZE_GB"} 45 | for _, c := range options.ExtraColumns { 46 | h, ok := k8sHeaders[c] 47 | if !ok { 48 | h = "META:" + c 49 | } 50 | headers = append(headers, h) 51 | } 52 | if options.Verbose { 53 | headers = append(headers, "PROVIDER_META", "DISK_META") 54 | } 55 | 56 | fmt.Fprintln(w, strings.Join(headers, "\t")) // nolint:errcheck 57 | 58 | for _, d := range disks { 59 | p := d.Provider() 60 | 61 | row := []string{p.Name(), d.Name(), internal.Age(d.CreatedAt()), internal.Age(d.LastUsedAt()), string(d.DiskType()), fmt.Sprintf("%d", d.SizeGB())} 62 | meta := d.Meta() 63 | for _, c := range options.ExtraColumns { 64 | var v string 65 | switch c { 66 | case KubernetesNS: 67 | v = meta.CreatedForNamespace() 68 | case KubernetesPV: 69 | v = meta.CreatedForPV() 70 | case KubernetesPVC: 71 | v = meta.CreatedForPVC() 72 | default: 73 | v = meta[c] 74 | } 75 | row = append(row, v) 76 | } 77 | if options.Verbose { 78 | row = append(row, p.Meta().String(), d.Meta().String()) 79 | } 80 | 81 | fmt.Fprintln(w, strings.Join(row, "\t")) // nolint:errcheck 82 | } 83 | 84 | if err := w.Flush(); err != nil { 85 | return fmt.Errorf("flushing table contents: %w", err) 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func listUnusedDisks(ctx context.Context, providers []unused.Provider) (unused.Disks, error) { 92 | var ( 93 | wg sync.WaitGroup 94 | mu sync.Mutex 95 | total unused.Disks 96 | ) 97 | 98 | wg.Add(len(providers)) 99 | 100 | ctx, cancel := context.WithCancel(ctx) 101 | defer cancel() 102 | 103 | errCh := make(chan error, len(providers)) 104 | 105 | for _, p := range providers { 106 | go func(p unused.Provider) { 107 | defer wg.Done() 108 | 109 | disks, err := p.ListUnusedDisks(ctx) 110 | if err != nil { 111 | cancel() 112 | errCh <- fmt.Errorf("%s %s: %w", p.Name(), p.Meta(), err) 113 | return 114 | } 115 | 116 | mu.Lock() 117 | total = append(total, disks...) 118 | mu.Unlock() 119 | }(p) 120 | } 121 | 122 | wg.Wait() 123 | 124 | select { 125 | case err := <-errCh: 126 | return nil, err 127 | default: 128 | } 129 | 130 | return total, nil 131 | } 132 | -------------------------------------------------------------------------------- /cmd/unused/internal/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grafana/unused" 7 | ) 8 | 9 | type Filter struct { 10 | Key, Value string 11 | } 12 | 13 | type Options struct { 14 | Providers []unused.Provider 15 | ExtraColumns []string 16 | Filter Filter 17 | Group string 18 | Verbose bool 19 | DryRun bool 20 | } 21 | 22 | type DisplayFunc func(ctx context.Context, options Options) error 23 | 24 | const ( 25 | KubernetesNS = "__k8s:ns__" 26 | KubernetesPV = "__k8s:pv__" 27 | KubernetesPVC = "__k8s:pvc__" 28 | ) 29 | -------------------------------------------------------------------------------- /cmd/unused/main.go: -------------------------------------------------------------------------------- 1 | // unused is a CLI tool to query the given providers for unused disks. 2 | // 3 | // In its default operation mode it outputs a table listing all the 4 | // unused disks. I also supports an interactive mode where the user 5 | // can see mark unused disks from the listing tables to individually 6 | // delete them. 7 | // 8 | // Provider selection is opinionated, currently accepting the 9 | // following authentication method for each provider: 10 | // - GCP: pass gcp.project with a valid GCP project ID. 11 | // - AWS: pass aws.profile with a valid AWS shared profile. 12 | // - Azure: pass azure.sub with a valid Azure subscription ID. 13 | package main 14 | 15 | import ( 16 | "context" 17 | "errors" 18 | "flag" 19 | "fmt" 20 | "log/slog" 21 | "os" 22 | "os/signal" 23 | "strings" 24 | 25 | "github.com/grafana/unused/cmd/internal" 26 | "github.com/grafana/unused/cmd/unused/internal/ui" 27 | ) 28 | 29 | func main() { 30 | var ( 31 | gcpProjects, awsProfiles, azureSubs internal.StringSliceFlag 32 | 33 | interactiveMode bool 34 | 35 | options ui.Options 36 | ) 37 | 38 | internal.ProviderFlags(flag.CommandLine, &gcpProjects, &awsProfiles, &azureSubs) 39 | 40 | flag.BoolVar(&interactiveMode, "i", false, "Interactive UI mode") 41 | flag.BoolVar(&options.Verbose, "v", false, "Verbose mode") 42 | flag.BoolVar(&options.DryRun, "n", false, "Do not delete disks in interactive mode") 43 | 44 | flag.Func("filter", "Filter by disk metadata", func(v string) error { 45 | ps := strings.SplitN(v, "=", 2) 46 | 47 | if len(ps) == 0 || ps[0] == "" { 48 | return errors.New("invalid filter format") 49 | } 50 | 51 | options.Filter.Key = ps[0] 52 | 53 | if len(ps) == 2 { 54 | options.Filter.Value = ps[1] 55 | } 56 | 57 | return nil 58 | }) 59 | 60 | flag.Func("add-column", "Display additional column with metadata", func(c string) error { 61 | options.ExtraColumns = append(options.ExtraColumns, c) 62 | return nil 63 | }) 64 | 65 | flag.Func("add-k8s-column", "Add Kubernetes metadata column; valid values are: ns, pvc, pv", func(c string) error { 66 | switch c { 67 | case "ns": 68 | options.ExtraColumns = append(options.ExtraColumns, ui.KubernetesNS) 69 | case "pvc": 70 | options.ExtraColumns = append(options.ExtraColumns, ui.KubernetesPVC) 71 | case "pv": 72 | options.ExtraColumns = append(options.ExtraColumns, ui.KubernetesPV) 73 | default: 74 | return errors.New("valid values are ns, pvc, pv") 75 | } 76 | 77 | return nil 78 | }) 79 | 80 | flag.StringVar(&options.Group, "group-by", "", "Group by disk metadata values") 81 | 82 | flag.Parse() 83 | 84 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 85 | defer cancel() 86 | 87 | logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 88 | 89 | providers, err := internal.CreateProviders(ctx, logger, gcpProjects, awsProfiles, azureSubs) 90 | if err != nil { 91 | cancel() 92 | fmt.Fprintln(os.Stderr, "creating providers:", err) 93 | os.Exit(1) 94 | } 95 | 96 | options.Providers = providers 97 | 98 | var display ui.DisplayFunc = ui.Table 99 | if options.Group != "" { 100 | display = ui.GroupTable 101 | } 102 | if interactiveMode { 103 | display = ui.Interactive 104 | } 105 | 106 | if err := display(ctx, options); err != nil { 107 | cancel() // cleanup resources 108 | fmt.Fprintln(os.Stderr, "displaying output:", err) 109 | os.Exit(1) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /disk.go: -------------------------------------------------------------------------------- 1 | package unused 2 | 3 | import "time" 4 | 5 | // Disk represents an unused disk on a given cloud provider. 6 | type Disk interface { 7 | // ID should return a unique ID for a disk within each cloud 8 | // provider. 9 | ID() string 10 | 11 | // Provider returns a reference to the provider used to instantiate 12 | // this disk 13 | Provider() Provider 14 | 15 | // Name returns the disk name. 16 | Name() string 17 | 18 | // SizeGB returns the disk size in GB (Azure/GCP) and GiB for AWS. 19 | SizeGB() int 20 | 21 | // SizeBytes returns the disk size in bytes. 22 | SizeBytes() float64 23 | 24 | // CreatedAt returns the time when the disk was created. 25 | CreatedAt() time.Time 26 | 27 | // LastUsedAt returns the date when the disk was last used. 28 | LastUsedAt() time.Time 29 | 30 | // Meta returns the disk metadata. 31 | Meta() Meta 32 | 33 | // DiskType returns the normalized type of disk. 34 | DiskType() DiskType 35 | } 36 | 37 | type DiskType string 38 | 39 | const ( 40 | SSD DiskType = "ssd" 41 | HDD DiskType = "hdd" 42 | Unknown DiskType = "unknown" 43 | ) 44 | 45 | const GiBbytes = 1_073_741_824 // 2^30 46 | -------------------------------------------------------------------------------- /disks.go: -------------------------------------------------------------------------------- 1 | package unused 2 | 3 | import "sort" 4 | 5 | // Disks is a collection of Disk. 6 | type Disks []Disk 7 | 8 | // ByFunc is the type of sorting functions for Disks. 9 | type ByFunc func(p, q Disk) bool 10 | 11 | // ByProvider sorts a Disks collection by provider name. 12 | func ByProvider(p, q Disk) bool { 13 | return p.Provider().Name() < q.Provider().Name() 14 | } 15 | 16 | // ByName sorts a Disks collection by disk name. 17 | func ByName(p, q Disk) bool { 18 | return p.Name() < q.Name() 19 | } 20 | 21 | // ByCreatedAt sorts a Disks collection by disk creation time. 22 | func ByCreatedAt(p, q Disk) bool { 23 | return p.CreatedAt().Before(q.CreatedAt()) 24 | } 25 | 26 | // Sort sorts the collection by the given function. 27 | func (d Disks) Sort(by ByFunc) { 28 | sort.Slice(d, func(i, j int) bool { return by(d[i], d[j]) }) 29 | } 30 | -------------------------------------------------------------------------------- /disks_test.go: -------------------------------------------------------------------------------- 1 | package unused_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/grafana/unused" 8 | "github.com/grafana/unused/unusedtest" 9 | ) 10 | 11 | func TestDisksSort(t *testing.T) { 12 | var ( 13 | now = time.Now() 14 | 15 | foo = unusedtest.NewProvider("foo", nil) 16 | baz = unusedtest.NewProvider("baz", nil) 17 | bar = unusedtest.NewProvider("bar", nil) 18 | 19 | gcp = unusedtest.NewDisk("ghi", foo, now.Add(-10*time.Minute)) 20 | aws = unusedtest.NewDisk("abc", baz, now.Add(-5*time.Minute)) 21 | az = unusedtest.NewDisk("def", bar, now.Add(-2*time.Minute)) 22 | 23 | disks = unused.Disks{gcp, aws, az} 24 | ) 25 | 26 | tests := map[string]struct { 27 | exp []unused.Disk 28 | by unused.ByFunc 29 | }{ 30 | "ByProvider": {[]unused.Disk{az, aws, gcp}, unused.ByProvider}, 31 | "ByName": {[]unused.Disk{aws, az, gcp}, unused.ByName}, 32 | "ByCreatedAt": {[]unused.Disk{gcp, aws, az}, unused.ByCreatedAt}, 33 | } 34 | 35 | for n, tt := range tests { 36 | t.Run(n, func(t *testing.T) { 37 | disks.Sort(tt.by) 38 | 39 | for i, got := range disks { 40 | assertEqualDisks(t, tt.exp[i], got) 41 | } 42 | }) 43 | } 44 | } 45 | 46 | func assertEqualDisks(t *testing.T, p, q unused.Disk) { 47 | t.Helper() 48 | 49 | if e, g := p.Name(), q.Name(); e != g { 50 | t.Errorf("expecting name %q, got %q", e, g) 51 | } 52 | 53 | if e, g := p.Provider(), q.Provider(); e != g { 54 | t.Errorf("expecting provider %v, got %v", e, g) 55 | } 56 | 57 | if e, g := p.CreatedAt(), q.CreatedAt(); !e.Equal(g) { 58 | t.Errorf("expecting created at %v, got %v", e, g) 59 | } 60 | 61 | mp, mq := p.Meta(), q.Meta() 62 | 63 | if e, g := len(mp), len(mq); e != g { 64 | t.Fatalf("expecting %d metadata items, got %d", e, g) 65 | } 66 | 67 | for k, v := range mp { 68 | if mq[k] != v { 69 | t.Errorf("expecting metadata %q with value %q, got %q", k, v, mq[k]) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package unused provides the basic interfaces to obtain information 2 | // and in some cases, manipulate, unused resources in different Cloud 3 | // Service Providers (CSPs). 4 | // 5 | // Currently only unused disks are supported. 6 | // 7 | // The following providers are already implemented: 8 | // - Google Cloud Platform (GCP) 9 | // - Amazon Web Services (AWS) 10 | // - Azure 11 | package unused 12 | -------------------------------------------------------------------------------- /gcp/disk.go: -------------------------------------------------------------------------------- 1 | package gcp 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/grafana/unused" 9 | compute "google.golang.org/api/compute/v1" 10 | ) 11 | 12 | // ensure we are properly defining the interface 13 | var _ unused.Disk = &Disk{} 14 | 15 | // Disk holds information about a GCP compute disk. 16 | type Disk struct { 17 | *compute.Disk 18 | provider *Provider 19 | meta unused.Meta 20 | } 21 | 22 | // ID returns the GCP compute disk ID, prefixed by gcp-disk. 23 | func (d *Disk) ID() string { return fmt.Sprintf("gcp-disk-%d", d.Disk.Id) } // TODO remove prefix 24 | 25 | // Provider returns a reference to the provider used to instantiate 26 | // this disk. 27 | func (d *Disk) Provider() unused.Provider { return d.provider } 28 | 29 | // Name returns the name of the GCP compute disk. 30 | func (d *Disk) Name() string { return d.Disk.Name } 31 | 32 | // CreatedAt returns the time when the GCP compute disk was created. 33 | func (d *Disk) CreatedAt() time.Time { 34 | // it's safe to assume GCP will send a valid timestamp 35 | c, _ := time.Parse(time.RFC3339, d.Disk.CreationTimestamp) 36 | 37 | return c 38 | } 39 | 40 | // Meta returns the disk metadata. 41 | func (d *Disk) Meta() unused.Meta { return d.meta } 42 | 43 | // LastUsedAt returns the time when the GCP compute disk was last 44 | // detached. 45 | func (d *Disk) LastUsedAt() time.Time { 46 | // it's safe to assume GCP will send a valid timestamp 47 | t, _ := time.Parse(time.RFC3339, d.Disk.LastDetachTimestamp) 48 | return t 49 | } 50 | 51 | // SizeGB returns the size of the GCP compute disk in binary GB (aka GiB). 52 | // GCP Storage docs: https://cloud.google.com/compute/docs/disks 53 | // GCP pricing docs: https://cloud.google.com/compute/disks-image-pricing 54 | // Note that it specifies the use of JEDEC binary gigabytes for the disk size. 55 | func (d *Disk) SizeGB() int { return int(d.Disk.SizeGb) } 56 | 57 | // SizeBytes returns the size of the GCP compute disk in bytes. 58 | func (d *Disk) SizeBytes() float64 { return float64(d.Disk.SizeGb) * unused.GiBbytes } 59 | 60 | // DiskType Type returns the type of the GCP compute disk. 61 | func (d *Disk) DiskType() unused.DiskType { 62 | splitDiskType := strings.Split(d.Disk.Type, "/") 63 | diskType := splitDiskType[len(splitDiskType)-1] 64 | switch diskType { 65 | case "pd-ssd": 66 | return unused.SSD 67 | case "pd-standard": 68 | return unused.HDD 69 | default: 70 | return unused.Unknown 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /gcp/disk_test.go: -------------------------------------------------------------------------------- 1 | package gcp 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/grafana/unused" 8 | "github.com/grafana/unused/unusedtest" 9 | compute "google.golang.org/api/compute/v1" 10 | ) 11 | 12 | func TestDisk(t *testing.T) { 13 | createdAt := time.Date(2021, 7, 16, 5, 55, 00, 0, time.UTC) 14 | detachedAt := createdAt.Add(1 * time.Hour) 15 | size := 10 16 | 17 | var d unused.Disk = &Disk{ 18 | &compute.Disk{ 19 | Id: 1234, 20 | Name: "my-disk", 21 | CreationTimestamp: createdAt.Format(time.RFC3339), 22 | LastDetachTimestamp: detachedAt.Format(time.RFC3339), 23 | SizeGb: int64(size), 24 | }, 25 | nil, 26 | unused.Meta{"foo": "bar"}, 27 | } 28 | 29 | if exp, got := "gcp-disk-1234", d.ID(); exp != got { 30 | t.Errorf("expecting ID() %q, got %q", exp, got) 31 | } 32 | 33 | if exp, got := "GCP", d.Provider().Name(); exp != got { 34 | t.Errorf("expecting Provider() %q, got %q", exp, got) 35 | } 36 | 37 | if exp, got := "my-disk", d.Name(); exp != got { 38 | t.Errorf("expecting Name() %q, got %q", exp, got) 39 | } 40 | 41 | if !createdAt.Equal(d.CreatedAt()) { 42 | t.Errorf("expecting CreatedAt() %v, got %v", createdAt, d.CreatedAt()) 43 | } 44 | 45 | if !detachedAt.Equal(d.LastUsedAt()) { 46 | t.Errorf("expecting LastUsedAt() %v, got %v", detachedAt, d.LastUsedAt()) 47 | } 48 | 49 | if exp, got := size, d.SizeGB(); exp != got { 50 | t.Errorf("expecting SizeGB() %d, got %d", exp, got) 51 | } 52 | 53 | if exp, got := float64(size)*unused.GiBbytes, d.SizeBytes(); exp != got { 54 | t.Errorf("expecting SizeBytes() %f, got %f", exp, got) 55 | } 56 | 57 | err := unusedtest.AssertEqualMeta(unused.Meta{"foo": "bar"}, d.Meta()) 58 | if err != nil { 59 | t.Fatalf("metadata doesn't match: %v", err) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /gcp/provider.go: -------------------------------------------------------------------------------- 1 | package gcp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "strings" 10 | 11 | "github.com/grafana/unused" 12 | compute "google.golang.org/api/compute/v1" 13 | ) 14 | 15 | var ProviderName = "GCP" 16 | 17 | // ErrMissingProject is the error used when no project ID is provided 18 | // when trying to create a provider. 19 | var ErrMissingProject = errors.New("missing project id") 20 | 21 | var _ unused.Provider = &Provider{} 22 | 23 | // Provider implements [unused.Provider] for GCP. 24 | type Provider struct { 25 | project string 26 | svc *compute.Service 27 | meta unused.Meta 28 | logger *slog.Logger 29 | } 30 | 31 | // Name returns GCP. 32 | func (p *Provider) Name() string { return ProviderName } 33 | 34 | // Meta returns the provider metadata. 35 | func (p *Provider) Meta() unused.Meta { return p.meta } 36 | 37 | // ID returns the GCP project for this provider. 38 | func (p *Provider) ID() string { return p.project } 39 | 40 | // NewProvider creates a new GCP [unused.Provider]. 41 | // 42 | // A valid GCP compute service must be supplied in order to listed the 43 | // unused resources. It also requires a valid project ID which should 44 | // be the project where the disks were created. 45 | func NewProvider(logger *slog.Logger, svc *compute.Service, project string, meta unused.Meta) (*Provider, error) { 46 | if project == "" { 47 | return nil, ErrMissingProject 48 | } 49 | 50 | if meta == nil { 51 | meta = make(unused.Meta) 52 | } 53 | 54 | return &Provider{ 55 | project: project, 56 | svc: svc, 57 | meta: meta, 58 | logger: logger, 59 | }, nil 60 | } 61 | 62 | // ListUnusedDisks returns all the GCP compute disks that aren't 63 | // associated to any users, meaning that are not being in use. 64 | func (p *Provider) ListUnusedDisks(ctx context.Context) (unused.Disks, error) { 65 | var disks unused.Disks 66 | 67 | err := p.svc.Disks.AggregatedList(p.project).Filter("").Pages(ctx, 68 | func(res *compute.DiskAggregatedList) error { 69 | for _, item := range res.Items { 70 | for _, d := range item.Disks { 71 | if len(d.Users) > 0 { 72 | continue 73 | } 74 | 75 | m, err := diskMetadata(d) 76 | if err != nil { 77 | p.logger.Error("cannot parse disk metadata", 78 | slog.String("project", p.project), 79 | slog.String("disk", d.Name), 80 | slog.String("err", err.Error()), 81 | ) 82 | } 83 | disks = append(disks, &Disk{d, p, m}) 84 | } 85 | } 86 | return nil 87 | }) 88 | if err != nil { 89 | return nil, fmt.Errorf("listing unused disks: %w", err) 90 | } 91 | 92 | return disks, nil 93 | } 94 | 95 | func diskMetadata(d *compute.Disk) (unused.Meta, error) { 96 | m := make(unused.Meta) 97 | 98 | // GCP sends Kubernetes metadata as a JSON string in the 99 | // Description field. 100 | if d.Description != "" { 101 | if err := json.Unmarshal([]byte(d.Description), &m); err != nil { 102 | return nil, fmt.Errorf("cannot decode JSON description for disk %s: %w", d.Name, err) 103 | } 104 | } 105 | 106 | // Zone is returned as a URL, remove all but the zone name 107 | m["zone"] = d.Zone[strings.LastIndexByte(d.Zone, '/')+1:] 108 | 109 | return m, nil 110 | } 111 | 112 | // Delete deletes the given disk from GCP. 113 | func (p *Provider) Delete(ctx context.Context, disk unused.Disk) error { 114 | _, err := p.svc.Disks.Delete(p.project, disk.Meta()["zone"], disk.Name()).Do() 115 | if err != nil { 116 | return fmt.Errorf("cannot delete GCP disk: %w", err) 117 | } 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /gcp/provider_test.go: -------------------------------------------------------------------------------- 1 | package gcp_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "io" 9 | "log/slog" 10 | "net/http" 11 | "net/http/httptest" 12 | "regexp" 13 | "testing" 14 | 15 | "github.com/grafana/unused" 16 | "github.com/grafana/unused/gcp" 17 | "github.com/grafana/unused/unusedtest" 18 | compute "google.golang.org/api/compute/v1" 19 | "google.golang.org/api/option" 20 | ) 21 | 22 | func TestNewProvider(t *testing.T) { 23 | l := slog.New(slog.NewTextHandler(io.Discard, nil)) 24 | 25 | t.Run("project is required", func(t *testing.T) { 26 | p, err := gcp.NewProvider(l, nil, "", nil) 27 | if !errors.Is(err, gcp.ErrMissingProject) { 28 | t.Fatalf("expecting error %v, got %v", gcp.ErrMissingProject, err) 29 | } 30 | if p != nil { 31 | t.Fatalf("expecting nil provider, got %v", p) 32 | } 33 | }) 34 | 35 | t.Run("provider information is correct", func(t *testing.T) { 36 | p, err := gcp.NewProvider(l, nil, "my-project", unused.Meta{}) 37 | if err != nil { 38 | t.Fatalf("error creating provider: %v", err) 39 | } 40 | if p == nil { 41 | t.Fatalf("error creating provider, provider is nil") 42 | } 43 | 44 | if exp, got := "my-project", p.ID(); exp != got { 45 | t.Fatalf("provider id was incorrect, exp: %v, got: %v", exp, got) 46 | } 47 | }) 48 | 49 | t.Run("metadata", func(t *testing.T) { 50 | err := unusedtest.TestProviderMeta(func(meta unused.Meta) (unused.Provider, error) { 51 | svc, err := compute.NewService(context.Background(), option.WithAPIKey("123abc")) 52 | if err != nil { 53 | t.Fatalf("unexpected error creating GCP compute service: %v", err) 54 | } 55 | return gcp.NewProvider(l, svc, "my-provider", meta) 56 | }) 57 | if err != nil { 58 | t.Fatalf("unexpected error: %v", err) 59 | } 60 | }) 61 | } 62 | 63 | func TestProviderListUnusedDisks(t *testing.T) { 64 | ctx := context.Background() 65 | l := slog.New(slog.NewTextHandler(io.Discard, nil)) 66 | 67 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 68 | // are we requesting the right API endpoint? 69 | if got, exp := req.URL.Path, "/projects/my-project/aggregated/disks"; exp != got { 70 | t.Fatalf("expecting request to %s, got %s", exp, got) 71 | } 72 | 73 | res := &compute.DiskAggregatedList{ 74 | Items: map[string]compute.DisksScopedList{ 75 | "foo": { 76 | Disks: []*compute.Disk{ 77 | {Name: "disk-1", Zone: "https://www.googleapis.com/compute/v1/projects/ops-tools-1203/zones/us-central1-a"}, 78 | {Name: "with-users", Users: []string{"inkel"}}, 79 | {Name: "disk-2", Zone: "eu-west2-b", Description: `{"kubernetes.io-created-for-pv-name":"pvc-prometheus-1","kubernetes.io-created-for-pvc-name":"prometheus-1","kubernetes.io-created-for-pvc-namespace":"monitoring"}`}, 80 | }, 81 | }, 82 | }, 83 | } 84 | 85 | b, _ := json.Marshal(res) 86 | _, err := w.Write(b) 87 | if err != nil { 88 | t.Fatalf("unexpected error writing response: %v", err) 89 | } 90 | })) 91 | defer ts.Close() 92 | 93 | svc, err := compute.NewService(context.Background(), option.WithAPIKey("123abc"), option.WithEndpoint(ts.URL)) 94 | if err != nil { 95 | t.Fatalf("unexpected error creating GCP compute service: %v", err) 96 | } 97 | 98 | p, err := gcp.NewProvider(l, svc, "my-project", nil) 99 | if err != nil { 100 | t.Fatal("unexpected error creating provider:", err) 101 | } 102 | 103 | disks, err := p.ListUnusedDisks(ctx) 104 | if err != nil { 105 | t.Fatal("unexpected error listing unused disks:", err) 106 | } 107 | 108 | if exp, got := 2, len(disks); exp != got { 109 | t.Errorf("expecting %d disks, got %d", exp, got) 110 | } 111 | 112 | err = unusedtest.AssertEqualMeta(unused.Meta{"zone": "us-central1-a"}, disks[0].Meta()) 113 | if err != nil { 114 | t.Fatalf("metadata doesn't match: %v", err) 115 | } 116 | err = unusedtest.AssertEqualMeta(unused.Meta{ 117 | "zone": "eu-west2-b", 118 | "kubernetes.io-created-for-pv-name": "pvc-prometheus-1", 119 | "kubernetes.io-created-for-pvc-name": "prometheus-1", 120 | "kubernetes.io-created-for-pvc-namespace": "monitoring", 121 | }, disks[1].Meta()) 122 | if err != nil { 123 | t.Fatalf("metadata doesn't match: %v", err) 124 | } 125 | 126 | t.Run("disk without JSON in description", func(t *testing.T) { 127 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 128 | // are we requesting the right API endpoint? 129 | if got, exp := req.URL.Path, "/projects/my-project/aggregated/disks"; exp != got { 130 | t.Fatalf("expecting request to %s, got %s", exp, got) 131 | } 132 | 133 | res := &compute.DiskAggregatedList{ 134 | Items: map[string]compute.DisksScopedList{ 135 | "foo": { 136 | Disks: []*compute.Disk{ 137 | {Name: "disk-2", Zone: "eu-west2-b", Description: "some string that isn't JSON"}, 138 | }, 139 | }, 140 | }, 141 | } 142 | 143 | b, _ := json.Marshal(res) 144 | _, err := w.Write(b) 145 | if err != nil { 146 | t.Fatalf("unexpected error writing response %v", err) 147 | } 148 | })) 149 | defer ts.Close() 150 | 151 | svc, err := compute.NewService(context.Background(), option.WithAPIKey("123abc"), option.WithEndpoint(ts.URL)) 152 | if err != nil { 153 | t.Fatalf("unexpected error creating GCP compute service: %v", err) 154 | } 155 | 156 | var buf bytes.Buffer 157 | l := slog.New(slog.NewTextHandler(&buf, nil)) 158 | 159 | p, err := gcp.NewProvider(l, svc, "my-project", nil) 160 | if err != nil { 161 | t.Fatal("unexpected error creating provider:", err) 162 | } 163 | 164 | disks, err := p.ListUnusedDisks(ctx) 165 | if err != nil { 166 | t.Fatal("unexpected error listing unused disks:", err) 167 | } 168 | 169 | if len(disks) != 1 { 170 | t.Fatalf("expecting 1 unused disk, got %d", len(disks)) 171 | } 172 | 173 | // check that we logged about it 174 | m, _ := regexp.MatchString(`msg="cannot parse disk metadata".+disk=disk-2`, buf.String()) 175 | if !m { 176 | t.Fatal("expecting a log line to be emitted") 177 | } 178 | }) 179 | } 180 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/unused 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 9 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 10 | github.com/aws/aws-sdk-go-v2 v1.36.3 11 | github.com/aws/aws-sdk-go-v2/config v1.29.14 12 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 13 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.222.0 14 | github.com/aws/smithy-go v1.22.3 15 | github.com/charmbracelet/bubbles v0.21.0 16 | github.com/charmbracelet/bubbletea v1.3.5 17 | github.com/charmbracelet/lipgloss v1.1.0 18 | github.com/evertras/bubble-table v0.17.1 19 | github.com/google/uuid v1.6.0 20 | github.com/prometheus/client_golang v1.22.0 21 | google.golang.org/api v0.234.0 22 | ) 23 | 24 | require ( 25 | cloud.google.com/go/auth v0.16.1 // indirect 26 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 27 | cloud.google.com/go/compute/metadata v0.7.0 // indirect 28 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect 29 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect 30 | github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect 31 | github.com/atotto/clipboard v0.1.4 // indirect 32 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect 33 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect 34 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect 35 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect 37 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect 38 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect 39 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect 40 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect 41 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 42 | github.com/beorn7/perks v1.0.1 // indirect 43 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 44 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 45 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 46 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 47 | github.com/charmbracelet/x/term v0.2.1 // indirect 48 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 49 | github.com/felixge/httpsnoop v1.0.4 // indirect 50 | github.com/go-logr/logr v1.4.2 // indirect 51 | github.com/go-logr/stdr v1.2.2 // indirect 52 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 53 | github.com/google/s2a-go v0.1.9 // indirect 54 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 55 | github.com/googleapis/gax-go/v2 v2.14.2 // indirect 56 | github.com/kylelemons/godebug v1.1.0 // indirect 57 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 58 | github.com/mattn/go-isatty v0.0.20 // indirect 59 | github.com/mattn/go-localereader v0.0.1 // indirect 60 | github.com/mattn/go-runewidth v0.0.16 // indirect 61 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 62 | github.com/muesli/cancelreader v0.2.2 // indirect 63 | github.com/muesli/reflow v0.3.0 // indirect 64 | github.com/muesli/termenv v0.16.0 // indirect 65 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 66 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 67 | github.com/prometheus/client_model v0.6.1 // indirect 68 | github.com/prometheus/common v0.62.0 // indirect 69 | github.com/prometheus/procfs v0.15.1 // indirect 70 | github.com/rivo/uniseg v0.4.7 // indirect 71 | github.com/sahilm/fuzzy v0.1.1 // indirect 72 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 73 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 74 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 75 | go.opentelemetry.io/otel v1.35.0 // indirect 76 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 77 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 78 | golang.org/x/crypto v0.38.0 // indirect 79 | golang.org/x/net v0.40.0 // indirect 80 | golang.org/x/oauth2 v0.30.0 // indirect 81 | golang.org/x/sync v0.14.0 // indirect 82 | golang.org/x/sys v0.33.0 // indirect 83 | golang.org/x/text v0.25.0 // indirect 84 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect 85 | google.golang.org/grpc v1.72.1 // indirect 86 | google.golang.org/protobuf v1.36.6 // indirect 87 | ) 88 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= 2 | cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= 3 | cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= 4 | cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= 5 | cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= 6 | cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= 7 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= 8 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= 9 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 h1:j8BorDEigD8UFOSZQiSqAMOOleyQOOQPnUAwV+Ls1gA= 10 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= 11 | github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= 12 | github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= 13 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= 14 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= 15 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 h1:z7Mqz6l0EFH549GvHEqfjKvi+cRScxLWbaoeLm9wxVQ= 16 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0/go.mod h1:v6gbfH+7DG7xH2kUNs+ZJ9tF6O3iNnR85wMtmr+F54o= 17 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= 18 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= 19 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= 20 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= 21 | github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= 22 | github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= 23 | github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= 24 | github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= 25 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 26 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 27 | github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= 28 | github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= 29 | github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= 30 | github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= 31 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= 32 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= 33 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= 34 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= 35 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= 36 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= 37 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= 38 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= 39 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 40 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 41 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.222.0 h1:qPVuEWzRvc/Z8UA0CKG4QczxORbgYTbWwlviUAmVmgs= 42 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.222.0/go.mod h1:ouvGEfHbLaIlWwpDpOVWPWR+YwO0HDv3vm5tYLq8ImY= 43 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= 44 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= 45 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= 46 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= 47 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= 48 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= 49 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= 50 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= 51 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= 52 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= 53 | github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= 54 | github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= 55 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 56 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 57 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 58 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 59 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 60 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 61 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 62 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 63 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 64 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 65 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 66 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 67 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 68 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 69 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 70 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 71 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 72 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 73 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 74 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 75 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 76 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 77 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 78 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 79 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 80 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 81 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 82 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 83 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 84 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 85 | github.com/evertras/bubble-table v0.17.1 h1:HJwq3iQrZulXDE93ZcqJNiUVQCBbN4IJ2CkB/IxO3kk= 86 | github.com/evertras/bubble-table v0.17.1/go.mod h1:ifHujS1YxwnYSOgcR2+m3GnJ84f7CVU/4kUOxUCjEbQ= 87 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 88 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 89 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 90 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 91 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 92 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 93 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 94 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 95 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 96 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 97 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 98 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 99 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 100 | github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 101 | github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 102 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 103 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 104 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= 105 | github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= 106 | github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= 107 | github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= 108 | github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= 109 | github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= 110 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 111 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 112 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 113 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 114 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 115 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 116 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 117 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 118 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 119 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 120 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 121 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 122 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 123 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 124 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 125 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 126 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 127 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 128 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 129 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 130 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 131 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 132 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 133 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 134 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 135 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 136 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 137 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 138 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 139 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 140 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 141 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 142 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 143 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 144 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 145 | github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= 146 | github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= 147 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 148 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 149 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 150 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 151 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 152 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 153 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 154 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 155 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 156 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 157 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 158 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 159 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= 160 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= 161 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= 162 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 163 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= 164 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 165 | go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= 166 | go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= 167 | go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= 168 | go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= 169 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= 170 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 171 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 172 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 173 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 174 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 175 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 176 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 177 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 178 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 179 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 180 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 181 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 182 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 183 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 184 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 185 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 186 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 187 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 188 | google.golang.org/api v0.234.0 h1:d3sAmYq3E9gdr2mpmiWGbm9pHsA/KJmyiLkwKfHBqU4= 189 | google.golang.org/api v0.234.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg= 190 | google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= 191 | google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= 192 | google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0= 193 | google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw= 194 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 h1:IkAfh6J/yllPtpYFU0zZN1hUPYdT0ogkBT/9hMxHjvg= 195 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 196 | google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= 197 | google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 198 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 199 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 200 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 201 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 202 | -------------------------------------------------------------------------------- /meta.go: -------------------------------------------------------------------------------- 1 | package unused 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | // Meta is a map of key/value pairs. 9 | type Meta map[string]string 10 | 11 | // Keys returns all the keys in the map sorted alphabetically. 12 | func (m Meta) Keys() []string { 13 | ks := make([]string, 0, len(m)) 14 | for k := range m { 15 | ks = append(ks, k) 16 | } 17 | sort.Strings(ks) 18 | return ks 19 | } 20 | 21 | // String returns a string representation of metadata. 22 | func (m Meta) String() string { 23 | var s strings.Builder 24 | for i, k := range m.Keys() { 25 | s.WriteString(k) 26 | s.WriteRune('=') 27 | s.WriteString(m[k]) 28 | if i < len(m)-1 { 29 | s.WriteRune(',') 30 | } 31 | } 32 | return s.String() 33 | } 34 | 35 | func (m Meta) Equals(b Meta) bool { 36 | if len(m) != len(b) { 37 | return false 38 | } 39 | 40 | for ak, av := range m { 41 | bv, ok := b[ak] 42 | if !ok || av != bv { 43 | return false 44 | } 45 | } 46 | 47 | return true 48 | } 49 | 50 | // Matches returns true when the given key exists in the map with the 51 | // given value. 52 | func (m Meta) Matches(key, val string) bool { 53 | return m[key] == val 54 | } 55 | 56 | func (m Meta) CreatedForPV() string { 57 | return m.coalesce("kubernetes.io/created-for/pv/name", "kubernetes.io-created-for-pv-name") 58 | } 59 | 60 | func (m Meta) CreatedForPVC() string { 61 | return m.coalesce("kubernetes.io/created-for/pvc/name", "kubernetes.io-created-for-pvc-name") 62 | } 63 | 64 | func (m Meta) CreatedForNamespace() string { 65 | return m.coalesce("kubernetes.io/created-for/pvc/namespace", "kubernetes.io-created-for-pvc-namespace") 66 | } 67 | 68 | func (m Meta) Zone() string { 69 | return m.coalesce("zone", "location") 70 | } 71 | 72 | func (m Meta) coalesce(keys ...string) string { 73 | for _, k := range keys { 74 | v, ok := m[k] 75 | if !ok { 76 | continue 77 | } 78 | 79 | return v 80 | } 81 | 82 | return "" 83 | } 84 | -------------------------------------------------------------------------------- /meta_test.go: -------------------------------------------------------------------------------- 1 | package unused 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | ) 7 | 8 | func TestMeta(t *testing.T) { 9 | m := &Meta{ 10 | "def": "123", 11 | "ghi": "456", 12 | "abc": "789", 13 | } 14 | 15 | t.Run("keys return sorted", func(t *testing.T) { 16 | keys := m.Keys() 17 | if len(keys) != 3 { 18 | t.Fatalf("expecting 3 keys, got %d", len(keys)) 19 | } 20 | if !sort.StringsAreSorted(keys) { 21 | t.Fatalf("expecting keys to be sorted, got %q", keys) 22 | } 23 | }) 24 | 25 | t.Run("string is sorted and comma separated", func(t *testing.T) { 26 | exp := "abc=789,def=123,ghi=456" 27 | got := m.String() 28 | 29 | if exp != got { 30 | t.Errorf("expecting String() %q, got %q", exp, got) 31 | } 32 | }) 33 | } 34 | 35 | func TestMetaMatches(t *testing.T) { 36 | m := &Meta{ 37 | "def": "123", 38 | "ghi": "456", 39 | "abc": "789", 40 | } 41 | 42 | if ok := m.Matches("ghi", "456"); !ok { 43 | t.Error("expecting match") 44 | } 45 | if ok := m.Matches("zyx", "123"); ok { 46 | t.Error("expecting no match for unrecognized key") 47 | } 48 | if ok := m.Matches("def", "789"); ok { 49 | t.Error("expecting no match for different value") 50 | } 51 | } 52 | 53 | func TestCoalesce(t *testing.T) { 54 | tests := []struct { 55 | name string 56 | m Meta 57 | input []string 58 | expected string 59 | }{ 60 | { 61 | name: "single key returns self", 62 | m: Meta{ 63 | "foo": "bar", 64 | }, 65 | input: []string{"foo"}, 66 | expected: "bar", 67 | }, 68 | { 69 | name: "multiple keys returns first non-nil, single match", 70 | m: Meta{ 71 | "foo": "bar", 72 | }, 73 | input: []string{"buz", "foo"}, 74 | expected: "bar", 75 | }, 76 | { 77 | name: "multiple keys returns first non-nil, many possible matches", 78 | m: Meta{ 79 | "foo": "bar", 80 | "buz": "qux", 81 | }, 82 | input: []string{"buz", "foo"}, 83 | expected: "qux", 84 | }, 85 | { 86 | name: "any value is returned if key is present", 87 | m: Meta{ 88 | "foo": "", 89 | "buz": "qux", 90 | }, 91 | input: []string{"foo", "buz"}, 92 | expected: "", 93 | }, 94 | { 95 | name: "no given keys returns zero value", 96 | m: Meta{ 97 | "foo": "bar", 98 | "buz": "qux", 99 | }, 100 | input: []string{}, 101 | expected: "", 102 | }, 103 | { 104 | name: "no matching keys returns zero value", 105 | m: Meta{ 106 | "foo": "bar", 107 | "buz": "qux", 108 | }, 109 | input: []string{"nope"}, 110 | expected: "", 111 | }, 112 | } 113 | for _, tt := range tests { 114 | t.Run(tt.name, func(t *testing.T) { 115 | actual := tt.m.coalesce(tt.input...) 116 | if tt.expected != actual { 117 | t.Fatalf("expected %v but got %v", tt.expected, actual) 118 | } 119 | }) 120 | } 121 | } 122 | 123 | func TestEquals(t *testing.T) { 124 | tests := []struct { 125 | name string 126 | m Meta 127 | input Meta 128 | expected bool 129 | }{ 130 | { 131 | name: "nil values are equal", 132 | m: Meta{}, 133 | input: Meta{}, 134 | expected: true, 135 | }, 136 | { 137 | name: "nil & non-nil values are not equal", 138 | m: Meta{"not": "nil"}, 139 | input: Meta{}, 140 | expected: false, 141 | }, 142 | { 143 | name: "same keys but different values are not equal", 144 | m: Meta{"a": "b"}, 145 | input: Meta{"a": "c"}, 146 | expected: false, 147 | }, 148 | { 149 | name: "same values but different keys are not equal", 150 | m: Meta{"a": "b"}, 151 | input: Meta{"c": "b"}, 152 | expected: false, 153 | }, 154 | { 155 | name: "same keys & values are equal", 156 | m: Meta{"a": "b", "c": "d"}, 157 | input: Meta{"a": "b", "c": "d"}, 158 | expected: true, 159 | }, 160 | { 161 | name: "order is irrelevant", 162 | m: Meta{"a": "b", "c": "d"}, 163 | input: Meta{"c": "d", "a": "b"}, 164 | expected: true, 165 | }, 166 | } 167 | for _, tt := range tests { 168 | t.Run(tt.name, func(t *testing.T) { 169 | actual := tt.m.Equals(tt.input) 170 | if tt.expected != actual { 171 | t.Fatalf("expected %v but got %v", tt.expected, actual) 172 | } 173 | }) 174 | } 175 | } 176 | 177 | func TestCreatedForPV(t *testing.T) { 178 | tests := []struct { 179 | name string 180 | m Meta 181 | expected string 182 | }{ 183 | { 184 | name: "GCP disk", 185 | m: Meta{"kubernetes.io/created-for/pv/name": "pvc-c898536e-1601-4357-af13-01bbe82f3055"}, 186 | expected: "pvc-c898536e-1601-4357-af13-01bbe82f3055", 187 | }, 188 | { 189 | name: "AWS disk", 190 | m: Meta{"kubernetes.io/created-for/pv/name": "pvc-b78d13ec-426f-4ec6-80aa-231a7d4e7db9"}, 191 | expected: "pvc-b78d13ec-426f-4ec6-80aa-231a7d4e7db9", 192 | }, 193 | { 194 | name: "Azure disk", 195 | m: Meta{"kubernetes.io-created-for-pv-name": "pvc-10df52de-2b9d-44a2-8901-4cbfc4871f8c"}, 196 | expected: "pvc-10df52de-2b9d-44a2-8901-4cbfc4871f8c", 197 | }, 198 | } 199 | for _, tt := range tests { 200 | t.Run(tt.name, func(t *testing.T) { 201 | actual := tt.m.CreatedForPV() 202 | if tt.expected != actual { 203 | t.Fatalf("expected %v but got %v", tt.expected, actual) 204 | } 205 | }) 206 | } 207 | } 208 | 209 | func TestCreatedForPVC(t *testing.T) { 210 | tests := []struct { 211 | name string 212 | m Meta 213 | expected string 214 | }{ 215 | { 216 | name: "GCP disk", 217 | m: Meta{"kubernetes.io/created-for/pvc/name": "qwerty"}, 218 | expected: "qwerty", 219 | }, 220 | { 221 | name: "AWS disk", 222 | m: Meta{"kubernetes.io/created-for/pvc/name": "asdf"}, 223 | expected: "asdf", 224 | }, 225 | { 226 | name: "Azure disk", 227 | m: Meta{"kubernetes.io-created-for-pvc-name": "zxcv"}, 228 | expected: "zxcv", 229 | }, 230 | } 231 | for _, tt := range tests { 232 | t.Run(tt.name, func(t *testing.T) { 233 | actual := tt.m.CreatedForPVC() 234 | if tt.expected != actual { 235 | t.Fatalf("expected %v but got %v", tt.expected, actual) 236 | } 237 | }) 238 | } 239 | } 240 | 241 | func TestCreatedForNamespace(t *testing.T) { 242 | tests := []struct { 243 | name string 244 | m Meta 245 | expected string 246 | }{ 247 | { 248 | name: "GCP disk", 249 | m: Meta{"kubernetes.io/created-for/pvc/namespace": "ns1"}, 250 | expected: "ns1", 251 | }, 252 | { 253 | name: "AWS disk", 254 | m: Meta{"kubernetes.io/created-for/pvc/namespace": "ns2"}, 255 | expected: "ns2", 256 | }, 257 | { 258 | name: "Azure disk", 259 | m: Meta{"kubernetes.io-created-for-pvc-namespace": "ns3"}, 260 | expected: "ns3", 261 | }, 262 | } 263 | for _, tt := range tests { 264 | t.Run(tt.name, func(t *testing.T) { 265 | actual := tt.m.CreatedForNamespace() 266 | if tt.expected != actual { 267 | t.Fatalf("expected %v but got %v", tt.expected, actual) 268 | } 269 | }) 270 | } 271 | } 272 | 273 | func TestZone(t *testing.T) { 274 | tests := []struct { 275 | name string 276 | m Meta 277 | expected string 278 | }{ 279 | { 280 | name: "GCP disk", 281 | m: Meta{"zone": "asia-south1-a"}, 282 | expected: "asia-south1-a", 283 | }, 284 | { 285 | name: "AWS disk", 286 | m: Meta{"zone": "us-east-2a"}, 287 | expected: "us-east-2a", 288 | }, 289 | { 290 | name: "Azure disk", 291 | m: Meta{"location": "Central US"}, 292 | expected: "Central US", 293 | }, 294 | } 295 | for _, tt := range tests { 296 | t.Run(tt.name, func(t *testing.T) { 297 | actual := tt.m.Zone() 298 | if tt.expected != actual { 299 | t.Fatalf("expected %v but got %v", tt.expected, actual) 300 | } 301 | }) 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /provider.go: -------------------------------------------------------------------------------- 1 | package unused 2 | 3 | import "context" 4 | 5 | // Provider represents a cloud provider. 6 | type Provider interface { 7 | // Name returns the provider name. 8 | Name() string 9 | 10 | // ID returns the project (GCP) or profile (AWS) or subscription (Azure) for this provider. 11 | ID() string 12 | 13 | // ListUnusedDisks returns a list of unused disks for the given 14 | // provider. 15 | ListUnusedDisks(ctx context.Context) (Disks, error) 16 | 17 | // Meta returns the provider metadata. 18 | Meta() Meta 19 | 20 | // Delete deletes a disk from the provider. 21 | Delete(ctx context.Context, disk Disk) error 22 | } 23 | -------------------------------------------------------------------------------- /unusedtest/disk.go: -------------------------------------------------------------------------------- 1 | package unusedtest 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/grafana/unused" 7 | ) 8 | 9 | var _ unused.Disk = Disk{} 10 | 11 | // Disk implements [unused.Disk] for testing purposes. 12 | type Disk struct { 13 | id, name string 14 | provider unused.Provider 15 | createdAt time.Time 16 | meta unused.Meta 17 | size int 18 | diskType unused.DiskType 19 | } 20 | 21 | // NewDisk returns a new test disk. 22 | func NewDisk(name string, provider unused.Provider, createdAt time.Time) Disk { 23 | return Disk{name, name, provider, createdAt, nil, 0, unused.Unknown} 24 | } 25 | 26 | func (d Disk) ID() string { return d.name } 27 | func (d Disk) Provider() unused.Provider { return d.provider } 28 | func (d Disk) Name() string { return d.name } 29 | func (d Disk) CreatedAt() time.Time { return d.createdAt } 30 | func (d Disk) Meta() unused.Meta { return d.meta } 31 | func (d Disk) LastUsedAt() time.Time { return d.createdAt.Add(1 * time.Minute) } 32 | func (d Disk) SizeGB() int { return d.size } 33 | func (d Disk) SizeBytes() float64 { return float64(d.size) * unused.GiBbytes } 34 | func (d Disk) DiskType() unused.DiskType { return d.diskType } 35 | -------------------------------------------------------------------------------- /unusedtest/disk_test.go: -------------------------------------------------------------------------------- 1 | package unusedtest_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/grafana/unused/unusedtest" 8 | ) 9 | 10 | func TestDisk(t *testing.T) { 11 | p := unusedtest.NewProvider("my-provider", nil) 12 | createdAt := time.Now().Round(0) 13 | d := unusedtest.NewDisk("my-disk", p, createdAt) 14 | 15 | if exp, got := "my-disk", d.ID(); exp != got { 16 | t.Errorf("expecting ID() %q, got %q", exp, got) 17 | } 18 | if d.Name() != "my-disk" { 19 | t.Errorf("expecting Name() my-disk, got %s", d.Name()) 20 | } 21 | if got := d.CreatedAt(); !createdAt.Equal(got) { 22 | t.Errorf("expectng CreatedAt() %v, got %v", createdAt, got) 23 | } 24 | if got := d.Provider(); got != p { 25 | t.Errorf("expecting Provider() %v, got %v", p, got) 26 | } 27 | if got, exp := d.LastUsedAt(), d.CreatedAt().Add(time.Minute); !got.Equal(exp) { 28 | t.Errorf("expecting LastUsedAt() %v, got %v", exp, got) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /unusedtest/meta.go: -------------------------------------------------------------------------------- 1 | package unusedtest 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/grafana/unused" 7 | ) 8 | 9 | // AssertEqualMeta returns nil if both [unused.Meta] arguments are 10 | // equal. 11 | func AssertEqualMeta(p, q unused.Meta) error { 12 | if e, g := len(p), len(q); e != g { 13 | return fmt.Errorf("expecting %d metadata items, got %d", e, g) 14 | } 15 | 16 | for k, v := range p { 17 | if g := q[k]; v != g { 18 | return fmt.Errorf("expecting metadata item %q with value %q, got %q", k, v, g) 19 | } 20 | } 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /unusedtest/meta_test.go: -------------------------------------------------------------------------------- 1 | package unusedtest_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/grafana/unused" 7 | "github.com/grafana/unused/unusedtest" 8 | ) 9 | 10 | func TestAssertEqualMeta(t *testing.T) { 11 | var err error 12 | 13 | err = unusedtest.AssertEqualMeta(unused.Meta{"foo": "bar"}, unused.Meta{"baz": "quux", "lorem": "ipsum"}) 14 | if err == nil { 15 | t.Fatal("expecting error with different metadata lengths") 16 | } 17 | 18 | err = unusedtest.AssertEqualMeta(unused.Meta{"foo": "bar"}, unused.Meta{"foo": "quux"}) 19 | if err == nil { 20 | t.Fatal("expecting error with different metadata value") 21 | } 22 | 23 | err = unusedtest.AssertEqualMeta(unused.Meta{"foo": "bar"}, unused.Meta{"lorem": "bar"}) 24 | if err == nil { 25 | t.Fatal("expecting error with different metadata") 26 | } 27 | 28 | err = unusedtest.AssertEqualMeta(unused.Meta{"foo": "bar"}, unused.Meta{"foo": "bar"}) 29 | if err != nil { 30 | t.Fatalf("unexpected error with equal metadata: %v", err) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /unusedtest/provider.go: -------------------------------------------------------------------------------- 1 | package unusedtest 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/grafana/unused" 9 | ) 10 | 11 | var _ unused.Provider = &Provider{} 12 | 13 | // Provider implements [unused.Provider] for testing purposes. 14 | type Provider struct { 15 | name string 16 | disks unused.Disks 17 | meta unused.Meta 18 | } 19 | 20 | // NewProvider returns a new test provider that return the given disks 21 | // as unused. 22 | func NewProvider(name string, meta unused.Meta, disks ...unused.Disk) *Provider { 23 | if meta == nil { 24 | meta = make(unused.Meta) 25 | } 26 | return &Provider{name, disks, meta} 27 | } 28 | 29 | func (p *Provider) Name() string { return p.name } 30 | 31 | func (p *Provider) ID() string { return "my-id" } 32 | 33 | func (p *Provider) Meta() unused.Meta { return p.meta } 34 | 35 | func (p *Provider) SetMeta(meta unused.Meta) { p.meta = meta } 36 | 37 | func (p *Provider) ListUnusedDisks(ctx context.Context) (unused.Disks, error) { 38 | return p.disks, nil 39 | } 40 | 41 | var ErrDiskNotFound = errors.New("disk not found") 42 | 43 | func (p *Provider) Delete(ctx context.Context, disk unused.Disk) error { 44 | for i := range p.disks { 45 | if disk.Name() == p.disks[i].Name() { 46 | p.disks = append(p.disks[:i], p.disks[i+1:]...) 47 | return nil 48 | } 49 | } 50 | 51 | return ErrDiskNotFound 52 | } 53 | 54 | // TestProviderMeta returns nil if the provider properly implements 55 | // storing metadata. 56 | // 57 | // It accepts a constructor function that should return a valid 58 | // [unused.Provider] or an error when it isn't compliant with the 59 | // semantics of creating a provider. 60 | func TestProviderMeta(newProvider func(meta unused.Meta) (unused.Provider, error)) error { 61 | tests := map[string]unused.Meta{ 62 | "nil": nil, 63 | "empty": map[string]string{}, 64 | "respect values": map[string]string{ 65 | "foo": "bar", 66 | }, 67 | } 68 | 69 | for name, expMeta := range tests { 70 | p, err := newProvider(expMeta) 71 | if err != nil { 72 | return fmt.Errorf("%s: unexpected error: %v", name, err) 73 | } 74 | 75 | meta := p.Meta() 76 | if meta == nil { 77 | return fmt.Errorf("%s: expecting metadata, got nil", name) 78 | } 79 | 80 | if exp, got := len(expMeta), len(meta); exp != got { 81 | return fmt.Errorf("%s: expecting %d metadata value, got %d", name, exp, got) 82 | } 83 | for k, v := range expMeta { 84 | if exp, got := v, meta[k]; exp != got { 85 | return fmt.Errorf("%s: expecting metadata %q with value %q, got %q", name, k, exp, got) 86 | } 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /unusedtest/provider_test.go: -------------------------------------------------------------------------------- 1 | package unusedtest_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "math/rand" 8 | "testing" 9 | "time" 10 | 11 | "github.com/grafana/unused" 12 | "github.com/grafana/unused/unusedtest" 13 | ) 14 | 15 | func TestNewProvider(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | id string 19 | disks unused.Disks 20 | }{ 21 | {"no-disks", "my-id", nil}, 22 | } 23 | 24 | for _, tt := range tests { 25 | ctx := context.Background() 26 | p := unusedtest.NewProvider(tt.name, nil, tt.disks...) 27 | 28 | if p.Name() != tt.name { 29 | t.Errorf("unexpected provider.Name() %q", p.Name()) 30 | } 31 | 32 | if p.ID() != tt.id { 33 | t.Errorf("unexpected provider.ID() %q", p.ID()) 34 | } 35 | 36 | disks, err := p.ListUnusedDisks(ctx) 37 | if err != nil { 38 | t.Fatal("unexpected error:", err) 39 | } 40 | if got, exp := len(disks), len(tt.disks); exp != got { 41 | t.Fatalf("expecting %d disks, got %d", exp, got) 42 | } 43 | 44 | for i, exp := range tt.disks { 45 | got := disks[i] 46 | if exp != got { 47 | t.Errorf("expecting disk %d to be %v, got %v", i, exp, got) 48 | } 49 | } 50 | } 51 | } 52 | 53 | func TestProviderDelete(t *testing.T) { 54 | ctx := context.Background() 55 | 56 | setup := func() (unused.Provider, unused.Disks) { 57 | now := time.Now() 58 | 59 | disks := make(unused.Disks, 10) 60 | p := unusedtest.NewProvider("my-provider", nil, disks...) 61 | for i := 0; i < cap(disks); i++ { 62 | disks[i] = unusedtest.NewDisk(fmt.Sprintf("disk-%03d", i), p, now) 63 | } 64 | 65 | return p, disks 66 | } 67 | 68 | run := func(t *testing.T, p unused.Provider, disks unused.Disks, idx int) { 69 | t.Helper() 70 | 71 | t.Logf("deleting disk at index %d", idx) 72 | 73 | d := disks[idx] 74 | 75 | if err := p.Delete(ctx, d); err != nil { 76 | t.Fatalf("unexpected error when deleting: %v", err) 77 | } 78 | 79 | ds, err := p.ListUnusedDisks(context.Background()) 80 | if err != nil { 81 | t.Fatalf("unexpected error listing: %v", err) 82 | } 83 | 84 | if exp, got := len(disks)-1, len(ds); exp != got { 85 | t.Errorf("expecting %d disks, got %d", exp, got) 86 | } 87 | 88 | for i := range ds { 89 | if ds[i].Name() == d.Name() { 90 | t.Fatalf("found disk %v at index %d", d, i) 91 | } 92 | } 93 | } 94 | 95 | t.Run("first", func(t *testing.T) { 96 | p, disks := setup() 97 | run(t, p, disks, 0) 98 | }) 99 | 100 | t.Run("last", func(t *testing.T) { 101 | p, disks := setup() 102 | run(t, p, disks, len(disks)-1) 103 | }) 104 | 105 | t.Run("random", func(t *testing.T) { 106 | p, disks := setup() 107 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 108 | run(t, p, disks, r.Intn(len(disks))) 109 | }) 110 | 111 | t.Run("not found", func(t *testing.T) { 112 | p, _ := setup() 113 | 114 | if err := p.Delete(ctx, unusedtest.NewDisk("foo-bar-baz", p, time.Now())); !errors.Is(err, unusedtest.ErrDiskNotFound) { 115 | t.Fatalf("expecting error %v, got %v", unusedtest.ErrDiskNotFound, err) 116 | } 117 | }) 118 | } 119 | 120 | func TestTestProviderMeta(t *testing.T) { 121 | t.Run("fail to create provider", func(t *testing.T) { 122 | err := unusedtest.TestProviderMeta(func(unused.Meta) (unused.Provider, error) { 123 | return nil, errors.New("foo") 124 | }) 125 | if err == nil { 126 | t.Fatal("expecting error") 127 | } 128 | }) 129 | 130 | t.Run("returns nil metadata", func(t *testing.T) { 131 | err := unusedtest.TestProviderMeta(func(unused.Meta) (unused.Provider, error) { 132 | p := unusedtest.NewProvider("my-provider", nil) 133 | p.SetMeta(nil) 134 | return p, nil 135 | }) 136 | if err == nil { 137 | t.Fatal("expecting error") 138 | } 139 | }) 140 | 141 | t.Run("returns different metadata length", func(t *testing.T) { 142 | err := unusedtest.TestProviderMeta(func(meta unused.Meta) (unused.Provider, error) { 143 | // ensure we are always sending at least twice the length 144 | newMeta := make(unused.Meta) 145 | for k, v := range meta { 146 | newMeta[k] = v 147 | newMeta[v] = k 148 | } 149 | return unusedtest.NewProvider("my-provider", newMeta), nil 150 | }) 151 | if err == nil { 152 | t.Fatal("expecting error") 153 | } 154 | }) 155 | 156 | t.Run("metadata is unchanged", func(t *testing.T) { 157 | err := unusedtest.TestProviderMeta(func(meta unused.Meta) (unused.Provider, error) { 158 | // ensure we are always sending at least twice the length 159 | newMeta := make(unused.Meta) 160 | for k := range meta { 161 | newMeta[k] = k 162 | } 163 | return unusedtest.NewProvider("my-provider", newMeta), nil 164 | }) 165 | if err == nil { 166 | t.Fatal("expecting error") 167 | } 168 | }) 169 | 170 | t.Run("passes all testes", func(t *testing.T) { 171 | err := unusedtest.TestProviderMeta(func(meta unused.Meta) (unused.Provider, error) { 172 | return unusedtest.NewProvider("my-provider", meta), nil 173 | }) 174 | if err != nil { 175 | t.Fatalf("unexpected error: %v", err) 176 | } 177 | }) 178 | } 179 | --------------------------------------------------------------------------------