├── .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 |
--------------------------------------------------------------------------------