├── .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 | ![podmanRun](vhs/gifs/podmanRun.gif) 114 | 115 | 116 | 117 | 118 | ### **Previous release features:** 119 | 120 | 121 | 1. Easy navigation with vim keybinds and arrow keys. 122 | ![intro](vhs/gifs/intro.gif) 123 | 124 | 2. Exec into selected container with A SINGLE KEYSTROKE: `x`...How cool is that? 125 | ![exec](vhs/gifs/exec.gif) 126 | 127 | 3. Delete objects using `d` (You can force delete with `D`, you won't have to answer a prompt this way) 128 | ![delete](vhs/gifs/delete.gif) 129 | 130 | 4. Prune objects using `p` 131 | ![prune](vhs/gifs/prune.gif) 132 | 133 | 5. start/stop/pause/restart containers with `s`, `t` and `r` 134 | ![startstop](vhs/gifs/startstop.gif) 135 | 136 | 6. Filter objects with `/` 137 | ![search](vhs/gifs/search.gif) 138 | 139 | 7. Perfrom docker scout with `s` 140 | ![scout](vhs/gifs/scout.gif) 141 | 142 | 8. Run an image directly from the image tab by pressing `r`. 143 | ![runImage](vhs/gifs/runImage.gif) 144 | 145 | 9. You can directly copy the ID to your clipboard of an object by pressing `c`. 146 | ![copyId](vhs/gifs/copyId.gif) 147 | 148 | 10. You can now run and exec into an image directly from the images tab with `x` 149 | ![runAndExec](vhs/gifs/execFromImgs.gif) 150 | 151 | 11. Global notification system 152 | ![notificationSystem](vhs/gifs/notifications.gif) 153 | 154 | 12. Bulk operation mode: select multiple objects before performing an operations (saves so much time!!) 155 | ![bulkDelete](vhs/gifs/bulkDelete.gif) 156 | 157 | 13. Build image from Dockerfile using `b` 158 | ![build](vhs/gifs/build.gif) 159 | 160 | 14. View live logs from a container using `L` 161 | ![runImage](vhs/gifs/logs.gif) 162 | 163 | 15. Run image now takes arguments for port, name and env vars. 164 | ![runImage](vhs/gifs/runImage.gif) 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 | ![image](https://github.com/ajayd-san/gomanagedocker/assets/54715852/61be1ce3-c176-4392-820d-d0e94650ef01) 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