├── .dockerignore
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── build-publish-docker.yml
│ ├── go.yml
│ └── release.yml
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── cmd
├── p.go
└── root.go
├── config
├── config.go
├── config_test.go
└── defaultConfig.yaml
├── go.mod
├── go.sum
├── install.sh
├── justfile
├── main.go
├── podman
├── containers.go
├── images.go
├── podmanApi.go
├── pods.go
└── volumes.go
├── prod.Dockerfile
├── service
├── dockercmd
│ ├── container.go
│ ├── container_test.go
│ ├── images.go
│ ├── images_test.go
│ ├── test_utils.go
│ ├── types.go
│ ├── util.go
│ ├── util_test.go
│ ├── volumes.go
│ └── volumes_test.go
├── podmancmd
│ ├── containers.go
│ ├── images.go
│ ├── pods.go
│ ├── test_util.go
│ ├── types.go
│ ├── util.go
│ └── volumes.go
├── service.go
└── types
│ └── types.go
├── stressTestingonStartup.sh
├── tui
├── buildProgress.go
├── components
│ ├── list
│ │ ├── defaultitem.go
│ │ ├── keys.go
│ │ ├── list.go
│ │ └── style.go
│ ├── loading.go
│ ├── progressBar.go
│ └── spinner.go
├── dialogs.go
├── entry.go
├── entry_test.go
├── info.go
├── infoCardWrapper.go
├── keys.go
├── keys_test.go
├── list.go
├── list_test.go
├── mainModel.go
├── mainModel_test.go
├── operations.go
├── operations_test.go
├── styles.go
├── table.go
├── types.go
├── types_test.go
├── util.go
├── util_test.go
├── windowSizeTooSmallDialog.go
└── windowSizeTooSmallDialog_test.go
└── vhs
├── build.tape
├── bulkDelete.tape
├── copyId.tape
├── delete.tape
├── exec.tape
├── execFromImgs.tape
├── gifs
├── build.gif
├── bulkDelete.gif
├── copyId.gif
├── delete.gif
├── exec.gif
├── execFromImgs.gif
├── intro.gif
├── logs.gif
├── notifications.gif
├── podmanRun.gif
├── prune.gif
├── runImage.gif
├── scout.gif
├── search.gif
└── startstop.gif
├── idk.tape
├── intro.tape
├── logs.tape
├── notification.tape
├── podmanRun.tape
├── prune.tape
├── runImage.tape
├── scout.tape
├── search.tape
└── startstop.tape
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "BUG \U0001F41E:"
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 |
16 | **Expected behavior**
17 | A clear and concise description of what you expected to happen.
18 |
19 | **Screenshots**
20 | If applicable, add screenshots to help explain your problem.
21 |
22 | **Desktop (please complete the following information):**
23 | - OS: [e.g. linux mint, arch, MacOS, windows 10]
24 | - gmd Version: [do `gmd -v` in your terminal]
25 | - Any other related info
26 |
27 | **Additional context**
28 | Add any other context about the problem here.
29 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Additional context**
17 | Add any other context or screenshots about the feature request here.
18 |
--------------------------------------------------------------------------------
/.github/workflows/build-publish-docker.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image
2 |
3 | on:
4 | workflow_dispatch:
5 | release:
6 | types: [published]
7 |
8 | jobs:
9 | push_to_registry:
10 | name: Push Docker image to Docker Hub
11 | runs-on: ubuntu-latest
12 | permissions:
13 | packages: write
14 | contents: read
15 | attestations: write
16 | id-token: write
17 | steps:
18 | - name: Check out the repo
19 | uses: actions/checkout@v4
20 |
21 | - name: Log in to Docker Hub
22 | uses: docker/login-action@v3
23 | with:
24 | username: ${{ secrets.DOCKER_HUB_USERNAME }}
25 | password: ${{ secrets.DOCKER_HUB_SECRET }}
26 |
27 | - name: Extract metadata (tags, labels) for Docker
28 | id: meta
29 | uses: docker/metadata-action@v4
30 | with:
31 | images: kakshipth/gomanagedocker
32 |
33 | - name: Build and push Docker image
34 | id: push
35 | uses: docker/build-push-action@v6
36 | with:
37 | context: .
38 | file: ./prod.Dockerfile
39 | push: true
40 | tags: ${{ env.IMAGE_NAME }}:latest, ${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
41 | labels: ${{ steps.meta.outputs.labels }}
42 |
43 | - name: Generate artifact attestation
44 | uses: actions/attest-build-provenance@v1
45 | with:
46 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
47 | subject-digest: ${{ steps.push.outputs.digest }}
48 | push-to-registry: true
49 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: Build and test on ubuntu and mac
2 |
3 | on:
4 | workflow_dispatch:
5 | pull_request:
6 |
7 | jobs:
8 | build-and-test:
9 | runs-on: ${{ matrix.os }}
10 | strategy:
11 | matrix:
12 | os: [ubuntu-20.04, macos-13]
13 | steps:
14 | - uses: actions/checkout@v4
15 | - uses: awalsh128/cache-apt-pkgs-action@latest
16 | if: ${{ matrix.os == 'ubuntu-20.04' }}
17 | with:
18 | packages: dia libbtrfs-dev libgpgme-dev
19 | version: 1.0
20 |
21 | - name: install mac os deps
22 | if: ${{ matrix.os == 'macos-13' }}
23 | run: brew install gpgme
24 |
25 | - name: Set up Go
26 | uses: actions/setup-go@v4
27 | with:
28 | go-version: '1.22'
29 |
30 | - name: Build
31 | run: go build .
32 |
33 | - name: Test
34 | run: go test ./...
35 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Create draft release
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 | types:
8 | - closed
9 | workflow_dispatch:
10 |
11 | jobs:
12 | build-linux:
13 | if: ${{ github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' }}
14 | runs-on: ubuntu-20.04
15 | steps:
16 | - uses: actions/checkout@v4
17 |
18 | - name: install linux deps
19 | uses: awalsh128/cache-apt-pkgs-action@latest
20 | with:
21 | packages: dia libbtrfs-dev libgpgme-dev
22 | version: 1.0
23 |
24 | - name: Set up Go
25 | uses: actions/setup-go@v4
26 | with:
27 | go-version: '1.22'
28 |
29 | - name: Build artifact amd64
30 | run: go build -o gmd_linux_amd64 .
31 |
32 | - name: Store version info
33 | run: echo "version=v$(./gmd_linux_amd64 -v | cut --delimiter ' ' --fields 3)" >> $GITHUB_ENV
34 |
35 | - name: Tar artifact
36 | run: tar czf gomanagedocker_linux_amd64_${version}.tar.gz gmd_linux_amd64
37 |
38 | - name: Upload linux assets
39 | uses: actions/upload-artifact@v4
40 | with:
41 | name: linux_artifacts
42 | path: gomanagedocker_linux*.tar.gz
43 |
44 | build-darwin:
45 | if: ${{ github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' }}
46 | runs-on: macos-13
47 | steps:
48 | - uses: actions/checkout@v4
49 |
50 | - name: install mac os deps
51 | run: brew install gpgme
52 |
53 | - name: Set up Go
54 | uses: actions/setup-go@v4
55 | with:
56 | go-version: '1.22'
57 |
58 | - name: Build artifact amd64
59 | run: go build -o gmd_darwin_amd64 .
60 |
61 | - name: Build artifact arm64
62 | run: env GOOS=darwin GOARCH=amd64 go build -o gmd_darwin_arm64 .
63 |
64 | - name: Store version info
65 | run: echo "version=v$(./gmd_darwin_amd64 -v | cut -d ' ' -f 3)" >> $GITHUB_ENV
66 |
67 | - name: Tar artifact
68 | run: |
69 | tar czf gomanagedocker_darwin_amd64_${version}.tar.gz gmd_darwin_amd64
70 | tar czf gomanagedocker_darwin_arm64_${version}.tar.gz gmd_darwin_arm64
71 |
72 | - name: Upload darwin assets
73 | uses: actions/upload-artifact@v4
74 | with:
75 | name: macos_artifacts
76 | path: gomanagedocker_darwin*.tar.gz
77 |
78 | create-release:
79 | if: ${{ github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' }}
80 | runs-on: ubuntu-latest
81 | needs: [build-linux, build-darwin]
82 | steps:
83 | - name: Download artifacts
84 | uses: actions/download-artifact@v4
85 |
86 | - name: Create draft release
87 | uses: softprops/action-gh-release@v2
88 | with:
89 | draft: true
90 | files: |
91 | linux_artifacts/*
92 | macos_artifacts/*
93 |
94 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Contributing Guidelines
2 |
3 | Hey 👋, thanks for your interest in contributing to goManageDocker.
4 |
5 | You can contribute in 3 ways:
6 |
7 | 1. **Report a Bug:** goManageDocker is still new and quite a few bugs might be snooping around! If you find a bug, you can [open an issue](https://github.com/ajayd-san/gomanagedocker/issues/new?assignees=&labels=&projects=&template=bug_report.md&title=BUG+%F0%9F%90%9E%3A) and report the bug, try to be as descriptive about the bug as possible, it genuinely helps.
8 | 2. **Request a feature:** Do you have an idea for a feature that could be a great addition to goManageDocker's blazing-fast (🦀) arsenal? [Open a feature request!](https://github.com/ajayd-san/gomanagedocker/issues/new?assignees=&labels=&projects=&template=feature_request.md&title=)
9 | 3. **Work on a feature/issue:** Do you want to pick up a feature/issue? Then keep reading!
10 |
11 | ## Setting up the development environment:
12 |
13 | ### Requirements:
14 | - Go 1.22.2
15 | - Docker (not really needed, but helpful to debug and final testing)
16 | - just: [Install Here](https://github.com/casey/just) (command runner, is nice to have, since this lets you run commands quickly)
17 | - dlv (debugger, optional)
18 |
19 | ### Setting up the Development environment
20 | 1. Fork this repository
21 | 2. Clone the forked repo to your local machine
22 | 3. Hack away!
23 |
24 | ### Debugging
25 | I've added a debug flag `--debug` that writes logs to `./gmd_debug.log`. It is helpful while debugging the TUI quickly (for instance, making sure the control flow works as intended) without running a whole debugger (delve), to write a log just put `log.Println({LOG})` where you find fit.
26 |
27 | Make sure to run using `go run main.go --debug` to enable logging.
28 |
29 | ### Create a PR.
30 | You have now made your changes, congrats! Open a PR and try to be as descriptive as possible, include pertinent information such as a detailed description, issue ID (if it fixes an issue), etc. Adding a test case (if applicable) will reinforce confidence and make the PR move faster.
31 |
32 |
33 | ### Justfile
34 |
35 | There are multiple recipes in the included [justfile](./justfile):
36 |
37 | 1. `just run`: compiles and runs the project with `--debug` flag
38 | 2. `just test`: runs all tests, across all packages
39 | 3. `just build`: builds the binary(Not used much)
40 | 4. `just race`: runs the project with the race detector. Pretty useful to detect race conditions.
41 | 5. `just debug-server`: starts a `dlv` debug server at `localhost:43000`
42 | 6. `just debug-connect`: connects to an existing debug session running at `localhost:43000`. Do `just debug-server` before running this.
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Ajay D.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # goManageDocker
2 |
3 | Do Docker commands slip your mind because you don't use Docker often enough? Sick of googling commands for everyday tasks? GoManageDocker is designed to NUKE this annoyance.
4 |
5 | Introducing **goManageDocker** (get it?)! This blazing fast TUI, made using Go and BubbleTea, will make managing your Docker objects a breeze.
6 |
7 | ## Contents
8 |
9 | 1. [Install Instructions](#install-instructions)
10 | 2. [Quick Start](#quick-start)
11 | 2. [Features](#features)
12 | 3. [Keybinds](#keybinds)
13 | 4. [Configuration](#configuration)
14 | 5. [Roadmap](#roadmap)
15 | 6. [Found an issue?](#found-an-issue-)
16 | 7. [Contributing](#contributing)
17 |
18 | ## Install Instructions
19 |
20 | ### Unix
21 |
22 | You can install the latest release of goManageDocker on UNIX systems with a simple bash script:
23 |
24 | ```
25 | bash -c "$(curl -sLo- https://raw.githubusercontent.com/ajayd-san/gomanagedocker/main/install.sh)"
26 | ```
27 |
28 | This is the recommended way to install on Linux(`amd64` only) and MacOS(both `intel` and `arm`) systems.
29 | Start the program with `gmd`.
30 |
31 | ### Windows
32 |
33 | Building from source is currently the only way to install this on Windows. See next section.
34 |
35 | ### Build from source
36 |
37 | Just build like any other Go binary, this is currently the only way to make goManageDocker work on Windows and arm64 chipsets running **Linux**:
38 |
39 | ```
40 | go install github.com/ajayd-san/gomanagedocker@main
41 | ```
42 |
43 | Start the program with `gomanagedocker` (Rename it to `gmd` if you'd like, the binary will be installed at your `$GOPATH`).
44 |
45 |
46 | ### Docker
47 | Want to try this without installing a binary? I gotchu!
48 |
49 | **Docker:**
50 |
51 | ```
52 | docker run -it -v /var/run/docker.sock:/var/run/docker.sock kakshipth/gomanagedocker:latest
53 | ```
54 |
55 | **Podman:**
56 |
57 | First start the podman service:
58 |
59 | ```
60 | systemctl --user start podman.socket
61 | ```
62 |
63 | And then:
64 | ```
65 | docker run -it -v /run/user/1000/podman/podman.sock:/run/user/1000/podman/podman.sock kakshipth/gomanagedocker:latest p
66 | ```
67 |
68 | Alias it to something quicker (unless you like typing a lot 🙄)
69 |
70 | ## Quick Start
71 |
72 | ### docker
73 |
74 | To connect to the docker service:
75 | ```
76 | gmd
77 | ```
78 |
79 |
80 | ### podman
81 |
82 | First start the podman service:
83 |
84 | ```
85 | systemctl --user start podman.socket
86 | ```
87 |
88 | (replace `start` with `enable` if you'd like to start it during every boot)
89 |
90 | To connect to the podman service:
91 |
92 | ```
93 | gmd p
94 | ```
95 |
96 | (Issuing the subcommand `p` connects to the podman socket)
97 |
98 | > [!NOTE]
99 | > The command to invoke the TUI changes depending on the install method, if you installed from source you would be typing `gomanagedocker` instead of `gmd` (unless you aliased it to `gmd`).
100 |
101 |
102 | Now, **goManageDocker 😏!!**
103 |
104 | > [!NOTE]
105 | > goManageDocker runs best on terminals that support ANSI 256 colors and designed to run while the **terminal is maximized**.
106 |
107 | ## Features
108 |
109 | ### **New in v1.5:**
110 |
111 | 1. goManageDocker now has first class support for Podman!! (who doesn't like more secure containers 😉). You can now manage podman images, containers, volumes and even pods from the TUI!
112 |
113 | 
114 |
115 |
116 |
117 |
118 | ### **Previous release features:**
119 |
120 |
121 | 1. Easy navigation with vim keybinds and arrow keys.
122 | 
123 |
124 | 2. Exec into selected container with A SINGLE KEYSTROKE: `x`...How cool is that?
125 | 
126 |
127 | 3. Delete objects using `d` (You can force delete with `D`, you won't have to answer a prompt this way)
128 | 
129 |
130 | 4. Prune objects using `p`
131 | 
132 |
133 | 5. start/stop/pause/restart containers with `s`, `t` and `r`
134 | 
135 |
136 | 6. Filter objects with `/`
137 | 
138 |
139 | 7. Perfrom docker scout with `s`
140 | 
141 |
142 | 8. Run an image directly from the image tab by pressing `r`.
143 | 
144 |
145 | 9. You can directly copy the ID to your clipboard of an object by pressing `c`.
146 | 
147 |
148 | 10. You can now run and exec into an image directly from the images tab with `x`
149 | 
150 |
151 | 11. Global notification system
152 | 
153 |
154 | 12. Bulk operation mode: select multiple objects before performing an operations (saves so much time!!)
155 | 
156 |
157 | 13. Build image from Dockerfile using `b`
158 | 
159 |
160 | 14. View live logs from a container using `L`
161 | 
162 |
163 | 15. Run image now takes arguments for port, name and env vars.
164 | 
165 |
166 | ## Keybinds
167 |
168 | ### Navigation
169 | | Operation | Key |
170 | |------------------|---------------------------------------------------------------------|
171 | | Back | Esc |
172 | | Quit | Ctrl + c / q |
173 | | Next Tab | → / l / Tab |
174 | | Prev Tab | ← / h / Shift + Tab |
175 | | Next Item | ↓ / j |
176 | | Prev Item | ↑ / k |
177 | | Next Page | [ |
178 | | Prev Page | ] |
179 | | Enter bulk mode | Space |
180 |
181 | ### Image
182 | | Operation | Key |
183 | |-------------------|---------------------------------------------------------------|
184 | | Run | r |
185 | | Build Image | b |
186 | | Scout | s |
187 | | Prune | p |
188 | | Delete | d |
189 | | Delete (Force) | D |
190 | | Copy ID | c |
191 | | Run and Exec | x |
192 |
193 | ### Container
194 | | Operation | Key |
195 | |-------------------|---------------------------------------------------------------|
196 | | Toggle List All | a |
197 | | Toggle Start/Stop | s |
198 | | Toggle Pause | t |
199 | | Restart | r |
200 | | Delete | d |
201 | | Delete (Force) | D |
202 | | Exec | x |
203 | | Prune | p |
204 | | Copy ID | c |
205 | | Show Logs | L |
206 |
207 | ### Volume
208 | | Operation | Key |
209 | |-------------------|---------------------------------------------------------------|
210 | | Delete | d |
211 | | Prune | p |
212 | | Copy Volume Name | c |
213 |
214 |
215 | ### Pods
216 | | Operation | Key |
217 | |-------------------|---------------------------------------------------------------|
218 | | Create New Pod | n |
219 | | Toggle Start/Stop | s |
220 | | Toggle Pause | t |
221 | | Restart | r |
222 | | Delete | d |
223 | | Delete (Force) | D |
224 | | Prune | p |
225 | | Copy ID | c |
226 | | Show Logs | L |
227 |
228 | ## Configuration
229 |
230 | I've added support for config files from V1.2.
231 |
232 | Place `gomanagedocker/gomanagedocker.yaml` in your XDG config folder and configure to your heart's content!
233 |
234 | Default Configuration:
235 |
236 | ```
237 | config:
238 | Polling-Time: 500
239 | Tab-Order:
240 | Docker: [images, containers, volumes]
241 | Podman: [images, containers, volumes, pods]
242 | Notification-Timeout: 2000
243 |
244 | ```
245 |
246 | - Polling-Time: Set how frequently the program calls the docker API (measured in milliseconds, default: 500ms)
247 | - Tab-Order: Define the order of tabs displayed for Docker and Podman. Each key specifies the tab order for its respective environment. Valid tabs include `images`, `containers`, `volumes`, and `pods` (for Podman only). You can omit tabs you don’t wish to display.
248 | - Notification-Timeout: Set how long a status message sticks around for (measured in milliseconds, default: 2000ms)
249 |
250 | ## Roadmap
251 |
252 | - Add a networks tab
253 | - ~~Make compatible with podman 👀~~
254 |
255 | ## Found an issue ?
256 |
257 | Feel free to open a new issue, I will take a look ASAP.
258 |
259 | ## Contributing
260 |
261 | Please refer [CONTRIBUTING.md](./CONTRIBUTING.md) for more info.
262 |
263 | ## Thanks!!
264 |
265 | 
266 |
--------------------------------------------------------------------------------
/cmd/p.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2024 Saivenkat Ajay D.
3 | */
4 | package cmd
5 |
6 | import (
7 | "github.com/ajayd-san/gomanagedocker/service/types"
8 | "github.com/ajayd-san/gomanagedocker/tui"
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | // pCmd represents the p command
13 | var pCmd = &cobra.Command{
14 | Use: "p",
15 | Short: "Manage Podman objects",
16 | RunE: func(cmd *cobra.Command, args []string) error {
17 | return tui.StartTUI(debug, types.Podman)
18 | },
19 | }
20 |
21 | func init() {
22 | rootCmd.AddCommand(pCmd)
23 | }
24 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2024 Saivenkat Ajay D.
3 | */
4 | package cmd
5 |
6 | import (
7 | _ "embed"
8 | "os"
9 |
10 | "github.com/ajayd-san/gomanagedocker/service/types"
11 | "github.com/ajayd-san/gomanagedocker/tui"
12 | "github.com/spf13/cobra"
13 | )
14 |
15 | var (
16 | debug bool
17 | rootCmd = &cobra.Command{
18 | Use: "gmd",
19 | Short: "TUI to manage docker objects",
20 | Long: `The Definitive TUI to manage docker objects with ease.`,
21 | Version: "1.5",
22 | RunE: func(cmd *cobra.Command, args []string) error {
23 | return tui.StartTUI(debug, types.Docker)
24 | },
25 | }
26 | )
27 |
28 | func Execute() {
29 | err := rootCmd.Execute()
30 | if err != nil {
31 | os.Exit(1)
32 | }
33 | }
34 |
35 | func init() {
36 | rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "Send logs to ./gmd_debug.log")
37 | }
38 |
--------------------------------------------------------------------------------
/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "log"
5 |
6 | _ "embed"
7 |
8 | "github.com/knadh/koanf/parsers/yaml"
9 | "github.com/knadh/koanf/providers/file"
10 | "github.com/knadh/koanf/providers/rawbytes"
11 | "github.com/knadh/koanf/v2"
12 | )
13 |
14 | //go:embed defaultConfig.yaml
15 | var defaultConfigRaw []byte
16 |
17 | func ReadConfig(config *koanf.Koanf, path string) {
18 | if err := config.Load(rawbytes.Provider(defaultConfigRaw), yaml.Parser()); err != nil {
19 | log.Fatal("Could not load default config\n")
20 | }
21 |
22 | config.Load(file.Provider(path), yaml.Parser())
23 | }
24 |
--------------------------------------------------------------------------------
/config/config_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/google/go-cmp/cmp"
8 | "github.com/knadh/koanf/v2"
9 | )
10 |
11 | func TestReadConfig(t *testing.T) {
12 |
13 | tests := []struct {
14 | UserConfig string
15 | Want map[string]any
16 | }{
17 | {
18 | UserConfig: "",
19 | Want: map[string]any{
20 | "config.Polling-Time": 500,
21 | "config.Tab-Order.Docker": []any{"images", "containers", "volumes"},
22 | "config.Tab-Order.Podman": []any{"images", "containers", "volumes", "pods"},
23 | "config.Notification-Timeout": 2000,
24 | },
25 | },
26 | {
27 | UserConfig: `config:
28 | Polling-Time: 100`,
29 | Want: map[string]any{
30 | "config.Polling-Time": 100,
31 | "config.Tab-Order.Docker": []any{"images", "containers", "volumes"},
32 | "config.Tab-Order.Podman": []any{"images", "containers", "volumes", "pods"},
33 | "config.Notification-Timeout": 2000,
34 | },
35 | },
36 | {
37 | UserConfig: `config:
38 | Polling-Time: 200
39 | Tab-Order:
40 | Docker: [containers, volumes]
41 | Notification-Timeout: 10000`,
42 | Want: map[string]any{
43 | "config.Polling-Time": 200,
44 | "config.Tab-Order.Docker": []any{"containers", "volumes"},
45 | "config.Tab-Order.Podman": []any{"images", "containers", "volumes", "pods"},
46 | "config.Notification-Timeout": 10000,
47 | },
48 | },
49 | }
50 |
51 | for id, test := range tests {
52 | tempFile, _ := os.CreateTemp("", "")
53 | tempFile.WriteString(test.UserConfig)
54 | defer os.Remove(tempFile.Name())
55 |
56 | got := koanf.New(".")
57 | filePath := tempFile.Name()
58 | ReadConfig(got, filePath)
59 |
60 | if !cmp.Equal(got.All(), test.Want) {
61 | t.Errorf("Fail %d: %s", id, cmp.Diff(got.All(), test.Want))
62 | }
63 |
64 | }
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/config/defaultConfig.yaml:
--------------------------------------------------------------------------------
1 | config:
2 | Polling-Time: 500
3 | Tab-Order:
4 | Docker: [images, containers, volumes]
5 | Podman: [images, containers, volumes, pods]
6 | Notification-Timeout: 2000
7 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/ajayd-san/gomanagedocker
2 |
3 | // replace github.com/ajayd-san/teaDialog => /mnt/devenv/go/teaDialog
4 |
5 | go 1.22.2
6 |
7 | require (
8 | github.com/ajayd-san/teaDialog v1.1.11
9 | github.com/containers/buildah v1.37.1
10 | github.com/containers/common v0.60.1
11 | github.com/containers/podman/v5 v5.2.1
12 | github.com/docker/docker v27.1.1+incompatible
13 | github.com/evertras/bubble-table v0.16.0
14 | github.com/google/go-cmp v0.6.0
15 | github.com/knadh/koanf/parsers/yaml v0.1.0
16 | github.com/knadh/koanf/providers/file v0.1.0
17 | github.com/knadh/koanf/v2 v2.1.1
18 | github.com/muesli/reflow v0.3.0
19 | github.com/sahilm/fuzzy v0.1.1
20 | github.com/spf13/cobra v1.8.1
21 | )
22 |
23 | require (
24 | dario.cat/mergo v1.0.0 // indirect
25 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20240716105424-66b64c4bb379 // indirect
26 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
27 | github.com/BurntSushi/toml v1.4.0 // indirect
28 | github.com/Microsoft/hcsshim v0.12.5 // indirect
29 | github.com/VividCortex/ewma v1.2.0 // indirect
30 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
31 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
32 | github.com/atotto/clipboard v0.1.4 // indirect
33 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
34 | github.com/blang/semver/v4 v4.0.0 // indirect
35 | github.com/charmbracelet/harmonica v0.2.0 // indirect
36 | github.com/charmbracelet/x/ansi v0.4.5 // indirect
37 | github.com/charmbracelet/x/input v0.2.0 // indirect
38 | github.com/charmbracelet/x/term v0.1.1 // indirect
39 | github.com/chzyer/readline v1.5.1 // indirect
40 | github.com/cilium/ebpf v0.11.0 // indirect
41 | github.com/containerd/cgroups/v3 v3.0.3 // indirect
42 | github.com/containerd/containerd v1.7.20 // indirect
43 | github.com/containerd/errdefs v0.1.0 // indirect
44 | github.com/containerd/platforms v0.2.1 // indirect
45 | github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect
46 | github.com/containers/image/v5 v5.32.1 // indirect
47 | github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect
48 | github.com/containers/ocicrypt v1.2.0 // indirect
49 | github.com/containers/psgo v1.9.0 // indirect
50 | github.com/containers/storage v1.55.0 // indirect
51 | github.com/coreos/go-systemd/v22 v22.5.1-0.20231103132048-7d375ecc2b09 // indirect
52 | github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f // indirect
53 | github.com/cyphar/filepath-securejoin v0.3.1 // indirect
54 | github.com/disiqueira/gotree/v3 v3.0.2 // indirect
55 | github.com/docker/distribution v2.8.3+incompatible // indirect
56 | github.com/docker/docker-credential-helpers v0.8.2 // indirect
57 | github.com/dustin/go-humanize v1.0.1 // indirect
58 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
59 | github.com/fsnotify/fsnotify v1.7.0 // indirect
60 | github.com/go-jose/go-jose/v4 v4.0.2 // indirect
61 | github.com/go-openapi/analysis v0.23.0 // indirect
62 | github.com/go-openapi/errors v0.22.0 // indirect
63 | github.com/go-openapi/jsonpointer v0.21.0 // indirect
64 | github.com/go-openapi/jsonreference v0.21.0 // indirect
65 | github.com/go-openapi/loads v0.22.0 // indirect
66 | github.com/go-openapi/runtime v0.28.0 // indirect
67 | github.com/go-openapi/spec v0.21.0 // indirect
68 | github.com/go-openapi/strfmt v0.23.0 // indirect
69 | github.com/go-openapi/swag v0.23.0 // indirect
70 | github.com/go-openapi/validate v0.24.0 // indirect
71 | github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect
72 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
73 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
74 | github.com/golang/protobuf v1.5.4 // indirect
75 | github.com/google/go-containerregistry v0.20.0 // indirect
76 | github.com/google/go-intervals v0.0.2 // indirect
77 | github.com/google/uuid v1.6.0 // indirect
78 | github.com/gorilla/mux v1.8.1 // indirect
79 | github.com/gorilla/schema v1.4.1 // indirect
80 | github.com/hashicorp/errwrap v1.1.0 // indirect
81 | github.com/hashicorp/go-multierror v1.1.1 // indirect
82 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
83 | github.com/jinzhu/copier v0.4.0 // indirect
84 | github.com/josharian/intern v1.0.0 // indirect
85 | github.com/json-iterator/go v1.1.12 // indirect
86 | github.com/klauspost/compress v1.17.9 // indirect
87 | github.com/klauspost/pgzip v1.2.6 // indirect
88 | github.com/knadh/koanf/maps v0.1.1 // indirect
89 | github.com/kr/fs v0.1.0 // indirect
90 | github.com/letsencrypt/boulder v0.0.0-20240418210053-89b07f4543e0 // indirect
91 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
92 | github.com/mailru/easyjson v0.7.7 // indirect
93 | github.com/manifoldco/promptui v0.9.0 // indirect
94 | github.com/mattn/go-isatty v0.0.20 // indirect
95 | github.com/mattn/go-localereader v0.0.1 // indirect
96 | github.com/mattn/go-runewidth v0.0.16 // indirect
97 | github.com/mattn/go-sqlite3 v1.14.22 // indirect
98 | github.com/miekg/pkcs11 v1.1.1 // indirect
99 | github.com/mistifyio/go-zfs/v3 v3.0.1 // indirect
100 | github.com/mitchellh/copystructure v1.2.0 // indirect
101 | github.com/mitchellh/mapstructure v1.5.0 // indirect
102 | github.com/mitchellh/reflectwalk v1.0.2 // indirect
103 | github.com/moby/patternmatcher v0.6.0 // indirect
104 | github.com/moby/sys/mountinfo v0.7.2 // indirect
105 | github.com/moby/sys/sequential v0.6.0 // indirect
106 | github.com/moby/sys/user v0.2.0 // indirect
107 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
108 | github.com/modern-go/reflect2 v1.0.2 // indirect
109 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
110 | github.com/muesli/cancelreader v0.2.2 // indirect
111 | github.com/muesli/termenv v0.15.2 // indirect
112 | github.com/nxadm/tail v1.4.11 // indirect
113 | github.com/oklog/ulid v1.3.1 // indirect
114 | github.com/opencontainers/runc v1.1.13 // indirect
115 | github.com/opencontainers/runtime-spec v1.2.0 // indirect
116 | github.com/opencontainers/runtime-tools v0.9.1-0.20230914150019-408c51e934dc // indirect
117 | github.com/opencontainers/selinux v1.11.0 // indirect
118 | github.com/ostreedev/ostree-go v0.0.0-20210805093236-719684c64e4f // indirect
119 | github.com/pkg/sftp v1.13.6 // indirect
120 | github.com/proglottis/gpgme v0.1.3 // indirect
121 | github.com/rivo/uniseg v0.4.7 // indirect
122 | github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect
123 | github.com/sigstore/fulcio v1.4.5 // indirect
124 | github.com/sigstore/rekor v1.3.6 // indirect
125 | github.com/sigstore/sigstore v1.8.4 // indirect
126 | github.com/sirupsen/logrus v1.9.3 // indirect
127 | github.com/spf13/pflag v1.0.5 // indirect
128 | github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect
129 | github.com/sylabs/sif/v2 v2.18.0 // indirect
130 | github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
131 | github.com/tchap/go-patricia/v2 v2.3.1 // indirect
132 | github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
133 | github.com/ulikunitz/xz v0.5.12 // indirect
134 | github.com/vbatts/tar-split v0.11.5 // indirect
135 | github.com/vbauerster/mpb/v8 v8.7.5 // indirect
136 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
137 | go.mongodb.org/mongo-driver v1.14.0 // indirect
138 | go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect
139 | go.opencensus.io v0.24.0 // indirect
140 | golang.org/x/crypto v0.26.0 // indirect
141 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
142 | golang.org/x/exp/shiny v0.0.0-20240613232115-7f521ea00fb8 // indirect
143 | golang.org/x/image v0.14.0 // indirect
144 | golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
145 | golang.org/x/net v0.28.0 // indirect
146 | golang.org/x/sync v0.8.0 // indirect
147 | golang.org/x/term v0.23.0 // indirect
148 | golang.org/x/text v0.17.0 // indirect
149 | golang.org/x/time v0.5.0 // indirect
150 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
151 | google.golang.org/grpc v1.64.1 // indirect
152 | google.golang.org/protobuf v1.34.2 // indirect
153 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
154 | gopkg.in/yaml.v3 v3.0.1 // indirect
155 | sigs.k8s.io/yaml v1.4.0 // indirect
156 | tags.cncf.io/container-device-interface v0.8.0 // indirect
157 | )
158 |
159 | require (
160 | github.com/Microsoft/go-winio v0.6.2 // indirect
161 | github.com/charmbracelet/bubbles v0.18.0
162 | github.com/charmbracelet/bubbletea v0.26.4
163 | github.com/charmbracelet/lipgloss v0.13.0
164 | github.com/containerd/log v0.1.0 // indirect
165 | github.com/distribution/reference v0.6.0 // indirect
166 | github.com/docker/go-connections v0.5.0
167 | github.com/docker/go-units v0.5.0 // indirect
168 | github.com/felixge/httpsnoop v1.0.4 // indirect
169 | github.com/go-logr/logr v1.4.2 // indirect
170 | github.com/go-logr/stdr v1.2.2 // indirect
171 | github.com/gogo/protobuf v1.3.2 // indirect
172 | github.com/knadh/koanf/providers/rawbytes v0.1.0
173 | github.com/moby/docker-image-spec v1.3.1 // indirect
174 | github.com/moby/term v0.5.0 // indirect
175 | github.com/morikuni/aec v1.0.0 // indirect
176 | github.com/opencontainers/go-digest v1.0.0 // indirect
177 | github.com/opencontainers/image-spec v1.1.0 // indirect
178 | github.com/pkg/errors v0.9.1 // indirect
179 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
180 | go.opentelemetry.io/otel v1.26.0 // indirect
181 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 // indirect
182 | go.opentelemetry.io/otel/metric v1.26.0 // indirect
183 | go.opentelemetry.io/otel/sdk v1.25.0 // indirect
184 | go.opentelemetry.io/otel/trace v1.26.0 // indirect
185 | golang.design/x/clipboard v0.7.0
186 | golang.org/x/sys v0.27.0 // indirect
187 | gotest.tools/v3 v3.5.1
188 | )
189 |
--------------------------------------------------------------------------------
/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | ## Modified script from github.com/yoruokot/superfile. Thanks!!
4 |
5 | green='\033[0;32m'
6 | red='\033[0;31m'
7 | yellow='\033[0;33m'
8 | blue='\033[0;34m'
9 | purple='\033[0;35m'
10 | cyan='\033[0;36m'
11 | white='\033[0;37m'
12 | bright_red='\033[1;31m'
13 | bright_green='\033[1;32m'
14 | bright_yellow='\033[1;33m'
15 | bright_blue='\033[1;34m'
16 | bright_purple='\033[1;35m'
17 | bright_cyan='\033[1;36m'
18 | bright_white='\033[1;37m'
19 | nc='\033[0m' # No Color
20 |
21 | echo -e '
22 | \033[1;36m
23 | ░██████╗░░█████╗░███╗░░░███╗░█████╗░███╗░░██╗░█████╗░░██████╗░███████╗██████╗░░█████╗░░█████╗░██╗░░██╗███████╗██████╗░
24 | ██╔════╝░██╔══██╗████╗░████║██╔══██╗████╗░██║██╔══██╗██╔════╝░██╔════╝██╔══██╗██╔══██╗██╔══██╗██║░██╔╝██╔════╝██╔══██╗
25 | ██║░░██╗░██║░░██║██╔████╔██║███████║██╔██╗██║███████║██║░░██╗░█████╗░░██║░░██║██║░░██║██║░░╚═╝█████═╝░█████╗░░██████╔╝
26 | ██║░░╚██╗██║░░██║██║╚██╔╝██║██╔══██║██║╚████║██╔══██║██║░░╚██╗██╔══╝░░██║░░██║██║░░██║██║░░██╗██╔═██╗░██╔══╝░░██╔══██╗
27 | ╚██████╔╝╚█████╔╝██║░╚═╝░██║██║░░██║██║░╚███║██║░░██║╚██████╔╝███████╗██████╔╝╚█████╔╝╚█████╔╝██║░╚██╗███████╗██║░░██║
28 | ░╚═════╝░░╚════╝░╚═╝░░░░░╚═╝╚═╝░░╚═╝╚═╝░░╚══╝╚═╝░░╚═╝░╚═════╝░╚══════╝╚═════╝░░╚════╝░░╚════╝░╚═╝░░╚═╝╚══════╝╚═╝░░╚═╝
29 | '
30 |
31 | package=gomanagedocker
32 | arch=$(uname -m)
33 | os=$(uname -s)
34 |
35 | if [[ "$arch" == "x86_64" ]]; then
36 | arch="amd64"
37 | elif [[ "$arch" == "arm"* || "$arch" == "aarch64" ]]; then
38 | arch="arm64"
39 | else
40 | echo -e "${red}❌ Fail install goManageDocker: ${yellow}Unsupported architecture: ${nc}${arch}"
41 | exit 1
42 | fi
43 |
44 | if [[ "$os" == "Linux" ]]; then
45 | os="linux"
46 | elif [[ "$os" == "Darwin" ]]; then
47 | os="darwin"
48 | else
49 | echo -e "${red}❌ Fail install goManageDocker: ${yellow}Unsupported operating system${nc}${arch}"
50 | exit 1
51 | fi
52 |
53 | # allow specifying different destination directory in ENV
54 | DIR="${DIR:-"/usr/local/bin"}"
55 |
56 | GITHUB_LATEST_VERSION=$(curl -L -s -H 'Accept: application/json' https://github.com/ajayd-san/gomanagedocker/releases/latest | sed -e 's/.*"tag_name":"\([^"]*\)".*/\1/')
57 | FILE_NAME=${package}_${os}_${arch}_${GITHUB_LATEST_VERSION}
58 | GITHUB_URL="https://github.com/ajayd-san/gomanagedocker/releases/download/${GITHUB_LATEST_VERSION}/${FILE_NAME}.tar.gz"
59 |
60 | # install/update the local binary
61 | echo -e "${bright_yellow}Downloading ${cyan}${package} v${version} for ${os} (${arch})...${nc}"
62 | curl -L -o gomanagedocker.tar.gz $GITHUB_URL
63 |
64 | echo -e "${bright_yellow}Extracting ${cyan}${package}...${nc}"
65 | tar xzvf gomanagedocker.tar.gz -O >gmd
66 |
67 | if sudo install -Dm 755 gmd -t "$DIR" && rm gmd gomanagedocker.tar.gz; then
68 | echo -e "🎉 ${bright_green}Installation complete!${nc}"
69 | echo -e "${bright_cyan}You can type ${white}\"${bright_yellow}gmd${white}\" ${bright_cyan}to start!${nc}"
70 | else
71 | echo -e "${red}❌ Fail install goManageDocker to ${DIR} ${nc}"
72 | fi
73 |
--------------------------------------------------------------------------------
/justfile:
--------------------------------------------------------------------------------
1 | alias r := run
2 |
3 | # compile and run with debug flag
4 | run:
5 | go run main.go --debug
6 |
7 | runp:
8 | go run main.go p --debug
9 |
10 | # run all tests, disable caching
11 | test:
12 | go test ./... -count=1
13 |
14 | build:
15 | go build .
16 |
17 | # run race detector
18 | race:
19 | go run -race main.go --debug 2> race.log
20 |
21 | # start debug server
22 | debug-server:
23 | dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 --check-go-version=false . -- p --debug
24 |
25 | # connect to debug server
26 | debug-connect:
27 | dlv connect 127.0.0.1:43000
28 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2024 NAME HERE
3 |
4 | */
5 | package main
6 |
7 | import "github.com/ajayd-san/gomanagedocker/cmd"
8 |
9 | func main() {
10 | cmd.Execute()
11 | }
12 |
--------------------------------------------------------------------------------
/podman/containers.go:
--------------------------------------------------------------------------------
1 | package podman
2 |
3 | import (
4 | "github.com/containers/podman/v5/libpod/define"
5 | "github.com/containers/podman/v5/pkg/bindings/containers"
6 | "github.com/containers/podman/v5/pkg/domain/entities/reports"
7 | "github.com/containers/podman/v5/pkg/domain/entities/types"
8 | "github.com/containers/podman/v5/pkg/specgen"
9 | )
10 |
11 | func (p *PodmanClient) ContainerList(opts *containers.ListOptions) ([]types.ListContainer, error) {
12 | return containers.List(p.ctx, opts)
13 | }
14 |
15 | func (p *PodmanClient) ContainerInspect(id string, size bool) (*define.InspectContainerData, error) {
16 | opts := containers.InspectOptions{}
17 | opts.WithSize(size)
18 | return containers.Inspect(p.ctx, id, &opts)
19 | }
20 |
21 | func (p *PodmanClient) ContainerStart(id string) error {
22 | return containers.Start(p.ctx, id, nil)
23 | }
24 |
25 | func (p *PodmanClient) ContainerStop(id string) error {
26 | return containers.Stop(p.ctx, id, nil)
27 | }
28 |
29 | func (p *PodmanClient) ContainerRestart(id string) error {
30 | return containers.Restart(p.ctx, id, nil)
31 | }
32 |
33 | func (p *PodmanClient) ContainerPause(id string) error {
34 | return containers.Pause(p.ctx, id, nil)
35 | }
36 |
37 | func (p *PodmanClient) ContainerUnpause(id string) error {
38 | return containers.Unpause(p.ctx, id, nil)
39 | }
40 |
41 | func (p *PodmanClient) ContainerRemove(id string, removeOpts *containers.RemoveOptions) ([]*reports.RmReport, error) {
42 | return containers.Remove(p.ctx, id, removeOpts)
43 | }
44 |
45 | func (p *PodmanClient) ContainerPrune() ([]*reports.PruneReport, error) {
46 | return containers.Prune(p.ctx, nil)
47 | }
48 |
49 | func (p *PodmanClient) ContainerCreateWithSpec(spec *specgen.SpecGenerator, opts *containers.CreateOptions) (types.ContainerCreateResponse, error) {
50 | return containers.CreateWithSpec(p.ctx, spec, opts)
51 | }
52 |
--------------------------------------------------------------------------------
/podman/images.go:
--------------------------------------------------------------------------------
1 | package podman
2 |
3 | import (
4 | "github.com/containers/podman/v5/pkg/bindings/images"
5 | "github.com/containers/podman/v5/pkg/domain/entities/reports"
6 | "github.com/containers/podman/v5/pkg/domain/entities/types"
7 | tf "github.com/containers/podman/v5/pkg/domain/entities/types"
8 | )
9 |
10 | func (p *PodmanClient) ImageList(opts *images.ListOptions) ([]*tf.ImageSummary, error) {
11 | return images.List(p.ctx, opts)
12 | }
13 |
14 | func (pc *PodmanClient) ImageRemove(image_ids []string, opts *images.RemoveOptions) (*tf.ImageRemoveReport, []error) {
15 | return images.Remove(pc.ctx, image_ids, opts)
16 | }
17 |
18 | func (pc *PodmanClient) ImagePrune(opts *images.PruneOptions) ([]*reports.PruneReport, error) {
19 | return images.Prune(pc.ctx, opts)
20 | }
21 |
22 | func (p *PodmanClient) ImageBuild(containerFiles []string, opts types.BuildOptions) (*tf.BuildReport, error) {
23 | return images.Build(p.ctx, containerFiles, opts)
24 | }
25 |
--------------------------------------------------------------------------------
/podman/podmanApi.go:
--------------------------------------------------------------------------------
1 | /*
2 | This package is a wrapper around podman bindings, I've done this to facilitate testing since the
3 | podman bindings are pure functions. There is no way to swap them with stubs
4 | */
5 | package podman
6 |
7 | import (
8 | "context"
9 |
10 | "github.com/containers/podman/v5/libpod/define"
11 | "github.com/containers/podman/v5/pkg/bindings"
12 | "github.com/containers/podman/v5/pkg/bindings/containers"
13 | "github.com/containers/podman/v5/pkg/bindings/images"
14 | "github.com/containers/podman/v5/pkg/bindings/pods"
15 | "github.com/containers/podman/v5/pkg/bindings/volumes"
16 | "github.com/containers/podman/v5/pkg/domain/entities/reports"
17 | "github.com/containers/podman/v5/pkg/domain/entities/types"
18 | tf "github.com/containers/podman/v5/pkg/domain/entities/types"
19 | "github.com/containers/podman/v5/pkg/specgen"
20 | )
21 |
22 | type PodmanAPI interface {
23 | //images
24 | ImageList(opts *images.ListOptions) ([]*tf.ImageSummary, error)
25 | ImageRemove(image_ids []string, opts *images.RemoveOptions) (*tf.ImageRemoveReport, []error)
26 | ImagePrune(opts *images.PruneOptions) ([]*reports.PruneReport, error)
27 | ImageBuild(containerFiles []string, opts types.BuildOptions) (*tf.BuildReport, error)
28 |
29 | // containers
30 | ContainerList(opts *containers.ListOptions) ([]types.ListContainer, error)
31 | ContainerInspect(id string, size bool) (*define.InspectContainerData, error)
32 | ContainerStart(id string) error
33 | ContainerStop(id string) error
34 | ContainerRestart(id string) error
35 | ContainerPause(id string) error
36 | ContainerUnpause(id string) error
37 | ContainerRemove(id string, removeOpts *containers.RemoveOptions) ([]*reports.RmReport, error)
38 | ContainerPrune() ([]*reports.PruneReport, error)
39 | ContainerCreateWithSpec(spec *specgen.SpecGenerator, opts *containers.CreateOptions) (types.ContainerCreateResponse, error)
40 |
41 | // vols
42 | VolumesList(opts *volumes.ListOptions) ([]*types.VolumeListReport, error)
43 | VolumesRemove(id string, force bool) error
44 | VolumesPrune(opts *volumes.PruneOptions) ([]*reports.PruneReport, error)
45 |
46 | //pods
47 | PodsList(opts *pods.ListOptions) ([]*types.ListPodsReport, error)
48 | PodsCreate(name string) (*types.PodCreateReport, error)
49 | PodsRestart(id string, opts *pods.RestartOptions) (*types.PodRestartReport, error)
50 | PodsPrune(opts *pods.PruneOptions) ([]*types.PodPruneReport, error)
51 | PodsStop(id string, opts *pods.StopOptions) (*types.PodStopReport, error)
52 | PodsStart(id string, opts *pods.StartOptions) (*types.PodStartReport, error)
53 | PodsUnpause(id string, opts *pods.UnpauseOptions) (*types.PodUnpauseReport, error)
54 | PodsPause(id string, opts *pods.PauseOptions) (*types.PodPauseReport, error)
55 | PodsRemove(id string, opts *pods.RemoveOptions) (*types.PodRmReport, error)
56 | }
57 |
58 | type PodmanClient struct {
59 | ctx context.Context
60 | }
61 |
62 | const defaultSocket string = "unix:///run/user/1000/podman/podman.sock"
63 |
64 | func NewPodmanClient() (PodmanAPI, error) {
65 | ctx, err := bindings.NewConnection(context.Background(), defaultSocket)
66 |
67 | if err != nil {
68 | return nil, err
69 | }
70 |
71 | return &PodmanClient{
72 | ctx: ctx,
73 | }, nil
74 | }
75 |
--------------------------------------------------------------------------------
/podman/pods.go:
--------------------------------------------------------------------------------
1 | package podman
2 |
3 | import (
4 | "github.com/containers/podman/v5/pkg/bindings/pods"
5 | "github.com/containers/podman/v5/pkg/domain/entities/types"
6 | "github.com/containers/podman/v5/pkg/specgen"
7 | )
8 |
9 | func (p *PodmanClient) PodsList(opts *pods.ListOptions) ([]*types.ListPodsReport, error) {
10 | return pods.List(p.ctx, opts)
11 | }
12 |
13 | func (p *PodmanClient) PodsCreate(name string) (*types.PodCreateReport, error) {
14 | specGen := specgen.NewPodSpecGenerator()
15 | specGen.Name = name
16 |
17 | spec := types.PodSpec{
18 | PodSpecGen: *specGen,
19 | }
20 |
21 | return pods.CreatePodFromSpec(p.ctx, &spec)
22 | }
23 |
24 | func (p *PodmanClient) PodsRestart(id string, opts *pods.RestartOptions) (*types.PodRestartReport, error) {
25 | return pods.Restart(p.ctx, id, opts)
26 | }
27 |
28 | func (p *PodmanClient) PodsPrune(opts *pods.PruneOptions) ([]*types.PodPruneReport, error) {
29 | return pods.Prune(p.ctx, opts)
30 | }
31 |
32 | func (p *PodmanClient) PodsStop(id string, opts *pods.StopOptions) (*types.PodStopReport, error) {
33 | return pods.Stop(p.ctx, id, opts)
34 | }
35 |
36 | func (p *PodmanClient) PodsStart(id string, opts *pods.StartOptions) (*types.PodStartReport, error) {
37 | return pods.Start(p.ctx, id, opts)
38 | }
39 |
40 | func (p *PodmanClient) PodsUnpause(id string, opts *pods.UnpauseOptions) (*types.PodUnpauseReport, error) {
41 | return pods.Unpause(p.ctx, id, opts)
42 | }
43 |
44 | func (p *PodmanClient) PodsPause(id string, opts *pods.PauseOptions) (*types.PodPauseReport, error) {
45 | return pods.Pause(p.ctx, id, opts)
46 | }
47 |
48 | func (p *PodmanClient) PodsRemove(id string, opts *pods.RemoveOptions) (*types.PodRmReport, error) {
49 | return pods.Remove(p.ctx, id, opts)
50 | }
51 |
--------------------------------------------------------------------------------
/podman/volumes.go:
--------------------------------------------------------------------------------
1 | package podman
2 |
3 | import (
4 | "github.com/containers/podman/v5/pkg/bindings/volumes"
5 | "github.com/containers/podman/v5/pkg/domain/entities/reports"
6 | "github.com/containers/podman/v5/pkg/domain/entities/types"
7 | )
8 |
9 | func (p *PodmanClient) VolumesList(opts *volumes.ListOptions) ([]*types.VolumeListReport, error) {
10 | return volumes.List(p.ctx, nil)
11 | }
12 |
13 | func (p *PodmanClient) VolumesRemove(id string, force bool) error {
14 | opts := &volumes.RemoveOptions{}
15 | opts = opts.WithForce(force)
16 | return volumes.Remove(p.ctx, id, opts)
17 | }
18 |
19 | func (p *PodmanClient) VolumesPrune(opts *volumes.PruneOptions) ([]*reports.PruneReport, error) {
20 | return volumes.Prune(p.ctx, opts)
21 | }
22 |
--------------------------------------------------------------------------------
/prod.Dockerfile:
--------------------------------------------------------------------------------
1 | # dockerfile for building an image to run the executable via container,
2 | # bypassing local setup entirely.
3 |
4 | FROM golang:1-bullseye as builder
5 |
6 | ENV TERM=xterm-256color
7 |
8 | RUN apt-get update && apt-get install -y libbtrfs-dev libgpgme-dev libx11-dev
9 |
10 | RUN go install github.com/ajayd-san/gomanagedocker@main
11 |
12 | FROM debian:bullseye-slim
13 |
14 | ENV TERM=xterm-256color
15 |
16 | RUN apt-get update && apt-get install -y libbtrfs-dev libgpgme-dev libx11-dev
17 |
18 | COPY --from=builder /go/bin/gomanagedocker /app/gmd
19 |
20 | ENTRYPOINT ["/app/gmd"]
21 |
--------------------------------------------------------------------------------
/service/dockercmd/container.go:
--------------------------------------------------------------------------------
1 | package dockercmd
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "os/exec"
8 |
9 | "github.com/ajayd-san/gomanagedocker/service/types"
10 | "github.com/docker/docker/api/types/container"
11 | "github.com/docker/docker/api/types/filters"
12 | )
13 |
14 | func (dc *DockerClient) InspectContainer(id string) (*types.InspectContainerData, error) {
15 | raw, _, err := dc.cli.ContainerInspectWithRaw(context.Background(), id, true)
16 |
17 | if err != nil {
18 | return nil, err
19 | }
20 |
21 | return toContainerInspectData(&raw), nil
22 | }
23 |
24 | func (dc *DockerClient) ListContainers(showContainerSize bool) []types.ContainerSummary {
25 | listArgs := dc.containerListArgs
26 | listArgs.Size = showContainerSize
27 |
28 | containers, err := dc.cli.ContainerList(context.Background(), listArgs)
29 |
30 | if err != nil {
31 | panic(err)
32 | }
33 |
34 | return toContainerSummaryArr(containers)
35 | }
36 |
37 | // Toggles listing of inactive containers
38 | func (dc *DockerClient) ToggleContainerListAll() {
39 | dc.containerListOpts.All = !dc.containerListOpts.All
40 | dc.containerListArgs.All = !dc.containerListArgs.All
41 | }
42 |
43 | // Toggles running state of container
44 | func (dc *DockerClient) ToggleStartStopContainer(id string, isRunning bool) error {
45 | if isRunning {
46 | return dc.cli.ContainerStop(context.Background(), id, container.StopOptions{})
47 | } else {
48 | return dc.cli.ContainerStart(context.Background(), id, container.StartOptions{})
49 | }
50 | }
51 |
52 | func (dc *DockerClient) RestartContainer(id string) error {
53 | return dc.cli.ContainerRestart(context.Background(), id, container.StopOptions{})
54 | }
55 |
56 | func (dc *DockerClient) TogglePauseResume(id string, state string) error {
57 | if state == "paused" {
58 | err := dc.cli.ContainerUnpause(context.Background(), id)
59 |
60 | if err != nil {
61 | return err
62 | }
63 | } else if state == "running" {
64 | err := dc.cli.ContainerPause(context.Background(), id)
65 | if err != nil {
66 | return err
67 | }
68 | } else {
69 | return errors.New(fmt.Sprintf("Cannot Pause/unPause a %s Process.", state))
70 | }
71 |
72 | return nil
73 | }
74 |
75 | // Deletes the container
76 | func (dc *DockerClient) DeleteContainer(id string, opts types.ContainerRemoveOpts) error {
77 | dockerOpts := container.RemoveOptions{}
78 | if opts.Force {
79 | dockerOpts.Force = true
80 | }
81 | if opts.RemoveVolumes {
82 | dockerOpts.RemoveVolumes = true
83 | }
84 | return dc.cli.ContainerRemove(context.Background(), id, dockerOpts)
85 | }
86 |
87 | func (dc *DockerClient) PruneContainers() (types.ContainerPruneReport, error) {
88 | report, err := dc.cli.ContainersPrune(context.Background(), filters.Args{})
89 |
90 | if err != nil {
91 | return types.ContainerPruneReport{}, err
92 | }
93 |
94 | return types.ContainerPruneReport{ContainersDeleted: len(report.ContainersDeleted)}, nil
95 | }
96 |
97 | func (dc *DockerClient) ExecCmd(id string) *exec.Cmd {
98 | return exec.Command("docker", "exec", "-it", id, "/bin/sh", "-c", "eval $(grep ^$(id -un): /etc/passwd | cut -d : -f 7-)")
99 | }
100 |
101 | func (dc *DockerClient) LogsCmd(id string) *exec.Cmd {
102 | return exec.Command("docker", "logs", "--follow", id)
103 | }
104 |
--------------------------------------------------------------------------------
/service/dockercmd/container_test.go:
--------------------------------------------------------------------------------
1 | package dockercmd
2 |
3 | import (
4 | "testing"
5 |
6 | it "github.com/ajayd-san/gomanagedocker/service/types"
7 | "github.com/docker/docker/api/types"
8 | "github.com/docker/docker/api/types/container"
9 | "gotest.tools/v3/assert"
10 | )
11 |
12 | var dockerclient = NewDockerClient()
13 |
14 | /*
15 | was useful in deciding if i should query the containersize by default
16 | spoiler: querying container size has huge performance impact something like +5000% time increase or something
17 | */
18 | func BenchmarkContainerList(b *testing.B) {
19 | b.Run("Showing container size", func(b *testing.B) {
20 | for range b.N {
21 | dockerclient.ListContainers(false)
22 | }
23 | })
24 | b.Run("NOT Showing container size", func(b *testing.B) {
25 | for range b.N {
26 | dockerclient.ListContainers(true)
27 | }
28 | })
29 | }
30 |
31 | func TestListContainer(t *testing.T) {
32 |
33 | containers := []types.Container{
34 | {
35 | ID: "1",
36 | SizeRw: 200,
37 | SizeRootFs: 400,
38 | State: "running",
39 | Status: "",
40 | },
41 | {
42 | ID: "2",
43 | SizeRw: 201,
44 | SizeRootFs: 401,
45 | State: "running",
46 | },
47 | {
48 | ID: "3",
49 | SizeRw: 202,
50 | SizeRootFs: 402,
51 | State: "created",
52 | },
53 | {
54 | ID: "4",
55 | SizeRw: 203,
56 | SizeRootFs: 403,
57 | State: "stopped",
58 | },
59 | }
60 |
61 | t.Run("Default (not showing all containers)", func(t *testing.T) {
62 |
63 | dclient := DockerClient{
64 | cli: &MockApi{
65 | mockContainers: containers,
66 | CommonAPIClient: nil,
67 | },
68 | containerListArgs: container.ListOptions{},
69 | }
70 |
71 | got := dclient.ListContainers(false)
72 |
73 | want := []it.ContainerSummary{
74 | {
75 | ID: "1",
76 | Size: &it.SizeInfo{
77 | Rw: -1,
78 | RootFs: -1,
79 | },
80 | State: "running",
81 | Ports: []it.Port{},
82 | Mounts: []string{},
83 | },
84 | {
85 | ID: "2",
86 | Size: &it.SizeInfo{
87 | Rw: -1,
88 | RootFs: -1,
89 | },
90 | State: "running",
91 | Ports: []it.Port{},
92 | Mounts: []string{},
93 | },
94 | }
95 |
96 | assert.DeepEqual(t, want, got)
97 | })
98 |
99 | t.Run("Showing all containers", func(t *testing.T) {
100 |
101 | dclient := DockerClient{
102 | cli: &MockApi{
103 | mockContainers: containers,
104 | CommonAPIClient: nil,
105 | },
106 | containerListArgs: container.ListOptions{},
107 | }
108 |
109 | dclient.ToggleContainerListAll()
110 |
111 | got := dclient.ListContainers(false)
112 |
113 | want := []it.ContainerSummary{
114 | {
115 | ID: "1",
116 | Size: &it.SizeInfo{
117 | Rw: -1,
118 | RootFs: -1,
119 | },
120 | State: "running",
121 | Ports: []it.Port{},
122 | Mounts: []string{},
123 | },
124 | {
125 | ID: "2",
126 | Size: &it.SizeInfo{
127 | Rw: -1,
128 | RootFs: -1,
129 | },
130 | State: "running",
131 | Ports: []it.Port{},
132 | Mounts: []string{},
133 | },
134 | {
135 | ID: "3",
136 | Size: &it.SizeInfo{
137 | Rw: -1,
138 | RootFs: -1,
139 | },
140 | State: "created",
141 | Ports: []it.Port{},
142 | Mounts: []string{},
143 | },
144 | {
145 | ID: "4",
146 | Size: &it.SizeInfo{
147 | Rw: -1,
148 | RootFs: -1,
149 | },
150 | State: "stopped",
151 | Ports: []it.Port{},
152 | Mounts: []string{},
153 | },
154 | }
155 |
156 | assert.DeepEqual(t, got, want)
157 | })
158 |
159 | t.Run("Also calculate sizes", func(t *testing.T) {
160 |
161 | dclient := DockerClient{
162 | cli: &MockApi{
163 | mockContainers: containers,
164 | CommonAPIClient: nil,
165 | },
166 | containerListArgs: container.ListOptions{},
167 | }
168 |
169 | dclient.ToggleContainerListAll()
170 |
171 | got := dclient.ListContainers(true)
172 | want := []it.ContainerSummary{
173 | {
174 | ID: "1",
175 | Size: &it.SizeInfo{
176 | Rw: 200,
177 | RootFs: 400,
178 | },
179 | State: "running",
180 | Ports: []it.Port{},
181 | Mounts: []string{},
182 | },
183 | {
184 | ID: "2",
185 | Size: &it.SizeInfo{
186 | Rw: 201,
187 | RootFs: 401,
188 | },
189 | State: "running",
190 | Ports: []it.Port{},
191 | Mounts: []string{},
192 | },
193 | {
194 | ID: "3",
195 | Size: &it.SizeInfo{
196 | Rw: 202,
197 | RootFs: 402,
198 | },
199 | State: "created",
200 | Ports: []it.Port{},
201 | Mounts: []string{},
202 | },
203 | {
204 | ID: "4",
205 | Size: &it.SizeInfo{
206 | Rw: 203,
207 | RootFs: 403,
208 | },
209 | State: "stopped",
210 | Ports: []it.Port{},
211 | Mounts: []string{},
212 | },
213 | }
214 |
215 | assert.DeepEqual(t, got, want)
216 | })
217 | }
218 |
219 | func TestContainerToggleListAll(t *testing.T) {
220 | dclient := DockerClient{
221 | cli: &MockApi{
222 | mockContainers: nil,
223 | CommonAPIClient: nil,
224 | },
225 | containerListArgs: container.ListOptions{},
226 | }
227 |
228 | assert.Assert(t, !dclient.containerListArgs.All)
229 | assert.Assert(t, !dclient.containerListOpts.All)
230 | dclient.ToggleContainerListAll()
231 | assert.Assert(t, dclient.containerListArgs.All)
232 | assert.Assert(t, dclient.containerListOpts.All)
233 | }
234 |
235 | func TestToggleStartStopContainer(t *testing.T) {
236 | containers := []types.Container{
237 | {
238 | ID: "1",
239 | SizeRw: 200,
240 | SizeRootFs: 400,
241 | State: "running",
242 | Status: "",
243 | },
244 | {
245 | ID: "2",
246 | SizeRw: 201,
247 | SizeRootFs: 401,
248 | State: "running",
249 | },
250 | }
251 |
252 | dclient := DockerClient{
253 | cli: &MockApi{
254 | mockContainers: containers,
255 | CommonAPIClient: nil,
256 | },
257 | containerListArgs: container.ListOptions{},
258 | }
259 |
260 | t.Run("Stopping container test", func(t *testing.T) {
261 | isRunning := true
262 | dclient.ToggleStartStopContainer("2", isRunning)
263 |
264 | state := dclient.cli.(*MockApi).mockContainers
265 |
266 | assert.Assert(t, state[1].State == "stopped")
267 | })
268 |
269 | t.Run("Start container test", func(t *testing.T) {
270 | isRunning := false
271 | dclient.ToggleStartStopContainer("2", isRunning)
272 |
273 | state := dclient.cli.(*MockApi).mockContainers
274 |
275 | assert.Assert(t, state[1].State == "running")
276 | })
277 | }
278 |
279 | func TestPauseUnpauseContainer(t *testing.T) {
280 | containers := []types.Container{
281 | {
282 | ID: "1",
283 | State: "running",
284 | },
285 | {
286 | ID: "2",
287 | State: "stopped",
288 | },
289 | }
290 |
291 | dclient := DockerClient{
292 | cli: &MockApi{
293 | mockContainers: containers,
294 | CommonAPIClient: nil,
295 | },
296 | containerListArgs: container.ListOptions{},
297 | }
298 |
299 | t.Run("Pause running container", func(t *testing.T) {
300 | id := "1"
301 | err := dclient.TogglePauseResume(id, "running")
302 | assert.NilError(t, err)
303 | containers := dclient.cli.(*MockApi).mockContainers
304 |
305 | assert.Assert(t, containers[0].State == "paused")
306 | })
307 |
308 | t.Run("unpause running container", func(t *testing.T) {
309 | id := "1"
310 |
311 | err := dclient.TogglePauseResume(id, "paused")
312 | assert.NilError(t, err)
313 |
314 | containers := dclient.cli.(*MockApi).mockContainers
315 |
316 | assert.Assert(t, containers[0].State == "running")
317 | })
318 |
319 | t.Run("unpause stopped container(should throw error)", func(t *testing.T) {
320 | id := "2"
321 | err := dclient.TogglePauseResume(id, "exited")
322 | assert.ErrorContains(t, err, "Cannot Pause/unPause a")
323 | })
324 | }
325 |
326 | func TestDeleteContainer(t *testing.T) {
327 | containers := []types.Container{
328 | {
329 | ID: "1",
330 | State: "running",
331 | Names: []string{"certified loverboy"},
332 | },
333 | {
334 | ID: "2",
335 | State: "stopped",
336 | Names: []string{"certified *********"},
337 | },
338 | }
339 |
340 | dclient := DockerClient{
341 | cli: &MockApi{
342 | mockContainers: containers,
343 | CommonAPIClient: nil,
344 | },
345 | containerListArgs: container.ListOptions{},
346 | }
347 |
348 | t.Run("Delete stopped container", func(t *testing.T) {
349 | id := "2"
350 | err := dclient.DeleteContainer(id, it.ContainerRemoveOpts{})
351 | assert.NilError(t, err)
352 | })
353 |
354 | t.Run("Try delete runing container(fails)", func(t *testing.T) {
355 | id := "1"
356 | err := dclient.DeleteContainer(id, it.ContainerRemoveOpts{})
357 | assert.ErrorContains(t, err, "container is running")
358 | })
359 | }
360 |
361 | func TestPruneContainer(t *testing.T) {
362 | containers := []types.Container{
363 | {
364 | ID: "1",
365 | State: "stopped",
366 | },
367 | {
368 | ID: "2",
369 | State: "running",
370 | },
371 | {
372 | ID: "3",
373 | State: "stopped",
374 | },
375 | }
376 |
377 | dclient := DockerClient{
378 | cli: &MockApi{
379 | mockContainers: containers,
380 | CommonAPIClient: nil,
381 | },
382 | containerListArgs: container.ListOptions{},
383 | }
384 | dclient.PruneContainers()
385 |
386 | want := []types.Container{
387 | {
388 | ID: "2",
389 | State: "running",
390 | },
391 | }
392 |
393 | got := dclient.cli.(*MockApi).mockContainers
394 |
395 | assert.DeepEqual(t, want, got)
396 | }
397 |
--------------------------------------------------------------------------------
/service/dockercmd/images.go:
--------------------------------------------------------------------------------
1 | package dockercmd
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "os"
7 | "os/exec"
8 | "path/filepath"
9 | "regexp"
10 |
11 | it "github.com/ajayd-san/gomanagedocker/service/types"
12 | "github.com/docker/docker/api/types"
13 | "github.com/docker/docker/api/types/container"
14 | "github.com/docker/docker/api/types/filters"
15 | "github.com/docker/docker/api/types/image"
16 | "github.com/docker/docker/pkg/archive"
17 | "github.com/docker/go-connections/nat"
18 | )
19 |
20 | // builds a docker image from `options` and `buildContext`
21 | func (dc *DockerClient) BuildImage(buildContext string, options it.ImageBuildOptions) (*it.ImageBuildReport, error) {
22 | dockerignoreFile, err := os.Open(filepath.Join(buildContext, ".dockerignore"))
23 |
24 | opts := archive.TarOptions{}
25 | if err == nil {
26 | opts.ExcludePatterns = getDockerIgnorePatterns(dockerignoreFile)
27 | }
28 |
29 | tar, err := archive.TarWithOptions(buildContext, &opts)
30 |
31 | if err != nil {
32 | return nil, err
33 | }
34 | defer tar.Close()
35 |
36 | dockerOpts := types.ImageBuildOptions{
37 | Dockerfile: options.Dockerfile,
38 | Tags: options.Tags,
39 | }
40 |
41 | res, err := dc.cli.ImageBuild(context.Background(), tar, dockerOpts)
42 |
43 | return &it.ImageBuildReport{Body: res.Body}, err
44 | }
45 |
46 | func (dc *DockerClient) ListImages() []it.ImageSummary {
47 | images, err := dc.cli.ImageList(context.Background(), image.ListOptions{ContainerCount: true})
48 |
49 | if err != nil {
50 | panic(err)
51 | }
52 |
53 | return toImageSummaryArr(images)
54 | }
55 |
56 | // Runs the image and returns the container ID
57 | func (dc *DockerClient) RunImage(config it.ContainerCreateConfig) (*string, error) {
58 |
59 | // this is just a list of exposed ports and is used in containerConfig
60 | exposedPortsContainer := make(map[nat.Port]struct{}, len(config.PortBindings))
61 | // this is a port mapping from host to container and is used in hostConfig
62 | portBindings := make(nat.PortMap)
63 |
64 | for _, portBind := range config.PortBindings {
65 | port, err := nat.NewPort(portBind.Proto, portBind.ContainerPort)
66 | if err != nil {
67 | return nil, err
68 | }
69 | exposedPortsContainer[port] = struct{}{}
70 | portBindings[port] = []nat.PortBinding{
71 | {
72 | HostIP: "::1",
73 | HostPort: portBind.HostPort,
74 | },
75 | }
76 | }
77 |
78 | dockerConfig := container.Config{
79 | ExposedPorts: exposedPortsContainer,
80 | Env: config.Env,
81 | Image: config.ImageId,
82 | }
83 |
84 | dockerHostConfig := container.HostConfig{
85 | PortBindings: portBindings,
86 | }
87 |
88 | res, err := dc.cli.ContainerCreate(
89 | context.Background(),
90 | &dockerConfig,
91 | &dockerHostConfig,
92 | nil,
93 | nil,
94 | config.Name,
95 | )
96 |
97 | if err != nil {
98 | return nil, err
99 | }
100 |
101 | err = dc.cli.ContainerStart(context.Background(), res.ID, container.StartOptions{})
102 |
103 | if err != nil {
104 | return nil, err
105 | }
106 |
107 | return &res.ID, nil
108 | }
109 |
110 | func (dc *DockerClient) DeleteImage(id string, opts it.RemoveImageOptions) error {
111 | dockerOpts := image.RemoveOptions{
112 | Force: opts.Force,
113 | PruneChildren: opts.NoPrune,
114 | }
115 |
116 | _, err := dc.cli.ImageRemove(context.Background(), id, dockerOpts)
117 | return err
118 | }
119 |
120 | func (dc *DockerClient) PruneImages() (it.ImagePruneReport, error) {
121 | report, err := dc.cli.ImagesPrune(context.Background(), filters.Args{})
122 |
123 | return it.ImagePruneReport{ImagesDeleted: len(report.ImagesDeleted)}, err
124 | }
125 |
126 | // runs docker scout and parses the output using regex
127 | func (dc *DockerClient) ScoutImage(ctx context.Context, imageName string) (*ScoutData, error) {
128 | res, err := runDockerScout(ctx, imageName)
129 |
130 | if err != nil {
131 | return nil, err
132 | }
133 |
134 | return parseDockerScoutOutput(res), nil
135 | }
136 |
137 | // this parses docker scout quickview output
138 | func parseDockerScoutOutput(reader []byte) *ScoutData {
139 |
140 | unifiedRegex := regexp.MustCompile(`\s*([\w ]+?)\s*│\s*([\w[:punct:]]+)\s*│\s+(\d)C\s+(\d+)H\s+(\d+)M\s+(\d+)L\s*(:?(\d+)\?)?`)
141 |
142 | matches := unifiedRegex.FindAllSubmatch(reader, -1)
143 |
144 | vulnerabilityEntries := make([]ImageVulnerabilities, 0, len(matches))
145 |
146 | for _, match := range matches {
147 | vulnerabilityEntries = append(vulnerabilityEntries, makeImageVulnerabilities(match))
148 | }
149 |
150 | return &ScoutData{
151 | ImageVulEntries: vulnerabilityEntries,
152 | }
153 | }
154 |
155 | func runDockerScout(ctx context.Context, imageId string) ([]byte, error) {
156 | cmd := exec.CommandContext(ctx, "docker", "scout", "quickview", imageId)
157 |
158 | output, err := cmd.Output()
159 |
160 | // we the error is due to Cancel() being invoked, ignore that error
161 | if err != nil && !errors.Is(ctx.Err(), context.Canceled) {
162 | return nil, err
163 | }
164 |
165 | return output, nil
166 | }
167 |
--------------------------------------------------------------------------------
/service/dockercmd/images_test.go:
--------------------------------------------------------------------------------
1 | package dockercmd
2 |
3 | import (
4 | "bufio"
5 | "os"
6 | "slices"
7 | "testing"
8 |
9 | it "github.com/ajayd-san/gomanagedocker/service/types"
10 | "github.com/docker/docker/api/types/container"
11 | dimage "github.com/docker/docker/api/types/image"
12 | "github.com/google/go-cmp/cmp"
13 | "gotest.tools/v3/assert"
14 | )
15 |
16 | func TestListImages(t *testing.T) {
17 |
18 | imgs := []dimage.Summary{
19 | {
20 | Containers: 0,
21 | ID: "0",
22 | },
23 |
24 | {
25 | Containers: 2,
26 | ID: "1",
27 | },
28 | {
29 | Containers: 3,
30 | ID: "2",
31 | },
32 | {
33 | Containers: 4,
34 | ID: "5",
35 | },
36 | }
37 |
38 | dclient := DockerClient{
39 | cli: &MockApi{
40 | mockImages: imgs,
41 | CommonAPIClient: nil,
42 | },
43 | containerListArgs: container.ListOptions{},
44 | }
45 |
46 | got := dclient.ListImages()
47 | want := []it.ImageSummary{
48 | {
49 | Containers: 0,
50 | ID: "0",
51 | },
52 |
53 | {
54 | Containers: 2,
55 | ID: "1",
56 | },
57 | {
58 | Containers: 3,
59 | ID: "2",
60 | },
61 | {
62 | Containers: 4,
63 | ID: "5",
64 | },
65 | }
66 | assert.DeepEqual(t, got, want)
67 | }
68 |
69 | func TestDeleteImage(t *testing.T) {
70 | imgs := []dimage.Summary{
71 | {
72 | Containers: 0,
73 | ID: "0",
74 | },
75 |
76 | {
77 | Containers: 2,
78 | ID: "1",
79 | },
80 | {
81 | Containers: 3,
82 | ID: "2",
83 | },
84 | {
85 | Containers: 4,
86 | ID: "5",
87 | },
88 | }
89 |
90 | dclient := DockerClient{
91 | cli: &MockApi{
92 | mockImages: imgs,
93 | CommonAPIClient: nil,
94 | },
95 | containerListArgs: container.ListOptions{},
96 | }
97 |
98 | t.Run("No force required image test", func(t *testing.T) {
99 | err := dclient.DeleteImage("0", it.RemoveImageOptions{})
100 | assert.NilError(t, err)
101 |
102 | afterDeleteImgs := dclient.cli.(*MockApi).mockImages
103 |
104 | // we do len(img) - 1 because the slices.Delete swaps the 'to be deleted' index with last index and zeros it. so we exclude the last element in the array
105 | assert.DeepEqual(t, afterDeleteImgs, imgs[0:len(imgs)-1])
106 | })
107 |
108 | t.Run("Should fail, image has active containers", func(t *testing.T) {
109 | err := dclient.DeleteImage("1", it.RemoveImageOptions{})
110 | assert.ErrorContains(t, err, "must be forced")
111 | })
112 |
113 | t.Run("With force", func(t *testing.T) {
114 | err := dclient.DeleteImage("1", it.RemoveImageOptions{Force: true})
115 | assert.NilError(t, err)
116 |
117 | // same reason as above, but this time we exclude last to elements
118 | afterDeleteImgs := dclient.cli.(*MockApi).mockImages
119 | assert.DeepEqual(t, afterDeleteImgs, imgs[0:len(imgs)-2])
120 | })
121 | }
122 |
123 | func TestPruneImages(t *testing.T) {
124 | imgs := []dimage.Summary{
125 | {
126 | Containers: 0,
127 | ID: "0",
128 | },
129 |
130 | {
131 | Containers: 0,
132 | ID: "1",
133 | },
134 | {
135 | Containers: 3,
136 | ID: "2",
137 | },
138 | {
139 | Containers: 0,
140 | ID: "5",
141 | },
142 | }
143 |
144 | dclient := DockerClient{
145 | cli: &MockApi{
146 | mockImages: imgs,
147 | CommonAPIClient: nil,
148 | },
149 | containerListArgs: container.ListOptions{},
150 | }
151 |
152 | dclient.PruneImages()
153 |
154 | finalImages := dclient.cli.(*MockApi).mockImages
155 | want := []dimage.Summary{
156 | {
157 | Containers: 3,
158 | ID: "2",
159 | },
160 | }
161 | assert.DeepEqual(t, finalImages, want)
162 | }
163 | func TestParseDockerScoutOutput(t *testing.T) {
164 | type test struct {
165 | input string
166 | want ScoutData
167 | }
168 |
169 | cases := []test{
170 | {
171 | `
172 | Target │ nginx:latest │ 0C 0H 1M 48L 1?
173 | digest │ 1445eb9c6dc5 │
174 | Base image │ debian:bookworm-slim │ 0C 0H 0M 23L
175 | Updated base image │ debian:stable-slim │ 0C 0H 0M 23L
176 | │ │
177 |
178 | What's next:
179 | Include policy results in your quickview by supplying an organization → docker scout quickview nginx --org
180 | `,
181 | ScoutData{
182 | []ImageVulnerabilities{
183 | {Label: "Target", ImageName: "nginx:latest", Critical: "0", High: "0", Medium: "1", Low: "48", UnknownSeverity: "1"},
184 | {Label: "Base image", ImageName: "debian:bookworm-slim", Critical: "0", High: "0", Medium: "0", Low: "23", UnknownSeverity: "0"},
185 | {Label: "Updated base image", ImageName: "debian:stable-slim", Critical: "0", High: "0", Medium: "0", Low: "23", UnknownSeverity: "0"},
186 | },
187 | },
188 | },
189 | {
190 | `
191 |
192 | Target │ myimage:latest │ 1C 2H 3M 4L 5?
193 | digest │ abcdef123456 │
194 | Base image │ ubuntu:20.04 │ 1C 2H 3M 4L
195 | Updated base image │ ubuntu:latest │ 0C 0H 1M 2L
196 | │ │
197 | `,
198 | ScoutData{
199 | []ImageVulnerabilities{
200 | {"Target", "myimage:latest", "1", "2", "3", "4", "5"},
201 | {"Base image", "ubuntu:20.04", "1", "2", "3", "4", "0"},
202 | {"Updated base image", "ubuntu:latest", "0", "0", "1", "2", "0"},
203 | },
204 | },
205 | },
206 | }
207 |
208 | for _, tcase := range cases {
209 | got := parseDockerScoutOutput([]byte(tcase.input))
210 |
211 | if !cmp.Equal(&tcase.want, got) {
212 | t.Fatalf("structs do not match\n %s", cmp.Diff(&tcase.want, got))
213 | }
214 | }
215 |
216 | }
217 |
218 | func TestBuildImage(t *testing.T) {
219 | imgs := []dimage.Summary{
220 | {
221 | Containers: 0,
222 | ID: "0",
223 | },
224 |
225 | {
226 | Containers: 0,
227 | ID: "1",
228 | },
229 | {
230 | Containers: 3,
231 | ID: "2",
232 | },
233 | {
234 | Containers: 0,
235 | ID: "5",
236 | },
237 | }
238 |
239 | dclient := DockerClient{
240 | cli: &MockApi{
241 | mockImages: imgs,
242 | CommonAPIClient: nil,
243 | },
244 | containerListArgs: container.ListOptions{},
245 | }
246 |
247 | cwd, _ := os.Getwd()
248 | opts := it.ImageBuildOptions{
249 | Tags: []string{"test"},
250 | }
251 | res, err := dclient.BuildImage(cwd, opts)
252 | if err != nil {
253 | t.Error(err)
254 | }
255 |
256 | // no-op, must wait till this finishes
257 | reader := bufio.NewScanner(res.Body)
258 | for reader.Scan() {
259 | }
260 |
261 | got := dclient.ListImages()
262 |
263 | index := slices.IndexFunc(got, func(entry it.ImageSummary) bool {
264 | return slices.Equal(entry.RepoTags, []string{"test"})
265 | })
266 |
267 | if index == -1 {
268 | t.Error("Could not find built image")
269 | }
270 | }
271 |
--------------------------------------------------------------------------------
/service/dockercmd/test_utils.go:
--------------------------------------------------------------------------------
1 | package dockercmd
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "math/rand"
9 | "slices"
10 | "strings"
11 |
12 | "github.com/docker/docker/api/types"
13 | "github.com/docker/docker/api/types/container"
14 | "github.com/docker/docker/api/types/filters"
15 | dimage "github.com/docker/docker/api/types/image"
16 | "github.com/docker/docker/api/types/volume"
17 | "github.com/docker/docker/client"
18 | )
19 |
20 | type MockApi struct {
21 | mockContainers []types.Container
22 | mockVolumes []*volume.Volume
23 | mockImages []dimage.Summary
24 | client.CommonAPIClient
25 | }
26 |
27 | func (mo *MockApi) ContainerInspectWithRaw(ctx context.Context, container string, getSize bool) (types.ContainerJSON, []byte, error) {
28 |
29 | index := slices.IndexFunc(mo.mockContainers, func(cont types.Container) bool {
30 | if cont.ID == container {
31 | return true
32 | }
33 |
34 | return false
35 | })
36 |
37 | base, _ := mo.ContainerInspect(ctx, container)
38 |
39 | if getSize {
40 | base.SizeRw = &mo.mockContainers[index].SizeRw
41 | base.SizeRootFs = &mo.mockContainers[index].SizeRootFs
42 | }
43 |
44 | return base, nil, nil
45 | }
46 |
47 | func (m *MockApi) SetMockImages(imgs []dimage.Summary) {
48 | m.mockImages = imgs
49 | }
50 |
51 | func (m *MockApi) SetMockVolumes(vols []*volume.Volume) {
52 | m.mockVolumes = vols
53 | }
54 |
55 | func (m *MockApi) SetMockContainers(conts []types.Container) {
56 | m.mockContainers = conts
57 | }
58 |
59 | func (mo *MockApi) ContainerInspect(ctx context.Context, container string) (types.ContainerJSON, error) {
60 | index := slices.IndexFunc(mo.mockContainers, func(cont types.Container) bool {
61 | if cont.ID == container {
62 | return true
63 | }
64 |
65 | return false
66 | })
67 |
68 | cur := mo.mockContainers[index]
69 |
70 | state := types.ContainerState{}
71 | if cur.State == "running" {
72 | state.Running = true
73 | } else if cur.State == "paused" {
74 | state.Paused = true
75 | }
76 |
77 | return types.ContainerJSON{
78 | ContainerJSONBase: &types.ContainerJSONBase{
79 | ID: cur.ID,
80 | State: &state,
81 | },
82 | }, nil
83 |
84 | }
85 |
86 | func (m *MockApi) ContainerList(ctx context.Context, options container.ListOptions) ([]types.Container, error) {
87 | final := []types.Container{}
88 |
89 | for _, cont := range m.mockContainers {
90 | if cont.State == "running" || cont.State == "paused" || options.All {
91 | if !options.Size {
92 | cont.SizeRw = -1
93 | cont.SizeRootFs = -1
94 | }
95 |
96 | final = append(final, cont)
97 | }
98 | }
99 |
100 | return final, nil
101 | }
102 |
103 | func (mo *MockApi) ContainerLogs(ctx context.Context, container string, options container.LogsOptions) (io.ReadCloser, error) {
104 | panic("not implemented") // TODO: Implement
105 | }
106 |
107 | func (mo *MockApi) ContainerPause(ctx context.Context, container string) error {
108 |
109 | index := slices.IndexFunc(mo.mockContainers, func(cont types.Container) bool {
110 | if cont.ID == container {
111 | return true
112 | }
113 |
114 | return false
115 | })
116 |
117 | mo.mockContainers[index].State = "paused"
118 |
119 | return nil
120 | }
121 |
122 | func (mo *MockApi) ContainerRemove(ctx context.Context, container string, options container.RemoveOptions) error {
123 |
124 | index := slices.IndexFunc(mo.mockContainers, func(cont types.Container) bool {
125 | if cont.ID == container {
126 | return true
127 | }
128 |
129 | return false
130 | })
131 |
132 | if index == -1 {
133 | return errors.New(fmt.Sprintf("No such container: %s", container))
134 | }
135 |
136 | if mo.mockContainers[index].State == "running" && !options.Force {
137 | //not exact error but works for now
138 | return errors.New(fmt.Sprintf(
139 | "cannot remove container \"%s\": container is running: stop the container before removing or force remove",
140 | mo.mockContainers[index].Names[0],
141 | ))
142 | }
143 |
144 | mo.mockContainers = slices.Delete(mo.mockContainers, index, index+1)
145 |
146 | return nil
147 | }
148 |
149 | func (mo *MockApi) ContainerRestart(ctx context.Context, container string, options container.StopOptions) error {
150 | panic("not implemented") // TODO: Implement
151 | }
152 | func (mo *MockApi) ContainerStart(ctx context.Context, container string, options container.StartOptions) error {
153 | index := slices.IndexFunc(mo.mockContainers, func(cont types.Container) bool {
154 | if cont.ID == container {
155 | return true
156 | }
157 |
158 | return false
159 | })
160 |
161 | mo.mockContainers[index].State = "running"
162 |
163 | return nil
164 | }
165 |
166 | func (mo *MockApi) ContainerStop(ctx context.Context, container string, options container.StopOptions) error {
167 | index := slices.IndexFunc(mo.mockContainers, func(cont types.Container) bool {
168 | if cont.ID == container {
169 | return true
170 | }
171 |
172 | return false
173 | })
174 |
175 | mo.mockContainers[index].State = "stopped"
176 |
177 | return nil
178 | }
179 |
180 | func (mo *MockApi) ContainerUnpause(ctx context.Context, container string) error {
181 |
182 | index := slices.IndexFunc(mo.mockContainers, func(cont types.Container) bool {
183 | if cont.ID == container {
184 | return true
185 | }
186 |
187 | return false
188 | })
189 |
190 | mo.mockContainers[index].State = "running"
191 |
192 | return nil
193 | }
194 |
195 | func (mo *MockApi) ContainersPrune(ctx context.Context, pruneFilters filters.Args) (types.ContainersPruneReport, error) {
196 |
197 | final := []types.Container{}
198 |
199 | for _, cont := range mo.mockContainers {
200 | if cont.State == "stopped" {
201 | continue
202 | }
203 |
204 | final = append(final, cont)
205 | }
206 |
207 | mo.mockContainers = final
208 | return types.ContainersPruneReport{}, nil
209 |
210 | }
211 |
212 | func (m *MockApi) VolumeList(ctx context.Context, options volume.ListOptions) (volume.ListResponse, error) {
213 | return volume.ListResponse{
214 | Volumes: m.mockVolumes,
215 | }, nil
216 | }
217 |
218 | func (m *MockApi) VolumeRemove(ctx context.Context, volumeID string, force bool) error {
219 | final := []*volume.Volume{}
220 |
221 | for _, vol := range m.mockVolumes {
222 | if vol.Name == volumeID {
223 | continue
224 | }
225 |
226 | final = append(final, vol)
227 | }
228 |
229 | m.mockVolumes = final
230 | return nil
231 |
232 | }
233 |
234 | func (mo *MockApi) VolumesPrune(ctx context.Context, pruneFilter filters.Args) (types.VolumesPruneReport, error) {
235 | panic("not implemented") // TODO: Implement
236 | }
237 |
238 | func (mo *MockApi) ImageBuild(ctx context.Context, context io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) {
239 | newImg := dimage.Summary{
240 | ID: randStr(10),
241 | RepoTags: options.Tags,
242 | }
243 |
244 | mo.mockImages = append(mo.mockImages, newImg)
245 | return types.ImageBuildResponse{
246 | Body: io.NopCloser(strings.NewReader("built image!")),
247 | OSType: "linux",
248 | }, nil
249 | }
250 |
251 | func (m *MockApi) ImageList(ctx context.Context, options dimage.ListOptions) ([]dimage.Summary, error) {
252 | return m.mockImages, nil
253 | }
254 |
255 | func (m *MockApi) ImageRemove(ctx context.Context, image string, options dimage.RemoveOptions) ([]dimage.DeleteResponse, error) {
256 |
257 | res := []dimage.DeleteResponse{}
258 |
259 | index := slices.IndexFunc(m.mockImages, func(i dimage.Summary) bool {
260 | if i.ID == image {
261 | return true
262 | }
263 |
264 | return false
265 | })
266 |
267 | if index == -1 {
268 | return nil, errors.New("No such image:")
269 | }
270 |
271 | if !options.Force && m.mockImages[index].Containers > 0 {
272 | return nil, errors.New(fmt.Sprintf("unable to delete %s (must be forced) - image is ...", m.mockImages[index].ID))
273 | }
274 |
275 | m.mockImages = slices.Delete(m.mockImages, index, index+1)
276 |
277 | return res, nil
278 |
279 | }
280 |
281 | func (te *MockApi) ImagesPrune(ctx context.Context, pruneFilter filters.Args) (types.ImagesPruneReport, error) {
282 | final := []dimage.Summary{}
283 |
284 | for _, img := range te.mockImages {
285 | if img.Containers == 0 {
286 | continue
287 | }
288 |
289 | final = append(final, img)
290 | }
291 |
292 | te.mockImages = final
293 |
294 | return types.ImagesPruneReport{}, nil
295 |
296 | }
297 |
298 | // util
299 | func randStr(length uint) string {
300 | bytes := make([]byte, int(length))
301 | for i := uint(0); i < length; i++ {
302 | bytes[i] = byte('!' + rand.Intn('~'-'!'))
303 | }
304 | return string(bytes)
305 | }
306 |
--------------------------------------------------------------------------------
/service/dockercmd/types.go:
--------------------------------------------------------------------------------
1 | package dockercmd
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/ajayd-san/gomanagedocker/service"
7 | "github.com/ajayd-san/gomanagedocker/service/types"
8 | "github.com/docker/docker/api/types/container"
9 | "github.com/docker/docker/client"
10 | )
11 |
12 | type ImageVulnerabilities struct {
13 | Label string
14 | ImageName string
15 | Critical string
16 | High string
17 | Medium string
18 | Low string
19 | UnknownSeverity string
20 | }
21 |
22 | // takes [][]bytes returned by regex.FindSubmatches and returns ImageVulnerabilities
23 | func makeImageVulnerabilities(submatches [][]byte) ImageVulnerabilities {
24 | //this makes sure "" is not printed in the table
25 | unknownSev := string(submatches[8])
26 | if unknownSev == "" {
27 | unknownSev = "0"
28 | }
29 |
30 | return ImageVulnerabilities{
31 | Label: string(submatches[1]),
32 | ImageName: string(submatches[2]),
33 | Critical: string(submatches[3]),
34 | High: string(submatches[4]),
35 | Medium: string(submatches[5]),
36 | Low: string(submatches[6]),
37 | UnknownSeverity: unknownSev,
38 | }
39 |
40 | }
41 |
42 | type ScoutData struct {
43 | ImageVulEntries []ImageVulnerabilities
44 | }
45 |
46 | type DockerClient struct {
47 | cli client.CommonAPIClient
48 | //external
49 | containerListOpts types.ContainerListOptions
50 | //internal
51 | containerListArgs container.ListOptions
52 | }
53 |
54 | func NewDockerClient() service.Service {
55 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
56 | if err != nil {
57 | panic(err)
58 | }
59 |
60 | //TODO: size should not be true, investigate later
61 | return &DockerClient{
62 | cli: cli,
63 | containerListArgs: container.ListOptions{
64 | Size: true,
65 | All: false,
66 | Latest: false,
67 | },
68 | }
69 | }
70 |
71 | func (dc DockerClient) Ping() error {
72 | _, err := dc.cli.Ping(context.Background())
73 | return err
74 | }
75 |
76 | // used for testing only
77 | func NewMockCli(cli *MockApi) service.Service {
78 | return &DockerClient{
79 | cli: cli,
80 | containerListOpts: types.ContainerListOptions{},
81 | containerListArgs: container.ListOptions{},
82 | }
83 | }
84 |
85 | // util
86 | func (dc DockerClient) GetListOptions() types.ContainerListOptions {
87 | return dc.containerListOpts
88 | }
89 |
--------------------------------------------------------------------------------
/service/dockercmd/util.go:
--------------------------------------------------------------------------------
1 | package dockercmd
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "io"
7 | "log"
8 | "strings"
9 | "time"
10 |
11 | "github.com/ajayd-san/gomanagedocker/service/types"
12 | et "github.com/docker/docker/api/types"
13 | "github.com/docker/docker/api/types/image"
14 | "github.com/docker/docker/api/types/volume"
15 | )
16 |
17 | func timeBenchmark(start time.Time, msg string) {
18 | timeTook := time.Since(start)
19 | log.Println(fmt.Sprintf("%s : %s", msg, timeTook))
20 | }
21 |
22 | func getDockerIgnorePatterns(file io.Reader) []string {
23 | patterns := make([]string, 0)
24 | buffer := bufio.NewReader(file)
25 |
26 | for {
27 | line, err := buffer.ReadString('\n')
28 | if err != nil {
29 | break
30 | }
31 | line = strings.TrimSuffix(line, "\n")
32 | patterns = append(patterns, line)
33 | }
34 |
35 | return patterns
36 | }
37 |
38 | func toImageSummaryArr(summary []image.Summary) []types.ImageSummary {
39 | res := make([]types.ImageSummary, len(summary))
40 |
41 | for index, entry := range summary {
42 | res[index] = types.ImageSummary{
43 | ID: entry.ID,
44 | Size: entry.Size,
45 | RepoTags: entry.RepoTags,
46 | Containers: entry.Containers,
47 | Created: entry.Created,
48 | }
49 | }
50 |
51 | return res
52 | }
53 |
54 | func toContainerSummaryArr(summary []et.Container) []types.ContainerSummary {
55 | res := make([]types.ContainerSummary, len(summary))
56 |
57 | for i, entry := range summary {
58 | item := types.ContainerSummary{
59 | ServiceKind: types.Docker,
60 | ID: entry.ID,
61 | ImageID: entry.ImageID,
62 | Created: entry.Created,
63 | Names: entry.Names,
64 | State: entry.State,
65 | Command: entry.Command,
66 | Mounts: getMounts(entry.Mounts),
67 | Ports: toPort(entry.Ports),
68 | //BUG: this should be set to null if entry.SizeRw are 0
69 | Size: &types.SizeInfo{
70 | Rw: entry.SizeRw,
71 | RootFs: entry.SizeRootFs,
72 | },
73 | }
74 |
75 | res[i] = item
76 | }
77 |
78 | return res
79 | }
80 |
81 | func toPort(ports []et.Port) []types.Port {
82 | res := make([]types.Port, len(ports))
83 |
84 | for i, port := range ports {
85 | res[i] = types.Port{
86 | HostIP: port.IP,
87 | HostPort: port.PublicPort,
88 | ContainerPort: port.PrivatePort,
89 | Proto: port.Type,
90 | }
91 | }
92 |
93 | return res
94 | }
95 |
96 | func getMounts(mounts []et.MountPoint) []string {
97 |
98 | res := make([]string, len(mounts))
99 |
100 | for i, mount := range mounts {
101 | var entry strings.Builder
102 |
103 | entry.WriteString(mount.Source)
104 | entry.WriteString(":")
105 | entry.WriteString(mount.Destination)
106 |
107 | res[i] = entry.String()
108 | }
109 |
110 | return res
111 | }
112 |
113 | // func mapState(state *et.ContainerState) *types.ContainerState {
114 | // return &types.ContainerState{
115 | // Status: state.Status,
116 | // Running: state.Running,
117 | // Paused: state.Paused,
118 | // Restarting: state.Restarting,
119 | // OOMKilled: state.OOMKilled,
120 | // Dead: state.Dead,
121 | // Pid: state.Pid,
122 | // ExitCode: state.ExitCode,
123 | // Error: state.Error,
124 | // }
125 | // }
126 |
127 | func toContainerInspectData(info *et.ContainerJSON) *types.InspectContainerData {
128 | res := types.ContainerSummary{
129 | ServiceKind: types.Docker,
130 | ID: info.ID,
131 | ImageID: info.Image,
132 | // TODO: figure out created
133 | // Created: info.Created,
134 | Names: []string{info.Name},
135 | State: info.State.Status,
136 | // Command: info.Command,
137 | }
138 |
139 | if info.SizeRootFs != nil && info.SizeRw != nil {
140 | res.Size = &types.SizeInfo{
141 | Rw: *info.SizeRw,
142 | RootFs: *info.SizeRootFs,
143 | }
144 | }
145 |
146 | return &types.InspectContainerData{ContainerSummary: res}
147 | }
148 |
149 | func toVolumeSummaryArr(entries []*volume.Volume) []types.VolumeSummary {
150 | res := make([]types.VolumeSummary, len(entries))
151 |
152 | for index, entry := range entries {
153 | res[index] = types.VolumeSummary{
154 | Name: entry.Name,
155 | CreatedAt: entry.CreatedAt,
156 | Driver: entry.Driver,
157 | Mountpoint: entry.Mountpoint,
158 | }
159 |
160 | if entry.UsageData != nil {
161 | res[index].UsageData = entry.UsageData.Size
162 | }
163 | }
164 |
165 | return res
166 | }
167 |
--------------------------------------------------------------------------------
/service/dockercmd/util_test.go:
--------------------------------------------------------------------------------
1 | package dockercmd
2 |
3 | import (
4 | "bytes"
5 | "testing"
6 |
7 | "gotest.tools/v3/assert"
8 | )
9 |
10 | func TestGetDockerIgnorePatternsFromFS(t *testing.T) {
11 | t.Run("Test 1", func(t *testing.T) {
12 | file := `abc
13 | czy*
14 | */bar
15 | `
16 | content := bytes.NewBuffer([]byte(file))
17 | got := getDockerIgnorePatterns(content)
18 |
19 | want := []string{"abc", "czy*", "*/bar"}
20 | assert.DeepEqual(t, got, want)
21 |
22 | })
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/service/dockercmd/volumes.go:
--------------------------------------------------------------------------------
1 | package dockercmd
2 |
3 | import (
4 | "context"
5 |
6 | it "github.com/ajayd-san/gomanagedocker/service/types"
7 | "github.com/docker/docker/api/types/filters"
8 | "github.com/docker/docker/api/types/volume"
9 | )
10 |
11 | func (dc *DockerClient) ListVolumes() ([]it.VolumeSummary, error) {
12 | res, err := dc.cli.VolumeList(context.Background(), volume.ListOptions{})
13 |
14 | if err != nil {
15 | panic(err)
16 | }
17 | return toVolumeSummaryArr(res.Volumes), nil
18 | }
19 |
20 | func (dc *DockerClient) PruneVolumes() (*it.VolumePruneReport, error) {
21 | res, err := dc.cli.VolumesPrune(context.Background(), filters.Args{})
22 |
23 | if err != nil {
24 | return nil, err
25 | }
26 | return &it.VolumePruneReport{VolumesPruned: len(res.VolumesDeleted)}, nil
27 | }
28 |
29 | func (dc *DockerClient) DeleteVolume(id string, force bool) error {
30 | return dc.cli.VolumeRemove(context.Background(), id, force)
31 | }
32 |
--------------------------------------------------------------------------------
/service/dockercmd/volumes_test.go:
--------------------------------------------------------------------------------
1 | package dockercmd
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/ajayd-san/gomanagedocker/service/types"
7 | "github.com/docker/docker/api/types/container"
8 | "github.com/docker/docker/api/types/volume"
9 | "gotest.tools/v3/assert"
10 | )
11 |
12 | func TestListVolumes(t *testing.T) {
13 | want := []types.VolumeSummary{
14 | {
15 | Name: "1",
16 | },
17 | {
18 | Name: "2",
19 | },
20 | {
21 | Name: "3",
22 | },
23 | {
24 | Name: "4",
25 | },
26 | }
27 | vols := []*volume.Volume{
28 | {
29 | Name: "1",
30 | },
31 | {
32 | Name: "2",
33 | },
34 | {
35 | Name: "3",
36 | },
37 | {
38 | Name: "4",
39 | },
40 | }
41 |
42 | dclient := DockerClient{
43 | cli: &MockApi{
44 | mockVolumes: vols,
45 | CommonAPIClient: nil,
46 | },
47 | containerListArgs: container.ListOptions{},
48 | }
49 |
50 | got, _ := dclient.ListVolumes()
51 |
52 | assert.DeepEqual(t, got, want)
53 | }
54 |
55 | func TestDeleteVolume(t *testing.T) {
56 | vols := []*volume.Volume{
57 | {
58 | Name: "1",
59 | },
60 | {
61 | Name: "2",
62 | },
63 | {
64 | Name: "3",
65 | },
66 | {
67 | Name: "4",
68 | },
69 | }
70 |
71 | dclient := DockerClient{
72 | cli: &MockApi{
73 | mockVolumes: vols,
74 | CommonAPIClient: nil,
75 | },
76 | containerListArgs: container.ListOptions{},
77 | }
78 |
79 | dclient.DeleteVolume("1", false)
80 |
81 | want := vols[1:]
82 | finalVols := dclient.cli.(*MockApi).mockVolumes
83 | assert.DeepEqual(t, finalVols, want)
84 | }
85 |
--------------------------------------------------------------------------------
/service/podmancmd/containers.go:
--------------------------------------------------------------------------------
1 | package podmancmd
2 |
3 | import (
4 | "fmt"
5 | "os/exec"
6 |
7 | it "github.com/ajayd-san/gomanagedocker/service/types"
8 | "github.com/containers/podman/v5/pkg/bindings/containers"
9 | )
10 |
11 | func (pc *PodmanClient) InspectContainer(id string) (*it.InspectContainerData, error) {
12 | // TODO: refactor this, using `With` methods
13 | raw, err := pc.cli.ContainerInspect(id, true)
14 |
15 | if err != nil {
16 | return nil, err
17 | }
18 |
19 | return &it.InspectContainerData{
20 | ContainerSummary: toContainerSummary(raw),
21 | }, nil
22 |
23 | }
24 |
25 | func (pc *PodmanClient) ListContainers(showContainerSize bool) []it.ContainerSummary {
26 | opts := pc.listOptions.WithSize(showContainerSize)
27 | raw, err := pc.cli.ContainerList(opts)
28 |
29 | // log.Panicln("---", showContainerSize, raw[0])
30 |
31 | if err != nil {
32 | panic(err)
33 | }
34 |
35 | return toContainerSummaryArr(raw)
36 | }
37 |
38 | func (pc *PodmanClient) ToggleContainerListAll() {
39 | if pc.containerListOpts.All {
40 | pc.containerListOpts.All = false
41 | pc.listOptions.All = boolPtr(false)
42 | } else {
43 | pc.containerListOpts.All = true
44 | pc.listOptions.All = boolPtr(true)
45 | }
46 | }
47 |
48 | func (pc *PodmanClient) ToggleStartStopContainer(id string, isRunning bool) error {
49 | var err error
50 | if isRunning {
51 | err = pc.cli.ContainerStop(id)
52 | } else {
53 | err = pc.cli.ContainerStart(id)
54 | }
55 |
56 | return err
57 | }
58 |
59 | func (pc *PodmanClient) RestartContainer(id string) error {
60 | return pc.cli.ContainerRestart(id)
61 | }
62 |
63 | func (pc *PodmanClient) TogglePauseResume(id string, state string) error {
64 | var err error
65 | if state == "paused" {
66 | err = pc.cli.ContainerUnpause(id)
67 | } else if state == "running" {
68 | err = pc.cli.ContainerPause(id)
69 | } else {
70 | err = fmt.Errorf("Cannot Pause/unPause a %s Process.", state)
71 | }
72 |
73 | return err
74 | }
75 |
76 | func (pc *PodmanClient) DeleteContainer(id string, opts it.ContainerRemoveOpts) error {
77 | podmanOpts := &containers.RemoveOptions{}
78 | podmanOpts = podmanOpts.WithIgnore(true)
79 |
80 | if opts.Force {
81 | podmanOpts = podmanOpts.WithForce(true)
82 | }
83 | if opts.RemoveVolumes {
84 | podmanOpts = podmanOpts.WithVolumes(true)
85 | }
86 |
87 | _, err := pc.cli.ContainerRemove(id, podmanOpts)
88 | return err
89 | }
90 |
91 | func (po *PodmanClient) PruneContainers() (it.ContainerPruneReport, error) {
92 | report, err := po.cli.ContainerPrune()
93 |
94 | // only count successfully deleted containers
95 | containersDeleted := 0
96 | for _, entry := range report {
97 | if entry.Err == nil {
98 | containersDeleted += 1
99 | }
100 | }
101 | return it.ContainerPruneReport{ContainersDeleted: containersDeleted}, err
102 | }
103 |
104 | func (dc *PodmanClient) ExecCmd(id string) *exec.Cmd {
105 | return exec.Command("podman", "exec", "-it", id, "/bin/sh", "-c", "eval $(grep ^$(id -un): /etc/passwd | cut -d : -f 7-)")
106 | }
107 |
108 | func (dc *PodmanClient) LogsCmd(id string) *exec.Cmd {
109 | return exec.Command("podman", "logs", "--follow", id)
110 | }
111 |
--------------------------------------------------------------------------------
/service/podmancmd/images.go:
--------------------------------------------------------------------------------
1 | package podmancmd
2 |
3 | import (
4 | "bufio"
5 | "encoding/json"
6 | "io"
7 | "strconv"
8 |
9 | it "github.com/ajayd-san/gomanagedocker/service/types"
10 | "github.com/containers/buildah/define"
11 | "github.com/containers/podman/v5/pkg/bindings/images"
12 | "github.com/containers/podman/v5/pkg/domain/entities/types"
13 | "github.com/containers/podman/v5/pkg/specgen"
14 |
15 | nettypes "github.com/containers/common/libnetwork/types"
16 | )
17 |
18 | func (pc *PodmanClient) BuildImage(buildContext string, options it.ImageBuildOptions) (*it.ImageBuildReport, error) {
19 |
20 | /*
21 | INFO: this method has a lot going on, we return a io.Reader that receives data in form of types.ImageBuildJSON.
22 | We want to do this as the parallelly as each step in dockerfile gets processed by image.Build which is why
23 | we use pipes.
24 | */
25 | outR, outW := io.Pipe()
26 | reportPipeR, reportPipeW := io.Pipe()
27 |
28 | reportReader := bufio.NewReader(reportPipeR)
29 |
30 | // we use this to send the error from the builder goroutine to the reader goroutine(below)
31 | errChan := make(chan error, 2)
32 |
33 | go func() {
34 | reader := bufio.NewReader(outR)
35 | for {
36 | str, err := reader.ReadString('\n')
37 | if err != nil {
38 | break
39 | }
40 |
41 | var bytes []byte
42 |
43 | step := it.ImageBuildJSON{
44 | Stream: str,
45 | }
46 | bytes, _ = json.Marshal(step)
47 |
48 | reportPipeW.Write(bytes)
49 | }
50 |
51 | select {
52 | case err := <-errChan:
53 | if err != nil {
54 | errReport := it.ImageBuildJSON{
55 | Error: &it.JSONError{
56 | Message: err.Error(),
57 | },
58 | }
59 |
60 | bytes, _ := json.Marshal(errReport)
61 | reportPipeW.Write(bytes)
62 | }
63 | }
64 |
65 | reportPipeW.Close()
66 | }()
67 |
68 | go func() {
69 | //TODO: registry option
70 | _, err := pc.cli.ImageBuild([]string{options.Dockerfile}, types.BuildOptions{
71 | BuildOptions: define.BuildOptions{
72 | // Labels: []string{"teststr"},
73 | // Registry: "regname",
74 | AdditionalTags: options.Tags,
75 | Out: outW,
76 | },
77 | })
78 |
79 | errChan <- err
80 |
81 | outW.Close()
82 |
83 | }()
84 |
85 | return &it.ImageBuildReport{
86 | Body: reportReader,
87 | }, nil
88 |
89 | }
90 |
91 | func (pc *PodmanClient) ListImages() []it.ImageSummary {
92 | raw, err := pc.cli.ImageList(nil)
93 |
94 | if err != nil {
95 | panic(err)
96 | }
97 |
98 | return toImageSummaryArr(raw)
99 | }
100 |
101 | // runs image and returns container ID
102 | func (pc *PodmanClient) RunImage(config it.ContainerCreateConfig) (*string, error) {
103 | spec := specgen.NewSpecGenerator(config.ImageId, false)
104 |
105 | envMap, err := getEnvMap(&config.Env)
106 |
107 | if err != nil {
108 | return nil, err
109 | }
110 |
111 | bindings := make([]nettypes.PortMapping, len(config.PortBindings))
112 | for i, mapping := range config.PortBindings {
113 | containerPort, _ := strconv.ParseUint(mapping.ContainerPort, 10, 16)
114 | HostPort, _ := strconv.ParseUint(mapping.HostPort, 10, 16)
115 |
116 | bindings[i] = nettypes.PortMapping{
117 | HostIP: "::1",
118 | ContainerPort: uint16(containerPort),
119 | HostPort: uint16(HostPort),
120 | Protocol: mapping.Proto,
121 | }
122 | }
123 |
124 | spec.Name = config.Name
125 | spec.Env = envMap
126 | spec.PortMappings = bindings
127 | spec.NetNS = specgen.Namespace{
128 | NSMode: specgen.Bridge,
129 | }
130 | spec.Pod = config.Pod
131 |
132 | res, err := pc.cli.ContainerCreateWithSpec(spec, nil)
133 |
134 | if err != nil {
135 | return nil, err
136 | }
137 |
138 | err = pc.cli.ContainerStart(res.ID)
139 |
140 | if err != nil {
141 | return nil, err
142 | }
143 |
144 | return &res.ID, nil
145 | }
146 |
147 | func (pc *PodmanClient) DeleteImage(id string, opts it.RemoveImageOptions) error {
148 | _, errs := pc.cli.ImageRemove([]string{id}, &images.RemoveOptions{
149 | All: &opts.All,
150 | Force: &opts.Force,
151 | Ignore: &opts.Ignore,
152 | LookupManifest: &opts.LookupManifest,
153 | NoPrune: &opts.NoPrune,
154 | })
155 |
156 | if errs != nil {
157 | return errs[0]
158 | }
159 |
160 | return nil
161 | }
162 |
163 | func (pc *PodmanClient) PruneImages() (it.ImagePruneReport, error) {
164 | t := true
165 | reports, err := pc.cli.ImagePrune(&images.PruneOptions{
166 | All: &t,
167 | })
168 |
169 | return it.ImagePruneReport{ImagesDeleted: len(reports)}, err
170 | }
171 |
--------------------------------------------------------------------------------
/service/podmancmd/pods.go:
--------------------------------------------------------------------------------
1 | package podmancmd
2 |
3 | import (
4 | "fmt"
5 | "os/exec"
6 |
7 | it "github.com/ajayd-san/gomanagedocker/service/types"
8 | "github.com/containers/podman/v5/pkg/bindings/pods"
9 | "github.com/containers/podman/v5/pkg/domain/entities/types"
10 | )
11 |
12 | func (pc *PodmanClient) ListPods() ([]*types.ListPodsReport, error) {
13 | pods, err := pc.cli.PodsList(nil)
14 |
15 | if err != nil {
16 | return nil, err
17 | }
18 |
19 | return pods, nil
20 | }
21 |
22 | func (pc *PodmanClient) CreatePod(name string) (*types.PodCreateReport, error) {
23 | return pc.cli.PodsCreate(name)
24 | }
25 |
26 | func (pc *PodmanClient) PausePods(id string) error {
27 | _, err := pc.cli.PodsPause(id, nil)
28 | return err
29 | }
30 |
31 | func (pc *PodmanClient) ResumePods(id string) error {
32 | _, err := pc.cli.PodsUnpause(id, nil)
33 | return err
34 | }
35 |
36 | func (pc *PodmanClient) RestartPod(id string) error {
37 | _, err := pc.cli.PodsRestart(id, nil)
38 | return err
39 | }
40 |
41 | func (pc *PodmanClient) PrunePods() (*it.PodsPruneReport, error) {
42 | reports, err := pc.cli.PodsPrune(nil)
43 |
44 | if err != nil {
45 | return nil, err
46 | }
47 |
48 | var success int
49 | for _, report := range reports {
50 | if report.Err == nil {
51 | success += 1
52 | }
53 | }
54 |
55 | return &it.PodsPruneReport{
56 | Removed: success,
57 | }, nil
58 | }
59 |
60 | func (pc *PodmanClient) ToggleStartStopPod(id string, isRunning bool) error {
61 | var err error
62 | if isRunning {
63 | _, err = pc.cli.PodsStop(id, nil)
64 | } else {
65 | _, err = pc.cli.PodsStart(id, nil)
66 | }
67 | return err
68 | }
69 |
70 | func (pc *PodmanClient) TogglePauseResumePod(id string, state string) error {
71 | var err error
72 | if state == "paused" {
73 | _, err = pc.cli.PodsUnpause(id, nil)
74 | } else if state == "running" {
75 | _, err = pc.cli.PodsPause(id, nil)
76 | } else {
77 | err = fmt.Errorf("Cannot Pause/unPause a %s Pod.", state)
78 | }
79 |
80 | return err
81 | }
82 |
83 | func (pc *PodmanClient) DeletePod(id string, force bool) (*it.PodsRemoveReport, error) {
84 | opts := pods.RemoveOptions{}
85 |
86 | if force {
87 | opts.WithForce(true)
88 | }
89 |
90 | report, err := pc.cli.PodsRemove(id, &opts)
91 |
92 | if err != nil {
93 | return nil, err
94 | }
95 |
96 | return &it.PodsRemoveReport{
97 | RemovedCtrs: len(report.RemovedCtrs),
98 | }, nil
99 | }
100 |
101 | func (pc *PodmanClient) LogsCmdPods(id string) *exec.Cmd {
102 | return exec.Command("podman", "pod", "logs", "--follow", "--color", id)
103 | }
104 |
--------------------------------------------------------------------------------
/service/podmancmd/types.go:
--------------------------------------------------------------------------------
1 | package podmancmd
2 |
3 | import (
4 | "github.com/ajayd-san/gomanagedocker/podman"
5 | "github.com/ajayd-san/gomanagedocker/service/types"
6 | "github.com/containers/podman/v5/pkg/bindings/containers"
7 | )
8 |
9 | // TODO: investigate why we have two different containerListopts
10 | type PodmanClient struct {
11 | cli podman.PodmanAPI
12 | containerListOpts types.ContainerListOptions
13 | // internal
14 | listOptions containers.ListOptions
15 | }
16 |
17 | func (pc *PodmanClient) GetListOptions() types.ContainerListOptions {
18 | return pc.containerListOpts
19 | }
20 |
21 | func NewPodmanClient() (*PodmanClient, error) {
22 | api, err := podman.NewPodmanClient()
23 |
24 | if err != nil {
25 | return nil, err
26 | }
27 |
28 | return &PodmanClient{
29 | api,
30 | types.ContainerListOptions{},
31 | containers.ListOptions{
32 | All: boolPtr(false),
33 | },
34 | }, nil
35 | }
36 |
37 | // no-op since bindings.NewConnection already pings
38 | func (pc *PodmanClient) Ping() error {
39 | return nil
40 | }
41 |
42 | func NewMockCli(cli *PodmanMockApi) *PodmanClient {
43 | return &PodmanClient{
44 | cli: cli,
45 | containerListOpts: types.ContainerListOptions{},
46 | listOptions: containers.ListOptions{
47 | All: boolPtr(false),
48 | },
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/service/podmancmd/util.go:
--------------------------------------------------------------------------------
1 | package podmancmd
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "strings"
7 |
8 | it "github.com/ajayd-san/gomanagedocker/service/types"
9 | "github.com/containers/common/libnetwork/types"
10 | "github.com/containers/podman/v5/libpod/define"
11 | et "github.com/containers/podman/v5/pkg/domain/entities/types"
12 | )
13 |
14 | func toImageSummaryArr(summary []*et.ImageSummary) []it.ImageSummary {
15 | res := make([]it.ImageSummary, len(summary))
16 |
17 | for index, entry := range summary {
18 | res[index] = it.ImageSummary{
19 | ID: entry.ID,
20 | Size: entry.Size,
21 | RepoTags: entry.RepoTags,
22 | Containers: int64(entry.Containers),
23 | Created: entry.Created,
24 | }
25 |
26 | }
27 |
28 | return res
29 | }
30 |
31 | func toContainerSummaryArr(summary []et.ListContainer) []it.ContainerSummary {
32 | res := make([]it.ContainerSummary, len(summary))
33 | log.Printf("%#v", summary)
34 |
35 | for index, entry := range summary {
36 | res[index] = it.ContainerSummary{
37 | ServiceKind: it.Podman,
38 | ID: entry.ID,
39 | ImageID: entry.ImageID,
40 | Created: entry.Created.Unix(),
41 | Names: entry.Names,
42 | State: entry.State,
43 | Command: strings.Join(entry.Command, " "),
44 | Mounts: entry.Mounts,
45 | Ports: toPort(entry.Ports),
46 | Pod: entry.PodName,
47 | }
48 |
49 | if entry.Size != nil {
50 | res[index].Size = &it.SizeInfo{
51 | Rw: entry.Size.RwSize,
52 | RootFs: entry.Size.RootFsSize,
53 | }
54 | }
55 | }
56 |
57 | return res
58 | }
59 |
60 | func toPort(ports []types.PortMapping) []it.Port {
61 | res := make([]it.Port, len(ports))
62 |
63 | for i, port := range ports {
64 | res[i] = it.Port{
65 | HostIP: port.HostIP,
66 | HostPort: port.HostPort,
67 | ContainerPort: port.ContainerPort,
68 | Proto: port.Protocol,
69 | }
70 | }
71 |
72 | return res
73 | }
74 |
75 | // func mapState(state *define.InspectContainerState) *types.ContainerState {
76 | // return &types.ContainerState{
77 | // Status: state.Status,
78 | // Running: state.Running,
79 | // Paused: state.Paused,
80 | // Restarting: state.Restarting,
81 | // OOMKilled: state.OOMKilled,
82 | // Dead: state.Dead,
83 | // Pid: state.Pid,
84 | // ExitCode: int(state.ExitCode),
85 | // Error: state.Error,
86 | // }
87 | // }
88 |
89 | func toContainerSummary(info *define.InspectContainerData) it.ContainerSummary {
90 | // jcart, _ := json.MarshalIndent(info, "", "\t")
91 | // log.Println(string(jcart))
92 | res := it.ContainerSummary{
93 | ServiceKind: it.Podman,
94 | ID: info.ID,
95 | ImageID: info.Image,
96 | Created: info.Created.Unix(),
97 | Names: []string{info.Name},
98 | State: info.State.Status,
99 | Pod: info.Pod,
100 | // Command: strings.Join(entry.Command, " "),
101 | Size: &it.SizeInfo{
102 | Rw: *info.SizeRw,
103 | RootFs: info.SizeRootFs,
104 | },
105 | }
106 |
107 | return res
108 | }
109 |
110 | func toVolumeSummaryArr(entries []*et.VolumeListReport) []it.VolumeSummary {
111 | res := make([]it.VolumeSummary, len(entries))
112 |
113 | for index, entry := range entries {
114 | res[index] = it.VolumeSummary{
115 | Name: entry.Name,
116 | CreatedAt: entry.CreatedAt.String(),
117 | Driver: entry.Driver,
118 | Mountpoint: entry.Mountpoint,
119 | UsageData: 0,
120 | }
121 | }
122 |
123 | return res
124 | }
125 |
126 | func boolPtr(b bool) *bool {
127 | return &b
128 | }
129 |
130 | func getEnvMap(envVars *[]string) (map[string]string, error) {
131 | res := make(map[string]string)
132 | for _, entry := range *envVars {
133 | seps := strings.Split(entry, "=")
134 | if len(seps) != 2 {
135 | return nil, fmt.Errorf("Invalid environment variable: %s", entry)
136 | }
137 |
138 | res[seps[0]] = seps[1]
139 | }
140 |
141 | return res, nil
142 | }
143 |
--------------------------------------------------------------------------------
/service/podmancmd/volumes.go:
--------------------------------------------------------------------------------
1 | package podmancmd
2 |
3 | import (
4 | it "github.com/ajayd-san/gomanagedocker/service/types"
5 | )
6 |
7 | func (pc *PodmanClient) ListVolumes() ([]it.VolumeSummary, error) {
8 | res, err := pc.cli.VolumesList(nil)
9 |
10 | if err != nil {
11 | return nil, err
12 | }
13 |
14 | return toVolumeSummaryArr(res), nil
15 | }
16 |
17 | func (pc *PodmanClient) PruneVolumes() (*it.VolumePruneReport, error) {
18 | report, err := pc.cli.VolumesPrune(nil)
19 |
20 | if err != nil {
21 | return nil, err
22 | }
23 |
24 | volumesPruned := 0
25 |
26 | for _, entry := range report {
27 | if entry.Err == nil {
28 | volumesPruned += 1
29 | }
30 | }
31 |
32 | return &it.VolumePruneReport{VolumesPruned: volumesPruned}, nil
33 |
34 | }
35 |
36 | func (pc *PodmanClient) DeleteVolume(id string, force bool) error {
37 | return pc.cli.VolumesRemove(id, force)
38 | }
39 |
--------------------------------------------------------------------------------
/service/service.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "os/exec"
5 |
6 | "github.com/ajayd-san/gomanagedocker/service/types"
7 | )
8 |
9 | // both DockerClient and PodmanClient satisfy this interface
10 | type Service interface {
11 | Ping() error
12 | GetListOptions() types.ContainerListOptions
13 |
14 | // image
15 | BuildImage(buildContext string, options types.ImageBuildOptions) (*types.ImageBuildReport, error)
16 | ListImages() []types.ImageSummary
17 | RunImage(config types.ContainerCreateConfig) (*string, error)
18 | DeleteImage(id string, opts types.RemoveImageOptions) error
19 | PruneImages() (types.ImagePruneReport, error)
20 |
21 | // container
22 | InspectContainer(id string) (*types.InspectContainerData, error)
23 | ListContainers(showContainerSize bool) []types.ContainerSummary
24 | ToggleContainerListAll()
25 | ToggleStartStopContainer(id string, isRunning bool) error
26 | RestartContainer(id string) error
27 | TogglePauseResume(id string, state string) error
28 | DeleteContainer(id string, opts types.ContainerRemoveOpts) error
29 | PruneContainers() (types.ContainerPruneReport, error)
30 | ExecCmd(id string) *exec.Cmd
31 | LogsCmd(id string) *exec.Cmd
32 |
33 | // volume
34 | ListVolumes() ([]types.VolumeSummary, error)
35 | PruneVolumes() (*types.VolumePruneReport, error)
36 | DeleteVolume(id string, force bool) error
37 | }
38 |
--------------------------------------------------------------------------------
/service/types/types.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import "io"
4 |
5 | type ServiceType int
6 |
7 | const (
8 | Docker ServiceType = iota
9 | Podman
10 | )
11 |
12 | type ImageSummary struct {
13 | ID string
14 | Size int64
15 | RepoTags []string
16 | Containers int64
17 | Created int64
18 | }
19 |
20 | /*
21 | this type direct copy of podman's `types.RemoveImageOptions`,
22 | I chose this cuz it is more exhausive compared to docker's
23 | */
24 | type RemoveImageOptions struct {
25 | All bool
26 | Force bool
27 | Ignore bool
28 | LookupManifest bool
29 | NoPrune bool
30 | }
31 |
32 | type ImagePruneReport struct {
33 | ImagesDeleted int
34 | }
35 |
36 | type ImageBuildOptions struct {
37 | Tags []string
38 | Dockerfile string
39 | }
40 |
41 | type ImageBuildReport struct {
42 | Body io.Reader
43 | }
44 |
45 | type ImageBuildJSON struct {
46 | Stream string `json:"stream,omitempty"`
47 | Error *JSONError `json:"errorDetail,omitempty"`
48 | }
49 |
50 | type JSONError struct {
51 | Code int `json:"code,omitempty"`
52 | Message string `json:"message,omitempty"`
53 | }
54 |
55 | type ContainerSummary struct {
56 | // podman or docker
57 | ServiceKind ServiceType
58 | ID string
59 | ImageID string
60 | Created int64
61 | Names []string
62 | State string
63 | Command string
64 | // Status string
65 | Size *SizeInfo
66 | Mounts []string
67 | Ports []Port
68 | // only for podman
69 | Pod string
70 | }
71 |
72 | type SizeInfo struct {
73 | Rw int64
74 | RootFs int64
75 | }
76 |
77 | type Port struct {
78 | HostIP string
79 | HostPort uint16
80 | ContainerPort uint16
81 | Proto string
82 | }
83 |
84 | // // represents container state
85 | // type ContainerState struct {
86 | // Status string // String representation of the container state. Can be one of "created", "running", "paused", "restarting", "removing", "exited", or "dead"
87 | // Running bool
88 | // Paused bool
89 | // Restarting bool
90 | // OOMKilled bool
91 | // Dead bool
92 | // Pid int
93 | // ExitCode int
94 | // Error string
95 | // }
96 |
97 | type VolumeSummary struct {
98 | Name string
99 | CreatedAt string
100 | Driver string
101 | Mountpoint string
102 | UsageData int64
103 | }
104 |
105 | type VolumePruneReport struct {
106 | VolumesPruned int
107 | }
108 |
109 | type InspectContainerData struct {
110 | ContainerSummary
111 | }
112 |
113 | type ContainerListOptions struct {
114 | All bool
115 | Size bool
116 | }
117 |
118 | type ContainerRemoveOpts struct {
119 | Force bool
120 | RemoveVolumes bool
121 | RemoveLinks bool
122 | }
123 |
124 | type ContainerPruneReport struct {
125 | ContainersDeleted int
126 | }
127 |
128 | type ContainerCreateConfig struct {
129 | // ExposedPorts []PortMapping
130 | // name of the container
131 | Name string
132 | Env []string
133 | // ID of image
134 | ImageId string
135 | PortBindings []PortBinding
136 | // only for it.Podman
137 | Pod string
138 | }
139 |
140 | type PortBinding struct {
141 | HostPort string
142 | ContainerPort string
143 | Proto string
144 | }
145 |
146 | // Podman
147 |
148 | type PodsPruneReport struct {
149 | Removed int
150 | }
151 |
152 | type PodsRemoveReport struct {
153 | RemovedCtrs int
154 | }
155 |
--------------------------------------------------------------------------------
/stressTestingonStartup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | for i in {1..100}; do
4 | gnome-terminal --full-screen --title "testing" -- bash -c "go run main.go -debug 2>>error.log" &
5 |
6 | sleep 2
7 |
8 | xdotool search --name testing type q
9 | done
10 |
11 |
--------------------------------------------------------------------------------
/tui/buildProgress.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | "strconv"
7 |
8 | "github.com/ajayd-san/gomanagedocker/tui/components"
9 | teadialog "github.com/ajayd-san/teaDialog"
10 | tea "github.com/charmbracelet/bubbletea"
11 | )
12 |
13 | type buildProgressModel struct {
14 | regex *regexp.Regexp
15 | progressBar components.ProgressBar
16 | progressChan chan string
17 | inner *teadialog.InfoCard
18 | currentStep string
19 | }
20 |
21 | func (m buildProgressModel) Init() tea.Cmd {
22 | return m.progressBar.Init()
23 | }
24 |
25 | func (m buildProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
26 | var cmds []tea.Cmd
27 |
28 | loop:
29 | for {
30 | select {
31 | case status := <-m.progressChan:
32 | matches := m.regex.FindStringSubmatch(status)
33 | if matches != nil {
34 | currentStep, _ := strconv.ParseFloat(matches[1], 64)
35 | TotalSteps, _ := strconv.ParseFloat(matches[2], 64)
36 | m.currentStep = matches[3]
37 |
38 | /*
39 | HACK: we do `-1` since `currentStep` is the current ongoing step, it is not finished yet.
40 | when currentStep == TotalSteps, the progress bar would show 100% even when the build process is not finished
41 | which is not the right behaviour
42 | */
43 | progressBarIncrement := (currentStep - 1) / TotalSteps
44 |
45 | bar, cmd := m.progressBar.Update(components.UpdateProgress(progressBarIncrement))
46 | cmds = append(cmds, cmd)
47 | m.progressBar = bar.(components.ProgressBar)
48 | }
49 | default:
50 | break loop
51 | }
52 | }
53 |
54 | bar, cmd := m.progressBar.Update(msg)
55 | m.progressBar = bar.(components.ProgressBar)
56 | m.inner.Message = fmt.Sprintf("%s\n\n%s", m.progressBar.View(), m.currentStep)
57 | cmds = append(cmds, cmd)
58 |
59 | update, cmd := m.inner.Update(msg)
60 | cmds = append(cmds, cmd)
61 | infoCard := update.(teadialog.InfoCard)
62 | m.inner = &infoCard
63 |
64 | return m, tea.Batch(cmds...)
65 | }
66 |
67 | func (m buildProgressModel) View() string {
68 | return dialogContainerStyle.Render(m.inner.View())
69 | }
70 |
--------------------------------------------------------------------------------
/tui/components/list/defaultitem.go:
--------------------------------------------------------------------------------
1 | package list
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "strings"
7 |
8 | "github.com/charmbracelet/bubbles/key"
9 | tea "github.com/charmbracelet/bubbletea"
10 | "github.com/charmbracelet/lipgloss"
11 | "github.com/muesli/reflow/truncate"
12 | )
13 |
14 | // DefaultItemStyles defines styling for a default list item.
15 | // See DefaultItemView for when these come into play.
16 | type DefaultItemStyles struct {
17 | // The Normal state.
18 | NormalTitle lipgloss.Style
19 | NormalDesc lipgloss.Style
20 |
21 | // The selected item state.
22 | CursorOnTitle lipgloss.Style
23 | CursorOnDesc lipgloss.Style
24 |
25 | // The dimmed state, for when the filter input is initially activated.
26 | DimmedTitle lipgloss.Style
27 | DimmedDesc lipgloss.Style
28 |
29 | // Characters matching the current filter, if any.
30 | FilterMatch lipgloss.Style
31 |
32 | //selected item
33 | SelectedItemTitle lipgloss.Style
34 | SelectedItemDesc lipgloss.Style
35 | }
36 |
37 | // NewDefaultItemStyles returns style definitions for a default item. See
38 | // DefaultItemView for when these come into play.
39 | func NewDefaultItemStyles() (s DefaultItemStyles) {
40 | s.NormalTitle = lipgloss.NewStyle().
41 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}).
42 | Padding(0, 0, 0, 2)
43 |
44 | s.NormalDesc = s.NormalTitle.Copy().
45 | Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"})
46 |
47 | s.CursorOnTitle = lipgloss.NewStyle().
48 | Border(lipgloss.NormalBorder(), false, false, false, true).
49 | BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}).
50 | Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}).
51 | Padding(0, 0, 0, 1)
52 |
53 | s.CursorOnDesc = s.CursorOnTitle.Copy().
54 | Foreground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"})
55 |
56 | s.DimmedTitle = lipgloss.NewStyle().
57 | Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}).
58 | Padding(0, 0, 0, 2)
59 |
60 | s.DimmedDesc = s.DimmedTitle.Copy().
61 | Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"})
62 |
63 | s.FilterMatch = lipgloss.NewStyle().Underline(true)
64 |
65 | // Selected Items styles
66 | s.SelectedItemDesc = lipgloss.NewStyle().
67 | Border(lipgloss.NormalBorder(), false, false, false, true).
68 | BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#FFEA00"}).
69 | Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}).
70 | Padding(0, 0, 0, 1)
71 |
72 | s.SelectedItemTitle = lipgloss.NewStyle().
73 | Border(lipgloss.NormalBorder(), false, false, false, true).
74 | BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#FFEA00"}).
75 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}).
76 | Padding(0, 0, 0, 1)
77 | return s
78 | }
79 |
80 | // DefaultItem describes an items designed to work with DefaultDelegate.
81 | type DefaultItem interface {
82 | Item
83 | GetId() string
84 | Title() string
85 | Description() string
86 | }
87 |
88 | // DefaultDelegate is a standard delegate designed to work in lists. It's
89 | // styled by DefaultItemStyles, which can be customized as you like.
90 | //
91 | // The description line can be hidden by setting Description to false, which
92 | // renders the list as single-line-items. The spacing between items can be set
93 | // with the SetSpacing method.
94 | //
95 | // Setting UpdateFunc is optional. If it's set it will be called when the
96 | // ItemDelegate called, which is called when the list's Update function is
97 | // invoked.
98 | //
99 | // Settings ShortHelpFunc and FullHelpFunc is optional. They can be set to
100 | // include items in the list's default short and full help menus.
101 | type DefaultDelegate struct {
102 | ShowDescription bool
103 | Styles DefaultItemStyles
104 | UpdateFunc func(tea.Msg, *Model) tea.Cmd
105 | ShortHelpFunc func() []key.Binding
106 | FullHelpFunc func() [][]key.Binding
107 | height int
108 | spacing int
109 | }
110 |
111 | // NewDefaultDelegate creates a new delegate with default styles.
112 | func NewDefaultDelegate() DefaultDelegate {
113 | return DefaultDelegate{
114 | ShowDescription: true,
115 | Styles: NewDefaultItemStyles(),
116 | height: 2,
117 | spacing: 1,
118 | }
119 | }
120 |
121 | // SetHeight sets delegate's preferred height.
122 | func (d *DefaultDelegate) SetHeight(i int) {
123 | d.height = i
124 | }
125 |
126 | // Height returns the delegate's preferred height.
127 | // This has effect only if ShowDescription is true,
128 | // otherwise height is always 1.
129 | func (d DefaultDelegate) Height() int {
130 | if d.ShowDescription {
131 | return d.height
132 | }
133 | return 1
134 | }
135 |
136 | // SetSpacing sets the delegate's spacing.
137 | func (d *DefaultDelegate) SetSpacing(i int) {
138 | d.spacing = i
139 | }
140 |
141 | // Spacing returns the delegate's spacing.
142 | func (d DefaultDelegate) Spacing() int {
143 | return d.spacing
144 | }
145 |
146 | // Update checks whether the delegate's UpdateFunc is set and calls it.
147 | func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd {
148 | if d.UpdateFunc == nil {
149 | return nil
150 | }
151 | return d.UpdateFunc(msg, m)
152 | }
153 |
154 | // Render prints an item.
155 | func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item Item) {
156 | var (
157 | title, desc, Id string
158 | matchedRunes []int
159 | s = &d.Styles
160 | )
161 |
162 | if i, ok := item.(DefaultItem); ok {
163 | title = i.Title()
164 | desc = i.Description()
165 | Id = i.GetId()
166 | } else {
167 | return
168 | }
169 |
170 | if m.width <= 0 {
171 | // short-circuit
172 | return
173 | }
174 |
175 | // Prevent text from exceeding list width
176 | textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight())
177 | title = truncate.StringWithTail(title, textwidth, ellipsis)
178 | if d.ShowDescription {
179 | var lines []string
180 | for i, line := range strings.Split(desc, "\n") {
181 | if i >= d.height-1 {
182 | break
183 | }
184 | lines = append(lines, truncate.StringWithTail(line, textwidth, ellipsis))
185 | }
186 | desc = strings.Join(lines, "\n")
187 | }
188 |
189 | // Conditions
190 | var (
191 | isCursorOnCurrentItem = index == m.Index()
192 | emptyFilter = m.FilterState() == Filtering && m.FilterValue() == ""
193 | isFiltered = m.FilterState() == Filtering || m.FilterState() == FilterApplied
194 | )
195 |
196 | if isFiltered && index < len(m.filteredItems) {
197 | // Get indices of matched characters
198 | matchedRunes = m.MatchesForItem(index)
199 | }
200 |
201 | if emptyFilter {
202 | title = s.DimmedTitle.Render(title)
203 | desc = s.DimmedDesc.Render(desc)
204 | } else if isCursorOnCurrentItem && m.FilterState() != Filtering {
205 | if isFiltered {
206 | // Highlight matches
207 | unmatched := s.CursorOnTitle.Inline(true)
208 | matched := unmatched.Copy().Inherit(s.FilterMatch)
209 | title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
210 | }
211 | title = s.CursorOnTitle.Render(title)
212 | desc = s.CursorOnDesc.Render(desc)
213 | } else {
214 | if isFiltered {
215 | // Highlight matches
216 | unmatched := s.NormalTitle.Inline(true)
217 | matched := unmatched.Copy().Inherit(s.FilterMatch)
218 | title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
219 | }
220 | if _, ok := m.selectedItems[Id]; ok {
221 | title = s.SelectedItemTitle.Render(title)
222 | desc = s.SelectedItemDesc.Render(desc)
223 |
224 | } else {
225 | title = s.NormalTitle.Render(title)
226 | desc = s.NormalDesc.Render(desc)
227 | }
228 | }
229 |
230 | res := fmt.Sprintf("%s", title)
231 | if d.ShowDescription {
232 | res = fmt.Sprintf("%s\n%s", title, desc)
233 | }
234 |
235 | fmt.Fprintf(w, "%s", res)
236 | }
237 |
238 | // ShortHelp returns the delegate's short help.
239 | func (d DefaultDelegate) ShortHelp() []key.Binding {
240 | if d.ShortHelpFunc != nil {
241 | return d.ShortHelpFunc()
242 | }
243 | return nil
244 | }
245 |
246 | // FullHelp returns the delegate's full help.
247 | func (d DefaultDelegate) FullHelp() [][]key.Binding {
248 | if d.FullHelpFunc != nil {
249 | return d.FullHelpFunc()
250 | }
251 | return nil
252 | }
253 |
--------------------------------------------------------------------------------
/tui/components/list/keys.go:
--------------------------------------------------------------------------------
1 | package list
2 |
3 | import "github.com/charmbracelet/bubbles/key"
4 |
5 | // KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
6 | // is used to render the menu.
7 | type KeyMap struct {
8 | // Keybindings used when browsing the list.
9 | CursorUp key.Binding
10 | CursorDown key.Binding
11 | NextPage key.Binding
12 | PrevPage key.Binding
13 | GoToStart key.Binding
14 | GoToEnd key.Binding
15 | Filter key.Binding
16 | ClearFilter key.Binding
17 |
18 | // Keybindings used when setting a filter.
19 | CancelWhileFiltering key.Binding
20 | AcceptWhileFiltering key.Binding
21 |
22 | // Help toggle keybindings.
23 | ShowFullHelp key.Binding
24 | CloseFullHelp key.Binding
25 |
26 | // The quit keybinding. This won't be caught when filtering.
27 | Quit key.Binding
28 |
29 | // The quit-no-matter-what keybinding. This will be caught when filtering.
30 | ForceQuit key.Binding
31 | }
32 |
33 | // DefaultKeyMap returns a default set of keybindings.
34 | func DefaultKeyMap() KeyMap {
35 | return KeyMap{
36 | // Browsing.
37 | CursorUp: key.NewBinding(
38 | key.WithKeys("up", "k"),
39 | key.WithHelp("↑/k", "up"),
40 | ),
41 | CursorDown: key.NewBinding(
42 | key.WithKeys("down", "j"),
43 | key.WithHelp("↓/j", "down"),
44 | ),
45 | PrevPage: key.NewBinding(
46 | key.WithKeys("left", "h", "pgup", "b", "u"),
47 | key.WithHelp("←/h/pgup", "prev page"),
48 | ),
49 | NextPage: key.NewBinding(
50 | key.WithKeys("right", "l", "pgdown", "f", "d"),
51 | key.WithHelp("→/l/pgdn", "next page"),
52 | ),
53 | GoToStart: key.NewBinding(
54 | key.WithKeys("home", "g"),
55 | key.WithHelp("g/home", "go to start"),
56 | ),
57 | GoToEnd: key.NewBinding(
58 | key.WithKeys("end", "G"),
59 | key.WithHelp("G/end", "go to end"),
60 | ),
61 | Filter: key.NewBinding(
62 | key.WithKeys("/"),
63 | key.WithHelp("/", "filter"),
64 | ),
65 | ClearFilter: key.NewBinding(
66 | key.WithKeys("esc"),
67 | key.WithHelp("esc", "clear filter"),
68 | ),
69 |
70 | // Filtering.
71 | CancelWhileFiltering: key.NewBinding(
72 | key.WithKeys("esc"),
73 | key.WithHelp("esc", "cancel"),
74 | ),
75 | AcceptWhileFiltering: key.NewBinding(
76 | key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"),
77 | key.WithHelp("enter", "apply filter"),
78 | ),
79 |
80 | // Toggle help.
81 | ShowFullHelp: key.NewBinding(
82 | key.WithKeys("?"),
83 | key.WithHelp("?", "more"),
84 | ),
85 | CloseFullHelp: key.NewBinding(
86 | key.WithKeys("?"),
87 | key.WithHelp("?", "close help"),
88 | ),
89 |
90 | // Quitting.
91 | Quit: key.NewBinding(
92 | key.WithKeys("q", "esc"),
93 | key.WithHelp("q", "quit"),
94 | ),
95 | ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")),
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/tui/components/list/style.go:
--------------------------------------------------------------------------------
1 | package list
2 |
3 | import (
4 | "github.com/charmbracelet/lipgloss"
5 | )
6 |
7 | const (
8 | bullet = "•"
9 | ellipsis = "…"
10 | )
11 |
12 | // Styles contains style definitions for this list component. By default, these
13 | // values are generated by DefaultStyles.
14 | type Styles struct {
15 | TitleBar lipgloss.Style
16 | Title lipgloss.Style
17 | Spinner lipgloss.Style
18 | FilterPrompt lipgloss.Style
19 | FilterCursor lipgloss.Style
20 |
21 | // Default styling for matched characters in a filter. This can be
22 | // overridden by delegates.
23 | DefaultFilterCharacterMatch lipgloss.Style
24 |
25 | StatusBar lipgloss.Style
26 | StatusEmpty lipgloss.Style
27 | StatusBarActiveFilter lipgloss.Style
28 | StatusBarFilterCount lipgloss.Style
29 |
30 | NoItems lipgloss.Style
31 |
32 | PaginationStyle lipgloss.Style
33 | HelpStyle lipgloss.Style
34 |
35 | // Styled characters.
36 | ActivePaginationDot lipgloss.Style
37 | InactivePaginationDot lipgloss.Style
38 | ArabicPagination lipgloss.Style
39 | DividerDot lipgloss.Style
40 | }
41 |
42 | // DefaultStyles returns a set of default style definitions for this list
43 | // component.
44 | func DefaultStyles() (s Styles) {
45 | verySubduedColor := lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
46 | subduedColor := lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}
47 |
48 | s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2)
49 |
50 | s.Title = lipgloss.NewStyle().
51 | Background(lipgloss.Color("62")).
52 | Foreground(lipgloss.Color("230")).
53 | Padding(0, 1)
54 |
55 | s.Spinner = lipgloss.NewStyle().
56 | Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
57 |
58 | s.FilterPrompt = lipgloss.NewStyle().
59 | Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"})
60 |
61 | s.FilterCursor = lipgloss.NewStyle().
62 | Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"})
63 |
64 | s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true)
65 |
66 | s.StatusBar = lipgloss.NewStyle().
67 | Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}).
68 | Padding(0, 0, 1, 2)
69 |
70 | s.StatusEmpty = lipgloss.NewStyle().Foreground(subduedColor)
71 |
72 | s.StatusBarActiveFilter = lipgloss.NewStyle().
73 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
74 |
75 | s.StatusBarFilterCount = lipgloss.NewStyle().Foreground(verySubduedColor)
76 |
77 | s.NoItems = lipgloss.NewStyle().
78 | Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"})
79 |
80 | s.ArabicPagination = lipgloss.NewStyle().Foreground(subduedColor)
81 |
82 | s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:gomnd
83 |
84 | s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2)
85 |
86 | s.ActivePaginationDot = lipgloss.NewStyle().
87 | Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}).
88 | SetString(bullet)
89 |
90 | s.InactivePaginationDot = lipgloss.NewStyle().
91 | Foreground(verySubduedColor).
92 | SetString(bullet)
93 |
94 | s.DividerDot = lipgloss.NewStyle().
95 | Foreground(verySubduedColor).
96 | SetString(" " + bullet + " ")
97 |
98 | return s
99 | }
100 |
--------------------------------------------------------------------------------
/tui/components/loading.go:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/charmbracelet/bubbles/help"
7 | tea "github.com/charmbracelet/bubbletea"
8 | )
9 |
10 | type LoadingModel struct {
11 | spinner SpinnerModel
12 | Loaded bool
13 | msg string
14 | ProgressChan chan UpdateInfo
15 | Help help.Model
16 | }
17 |
18 | type updateType int
19 |
20 | const (
21 | UTLoaded updateType = iota
22 | UTInProgress
23 | )
24 |
25 | type UpdateInfo struct {
26 | Kind updateType
27 | Msg string
28 | }
29 |
30 | func (m LoadingModel) Init() tea.Cmd {
31 | return m.spinner.Init()
32 | }
33 |
34 | func (m LoadingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
35 | select {
36 | case update := <-m.ProgressChan:
37 | if update.Kind == UTLoaded {
38 | m.Loaded = true
39 | }
40 | m.msg = update.Msg
41 | default:
42 | }
43 |
44 | if !m.Loaded {
45 | spinner, cmd := m.spinner.Update(msg)
46 | m.spinner = spinner.(SpinnerModel)
47 | return m, cmd
48 | }
49 |
50 | return m, nil
51 | }
52 |
53 | func (m LoadingModel) View() string {
54 | return fmt.Sprintf("%s %s", m.spinner.View(), m.msg)
55 | }
56 |
57 | func NewLoadingModel() LoadingModel {
58 | return LoadingModel{
59 | spinner: InitialModel(),
60 | ProgressChan: make(chan UpdateInfo),
61 | Help: help.Model{},
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/tui/components/progressBar.go:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import (
4 | "github.com/charmbracelet/bubbles/progress"
5 | tea "github.com/charmbracelet/bubbletea"
6 | "github.com/charmbracelet/lipgloss"
7 | )
8 |
9 | const (
10 | padding = 2
11 | maxWidth = 80
12 | )
13 |
14 | var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render
15 |
16 | type UpdateProgress float64
17 |
18 | type ProgressBar struct {
19 | Progress progress.Model
20 | }
21 |
22 | func (m ProgressBar) Init() tea.Cmd {
23 | return nil
24 | }
25 |
26 | func (m ProgressBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
27 | switch msg := msg.(type) {
28 | case tea.WindowSizeMsg:
29 | m.Progress.Width = msg.Width - padding*2 - 4
30 | if m.Progress.Width > maxWidth {
31 | m.Progress.Width = maxWidth
32 | }
33 | return m, nil
34 |
35 | case UpdateProgress:
36 | cmd := m.Progress.SetPercent(float64(msg))
37 | return m, cmd
38 |
39 | // FrameMsg is sent when the progress bar wants to animate itself
40 | case progress.FrameMsg:
41 | progressModel, cmd := m.Progress.Update(msg)
42 | m.Progress = progressModel.(progress.Model)
43 | return m, cmd
44 |
45 | default:
46 | return m, nil
47 | }
48 | }
49 |
50 | func (m ProgressBar) View() string {
51 | return m.Progress.View()
52 |
53 | }
54 |
55 | func NewProgressBar() ProgressBar {
56 | return ProgressBar{
57 | Progress: progress.New(progress.WithDefaultGradient()),
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tui/components/spinner.go:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | // A simple program demonstrating the spinner component from the Bubbles
4 | // component library.
5 |
6 | import (
7 | "github.com/charmbracelet/bubbles/spinner"
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/charmbracelet/lipgloss"
10 | )
11 |
12 | type errMsg error
13 |
14 | type SpinnerModel struct {
15 | spinner spinner.Model
16 | quitting bool
17 | err error
18 | }
19 |
20 | func InitialModel() SpinnerModel {
21 | s := spinner.New()
22 | s.Spinner = spinner.Dot
23 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
24 | return SpinnerModel{spinner: s}
25 | }
26 |
27 | func (m SpinnerModel) Init() tea.Cmd {
28 | return m.spinner.Tick
29 | }
30 |
31 | func (m SpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
32 | switch msg := msg.(type) {
33 | case tea.KeyMsg:
34 | return m, nil
35 |
36 | case errMsg:
37 | m.err = msg
38 | return m, nil
39 |
40 | default:
41 | var cmd tea.Cmd
42 | m.spinner, cmd = m.spinner.Update(msg)
43 | return m, cmd
44 | }
45 | }
46 |
47 | func (m SpinnerModel) View() string {
48 | if m.err != nil {
49 | return m.err.Error()
50 | }
51 | return m.spinner.View()
52 | }
53 |
--------------------------------------------------------------------------------
/tui/dialogs.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | "slices"
7 |
8 | "github.com/ajayd-san/gomanagedocker/service/dockercmd"
9 | "github.com/ajayd-san/gomanagedocker/tui/components"
10 | teadialog "github.com/ajayd-san/teaDialog"
11 | )
12 |
13 | const (
14 | // containers
15 | dialogRemoveContainer teadialog.DialogType = iota
16 | dialogPruneContainers
17 |
18 | // images
19 | dialogRemoveImage
20 | dialogPruneImages
21 | dialogRunImage
22 |
23 | dialogImageScout
24 | dialogImageBuild
25 | dialogImageBuildProgress
26 |
27 | // volumes
28 | dialogPruneVolumes
29 | dialogRemoveVolumes
30 |
31 | // pods
32 | dialogPrunePods
33 | dialogDeletePod
34 | dialogCreatePod
35 | )
36 |
37 | func getRunImageDialogDocker(storage map[string]string) teadialog.Dialog {
38 | // MGS nerds will prolly like this
39 | prompt := []teadialog.Prompt{
40 | teadialog.MakeTextInputPrompt(
41 | "port",
42 | "Port mappings",
43 | teadialog.WithPlaceHolder("Ex: 1011:2016,226:1984/udp"),
44 | teadialog.WithTextWidth(30),
45 | ),
46 | teadialog.MakeTextInputPrompt(
47 | "name",
48 | "Name",
49 | teadialog.WithPlaceHolder("prologueAwakening"),
50 | teadialog.WithTextWidth(30),
51 | ),
52 | teadialog.MakeTextInputPrompt(
53 | "env",
54 | "Environment variables",
55 | teadialog.WithPlaceHolder("VENOM=AHAB,DD=goodDoggo"),
56 | teadialog.WithTextWidth(30),
57 | ),
58 | }
59 |
60 | title := "Run Image\n(Leave inputs blank for defaults)"
61 | return teadialog.InitDialogWithPrompt(
62 | title,
63 | prompt,
64 | dialogRunImage,
65 | storage,
66 | teadialog.WithShowFullHelp(true),
67 | )
68 | }
69 |
70 | func getRunImageDialogPodman(storage map[string]string, pods []*PodItem) teadialog.Dialog {
71 | final := make([]teadialog.PopupListItem, len(pods))
72 | for i, pod := range pods {
73 | final[i] = teadialog.PopupListItem{
74 | Name: pod.Name,
75 | AdditionalData: pod.Id,
76 | }
77 | }
78 | // button that opens nested dialog to select the pod to run the container in
79 | PodButton := teadialog.Default_list("pod", "pod", final, "select pod", 30, 15)
80 |
81 | prompt := []teadialog.Prompt{
82 | &PodButton,
83 | teadialog.MakeTextInputPrompt(
84 | "port",
85 | "Port mappings",
86 | teadialog.WithPlaceHolder("Ex: 1011:2016,226:1984/udp"),
87 | teadialog.WithTextWidth(30),
88 | ),
89 | teadialog.MakeTextInputPrompt(
90 | "name",
91 | "Name",
92 | teadialog.WithPlaceHolder("prologueAwakening"),
93 | teadialog.WithTextWidth(30),
94 | ),
95 | teadialog.MakeTextInputPrompt(
96 | "env",
97 | "Environment variables",
98 | teadialog.WithPlaceHolder("VENOM=AHAB,DD=goodDoggo"),
99 | teadialog.WithTextWidth(30),
100 | ),
101 | }
102 |
103 | title := "Run Image\n(Leave inputs blank for defaults)"
104 | return teadialog.InitDialogWithPrompt(
105 | title,
106 | prompt,
107 | dialogRunImage,
108 | storage,
109 | teadialog.WithShowFullHelp(true),
110 | )
111 | }
112 |
113 | func getImageScoutDialog(f func() (*dockercmd.ScoutData, error)) DockerScoutInfoCard {
114 | infoCard := teadialog.InitInfoCard(
115 | "Image Scout",
116 | "",
117 | dialogImageScout,
118 | teadialog.WithMinHeight(13),
119 | teadialog.WithMinWidth(130),
120 | )
121 | return DockerScoutInfoCard{
122 | tableChan: make(chan *TableModel),
123 | inner: &infoCard,
124 | f: f,
125 | spinner: components.InitialModel(),
126 | }
127 | }
128 |
129 | func getRemoveContainerDialog(storage map[string]string) teadialog.Dialog {
130 | prompts := []teadialog.Prompt{
131 | teadialog.MakeTogglePrompt("remVols", "Remove volumes?"),
132 | teadialog.MakeTogglePrompt("remLinks", "Remove links?"),
133 | teadialog.MakeTogglePrompt("force", "Force?"),
134 | }
135 |
136 | return teadialog.InitDialogWithPrompt("Remove Container Options:", prompts, dialogRemoveContainer, storage)
137 | }
138 |
139 | func getRemoveVolumeDialog(storage map[string]string) teadialog.Dialog {
140 | prompts := []teadialog.Prompt{
141 | teadialog.MakeTogglePrompt("force", "Force?"),
142 | }
143 |
144 | return teadialog.InitDialogWithPrompt("Remove Volume Options:", prompts, dialogRemoveVolumes, storage)
145 | }
146 |
147 | func getPruneContainersDialog(storage map[string]string) teadialog.Dialog {
148 | prompts := []teadialog.Prompt{
149 | teadialog.MakeOptionPrompt("confirm", "This will remove all stopped containers, are your sure?", []string{"Yes", "No"}),
150 | }
151 |
152 | return teadialog.InitDialogWithPrompt("Prune Containers: ", prompts, dialogPruneContainers, storage)
153 | }
154 |
155 | func getRemoveImageDialog(storage map[string]string) teadialog.Dialog {
156 | prompts := []teadialog.Prompt{
157 | teadialog.MakeTogglePrompt("force", "Force"),
158 | teadialog.MakeTogglePrompt("pruneChildren", "Prune Children"),
159 | }
160 |
161 | return teadialog.InitDialogWithPrompt("Remove Image Options:", prompts, dialogRemoveImage, storage)
162 | }
163 |
164 | func getPruneImagesDialog(storage map[string]string) teadialog.Dialog {
165 | prompts := []teadialog.Prompt{
166 | teadialog.MakeOptionPrompt("confirm", "This will remove all unused images, are your sure?", []string{"Yes", "No"}),
167 | }
168 |
169 | return teadialog.InitDialogWithPrompt("Prune Containers: ", prompts, dialogPruneImages, storage)
170 | }
171 |
172 | func getPruneVolumesDialog(storage map[string]string) teadialog.Dialog {
173 | prompts := []teadialog.Prompt{
174 | teadialog.MakeTogglePrompt("all", "Removed all unused volumes(not just anonymous ones)"),
175 | teadialog.MakeOptionPrompt("confirm", "This will remove all unused volumes, are your sure?", []string{"Yes", "No"}),
176 | }
177 |
178 | return teadialog.InitDialogWithPrompt("Prune Containers: ", prompts, dialogPruneVolumes, storage)
179 | }
180 |
181 | func getBuildImageDialog(storage map[string]string) teadialog.Dialog {
182 | prompts := []teadialog.Prompt{
183 | // teadialog.NewFilePicker("browser"),
184 | // NewFilePicker("filepicker"),
185 | teadialog.MakeTextInputPrompt("image_tags", "Image Tags:"),
186 | }
187 |
188 | return teadialog.InitDialogWithPrompt("Build Image: ", prompts, dialogImageBuild, storage)
189 | }
190 |
191 | // Gets the build progress bar info card/dialog
192 | func getBuildProgress(progressBar components.ProgressBar) buildProgressModel {
193 |
194 | infoCard := teadialog.InitInfoCard(
195 | "Image Build",
196 | "",
197 | dialogImageBuildProgress,
198 | teadialog.WithMinHeight(8),
199 | teadialog.WithMinWidth(100),
200 | )
201 |
202 | reg := regexp.MustCompile(`(?i)Step\s(\d+)\/(\d+)\s?:\s(.*)`)
203 |
204 | return buildProgressModel{
205 | progressChan: make(chan string, 10),
206 | regex: reg,
207 | progressBar: progressBar,
208 | inner: &infoCard,
209 | }
210 | }
211 |
212 | // PODS
213 | func getPrunePodsDialog(storage map[string]string) teadialog.Dialog {
214 | prompts := []teadialog.Prompt{
215 | teadialog.MakeOptionPrompt("confirmPrunePods", "This will remove all stopped pods, are your sure?", []string{"Yes", "No"}),
216 | }
217 |
218 | return teadialog.InitDialogWithPrompt("Prune Pods: ", prompts, dialogPrunePods, storage)
219 | }
220 |
221 | func getRemovePodDialog(running int, storage map[string]string) teadialog.Dialog {
222 | prompts := []teadialog.Prompt{
223 | teadialog.MakeTogglePrompt("force", "Force?"),
224 | }
225 |
226 | if running > 0 {
227 | runningContainersString := containerCountForeground.Render(fmt.Sprintf("%d running", running))
228 | confirmPrompt := teadialog.MakeOptionPrompt(
229 | "confirm",
230 | fmt.Sprintf(
231 | "Are you sure? This pod has %s containers.",
232 | runningContainersString,
233 | ),
234 | []string{"Yes", "No"})
235 | prompts = slices.Insert(prompts, 0, confirmPrompt)
236 | }
237 | return teadialog.InitDialogWithPrompt("Remove Pod Options:", prompts, dialogDeletePod, storage)
238 | }
239 |
240 | func getCreatePodDialog() teadialog.Dialog {
241 | prompts := []teadialog.Prompt{
242 | teadialog.MakeTextInputPrompt(
243 | "podName",
244 | "Name: ",
245 | teadialog.WithTextWidth(30),
246 | teadialog.WithPlaceHolder("enter Pod name"),
247 | ),
248 | }
249 |
250 | return teadialog.InitDialogWithPrompt("Create Pod: ", prompts, dialogCreatePod, nil)
251 | }
252 |
--------------------------------------------------------------------------------
/tui/entry.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "log"
7 | "os"
8 | "time"
9 |
10 | config "github.com/ajayd-san/gomanagedocker/config"
11 | "github.com/ajayd-san/gomanagedocker/service"
12 | "github.com/ajayd-san/gomanagedocker/service/dockercmd"
13 | "github.com/ajayd-san/gomanagedocker/service/podmancmd"
14 | "github.com/ajayd-san/gomanagedocker/service/types"
15 | tea "github.com/charmbracelet/bubbletea"
16 | "github.com/knadh/koanf/v2"
17 | )
18 |
19 | const xdgPathTail string = "/gomanagedocker/gomanagedocker.yaml"
20 |
21 | type TabOrderingMap map[string]tabId
22 |
23 | var (
24 | IMAGES tabId
25 | CONTAINERS tabId
26 | VOLUMES tabId
27 | PODS tabId
28 | )
29 |
30 | var CONFIG_POLLING_TIME time.Duration
31 | var CONFIG_TAB_ORDERING []string
32 | var CONFIG_NOTIFICATION_TIMEOUT time.Duration
33 |
34 | var globalConfig = koanf.New(".")
35 |
36 | /*
37 | stores fatal error that we can print before quitting gracefully
38 | I dont think there is a native way that bubble tea lets you do it for now
39 | */
40 | var earlyExitErr error
41 |
42 | func StartTUI(debug bool, serviceKind types.ServiceType) error {
43 | if debug {
44 | f, _ := tea.LogToFile("gmd_debug.log", "debug")
45 | defer f.Close()
46 | } else {
47 | log.SetOutput(io.Discard)
48 | }
49 |
50 | readConfig()
51 | loadConfig(serviceKind)
52 |
53 | var client service.Service
54 | if serviceKind == types.Docker {
55 | client = dockercmd.NewDockerClient()
56 | } else {
57 | client, _ = podmancmd.NewPodmanClient()
58 | }
59 |
60 | m := NewModel(client, serviceKind)
61 | if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
62 | fmt.Println("Error running program:", err)
63 | return err
64 | }
65 |
66 | /*
67 | we check if there is a fatal error (mostly if docker ping returned an error), print it
68 | and exit with non-zero error code
69 | */
70 | if earlyExitErr != nil {
71 | fmt.Println(earlyExitErr.Error())
72 | os.Exit(1)
73 | }
74 | return nil
75 | }
76 |
77 | func readConfig() {
78 | configPath, err := os.UserConfigDir()
79 |
80 | if err != nil {
81 | log.Println("$HOME could not be determined")
82 | }
83 |
84 | config.ReadConfig(globalConfig, configPath+xdgPathTail)
85 | }
86 |
87 | func loadConfig(serviceKind types.ServiceType) {
88 | CONFIG_POLLING_TIME = globalConfig.Duration("config.Polling-Time") * time.Millisecond
89 | CONFIG_NOTIFICATION_TIMEOUT = globalConfig.Duration("config.Notification-Timeout") * time.Millisecond
90 | // I have no idea how I made this work this late in the dev process, need a reliable way to test this
91 |
92 | switch serviceKind {
93 | case types.Docker:
94 | CONFIG_TAB_ORDERING = globalConfig.Strings("config.Tab-Order.Docker")
95 | case types.Podman:
96 | CONFIG_TAB_ORDERING = globalConfig.Strings("config.Tab-Order.Podman")
97 | }
98 | setTabConstants(CONFIG_TAB_ORDERING)
99 | }
100 |
101 | // set tab variables, AKA IMAGES, CONTAINERS, VOLUMES, etc.
102 | func setTabConstants(configOrder []string) TabOrderingMap {
103 | tabIndexMap := make(TabOrderingMap)
104 | for i, tab := range configOrder {
105 | tabIndexMap[tab] = tabId(i)
106 | }
107 |
108 | // we cannot let tab constants be default values (0) if they are not supplied in config, otherwise it will interfere with tab updation and navigation
109 | if index, ok := tabIndexMap["images"]; ok {
110 | IMAGES = index
111 | } else {
112 | IMAGES = 999
113 | }
114 |
115 | if index, ok := tabIndexMap["containers"]; ok {
116 | CONTAINERS = index
117 | } else {
118 | CONTAINERS = 999
119 | }
120 |
121 | if index, ok := tabIndexMap["volumes"]; ok {
122 | VOLUMES = index
123 | } else {
124 | VOLUMES = 999
125 | }
126 |
127 | if index, ok := tabIndexMap["pods"]; ok {
128 | PODS = index
129 | } else {
130 | PODS = 999
131 | }
132 | return tabIndexMap
133 | }
134 |
--------------------------------------------------------------------------------
/tui/entry_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/ajayd-san/gomanagedocker/service/types"
7 | "gotest.tools/v3/assert"
8 | )
9 |
10 | func TestSetTabConstants(t *testing.T) {
11 |
12 | t.Run("Order 1", func(t *testing.T) {
13 | order := []string{"containers", "volumes", "images"}
14 | setTabConstants(order)
15 | assert.Equal(t, CONTAINERS, tabId(0))
16 | assert.Equal(t, VOLUMES, tabId(1))
17 | assert.Equal(t, IMAGES, tabId(2))
18 | })
19 |
20 | t.Run("Order 2", func(t *testing.T) {
21 | order := []string{"images", "volumes"}
22 | setTabConstants(order)
23 | assert.Equal(t, CONTAINERS, tabId(999))
24 | assert.Equal(t, IMAGES, tabId(0))
25 | assert.Equal(t, VOLUMES, tabId(1))
26 | })
27 | }
28 |
29 | func TestLoadConfig(t *testing.T) {
30 | t.Run("with Docker", func(t *testing.T) {
31 | readConfig()
32 | loadConfig(types.Docker)
33 | assert.DeepEqual(t, CONFIG_TAB_ORDERING, []string{"images", "containers", "volumes"})
34 | })
35 |
36 | t.Run("with Podman", func(t *testing.T) {
37 | readConfig()
38 | loadConfig(types.Podman)
39 | assert.DeepEqual(t, CONFIG_TAB_ORDERING, []string{"images", "containers", "volumes", "pods"})
40 | })
41 | }
42 |
--------------------------------------------------------------------------------
/tui/info.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 | "strings"
7 | "time"
8 |
9 | it "github.com/ajayd-san/gomanagedocker/service/types"
10 | )
11 |
12 | type SimpleInfoBoxer interface {
13 | InfoBox() string
14 | }
15 |
16 | type SizeInfoBoxer interface {
17 | InfoBox(map[string]it.SizeInfo) string
18 | }
19 |
20 | func (im imageItem) InfoBox() string {
21 | var res strings.Builder
22 | id := strings.TrimPrefix(im.ID, "sha256:")
23 | id = trimToLength(id, moreInfoStyle.GetWidth())
24 | addEntry(&res, "id: ", id)
25 | addEntry(&res, "Name: ", im.getName())
26 | sizeInGb := float64(im.getSize())
27 | addEntry(&res, "Size: ", strconv.FormatFloat(sizeInGb, 'f', 2, 64)+"GB")
28 | if im.Containers != -1 {
29 | addEntry(&res, "Containers: ", strconv.Itoa(int(im.Containers)))
30 | }
31 | addEntry(&res, "Created: ", time.Unix(im.Created, 0).Format(time.UnixDate))
32 | return res.String()
33 | }
34 |
35 | func (containerInfo containerItem) InfoBox(containerSizeInfo map[string]it.SizeInfo) string {
36 | var res strings.Builder
37 |
38 | id := trimToLength(containerInfo.ID, moreInfoStyle.GetWidth())
39 | addEntry(&res, "ID: ", id)
40 | addEntry(&res, "Name: ", containerInfo.getName())
41 | addEntry(&res, "Image: ", containerInfo.ImageName)
42 |
43 | if containerInfo.ServiceKind == it.Podman && containerInfo.Pod != "" {
44 | addEntry(&res, "Pod: ", containerInfo.Pod)
45 | }
46 | addEntry(&res, "Created: ", time.Unix(containerInfo.Created, 0).Format(time.UnixDate))
47 |
48 | if size, ok := containerSizeInfo[id]; ok {
49 | rootSizeInGb := float64(size.RootFs) / float64(1e+9)
50 | SizeRwInGb := float64(size.Rw) / float64(1e+9)
51 |
52 | addEntry(&res, "Root FS Size: ", strconv.FormatFloat(rootSizeInGb, 'f', 2, 64)+"GB")
53 | addEntry(&res, "SizeRw: ", strconv.FormatFloat(SizeRwInGb, 'f', 2, 64)+"GB")
54 | } else {
55 |
56 | addEntry(&res, "Root FS Size: ", "Calculating...")
57 | addEntry(&res, "SizeRw: ", "Calculating...")
58 | }
59 |
60 | addEntry(&res, "Command: ", containerInfo.Command)
61 | addEntry(&res, "State: ", containerInfo.State)
62 |
63 | // TODO: figure ports and mount points out
64 | if len(containerInfo.Mounts) > 0 {
65 | addEntry(&res, "Mounts: ", mountPointString(containerInfo.Mounts))
66 | }
67 | if len(containerInfo.Ports) > 0 {
68 | addEntry(&res, "Ports: ", portsString(containerInfo.Ports))
69 | }
70 | return res.String()
71 | }
72 |
73 | func (vi VolumeItem) InfoBox() string {
74 | var res strings.Builder
75 |
76 | addEntry(&res, "Name: ", vi.getName())
77 | addEntry(&res, "Created: ", vi.CreatedAt)
78 | addEntry(&res, "Driver: ", vi.Driver)
79 |
80 | mntPt := trimToLength(vi.Mountpoint, moreInfoStyle.GetWidth())
81 | addEntry(&res, "Mount Point: ", mntPt)
82 |
83 | if size := vi.getSize(); size != -1 {
84 | addEntry(&res, "Size: ", fmt.Sprintf("%f", size))
85 | } else {
86 | addEntry(&res, "Size: ", "Not Available")
87 | }
88 |
89 | return res.String()
90 | }
91 |
92 | func (pi PodItem) InfoBox() string {
93 | var res strings.Builder
94 | addEntry(&res, "Name: ", pi.Name)
95 | addEntry(&res, "ID:", pi.Id)
96 | addEntry(&res, "Status: ", pi.Status)
97 | addEntry(&res, "Containers: ", strconv.Itoa(len(pi.Containers)))
98 |
99 | return res.String()
100 | }
101 |
102 | // UTIL
103 | func addEntry(res *strings.Builder, label string, val string) {
104 | label = infoEntryLabel.Render(label)
105 | entry := infoEntry.Render(label + val)
106 | res.WriteString(entry)
107 | }
108 |
109 | func mountPointString(mounts []string) string {
110 | var res strings.Builder
111 |
112 | // slices.SortStableFunc(mounts, func(a types.MountPoint, b types.MountPoint) int {
113 | // return cmp.Compare(a.Source, b.Source)
114 | // })
115 |
116 | for i, mount := range mounts {
117 | res.WriteString(mount)
118 |
119 | if i < len(mounts)-1 {
120 | res.WriteString(", ")
121 | }
122 | }
123 |
124 | return res.String()
125 | }
126 |
127 | // converts []types.Port to human readable string
128 | func portsString(ports []it.Port) string {
129 | var res strings.Builder
130 |
131 | for _, port := range ports {
132 | var str string
133 | if port.HostPort == 0 {
134 | str = fmt.Sprintf("%d/%s, ", port.ContainerPort, port.Proto)
135 | } else {
136 | str = fmt.Sprintf("%d -> %d/%s, ", port.HostPort, port.ContainerPort, port.Proto)
137 | }
138 | res.WriteString(str)
139 | }
140 |
141 | return strings.TrimSuffix(res.String(), ", ")
142 | }
143 |
144 | func mapToString(m map[string]string) string {
145 | var res strings.Builder
146 |
147 | for key, value := range m {
148 | res.WriteString(fmt.Sprintf("%s: %s", key, value))
149 | }
150 | return res.String()
151 | }
152 |
153 | func trimToLength(id string, availableWidth int) string {
154 | return id[:min(availableWidth-10, len(id))]
155 | }
156 |
--------------------------------------------------------------------------------
/tui/infoCardWrapper.go:
--------------------------------------------------------------------------------
1 | // this is a wrapper on tea.InfoCard since I need my implementation to update
2 | package tui
3 |
4 | import (
5 | "fmt"
6 |
7 | teadialog "github.com/ajayd-san/teaDialog"
8 | tea "github.com/charmbracelet/bubbletea"
9 |
10 | "github.com/ajayd-san/gomanagedocker/service/dockercmd"
11 | "github.com/ajayd-san/gomanagedocker/tui/components"
12 | )
13 |
14 | const customLoadingMessage = "Loading (this may take some time)..."
15 |
16 | type DockerScoutInfoCard struct {
17 | tableChan chan *TableModel
18 | loaded bool
19 | spinner components.SpinnerModel
20 | tableModel *TableModel
21 | inner *teadialog.InfoCard
22 | f func() (*dockercmd.ScoutData, error)
23 | }
24 |
25 | func (m DockerScoutInfoCard) Init() tea.Cmd {
26 | go func() {
27 | ScoutData, err := m.f()
28 | if err != nil {
29 | return
30 | }
31 | m.tableChan <- NewTable(*ScoutData)
32 | }()
33 |
34 | return m.spinner.Init()
35 | }
36 |
37 | func (m DockerScoutInfoCard) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
38 | var cmds []tea.Cmd
39 | if !m.loaded {
40 | spinner, cmd := m.spinner.Update(msg)
41 | m.spinner = spinner.(components.SpinnerModel)
42 | m.inner.Message = fmt.Sprintf("%s %s", m.spinner.View(), customLoadingMessage)
43 | cmds = append(cmds, cmd)
44 |
45 | select {
46 | case m.tableModel = <-m.tableChan:
47 | m.loaded = true
48 | m.inner.Message = m.tableModel.View()
49 | default:
50 | }
51 | }
52 |
53 | update, cmd := m.inner.Update(msg)
54 | infoCard := update.(teadialog.InfoCard)
55 | m.inner = &infoCard
56 |
57 | cmds = append(cmds, cmd)
58 |
59 | return m, tea.Batch(cmds...)
60 | }
61 |
62 | // View renders the program's UI, which is just a string. The view is
63 | // rendered after every Update.
64 | func (m DockerScoutInfoCard) View() string {
65 | return dialogContainerStyle.Render(m.inner.View())
66 | }
67 |
--------------------------------------------------------------------------------
/tui/keys_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 |
6 | it "github.com/ajayd-san/gomanagedocker/service/types"
7 | "gotest.tools/v3/assert"
8 | )
9 |
10 | func TestNewKeyMap(t *testing.T) {
11 | t.Run("Docker, Scout should be enabled enabled", func(t *testing.T) {
12 | dockerKeymap := NewKeyMap(it.Docker)
13 | scoutEnabled := dockerKeymap.image.Scout.Enabled()
14 | assert.Assert(t, scoutEnabled)
15 | })
16 |
17 | t.Run("Podman, Scout should be disabled", func(t *testing.T) {
18 | dockerKeymap := NewKeyMap(it.Podman)
19 | scoutEnabled := dockerKeymap.image.Scout.Enabled()
20 | assert.Assert(t, !scoutEnabled)
21 | })
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/tui/list.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "slices"
5 |
6 | "github.com/ajayd-san/gomanagedocker/tui/components/list"
7 | "github.com/charmbracelet/bubbles/help"
8 | "github.com/charmbracelet/bubbles/key"
9 | tea "github.com/charmbracelet/bubbletea"
10 | )
11 |
12 | const listWidthRatioWithInfoBox = 0.3
13 | const listWidthRatioWithOutInfoBox = 0.85
14 |
15 | var (
16 | // list always takes up 30% of the screen by default
17 | listWidthRatio float32 = listWidthRatioWithInfoBox
18 | )
19 |
20 | type itemSelect struct{}
21 | type clearSelection struct{}
22 |
23 | type listModel struct {
24 | list list.Model
25 | ExistingIds map[string]struct{}
26 | tabKind tabId
27 | listEmpty bool
28 | objectHelp help.KeyMap
29 | objectHelpBulk help.KeyMap
30 | }
31 |
32 | func (m listModel) Init() tea.Cmd {
33 | return nil
34 | }
35 |
36 | func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
37 | switch msg := msg.(type) {
38 | case tea.WindowSizeMsg:
39 | m.list.SetSize(int(listWidthRatio*float32(msg.Width)), msg.Height-12)
40 | listContainer = listContainer.Width(int(listWidthRatio * float32(msg.Width))).Height(msg.Height - 12)
41 | case []dockerRes:
42 | m.updateTab(msg)
43 |
44 | if len(msg) == 0 {
45 | m.listEmpty = true
46 | } else {
47 | m.listEmpty = false
48 | }
49 | case itemSelect:
50 | m.list.ToggleSelect()
51 |
52 | case clearSelection:
53 | m.list.ClearSelection()
54 | }
55 |
56 | var cmd tea.Cmd
57 | m.list, cmd = m.list.Update(msg)
58 | return m, cmd
59 | }
60 |
61 | func (m listModel) View() string {
62 | if m.listEmpty {
63 | return listContainer.Render(emptyListStyle.Render("No items"))
64 | }
65 |
66 | return listContainer.Render(listDocStyle.Render(m.list.View()))
67 | }
68 |
69 | func InitList(tabkind tabId, objectHelp, objectHelpBulk help.KeyMap) listModel {
70 |
71 | items := make([]list.Item, 0)
72 | m := listModel{
73 | list: list.New(items, list.NewDefaultDelegate(), 60, 30),
74 | ExistingIds: make(map[string]struct{}),
75 | tabKind: tabkind,
76 | objectHelp: objectHelp,
77 | objectHelpBulk: objectHelpBulk,
78 | }
79 |
80 | m.list.Title = CONFIG_POLLING_TIME.String()
81 | m.list.StatusMessageLifetime = CONFIG_NOTIFICATION_TIMEOUT
82 | m.list.DisableQuitKeybindings()
83 | m.list.SetShowHelp(false)
84 | m.list.KeyMap.NextPage = key.NewBinding(key.WithKeys("]"))
85 | m.list.KeyMap.PrevPage = key.NewBinding(key.WithKeys("["))
86 |
87 | return m
88 | }
89 |
90 | // returns if we are in bulk more or nah
91 | func (m listModel) inBulkMode() bool {
92 | return len(m.list.GetSelected()) > 1
93 | }
94 |
95 | // returns `help.KeyMap` depending on context (i.e in bulk selection mode or nah)
96 | func (m listModel) getKeymap() help.KeyMap {
97 | if m.inBulkMode() {
98 | return m.objectHelpBulk
99 | }
100 | return m.objectHelp
101 | }
102 |
103 | func makeItems(raw []dockerRes) []list.Item {
104 | listItems := make([]list.Item, len(raw))
105 |
106 | // TODO: only converting to gb (might want to change later to accommodate mb)
107 | for i, data := range raw {
108 | listItems[i] = list.Item(data)
109 | }
110 |
111 | return listItems
112 | }
113 |
114 | // Util
115 |
116 | /*
117 | This function repopulates the tab with updated items(if they are any).
118 | For now does a linear search if the number of items have not changed to update the list (O(n) time)
119 | */
120 | func (m *listModel) updateTab(newlist []dockerRes) {
121 | comparisonFunc := func(a dockerRes, b list.Item) bool {
122 | switch m.tabKind {
123 | case IMAGES:
124 | newA := a.(imageItem)
125 | newB := b.(imageItem)
126 |
127 | if newA.Containers != newB.Containers {
128 | return false
129 | }
130 | case CONTAINERS:
131 | newA := a.(containerItem)
132 | newB := b.(containerItem)
133 |
134 | if newA.State != newB.State {
135 | return false
136 | }
137 | case VOLUMES:
138 | // newA := a.(VolumeItem)
139 | // newB := b.(VolumeItem)
140 | case PODS:
141 | newA := a.(PodItem)
142 | newB := b.(PodItem)
143 |
144 | if newA.Status != newB.Status {
145 | return false
146 | }
147 |
148 | if len(newA.Containers) != len(newB.Containers) {
149 | return false
150 | }
151 | }
152 |
153 | return true
154 | }
155 |
156 | if !slices.EqualFunc(newlist, m.list.Items(), comparisonFunc) {
157 | newlistItems := makeItems(newlist)
158 | m.list.SetItems(newlistItems)
159 | go m.updateExistigIds(&newlist)
160 | }
161 |
162 | }
163 |
164 | func (m *listModel) updateExistigIds(newlistItems *[]dockerRes) {
165 | for _, item := range *newlistItems {
166 | if _, ok := m.ExistingIds[item.GetId()]; !ok {
167 | m.ExistingIds[item.GetId()] = struct{}{}
168 | }
169 | }
170 | }
171 |
172 | // returns `tea.Cmd` that returns `clearSelection{}`
173 | func clearSelectionCmd() tea.Cmd {
174 | return func() tea.Msg {
175 | return clearSelection{}
176 | }
177 | }
178 |
--------------------------------------------------------------------------------
/tui/list_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/ajayd-san/gomanagedocker/service/types"
8 | it "github.com/ajayd-san/gomanagedocker/service/types"
9 | tea "github.com/charmbracelet/bubbletea"
10 | "gotest.tools/v3/assert"
11 | )
12 |
13 | func TestUpdateExistingIds(t *testing.T) {
14 |
15 | containers := []types.ContainerSummary{
16 | {
17 | Names: []string{"a"},
18 | ID: "1",
19 | Size: &it.SizeInfo{
20 | Rw: 1e+9,
21 | RootFs: 2e+9,
22 | },
23 | State: "running",
24 | },
25 | {
26 | Names: []string{"b"},
27 | ID: "2",
28 | Size: &it.SizeInfo{
29 | Rw: 201,
30 | RootFs: 401,
31 | },
32 | State: "running",
33 | },
34 | {
35 | Names: []string{"c"},
36 | ID: "3",
37 | Size: &it.SizeInfo{
38 | Rw: 202,
39 | RootFs: 403,
40 | },
41 | State: "running",
42 | },
43 | {
44 |
45 | Names: []string{"d"},
46 | ID: "4",
47 | Size: &it.SizeInfo{
48 | Rw: 203,
49 | RootFs: 403,
50 | },
51 | State: "running",
52 | },
53 | }
54 |
55 | imgs := []it.ImageSummary{
56 | {
57 | Containers: 0,
58 | ID: "0",
59 | RepoTags: []string{"a"},
60 | },
61 |
62 | {
63 | Containers: 0,
64 | ID: "1",
65 | RepoTags: []string{"b"},
66 | },
67 | {
68 | Containers: 3,
69 | ID: "2",
70 | RepoTags: []string{"c"},
71 | },
72 | {
73 | Containers: 0,
74 | ID: "3",
75 | RepoTags: []string{"d"},
76 | },
77 | }
78 | CONTAINERS = 0
79 | IMAGES = 1
80 |
81 | keymap := NewKeyMap(it.Docker)
82 |
83 | t.Run("Assert container Ids", func(t *testing.T) {
84 | contList := InitList(0, keymap.container, keymap.containerBulk)
85 | dres := makeContainerItems(containers, make(map[string]string))
86 | contList.updateExistigIds(&dres)
87 | want := map[string]struct{}{
88 | "1": {},
89 | "2": {},
90 | "3": {},
91 | "4": {},
92 | }
93 |
94 | assert.DeepEqual(t, contList.ExistingIds, want)
95 | })
96 |
97 | t.Run("Assert image Ids", func(t *testing.T) {
98 | imgsList := InitList(IMAGES, keymap.image, keymap.imageBulk)
99 | dres := makeImageItems(imgs)
100 | imgsList.updateExistigIds(&dres)
101 | want := map[string]struct{}{
102 | "0": {},
103 | "1": {},
104 | "2": {},
105 | "3": {},
106 | }
107 | assert.DeepEqual(t, imgsList.ExistingIds, want)
108 | })
109 | }
110 |
111 | func TestUpdateTab(t *testing.T) {
112 | IMAGES = 0
113 | CONTAINERS = 1
114 |
115 | imgs := []it.ImageSummary{
116 | {
117 | Containers: 0,
118 | ID: "0",
119 | RepoTags: []string{"a"},
120 | },
121 |
122 | {
123 | Containers: 0,
124 | ID: "1",
125 | RepoTags: []string{"b"},
126 | },
127 | {
128 | Containers: 3,
129 | ID: "2",
130 | RepoTags: []string{"c"},
131 | },
132 | {
133 | Containers: 0,
134 | ID: "3",
135 | RepoTags: []string{"d"},
136 | },
137 | }
138 |
139 | keymap := NewKeyMap(it.Docker)
140 |
141 | list := InitList(IMAGES, keymap.image, keymap.imageBulk)
142 | t.Run("Assert Images subset", func(t *testing.T) {
143 | subset := imgs[:2]
144 | dres := makeImageItems(subset)
145 | list.updateTab(dres)
146 |
147 | liItems := list.list.Items()
148 |
149 | for i := range len(liItems) {
150 | got := liItems[i].(imageItem)
151 | want := subset[i]
152 |
153 | assert.DeepEqual(t, got.ImageSummary, want)
154 | }
155 | })
156 |
157 | t.Run("Assert Images full", func(t *testing.T) {
158 | dres := makeImageItems(imgs)
159 | list.updateTab(dres)
160 |
161 | liItems := list.list.Items()
162 |
163 | for i := range len(liItems) {
164 | got := liItems[i].(imageItem)
165 | want := imgs[i]
166 |
167 | assert.DeepEqual(t, got.ImageSummary, want)
168 | }
169 | })
170 |
171 | }
172 |
173 | func TestUpdate(t *testing.T) {
174 | IMAGES = 0
175 | CONTAINERS = 1
176 |
177 | imgs := []it.ImageSummary{
178 | {
179 | Containers: 0,
180 | ID: "0",
181 | RepoTags: []string{"a"},
182 | },
183 |
184 | {
185 | Containers: 0,
186 | ID: "1",
187 | RepoTags: []string{"b"},
188 | },
189 | {
190 | Containers: 3,
191 | ID: "2",
192 | RepoTags: []string{"c"},
193 | },
194 | {
195 | Containers: 0,
196 | ID: "3",
197 | RepoTags: []string{"d"},
198 | },
199 | }
200 |
201 | keymap := NewKeyMap(it.Docker)
202 | imgList := InitList(IMAGES, keymap.image, keymap.imageBulk)
203 |
204 | t.Run("Update images", func(t *testing.T) {
205 | dres := makeImageItems(imgs)
206 | temp, _ := imgList.Update(dres)
207 | imgList = temp.(listModel)
208 |
209 | listItems := imgList.list.Items()
210 |
211 | for i := range len(listItems) {
212 | got := listItems[i].(imageItem)
213 | want := imgs[i]
214 |
215 | assert.DeepEqual(t, got.ImageSummary, want)
216 | }
217 | })
218 |
219 | t.Run("Update list size", func(t *testing.T) {
220 | assert.Equal(t, imgList.list.Width(), 60)
221 | temp, _ := imgList.Update(tea.WindowSizeMsg{Width: 210, Height: 100})
222 | imgList := temp.(listModel)
223 |
224 | assert.Equal(t, imgList.list.Width(), int(210*0.3))
225 | })
226 | }
227 |
228 | func TestEmptyList(t *testing.T) {
229 |
230 | IMAGES = 0
231 | CONTAINERS = 1
232 |
233 | imgs := []it.ImageSummary{
234 | {
235 | Containers: 0,
236 | ID: "0as;dkfjasdfasdfasdfaasdf",
237 | RepoTags: []string{"a"},
238 | },
239 |
240 | {
241 | Containers: 0,
242 | ID: "10as;dkfjasdfasdfasdfaasdf",
243 | RepoTags: []string{"b"},
244 | },
245 | {
246 | Containers: 3,
247 | ID: "20as;dkfjasdfasdfasdfaasdf",
248 | RepoTags: []string{"c"},
249 | },
250 | {
251 | Containers: 0,
252 | ID: "30as;dkfjasdfasdfasdfaasdf",
253 | RepoTags: []string{"d"},
254 | },
255 | }
256 |
257 | keymap := NewKeyMap(it.Docker)
258 | imgList := InitList(IMAGES, keymap.image, keymap.imageBulk)
259 |
260 | t.Run("List with items", func(t *testing.T) {
261 | dres := makeImageItems(imgs)
262 | temp, _ := imgList.Update(dres)
263 | imgList = temp.(listModel)
264 | got := imgList.View()
265 |
266 | assert.Assert(t, !strings.Contains(got, "No items"))
267 | })
268 |
269 | t.Run("Empty list", func(t *testing.T) {
270 | temp, _ := imgList.Update([]dockerRes{})
271 | imgList = temp.(listModel)
272 | got := imgList.View()
273 |
274 | assert.Assert(t, strings.Contains(got, "No items"))
275 |
276 | })
277 |
278 | }
279 |
--------------------------------------------------------------------------------
/tui/mainModel_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "errors"
5 | "regexp"
6 | "strings"
7 | "sync"
8 | "testing"
9 |
10 | "github.com/ajayd-san/gomanagedocker/service/dockercmd"
11 | "github.com/ajayd-san/gomanagedocker/service/podmancmd"
12 | it "github.com/ajayd-san/gomanagedocker/service/types"
13 | tea "github.com/charmbracelet/bubbletea"
14 | "github.com/docker/docker/api/types"
15 | "github.com/docker/docker/api/types/image"
16 | "gotest.tools/v3/assert"
17 | )
18 |
19 | func TestNewModel(t *testing.T) {
20 | CONFIG_TAB_ORDERING = []string{"images", "volumes"}
21 |
22 | t.Run("with docker client", func(t *testing.T) {
23 | client, _ := podmancmd.NewPodmanClient()
24 | model := NewModel(client, it.Docker)
25 | assert.DeepEqual(t, model.Tabs, CONFIG_TAB_ORDERING)
26 | assert.Equal(t, model.activeTab, tabId(0))
27 | assert.Equal(t, model.serviceKind, it.Docker)
28 | })
29 |
30 | t.Run("with podman client", func(t *testing.T) {
31 | client := dockercmd.NewDockerClient()
32 | model := NewModel(client, it.Podman)
33 | assert.DeepEqual(t, model.Tabs, CONFIG_TAB_ORDERING)
34 | assert.Equal(t, model.activeTab, tabId(0))
35 | assert.Equal(t, model.serviceKind, it.Podman)
36 | })
37 | }
38 |
39 | func TestFetchNewData(t *testing.T) {
40 | api := dockercmd.MockApi{}
41 |
42 | containers := []types.Container{
43 | {
44 | Names: []string{"a"},
45 | ID: "1",
46 | SizeRw: 1e+9,
47 | SizeRootFs: 2e+9,
48 | State: "running",
49 | Status: "",
50 | },
51 | {
52 | Names: []string{"b"},
53 | ID: "2",
54 | SizeRw: 201,
55 | SizeRootFs: 401,
56 | State: "running",
57 | },
58 | {
59 | Names: []string{"c"},
60 | ID: "3",
61 | SizeRw: 202,
62 | SizeRootFs: 402,
63 | State: "running",
64 | },
65 | {
66 |
67 | Names: []string{"d"},
68 | ID: "4",
69 | SizeRw: 203,
70 | SizeRootFs: 403,
71 | State: "running",
72 | },
73 | }
74 |
75 | imgs := []image.Summary{
76 | {
77 | Containers: 0,
78 | ID: "0",
79 | RepoTags: []string{"a"},
80 | },
81 |
82 | {
83 | Containers: 0,
84 | ID: "1",
85 | RepoTags: []string{"b"},
86 | },
87 | {
88 | Containers: 3,
89 | ID: "2",
90 | RepoTags: []string{"c"},
91 | },
92 | {
93 | Containers: 0,
94 | ID: "3",
95 | RepoTags: []string{"d"},
96 | },
97 | }
98 |
99 | api.SetMockContainers(containers)
100 | api.SetMockImages(imgs)
101 |
102 | mockcli := dockercmd.NewMockCli(&api)
103 |
104 | CONTAINERS = 0
105 | IMAGES = 1
106 | VOLUMES = 2
107 | keymap := NewKeyMap(it.Docker)
108 | model := MainModel{
109 | dockerClient: mockcli,
110 | activeTab: 0,
111 | TabContent: []listModel{
112 | InitList(0, keymap.container, keymap.container),
113 | },
114 | containerSizeTracker: ContainerSizeManager{
115 | sizeMap: make(map[string]it.SizeInfo),
116 | mu: &sync.Mutex{},
117 | },
118 | imageIdToNameMap: map[string]string{},
119 | }
120 |
121 | wg := sync.WaitGroup{}
122 | newlist := model.fetchNewData(0, false, &wg)
123 | wg.Wait()
124 |
125 | t.Run("Containers", func(t *testing.T) {
126 | t.Run("Assert lists", func(t *testing.T) {
127 | want := containers
128 |
129 | assert.Equal(t, len(newlist), len(want))
130 | for i := range len(newlist) {
131 | assert.Equal(t, newlist[i].GetId(), want[i].ID)
132 | assert.Equal(t, newlist[i].getName(), strings.Join(want[i].Names, ","))
133 | }
134 | })
135 |
136 | // this fails on macos ci for some reason
137 | // t.Run("Assert containerSizeMaps", func(t *testing.T) {
138 | // want := map[string]it.SizeInfo{
139 | // "1": {Rw: 1e+9, RootFs: 2e+9},
140 | // "2": {Rw: 201, RootFs: 401},
141 | // "3": {Rw: 202, RootFs: 402},
142 | // "4": {Rw: 203, RootFs: 403},
143 | // }
144 |
145 | // assert.DeepEqual(t, model.containerSizeTracker.sizeMap, want, cmpopts.EquateComparable(it.SizeInfo{}))
146 | // })
147 | })
148 |
149 | t.Run("Images", func(t *testing.T) {
150 | model.nextTab()
151 | assert.Equal(t, model.activeTab, IMAGES)
152 | newlist := model.fetchNewData(IMAGES, true, &wg)
153 | wg.Wait()
154 | t.Run("Assert images", func(t *testing.T) {
155 |
156 | for i := range len(newlist) {
157 | img := newlist[i].(imageItem)
158 | assert.Equal(t, img.ImageSummary.ID, imgs[i].ID)
159 | assert.DeepEqual(t, img.ImageSummary.RepoTags, imgs[i].RepoTags)
160 | }
161 | })
162 |
163 | t.Run("Assert imageIdToNameMap", func(t *testing.T) {
164 | want := map[string]string{
165 | "0": "a",
166 | "1": "b",
167 | "2": "c",
168 | "3": "d",
169 | }
170 | assert.DeepEqual(t, model.imageIdToNameMap, want)
171 | })
172 |
173 | })
174 |
175 | }
176 |
177 | func TestInfoBoxSize(t *testing.T) {
178 | api := dockercmd.MockApi{}
179 |
180 | containers := []types.Container{
181 | {
182 | Names: []string{"a"},
183 | ID: "1",
184 | SizeRw: 1e+9,
185 | SizeRootFs: 2e+9,
186 | State: "running",
187 | Status: "",
188 | },
189 | }
190 |
191 | api.SetMockContainers(containers)
192 |
193 | mockcli := dockercmd.NewMockCli(&api)
194 |
195 | keymap := NewKeyMap(it.Docker)
196 | CONTAINERS = 0
197 | model := MainModel{
198 | dockerClient: mockcli,
199 | activeTab: 0,
200 | TabContent: []listModel{
201 | InitList(0, keymap.container, keymap.containerBulk),
202 | },
203 | }
204 |
205 | t.Run("With (100 width, 100 height)", func(t *testing.T) {
206 | model.Update(tea.WindowSizeMsg{Width: 100, Height: 100})
207 | assert.Equal(t, moreInfoStyle.GetHeight(), 60)
208 | assert.Equal(t, moreInfoStyle.GetWidth(), 55)
209 | })
210 |
211 | t.Run("With (350 width, 200 height)", func(t *testing.T) {
212 | model.Update(tea.WindowSizeMsg{Width: 350, Height: 200})
213 | assert.Equal(t, moreInfoStyle.GetHeight(), 120)
214 | assert.Equal(t, moreInfoStyle.GetWidth(), 192)
215 | })
216 |
217 | }
218 |
219 | func TestMainModelUpdate(t *testing.T) {
220 | api := dockercmd.MockApi{}
221 |
222 | containers := []types.Container{
223 | {
224 | Names: []string{"a"},
225 | ID: "1",
226 | SizeRw: 1e+9,
227 | SizeRootFs: 2e+9,
228 | State: "running",
229 | Status: "",
230 | },
231 | }
232 |
233 | api.SetMockContainers(containers)
234 |
235 | mockcli := dockercmd.NewMockCli(&api)
236 |
237 | keymap := NewKeyMap(it.Docker)
238 | CONTAINERS = 0
239 | model := MainModel{
240 | dockerClient: mockcli,
241 | activeTab: 0,
242 | TabContent: []listModel{
243 | InitList(0, keymap.container, keymap.containerBulk),
244 | },
245 | }
246 |
247 | //model.windowTooSmall should be true if height < 25 or width < 65
248 | t.Run("Assert Window too small with small height", func(t *testing.T) {
249 | temp, _ := model.Update(tea.WindowSizeMsg{
250 | Width: 100,
251 | Height: 24,
252 | })
253 |
254 | model = temp.(MainModel)
255 |
256 | assert.Check(t, model.windowTooSmall)
257 | })
258 |
259 | t.Run("Assert Window too small with small width", func(t *testing.T) {
260 | temp, _ := model.Update(tea.WindowSizeMsg{
261 | Width: 64,
262 | Height: 100,
263 | })
264 |
265 | model = temp.(MainModel)
266 |
267 | assert.Check(t, model.windowTooSmall)
268 | })
269 |
270 | // if msg.Height <= 31 || msg.Width < 105 {
271 | t.Run("Assert displayInfoBox with small width", func(t *testing.T) {
272 | temp, _ := model.Update(tea.WindowSizeMsg{
273 | Width: 104,
274 | Height: 100,
275 | })
276 |
277 | model = temp.(MainModel)
278 |
279 | assert.Check(t, !model.displayInfoBox)
280 | })
281 |
282 | t.Run("Assert displayInfoBox with small height", func(t *testing.T) {
283 | temp, _ := model.Update(tea.WindowSizeMsg{
284 | Width: 105,
285 | Height: 31,
286 | })
287 |
288 | model = temp.(MainModel)
289 |
290 | assert.Check(t, !model.displayInfoBox)
291 | })
292 | }
293 |
294 | func TestRunBackground(t *testing.T) {
295 | model := MainModel{
296 | possibleLongRunningOpErrorChan: make(chan error, 10),
297 | notificationChan: make(chan notificationMetadata, 10),
298 | }
299 |
300 | t.Run("Gets error, should not send notification", func(t *testing.T) {
301 | op := func() error {
302 | return errors.New("error")
303 | }
304 |
305 | model.runBackground(op)
306 |
307 | select {
308 | case <-model.possibleLongRunningOpErrorChan:
309 | default:
310 | t.Errorf("Should recieve an error")
311 | }
312 | })
313 |
314 | t.Run("Does not get an error, should send notification", func(t *testing.T) {
315 | op := func() error {
316 | return nil
317 | }
318 |
319 | model.runBackground(op)
320 |
321 | select {
322 | case <-model.possibleLongRunningOpErrorChan:
323 | t.Errorf("Should not recieve an error")
324 | default:
325 | }
326 | })
327 | }
328 |
329 | func TestGetRegexMatch(t *testing.T) {
330 | reg := regexp.MustCompile(`(?i)Step\s(\d+)\/(\d+)\s?:\s(.*)`)
331 | t.Run("docker step", func(t *testing.T) {
332 | str := "Step 4/4 : RUN echo \"alpine\""
333 |
334 | matches := reg.FindStringSubmatch(str)
335 | assert.DeepEqual(t, matches, []string{str, "4", "4", "RUN echo \"alpine\""})
336 | })
337 |
338 | t.Run("podman step", func(t *testing.T) {
339 | str := "STEP 3/5: RUN sleep 2"
340 |
341 | matches := reg.FindStringSubmatch(str)
342 | assert.DeepEqual(t, matches, []string{str, "3", "5", "RUN sleep 2"})
343 | })
344 |
345 | }
346 |
--------------------------------------------------------------------------------
/tui/styles.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import "github.com/charmbracelet/lipgloss"
4 |
5 | var (
6 | subduedColor = lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}
7 | statusGreen = lipgloss.Color("35")
8 | )
9 |
10 | var (
11 | successForeground = lipgloss.NewStyle().Foreground(statusGreen)
12 | containerCountForeground = successForeground.Bold(true)
13 | )
14 |
15 | var (
16 | inactiveTabBorder = tabBorderWithBottom("┴", "─", "┴")
17 | activeTabBorder = tabBorderWithBottom("┘", " ", "└")
18 | // The outer most container, this just applies padding to the Window
19 | docStyle = lipgloss.NewStyle().Padding(0, 1, 0, 2)
20 | highlightColor = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
21 | inactiveTabStyle = lipgloss.NewStyle().Border(inactiveTabBorder, true).BorderForeground(highlightColor).Padding(0, 1)
22 | activeTabStyle = inactiveTabStyle.Copy().Border(activeTabBorder, true)
23 | fillerStyle = lipgloss.NewStyle().Foreground(highlightColor)
24 | // The outer most visible border, this encloses all the elements on screen (except for dialogs, for now)
25 | windowStyle = lipgloss.NewStyle().
26 | BorderForeground(highlightColor).
27 | Border(lipgloss.NormalBorder()).
28 | UnsetBorderTop()
29 |
30 | listDocStyle = lipgloss.NewStyle().Margin(1, 5, 0, 1)
31 | listContainer = lipgloss.NewStyle().Border(lipgloss.HiddenBorder()).Width(60) // 60 is the default width of the list
32 |
33 | emptyListStyle = lipgloss.NewStyle().Foreground(subduedColor).MarginTop(2).MarginLeft(2)
34 | listStatusMessageStyle = lipgloss.NewStyle().Background(statusGreen).Padding(0, 2)
35 |
36 | moreInfoStyle = lipgloss.NewStyle().
37 | Border(lipgloss.NormalBorder()).
38 | BorderForeground(lipgloss.Color("69")).
39 | Width(90).
40 | Height(25).MarginTop(2).MarginLeft(5).MarginRight(1)
41 |
42 | infoEntryLabel = lipgloss.NewStyle().
43 | Foreground(lipgloss.Color("49")).
44 | Bold(true)
45 |
46 | infoEntry = lipgloss.NewStyle().Margin(1)
47 |
48 | dialogContainerStyle = lipgloss.NewStyle().Align(lipgloss.Center, lipgloss.Center)
49 |
50 | windowTooSmallStyle = lipgloss.NewStyle().
51 | Border(lipgloss.NormalBorder()).
52 | BorderForeground(highlightColor).
53 | Align(lipgloss.Center, lipgloss.Center).
54 | Padding(2, 2)
55 | containerRunningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("41"))
56 | containerExitedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("172"))
57 | containerCreatedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("118"))
58 | containerDeadStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("88"))
59 | containerRestartingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("200"))
60 | )
61 |
--------------------------------------------------------------------------------
/tui/table.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "github.com/ajayd-san/gomanagedocker/service/dockercmd"
5 | tea "github.com/charmbracelet/bubbletea"
6 | "github.com/charmbracelet/lipgloss"
7 | "github.com/evertras/bubble-table/table"
8 | )
9 |
10 | const (
11 | columnKeyLabel = "label"
12 | columnKeyImgName = "imgName"
13 | columnKeyCrit = "crit"
14 | columnKeyhigh = "high"
15 | columnKeyMed = "med"
16 | columnKeyLow = "low"
17 | columnKeyUnknown = "unknown"
18 | )
19 |
20 | const (
21 | columnLabelWidth = 25
22 | columnImgNameWidth = 25
23 | columnNumTypeWidth = 10
24 | )
25 |
26 | var (
27 | colorImgName = lipgloss.NewStyle().Foreground(lipgloss.Color("#fa0"))
28 | colorCrit = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) //196
29 | colorHigh = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
30 | colorMed = lipgloss.NewStyle().Foreground(lipgloss.Color("208"))
31 | colorLow = lipgloss.NewStyle().Foreground(lipgloss.Color("190")) //226
32 | colorUnkown = lipgloss.NewStyle().Foreground(lipgloss.Color("129"))
33 | )
34 |
35 | var (
36 | styleBase = lipgloss.NewStyle().
37 | Foreground(lipgloss.Color("#a7a")).
38 | BorderForeground(lipgloss.Color("#a38")).Align(lipgloss.Center)
39 | )
40 |
41 | type TableModel struct {
42 | inner table.Model
43 | }
44 |
45 | func makeRow(label, name, crit, high, med, low, unknown string) table.Row {
46 | return table.NewRow(table.RowData{
47 | columnKeyLabel: label,
48 | columnKeyImgName: table.NewStyledCell(name, colorImgName),
49 | columnKeyCrit: table.NewStyledCell(crit, colorCrit),
50 | columnKeyhigh: table.NewStyledCell(high, colorHigh),
51 | columnKeyMed: table.NewStyledCell(med, colorMed),
52 | columnKeyLow: table.NewStyledCell(low, colorLow),
53 | columnKeyUnknown: table.NewStyledCell(unknown, colorUnkown),
54 | })
55 | }
56 |
57 | func NewTable(scoutData dockercmd.ScoutData) *TableModel {
58 | rows := make([]table.Row, 0, len(scoutData.ImageVulEntries))
59 |
60 | for _, scoutEntry := range scoutData.ImageVulEntries {
61 | row := makeRow(
62 | scoutEntry.Label,
63 | scoutEntry.ImageName,
64 | scoutEntry.Critical,
65 | scoutEntry.High,
66 | scoutEntry.Medium,
67 | scoutEntry.Low,
68 | scoutEntry.UnknownSeverity,
69 | )
70 | rows = append(rows, row)
71 | }
72 | return &TableModel{
73 | inner: table.New([]table.Column{
74 | table.NewColumn(columnKeyLabel, "", columnLabelWidth),
75 | table.NewColumn(columnKeyImgName, "Image Name", columnImgNameWidth),
76 | table.NewColumn(columnKeyCrit, "Critical", columnNumTypeWidth),
77 | table.NewColumn(columnKeyhigh, "High", columnNumTypeWidth),
78 | table.NewColumn(columnKeyMed, "Medium", columnNumTypeWidth),
79 | table.NewColumn(columnKeyLow, "Low", columnNumTypeWidth),
80 | table.NewColumn(columnKeyUnknown, "Unknown", columnNumTypeWidth),
81 | }).WithRows(rows).
82 | BorderRounded().
83 | WithBaseStyle(styleBase),
84 | }
85 | }
86 |
87 | func (m TableModel) Init() tea.Cmd {
88 | return nil
89 | }
90 |
91 | func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
92 | var (
93 | cmd tea.Cmd
94 | cmds []tea.Cmd
95 | )
96 |
97 | m.inner, cmd = m.inner.Update(msg)
98 | cmds = append(cmds, cmd)
99 |
100 | return m, tea.Batch(cmds...)
101 | }
102 |
103 | func (m TableModel) View() string {
104 | view := m.inner.View()
105 |
106 | return lipgloss.NewStyle().MarginLeft(1).Render(view)
107 | }
108 |
109 | // has no utility, useful to debug and style dockerscout table quickly
110 | func getDockerScoutDummyTable() *TableModel {
111 | return &TableModel{
112 | table.New([]table.Column{
113 | table.NewColumn(columnKeyLabel, "", 25),
114 | table.NewColumn(columnKeyImgName, "Image Name", 25),
115 | table.NewColumn(columnKeyCrit, "Critical", 10),
116 | table.NewColumn(columnKeyhigh, "High", 10),
117 | table.NewColumn(columnKeyMed, "Medium", 10),
118 | table.NewColumn(columnKeyLow, "Low", 10),
119 | table.NewColumn(columnKeyUnknown, "Unknown", 10),
120 | }).WithRows([]table.Row{
121 | makeRow("target", "nginx ", "2300648", "21", "84", "1", "0"),
122 | makeRow("target", "nginx ", "2300648", "21", "84", "1", "0"),
123 | makeRow("target", "nginx ", "2300648", "21", "84", "1", "0"),
124 | makeRow("target", "nginx ", "2300648", "21", "84", "1", "0"),
125 | makeRow("target", "nginx ", "2300648", "21", "84", "1", "0"),
126 | makeRow("target", "nginx ", "2300648", "21", "84", "1", "0"),
127 | makeRow("target", "nginx ", "2300648", "21", "84", "1", "0"),
128 | }).
129 | BorderRounded().
130 | WithBaseStyle(styleBase),
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/tui/types.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "cmp"
5 | "slices"
6 | "sort"
7 | "strconv"
8 | "strings"
9 |
10 | it "github.com/ajayd-san/gomanagedocker/service/types"
11 | "github.com/ajayd-san/gomanagedocker/tui/components/list"
12 | "github.com/charmbracelet/lipgloss"
13 | "github.com/containers/podman/v5/pkg/domain/entities/types"
14 | )
15 |
16 | type status int
17 |
18 | const (
19 | containerStateRunning status = iota
20 | containerStatePaused
21 | containerStateRestarting
22 | containerStateExited
23 | containerStateCreated
24 | containerStateRemoving
25 | containerStateDead
26 | )
27 |
28 | var statusMap = map[string]status{
29 | "running": containerStateRunning,
30 | "paused": containerStatePaused,
31 | "restarting": containerStateRestarting,
32 | "exited": containerStateExited,
33 | "created": containerStateCreated,
34 | "removing": containerStateRemoving,
35 | "dead": containerStateDead,
36 | }
37 |
38 | type dockerRes interface {
39 | list.Item
40 | list.DefaultItem
41 | getSize() float64
42 | getLabel() string
43 | getName() string
44 | }
45 |
46 | type imageItem struct {
47 | it.ImageSummary
48 | }
49 |
50 | func makeImageItems(dockerlist []it.ImageSummary) []dockerRes {
51 | res := make([]dockerRes, 0)
52 |
53 | for i := range dockerlist {
54 | if len(dockerlist[i].RepoTags) == 0 {
55 | continue
56 | }
57 |
58 | res = append(res, imageItem{dockerlist[i]})
59 | }
60 |
61 | return res
62 | }
63 |
64 | // INFO: impl dockerRes Interface
65 | func (i imageItem) GetId() string {
66 | return i.ID
67 | }
68 |
69 | func (i imageItem) getSize() float64 {
70 | return float64(i.Size) / float64(1e+9)
71 | }
72 |
73 | // TODO: either use this or omit this
74 | func (i imageItem) getLabel() string {
75 | return "image labels here"
76 | }
77 |
78 | func (i imageItem) getName() string {
79 | return transformListNames(i.RepoTags)
80 | }
81 |
82 | // INFO: impl list.Item Interface
83 | func (i imageItem) Title() string { return i.getName() }
84 |
85 | func (i imageItem) Description() string {
86 | id := i.GetId()
87 | id = strings.TrimPrefix(id, "sha256:")
88 | shortId := id[:15]
89 |
90 | sizeStr := strconv.FormatFloat(i.getSize(), 'f', 2, 64) + "GB"
91 |
92 | return makeDescriptionString(shortId, sizeStr, len(shortId))
93 | }
94 |
95 | func (i imageItem) FilterValue() string { return i.getName() }
96 |
97 | type containerItem struct {
98 | it.ContainerSummary
99 | ImageName string
100 | }
101 |
102 | func makeContainerItems(
103 | dockerlist []it.ContainerSummary,
104 | imageIdToNameMap map[string]string,
105 | ) []dockerRes {
106 | res := make([]dockerRes, len(dockerlist))
107 |
108 | slices.SortFunc(dockerlist, func(a it.ContainerSummary, b it.ContainerSummary) int {
109 |
110 | if statusMap[a.State] < statusMap[b.State] {
111 | return -1
112 | } else if statusMap[a.State] > statusMap[b.State] {
113 | return 1
114 | }
115 |
116 | // we can compare by only first name, since names cannot be equal
117 | return cmp.Compare(a.Names[0], b.Names[0])
118 | })
119 |
120 | for i := range dockerlist {
121 | newItem := containerItem{
122 | ContainerSummary: dockerlist[i],
123 | }
124 | newItem.ImageName = imageIdToNameMap[newItem.ImageID]
125 | res[i] = newItem
126 | }
127 |
128 | // log.Println("------------------------")
129 | // for _, items := range res {
130 | // log.Println(items.(containerItem).Size)
131 | // }
132 | // log.Println("------------------------")
133 |
134 | return res
135 | }
136 |
137 | // INFO: impl dockerRes Interface
138 | func (c containerItem) GetId() string {
139 | return c.ID
140 | }
141 |
142 | func (c containerItem) getSize() float64 {
143 | panic("unimplemented")
144 | }
145 |
146 | func (c containerItem) getLabel() string {
147 | return c.getName()
148 | }
149 |
150 | func (c containerItem) getName() string {
151 | return transformListNames(c.Names)
152 | }
153 |
154 | func (c containerItem) getState() string {
155 | return c.State
156 | }
157 |
158 | // INFO: impl list.Item Interface
159 | func (i containerItem) Title() string { return i.getName() }
160 |
161 | func (i containerItem) Description() string {
162 |
163 | id := i.GetId()
164 | id = strings.TrimPrefix(id, "sha256:")
165 | shortId := id[:15]
166 |
167 | state := i.State
168 | switch i.State {
169 | case "running":
170 | state = containerRunningStyle.Render(state)
171 | case "exited":
172 | state = containerExitedStyle.Render(state)
173 | case "created":
174 | state = containerCreatedStyle.Render(state)
175 | case "restarting":
176 | state = containerRestartingStyle.Render(state)
177 | case "dead":
178 | state = containerDeadStyle.Render(state)
179 | }
180 |
181 | return makeDescriptionString(shortId, state, len(shortId))
182 | }
183 |
184 | func (i containerItem) FilterValue() string { return i.getLabel() }
185 |
186 | type VolumeItem struct {
187 | it.VolumeSummary
188 | }
189 |
190 | func (v VolumeItem) FilterValue() string {
191 | return v.GetId()
192 | }
193 |
194 | func (v VolumeItem) GetId() string {
195 | return v.Name
196 | }
197 |
198 | func (v VolumeItem) getLabel() string {
199 | panic("unimplemented")
200 | }
201 |
202 | func (v VolumeItem) getName() string {
203 |
204 | return v.Name[:min(30, len(v.Name))]
205 | }
206 |
207 | func (v VolumeItem) getSize() float64 {
208 | return float64(v.UsageData)
209 | }
210 |
211 | func (i VolumeItem) Title() string { return i.getName() }
212 |
213 | func (i VolumeItem) Description() string { return "" }
214 |
215 | func makeVolumeItem(dockerlist []it.VolumeSummary) []dockerRes {
216 | res := make([]dockerRes, len(dockerlist))
217 |
218 | for i, volume := range dockerlist {
219 | res[i] = VolumeItem{VolumeSummary: volume}
220 | }
221 |
222 | sort.Slice(res, func(i, j int) bool {
223 | return res[i].getName() < res[j].getName()
224 | })
225 |
226 | return res
227 | }
228 |
229 | type PodItem struct {
230 | types.ListPodsReport
231 | }
232 |
233 | func (po PodItem) getSize() float64 {
234 | return 0
235 | }
236 |
237 | func (po PodItem) getLabel() string {
238 | return po.Name
239 | }
240 |
241 | func (po PodItem) getName() string {
242 | return po.Name
243 | }
244 |
245 | func (po PodItem) GetId() string {
246 | return po.Id
247 | }
248 |
249 | func (po PodItem) Title() string {
250 | return po.Name
251 | }
252 |
253 | func (po PodItem) getState() string {
254 | return strings.ToLower(po.Status)
255 | }
256 |
257 | // returns count of running container
258 | func (po PodItem) getRunningContainers() int {
259 | var counter int
260 | for _, cont := range po.Containers {
261 | if cont.Status == "running" {
262 | counter += 1
263 | }
264 | }
265 |
266 | return counter
267 | }
268 |
269 | func (po PodItem) Description() string {
270 | state := po.Status
271 | switch state {
272 | case "running":
273 | state = containerRunningStyle.Render(state)
274 | case "exited":
275 | state = containerExitedStyle.Render(state)
276 | case "created":
277 | state = containerCreatedStyle.Render(state)
278 | case "restarting":
279 | state = containerRestartingStyle.Render(state)
280 | case "dead":
281 | state = containerDeadStyle.Render(state)
282 | }
283 | return makeDescriptionString(po.Id[:15], state, len(po.Id[:15]))
284 | }
285 |
286 | // FilterValue is the value we use when filtering against this item when
287 | // we're filtering the list.
288 | func (po PodItem) FilterValue() string {
289 | return po.Name
290 | }
291 |
292 | func makePodItem(dockerlist []*types.ListPodsReport) []dockerRes {
293 | res := make([]dockerRes, len(dockerlist))
294 |
295 | for i, item := range dockerlist {
296 | // cuz we use lower case version of status
297 | item.Status = strings.ToLower(item.Status)
298 | res[i] = PodItem{
299 | ListPodsReport: *item,
300 | }
301 | }
302 | return res
303 | }
304 |
305 | // util
306 |
307 | /*
308 | This function makes the final description string with white space between the two strings
309 | using string manipulation, offset is typically the length of the first string.
310 | The final length of the returned string would be listContainer.Width - offset - 3
311 | */
312 | func makeDescriptionString(str1, str2 string, offset int) string {
313 | str2 = lipgloss.PlaceHorizontal(listContainer.GetWidth()-offset-3, lipgloss.Right, str2)
314 | return lipgloss.JoinHorizontal(lipgloss.Left, str1, str2)
315 | }
316 |
317 | // This function takes in names associated with objects (e.g: RepoTags in case of Image)
318 | // and concatenates into a string depending on the width of the list
319 | func transformListNames(names []string) string {
320 | if len(names) == 0 {
321 | return ""
322 | }
323 |
324 | runningLength := 0
325 | var maxindex int
326 | for index, name := range names {
327 | runningLength += len(name)
328 | if runningLength > listContainer.GetWidth()-7 {
329 | break
330 | }
331 | if index != len(names)-1 {
332 | runningLength += 2 // +2 cuz we also append ", " after each element
333 | }
334 | maxindex = index
335 | }
336 |
337 | res := strings.Join(names[:maxindex+1], ", ")
338 |
339 | if len(res) > listContainer.GetWidth()-7 {
340 | return res[:listContainer.GetWidth()-7] + "..."
341 | }
342 |
343 | return res
344 | }
345 |
--------------------------------------------------------------------------------
/tui/types_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/ajayd-san/gomanagedocker/service/types"
8 | podmanTypes "github.com/containers/podman/v5/pkg/domain/entities/types"
9 | "gotest.tools/v3/assert"
10 | )
11 |
12 | func TestMakeDescriptionString(t *testing.T) {
13 | str1, str2 := "mad", "scientist"
14 |
15 | listContainerWidth := listContainer.GetWidth()
16 | got := makeDescriptionString(str1, str2, len(str1))
17 | want := str1 + strings.Repeat(" ", listContainerWidth-len(str1)-len(str2)-3) + str2
18 |
19 | assert.Equal(t, got, want)
20 | }
21 |
22 | func TestMakeImageItems(t *testing.T) {
23 | dockerList := []types.ImageSummary{
24 | {
25 | ID: "#1",
26 | RepoTags: []string{"latest", "tag1", "tag2"},
27 | }, {
28 | ID: "#2",
29 | RepoTags: []string{},
30 | },
31 | }
32 |
33 | t.Run("Should only return non dangling items", func(t *testing.T) {
34 | // for dangling items the repo tags would be an empty slice
35 | got := makeImageItems(dockerList)
36 | assert.Equal(t, len(got), 1)
37 |
38 | for _, item := range got {
39 | imgItem, ok := item.(imageItem)
40 | assert.Equal(t, ok, true)
41 | assert.Assert(t, len(imgItem.RepoTags) != 0)
42 | }
43 | })
44 |
45 | }
46 |
47 | func TestTransformListNames(t *testing.T) {
48 |
49 | names := []string{"name1", "name2", "name3", "name4"}
50 |
51 | t.Run("With listContainer.Width = 13", func(t *testing.T) {
52 | listContainer = listContainer.Width(13)
53 | got := transformListNames(names)
54 | want := "name1"
55 | assert.Equal(t, got, want)
56 | })
57 |
58 | t.Run("With listContainer.Width = 12", func(t *testing.T) {
59 | listContainer = listContainer.Width(12)
60 | got := transformListNames(names)
61 | want := "name1"
62 | assert.Equal(t, got, want)
63 | })
64 |
65 | t.Run("With listContainer.Width = 20", func(t *testing.T) {
66 | listContainer = listContainer.Width(20)
67 | got := transformListNames(names)
68 | want := "name1, name2"
69 | assert.Equal(t, got, want)
70 | })
71 |
72 | t.Run("With listContainer.Width=56", func(t *testing.T) {
73 | listContainer = listContainer.Width(56)
74 | names := []string{"a.smol.list:latest", "alpine:latest", "b.star.man:latest"}
75 | got := transformListNames(names)
76 | want := "a.smol.list:latest, alpine:latest"
77 | assert.Equal(t, got, want)
78 | })
79 |
80 | t.Run("with listContainer.Width=20, Edge case", func(t *testing.T) {
81 | listContainer = listContainer.Width(20)
82 | names := []string{"Zenitsu", "best"}
83 | got := transformListNames(names)
84 | want := "Zenitsu, best"
85 | assert.Equal(t, got, want)
86 | })
87 |
88 | t.Run("With empty list", func(t *testing.T) {
89 | defer func() {
90 | if recover() != nil {
91 | t.Error("This function should not panic")
92 | }
93 | }()
94 | listContainer = listContainer.Width(20)
95 | names := make([]string, 0)
96 | // should not panic
97 | transformListNames(names)
98 | })
99 | }
100 |
101 | func TestGetRunningContainers(t *testing.T) {
102 | item := PodItem{
103 | ListPodsReport: podmanTypes.ListPodsReport{
104 | Containers: []*podmanTypes.ListPodContainer{
105 | {
106 | Id: "a",
107 | Status: "running",
108 | },
109 | {
110 | Id: "b",
111 | Status: "running",
112 | },
113 | {
114 | Id: "c",
115 | Status: "running",
116 | },
117 | {
118 | Id: "d",
119 | Status: "exited",
120 | },
121 | },
122 | Id: "1234",
123 | Name: "mario",
124 | Status: "running",
125 | },
126 | }
127 |
128 | got := item.getRunningContainers()
129 | want := 3
130 |
131 | assert.Equal(t, got, want)
132 | }
133 |
--------------------------------------------------------------------------------
/tui/util.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 |
8 | "github.com/ajayd-san/gomanagedocker/service/types"
9 | "github.com/ajayd-san/gomanagedocker/tui/components/list"
10 | tea "github.com/charmbracelet/bubbletea"
11 | )
12 |
13 | type notificationMetadata struct {
14 | listId tabId
15 | msg string
16 | }
17 |
18 | func NotifyList(list *list.Model, msg string) tea.Cmd {
19 | return list.NewStatusMessage(msg)
20 | }
21 |
22 | func NewNotification(id tabId, msg string) notificationMetadata {
23 | return notificationMetadata{
24 | id, msg,
25 | }
26 | }
27 |
28 | func GetPortMappingFromStr(portStr string) ([]types.PortBinding, error) {
29 | portBindings := make([]types.PortBinding, 0, len(portStr))
30 | portStr = strings.Trim(portStr, " ")
31 | portMappingStrs := strings.Split(portStr, ",")
32 |
33 | for _, mappingStr := range portMappingStrs {
34 | mappingStr = strings.Trim(mappingStr, " ")
35 | if mappingStr == "" {
36 | continue
37 | }
38 | substr := strings.Split(mappingStr, ":")
39 | if len(substr) != 2 {
40 | return nil, errors.New(fmt.Sprintf("Port Mapping %s is invalid", mappingStr))
41 | }
42 |
43 | if containerPort, found := strings.CutSuffix(substr[1], "/udp"); found {
44 | portBindings = append(portBindings, types.PortBinding{HostPort: substr[0], ContainerPort: containerPort, Proto: "udp"})
45 | } else {
46 | containerPort, _ = strings.CutSuffix(containerPort, "/tcp")
47 | portBindings = append(portBindings, types.PortBinding{HostPort: substr[0], ContainerPort: containerPort, Proto: "tcp"})
48 | }
49 | }
50 |
51 | return portBindings, nil
52 | }
53 |
--------------------------------------------------------------------------------
/tui/util_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/ajayd-san/gomanagedocker/service/dockercmd"
8 | it "github.com/ajayd-san/gomanagedocker/service/types"
9 | "github.com/docker/docker/api/types"
10 | "gotest.tools/v3/assert"
11 | )
12 |
13 | func TestNotifyList(t *testing.T) {
14 | api := dockercmd.MockApi{}
15 |
16 | containers := []types.Container{
17 | {
18 | Names: []string{"a"},
19 | ID: "1",
20 | SizeRw: 1e+9,
21 | SizeRootFs: 2e+9,
22 | State: "running",
23 | Status: "",
24 | },
25 | }
26 |
27 | api.SetMockContainers(containers)
28 |
29 | mockcli := dockercmd.NewMockCli(&api)
30 |
31 | keymap := NewKeyMap(it.Docker)
32 |
33 | CONTAINERS = 0
34 | model := MainModel{
35 | dockerClient: mockcli,
36 | activeTab: 0,
37 | TabContent: []listModel{
38 | InitList(0, keymap.container, keymap.containerBulk),
39 | },
40 | }
41 |
42 | t.Run("Notify test", func(t *testing.T) {
43 | NotifyList(model.getActiveList(), "Kiryu")
44 | got := model.View()
45 | contains := "Kiryu"
46 | assert.Check(t, strings.Contains(got, contains))
47 | })
48 | }
49 |
50 | func TestSepPortMapping(t *testing.T) {
51 | t.Run("Clean string, test mapping", func(t *testing.T) {
52 | // format is host:container
53 | testStr := "8080:80/tcp,1123:112,6969:9696/udp"
54 | want := []it.PortBinding{
55 | {
56 | HostPort: "8080",
57 | ContainerPort: "80",
58 | Proto: "tcp",
59 | },
60 | {
61 | HostPort: "1123",
62 | ContainerPort: "112",
63 | Proto: "tcp",
64 | },
65 | {
66 | HostPort: "6969",
67 | ContainerPort: "9696",
68 | Proto: "udp",
69 | },
70 | }
71 |
72 | got, err := GetPortMappingFromStr(testStr)
73 |
74 | assert.NilError(t, err)
75 |
76 | assert.DeepEqual(t, got, want)
77 | })
78 |
79 | t.Run("Empty port string", func(t *testing.T) {
80 | testStr := ""
81 | _, err := GetPortMappingFromStr(testStr)
82 | assert.NilError(t, err)
83 | })
84 |
85 | t.Run("Invalid mapping, should throw error", func(t *testing.T) {
86 | testStr := "8080:878:9/tcp"
87 | _, err := GetPortMappingFromStr(testStr)
88 | assert.Error(t, err, "Port Mapping 8080:878:9/tcp is invalid")
89 | })
90 | }
91 |
--------------------------------------------------------------------------------
/tui/windowSizeTooSmallDialog.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | )
8 |
9 | type WindowTooSmallModel struct {
10 | height int
11 | width int
12 | }
13 |
14 | func (m WindowTooSmallModel) Init() tea.Cmd {
15 | return nil
16 | }
17 |
18 | func (m WindowTooSmallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
19 | switch msg := msg.(type) {
20 | case tea.WindowSizeMsg:
21 | m.height = msg.Height
22 | m.width = msg.Width
23 | }
24 | return m, nil
25 | }
26 |
27 | func (m WindowTooSmallModel) View() string {
28 | return windowTooSmallStyle.Render(fmt.Sprintf(
29 | "Window size too small (%d x %d)\n\n"+
30 | "Minimum dimensions needed - Width: 65, Height: 25\n\n"+
31 | "Consider going fullscreen for optimal experience.",
32 | m.width, m.height,
33 | ))
34 | }
35 |
36 | func MakeNewWindowTooSmallModel() WindowTooSmallModel {
37 | return WindowTooSmallModel{}
38 | }
39 |
--------------------------------------------------------------------------------
/tui/windowSizeTooSmallDialog_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | )
8 |
9 | func TestWindowTooSmallModel_Update(t *testing.T) {
10 | model := WindowTooSmallModel{}
11 |
12 | msg := tea.WindowSizeMsg{Width: 100, Height: 24}
13 | updatedModel, _ := model.Update(msg)
14 |
15 | if updatedModel.(WindowTooSmallModel).width != 100 {
16 | t.Errorf("expected width to be 100, got %d", updatedModel.(WindowTooSmallModel).width)
17 | }
18 | if updatedModel.(WindowTooSmallModel).height != 24 {
19 | t.Errorf("expected height to be 24, got %d", updatedModel.(WindowTooSmallModel).height)
20 | }
21 | }
22 |
23 | func TestWindowTooSmallModel_View(t *testing.T) {
24 | model := WindowTooSmallModel{width: 100, height: 24}
25 | expectedOutput := windowTooSmallStyle.Render(
26 | "Window size too small (100 x 24)\n\n" +
27 | "Minimum dimensions needed - Width: 65, Height: 25\n\n" +
28 | "Consider going fullscreen for optimal experience.",
29 | )
30 |
31 | if model.View() != expectedOutput {
32 | t.Errorf("expected %q, got %q", expectedOutput, model.View())
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/vhs/build.tape:
--------------------------------------------------------------------------------
1 | Output vhs/gifs/build.gif
2 |
3 | Require gmd
4 |
5 | Set Shell "zsh"
6 | Set FontSize 16
7 | Set Width 1920
8 | Set Height 1080
9 | Set TypingSpeed 0.1
10 | Set Theme Batman
11 |
12 |
13 | Sleep 1s
14 | Type "gmd"
15 | Enter
16 | Sleep 2s
17 | Type "b"
18 | Sleep 1s
19 | Type@150ms "space-program"
20 | Enter
21 | Sleep 5s
22 | Enter
23 | Sleep 2s
24 | Type "q"
25 |
26 |
--------------------------------------------------------------------------------
/vhs/bulkDelete.tape:
--------------------------------------------------------------------------------
1 | Output vhs/gifs/bulkDelete.gif
2 |
3 | Require gmd
4 |
5 | Set Shell "zsh"
6 | Set FontSize 16
7 | Set Width 1920
8 | Set Height 1080
9 | Set TypingSpeed 0.1
10 | Set Theme Batman
11 |
12 | Sleep 1.5s
13 | Type "gmd"
14 | Enter
15 | Sleep 1s
16 | Type "l"
17 | Sleep 1s
18 | Type " j"
19 | Sleep 800ms
20 | Type " j"
21 | Sleep 800ms
22 | Type " j"
23 | Sleep 2s
24 | Type "D"
25 | Sleep 4s
26 | Type "q"
27 | Sleep 1.5s
28 |
29 |
--------------------------------------------------------------------------------
/vhs/copyId.tape:
--------------------------------------------------------------------------------
1 | Output vhs/gifs/copyId.gif
2 |
3 | Require gmd
4 |
5 | Set Shell "zsh"
6 | Set FontSize 16
7 | Set Width 1920
8 | Set Height 1080
9 | Set TypingSpeed 0.1
10 | Set Theme Batman
11 |
12 |
13 | Sleep 1s
14 | Type "gmd"
15 | Sleep 1s
16 | Enter
17 | Sleep 1s
18 | Type "j"
19 | Sleep 1s
20 | Type "c"
21 | Sleep 2.5s
22 | Type "q"
23 | Sleep 1.5s
24 | Hide
25 | Type@0 "a606584aa9aa87555209"
26 | Show
27 | Sleep 3s
28 |
29 |
--------------------------------------------------------------------------------
/vhs/delete.tape:
--------------------------------------------------------------------------------
1 | Output vhs/gifs/delete.gif
2 |
3 | Require gmd
4 |
5 | Set Shell "zsh"
6 | Set FontSize 16
7 | Set Width 1920
8 | Set Height 1080
9 | Set TypingSpeed 0.1
10 | Set Theme Batman
11 |
12 | Sleep 1.5s
13 | Type "gmd"
14 | Enter
15 | Sleep 1s
16 | Type "l"
17 | Sleep 1s
18 | Type "j"
19 | Sleep 500ms
20 | Type "d"
21 | Sleep 500ms
22 | Type "jj"
23 | Sleep 500ms
24 | Type " "
25 | Sleep 1s
26 | Enter
27 | Sleep 2s
28 | Type "q"
29 | Sleep 1.5s
30 |
31 |
--------------------------------------------------------------------------------
/vhs/exec.tape:
--------------------------------------------------------------------------------
1 | Output vhs/gifs/exec.gif
2 |
3 | Require gmd
4 |
5 | Set FontSize 16
6 | Set Width 1920
7 | Set Height 1080
8 | Set TypingSpeed 0.1
9 | Set Theme Batman
10 | Set Shell zsh
11 |
12 | Type "gmd"
13 | Enter
14 | Sleep 500ms
15 | Type "l"
16 | Sleep 2s
17 | Type "x"
18 | Sleep 2s
19 | Type "whoami"
20 | Enter
21 | Sleep 2.5s
22 | Type "ls"
23 | Enter
24 | Sleep 2.5s
25 | Type "cd home"
26 | Enter
27 | Type "pwd"
28 | Enter
29 | Sleep 3s
30 | Type "exit"
31 | Enter
32 | Sleep 1s
33 | Type "q"
34 | Sleep 1s
35 |
36 |
--------------------------------------------------------------------------------
/vhs/execFromImgs.tape:
--------------------------------------------------------------------------------
1 | Output vhs/gifs/execFromImgs.gif
2 |
3 | Require gmd
4 |
5 | Set FontSize 16
6 | Set Width 1920
7 | Set Height 1080
8 | Set TypingSpeed 0.1
9 | Set Theme Batman
10 | Set Shell zsh
11 |
12 | Type "gmd"
13 | Enter
14 | Sleep 2
15 | Type "j"
16 | Sleep 1s
17 | Type "j"
18 | Sleep 1s
19 | Type "x"
20 | Sleep 2s
21 | Type "whoami"
22 | Enter
23 | Sleep 4s
24 | Type "pwd"
25 | Enter
26 | Sleep 3s
27 | Type "cd .."
28 | Enter
29 | Sleep 3s
30 | Type "ls"
31 | Enter
32 | Sleep 3s
33 | Type "exit"
34 | Enter
35 | Sleep 1s
36 | Type "q"
37 | Sleep 1s
38 |
39 |
--------------------------------------------------------------------------------
/vhs/gifs/build.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/build.gif
--------------------------------------------------------------------------------
/vhs/gifs/bulkDelete.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/bulkDelete.gif
--------------------------------------------------------------------------------
/vhs/gifs/copyId.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/copyId.gif
--------------------------------------------------------------------------------
/vhs/gifs/delete.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/delete.gif
--------------------------------------------------------------------------------
/vhs/gifs/exec.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/exec.gif
--------------------------------------------------------------------------------
/vhs/gifs/execFromImgs.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/execFromImgs.gif
--------------------------------------------------------------------------------
/vhs/gifs/intro.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/intro.gif
--------------------------------------------------------------------------------
/vhs/gifs/logs.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/logs.gif
--------------------------------------------------------------------------------
/vhs/gifs/notifications.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/notifications.gif
--------------------------------------------------------------------------------
/vhs/gifs/podmanRun.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/podmanRun.gif
--------------------------------------------------------------------------------
/vhs/gifs/prune.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/prune.gif
--------------------------------------------------------------------------------
/vhs/gifs/runImage.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/runImage.gif
--------------------------------------------------------------------------------
/vhs/gifs/scout.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/scout.gif
--------------------------------------------------------------------------------
/vhs/gifs/search.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/search.gif
--------------------------------------------------------------------------------
/vhs/gifs/startstop.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajayd-san/gomanagedocker/16e5c1252cf110a7dcf797c99cde57106a20cdda/vhs/gifs/startstop.gif
--------------------------------------------------------------------------------
/vhs/idk.tape:
--------------------------------------------------------------------------------
1 | # VHS documentation
2 | #
3 | # Output:
4 | # Output .gif Create a GIF output at the given
5 | # Output .mp4 Create an MP4 output at the given
6 | # Output .webm Create a WebM output at the given
7 | #
8 | # Require:
9 | # Require Ensure a program is on the $PATH to proceed
10 | #
11 | # Settings:
12 | # Set FontSize Set the font size of the terminal
13 | # Set FontFamily Set the font family of the terminal
14 | # Set Height Set the height of the terminal
15 | # Set Width Set the width of the terminal
16 | # Set LetterSpacing Set the font letter spacing (tracking)
17 | # Set LineHeight Set the font line height
18 | # Set LoopOffset % Set the starting frame offset for the GIF loop
19 | # Set Theme Set the theme of the terminal
20 | # Set Padding Set the padding of the terminal
21 | # Set Framerate Set the framerate of the recording
22 | # Set PlaybackSpeed Set the playback speed of the recording
23 | # Set MarginFill Set the file or color the margin will be filled with.
24 | # Set Margin Set the size of the margin. Has no effect if MarginFill isn't set.
25 | # Set BorderRadius Set terminal border radius, in pixels.
26 | # Set WindowBar Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight)
27 | # Set WindowBarSize Set window bar size, in pixels. Default is 40.
28 | # Set TypingSpeed